omp-conductor 0.20.0 → 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.
package/src/to-spec.ts CHANGED
@@ -12,8 +12,9 @@
12
12
  * The five contract verdicts map onto it — PROMOTABLE → `promotable`;
13
13
  * ALREADY DONE and NEEDS DECOMPOSITION → `considered`; BLOCKED and NEEDS
14
14
  * PRODUCT DECISION → `blocked` — and a result that cannot be trusted
15
- * (malformed JSON, a schema violation, no authoritative source, or a stale
16
- * source) persists as `blocked` and never as `promotable`/`considered`.
15
+ * (no answer at all, malformed JSON, a schema violation, no authoritative
16
+ * source, or a stale source) persists as `blocked` and never as
17
+ * `promotable`/`considered`.
17
18
  * Reprocessing the same candidate is deterministic: the same input at the
18
19
  * same observation time produces the same row, and malformed output never
19
20
  * erases a prior valid (promotable/considered) result. Stale or source-less
@@ -326,9 +327,16 @@ export type ToSpecResult = Omit<z.infer<typeof ToSpecResultSchema>, "source"> &
326
327
  source: { name: string; ref: string; freshAt: number };
327
328
  };
328
329
 
329
- /** Why a result could not be trusted; each persists as a blocked record. */
330
+ /** Why a result could not be trusted; each persists as a blocked record.
331
+ *
332
+ * `no-answer` is the session-level refusal (#1064): the pass produced no
333
+ * payload at all — the scout was killed at its turn ceiling, or the session
334
+ * ended without ever yielding. It is a property of the session, never of
335
+ * output, so it is decided by the pass before anything is parsed and is never
336
+ * `malformed`. */
330
337
  export type ToSpecFailure =
331
338
  | { kind: "malformed"; detail: string }
339
+ | { kind: "no-answer"; detail: string }
332
340
  | { kind: "missing-source"; detail: string }
333
341
  | { kind: "stale-source"; detail: string };
334
342
 
@@ -338,6 +346,20 @@ export type ParseToSpecOutcome = { ok: true; result: ToSpecResult } | { ok: fals
338
346
  export interface ToSpecEvidence {
339
347
  kind: "to-spec";
340
348
  result: ToSpecResult;
349
+ /**
350
+ * How the answer arrived (#1064): through the `yield` tool, or recovered
351
+ * from the session's text because it never yielded. `"text"` is the record's
352
+ * admission that the verdict did not come through the yield contract — the
353
+ * answer was complete and valid, so it stands, but a later reader can tell
354
+ * the two apart. Absent (or `"yield"`) means it came through the contract.
355
+ */
356
+ via?: "yield" | "text";
357
+ /**
358
+ * Whether the one bounded repair round produced the answer (#1064): the
359
+ * first answer parsed but violated the contract, the validation error was
360
+ * handed back, and the re-answer is what this record carries.
361
+ */
362
+ repaired?: boolean;
341
363
  /**
342
364
  * What the ready gate found missing when a valid PROMOTABLE verdict was
343
365
  * refused mechanical promotion (#1041). A sibling of `result`, never a
@@ -356,12 +378,21 @@ export interface ToSpecFailureEvidence {
356
378
  input: string;
357
379
  }
358
380
 
359
- /** How much raw agent output a failure record may carry. */
360
- export const TO_SPEC_INPUT_SAMPLE_MAX = 2_000;
381
+ /**
382
+ * How much raw agent output a failure record may carry (#1064).
383
+ *
384
+ * Sized for the whole refused payload, not a sample of it: a complete verdict
385
+ * with a proposed brief and sizing analysis routinely exceeds 2 KB, and a
386
+ * discarded verdict should be recoverable from the row rather than
387
+ * re-derived at full session cost. 8 KB holds a full payload with headroom
388
+ * while still keeping a single refused row bounded.
389
+ */
390
+ export const TO_SPEC_INPUT_SAMPLE_MAX = 8_000;
361
391
 
362
392
  /** Refused results always land on the same store surface, named by failure. */
363
393
  const FAILURE_REASON: Record<ToSpecFailure["kind"], string> = {
364
394
  malformed: "malformed",
395
+ "no-answer": "no-answer",
365
396
  "missing-source": "missing-source",
366
397
  "stale-source": "stale-source",
367
398
  };
@@ -411,6 +442,59 @@ function extractJson(input: string): string {
411
442
  return (fenced?.[1] ?? input).trim();
412
443
  }
413
444
 
445
+ /**
446
+ * The three-way gate the scout runner decides a payload against (#1064):
447
+ * whether a string is a payload at all, and whether that payload already
448
+ * conforms to the contract or is a single re-answer away from it.
449
+ *
450
+ * - `conforming` — a JSON object that satisfies the strict contract. For
451
+ * text-recovered blocks this is the criterion that separates "a complete
452
+ * verdict written outside the yield tool" from mere narration.
453
+ * - `repairable` — a JSON object that violates the contract (the #1062
454
+ * shape: every required field present, one key misnamed). The failure
455
+ * carries the first validation issue, which is what the repair round hands
456
+ * back to the scout.
457
+ * - `not-a-payload` — prose, truncated JSON, a non-object: nothing a
458
+ * re-answer can repair, so the pass records no answer rather than grading
459
+ * it.
460
+ *
461
+ * Same parser as {@link parseToSpecResult}, deliberately: the runner probes
462
+ * with the one contract so its repair decision can never drift from what the
463
+ * persistence layer will accept — this is the one validator used twice, not a
464
+ * second, weaker one.
465
+ */
466
+ export type ToSpecPayloadGate =
467
+ | { state: "conforming"; body: string }
468
+ | { state: "repairable"; body: string; failure: ToSpecFailure }
469
+ | { state: "not-a-payload" };
470
+
471
+ export function gateToSpecPayload(payload: string): ToSpecPayloadGate {
472
+ const body = extractJson(payload);
473
+ if (body.length === 0) return { state: "not-a-payload" };
474
+ let raw: unknown;
475
+ try {
476
+ raw = JSON.parse(body);
477
+ } catch {
478
+ return { state: "not-a-payload" };
479
+ }
480
+ if (!isObject(raw)) return { state: "not-a-payload" };
481
+ const parsed = ToSpecResultSchema.safeParse(raw);
482
+ if (!parsed.success) {
483
+ // The field path rides in front of the message: zod says "Required" for a
484
+ // missing field and "Unrecognized key(s)…" for an extra one, and the
485
+ // repair round's whole value is that the scout sees WHICH field failed.
486
+ const first = parsed.error.issues[0];
487
+ const detail =
488
+ first === undefined
489
+ ? "schema violation"
490
+ : first.path.length === 0
491
+ ? first.message
492
+ : `${first.path.join(".")}: ${first.message}`;
493
+ return { state: "repairable", body, failure: { kind: "malformed", detail } };
494
+ }
495
+ return { state: "conforming", body };
496
+ }
497
+
414
498
  /**
415
499
  * Parse and validate a groomer's raw output against the to-spec contract.
416
500
  * `now` is the settle time, explicit so staleness and determinism are
@@ -487,8 +571,31 @@ export type ToSpecGroomingOutcome =
487
571
  export interface ToSpecGroomingRequest {
488
572
  project: string;
489
573
  issue: number;
490
- /** The agent's raw output, exactly as returned. */
574
+ /** The agent's structured answer, exactly as returned: the yield payload,
575
+ * or a conforming block recovered from its text. Empty when the pass
576
+ * produced no answer at all — which is its own refusal class, decided here
577
+ * before anything is parsed (#1064). */
491
578
  input: string;
579
+ /** How the answer arrived: through the yield tool, or recovered from the
580
+ * session's text because it never yielded. `"text"` marks the record so it
581
+ * stays visible that the verdict did not come through the yield contract;
582
+ * omitted reads as a yield, and an empty `input` with no `via` reads as no
583
+ * answer. */
584
+ via?: "yield" | "text";
585
+ /** The session's last narration, recorded as the refusal's context when no
586
+ * answer arrived — never parsed as a verdict (#1064). */
587
+ report?: string;
588
+ /** Whether the one bounded repair round produced the carried answer
589
+ * (#1064); recorded on the row so a repaired verdict is distinguishable. */
590
+ repaired?: boolean;
591
+ /** Whether the session was killed at its turn ceiling before it answered
592
+ * (#1064) — half of the no-answer shape naming. */
593
+ killedAtCeiling?: boolean;
594
+ /** The turn ceiling the pass ran under, and the turns it took: the other
595
+ * half of the no-answer shape naming (`turns >= maxTurns` reads as a cap
596
+ * kill even when the runner did not flag it). */
597
+ maxTurns?: number;
598
+ turns?: number;
492
599
  /** Observation time for staleness and the recorded row; `Date.now()` when
493
600
  * omitted (tests pass it to keep reprocessing deterministic). */
494
601
  now?: number;
@@ -520,24 +627,51 @@ function failureEvidence(failure: ToSpecFailure, input: string): string {
520
627
  * it, not anything the agent wrote, becomes the recorded source observation
521
628
  * time.
522
629
  */
523
- export function recordToSpecGrooming(store: Store, request: ToSpecGroomingRequest): ToSpecGroomingOutcome {
524
- const now = request.now ?? Date.now();
525
- const parsed = parseToSpecResult(request.input, now, { launchedAt: request.launchedAt });
526
- if (parsed.ok) {
527
- const evidence: ToSpecEvidence = { kind: "to-spec", result: parsed.result };
528
- store.upsertGrooming({
529
- project: request.project,
530
- issue: request.issue,
531
- verdict: mapStoreVerdict(parsed.result.verdict),
532
- reason: mapStoreReason(parsed.result.verdict),
533
- evidence: JSON.stringify(evidence),
534
- at: now,
535
- });
536
- return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
630
+ /**
631
+ * The reason a via-text verdict carries: the groomer's own label with a
632
+ * visible marker that the answer arrived outside the yield contract (#1064),
633
+ * so `status` reads `#13 promotable via text` instead of hiding the
634
+ * distinction in an evidence string. Yielded verdicts keep the bare label.
635
+ */
636
+ function mapStoreReasonWithVia(verdict: ToSpecVerdict, via: "yield" | "text" | undefined): string {
637
+ const label = mapStoreReason(verdict);
638
+ return via === "text" ? `${label} via text` : label;
639
+ }
640
+
641
+ /**
642
+ * The no-answer detail names which of the two session shapes produced it
643
+ * (#1064): a turn-ceiling kill (`killedAtCeiling`, or `turns >= maxTurns`
644
+ * when the runner did not flag it) or a session that ended without ever
645
+ * yielding.
646
+ */
647
+ function noAnswerDetail(request: ToSpecGroomingRequest): string {
648
+ const killed =
649
+ request.killedAtCeiling === true ||
650
+ (request.maxTurns !== undefined && request.turns !== undefined && request.turns >= request.maxTurns);
651
+ const turns = request.turns === undefined ? "" : ` after ${request.turns} turn(s)`;
652
+ if (killed) {
653
+ const ceiling = request.maxTurns === undefined ? "its turn ceiling" : `its ${request.maxTurns}-turn ceiling`;
654
+ return `the scout was killed at ${ceiling}${turns} and never yielded an answer`;
537
655
  }
656
+ return `the scout session ended${turns} without yielding an answer`;
657
+ }
658
+
659
+ /**
660
+ * Persist one refusal, or keep a prior valid verdict when the new pass
661
+ * produced something no-answer-like or malformed. `input` is what the failure
662
+ * record carries: the raw payload of the refused pass — or, for a no-answer
663
+ * pass, the session's narration as context (#1064).
664
+ */
665
+ function persistGroomingRefusal(
666
+ store: Store,
667
+ request: ToSpecGroomingRequest,
668
+ failure: ToSpecFailure,
669
+ input: string,
670
+ now: number,
671
+ ): ToSpecGroomingOutcome {
538
672
  const prior = store.grooming(request.project, request.issue);
539
673
  if (
540
- parsed.failure.kind === "malformed" &&
674
+ (failure.kind === "malformed" || failure.kind === "no-answer") &&
541
675
  prior !== undefined &&
542
676
  (prior.verdict === "promotable" || prior.verdict === "considered")
543
677
  ) {
@@ -547,13 +681,83 @@ export function recordToSpecGrooming(store: Store, request: ToSpecGroomingReques
547
681
  project: request.project,
548
682
  issue: request.issue,
549
683
  verdict: "blocked",
550
- reason: FAILURE_REASON[parsed.failure.kind],
551
- evidence: failureEvidence(parsed.failure, request.input),
684
+ reason: FAILURE_REASON[failure.kind],
685
+ evidence: failureEvidence(failure, input),
552
686
  at: now,
553
687
  });
554
688
  return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
555
689
  }
556
690
 
691
+ /**
692
+ * The only way a groomer's output becomes a durable grooming verdict: parse
693
+ * and validate, then upsert through the existing store — no second store, no
694
+ * `GroomingRecord` extension. Valid results map to the store's verdict
695
+ * surface with the full structured result serialized into `evidence`
696
+ * (`{"kind":"to-spec","result":…}`), which is what survives a restart. A
697
+ * refused result persists as `blocked` with the failure named in
698
+ * `evidence` (`to-spec-failure`) — never as promotable/considered — except
699
+ * when a prior valid result exists: malformed and no-answer reprocessing then
700
+ * keep that prior row instead of erasing it. Nothing here touches labels or
701
+ * issues.
702
+ *
703
+ * The refusal classes come from the whole pass, not just its output (#1064):
704
+ *
705
+ * - `no-answer` — the session produced no payload at all (no yield, no
706
+ * conforming text block): a turn-ceiling kill or a session end without a
707
+ * yield, recorded by shape, never as `malformed`. The narration is kept as
708
+ * context only. A text-carried block that fails validation is also
709
+ * `no-answer` — such a session never yielded, and the block is not an
710
+ * answer — except a batch-window staleness, which is a property of the
711
+ * pass rather than the answer.
712
+ * - `malformed` / `missing-source` — a *yielded* payload (or an unjudged
713
+ * one) that violates the contract, with the raw payload persisted so the
714
+ * discarded verdict stays recoverable.
715
+ * - `stale-source` — the batch window outlived the freshness ceiling.
716
+ *
717
+ * `request.launchedAt` is the dispatcher's launch stamp for the batch (#1000);
718
+ * it, not anything the agent wrote, becomes the recorded source observation
719
+ * time.
720
+ */
721
+ export function recordToSpecGrooming(store: Store, request: ToSpecGroomingRequest): ToSpecGroomingOutcome {
722
+ const now = request.now ?? Date.now();
723
+ if (request.input === "") {
724
+ return persistGroomingRefusal(
725
+ store,
726
+ request,
727
+ { kind: "no-answer", detail: noAnswerDetail(request) },
728
+ request.report ?? "",
729
+ now,
730
+ );
731
+ }
732
+ const parsed = parseToSpecResult(request.input, now, { launchedAt: request.launchedAt });
733
+ if (parsed.ok) {
734
+ const evidence: ToSpecEvidence = {
735
+ kind: "to-spec",
736
+ result: parsed.result,
737
+ ...(request.via === undefined ? {} : { via: request.via }),
738
+ ...(request.repaired === true ? { repaired: true } : {}),
739
+ };
740
+ store.upsertGrooming({
741
+ project: request.project,
742
+ issue: request.issue,
743
+ verdict: mapStoreVerdict(parsed.result.verdict),
744
+ reason: mapStoreReasonWithVia(parsed.result.verdict, request.via),
745
+ evidence: JSON.stringify(evidence),
746
+ at: now,
747
+ });
748
+ return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
749
+ }
750
+ // A text-carried answer that fails validation was never yielded and does
751
+ // not conform: that is a no-answer pass, with the block kept as the
752
+ // failure's context — never malformed (#1064). The one exception is a
753
+ // batch-window staleness, whose class is about the pass, not the answer.
754
+ const failure =
755
+ request.via === "text" && parsed.failure.kind !== "stale-source"
756
+ ? { kind: "no-answer" as const, detail: noAnswerDetail(request) }
757
+ : parsed.failure;
758
+ return persistGroomingRefusal(store, request, failure, request.input, now);
759
+ }
760
+
557
761
  /**
558
762
  * Recover the validated result from a record's evidence (the restart
559
763
  * round-trip). Returns undefined for anything that is not a `to-spec`
@@ -601,7 +805,12 @@ export function parseToSpecFailureEvidence(evidence: string): ToSpecFailure | un
601
805
  if (!isObject(raw) || raw.kind !== "to-spec-failure") return undefined;
602
806
  const failure = raw.failure;
603
807
  if (!isObject(failure) || typeof failure.detail !== "string") return undefined;
604
- if (failure.kind !== "malformed" && failure.kind !== "missing-source" && failure.kind !== "stale-source") {
808
+ if (
809
+ failure.kind !== "malformed" &&
810
+ failure.kind !== "no-answer" &&
811
+ failure.kind !== "missing-source" &&
812
+ failure.kind !== "stale-source"
813
+ ) {
605
814
  return undefined;
606
815
  }
607
816
  return { kind: failure.kind, detail: failure.detail };
package/src/types.ts CHANGED
@@ -1797,6 +1797,14 @@ export const FAILURE_CLASSES = [
1797
1797
  "dispatch-infra",
1798
1798
  "merge-conflict",
1799
1799
  "question",
1800
+ /** A run that finished its work and stopped because it is waiting on an
1801
+ * observable condition — its settlement names `blockers:` (a PR whose
1802
+ * checks were still pending, say) and no question (#1068). Distinct from
1803
+ * `question` because the remedy is not a human: the recovery is the later
1804
+ * observation, which the settle sweep performs by re-offering the blocked
1805
+ * row until its PR resolves (this fleet's #1062 sat `blocked`/`question`
1806
+ * after its PR merged and its tick kept listing it for Duty 1 triage). */
1807
+ "awaiting-observation",
1800
1808
  "orphan-clean",
1801
1809
  "orphan-dirty",
1802
1810
  "settlement-stuck",
@@ -1831,21 +1839,27 @@ export type FailureClass = (typeof FAILURE_CLASSES)[number];
1831
1839
  /**
1832
1840
  * What the daemon does about a class.
1833
1841
  *
1834
- * Two of these perform nothing, and they are not the same nothing:
1842
+ * Three of these perform nothing, and they are not the same nothing:
1835
1843
  *
1844
+ * - `observe` — recorded and deliberately left alone, because the recovery IS
1845
+ * the later observation: `awaiting-observation` rows wait on something the
1846
+ * world will resolve (their PR), and the settle sweep re-offers the blocked
1847
+ * row until it does, settling it the way it settles `settlement-stuck` when
1848
+ * the PR merges. Like `hold` it never stamps `recoveredAt` — nothing is
1849
+ * recovered yet, by design (#1068).
1836
1850
  * - `hold` — recorded and deliberately left alone, because acting would destroy
1837
1851
  * something. `orphan-dirty` is the case: the tree is the only copy, so
1838
1852
  * `unblock` refuses rather than re-claiming over it.
1839
1853
  * - `none` — the classifier declining to name a failure at all. Two callers
1840
1854
  * reach it, and neither is "unclassifiable": `classifyRun` returns it for a
1841
- * terminal row whose PR is open and fully green, where `classifyAndRecover`
1855
+ * terminal row whose PR is open and fully green, where `classifyAndRecover`
1842
1856
  * restores `pushed-green` *without* persisting a class (#766); and the settle
1843
1857
  * sweep persists it beside `returned-for-revision` when a reviewer closes
1844
1858
  * pushed work without merging, where the remedy is the queue label the sweep
1845
1859
  * deliberately leaves on.
1846
1860
  *
1847
1861
  * An *unclassifiable* run is `unknown` → `escalate`, and never a silent retry.
1848
- * This comment previously said nothing in the decision table maps to `none`,
1862
+ * This comment previously said nothing in the decision tree maps to `none`,
1849
1863
  * which both callers above contradict; it cost a reading of two modules to
1850
1864
  * discover, which is what a wrong comment costs (#132).
1851
1865
  */
@@ -1855,6 +1869,7 @@ export const RECOVERY_ACTIONS = [
1855
1869
  "rerun-checks",
1856
1870
  "settle",
1857
1871
  "escalate",
1872
+ "observe",
1858
1873
  "hold",
1859
1874
  "none",
1860
1875
  ] as const;
package/src/worker.ts CHANGED
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  import { knowledgeSection } from "./knowledge.ts";
16
16
  import { createSession, disposeSession, SessionAdmissionClosedError, type AgentSessionLike } from "./omp.ts";
17
17
  import type { GateShape, ReleaseBlockContext } from "./release-policy.ts";
18
- import { TO_SPEC_SCHEMA } from "./to-spec.ts";
18
+ import { gateToSpecPayload, TO_SPEC_SCHEMA, type ToSpecFailure } from "./to-spec.ts";
19
19
  import type { Caps, GraphToolsObservation, ResolvedGrants, RunState } from "./types.ts";
20
20
 
21
21
  /** Structured evidence fields from the worker's final report. */
@@ -1057,7 +1057,18 @@ export async function runAdjudicator(
1057
1057
  }
1058
1058
 
1059
1059
  /**
1060
- * The turn ceiling for one to-spec grooming scout (#1041).
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).
1061
1072
  *
1062
1073
  * Half the adjudicator's 40, and for the opposite reason: an adjudication is
1063
1074
  * handed its whole evidence in the brief (issue, diff, checks, every prior
@@ -1065,28 +1076,48 @@ export async function runAdjudicator(
1065
1076
  * and must go and READ — the source repo at a ref, the entry points, the
1066
1077
  * existing tests, the later work that may have retired the premise. That is
1067
1078
  * search, and search is where a session bounded only by a worker's budget
1068
- * quietly spends a worker's budget. Twenty read/answer turns is enough for a
1069
- * bounded repo walk plus the structured answer; a scout still going after
1070
- * them has stopped grooming one candidate and started exploring the
1071
- * repository, which is exactly the cost the mechanical selection bounds
1072
- * everywhere else.
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.
1073
1084
  */
1074
- export const TO_SPEC_MAX_TURNS = 20;
1085
+ export const TO_SPEC_MAX_TURNS = TO_SPEC_MEASURED_FINISHED_MAX_TURNS + 1;
1075
1086
 
1076
1087
  /** What one to-spec scout produced (#1041). */
1077
1088
  export interface ToSpecScoutResult {
1078
1089
  /**
1079
1090
  * The agent's answer, verbatim and unjudged — the structured payload it
1080
- * yielded, serialized, or its own last text when it yielded nothing. This
1081
- * string is `recordToSpecGrooming`'s `input`, and nothing here reads a field
1082
- * of it: conductor re-validates every result through the strict parser at
1083
- * persistence time, so a runner that pre-parsed would be a second, weaker
1084
- * contract. Empty when the session produced neither.
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`.
1085
1099
  */
1086
1100
  raw: string;
1087
1101
  /** The session's last words, kept whether or not it answered: for a scout
1088
- * that produced nothing this text IS the evidence of what went wrong. */
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). */
1089
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;
1090
1121
  /** What actually ran it, for the durable provenance (#875). */
1091
1122
  model?: string;
1092
1123
  provider?: string;
@@ -1115,7 +1146,7 @@ export interface ToSpecScoutOpts {
1115
1146
  }
1116
1147
 
1117
1148
  /**
1118
- * Run one bounded to-spec grooming pass (#1041).
1149
+ * Run one bounded to-spec grooming pass (#1041, #1064).
1119
1150
  *
1120
1151
  * The adjudicator's twin, and read-only by the same construction: no
1121
1152
  * `verbSocketPath`, so no conductor mutation verb — no label change, no
@@ -1124,14 +1155,31 @@ export interface ToSpecScoutOpts {
1124
1155
  * the scout cannot queue the candidate it is grooming, only describe it. The
1125
1156
  * queue label is added later, by the daemon, and only through the ready gate.
1126
1157
  *
1127
- * Two deliberate differences from {@link runAdjudicator}. It carries
1158
+ * Deliberate differences from {@link runAdjudicator}. It carries
1128
1159
  * {@link TO_SPEC_SCHEMA} with `outputSchemaMode: "strict"` — the to-spec
1129
1160
  * contract has always been invoked strictly (#772), and a permissive schema
1130
1161
  * would let a half-filled result through the harness only for the parser to
1131
1162
  * refuse it as malformed one layer later, spending the batch to learn nothing.
1132
- * And it returns the answer UNPARSED: `recordToSpecGrooming` is the single
1133
- * persistence path and the only validator, so a second decode here would be
1134
- * a second contract to keep in sync.
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.
1135
1183
  */
1136
1184
  export async function runToSpecScout(
1137
1185
  o: ToSpecScoutOpts,
@@ -1162,6 +1210,7 @@ export async function runToSpecScout(
1162
1210
  return {
1163
1211
  raw: "",
1164
1212
  report: err instanceof Error ? err.message : String(err),
1213
+ via: "none",
1165
1214
  turns: 0,
1166
1215
  spendUsd: 0,
1167
1216
  };
@@ -1173,10 +1222,16 @@ export async function runToSpecScout(
1173
1222
  let yielded = "";
1174
1223
  let model: string | undefined;
1175
1224
  let provider: string | undefined;
1176
- const finished = Promise.withResolvers<void>();
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>();
1177
1229
  session.on("turn_start", () => {
1178
1230
  turns += 1;
1179
- if (turns > o.maxTurns) session.abort();
1231
+ if (turns > o.maxTurns) {
1232
+ killedAtCeiling = true;
1233
+ session.abort();
1234
+ }
1180
1235
  });
1181
1236
  session.on("message_end", (event) => {
1182
1237
  const message = field(event, "message");
@@ -1202,26 +1257,85 @@ export async function runToSpecScout(
1202
1257
  if (shouldComplete(event as { isTerminal?: boolean })) finished.resolve();
1203
1258
  });
1204
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;
1205
1290
  try {
1206
1291
  await session.prompt(o.brief);
1207
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
+ };
1208
1322
  } finally {
1209
1323
  await disposeSession(session);
1210
1324
  }
1211
- return {
1212
- // The yielded payload first: under `requireYieldTool` that IS the answer,
1213
- // and a scout's prose is usually a sentence about it rather than the
1214
- // contract. The text stands in when nothing was yielded, so a session
1215
- // that answered in a fenced JSON block still persists as a verdict and
1216
- // one that only chatted persists as `blocked(malformed)` which is the
1217
- // honest record of a spent pass, not a discard.
1218
- raw: yielded !== "" ? yielded : report,
1219
- report,
1220
- ...(model === undefined ? {} : { model }),
1221
- ...(provider === undefined ? {} : { provider }),
1222
- turns,
1223
- spendUsd,
1224
- };
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
+ );
1225
1339
  }
1226
1340
 
1227
1341
  /**