@claudexor/orchestrator 3.0.4 → 3.1.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 (83) hide show
  1. package/dist/attemptFinalize.d.ts +200 -0
  2. package/dist/attemptFinalize.d.ts.map +1 -0
  3. package/dist/attemptFinalize.js +412 -0
  4. package/dist/attemptFinalize.js.map +1 -0
  5. package/dist/attemptTelemetry.d.ts +88 -7
  6. package/dist/attemptTelemetry.d.ts.map +1 -1
  7. package/dist/attemptTelemetry.js +276 -14
  8. package/dist/attemptTelemetry.js.map +1 -1
  9. package/dist/budgetFailure.d.ts +70 -0
  10. package/dist/budgetFailure.d.ts.map +1 -0
  11. package/dist/budgetFailure.js +114 -0
  12. package/dist/budgetFailure.js.map +1 -0
  13. package/dist/candidateEvidence.d.ts +55 -1
  14. package/dist/candidateEvidence.d.ts.map +1 -1
  15. package/dist/candidateEvidence.js +72 -0
  16. package/dist/candidateEvidence.js.map +1 -1
  17. package/dist/continuation.d.ts +77 -0
  18. package/dist/continuation.d.ts.map +1 -0
  19. package/dist/continuation.js +92 -0
  20. package/dist/continuation.js.map +1 -0
  21. package/dist/deepScanReducer.d.ts +148 -0
  22. package/dist/deepScanReducer.d.ts.map +1 -0
  23. package/dist/deepScanReducer.js +348 -0
  24. package/dist/deepScanReducer.js.map +1 -0
  25. package/dist/effortGovernance.d.ts +49 -0
  26. package/dist/effortGovernance.d.ts.map +1 -0
  27. package/dist/effortGovernance.js +35 -0
  28. package/dist/effortGovernance.js.map +1 -0
  29. package/dist/harnessFailure.d.ts +27 -0
  30. package/dist/harnessFailure.d.ts.map +1 -0
  31. package/dist/harnessFailure.js +61 -0
  32. package/dist/harnessFailure.js.map +1 -0
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/orchestrator.d.ts +103 -16
  37. package/dist/orchestrator.d.ts.map +1 -1
  38. package/dist/orchestrator.js +1245 -327
  39. package/dist/orchestrator.js.map +1 -1
  40. package/dist/planQuestions.d.ts.map +1 -1
  41. package/dist/planQuestions.js +105 -50
  42. package/dist/planQuestions.js.map +1 -1
  43. package/dist/planRun.d.ts +15 -7
  44. package/dist/planRun.d.ts.map +1 -1
  45. package/dist/planRun.js +88 -42
  46. package/dist/planRun.js.map +1 -1
  47. package/dist/planTerminal.d.ts +36 -0
  48. package/dist/planTerminal.d.ts.map +1 -0
  49. package/dist/planTerminal.js +63 -0
  50. package/dist/planTerminal.js.map +1 -0
  51. package/dist/reviewerPanel.d.ts +4 -0
  52. package/dist/reviewerPanel.d.ts.map +1 -1
  53. package/dist/reviewerPanel.js +54 -9
  54. package/dist/reviewerPanel.js.map +1 -1
  55. package/dist/revisePrompt.d.ts +17 -0
  56. package/dist/revisePrompt.d.ts.map +1 -0
  57. package/dist/revisePrompt.js +15 -0
  58. package/dist/revisePrompt.js.map +1 -0
  59. package/dist/runSupport.d.ts +67 -2
  60. package/dist/runSupport.d.ts.map +1 -1
  61. package/dist/runSupport.js +86 -6
  62. package/dist/runSupport.js.map +1 -1
  63. package/dist/runTelemetryWriter.d.ts +6 -1
  64. package/dist/runTelemetryWriter.d.ts.map +1 -1
  65. package/dist/runTelemetryWriter.js +2 -0
  66. package/dist/runTelemetryWriter.js.map +1 -1
  67. package/dist/runTerminals.d.ts +15 -4
  68. package/dist/runTerminals.d.ts.map +1 -1
  69. package/dist/runTerminals.js +16 -1
  70. package/dist/runTerminals.js.map +1 -1
  71. package/dist/structuredOutput.d.ts +27 -0
  72. package/dist/structuredOutput.d.ts.map +1 -1
  73. package/dist/structuredOutput.js +65 -7
  74. package/dist/structuredOutput.js.map +1 -1
  75. package/dist/transientClassify.d.ts +59 -0
  76. package/dist/transientClassify.d.ts.map +1 -0
  77. package/dist/transientClassify.js +109 -0
  78. package/dist/transientClassify.js.map +1 -0
  79. package/dist/write-isolation.d.ts +11 -0
  80. package/dist/write-isolation.d.ts.map +1 -0
  81. package/dist/write-isolation.js +13 -0
  82. package/dist/write-isolation.js.map +1 -0
  83. package/package.json +17 -17
@@ -3,7 +3,7 @@ import { observeNativeSessionEvent, preflightCredentialProfile, preflightDefault
3
3
  import { writeRunTelemetryArtifact } from "./runTelemetryWriter.js";
4
4
  import { buildFileBackedSynthesisInput, materializeWinnerOutputs, stageFileBackedContext, writeCandidateAttemptArtifacts, } from "./candidateOutputs.js";
5
5
  import { processAttemptUsage } from "./attemptUsage.js";
6
- import { toCandidateEvidence } from "./candidateEvidence.js";
6
+ import { candidateRoster, convergenceOutcomeFacts, isWorkingCandidate, partitionCandidates, toCandidateEvidence, } from "./candidateEvidence.js";
7
7
  import { capabilityIntents } from "@claudexor/gateway";
8
8
  import { policyFindings } from "./policyFindings.js";
9
9
  import { join } from "node:path";
@@ -12,29 +12,37 @@ import { HarnessRunSpec, ModeKind as ModeKindSchema, SCHEMA_VERSION, TRUST_FULL_
12
12
  import { globalConfigDir, loadConfig, trustConfigPath } from "@claudexor/config";
13
13
  import { AnswerAssembly, CLAUDEXOR_ARTIFACT_DIR, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
14
14
  import { assertRouteModelsAllowed } from "./modelGovernance.js";
15
+ import { governRouteEffort } from "./effortGovernance.js";
15
16
  import { RequestRequirementsResolver } from "./requestRequirements.js";
17
+ import { buildRevisePrompt } from "./revisePrompt.js";
16
18
  import { cancelledResult, failTerminally, guardAnnouncedRun, writeFailure, } from "./runTerminals.js";
19
+ import { budgetFailureRecord, classifyBudgetFailure } from "./budgetFailure.js";
17
20
  import { assertOutputSchemaCompiles, finalizeStructuredOutput } from "./structuredOutput.js";
18
- import { transientRetryDelayMs, promptWithProtectedPathConstraint, sleep, redactHarnessEvent, harnessEventPayload, formatFindings, renderSummary, observeBudgetSignals, rotateOnStall, recordCleanAttemptMetrics, envInheritance, transientRetryPolicy, reviewerTimeoutMs, harnessInactivityTimeoutMs, observeAuthSwitch, emitPrimaryDivergence, deliveryRefusalFailure, writeRaceDeliveryDecision, } from "./runSupport.js";
21
+ import { transientRetryDelayMs, promptWithEngineConstraints, sleep, redactHarnessEvent, harnessEventPayload, safeErrorMessage, renderSummary, observeBudgetSignals, rotateOnStall, recordCleanAttemptMetrics, envInheritance, transientRetryPolicy, reviewerTimeoutMs, harnessInactivityTimeoutMs, observeAuthSwitch, emitPrimaryDivergence, emitPoolDegraded, deliveryRefusalFailure, writeRaceDeliveryDecision, } from "./runSupport.js";
19
22
  import { candidateStatusInRouteContext, resolveReadOnlyRouteContext, } from "./routeContext.js";
20
23
  import { resolveAutoReviewerPanel, resolveExplicitReviewerPanel } from "./reviewerPanel.js";
21
24
  import { buildContinuation, } from "./continuity.js";
22
25
  import { activePlanPointer, resolveContinuitySummary, workspaceAnchor, } from "./continuity-facts.js";
23
26
  import { runDiffReview } from "./diffReview.js";
24
- import { createAttemptTelemetry, observeAttemptTelemetry, setAttemptOutcome, telemetrySummary, toolWarnings, unrecoveredToolErrors, webUnsatisfied, } from "./attemptTelemetry.js";
27
+ import { rawScoutBundle, resolveDeepScanSynthesis, } from "./deepScanReducer.js";
28
+ import { classifyAdapterThrow, createAttemptTelemetry, observeAttemptTelemetry, setAttemptOutcome, telemetrySummary, toolWarnings, unrecoveredToolErrors, webUnsatisfied, } from "./attemptTelemetry.js";
29
+ import { dominantHarnessFailureCategory, harnessFailureNextActions } from "./harnessFailure.js";
30
+ import { finalizeAttempt, readOnlyNoSuccessTerminal, resolveWorkReportEnvelope, unrecoveredToolErrorFailure, unwrapWorkReportEnvelope, webEvidenceFailure, } from "./attemptFinalize.js";
31
+ import { buildContinuationPacket, decideContinuation, synthesizeContinuationRequest, } from "./continuation.js";
25
32
  import { interactionChannelFor } from "./interaction.js";
26
33
  import { gateSpecsFromContract, renderTestsEvidence, resolveContractGates, } from "./contract-gates.js";
27
34
  import { ArtifactStore } from "@claudexor/artifact-store";
28
35
  import { EventLog } from "@claudexor/event-log";
29
36
  import { assertMandatoryContext, buildContextPack, rawContextForEnvelope, preflightEvidence, writeEvidencePacket, } from "@claudexor/context";
30
- import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureGitRepository, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
37
+ import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureClaudeBridge, ensureGitRepository, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
31
38
  import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch, verifyAndDeliver, } from "@claudexor/delivery";
32
39
  import { HarnessGateway } from "@claudexor/gateway";
33
40
  import { ReadinessLedger, evaluateConvergence, failureSignature, gatesPassed, reviewCandidate, revalidateFindings, runGates, } from "@claudexor/review";
34
41
  import { arbitrate } from "@claudexor/arbitration";
35
42
  import { buildSynthesisPlan, decideSynthesis } from "@claudexor/synthesis";
36
- import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, loadHarnessMetrics, promptFingerprint, unknownCostSettlement, rankHarnesses, reviewUsageCostSettlement, } from "@claudexor/budget";
43
+ import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, explainRanking, loadHarnessMetrics, promptFingerprint, unknownCostSettlement, rankHarnesses, reviewUsageCostSettlement, } from "@claudexor/budget";
37
44
  import { readTextSafe, appendLine, assertNoInlineSecretValues, containsSecretLikeToken, DELEGATION_ENV, hashJson, newId, noProjectRepoRoot, nowIso, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
45
+ import { assertWriteIsolation } from "./write-isolation.js";
38
46
  /** A routed candidate adapter plus its manifest capabilities and user settings. */
39
47
  /** The two access profiles that map to codex `danger-full-access` / an
40
48
  * unsandboxed lane — the only ones under which a full-access-requiring MCP
@@ -42,6 +50,25 @@ import { readTextSafe, appendLine, assertNoInlineSecretValues, containsSecretLik
42
50
  export function isFullAccess(access) {
43
51
  return access === "full" || access === "external_sandbox_full";
44
52
  }
53
+ /**
54
+ * A routing preflight refusal (`RoutingPreflightError`: quality routing with no
55
+ * comparable user-declared tier for the intent) is a CONFIGURATION error, not a
56
+ * harness-availability problem (A-1/D-9/#22). Classifying it as
57
+ * `harness_unavailable` sent the operator to re-auth or wait for a harness; the
58
+ * real fix is to configure a tier or change the routing goal. Detected by the
59
+ * typed `code` (robust across duplicate `@claudexor/budget` package copies) so
60
+ * EVERY strategy's routing catch (ask/agent/plan/deep-scan/council) classifies
61
+ * it identically. Returns the failure category + matching remediation.
62
+ */
63
+ export function routingFailureClassification(err) {
64
+ const isPreflightRefusal = !!err &&
65
+ typeof err === "object" &&
66
+ err.code === "routing_preflight_refused";
67
+ if (isPreflightRefusal) {
68
+ return { category: "config_error", nextActions: harnessFailureNextActions("config_error") };
69
+ }
70
+ return { category: "harness_unavailable" };
71
+ }
45
72
  const LABELS = "ABCDEFGHIJ".split("");
46
73
  const NO_PROJECT_ROOT = noProjectRepoRoot();
47
74
  /** Concurrency cap for parallel candidates/explorers (locked decision: min(n, 4)). */
@@ -68,6 +95,11 @@ export class Orchestrator {
68
95
  deps;
69
96
  gateway;
70
97
  requestRequirements = new RequestRequirementsResolver();
98
+ /** QA-034: the typed routing rationale computed ONCE at pool ordering, keyed
99
+ * by run id so the terminal telemetry writer can record it as run evidence
100
+ * (RunTelemetry.routing_rationale). Cleared when the run's telemetry is
101
+ * written. Absent for runs with an explicit single-harness pool (no ranking). */
102
+ routingRationaleByRun = new Map();
71
103
  /** Per-attempt cap on forwarded live delta chunks (W-C4 flood guard, sol
72
104
  * #10): past this the deltas are dropped and the cutoff is disclosed once;
73
105
  * the complete message always still lands. */
@@ -105,19 +137,21 @@ export class Orchestrator {
105
137
  throw new Error(`unknown mode: ${String(resolved.mode)}`);
106
138
  }
107
139
  const mode = parsedMode.data;
108
- // denyPaths is enforced by the post-diff policy gate BEFORE delivery, which
109
- // only exists on envelope/isolated runs — an in-place run mutates the live
110
- // tree directly, so the gate could not contain a violation. Refuse loudly
111
- // rather than accept a knob the engine cannot honor (INV-023).
112
- if ((resolved.denyPaths?.length ?? 0) > 0 && resolved.inPlace === true) {
113
- throw new Error("denyPaths requires an isolated/envelope run: the post-diff policy gate blocks a violating patch before delivery, which an in-place run cannot guarantee; drop --deny-path or run isolated");
114
- }
140
+ const projectProtectedPaths = mode === "agent" ? this.projectConfig(resolved.repoRoot).constraints.protected_paths : [];
141
+ assertWriteIsolation({
142
+ mode,
143
+ protectedPaths: projectProtectedPaths,
144
+ denyPaths: resolved.denyPaths,
145
+ inPlace: resolved.inPlace,
146
+ repoRoot: resolved.repoRoot,
147
+ executionRoot: this.execRootOf(resolved),
148
+ });
115
149
  // outputSchema constrains the run's final ANSWER. It is honored exactly
116
150
  // where a final answer is delivered (agent race incl. synthesis, and ask);
117
151
  // every other strategy refuses loudly rather than carrying a contract the
118
152
  // engine would not validate (INV-023). The schema itself is normalized for
119
153
  // the native structured-output routes here at the boundary — unsupported
120
- // shapes ($ref, non-object root) are a typed refusal, not a mid-run 400.
154
+ // shapes (external/cyclic $ref, non-object root) are a typed refusal, not a mid-run 400.
121
155
  if (resolved.outputSchema !== undefined && resolved.outputSchema !== null) {
122
156
  if (mode !== "agent" && mode !== "ask") {
123
157
  throw new Error(`outputSchema constrains the final answer and applies to agent/ask runs (got mode=${mode}); drop the schema or switch modes`);
@@ -128,8 +162,8 @@ export class Orchestrator {
128
162
  // Shape-refuse unsupported schemas, then PROVE it compiles under the same
129
163
  // ajv the engine validator uses — a malformed schema is a preflight
130
164
  // refusal here (before any run dir), never a mid-run validator crash. The
131
- // contract keeps the ORIGINAL (conformance authority); strictify is a
132
- // transport-only transform applied per-lane in harnessSpecKnobs.
165
+ // contract keeps the ORIGINAL (conformance authority); local-ref inlining
166
+ // and strictification are transport-only transforms in harnessSpecKnobs.
133
167
  resolved.outputSchema = normalizeUserOutputSchema(resolved.outputSchema);
134
168
  assertOutputSchemaCompiles(resolved.outputSchema);
135
169
  }
@@ -169,9 +203,13 @@ export class Orchestrator {
169
203
  case "plan":
170
204
  return this.runPlan(resolved, announce);
171
205
  }
172
- });
206
+ },
207
+ // Single per-run terminalization hook: release the routing-rationale map
208
+ // entry on EVERY terminal (incl. a run that died before its telemetry
209
+ // writer ran, which is the leak this closes).
210
+ (runId) => this.routingRationaleByRun.delete(runId));
173
211
  }
174
- async resolveReviewers(cwd, runAuthPreference) {
212
+ async resolveReviewers(cwd, runAuthPreference, onIgnoredSetting) {
175
213
  if (this.deps.reviewers)
176
214
  return this.deps.reviewers;
177
215
  if (this.deps.reviewerPanel && this.deps.reviewerPanel.length > 0) {
@@ -182,10 +220,8 @@ export class Orchestrator {
182
220
  registry: this.deps.registry,
183
221
  harnessSettings: this.config(cwd)?.global.harnesses ?? {},
184
222
  authPreferenceFor: (id) => this.authPreferenceForHarness(cwd, id, runAuthPreference),
185
- }, {
186
- reviewerModels: this.deps.reviewerModels,
187
- reviewerEfforts: this.deps.reviewerEfforts,
188
- });
223
+ onIgnoredSetting,
224
+ }, { reviewerModels: this.deps.reviewerModels, reviewerEfforts: this.deps.reviewerEfforts });
189
225
  }
190
226
  /**
191
227
  * Resolve reviewers INSIDE a strategy, after run-dir creation: an explicit
@@ -196,7 +232,9 @@ export class Orchestrator {
196
232
  */
197
233
  async resolveReviewersWithArtifacts(input, log, store, paths, runId, taskId, mode) {
198
234
  try {
199
- return { reviewers: await this.resolveReviewers(input.repoRoot, input.authPreference) };
235
+ // Auto-panel dropped knobs (reviewerEfforts) → ignored-settings channel (QA-070):
236
+ const warn = (d) => void log.emit("review.preflight", { ignored_settings: [d] });
237
+ return { reviewers: await this.resolveReviewers(input.repoRoot, input.authPreference, warn) };
200
238
  }
201
239
  catch (err) {
202
240
  const message = safeErrorMessage(err);
@@ -385,25 +423,38 @@ export class Orchestrator {
385
423
  const cfg = this.config(input.repoRoot);
386
424
  const configuredPool = cfg?.global.routing.eligible_harnesses;
387
425
  const harnesses = input.harnesses ?? (configuredPool && configuredPool.length > 0 ? configuredPool : undefined);
388
- const primaryHarness = input.primaryHarness ?? cfg?.global.routing.primary_harness ?? undefined;
426
+ // GH #25 precedence: an explicit --primary-harness wins and is validated
427
+ // against the pool; else a single-item explicit pool infers itself as
428
+ // primary (shipped in #34); else the configured default primary applies.
429
+ const explicitPrimary = input.primaryHarness;
430
+ const configPrimary = cfg?.global.routing.primary_harness;
431
+ const primaryHarness = explicitPrimary ??
432
+ (input.harnesses?.length === 1 ? input.harnesses[0] : undefined) ??
433
+ configPrimary ??
434
+ undefined;
389
435
  if (primaryHarness &&
390
436
  harnesses &&
391
437
  harnesses.length > 0 &&
392
438
  !harnesses.includes(primaryHarness)) {
393
- throw new Error(`primary harness '${primaryHarness}' is not in the eligible harness pool (${harnesses.join(", ")})`);
439
+ if (explicitPrimary) {
440
+ // An explicit primary must be a member of the eligible pool (authoritative).
441
+ throw new Error(`primary harness '${explicitPrimary}' is not in the eligible harness pool (${harnesses.join(", ")}); ` +
442
+ `pass --primary-harness as one of [${harnesses.join(", ")}], or add '${explicitPrimary}' to --harness`);
443
+ }
444
+ // GH #25 remainder: a MULTI-harness pool whose CONFIGURED default primary
445
+ // is absent, with no --primary-harness pinned, is ambiguous — the engine
446
+ // must not silently reroute. Refuse with a structured, copy-pasteable fix
447
+ // naming the pool, the missing primary, and the exact flag to add.
448
+ throw new HarnessUnavailableError(`ambiguous primary harness: the configured default primary '${primaryHarness}' is not in the selected pool [${harnesses.join(", ")}], ` +
449
+ `and no --primary-harness was given. Pin one explicitly, e.g. \`--primary-harness ${harnesses[0]}\` ` +
450
+ `(or another of [${harnesses.join(", ")}]).`);
394
451
  }
395
452
  if (input.web && input.externalContextPolicy && input.web !== input.externalContextPolicy) {
396
453
  throw new Error(`contradictory web policy: web='${input.web}' vs externalContextPolicy='${input.externalContextPolicy}' (pass one, or equal values)`);
397
454
  }
398
455
  const web = input.web ?? input.externalContextPolicy ?? "auto";
399
- // INV-103: model choice is harness-scoped end-to-end. The scalar
400
- // `model` is a convenience that expands to the RESOLVED PRIMARY only —
401
- // never the whole pool (the old global fallback poisoned every pool
402
- // member with one vendor's model id). Specific beats general: an explicit
403
- // per-harness map entry wins over the scalar.
404
- //
405
- // Map KEYS fail loudly (INV-021): a typo'd harness id ("claud") must
406
- // never silently no-op into "the run used defaults and nothing said why".
456
+ // INV-103: scalar `model` expands only to the resolved primary, never the pool;
457
+ // an explicit per-harness map wins. Unknown map keys fail loudly (INV-021).
407
458
  const knownHarnessIds = new Set(this.deps.registry.keys());
408
459
  for (const key of Object.keys(input.models ?? {})) {
409
460
  if (!knownHarnessIds.has(key)) {
@@ -421,6 +472,19 @@ export class Orchestrator {
421
472
  }
422
473
  models[scalarTarget] ??= input.model;
423
474
  }
475
+ // QA-035: FREEZE the config-derived per-harness default_model into the
476
+ // resolved model map at initial normalization, exactly like an explicit
477
+ // input. Without this the TaskContract records `routing_models: {}` and an
478
+ // Exact Retry re-resolves the model against CURRENT settings — silently
479
+ // changing the route after a settings edit. A per-turn/scalar value already
480
+ // set wins (??=). Only a known resolved pool can be frozen here; a pure
481
+ // auto pool's lanes are not yet known (documented seam).
482
+ const harnessCfg = cfg?.global.harnesses ?? {};
483
+ for (const hid of harnesses ?? []) {
484
+ const def = harnessCfg[hid]?.default_model;
485
+ if (def)
486
+ models[hid] ??= def;
487
+ }
424
488
  return {
425
489
  ...input,
426
490
  harnesses,
@@ -436,7 +500,16 @@ export class Orchestrator {
436
500
  externalContextPolicy: web,
437
501
  };
438
502
  }
439
- async resolveCandidateAdapters(input, intent, ledger, log, routeContext) {
503
+ async resolveCandidateAdapters(input, intent, ledger, log, routeContext,
504
+ /** QA-034: when provided, the pool-ordering rationale is recorded under this
505
+ * run id so the terminal telemetry writer can persist it. */
506
+ runId,
507
+ /** Deep-scan opts in: multi-scout coverage repeats a surviving harness to
508
+ * reach the requested width (distinct SLICES, not distinct harnesses), so a
509
+ * dropped lane must not clamp the scout count. Best-of leaves this false —
510
+ * its width is distinct-harness diversity, so a dropped lane clamps rather
511
+ * than self-races (QA-043). */
512
+ allowDuplicateFill = false) {
440
513
  let ids = input.harnesses;
441
514
  const explicitPool = Boolean(ids && ids.length > 0);
442
515
  const harnessSettings = this.config(input.repoRoot)?.global.harnesses ?? {};
@@ -492,6 +565,21 @@ export class Orchestrator {
492
565
  const policy = input.web ?? input.externalContextPolicy ?? "auto";
493
566
  const pool = [];
494
567
  const dropped = [];
568
+ // Structured requested-vs-effective route receipt (QA-043): every auto-pool
569
+ // drop is recorded with its typed STAGE so the disclosure preserves the
570
+ // real cause instead of collapsing to one reason.
571
+ const droppedLanes = [];
572
+ // The ONE explicit-lane admission gate shared by every drop site (QA-043 /
573
+ // QA-047 meta-move): an EXPLICITLY selected lane that becomes ineligible is
574
+ // a loud typed refusal naming the lane + reason — never a silent
575
+ // substitution or self-race duplication; an AUTO lane is dropped with a
576
+ // typed omission recorded for the degradation receipt.
577
+ const dropLane = (harnessId, stage, detail) => {
578
+ if (explicitPool)
579
+ throw new HarnessUnavailableError(detail);
580
+ dropped.push(detail);
581
+ droppedLanes.push({ harnessId, stage, detail });
582
+ };
495
583
  for (const id of ids) {
496
584
  const adapter = this.deps.registry.get(id);
497
585
  if (!adapter) {
@@ -502,7 +590,7 @@ export class Orchestrator {
502
590
  const known = [...this.deps.registry.keys()].sort().join(", ");
503
591
  throw new HarnessUnavailableError(`unknown harness '${id}' (registered: ${known}); run \`claudexor harness list --all\``);
504
592
  }
505
- dropped.push(`${id} (not registered)`);
593
+ dropLane(id, "discovery", `${id} (not registered)`);
506
594
  continue;
507
595
  }
508
596
  // Per-harness settings: a user-disabled harness never routes. Explicit
@@ -511,9 +599,7 @@ export class Orchestrator {
511
599
  const cfgEntry = harnessSettings[id];
512
600
  if (cfgEntry && cfgEntry.enabled === false) {
513
601
  const why = `${id} is disabled in settings (harnesses.${id}.enabled=false)`;
514
- if (explicitPool)
515
- throw new HarnessUnavailableError(why);
516
- dropped.push(why);
602
+ dropLane(id, "settings", why);
517
603
  continue;
518
604
  }
519
605
  // INV-135 accounts authority: with the native/CLI login excluded and no
@@ -523,9 +609,7 @@ export class Orchestrator {
523
609
  if (this.effectiveProfileId(input, id) === null &&
524
610
  this.nativeCredentialsDisabled(input.repoRoot, id)) {
525
611
  const why = `${id} has no routable credential: the CLI login is disabled (harnesses.${id}.native_credentials_enabled=false) and no account is pinned (--profile)`;
526
- if (explicitPool)
527
- throw new HarnessUnavailableError(why);
528
- dropped.push(why);
612
+ dropLane(id, "credential", why);
529
613
  continue;
530
614
  }
531
615
  // W3.3 (TZ-1 §B): a route is admitted on readiness truth from the SAME
@@ -533,7 +617,12 @@ export class Orchestrator {
533
617
  let status = await candidateStatusInRouteContext(this.gateway, routeContext, id, this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), statusById);
534
618
  const manifest = status?.manifest ?? null;
535
619
  if (!status || !manifest) {
536
- dropped.push(`${id} (unavailable)`);
620
+ // QA-047: an explicit member with no doctor manifest (absent binary /
621
+ // unconfigured provider) is unavailable — it must fail LOUDLY for an
622
+ // explicit pool (naming the real doctor reasons), not vanish before the
623
+ // later explicit-status guard because a healthier lane survived.
624
+ const reasons = status?.reasons?.length ? `: ${status.reasons.join("; ")}` : "";
625
+ dropLane(id, "doctor", `${id} is unavailable${reasons || " (no manifest / not ready)"}`);
537
626
  continue;
538
627
  }
539
628
  // Doctor status is the readiness truth. A DEGRADED harness (e.g. key present but
@@ -570,21 +659,17 @@ export class Orchestrator {
570
659
  }
571
660
  else {
572
661
  const why = `${id} credential profile is not ready: ${profileVerdict}`;
573
- if (explicitPool)
574
- throw new HarnessUnavailableError(why);
575
- dropped.push(why);
662
+ dropLane(id, "credential", why);
576
663
  continue;
577
664
  }
578
665
  }
579
666
  if (status.status === "unavailable" && !profileAdmitted) {
580
667
  const why = `${id} is unavailable${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`;
581
- if (explicitPool)
582
- throw new HarnessUnavailableError(why);
583
- dropped.push(why);
668
+ dropLane(id, "doctor", why);
584
669
  continue;
585
670
  }
586
671
  if (status.status !== "ok" && !explicitPool && !profileAdmitted) {
587
- dropped.push(`${id} is ${status.status}${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`);
672
+ dropLane(id, "doctor", `${id} is ${status.status}${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`);
588
673
  continue;
589
674
  }
590
675
  const readOnlyIntent = intent === "plan" || intent === "spec" || intent === "explain" || intent === "audit";
@@ -607,16 +692,12 @@ export class Orchestrator {
607
692
  (routeWebRequired && (webSupport === "none" || webSupport === "uncontrolled"));
608
693
  if (webIncompatible) {
609
694
  const why = `${id} cannot enforce web policy '${routePolicy}' (manifest web_policy=${webSupport}); choose a web-capable/enforceable harness or change --web to a compatible policy`;
610
- if (explicitPool)
611
- throw new HarnessUnavailableError(why);
612
- dropped.push(why);
695
+ dropLane(id, "web", why);
613
696
  continue;
614
697
  }
615
698
  const attachmentRefusal = this.requestRequirements.attachmentRefusal(id, input.attachments ?? [], manifest.capability_profile.attachment_inputs);
616
699
  if (attachmentRefusal) {
617
- if (explicitPool)
618
- throw new HarnessUnavailableError(attachmentRefusal);
619
- dropped.push(attachmentRefusal);
700
+ dropLane(id, "attachment", attachmentRefusal);
620
701
  continue;
621
702
  }
622
703
  const reason = status.reasons.length > 0 ? `: ${status.reasons.join("; ")}` : "";
@@ -642,8 +723,11 @@ export class Orchestrator {
642
723
  // the default store's sources apply only to profile-less runs.
643
724
  authRouteEstimate: this.profileAuthRoute(input, id) ??
644
725
  estimateEffectiveAuthRoute(this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), status.authSources),
726
+ supportsSynthesize: manifest.capabilities.synthesize,
645
727
  supportsInteractive: manifest.capabilities.interactive,
646
728
  supportsJsonSchemaOutput: manifest.capabilities.json_schema_output,
729
+ workReportTransport: manifest.capabilities.work_report_transport,
730
+ structuredOutputChannel: manifest.capabilities.structured_output_channel,
647
731
  supportsMcpInjection: manifest.capability_profile.mcp_injection,
648
732
  mcpInjectionRequiresFullAccess: manifest.capability_profile.mcp_injection_requires_full_access,
649
733
  implementationTransport: manifest.capabilities.implementation_transport,
@@ -661,21 +745,51 @@ export class Orchestrator {
661
745
  : null,
662
746
  });
663
747
  }
664
- else
665
- dropped.push(`${id} (${accessSupported ? `cannot ${intent}${reason}` : `cannot enforce ${requiredAccess}`})`);
748
+ else {
749
+ // QA-043: an intent- or access-incompatible lane. For an EXPLICIT pool
750
+ // this is a loud refusal (dropLane throws) naming the lane and the
751
+ // exact capability gap — never a silent omission that a surviving lane
752
+ // then masks by modulo self-duplication. The typed stage distinguishes
753
+ // an access refusal from a capability one so the disclosure is honest.
754
+ dropLane(id, accessSupported ? "capability" : "access", `${id} (${accessSupported ? `cannot ${intent}${reason}` : `cannot enforce ${requiredAccess}`})`);
755
+ }
666
756
  }
667
757
  if (pool.length === 0) {
668
758
  throw new HarnessUnavailableError(`no harness can perform '${intent}' for this mode${dropped.length ? ` (skipped: ${dropped.join(", ")})` : ""}`);
669
759
  }
670
- const ordered = this.orderPool(pool, input, intent, statusById, ledger);
760
+ const ordered = this.orderPool(pool, input, intent, statusById, ledger, runId);
671
761
  if (ordered.length === 0) {
672
762
  throw new HarnessUnavailableError(`no harness remains eligible for '${intent}' after budget and quota routing`);
673
763
  }
674
764
  emitPrimaryDivergence(log, input.primaryHarness, ordered, pool, dropped);
675
765
  const n = input.n ?? ordered.length;
676
766
  const out = [];
677
- for (let i = 0; i < n; i++)
678
- out.push(ordered[i % ordered.length]);
767
+ if (droppedLanes.length > 0 && !allowDuplicateFill) {
768
+ // QA-043: lanes were dropped from an AUTO best-of pool (an explicit pool
769
+ // would have thrown at the drop). NEVER refill a dropped lane's slot by
770
+ // duplicating a surviving harness — that manufactures a self-race that
771
+ // masks the omission. Clamp to distinct survivors and disclose below.
772
+ // (Deep-scan sets allowDuplicateFill: its width is scout coverage, not
773
+ // harness diversity, so a dropped lane must not cut the scout count.)
774
+ for (let i = 0; i < Math.min(n, ordered.length); i++)
775
+ out.push(ordered[i]);
776
+ }
777
+ else {
778
+ // No lane was dropped: a pool smaller than `n` is an intentional
779
+ // best-of-N on the available harness(es) (e.g. explicit `--harness codex
780
+ // -n 3`), so the historical width fill is preserved.
781
+ for (let i = 0; i < n; i++)
782
+ out.push(ordered[i % ordered.length]);
783
+ }
784
+ // Disclose an auto-pool omission / width clamp once, with the
785
+ // requested-vs-effective route receipt (never silent — QA-043).
786
+ emitPoolDegraded(log, {
787
+ requestedHarnesses: ids,
788
+ effectiveHarnesses: [...new Set(out.map((lane) => lane.adapter.id))],
789
+ requestedN: n,
790
+ effectiveN: out.length,
791
+ droppedLanes,
792
+ });
679
793
  this.requestRequirements.requireEffectiveBrowser(input.browser === true, out.map((lane) => lane.browserRequirement));
680
794
  // Delegation belt (D32): agent-only, and only on a lane whose adapter can
681
795
  // inject MCP servers. A requested delegate with NO injecting lane is a typed
@@ -730,7 +844,7 @@ export class Orchestrator {
730
844
  * explicit user pool keeps the user's order; an explicit primary harness is
731
845
  * always pinned first. Cross-family diversity is encouraged for later slots.
732
846
  */
733
- orderPool(pool, input, intent, statusById, ledger) {
847
+ orderPool(pool, input, intent, statusById, ledger, runId) {
734
848
  let ordered = pool;
735
849
  if (pool.length > 0) {
736
850
  const routeLedger = ledger ?? new BudgetLedger();
@@ -740,7 +854,8 @@ export class Orchestrator {
740
854
  // Settled cost is evidence for economy routing, never a provider quality prior.
741
855
  const metrics = loadHarnessMetrics(globalConfigDir());
742
856
  const remaining = pool.map((r) => {
743
- const authModes = statusById.get(r.adapter.id)?.manifest?.auth_modes ?? [];
857
+ const status = statusById.get(r.adapter.id);
858
+ const authModes = status?.manifest?.auth_modes ?? [];
744
859
  const metric = metrics[r.adapter.id];
745
860
  // Auth mode for routing: prefer the ROUTE EVIDENCE from the
746
861
  // last settled attempt (adapter-disclosed, persisted in metrics) over
@@ -773,13 +888,23 @@ export class Orchestrator {
773
888
  catch {
774
889
  credentialSubjectId = undefined;
775
890
  }
891
+ // QA-034: the typed auth-route evidence (doctor source verification x the
892
+ // resolved route) is AUTHORITATIVE for billing knowledge in the router —
893
+ // a VERIFIED native route proves subscription_entitlement, so it survives
894
+ // paid_fallback:never and ranks with a real economy tuple instead of
895
+ // reading as unknown/paid. Absent (unknown route) falls back to the
896
+ // metric-derived billingKnowledge below.
897
+ const authRoute = this.authRouteEvidenceFor(authMode, status?.authSources ?? []);
776
898
  return {
777
899
  harnessId: r.adapter.id,
778
900
  available: true,
779
901
  model: input.models?.[r.adapter.id] ??
780
902
  config.harnesses[r.adapter.id]?.default_model ??
781
903
  undefined,
782
- effort: input.effort ?? config.harnesses[r.adapter.id]?.effort ?? undefined,
904
+ effort: input.efforts?.[r.adapter.id] ??
905
+ input.effort ??
906
+ config.harnesses[r.adapter.id]?.effort ??
907
+ undefined,
783
908
  billingKnowledge: authMode === "api_key" ? "metered" : "unknown",
784
909
  incrementalCostUsd: authMode === "api_key" ? (metric?.avg_cost_usd ?? null) : null,
785
910
  credentialRoute: authMode === "api_key"
@@ -787,18 +912,25 @@ export class Orchestrator {
787
912
  : authMode === "local_session"
788
913
  ? "vendor_native"
789
914
  : undefined,
915
+ ...(authRoute ? { authRoute } : {}),
790
916
  credentialSubjectId,
791
917
  };
792
918
  });
793
- const ranked = rankHarnesses(remaining, {
919
+ const routeCtx = {
794
920
  goal,
795
921
  paidFallback: config.routing.paid_fallback,
796
922
  intent,
797
923
  qualityTiers: config.routing.quality_tiers,
798
924
  ledger: routeLedger,
799
- })
925
+ now: Date.now(), // ONE instant for the sort AND the rationale below
926
+ };
927
+ const ranked = rankHarnesses(remaining, routeCtx)
800
928
  .map((candidate) => byId.get(candidate.harnessId))
801
929
  .filter((candidate) => Boolean(candidate));
930
+ // QA-034: the rationale is run evidence recorded ONCE at pool ordering,
931
+ // pinned to routeCtx.now so it cannot disagree with the order just taken.
932
+ if (runId)
933
+ this.routingRationaleByRun.set(runId, explainRanking(remaining, routeCtx));
802
934
  ordered = ranked;
803
935
  }
804
936
  if (input.primaryHarness) {
@@ -808,6 +940,30 @@ export class Orchestrator {
808
940
  }
809
941
  return ordered;
810
942
  }
943
+ /**
944
+ * Typed auth-route evidence for one candidate (QA-034): the concrete
945
+ * credential route the resolved auth mode maps to, plus the doctor's
946
+ * verification for the source that route runs under. `local_session` →
947
+ * vendor_native + the native/OAuth source verification; `api_key` →
948
+ * managed_api_key + the key source verification. Unknown route → no evidence
949
+ * (the router keeps its conservative metric-derived billing). Verification is
950
+ * the source's typed verdict — never inferred from mere availability.
951
+ */
952
+ authRouteEvidenceFor(authMode, sources) {
953
+ const usable = (s) => s.availability === "available" && s.verification !== "failed";
954
+ if (authMode === "local_session") {
955
+ const native = sources.find((s) => usable(s) && (s.source === "native_session" || s.source === "oauth_token_env"));
956
+ return { route: "vendor_native", verification: native?.verification ?? "not_run" };
957
+ }
958
+ if (authMode === "api_key") {
959
+ const key = sources.find((s) => usable(s) &&
960
+ (s.source === "api_key_env" ||
961
+ s.source === "api_key_flag" ||
962
+ s.source === "provider_auth_file"));
963
+ return { route: "managed_api_key", verification: key?.verification ?? "not_run" };
964
+ }
965
+ return undefined;
966
+ }
811
967
  /**
812
968
  * Lazy ContextPack: built ONLY for the read-only report modes
813
969
  * (explore/plan/readonly_audit) that consume it. Persisted to
@@ -916,7 +1072,7 @@ export class Orchestrator {
916
1072
  projectCommands: cfg.tests?.commands ?? [],
917
1073
  });
918
1074
  const commands = resolvedGates.commands;
919
- const protectedPaths = [];
1075
+ const protectedPaths = [...new Set(cfg.constraints.protected_paths)];
920
1076
  const autoProtectedPaths = resolvedGates.autoProtectedPaths;
921
1077
  const protectedPathApprovals = [
922
1078
  ...new Map([...(input.protectedPathApprovals ?? [])].map((approval) => [approval.path, approval])).values(),
@@ -973,6 +1129,22 @@ export class Orchestrator {
973
1129
  // primary by resolveRunInput). The contract is what route spec building
974
1130
  // reads — there is no run-global model (INV-103).
975
1131
  routing_models: input.models ?? {},
1132
+ // QA-035: freeze the RESOLVED reasoning-effort per known lane so Exact
1133
+ // Retry replays it instead of re-resolving current settings. Precedence
1134
+ // (specific beats general): the harness-scoped `efforts` map entry, then a
1135
+ // per-turn scalar `input.effort`, then the harness settings default — the
1136
+ // same map that Exact Retry replays so a NON-PRIMARY lane keeps its own
1137
+ // frozen effort (QA-035 completeness). Only known-pool lanes are frozen
1138
+ // here (a pure auto pool's lanes resolve later — documented seam).
1139
+ routing_efforts: Object.fromEntries([...new Set([...(input.harnesses ?? []), ...Object.keys(input.efforts ?? {})])]
1140
+ .map((hid) => [
1141
+ hid,
1142
+ input.efforts?.[hid] ??
1143
+ input.effort ??
1144
+ resolvedCfg.global.harnesses?.[hid]?.effort ??
1145
+ null,
1146
+ ])
1147
+ .filter((entry) => entry[1] !== null)),
976
1148
  });
977
1149
  }
978
1150
  /**
@@ -1044,6 +1216,41 @@ export class Orchestrator {
1044
1216
  : {}),
1045
1217
  };
1046
1218
  }
1219
+ /**
1220
+ * D-16: the WorkReport transport envelope for one route. Called at every
1221
+ * task-producing spec-build site AFTER harnessSpecKnobs so it OVERRIDES the
1222
+ * plain caller-schema transport with the compiled `{work_report, output}`
1223
+ * envelope on capable routes. The returned `mode` is retained by the caller
1224
+ * and handed to `unwrapWorkReportEnvelope` when the answer is finalized.
1225
+ */
1226
+ workReportEnvelopeFor(routed, contract, interactive) {
1227
+ return resolveWorkReportEnvelope({
1228
+ transport: routed.workReportTransport,
1229
+ channel: routed.structuredOutputChannel,
1230
+ supportsJsonSchemaOutput: routed.supportsJsonSchemaOutput,
1231
+ interactive,
1232
+ callerSchema: contract.output_schema ?? null,
1233
+ });
1234
+ }
1235
+ /**
1236
+ * D-16: apply the resolved WorkReport transport to a built spec — set the
1237
+ * envelope output_schema (constrained/side_tool routes) and APPEND the fenced
1238
+ * envelope instruction (validated routes, e.g. cursor). Mutates the spec in
1239
+ * place and returns the mode the answer unwrap consumes. Called at every
1240
+ * task-producing spec-build site so the transport is never wired one-off.
1241
+ */
1242
+ applyWorkEnvelope(spec, workEnvelope) {
1243
+ if (workEnvelope.outputSchema !== undefined)
1244
+ spec.output_schema = workEnvelope.outputSchema;
1245
+ const instruction = workEnvelope.mode.instruction;
1246
+ if (instruction) {
1247
+ spec.instructions =
1248
+ spec.instructions && spec.instructions.trim()
1249
+ ? `${spec.instructions}\n\n${instruction}`
1250
+ : instruction;
1251
+ }
1252
+ return workEnvelope.mode;
1253
+ }
1047
1254
  routeSpecKnobs(routed, contract, overrideModel, effortHint) {
1048
1255
  const s = routed.settings;
1049
1256
  const contractPolicy = contract.external_context.policy;
@@ -1076,13 +1283,16 @@ export class Orchestrator {
1076
1283
  // per-harness map, which beats the per-harness settings default. There is
1077
1284
  // no run-global model.
1078
1285
  const model = overrideModel ?? contract.routing_models[routed.adapter.id] ?? s?.defaultModel ?? null;
1079
- // Effort disclosure (INV-105): a requested effort on a harness with no
1080
- // declared ladder is DISCLOSED as ignored, never silently dropped.
1081
- let effort = effortHint ?? s?.effort ?? null;
1082
- if (effort && routed.effortLevels.length === 0) {
1083
- ignored.push(`effort=${effort} (manifest capabilities.effort_levels is empty for ${routed.adapter.id})`);
1084
- effort = null;
1085
- }
1286
+ // Effort disclosure (INV-105) against the harness's advertised ladder. This
1287
+ // gate only DISCLOSES an unplaceable level; the clamp belongs to the adapter,
1288
+ // which resolves against the catalog for the profile env the child runs in
1289
+ // (the manifest here is the DEFAULT account's — see effortGovernance.ts). The
1290
+ // contract's FROZEN per-lane effort (QA-035) wins so Exact Retry replays it
1291
+ // without re-reading settings; `effortHint`/settings apply only to an unfrozen lane.
1292
+ const governed = governRouteEffort(contract.routing_efforts[routed.adapter.id] ?? effortHint ?? s?.effort ?? null, { id: routed.adapter.id, ...routed });
1293
+ const effort = governed.effort;
1294
+ if (governed.ignored)
1295
+ ignored.push(governed.ignored);
1086
1296
  return {
1087
1297
  model,
1088
1298
  effort,
@@ -1198,7 +1408,11 @@ export class Orchestrator {
1198
1408
  return { pointerLine: null };
1199
1409
  }
1200
1410
  }
1201
- async runCandidateInEnvelope(routed, envelope, attemptId, label, contract, prompt, store, paths, wsm, ledger, access = "workspace_write", onHarnessEvent, signal, modelHint, effortHint, intent = "implement", log, effectiveWebMode, interaction, budgetGuard, runInput, streamDeltas = false, fileBackedContext) {
1411
+ async runCandidateInEnvelope(routed, envelope, attemptId, label, contract, prompt, store, paths, wsm, ledger, access = "workspace_write", onHarnessEvent, signal, modelHint, effortHint, intent = "implement", log, effectiveWebMode, interaction, budgetGuard, runInput, streamDeltas = false, fileBackedContext,
1412
+ /** D-16d: when set, the mechanical continuation checkpoint pointer for a
1413
+ * one-shot fresh-session continuation — appended to the prompt so the model
1414
+ * (and the offline fake) re-grounds in the exhausted attempt's partial work. */
1415
+ continuationPointer) {
1202
1416
  const adapter = routed.adapter;
1203
1417
  const knobs = this.routeSpecKnobs(routed, contract, modelHint, effortHint);
1204
1418
  // Isolated scoped-home sessions are never retained after disposal.
@@ -1212,13 +1426,14 @@ export class Orchestrator {
1212
1426
  const laneContinuity = runInput
1213
1427
  ? await this.resolveContinuity(runInput, adapter.id, sessionFields?.credential_profile?.profile_id ?? runInput.credentialProfileId ?? null, inPlaceEnvelope && !!sessionFields?.resume_session_id, store, paths, envelope.repo_root, log)
1214
1428
  : null;
1215
- const promptWithContinuity = laneContinuity?.pointerLine
1216
- ? `${prompt}\n\n${laneContinuity.pointerLine}`
1217
- : prompt;
1218
1429
  let spec = HarnessRunSpec.parse({
1219
1430
  session_id: newId("ses"),
1220
1431
  intent,
1221
- prompt: promptWithProtectedPathConstraint(promptWithContinuity, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals),
1432
+ // Engine-derived read-only prompt constraints: protected/auto-protected
1433
+ // paths PLUS the exact typed gate argv the run will execute (QA-022 FIX B).
1434
+ prompt: promptWithEngineConstraints([prompt, laneContinuity?.pointerLine, continuationPointer]
1435
+ .filter((s) => typeof s === "string" && s.length > 0)
1436
+ .join("\n\n"), contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals, contract.tests.commands),
1222
1437
  attachments: runInput?.attachments ?? [],
1223
1438
  browser: this.requestRequirements.browserSpec(routed.browserRequirement,
1224
1439
  // F4: browser-MCP screenshots land in the claudexor-owned
@@ -1246,6 +1461,10 @@ export class Orchestrator {
1246
1461
  });
1247
1462
  if (interaction)
1248
1463
  spec.extra["interactionChannel"] = interaction;
1464
+ // D-16: compile the WorkReport envelope onto the spec (overriding the plain
1465
+ // caller-schema transport) and keep the mode for the answer unwrap.
1466
+ const workEnvelope = this.workReportEnvelopeFor(routed, contract, Boolean(interaction));
1467
+ const workReportMode = this.applyWorkEnvelope(spec, workEnvelope);
1249
1468
  const inactivityMs = harnessInactivityTimeoutMs(this.config(contract.repo.root));
1250
1469
  const attemptStartedMs = Date.now();
1251
1470
  const budgetSignalState = { quotaPressureDisclosed: false };
@@ -1256,12 +1475,25 @@ export class Orchestrator {
1256
1475
  // W-C4 delta flood budget (per attempt): counts forwarded delta chunks.
1257
1476
  let deltaCount = 0;
1258
1477
  let deltaCutoffDisclosed = false;
1478
+ // QA-024: emit the belt-failure disclosure event at most once per attempt.
1479
+ let beltFailureDisclosed = false;
1259
1480
  const errors = [];
1260
1481
  const answer = new AnswerAssembly();
1261
1482
  const retryPolicy = transientRetryPolicy(this.config(contract.repo.root));
1483
+ // QA-024: the delegation belt is the ONLY engine-owned extra MCP server
1484
+ // injected into an agent lane (the browser MCP rides its own field), so its
1485
+ // presence in the spec marks the belt requested-and-injected for THIS
1486
+ // attempt. A mixed pool leaves it off lanes that cannot host it, so this is
1487
+ // per-attempt truth, not the run-wide --delegate flag.
1488
+ const beltServerName = spec.extra_mcp_servers?.[0]?.name ?? null;
1489
+ // QA-040: the browser MCP is injected under the fixed `browser` namespace
1490
+ // (codex `mcp_servers.browser.*`, claude `mcp__browser__*`). Its presence in
1491
+ // the spec marks the browser armed for THIS attempt — the telemetry fold
1492
+ // then recognizes browser tool calls as trusted live-web evidence.
1493
+ const browserServerName = spec.browser ? "browser" : null;
1262
1494
  const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
1263
1495
  knobs.webPolicy === "cached" ||
1264
- knobs.webPolicy === "live", effectiveWebMode ?? knobs.webPolicy, [routed.browserRequirement, routed.denyRequirement], knobs.model);
1496
+ knobs.webPolicy === "live", effectiveWebMode ?? knobs.webPolicy, [routed.browserRequirement, routed.denyRequirement], knobs.model, beltServerName, browserServerName);
1265
1497
  let activeSessionId = spec.session_id;
1266
1498
  const onAbort = () => {
1267
1499
  void adapter.cancel?.(activeSessionId)?.catch(() => { });
@@ -1341,6 +1573,23 @@ export class Orchestrator {
1341
1573
  observeNativeSessionEvent(runInput, adapter.id, safeEv);
1342
1574
  observeAuthSwitch(log, adapter.id, attemptId, safeEv);
1343
1575
  observeAttemptTelemetry(telemetry, safeEv);
1576
+ // QA-024: the injected delegation belt's MCP server reported `failed`
1577
+ // to start. Disclose it ONCE as a typed run event the moment the
1578
+ // `started` frame reveals it — the harness is about to run without
1579
+ // `mcp__<belt>__*` tools and may degrade to its own native subagent.
1580
+ // The terminal outcome axis (delegationBeltUnavailable) reflects it
1581
+ // too; this event makes the failure visible while the run is live.
1582
+ if (telemetry.delegationBelt.requested &&
1583
+ telemetry.delegationBelt.failed &&
1584
+ !beltFailureDisclosed) {
1585
+ beltFailureDisclosed = true;
1586
+ log?.emit("delegation.belt.unavailable", {
1587
+ attempt_id: attemptId,
1588
+ harness_id: adapter.id,
1589
+ server_name: telemetry.delegationBelt.serverName,
1590
+ reason: "mcp_server_failed_to_start",
1591
+ });
1592
+ }
1344
1593
  // Live plan checklist: forward the adapter's typed plan
1345
1594
  // progress as a run event (LAST WINS; the UI renders the latest).
1346
1595
  if (safeEv.plan_progress) {
@@ -1393,15 +1642,21 @@ export class Orchestrator {
1393
1642
  }
1394
1643
  catch (err) {
1395
1644
  // A throwing adapter must not lose the cost already streamed: record the
1396
- // error here and let the caller settle the REAL accumulated spend.
1645
+ // error here and let the caller settle the REAL accumulated spend. #31:
1646
+ // classify the throw (watchdog timeout vs process crash) so the retry
1647
+ // gate and required-actions read a typed category, not a bare boolean.
1397
1648
  harnessErrored = true;
1398
1649
  errors.push(safeErrorMessage(err));
1650
+ telemetry.transientFailures.push(classifyAdapterThrow({ errorName: err instanceof Error ? err.name : null }));
1399
1651
  }
1400
1652
  finally {
1401
1653
  clearFileBackedContext();
1402
1654
  }
1403
- const transient = telemetry.transientFailures.at(-1) ?? null;
1404
- const sawTransient = telemetry.transientFailures.length > transientStart;
1655
+ const newTransients = telemetry.transientFailures.slice(transientStart);
1656
+ const transient = newTransients.at(-1) ?? null;
1657
+ // #31: the centralized retry gate reads the classified `retryable`, not a
1658
+ // bare "saw any transient" boolean.
1659
+ const sawRetryable = newTransients.some((f) => f.retryable);
1405
1660
  const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
1406
1661
  const currentDiff = await wsm.diff(envelope);
1407
1662
  const currentAnswer = answer.text();
@@ -1432,7 +1687,7 @@ export class Orchestrator {
1432
1687
  }
1433
1688
  }
1434
1689
  if (!harnessErrored ||
1435
- !sawTransient ||
1690
+ !sawRetryable ||
1436
1691
  !deliverableEmpty ||
1437
1692
  nativeTry >= retryPolicy.maxRetries ||
1438
1693
  signal?.aborted)
@@ -1443,6 +1698,7 @@ export class Orchestrator {
1443
1698
  harness_id: adapter.id,
1444
1699
  attempt_id: attemptId,
1445
1700
  kind: transient?.kind ?? "unknown",
1701
+ category: transient?.category ?? "unknown_harness_error",
1446
1702
  native_try: nativeTry + 1,
1447
1703
  });
1448
1704
  log?.emit("route.transient.retry_scheduled", {
@@ -1463,16 +1719,23 @@ export class Orchestrator {
1463
1719
  log?.emit("route.transient.exhausted", {
1464
1720
  harness_id: adapter.id,
1465
1721
  attempt_id: attemptId,
1722
+ category: telemetry.transientFailures.at(-1)?.category ?? "unknown_harness_error",
1466
1723
  retries: retryPolicy.maxRetries,
1467
1724
  });
1468
1725
  }
1469
1726
  const attemptStreamEndedMs = Date.now();
1470
1727
  if (webUnsatisfied(telemetry)) {
1471
- errors.push(`web evidence unsatisfied: ${telemetry.web.errorSummary ?? (telemetry.web.attempted ? "web tool failed without verified recovery" : "web evidence required but never attempted")}`);
1728
+ errors.push(webEvidenceFailure(telemetry.web));
1472
1729
  }
1473
1730
  const diff = await wsm.diff(envelope);
1474
- const answerText = answer.text() || undefined;
1475
- const deliverablePresent = diff.trim().length > 0 || Boolean(answerText);
1731
+ // D-16: un-nest {work_report, output} so answer.md persists the OUTPUT, not the envelope.
1732
+ const unwrapped = unwrapWorkReportEnvelope(answer.machineText() ?? "", workReportMode, {
1733
+ sideToolReport: telemetry.sideToolWorkReport ?? undefined,
1734
+ });
1735
+ // X119: persist the VERBATIM redacted bytes; trim ONLY for the emptiness check.
1736
+ const redacted = redactSecrets(unwrapped.deliverable);
1737
+ const answerText = redacted.trim().length > 0 ? redacted : undefined;
1738
+ const deliverableEvidence = diff.trim().length > 0 || Boolean(answerText);
1476
1739
  // Cancelled attempts skip gates entirely: the operator asked to
1477
1740
  // stop NOW; running a 600s-per-gate suite after the abort delays the ack
1478
1741
  // and burns compute on a result nobody will adopt. Diff/attempt.yaml
@@ -1507,12 +1770,28 @@ export class Orchestrator {
1507
1770
  });
1508
1771
  }
1509
1772
  const webBlocked = webUnsatisfied(telemetry);
1773
+ // D-16 unified finalizer: fold the WorkReport / context signals into the
1774
+ // deliverable + work_state. A broken contract on a constrained route
1775
+ // elevates harnessErrored (never a prose success).
1776
+ const finalized = finalizeAttempt({
1777
+ deliverableEvidence,
1778
+ harnessErrored,
1779
+ workReport: unwrapped.workReport,
1780
+ workReportSource: unwrapped.source,
1781
+ workReportViolation: unwrapped.contractViolation,
1782
+ contextTerminalExhausted: telemetry.contextExhausted,
1783
+ });
1784
+ harnessErrored = finalized.harnessErrored;
1785
+ if (finalized.outcomeClass === "contract_failure" && unwrapped.contractViolation)
1786
+ errors.push(`work_report contract: ${unwrapped.contractViolation}`);
1787
+ const deliverablePresent = finalized.deliverablePresent;
1510
1788
  const errored = harnessErrored || webBlocked;
1511
1789
  setAttemptOutcome(telemetry, {
1512
1790
  deliverablePresent,
1513
1791
  gatesPassed: gates.length > 0 ? gatesPassed(gates) : null,
1514
1792
  harnessErrored,
1515
1793
  webRequiredUnsatisfied: webBlocked,
1794
+ workState: finalized.workState,
1516
1795
  });
1517
1796
  const attemptDir = join(paths.attemptsDir, attemptId);
1518
1797
  try {
@@ -1565,6 +1844,7 @@ export class Orchestrator {
1565
1844
  costEstimated,
1566
1845
  errors: errors.slice(0, 8),
1567
1846
  telemetry,
1847
+ outcomeClass: finalized.outcomeClass,
1568
1848
  };
1569
1849
  }
1570
1850
  interactionChannelFor(input, log, runId, taskId, attemptId, harnessId,
@@ -1624,6 +1904,40 @@ export class Orchestrator {
1624
1904
  return message;
1625
1905
  }
1626
1906
  }
1907
+ /**
1908
+ * D-14 layer 3 (AGENTS.md unification, INV-113): the ONE new live-tree write.
1909
+ * When the PROJECT root has `AGENTS.md` and no `CLAUDE.md`, drop a thin
1910
+ * `CLAUDE.md` (`@AGENTS.md` import + Claudexor ownership marker) so a Claude
1911
+ * Code route reads the same instruction file codex/cursor read natively.
1912
+ *
1913
+ * Fenced exactly where the automatic git-init boundary is: read-only modes
1914
+ * never reach this run-prep stage; `--in-place` stateful targets are left
1915
+ * untouched; the write targets the PROJECT root (`repoRoot`), never a worktree
1916
+ * envelope. The workspace helper adds exclusive-create + no-follow +
1917
+ * idempotency, so a hand-written or symlinked `CLAUDE.md` is never overwritten
1918
+ * and a concurrent/second prep is a no-op. Announced via a typed
1919
+ * `project.claude_bridge.created` event on an actual create only — the git-init
1920
+ * pattern. A bridge is a convenience, not a precondition: any failure is
1921
+ * swallowed so it can never fail an otherwise-valid write run.
1922
+ */
1923
+ ensureClaudeBridgeForRun(repoRoot, inPlace, log) {
1924
+ if (repoRoot === NO_PROJECT_ROOT || inPlace)
1925
+ return;
1926
+ let result;
1927
+ try {
1928
+ result = ensureClaudeBridge(repoRoot);
1929
+ }
1930
+ catch {
1931
+ return;
1932
+ }
1933
+ if (result.created) {
1934
+ log.emit("project.claude_bridge.created", {
1935
+ project_root: repoRoot,
1936
+ path: "CLAUDE.md",
1937
+ source: "AGENTS.md",
1938
+ });
1939
+ }
1940
+ }
1627
1941
  /**
1628
1942
  * Freeze-on-implement delivery (D17/D27): verify the frozen plan's hash and
1629
1943
  * materialize it as context/PLAN.md in the run artifact tree — OUTSIDE every
@@ -1707,6 +2021,9 @@ export class Orchestrator {
1707
2021
  candidates: [],
1708
2022
  };
1709
2023
  }
2024
+ // Same run-prep stage as the git boundary: if the PROJECT root uses AGENTS.md
2025
+ // with no CLAUDE.md, bridge it so a Claude Code candidate reads it (INV-113).
2026
+ this.ensureClaudeBridgeForRun(input.repoRoot, input.inPlace === true, log);
1710
2027
  // Pre-turn snapshot of the live tree for in-place runs: the revert restore
1711
2028
  // target (server-owned revertInPlace). A snapshot failure must never fail the
1712
2029
  // run — revert is simply unavailable then.
@@ -1730,17 +2047,23 @@ export class Orchestrator {
1730
2047
  });
1731
2048
  let adapters;
1732
2049
  try {
1733
- // Best-of races the whole pool (no divergence `log`: the pool still runs).
1734
- adapters = await this.resolveCandidateAdapters(input, this.candidateIntent(input), ledger);
2050
+ // Best-of races the whole pool. The `log` is passed so an AUTO pool that
2051
+ // drops a lane / clamps width discloses `route.pool.degraded` (QA-043)
2052
+ // the resolver never refills a dropped slot with a duplicate harness.
2053
+ adapters = await this.resolveCandidateAdapters(input, this.candidateIntent(input), ledger, log, undefined, runId);
1735
2054
  }
1736
2055
  catch (err) {
1737
2056
  const message = safeErrorMessage(err);
1738
2057
  store.writeText(join(paths.contextDir, "context_error.md"), `# Routing Error\n\n${message}\n`);
2058
+ // A routing preflight refusal is a config_error, not harness_unavailable
2059
+ // (A-1/#22): classify identically across every strategy's routing catch.
2060
+ const routingFailure = routingFailureClassification(err);
1739
2061
  writeFailure(store, paths, {
1740
2062
  phase: "routing",
1741
- category: "harness_unavailable",
2063
+ category: routingFailure.category,
1742
2064
  safeMessage: message,
1743
2065
  runDir: paths.root,
2066
+ ...(routingFailure.nextActions ? { nextActions: routingFailure.nextActions } : {}),
1744
2067
  });
1745
2068
  store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
1746
2069
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
@@ -1777,6 +2100,9 @@ export class Orchestrator {
1777
2100
  };
1778
2101
  const candidateAccess = contract.access.effective_profile;
1779
2102
  let budgetStopped = false;
2103
+ // QA-050: keep the ledger's typed denial so the zero-candidate terminal
2104
+ // emits budget remediation (not an empty/auth action list).
2105
+ let budgetDenial = null;
1780
2106
  let softWarned = false;
1781
2107
  const requestedSingleCandidate = adapters.length === 1;
1782
2108
  const slots = [];
@@ -1801,6 +2127,12 @@ export class Orchestrator {
1801
2127
  // already granted; only a tripped hard cap stops everything.
1802
2128
  if (lease.denied !== "estimate_headroom")
1803
2129
  budgetStopped = true;
2130
+ budgetDenial ??= {
2131
+ code: lease.denied ?? "hard_cap",
2132
+ reason: lease.reason ?? "budget lease denied",
2133
+ harnessId: routed.adapter.id,
2134
+ attemptId,
2135
+ };
1804
2136
  break; // do not spawn more paid work
1805
2137
  }
1806
2138
  slots.push({
@@ -1811,6 +2143,11 @@ export class Orchestrator {
1811
2143
  });
1812
2144
  }
1813
2145
  const runsBySlot = new Array(slots.length);
2146
+ // D-16d: one-shot continuation budget shared across the concurrent candidate
2147
+ // slots (parity with the read-only chain's single counter). Claimed
2148
+ // synchronously (check-then-increment with no await between), so two slots
2149
+ // that both exhaust cannot both consume the single continuation.
2150
+ let candidateContinuationCount = 0;
1814
2151
  const runSlot = async (slot, slotIdx) => {
1815
2152
  if (input.signal?.aborted) {
1816
2153
  ledger.cancel(slot.leaseId);
@@ -1891,11 +2228,109 @@ export class Orchestrator {
1891
2228
  log.emit("harness.completed", {
1892
2229
  harness_id: adapter.id,
1893
2230
  attempt_id: slot.attemptId,
1894
- status: run.errored ? "failed" : "success",
2231
+ // QA-027: never claim `success` over an attempt the operator/deadline
2232
+ // cut short. An abort makes the run non-successful; the top-level
2233
+ // status axis must say `cancelled` (the nested outcome axis already
2234
+ // rides in telemetrySummary), not launder a torn-off stream as clean.
2235
+ status: input.signal?.aborted ? "cancelled" : run.errored ? "failed" : "success",
1895
2236
  cost_usd: run.cost,
1896
2237
  ...telemetrySummary(run.telemetry),
1897
2238
  });
1898
- runsBySlot[slotIdx] = run;
2239
+ // D-16d one-shot continuation for an ENVELOPED candidate (parity with the
2240
+ // read-only loop, which had the ONLY continuation wiring). An eligible
2241
+ // terminal context exhaustion (repeated_refill, no completed report) gets
2242
+ // ONE fresh-session re-run in the SAME envelope, re-grounded by a
2243
+ // mechanical checkpoint packet; the exhausted candidate is superseded ONLY
2244
+ // after the continuation completes. In-place candidates are excluded (a
2245
+ // fresh session cannot safely resume mutation of the live tree).
2246
+ let effectiveRun = run;
2247
+ const envInPlace = envelope.worktree_path === envelope.repo_root;
2248
+ if (!run.errored && !input.signal?.aborted && candidateContinuationCount === 0) {
2249
+ const contDecision = decideContinuation({
2250
+ contextExhausted: run.telemetry.contextExhausted,
2251
+ contextExhaustedCause: run.telemetry.contextExhaustedCause,
2252
+ workStateCompleted: run.telemetry.outcome?.workState?.state === "completed",
2253
+ continuationCount: candidateContinuationCount,
2254
+ runKind: envInPlace ? "in_place" : "enveloped",
2255
+ });
2256
+ if (contDecision.eligible) {
2257
+ const contAttemptId = `${slot.attemptId}c`;
2258
+ const packet = buildContinuationPacket(synthesizeContinuationRequest({
2259
+ harness: adapter.id,
2260
+ profileId: input.credentialProfileId ?? null,
2261
+ priorPrompt: input.prompt,
2262
+ priorOutput: run.answerText ?? run.diff ?? "",
2263
+ }));
2264
+ // Reserve the continuation lease BEFORE any disclosure: a denied lease
2265
+ // must never emit run.continuation (which claims a continuation
2266
+ // launched) and must not consume the one-shot with no attempt. Grant ->
2267
+ // claim + disclose + run; refusal -> typed run.continuation.denied.
2268
+ const contLease = ledger.reserve({
2269
+ taskId,
2270
+ attemptId: contAttemptId,
2271
+ intent: this.candidateIntent(input),
2272
+ harnessId: adapter.id,
2273
+ cost: attemptCostEvidence(adapter.id, contAttemptId, this.estimateUsdFloor(input.repoRoot), this.routeBillingKnowledge(input, adapter.id)),
2274
+ });
2275
+ if (contLease.granted) {
2276
+ candidateContinuationCount += 1; // claim the one-shot only once it launches
2277
+ log.emit("run.continuation", {
2278
+ from_attempt: run.attemptId,
2279
+ cause: run.telemetry.contextExhaustedCause,
2280
+ continuation_count: candidateContinuationCount,
2281
+ packet_turns: packet.continuity.disclosure.packetTurns,
2282
+ });
2283
+ const contLeaseId = contLease.lease?.lease_id ?? "";
2284
+ try {
2285
+ const contRun = await this.runCandidateInEnvelope(slot.routed, envelope, contAttemptId, slot.label, contract, input.prompt, store, paths, wsm, ledger, candidateAccess, (ev) => {
2286
+ const safeEv = redactHarnessEvent(ev);
2287
+ safeInvoke(input.onHarnessEvent, safeEv);
2288
+ log.emit("harness.event", harnessEventPayload(adapter.id, contAttemptId, safeEv));
2289
+ }, input.signal, downgradeModel ?? undefined, input.effort, this.candidateIntent(input), log, effectiveWeb, this.interactionChannelFor(input, log, runId, taskId, contAttemptId, adapter.id, slot.routed.supportsInteractive), (streamedUsd) => {
2290
+ ledger.updateHold(contLeaseId, streamedUsd);
2291
+ if (ledger.tier() !== "hard")
2292
+ return false;
2293
+ budgetStopped = true;
2294
+ return true;
2295
+ }, input, requestedSingleCandidate, undefined, packet.pointerLine ?? undefined);
2296
+ ledger.settle(contLeaseId, attemptUsageCostSettlement(contRun.cost, contRun.costEstimated, contRun.attemptId, contRun.harnessId, contRun.telemetry.authMode, contRun.telemetry.usageCost));
2297
+ log.emit("harness.completed", {
2298
+ harness_id: adapter.id,
2299
+ attempt_id: contAttemptId,
2300
+ status: input.signal?.aborted
2301
+ ? "cancelled"
2302
+ : contRun.errored
2303
+ ? "failed"
2304
+ : "success",
2305
+ cost_usd: contRun.cost,
2306
+ ...telemetrySummary(contRun.telemetry),
2307
+ });
2308
+ // Supersede the exhausted candidate ONLY after the continuation
2309
+ // actually completes cleanly (never over a torn-off/aborted stream).
2310
+ if (!contRun.errored && !input.signal?.aborted)
2311
+ effectiveRun = contRun;
2312
+ }
2313
+ catch (err) {
2314
+ ledger.settle(contLeaseId, unknownCostSettlement("continuation-error", 0));
2315
+ log.emit("harness.completed", {
2316
+ harness_id: adapter.id,
2317
+ attempt_id: contAttemptId,
2318
+ status: "failed",
2319
+ error: safeErrorMessage(err),
2320
+ });
2321
+ }
2322
+ }
2323
+ else {
2324
+ ledger.cancel(contLease.lease?.lease_id ?? "");
2325
+ log.emit("run.continuation.denied", {
2326
+ from_attempt: run.attemptId,
2327
+ cause: run.telemetry.contextExhaustedCause,
2328
+ reason: contLease.reason ?? contLease.denied ?? "budget lease denied",
2329
+ });
2330
+ }
2331
+ }
2332
+ }
2333
+ runsBySlot[slotIdx] = effectiveRun;
1899
2334
  reviewEnvelopes.push(envelope);
1900
2335
  envelope = undefined;
1901
2336
  }
@@ -1972,42 +2407,54 @@ export class Orchestrator {
1972
2407
  }
1973
2408
  if (input.signal?.aborted) {
1974
2409
  await disposeReviewEnvelopes();
1975
- return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
1976
- attemptId: r.attemptId,
1977
- harnessId: r.harnessId,
1978
- telemetry: r.telemetry,
1979
- })), null), ledger.spend(), input.signal, store);
2410
+ return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), null), ledger.spend(), input.signal, store);
1980
2411
  }
1981
2412
  if (runs.length === 0) {
1982
2413
  const budgetReason = ledger.terminal();
2414
+ // QA-050: when the zero-candidate cause is a budget refusal, the shared
2415
+ // classifier owns the typed code, the refused route/slot, and actionable
2416
+ // budget remediation (previously an empty nextActions array).
2417
+ const agentBudgetMapping = budgetStopped || budgetReason
2418
+ ? classifyBudgetFailure({ denial: budgetDenial, terminal: budgetReason })
2419
+ : null;
1983
2420
  const facts = makeOutcomeFacts("failed", {
1984
- reason: budgetReason ?? (budgetStopped ? "budget_exhausted" : "harness_failed"),
2421
+ reason: agentBudgetMapping?.reason ?? (budgetStopped ? "budget_exhausted" : "harness_failed"),
1985
2422
  noChanges: true,
1986
2423
  });
1987
- const why = budgetStopped
1988
- ? "budget exhausted before any candidate run"
1989
- : "no candidates produced";
2424
+ const why = agentBudgetMapping?.safeMessage ?? "no candidates produced";
1990
2425
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
1991
2426
  winner: null,
1992
2427
  facts,
1993
2428
  why_winner: why,
1994
2429
  evidence_facts: ["no candidates were produced"],
1995
2430
  apply_recommendation: "continue",
1996
- budget_summary: { spend_usd: ledger.spend(), estimated: false },
1997
- });
1998
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""}\n- Phase: budget\n\n${why}\n`);
1999
- writeFailure(store, paths, {
2000
- phase: "budget",
2001
- category: isBudgetTerminal(facts.reason) ? "budget" : "internal",
2002
- safeMessage: why,
2003
- runDir: paths.root,
2431
+ budget_summary: {
2432
+ spend_usd: ledger.spend(),
2433
+ estimated: false,
2434
+ cash_usd: ledger.spend(),
2435
+ valuation_usd: ledger.valuation(),
2436
+ },
2004
2437
  });
2438
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""}\n- Phase: ${agentBudgetMapping ? "budget" : "executor"}\n\n${why}\n`);
2439
+ if (agentBudgetMapping) {
2440
+ writeFailure(store, paths, budgetFailureRecord(agentBudgetMapping, { runDir: paths.root }));
2441
+ }
2442
+ else {
2443
+ writeFailure(store, paths, {
2444
+ phase: "executor",
2445
+ category: "internal",
2446
+ safeMessage: why,
2447
+ runDir: paths.root,
2448
+ nextActions: ["Open diagnostics", "Retry the run"],
2449
+ });
2450
+ }
2005
2451
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
2006
2452
  log.emit("run.failed", {
2007
2453
  lifecycle: facts.lifecycle,
2008
2454
  facts,
2009
2455
  reason: facts.reason,
2010
- phase: "budget",
2456
+ phase: agentBudgetMapping ? "budget" : "executor",
2457
+ ...(agentBudgetMapping?.harnessId ? { harness_id: agentBudgetMapping.harnessId } : {}),
2011
2458
  error: why,
2012
2459
  failure_ref: "final/failure.yaml",
2013
2460
  });
@@ -2029,32 +2476,34 @@ export class Orchestrator {
2029
2476
  // producing anything are corpses: reviewing "(empty diff)" spends real
2030
2477
  // reviewer money on nothing and buries the root cause behind an
2031
2478
  // arbitration scoring string.
2032
- const workingRuns = runs.filter((r) => !r.errored || r.diff.length > 0);
2479
+ const workingRuns = partitionCandidates(runs).working;
2033
2480
  if (workingRuns.length === 0) {
2034
2481
  await disposeReviewEnvelopes();
2035
2482
  const first = runs[0];
2036
2483
  const phase = first.infraPhase ?? "harness";
2037
- const rootCause = runs
2038
- .map((r) => `${r.attemptId}/${r.harnessId}: ${r.errors[0] ?? "failed before producing work"}`)
2039
- .join("; ");
2040
- const facts = makeOutcomeFacts("failed", { reason: "harness_failed", noChanges: true });
2484
+ const { facts, why: rootCause } = partitionCandidates(runs);
2041
2485
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
2042
2486
  winner: null,
2043
2487
  facts,
2044
2488
  why_winner: rootCause,
2045
2489
  evidence_facts: runs.map((r) => `${r.attemptId} produced no work: ${r.errors[0] ?? "unknown"}`),
2046
2490
  apply_recommendation: "continue",
2047
- budget_summary: { spend_usd: ledger.spend(), estimated: false },
2491
+ budget_summary: {
2492
+ spend_usd: ledger.spend(),
2493
+ estimated: false,
2494
+ cash_usd: ledger.spend(),
2495
+ valuation_usd: ledger.valuation(),
2496
+ },
2048
2497
  });
2049
- this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
2050
- attemptId: r.attemptId,
2051
- harnessId: r.harnessId,
2052
- telemetry: r.telemetry,
2053
- })), null);
2498
+ this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), null);
2054
2499
  store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}\n- Phase: ${phase}\n\n${rootCause}\n`);
2055
2500
  const existingEventRefs = runs
2056
2501
  .map((r) => `attempts/${r.attemptId}/events.jsonl`)
2057
2502
  .filter((rel) => existsSync(join(paths.root, rel)));
2503
+ // #31: auth guidance only on a classified auth failure; every other
2504
+ // harness cause (timeout, rate limit, crash, config) gets remediation that
2505
+ // fits it, instead of a doomed "Check harness authentication".
2506
+ const harnessCategory = dominantHarnessFailureCategory(first.telemetry.transientFailures);
2058
2507
  writeFailure(store, paths, {
2059
2508
  phase,
2060
2509
  category: phase === "workspace" ? "project" : "harness_error",
@@ -2066,7 +2515,7 @@ export class Orchestrator {
2066
2515
  runDir: paths.root,
2067
2516
  nextActions: phase === "workspace"
2068
2517
  ? ["Check the project folder", "Open diagnostics", "Retry the run"]
2069
- : ["Open diagnostics", "Check harness authentication", "Retry the run"],
2518
+ : harnessFailureNextActions(harnessCategory),
2070
2519
  });
2071
2520
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
2072
2521
  log.emit("run.failed", {
@@ -2094,7 +2543,31 @@ export class Orchestrator {
2094
2543
  spendUsd: ledger.spend(),
2095
2544
  };
2096
2545
  }
2097
- log.emit("review.started", { reviewers: reviewers.length, review_verified: reviewVerified });
2546
+ // QA-025: only announce that review STARTED when the panel will actually
2547
+ // run. A candidate that changed no files is skipped inside reviewRuns; a
2548
+ // start event before that check falsely claims a paid review began (and its
2549
+ // `review_verified` payload was the PRELIMINARY route-family count, not any
2550
+ // real verification). Compute the reviewable set first and emit a typed
2551
+ // `review.skipped` when nothing is reviewable, so every start has a matching
2552
+ // terminal and the no-diff path records `not_run` consistently.
2553
+ const reviewableRuns = workingRuns.filter((r) => r.diff.trim().length > 0);
2554
+ const configuredFamilies = new Set(reviewers.map((r) => r.providerFamily)).size;
2555
+ if (reviewableRuns.length === 0 || reviewers.length === 0) {
2556
+ log.emit("review.skipped", {
2557
+ reason: reviewers.length === 0 ? "no_reviewers" : "no_changes",
2558
+ reviewable_candidates: reviewableRuns.length,
2559
+ configured_reviewers: reviewers.length,
2560
+ configured_provider_families: configuredFamilies,
2561
+ });
2562
+ }
2563
+ else {
2564
+ log.emit("review.started", {
2565
+ reviewers: reviewers.length,
2566
+ reviewable_candidates: reviewableRuns.length,
2567
+ configured_provider_families: configuredFamilies,
2568
+ cross_family_route_eligible: reviewVerified,
2569
+ });
2570
+ }
2098
2571
  let evidences;
2099
2572
  try {
2100
2573
  // reviewRuns internally SKIPS the paid reviewer call for empty-diff
@@ -2113,11 +2586,7 @@ export class Orchestrator {
2113
2586
  await disposeReviewEnvelopes();
2114
2587
  }
2115
2588
  if (input.signal?.aborted) {
2116
- return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
2117
- attemptId: r.attemptId,
2118
- harnessId: r.harnessId,
2119
- telemetry: r.telemetry,
2120
- })), null), ledger.spend(), input.signal, store);
2589
+ return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), null), ledger.spend(), input.signal, store);
2121
2590
  }
2122
2591
  // Synthesis: if worthwhile, run a synthesizer as a NEW, re-checked candidate.
2123
2592
  const synth = decideSynthesis(evidences, input.synthesis ?? "auto");
@@ -2161,22 +2630,22 @@ export class Orchestrator {
2161
2630
  ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode, run.telemetry.usageCost));
2162
2631
  reviewEnvelopes.push(envelope);
2163
2632
  envelope = undefined;
2633
+ // D-16 r8: only a WORKING synth is reviewed/adopted (same veto owner
2634
+ // as the race lane); `runs` still records it for telemetry.
2635
+ runs.push(run);
2164
2636
  try {
2165
- const synthEvidence = await this.reviewRuns([run], reviewers, reviewVerified, reviewDir, input.repoRoot, contract, store, paths, log, ledger, taskId, input.signal);
2166
- evidences.push(...synthEvidence);
2637
+ if (isWorkingCandidate(run)) {
2638
+ const synthEvidence = await this.reviewRuns([run], reviewers, reviewVerified, reviewDir, input.repoRoot, contract, store, paths, log, ledger, taskId, input.signal);
2639
+ evidences.push(...synthEvidence);
2640
+ workingRuns.push(run);
2641
+ }
2167
2642
  if (input.signal?.aborted) {
2168
- return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
2169
- attemptId: r.attemptId,
2170
- harnessId: r.harnessId,
2171
- telemetry: r.telemetry,
2172
- })), null), ledger.spend(), input.signal, store);
2643
+ return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), null), ledger.spend(), input.signal, store);
2173
2644
  }
2174
2645
  }
2175
2646
  finally {
2176
2647
  await disposeReviewEnvelopes();
2177
2648
  }
2178
- runs.push(run);
2179
- workingRuns.push(run);
2180
2649
  }
2181
2650
  catch (err) {
2182
2651
  ledger.settle(lease.lease?.lease_id ?? "", unknownCostSettlement("synthesis-error"));
@@ -2193,17 +2662,17 @@ export class Orchestrator {
2193
2662
  }
2194
2663
  }
2195
2664
  if (input.signal?.aborted) {
2196
- return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({
2197
- attemptId: r.attemptId,
2198
- harnessId: r.harnessId,
2199
- telemetry: r.telemetry,
2200
- })), null), ledger.spend(), input.signal, store);
2665
+ return cancelledResult(log, runId, taskId, mode, paths.root, cancelledCandidates(), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), null), ledger.spend(), input.signal, store);
2201
2666
  }
2202
2667
  let result;
2203
2668
  try {
2204
2669
  result = arbitrate(evidences, {
2205
2670
  spendUsd: ledger.spend(),
2206
2671
  estimatedSpend: runs.some((r) => r.costEstimated),
2672
+ // QA-010b: carry the settled cash + subscription-valuation totals
2673
+ // (reviewer panel included) onto the decision record.
2674
+ cashUsd: ledger.spend(),
2675
+ valuationUsd: ledger.valuation(),
2207
2676
  });
2208
2677
  }
2209
2678
  catch (err) {
@@ -2213,6 +2682,11 @@ export class Orchestrator {
2213
2682
  log.emit("arbitration.completed", {
2214
2683
  winner: result.decision.winner,
2215
2684
  lifecycle: result.decision.facts.lifecycle,
2685
+ // QA-028: surface the axis that actually separated the winner from the
2686
+ // runner-up (null on an exact tie) so live surfaces can explain the pick.
2687
+ ...(result.decision.decisive_axis
2688
+ ? { decisive_axis: result.decision.decisive_axis.key }
2689
+ : {}),
2216
2690
  });
2217
2691
  // Winner can only be a candidate that actually produced work; corpses are
2218
2692
  // excluded from arbitration upstream and from the fallback here.
@@ -2266,7 +2740,7 @@ export class Orchestrator {
2266
2740
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
2267
2741
  ...result.decision,
2268
2742
  ...(needsDec
2269
- ? blockedDecisionOverride(result.decision.evidence_facts, finalVerify)
2743
+ ? blockedDecisionOverride(result.decision.evidence_facts, facts, finalVerify)
2270
2744
  : { facts }),
2271
2745
  review_verified: actualReviewVerified,
2272
2746
  final_verify: finalVerify,
@@ -2291,7 +2765,7 @@ export class Orchestrator {
2291
2765
  ? winnerEvidence.findings.filter((f) => isBlocking(f)).length
2292
2766
  : 0;
2293
2767
  // Prose from an empty-diff winner is an answer, never a patch.
2294
- const winnerAnswer = winnerRun.answerText?.trim() ?? "";
2768
+ const winnerAnswer = winnerRun.answerText ?? "";
2295
2769
  const resultKind = hasDiff ? "patch" : winnerAnswer.length > 0 ? "answer" : "none";
2296
2770
  // The winner's final MESSAGE is the human-facing answer and materializes
2297
2771
  // for diff-ful runs too: the chat renders final/answer.md (the projection
@@ -2430,7 +2904,7 @@ export class Orchestrator {
2430
2904
  : "diagnostic",
2431
2905
  });
2432
2906
  }
2433
- this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({ attemptId: r.attemptId, harnessId: r.harnessId, telemetry: r.telemetry })), result.decision.facts.lifecycle === "succeeded"
2907
+ this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), result.decision.facts.lifecycle === "succeeded"
2434
2908
  ? result.decision.winner
2435
2909
  : (winnerRun?.attemptId ?? null));
2436
2910
  // A needs-decision terminal (review blocked or checks failed) OR a
@@ -2464,9 +2938,16 @@ export class Orchestrator {
2464
2938
  });
2465
2939
  }
2466
2940
  else if (isFailureTerminal) {
2941
+ // QA-010c: a reviewer-accepted BLOCK/FIX_FIRST (or a failed deterministic
2942
+ // gate) that BLOCKS the run is an operator-decision terminal, not an
2943
+ // internal engine error. Only a genuinely unexpected non-decision terminal
2944
+ // stays `internal`. A review/checks block is `policy` (the acceptance
2945
+ // path), phase `review`, and its remedy names the decision, not "retry
2946
+ // with a different harness".
2947
+ const decisionBlock = needsHuman || needsDecisionTerminal;
2467
2948
  writeFailure(store, paths, {
2468
- phase: needsHuman ? "review" : "arbitration",
2469
- category: needsHuman
2949
+ phase: decisionBlock ? "review" : "arbitration",
2950
+ category: decisionBlock
2470
2951
  ? "policy"
2471
2952
  : winnerRun?.errored
2472
2953
  ? "harness_error"
@@ -2477,16 +2958,20 @@ export class Orchestrator {
2477
2958
  attemptId: winnerRun?.errored ? winnerRun.attemptId : undefined,
2478
2959
  safeMessage: needsHuman
2479
2960
  ? `review escalated to a human decision: ${result.decision.why_winner}`
2480
- : result.decision.why_winner,
2961
+ : needsDecisionTerminal
2962
+ ? `review blocked before apply: ${result.decision.why_winner}`
2963
+ : result.decision.why_winner,
2481
2964
  rawDetailRef: winnerRun?.errored
2482
2965
  ? `attempts/${winnerRun.attemptId}/attempt.yaml`
2483
2966
  : undefined,
2484
2967
  runDir: paths.root,
2485
- nextActions: needsHuman
2968
+ nextActions: decisionBlock
2486
2969
  ? [
2487
- "Open the review queue",
2488
- "Decide the NEEDS_HUMAN findings",
2489
- "Re-run after the decision",
2970
+ "Review the blocking findings on the run's turn",
2971
+ "Accept the risk to apply this exact patch, or discard the change",
2972
+ ...(facts.checks === "failed"
2973
+ ? ["Configure/approve the deterministic test command, then re-run"]
2974
+ : []),
2490
2975
  ]
2491
2976
  : [
2492
2977
  "Open diagnostics",
@@ -2550,7 +3035,11 @@ export class Orchestrator {
2550
3035
  };
2551
3036
  }
2552
3037
  /** Single-owner telemetry artifact (final/telemetry.yaml); surfaces project it, never recompute. */
2553
- writeRunTelemetry(store, paths, contract, runId, taskId, mode, attempts, finalAttemptId) {
3038
+ writeRunTelemetry(store, paths, contract, runId, taskId, mode, attempts, finalAttemptId, deepScanSynthesis) {
3039
+ // QA-034: attach the routing rationale recorded at pool ordering (if this
3040
+ // run computed one), then clear it — telemetry is written once at terminal.
3041
+ const routingRationale = this.routingRationaleByRun.get(runId) ?? null;
3042
+ this.routingRationaleByRun.delete(runId);
2554
3043
  writeRunTelemetryArtifact({
2555
3044
  store,
2556
3045
  finalDir: paths.finalDir,
@@ -2560,6 +3049,8 @@ export class Orchestrator {
2560
3049
  mode,
2561
3050
  attempts,
2562
3051
  finalAttemptId,
3052
+ routingRationale,
3053
+ deepScanSynthesis: deepScanSynthesis ?? null,
2563
3054
  resolveAuthPreference: (harnessId) => this.authPreferenceForHarness(contract.repo.root, harnessId, contract.auth_preference),
2564
3055
  });
2565
3056
  }
@@ -2784,6 +3275,11 @@ export class Orchestrator {
2784
3275
  candidates: [],
2785
3276
  };
2786
3277
  }
3278
+ // Same run-prep stage as the git boundary (and the same `!inPlace`
3279
+ // exclusion — we are inside that branch, so inPlace is false here): bridge
3280
+ // an AGENTS.md-only PROJECT root so a Claude Code convergence attempt reads
3281
+ // it (INV-113).
3282
+ this.ensureClaudeBridgeForRun(input.repoRoot, false, log);
2787
3283
  }
2788
3284
  const reviewDir = join(paths.root, "review-evidence");
2789
3285
  writeEvidencePacket(reviewDir, {
@@ -2799,17 +3295,21 @@ export class Orchestrator {
2799
3295
  // One envelope carried forward across attempts so the harness can repair its own work.
2800
3296
  let adapterPool;
2801
3297
  try {
2802
- adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger, log);
3298
+ adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger, log, undefined, runId);
2803
3299
  this.requestRequirements.assertConvergenceWorkspace(input.inPlace === true, adapterPool);
2804
3300
  }
2805
3301
  catch (err) {
2806
3302
  const message = safeErrorMessage(err);
2807
3303
  store.writeText(join(paths.contextDir, "context_error.md"), `# Routing Error\n\n${message}\n`);
3304
+ // A routing preflight refusal is a config_error, not harness_unavailable
3305
+ // (A-1/#22): classify identically across every strategy's routing catch.
3306
+ const routingFailure = routingFailureClassification(err);
2808
3307
  writeFailure(store, paths, {
2809
3308
  phase: "routing",
2810
- category: "harness_unavailable",
3309
+ category: routingFailure.category,
2811
3310
  safeMessage: message,
2812
3311
  runDir: paths.root,
3312
+ ...(routingFailure.nextActions ? { nextActions: routingFailure.nextActions } : {}),
2813
3313
  });
2814
3314
  store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
2815
3315
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
@@ -2881,6 +3381,7 @@ export class Orchestrator {
2881
3381
  let attempt = 0;
2882
3382
  let converged = false;
2883
3383
  let exhausted = false;
3384
+ let interrupted = false; // D-16 r8: terminalizes the run interrupted
2884
3385
  let lastFindings = [];
2885
3386
  let lastRun = null;
2886
3387
  let actualReviewVerified = false;
@@ -2939,7 +3440,7 @@ export class Orchestrator {
2939
3440
  : "";
2940
3441
  const prompt = attempt === 1
2941
3442
  ? input.prompt
2942
- : `${input.prompt}\n\nThe previous attempt did not converge. Address these review findings (verify each against the code; fix valid ones, rebut invalid ones with evidence):\n${formatFindings(lastFindings)}${runtimeErrors}`;
3443
+ : buildRevisePrompt(input.prompt, lastFindings, runtimeErrors);
2943
3444
  // Loop detection (budget router): the 3rd identical repair prompt means
2944
3445
  // findings/errors are not changing — stop burning paid attempts.
2945
3446
  const fingerprint = promptFingerprint(prompt);
@@ -2987,7 +3488,8 @@ export class Orchestrator {
2987
3488
  log.emit("harness.completed", {
2988
3489
  harness_id: adapter.id,
2989
3490
  attempt_id: attemptId,
2990
- status: run.errored ? "failed" : "success",
3491
+ // QA-027: an aborted attempt is `cancelled`, never a clean `success`.
3492
+ status: input.signal?.aborted ? "cancelled" : run.errored ? "failed" : "success",
2991
3493
  cost_usd: run.cost,
2992
3494
  ...telemetrySummary(run.telemetry),
2993
3495
  });
@@ -3017,6 +3519,12 @@ export class Orchestrator {
3017
3519
  }
3018
3520
  lastRun = run;
3019
3521
  attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry: run.telemetry });
3522
+ // D-16 r8: interrupted (errored===false) would CONVERGE a partial diff
3523
+ // as clean — break BEFORE review; a harness error still gate-retries.
3524
+ if (run.outcomeClass === "interrupted") {
3525
+ interrupted = true;
3526
+ break;
3527
+ }
3020
3528
  // Post-mutation fence for in-place: snapshot the live tree NOW (after the
3021
3529
  // harness mutated it, before this attempt's review). The last attempt's
3022
3530
  // value is the revert target persisted into work_product.yaml.
@@ -3242,22 +3750,41 @@ export class Orchestrator {
3242
3750
  // Base terminal AXES (D8) from the convergence loop outcome. Attempts-cap
3243
3751
  // exhaustion maps to budget_exhausted (an attempt budget); the give-up
3244
3752
  // states map to their matching RunReason.
3245
- let facts = input.signal?.aborted
3246
- ? makeOutcomeFacts("cancelled", { reason: "user_cancelled" })
3247
- : converged
3248
- ? makeOutcomeFacts("succeeded")
3249
- : stuckNoProgress
3250
- ? makeOutcomeFacts("failed", { reason: "stuck_no_progress" })
3251
- : exhausted
3252
- ? makeOutcomeFacts("failed", { reason: "budget_exhausted" })
3253
- : makeOutcomeFacts("failed", { reason: "not_converged" });
3753
+ //
3754
+ // QA-041 terminal-causality precedence: convergence used to hard-code EVERY
3755
+ // aborted signal to `user_cancelled`, which (a) fabricated an operator action
3756
+ // that never happened when the maxSeconds wall-clock deadline fired, and
3757
+ // (b) discarded an already-proven `stuck_no_progress` terminal. The typed
3758
+ // abort reason (`wall_clock_exceeded` from the deadline controller, carried
3759
+ // on `input.signal.reason` via AbortSignal.any) is now read at the source,
3760
+ // and an established semantic terminal (stuck_no_progress) wins over the
3761
+ // abort so the deadline that only ended a redundant post-proof panel does
3762
+ // not overwrite the actionable no-progress reason. `user_cancelled` is
3763
+ // emitted ONLY for a real control cancel (no typed deadline reason).
3764
+ const convAbortReason = typeof input.signal?.reason === "string" && input.signal.reason
3765
+ ? input.signal.reason
3766
+ : undefined;
3767
+ const convCancelFacts = () => makeOutcomeFacts("cancelled", {
3768
+ reason: convAbortReason === "wall_clock_exceeded" ? "wall_clock_exceeded" : "user_cancelled",
3769
+ });
3770
+ let facts = convergenceOutcomeFacts({
3771
+ converged,
3772
+ interrupted,
3773
+ stuckNoProgress,
3774
+ aborted: Boolean(input.signal?.aborted),
3775
+ exhausted,
3776
+ }, convCancelFacts);
3254
3777
  let decision = null;
3255
- if (lastRun) {
3778
+ // D-16 r8: interrupted is never arbitrated; the partial stays diagnostic.
3779
+ if (lastRun && !interrupted) {
3256
3780
  const arb = arbitrate([
3257
3781
  toCandidateEvidence(lastRun, contract, lastFindings, lastFinalReviewClean, actualReviewVerified),
3258
3782
  ], {
3259
3783
  spendUsd: ledger.spend(),
3260
3784
  estimatedSpend: lastRun.costEstimated || reviewSpendEstimated,
3785
+ // QA-010b: settled cash + valuation (reviewer panel included).
3786
+ cashUsd: ledger.spend(),
3787
+ valuationUsd: ledger.valuation(),
3261
3788
  });
3262
3789
  decision = arb.decision;
3263
3790
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
@@ -3308,15 +3835,18 @@ export class Orchestrator {
3308
3835
  decision = {
3309
3836
  ...decision,
3310
3837
  ...(convNeedsDecision
3311
- ? blockedDecisionOverride(decision.evidence_facts, convFinalVerify)
3838
+ ? blockedDecisionOverride(decision.evidence_facts, facts, convFinalVerify)
3312
3839
  : { facts }),
3313
3840
  final_verify: convFinalVerify,
3314
3841
  };
3315
3842
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
3316
3843
  }
3317
3844
  this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, attemptTelemetries, lastRun?.attemptId ?? null);
3318
- // Deliver the converged/last work to final/ so `apply` and `inspect` can use it.
3319
- if (lastRun) {
3845
+ // Deliver the converged/last work to final/ so `apply` and `inspect` can
3846
+ // use it. D-16 r8: an INTERRUPTED envelope run delivers no applyable
3847
+ // work_product (its partial patch.diff stays diagnostic via attempts/);
3848
+ // in-place keeps the product so the honest Revert offer survives.
3849
+ if (lastRun && (!interrupted || input.inPlace === true)) {
3320
3850
  assertNoSecretLikeTokens("final patch diff", lastRun.diff);
3321
3851
  const patchSha256 = sha256(lastRun.diff);
3322
3852
  store.writeText(join(paths.finalDir, "patch.diff"), lastRun.diff);
@@ -3386,12 +3916,16 @@ export class Orchestrator {
3386
3916
  attemptId: lastRun?.attemptId,
3387
3917
  runDir: paths.root,
3388
3918
  nextActions: facts.lifecycle === "cancelled"
3389
- ? ["Retry if cancellation was accidental"]
3919
+ ? facts.reason === "wall_clock_exceeded"
3920
+ ? [
3921
+ "Inspect the partial work kept from before the deadline",
3922
+ "Increase --max-seconds or narrow the scope, then re-run",
3923
+ ]
3924
+ : ["Retry if cancellation was accidental"]
3390
3925
  : convNeedsDecision
3391
3926
  ? [
3392
- "Open the review queue",
3393
- "Decide the NEEDS_HUMAN findings",
3394
- "Re-run after the decision",
3927
+ "Review the blocking findings on the run's turn",
3928
+ "Accept the risk to apply this exact patch, or discard the change",
3395
3929
  ]
3396
3930
  : facts.reason === "stuck_no_progress"
3397
3931
  ? [
@@ -3405,8 +3939,12 @@ export class Orchestrator {
3405
3939
  "Retry with more attempts or a narrower prompt",
3406
3940
  ],
3407
3941
  });
3408
- if (!lastRun) {
3409
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}\n- Attempts: ${attempt}\n`);
3942
+ // D-16 r8/r9: an INTERRUPTED envelope run still gets its diagnostic
3943
+ // summary + output.ready (only patch/work_product are withheld) the
3944
+ // ARCHITECTURE event contract guarantees output.ready precedes the
3945
+ // terminal in every mode.
3946
+ if (!lastRun || (interrupted && input.inPlace !== true)) {
3947
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""}\n- Attempts: ${attempt}\n`);
3410
3948
  log.emit("output.ready", {
3411
3949
  kind: "summary",
3412
3950
  path: "final/summary.md",
@@ -3414,7 +3952,10 @@ export class Orchestrator {
3414
3952
  });
3415
3953
  }
3416
3954
  }
3417
- log.emit("work_product.emitted", { winner: lastRun?.attemptId ?? null });
3955
+ // work_product.emitted only when a product was actually written (r9).
3956
+ if (lastRun && (!interrupted || input.inPlace === true)) {
3957
+ log.emit("work_product.emitted", { winner: lastRun.attemptId });
3958
+ }
3418
3959
  if (!convIsFailureTerminal) {
3419
3960
  log.emit("run.completed", {
3420
3961
  lifecycle: facts.lifecycle,
@@ -3440,6 +3981,11 @@ export class Orchestrator {
3440
3981
  attempts: attempt,
3441
3982
  phase: "convergence",
3442
3983
  failure_ref: "final/failure.yaml",
3984
+ // QA-041: surface the typed deadline reason on the terminal event so the
3985
+ // daemon job result / Control API can distinguish it from a user cancel.
3986
+ ...(facts.lifecycle === "cancelled" && convAbortReason
3987
+ ? { cancel_reason: convAbortReason }
3988
+ : {}),
3443
3989
  });
3444
3990
  }
3445
3991
  return {
@@ -3451,6 +3997,11 @@ export class Orchestrator {
3451
3997
  facts,
3452
3998
  winner: lastRun?.attemptId ?? null,
3453
3999
  runDir: paths.root,
4000
+ // QA-041: carry the typed deadline reason on the result so daemon/Control
4001
+ // API/CLI never falsely attribute a maxSeconds deadline to the user.
4002
+ ...(facts.lifecycle === "cancelled" && convAbortReason
4003
+ ? { cancelReason: convAbortReason }
4004
+ : {}),
3454
4005
  summary: converged
3455
4006
  ? `converged in ${attempt} attempt(s)`
3456
4007
  : `${facts.lifecycle} after ${attempt} attempt(s)`,
@@ -3500,15 +4051,7 @@ export class Orchestrator {
3500
4051
  `Keep it concise. Reference real paths you found. Do NOT paste large code blocks; describe the change instead.`,
3501
4052
  ].join("\n");
3502
4053
  }
3503
- /**
3504
- * Run ONE planner spawn (native plan mode, read-only) end to end: budget
3505
- * lease, spec build, continuity hydration, event streaming, telemetry, and
3506
- * settle. Shared by the solo plan loop (each pool member is a sequential
3507
- * fallback) and the Council strategy (each member is a parallel draft, then
3508
- * one merge iteration on the primary — same machinery, different prompt +
3509
- * intent). The caller owns bookkeeping that differs per path: which artifact
3510
- * a success writes to, fallback disclosure, and accumulation.
3511
- */
4054
+ /** One read-only planner spawn shared by solo fallback, Council drafts, and merge. */
3512
4055
  async runPlannerAttempt(args) {
3513
4056
  const { input, contract, taskId, runId, log, store, paths, ledger, routed, attemptId } = args;
3514
4057
  const adapter = routed.adapter;
@@ -3523,6 +4066,7 @@ export class Orchestrator {
3523
4066
  log.emit("budget.lease.created", {
3524
4067
  granted: false,
3525
4068
  reason: lease.reason,
4069
+ denied: lease.denied,
3526
4070
  attempt_id: attemptId,
3527
4071
  harness_id: adapter.id,
3528
4072
  });
@@ -3530,10 +4074,17 @@ export class Orchestrator {
3530
4074
  attemptId,
3531
4075
  harnessId: adapter.id,
3532
4076
  status: "failed",
4077
+ outcomeClass: "clean", // never spawned: refused pre-flight by the budget gate
3533
4078
  error: lease.reason ?? "budget lease denied",
3534
4079
  text: null,
3535
4080
  telemetry: null,
3536
4081
  budgetDenied: true,
4082
+ budgetDenial: {
4083
+ code: lease.denied ?? "hard_cap",
4084
+ reason: lease.reason ?? "budget lease denied",
4085
+ harnessId: adapter.id,
4086
+ attemptId,
4087
+ },
3537
4088
  };
3538
4089
  }
3539
4090
  const knobs = this.routeSpecKnobs(routed, contract, undefined, input.effort);
@@ -3570,6 +4121,10 @@ export class Orchestrator {
3570
4121
  const planInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
3571
4122
  if (planInteraction)
3572
4123
  spec.extra["interactionChannel"] = planInteraction;
4124
+ // D-16: compile the WorkReport envelope for the plan lane (require plan text
4125
+ // below folds the deliverable; the veto rides work_state).
4126
+ const planWorkEnvelope = this.workReportEnvelopeFor(routed, contract, Boolean(planInteraction));
4127
+ const planWorkMode = this.applyWorkEnvelope(spec, planWorkEnvelope);
3573
4128
  const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
3574
4129
  const answer = new AnswerAssembly();
3575
4130
  const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
@@ -3657,34 +4212,74 @@ export class Orchestrator {
3657
4212
  input.signal?.removeEventListener("abort", onAbort);
3658
4213
  ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode, telemetry.usageCost));
3659
4214
  }
4215
+ // D-16: unwrap and require PLAN TEXT — a plan with no text is not delivered.
4216
+ // The unwrap runs BEFORE the error axes: the deliverable it yields is what
4217
+ // decides whether an unrecovered tool error is fatal (explorer parity).
4218
+ const planUnwrapped = unwrapWorkReportEnvelope(answer.machineText() ?? "", planWorkMode, {
4219
+ sideToolReport: telemetry.sideToolWorkReport ?? undefined,
4220
+ });
4221
+ const planText = redactSecrets(planUnwrapped.deliverable).trim();
3660
4222
  const unrecovered = unrecoveredToolErrors(telemetry);
3661
4223
  const webBlocked = webUnsatisfied(telemetry);
3662
4224
  if (!harnessError && webBlocked) {
3663
- harnessError = `web evidence unsatisfied: ${telemetry.web.errorSummary ?? (telemetry.web.attempted ? "web tool failed without verified recovery" : "web evidence required but never attempted")}`;
4225
+ harnessError = webEvidenceFailure(telemetry.web);
3664
4226
  }
3665
- if (!harnessError && unrecovered.length > 0) {
3666
- const first = unrecovered[0];
3667
- harnessError = `${first.tool} failed without recovery: ${first.summary}`;
4227
+ // INV-043/INV-044, explorer parity: a DELIVERED plan keeps an unrecovered
4228
+ // non-web tool error as warning evidence instead of discarding the plan (see
4229
+ // the helper). Web keeps its hard gate above; the finalizer outranks both.
4230
+ harnessError ??= unrecoveredToolErrorFailure(unrecovered, planText.length > 0);
4231
+ const planFinalized = finalizeAttempt({
4232
+ deliverableEvidence: planText.length > 0,
4233
+ harnessErrored: harnessError !== null && !webBlocked,
4234
+ workReport: planUnwrapped.workReport,
4235
+ workReportSource: planUnwrapped.source,
4236
+ workReportViolation: planUnwrapped.contractViolation,
4237
+ contextTerminalExhausted: telemetry.contextExhausted,
4238
+ });
4239
+ // A broken WorkReport contract is a hard failure only when the finalizer
4240
+ // ranked it so (a terminal context exhaustion outranks it).
4241
+ if (!harnessError && planFinalized.outcomeClass === "contract_failure") {
4242
+ harnessError = `work_report contract: ${planUnwrapped.contractViolation}`;
3668
4243
  }
3669
- if (harnessError) {
4244
+ // D-16 r9: an interrupted (context-exhausted) planner is NEVER a clean
4245
+ // plan — partial text must not become final/plan.md as success. A VETO
4246
+ // (needs_input/incomplete work_state) is DIFFERENT by the sealed D-16
4247
+ // contract (X35, INV-116 canaries): the plan still delivers, lifecycle
4248
+ // succeeded, and the work_state veto rides the OUTCOME (non-zero exit) —
4249
+ // it must not be laundered into a harness failure either direction.
4250
+ if (!harnessError && planFinalized.outcomeClass === "interrupted") {
4251
+ harnessError = "context capacity exhausted before the plan completed";
4252
+ }
4253
+ const attemptError = harnessError ??
4254
+ (planFinalized.deliverablePresent ? null : "planner produced no plan text") ??
4255
+ (input.signal?.aborted ? "planner cancelled" : null);
4256
+ setAttemptOutcome(telemetry, {
4257
+ deliverablePresent: planFinalized.deliverablePresent,
4258
+ gatesPassed: null,
4259
+ harnessErrored: (harnessError !== null && !webBlocked) || planFinalized.harnessErrored,
4260
+ webRequiredUnsatisfied: webBlocked,
4261
+ workState: planFinalized.workState,
4262
+ });
4263
+ if (attemptError) {
3670
4264
  log.emit("harness.completed", {
3671
4265
  harness_id: adapter.id,
3672
4266
  attempt_id: attemptId,
3673
4267
  status: webBlocked ? "blocked" : "failed",
3674
- error: harnessError,
4268
+ error: attemptError,
3675
4269
  ...telemetrySummary(telemetry),
3676
4270
  });
3677
4271
  return {
3678
4272
  attemptId,
3679
4273
  harnessId: adapter.id,
3680
4274
  status: webBlocked ? "blocked" : "failed",
3681
- error: harnessError,
4275
+ outcomeClass: planFinalized.outcomeClass,
4276
+ error: attemptError,
3682
4277
  text: null,
3683
4278
  telemetry,
3684
4279
  budgetDenied: false,
3685
4280
  };
3686
4281
  }
3687
- const text = answer.text() || "(no output)";
4282
+ const text = planText || "(no output)";
3688
4283
  log.emit("harness.completed", {
3689
4284
  harness_id: adapter.id,
3690
4285
  attempt_id: attemptId,
@@ -3695,6 +4290,7 @@ export class Orchestrator {
3695
4290
  attemptId,
3696
4291
  harnessId: adapter.id,
3697
4292
  status: "success",
4293
+ outcomeClass: planFinalized.outcomeClass,
3698
4294
  error: null,
3699
4295
  text,
3700
4296
  telemetry,
@@ -3737,17 +4333,21 @@ export class Orchestrator {
3737
4333
  const laneRun = Boolean(input.threadId);
3738
4334
  let adapters;
3739
4335
  try {
3740
- adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, log, roHome);
4336
+ adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, log, roHome, runId);
3741
4337
  }
3742
4338
  catch (err) {
3743
4339
  roHome.dispose();
3744
4340
  const message = safeErrorMessage(err);
3745
4341
  store.writeText(join(paths.contextDir, "context_error.md"), `# Routing Error\n\n${message}\n`);
4342
+ // A routing preflight refusal is a config_error, not harness_unavailable
4343
+ // (A-1/#22): classify identically across every strategy's routing catch.
4344
+ const routingFailure = routingFailureClassification(err);
3746
4345
  writeFailure(store, paths, {
3747
4346
  phase: "routing",
3748
- category: "harness_unavailable",
4347
+ category: routingFailure.category,
3749
4348
  safeMessage: message,
3750
4349
  runDir: paths.root,
4350
+ ...(routingFailure.nextActions ? { nextActions: routingFailure.nextActions } : {}),
3751
4351
  });
3752
4352
  store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
3753
4353
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
@@ -3832,6 +4432,9 @@ export class Orchestrator {
3832
4432
  let fallbackFrom = null;
3833
4433
  const planAttempts = [];
3834
4434
  const attemptTelemetries = [];
4435
+ // QA-050: the ledger's typed denial when a planner slot was refused
4436
+ // pre-spawn, so the terminal is a budget failure (not "all planners failed").
4437
+ let planBudgetDenial = null;
3835
4438
  try {
3836
4439
  for (const [idx, routed] of adapters.entries()) {
3837
4440
  if (input.signal?.aborted)
@@ -3853,8 +4456,20 @@ export class Orchestrator {
3853
4456
  promptBody: this.planPrompt(input.prompt) + contextSection,
3854
4457
  intent: "plan",
3855
4458
  });
3856
- if (outcome.budgetDenied)
4459
+ if (outcome.budgetDenied) {
4460
+ // QA-050: retain the denied planner slot before breaking so the
4461
+ // terminal names the refused route and does not read "all planners
4462
+ // failed"; capture the typed denial for the budget classifier.
4463
+ planBudgetDenial ??= outcome.budgetDenial ?? null;
4464
+ planAttempts.push({
4465
+ attemptId,
4466
+ harnessId: outcome.harnessId,
4467
+ status: outcome.status,
4468
+ outcomeClass: outcome.outcomeClass,
4469
+ error: outcome.error,
4470
+ });
3857
4471
  break;
4472
+ }
3858
4473
  if (outcome.telemetry)
3859
4474
  attemptTelemetries.push({
3860
4475
  attemptId,
@@ -3865,6 +4480,7 @@ export class Orchestrator {
3865
4480
  attemptId,
3866
4481
  harnessId: outcome.harnessId,
3867
4482
  status: outcome.status,
4483
+ outcomeClass: outcome.outcomeClass,
3868
4484
  error: outcome.error,
3869
4485
  });
3870
4486
  if (outcome.status !== "success") {
@@ -3878,7 +4494,7 @@ export class Orchestrator {
3878
4494
  reason: "planner_failed",
3879
4495
  });
3880
4496
  }
3881
- else if (fallbackFrom || next === undefined) {
4497
+ else if (!input.signal?.aborted && (fallbackFrom || next === undefined)) {
3882
4498
  log.emit("route.fallback.exhausted", {
3883
4499
  harness_id: outcome.harnessId,
3884
4500
  attempt_id: attemptId,
@@ -3927,6 +4543,7 @@ export class Orchestrator {
3927
4543
  ledger,
3928
4544
  planAttempts,
3929
4545
  attemptTelemetries,
4546
+ budgetDenial: planBudgetDenial,
3930
4547
  }, "all planners failed");
3931
4548
  }
3932
4549
  if (input.signal?.aborted) {
@@ -4017,6 +4634,58 @@ export class Orchestrator {
4017
4634
  ? "metered"
4018
4635
  : "unknown";
4019
4636
  }
4637
+ /**
4638
+ * #27 / D-6: build the engine-side deps closure for the deep-scan bounded
4639
+ * synthesis reducer (packages/orchestrator/src/deepScanReducer.ts owns the
4640
+ * spawn/stream/settle machinery). The closure keeps the private
4641
+ * route/session/knob machinery HERE and hands the module only finished public
4642
+ * types (a `HarnessRunSpec`, cost evidence, a disposable home).
4643
+ */
4644
+ deepScanReducerDeps(input, contract, log) {
4645
+ return {
4646
+ newReadOnlyHome: () => resolveReadOnlyRouteContext(this.execRootOf(input)),
4647
+ costEvidence: (harnessId, attemptId) =>
4648
+ // The reducer admits under a finite estimate floor (mirror of the n>1
4649
+ // scout reserve) so a subscription route is not refused for lacking a
4650
+ // cash quote.
4651
+ attemptCostEvidence(harnessId, attemptId, this.estimateUsdFloor(input.repoRoot), this.routeBillingKnowledge(input, harnessId)),
4652
+ buildSpec: (routed, homeEnv, prompt, attemptId) => {
4653
+ const knobs = this.routeSpecKnobs(routed, contract, undefined, input.effort);
4654
+ const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
4655
+ const sessionFields = this.sessionSpecFields(input, routed.adapter.id, log);
4656
+ const spec = HarnessRunSpec.parse({
4657
+ session_id: newId("ses"),
4658
+ intent: "synthesize",
4659
+ prompt,
4660
+ cwd: this.execRootOf(input),
4661
+ access: "readonly",
4662
+ attachments: [],
4663
+ auth_preference: sessionFields.auth_preference,
4664
+ credential_profile: sessionFields.credential_profile,
4665
+ // A FRESH session — the reducer never resumes a scout's conversation.
4666
+ resume_session_id: null,
4667
+ ...this.harnessSpecKnobs(contract, knobs, "synthesize"),
4668
+ env_inheritance: envInheritance(this.config(input.repoRoot)),
4669
+ env: homeEnv,
4670
+ });
4671
+ // D-16: compile the WorkReport transport onto the reducer spec (the
4672
+ // reducer is non-interactive) so its output is unwrapped + finalized
4673
+ // through the shared attempt contract, not a fourth deliverable predicate.
4674
+ const workReportMode = this.applyWorkEnvelope(spec, this.workReportEnvelopeFor(routed, contract, false));
4675
+ return {
4676
+ spec,
4677
+ webPolicy: knobs.webPolicy,
4678
+ effectiveWeb,
4679
+ model: knobs.model,
4680
+ workReportMode,
4681
+ };
4682
+ },
4683
+ hardTimeoutMs: reviewerTimeoutMs(this.config(input.repoRoot)),
4684
+ inactivityTimeoutMs: harnessInactivityTimeoutMs(this.config(input.repoRoot)),
4685
+ webRequired: contract.external_context.web_required,
4686
+ quotaEventSink: this.deps.quotaEventSink,
4687
+ };
4688
+ }
4020
4689
  async runReadOnlyReport(input, opts, announce) {
4021
4690
  const taskId = input.taskId ?? newId("task");
4022
4691
  const runId = input.runId ?? newId("run");
@@ -4066,7 +4735,10 @@ export class Orchestrator {
4066
4735
  const laneRun = Boolean(input.threadId) && opts.mode === "ask" && !opts.deepScan;
4067
4736
  let adapters;
4068
4737
  try {
4069
- adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, log, roHome);
4738
+ adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, log, roHome, runId,
4739
+ // Deep-scan repeats a surviving harness to reach scout width; a dropped
4740
+ // lane must not clamp coverage (QA-043 clamp is best-of-only).
4741
+ opts.deepScan === true);
4070
4742
  if (!opts.deepScan) {
4071
4743
  const seen = new Set();
4072
4744
  adapters = adapters.filter((routed) => {
@@ -4081,11 +4753,15 @@ export class Orchestrator {
4081
4753
  roHome.dispose();
4082
4754
  const message = safeErrorMessage(err);
4083
4755
  store.writeText(join(paths.contextDir, "context_error.md"), `# Routing Error\n\n${message}\n`);
4756
+ // A routing preflight refusal is a config_error, not harness_unavailable
4757
+ // (A-1/#22): classify identically across every strategy's routing catch.
4758
+ const routingFailure = routingFailureClassification(err);
4084
4759
  writeFailure(store, paths, {
4085
4760
  phase: "routing",
4086
- category: "harness_unavailable",
4761
+ category: routingFailure.category,
4087
4762
  safeMessage: message,
4088
4763
  runDir: paths.root,
4764
+ ...(routingFailure.nextActions ? { nextActions: routingFailure.nextActions } : {}),
4089
4765
  });
4090
4766
  store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
4091
4767
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
@@ -4113,34 +4789,101 @@ export class Orchestrator {
4113
4789
  const attemptTelemetries = [];
4114
4790
  let fallbackOpen = false;
4115
4791
  let budgetStopped = false;
4792
+ // QA-050: keep the ledger's TYPED denial (not just a boolean) so the
4793
+ // terminal names the budget sub-code, the refused route/slot, and budget
4794
+ // remediation instead of a harness auth/setup template. First denial wins —
4795
+ // it is the decisive pre-spawn refusal.
4796
+ let budgetDenial = null;
4116
4797
  // in a swarm the same harness appears in several slots; resuming the
4117
4798
  // ONE native session id from all of them races the vendor's session store
4118
4799
  // (and is semantically wrong — N explorers continuing one conversation).
4119
4800
  // Grant resume to the first slot of each harness only; the rest run fresh.
4120
4801
  const resumeGranted = new Set();
4121
- const runReadonlyAttempt = async (routed, idx, modelOverride) => {
4802
+ // QA-019 disclosure: a scout the budget gate refused before spawn is
4803
+ // recorded as a failed attempt with a placeholder telemetry and a
4804
+ // budget_denied marker. It enters the denominator (honest 1/2), omissions,
4805
+ // and telemetry.yaml, and the marker lets the all-denied terminal still
4806
+ // route through the QA-050 budget classifier (never harness_error).
4807
+ const recordBudgetDeniedScout = (harnessId, attemptId, reason) => {
4808
+ const telemetry = createAttemptTelemetry(contract.external_context.policy, contract.external_context.web_required, contract.external_context.effective_mode);
4809
+ const error = `budget denied before spawn: ${reason}`;
4810
+ setAttemptOutcome(telemetry, {
4811
+ deliverablePresent: false,
4812
+ gatesPassed: null,
4813
+ harnessErrored: false,
4814
+ webRequiredUnsatisfied: false,
4815
+ });
4816
+ attempts.push({
4817
+ attemptId,
4818
+ harnessId,
4819
+ status: "failed",
4820
+ report: "",
4821
+ error,
4822
+ telemetry,
4823
+ budgetDenied: true,
4824
+ });
4825
+ attemptTelemetries.push({ attemptId, harnessId, telemetry });
4826
+ if (opts.deepScan) {
4827
+ store.writeText(join(paths.findingsDir, `${attemptId}-budget-denied.md`), `# Explorer ${attemptId} not started\n\n${error}\n`);
4828
+ }
4829
+ };
4830
+ const runReadonlyAttempt = async (routed, idx, modelOverride,
4831
+ // D-16d: a one-shot continuation re-run injects its checkpoint packet
4832
+ // pointer here; the attempt runs a FRESH session (resume is never granted
4833
+ // to a same-adapter follow-up slot) and is tagged `-cont`.
4834
+ continuationPointer,
4835
+ // D-16d: fired exactly once AFTER the budget lease is granted and BEFORE the
4836
+ // attempt streams — the continuation caller emits run.continuation here so
4837
+ // the disclosure never precedes (or outlives) a denied lease. The result
4838
+ // carries the denial reason so a refusal discloses run.continuation.denied.
4839
+ onLaunch) => {
4122
4840
  const adapter = routed.adapter;
4123
- const attemptId = modelOverride
4124
- ? `a${String(idx + 1).padStart(2, "0")}-fb`
4125
- : `a${String(idx + 1).padStart(2, "0")}`;
4841
+ const attemptId = continuationPointer
4842
+ ? `a${String(idx + 1).padStart(2, "0")}-cont`
4843
+ : modelOverride
4844
+ ? `a${String(idx + 1).padStart(2, "0")}-fb`
4845
+ : `a${String(idx + 1).padStart(2, "0")}`;
4126
4846
  const budgetSignalState = { quotaPressureDisclosed: false };
4127
4847
  const lease = ledger.reserve({
4128
4848
  taskId,
4129
4849
  attemptId,
4130
4850
  intent: opts.intent,
4131
4851
  harnessId: adapter.id,
4132
- cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
4852
+ // QA-019: an n>1 deep-scan scout admits under a FINITE estimate floor
4853
+ // (mirror of the candidate loop): the first scout reserves without a
4854
+ // floor, but later scouts pass the repo's usd floor so a subscription
4855
+ // swarm is not refused for lacking a per-attempt cash quote under a cap.
4856
+ cost: attemptCostEvidence(adapter.id, attemptId, opts.deepScan && idx > 0 ? this.estimateUsdFloor(input.repoRoot) : undefined, this.routeBillingKnowledge(input, adapter.id)),
4133
4857
  });
4134
4858
  if (!lease.granted) {
4135
4859
  log.emit("budget.lease.created", {
4136
4860
  granted: false,
4137
4861
  reason: lease.reason,
4862
+ denied: lease.denied,
4138
4863
  attempt_id: attemptId,
4139
4864
  harness_id: adapter.id,
4140
4865
  });
4141
4866
  budgetStopped = true;
4142
- return;
4867
+ budgetDenial ??= {
4868
+ code: lease.denied ?? "hard_cap",
4869
+ reason: lease.reason ?? "budget lease denied",
4870
+ harnessId: adapter.id,
4871
+ attemptId,
4872
+ };
4873
+ // QA-019 disclosure: a still-denied deep-scan scout must not vanish from
4874
+ // the denominator. Record a placeholder failed attempt with a
4875
+ // budget_denied marker so the explore-findings map counts it (1/2, not
4876
+ // 1/1), omissions and telemetry record the denial, and the all-denied
4877
+ // terminal still routes through the QA-050 budget classifier. The
4878
+ // sequential ask/audit path has no denominator — a denial there stays a
4879
+ // pure budget stop (no phantom failed attempt), preserving its terminal.
4880
+ if (opts.deepScan) {
4881
+ recordBudgetDeniedScout(adapter.id, attemptId, lease.reason ?? "budget lease denied");
4882
+ }
4883
+ return { status: "budget_denied", reason: lease.reason ?? "budget lease denied" };
4143
4884
  }
4885
+ // Lease granted: the attempt is now committed to run — disclose the launch.
4886
+ onLaunch?.();
4144
4887
  const knobs = this.routeSpecKnobs(routed, contract, modelOverride, input.effort);
4145
4888
  const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
4146
4889
  const explorerPrompt = (opts.deepScan
@@ -4157,12 +4900,15 @@ export class Orchestrator {
4157
4900
  const laneContinuity = laneRun
4158
4901
  ? await this.resolveContinuity(input, adapter.id, sessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null, grantResume, store, paths, this.execRootOf(input), log)
4159
4902
  : null;
4903
+ // D-16d: the continuation packet pointer rides after the lane pointer so
4904
+ // the fresh session is re-grounded in the exhausted attempt's work.
4905
+ const promptWithPointers = [explorerPrompt, laneContinuity?.pointerLine, continuationPointer]
4906
+ .filter((p) => Boolean(p))
4907
+ .join("\n\n");
4160
4908
  let spec = HarnessRunSpec.parse({
4161
4909
  session_id: newId("ses"),
4162
4910
  intent: opts.intent,
4163
- prompt: laneContinuity?.pointerLine
4164
- ? `${explorerPrompt}\n\n${laneContinuity.pointerLine}`
4165
- : explorerPrompt,
4911
+ prompt: promptWithPointers,
4166
4912
  cwd: this.execRootOf(input),
4167
4913
  access: "readonly",
4168
4914
  // ASK/EXPLORE/AUDIT read-only runs must forward the user's attachments —
@@ -4186,6 +4932,9 @@ export class Orchestrator {
4186
4932
  const reportInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
4187
4933
  if (reportInteraction)
4188
4934
  spec.extra["interactionChannel"] = reportInteraction;
4935
+ // D-16: compile the WorkReport envelope for the read-only lane.
4936
+ const readonlyWorkEnvelope = this.workReportEnvelopeFor(routed, contract, Boolean(reportInteraction));
4937
+ const readonlyWorkMode = this.applyWorkEnvelope(spec, readonlyWorkEnvelope);
4189
4938
  const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
4190
4939
  const answer = new AnswerAssembly();
4191
4940
  const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
@@ -4289,9 +5038,13 @@ export class Orchestrator {
4289
5038
  }
4290
5039
  catch (err) {
4291
5040
  harnessError = safeErrorMessage(err);
5041
+ // #31: classify the throw so the retry gate and required-actions read
5042
+ // a typed category (watchdog timeout vs process crash).
5043
+ telemetry.transientFailures.push(classifyAdapterThrow({ errorName: err instanceof Error ? err.name : null }));
4292
5044
  }
4293
- const transient = telemetry.transientFailures.at(-1) ?? null;
4294
- const sawTransient = telemetry.transientFailures.length > transientStart;
5045
+ const newTransients = telemetry.transientFailures.slice(transientStart);
5046
+ const transient = newTransients.at(-1) ?? null;
5047
+ const sawRetryable = newTransients.some((f) => f.retryable);
4295
5048
  const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
4296
5049
  const reportSoFar = answer.text();
4297
5050
  // W5.4 reactive failover, READ-ONLY lane (same contract as the
@@ -4319,7 +5072,7 @@ export class Orchestrator {
4319
5072
  }
4320
5073
  }
4321
5074
  if (!harnessError ||
4322
- !sawTransient ||
5075
+ !sawRetryable ||
4323
5076
  reportSoFar.length > 0 ||
4324
5077
  nativeTry >= retryPolicy.maxRetries ||
4325
5078
  input.signal?.aborted)
@@ -4330,6 +5083,7 @@ export class Orchestrator {
4330
5083
  harness_id: adapter.id,
4331
5084
  attempt_id: attemptId,
4332
5085
  kind: transient?.kind ?? "unknown",
5086
+ category: transient?.category ?? "unknown_harness_error",
4333
5087
  native_try: nativeTry + 1,
4334
5088
  });
4335
5089
  log.emit("route.transient.retry_scheduled", {
@@ -4350,26 +5104,43 @@ export class Orchestrator {
4350
5104
  log.emit("route.transient.exhausted", {
4351
5105
  harness_id: adapter.id,
4352
5106
  attempt_id: attemptId,
5107
+ category: telemetry.transientFailures.at(-1)?.category ?? "unknown_harness_error",
4353
5108
  retries: retryPolicy.maxRetries,
4354
5109
  });
4355
5110
  }
4356
5111
  attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
4357
- const report = redactSecrets(answer.text());
5112
+ // D-16: un-nest the {work_report, output} envelope; the OUTPUT is the report.
5113
+ const roUnwrapped = unwrapWorkReportEnvelope(answer.machineText() ?? "", readonlyWorkMode, {
5114
+ sideToolReport: telemetry.sideToolWorkReport ?? undefined,
5115
+ });
5116
+ const report = redactSecrets(roUnwrapped.deliverable);
4358
5117
  const unrecovered = unrecoveredToolErrors(telemetry);
4359
5118
  const webBlocked = webUnsatisfied(telemetry);
4360
- const deliverablePresent = report.length > 0;
5119
+ const reportPresent = report.length > 0;
4361
5120
  if (!harnessError && webBlocked) {
4362
- harnessError = `web evidence unsatisfied: ${telemetry.web.errorSummary ?? (telemetry.web.attempted ? "web tool failed without verified recovery" : "web evidence required but never attempted")}`;
5121
+ harnessError = webEvidenceFailure(telemetry.web);
4363
5122
  }
4364
- if (!harnessError && unrecovered.length > 0 && !deliverablePresent) {
4365
- const first = unrecovered[0];
4366
- harnessError = `${first.tool} failed without recovery: ${first.summary}`;
5123
+ harnessError ??= unrecoveredToolErrorFailure(unrecovered, reportPresent);
5124
+ const roFinalized = finalizeAttempt({
5125
+ deliverableEvidence: reportPresent,
5126
+ harnessErrored: harnessError !== null && !webBlocked,
5127
+ workReport: roUnwrapped.workReport,
5128
+ workReportSource: roUnwrapped.source,
5129
+ workReportViolation: roUnwrapped.contractViolation,
5130
+ contextTerminalExhausted: telemetry.contextExhausted,
5131
+ });
5132
+ // A broken WorkReport contract is a hard failure ONLY when the finalizer
5133
+ // ranked it so — a concurrent terminal context exhaustion outranks it
5134
+ // (interrupted, not a contract failure). Let the finalizer own precedence.
5135
+ if (!harnessError && roFinalized.outcomeClass === "contract_failure") {
5136
+ harnessError = `work_report contract: ${roUnwrapped.contractViolation}`;
4367
5137
  }
4368
5138
  setAttemptOutcome(telemetry, {
4369
- deliverablePresent,
5139
+ deliverablePresent: roFinalized.deliverablePresent,
4370
5140
  gatesPassed: null,
4371
5141
  harnessErrored: harnessError !== null && !webBlocked,
4372
5142
  webRequiredUnsatisfied: webBlocked,
5143
+ workState: roFinalized.workState,
4373
5144
  });
4374
5145
  if (harnessError) {
4375
5146
  log.emit("harness.completed", {
@@ -4390,21 +5161,25 @@ export class Orchestrator {
4390
5161
  if (opts.deepScan) {
4391
5162
  store.writeText(join(paths.findingsDir, `${attemptId}-error.md`), `# Explorer ${attemptId} failed\n\n${harnessError}\n`);
4392
5163
  }
4393
- return;
5164
+ return { status: "launched" };
4394
5165
  }
5166
+ // D-16 r8: an interrupted scout is a FAILED omission, never reducer input;
5167
+ // the sequential ask/audit winner folds at its own terminal (stays success).
5168
+ const scoutInterrupted = opts.deepScan && roFinalized.outcomeClass === "interrupted";
4395
5169
  log.emit("harness.completed", {
4396
5170
  harness_id: adapter.id,
4397
5171
  attempt_id: attemptId,
4398
- status: "success",
5172
+ status: scoutInterrupted ? "interrupted" : "success",
4399
5173
  ...telemetrySummary(telemetry),
4400
5174
  });
4401
5175
  attempts.push({
4402
5176
  attemptId,
4403
5177
  harnessId: adapter.id,
4404
- status: "success",
5178
+ status: scoutInterrupted ? "failed" : "success",
4405
5179
  report: report || "(no output)",
4406
- error: null,
5180
+ error: scoutInterrupted ? "context capacity exhausted before the scout completed" : null,
4407
5181
  telemetry,
5182
+ ...(scoutInterrupted ? { interrupted: true } : {}),
4408
5183
  });
4409
5184
  if (opts.deepScan) {
4410
5185
  const warningNote = toolWarnings(telemetry).length
@@ -4412,22 +5187,82 @@ export class Orchestrator {
4412
5187
  .map((e) => `${e.tool}: ${e.summary}`)
4413
5188
  .join("; ")}\n`
4414
5189
  : "";
4415
- store.writeText(join(paths.findingsDir, `${attemptId}.md`), `# Explorer ${attemptId} (${adapter.id})\n\n${report || "(no output)"}${warningNote}\n`);
5190
+ const scoutTag = scoutInterrupted
5191
+ ? " interrupted (context capacity exhausted)"
5192
+ : ` (${adapter.id})`;
5193
+ store.writeText(join(paths.findingsDir, `${attemptId}${scoutInterrupted ? "-interrupted" : ""}.md`), `# Explorer ${attemptId}${scoutTag}\n\n${report || "(no output)"}${warningNote}\n`);
4416
5194
  }
5195
+ return { status: "launched" };
4417
5196
  };
4418
5197
  try {
4419
5198
  if (opts.deepScan) {
4420
- // Explorer swarm runs in parallel (bounded), mirroring parallel candidates.
4421
- await runBounded(adapters, Math.min(adapters.length, MAX_PARALLEL_CANDIDATES), runReadonlyAttempt);
5199
+ // Explorer swarm runs in parallel (bounded), mirroring parallel
5200
+ // candidates. The swarm has no continuation lane, so the launched/denied
5201
+ // return is unused here.
5202
+ await runBounded(adapters, Math.min(adapters.length, MAX_PARALLEL_CANDIDATES), async (routed, idx) => {
5203
+ await runReadonlyAttempt(routed, idx);
5204
+ });
4422
5205
  }
4423
5206
  else {
4424
5207
  // ask/audit: sequential fallback chain — first success wins; a blocked
4425
5208
  // attempt opens a fallback arc to the next eligible harness.
5209
+ let continuationCount = 0; // D-16d: one-shot budget across the chain
4426
5210
  for (const [idx, routed] of adapters.entries()) {
4427
5211
  if (input.signal?.aborted)
4428
5212
  break;
4429
5213
  await runReadonlyAttempt(routed, idx);
4430
5214
  let last = attempts[attempts.length - 1];
5215
+ // D-16d one-shot continuation: an ELIGIBLE terminal context exhaustion
5216
+ // (repeated_refill, no completed report) gets ONE fresh-session re-run,
5217
+ // re-grounded by a mechanical checkpoint packet. On completion the
5218
+ // exhausted attempt is superseded so the continuation wins the terminal.
5219
+ if (last?.status === "success" && continuationCount === 0 && !budgetStopped) {
5220
+ const decision = decideContinuation({
5221
+ contextExhausted: last.telemetry.contextExhausted,
5222
+ contextExhaustedCause: last.telemetry.contextExhaustedCause,
5223
+ workStateCompleted: last.telemetry.outcome?.workState?.state === "completed",
5224
+ continuationCount,
5225
+ runKind: "read_only",
5226
+ });
5227
+ if (decision.eligible) {
5228
+ const exhausted = last;
5229
+ const packet = buildContinuationPacket(synthesizeContinuationRequest({
5230
+ harness: exhausted.harnessId,
5231
+ profileId: input.credentialProfileId ?? null,
5232
+ priorPrompt: prompt,
5233
+ priorOutput: exhausted.report,
5234
+ }));
5235
+ // The continuation lease is reserved INSIDE runReadonlyAttempt; emit
5236
+ // run.continuation via onLaunch (fires only AFTER the grant, before the
5237
+ // stream) so a denied lease never leaves a false "launched" disclosure
5238
+ // nor consumes the one-shot. A refusal emits run.continuation.denied.
5239
+ const outcome = await runReadonlyAttempt(routed, idx, undefined, packet.pointerLine ?? undefined, () => {
5240
+ continuationCount += 1;
5241
+ log.emit("run.continuation", {
5242
+ from_attempt: exhausted.attemptId,
5243
+ cause: last.telemetry.contextExhaustedCause,
5244
+ continuation_count: continuationCount,
5245
+ packet_turns: packet.continuity.disclosure.packetTurns,
5246
+ });
5247
+ });
5248
+ if (outcome.status === "budget_denied") {
5249
+ log.emit("run.continuation.denied", {
5250
+ from_attempt: exhausted.attemptId,
5251
+ cause: last.telemetry.contextExhaustedCause,
5252
+ reason: outcome.reason,
5253
+ });
5254
+ }
5255
+ else {
5256
+ const cont = attempts[attempts.length - 1];
5257
+ if (cont && cont !== exhausted && cont.status === "success") {
5258
+ exhausted.status = "failed";
5259
+ exhausted.error =
5260
+ exhausted.error ?? "superseded by one-shot continuation (context exhausted)";
5261
+ last = cont;
5262
+ }
5263
+ }
5264
+ }
5265
+ }
4431
5266
  // Per-harness fallback_model: one same-harness retry on FAILURE (not
4432
5267
  // policy blocks) before falling through to the next harness.
4433
5268
  const fallbackModel = routed.settings?.fallbackModel;
@@ -4498,18 +5333,27 @@ export class Orchestrator {
4498
5333
  // injected API-route state. Vendor-owned native credentials were not copied.
4499
5334
  roHome.dispose();
4500
5335
  }
4501
- if (input.signal?.aborted) {
4502
- return cancelledResult(log, runId, taskId, opts.mode, paths.root, attempts.map((a) => ({
4503
- attemptId: a.attemptId,
4504
- harnessId: a.harnessId,
4505
- status: a.status,
4506
- })), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null), ledger.spend(), input.signal, store);
4507
- }
5336
+ const candidateSummaries = attempts.map((a) => ({
5337
+ attemptId: a.attemptId,
5338
+ harnessId: a.harnessId,
5339
+ status: a.status,
5340
+ }));
5341
+ const cancelledTerminal = () => cancelledResult(log, runId, taskId, opts.mode, paths.root, candidateSummaries, () => this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null), ledger.spend(), input.signal, store);
5342
+ if (input.signal?.aborted)
5343
+ return cancelledTerminal();
4508
5344
  const succeededReadonly = attempts.filter((a) => a.status === "success");
4509
5345
  if (!opts.deepScan && succeededReadonly.length === 0) {
4510
5346
  const last = attempts[attempts.length - 1];
4511
5347
  const webBlocked = attempts.some((a) => a.status === "blocked");
4512
- const singleError = last?.error ?? (budgetStopped ? "budget exhausted before any attempt" : "harness failed");
5348
+ // QA-050: a budget refusal is a BUDGET failure, not a harness one route
5349
+ // it through the shared classifier so phase/category/code/route and the
5350
+ // remediation are budget-typed (never auth/setup) across every mode.
5351
+ const budgetMapping = budgetStopped && !webBlocked
5352
+ ? classifyBudgetFailure({ denial: budgetDenial, terminal: ledger.terminal() })
5353
+ : null;
5354
+ const singleError = budgetMapping?.safeMessage ??
5355
+ last?.error ??
5356
+ (budgetStopped ? "budget exhausted before any attempt" : "harness failed");
4513
5357
  if (fallbackOpen || webBlocked) {
4514
5358
  log.emit("route.fallback.exhausted", {
4515
5359
  harness_id: last?.harnessId ?? null,
@@ -4529,29 +5373,47 @@ export class Orchestrator {
4529
5373
  });
4530
5374
  }
4531
5375
  this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null);
4532
- store.writeText(join(paths.contextDir, "context_error.md"), `# Harness Error\n\n${singleError}\n`);
4533
- writeFailure(store, paths, {
4534
- phase: "harness",
4535
- category: webBlocked ? "policy" : budgetStopped ? "budget" : "harness_error",
4536
- harnessId: last?.harnessId,
4537
- attemptId: last?.attemptId,
4538
- safeMessage: singleError,
4539
- eventRefs: attempts.map((a) => `attempts/${a.attemptId}/events.jsonl`),
4540
- runDir: paths.root,
4541
- nextActions: ["Open diagnostics", "Check harness authentication", "Retry after setup"],
5376
+ store.writeText(join(paths.contextDir, "context_error.md"), `# ${budgetMapping ? "Budget Denied" : "Harness Error"}\n\n${singleError}\n`);
5377
+ const roEventRefs = attempts.map((a) => `attempts/${a.attemptId}/events.jsonl`);
5378
+ if (budgetMapping) {
5379
+ writeFailure(store, paths, budgetFailureRecord(budgetMapping, { eventRefs: roEventRefs, runDir: paths.root }));
5380
+ }
5381
+ else {
5382
+ // #31: classify the harness cause across the read-only attempts so auth
5383
+ // guidance appears only on a real auth failure.
5384
+ const roCategory = dominantHarnessFailureCategory(attemptTelemetries.flatMap((a) => a.telemetry.transientFailures));
5385
+ writeFailure(store, paths, {
5386
+ phase: "harness",
5387
+ category: webBlocked ? "policy" : "harness_error",
5388
+ harnessId: last?.harnessId,
5389
+ attemptId: last?.attemptId,
5390
+ safeMessage: singleError,
5391
+ eventRefs: roEventRefs,
5392
+ runDir: paths.root,
5393
+ nextActions: harnessFailureNextActions(roCategory),
5394
+ });
5395
+ }
5396
+ // QA-036: re-check the DELIVERABLE through the shared finalizer helper —
5397
+ // a blocked Ask that produced NO answer can no longer read as a succeeded
5398
+ // "Needs review" run (exit 0); it is an honest failure (exit 1).
5399
+ const roTerminal = readOnlyNoSuccessTerminal({
5400
+ webBlocked,
5401
+ hasDeliverable: partialReport.trim().length > 0,
5402
+ budgetStopped,
5403
+ attemptsCount: attempts.length,
5404
+ });
5405
+ const terminalFacts = makeOutcomeFacts(roTerminal.lifecycle, {
5406
+ ...(roTerminal.review ? { review: roTerminal.review } : {}),
5407
+ reason: roTerminal.reason,
4542
5408
  });
4543
- const terminalFacts = webBlocked
4544
- ? makeOutcomeFacts("succeeded", { review: "blocked", reason: "review_blocked" })
4545
- : budgetStopped && attempts.length === 0
4546
- ? makeOutcomeFacts("failed", { reason: "budget_exhausted" })
4547
- : makeOutcomeFacts("failed", { reason: "harness_failed" });
4548
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Harness: ${last?.harnessId ?? "none"}\n- Lifecycle: ${terminalFacts.lifecycle}${terminalFacts.reason ? ` (${terminalFacts.reason})` : ""}\n\n${singleError}\n`);
5409
+ const terminalHarnessId = budgetMapping?.harnessId ?? last?.harnessId;
5410
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Harness: ${terminalHarnessId ?? "none"}\n- Lifecycle: ${terminalFacts.lifecycle}${terminalFacts.reason ? ` (${terminalFacts.reason})` : ""}\n\n${singleError}\n`);
4549
5411
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
4550
5412
  if (terminalFacts.lifecycle === "succeeded") {
4551
5413
  log.emit("run.blocked", {
4552
5414
  lifecycle: terminalFacts.lifecycle,
4553
5415
  facts: terminalFacts,
4554
- harness_id: last?.harnessId,
5416
+ harness_id: terminalHarnessId,
4555
5417
  error: singleError,
4556
5418
  failure_ref: "final/failure.yaml",
4557
5419
  });
@@ -4561,7 +5423,8 @@ export class Orchestrator {
4561
5423
  lifecycle: terminalFacts.lifecycle,
4562
5424
  facts: terminalFacts,
4563
5425
  reason: terminalFacts.reason,
4564
- harness_id: last?.harnessId,
5426
+ phase: budgetMapping?.phase,
5427
+ harness_id: terminalHarnessId,
4565
5428
  error: singleError,
4566
5429
  failure_ref: "final/failure.yaml",
4567
5430
  });
@@ -4585,46 +5448,62 @@ export class Orchestrator {
4585
5448
  }
4586
5449
  const succeeded = succeededReadonly;
4587
5450
  if (opts.deepScan && succeeded.length === 0) {
4588
- const message = attempts
4589
- .map((a) => `${a.attemptId}/${a.harnessId}: ${a.error ?? "failed"}`)
4590
- .join("\n");
4591
5451
  const blocked = attempts.some((a) => a.status === "blocked");
4592
- this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null);
4593
- writeFailure(store, paths, {
4594
- phase: "harness",
4595
- category: blocked ? "policy" : "harness_error",
4596
- safeMessage: message || "all explorers failed",
4597
- eventRefs: attempts.map((a) => `attempts/${a.attemptId}/events.jsonl`),
4598
- runDir: paths.root,
4599
- nextActions: [
4600
- "Open diagnostics",
4601
- "Check harness authentication",
4602
- "Reduce explore width",
4603
- "Retry after setup",
4604
- ],
4605
- });
4606
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Lifecycle: ${blocked ? "succeeded (needs review)" : "failed"}\n\n${message}\n`);
4607
- log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
4608
- const scanFailFacts = blocked
4609
- ? makeOutcomeFacts("succeeded", { review: "blocked", reason: "review_blocked" })
4610
- : makeOutcomeFacts("failed", { reason: "harness_failed" });
4611
- if (blocked)
4612
- log.emit("run.blocked", {
4613
- lifecycle: scanFailFacts.lifecycle,
4614
- facts: scanFailFacts,
4615
- phase: "harness",
4616
- error: message,
4617
- failure_ref: "final/failure.yaml",
5452
+ // QA-050/QA-019: an all-denied scan (finite-zero, or every scout refused
5453
+ // before spawn) is a BUDGET failure, not harness_error — route it through
5454
+ // the shared classifier. Only a pure-denial scan (no scout actually errored
5455
+ // in the harness) qualifies, so a real explorer failure is never masked.
5456
+ const scanBudgetMapping = budgetStopped && !blocked && attempts.every((a) => a.budgetDenied === true)
5457
+ ? classifyBudgetFailure({ denial: budgetDenial, terminal: ledger.terminal() })
5458
+ : null;
5459
+ // D-16 r8: ALL scouts out of context → the aggregate is interrupted.
5460
+ const allInterrupted = attempts.length > 0 && attempts.every((a) => a.interrupted === true);
5461
+ const scanFailFacts = allInterrupted
5462
+ ? makeOutcomeFacts("interrupted", { reason: "context_capacity_exhausted" })
5463
+ : makeOutcomeFacts("failed", {
5464
+ reason: scanBudgetMapping ? scanBudgetMapping.reason : "harness_failed",
4618
5465
  });
4619
- else
4620
- log.emit("run.failed", {
4621
- lifecycle: scanFailFacts.lifecycle,
4622
- facts: scanFailFacts,
4623
- reason: scanFailFacts.reason,
5466
+ const message = scanBudgetMapping
5467
+ ? scanBudgetMapping.safeMessage
5468
+ : attempts.map((a) => `${a.attemptId}/${a.harnessId}: ${a.error ?? "failed"}`).join("\n");
5469
+ this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null);
5470
+ if (scanBudgetMapping) {
5471
+ writeFailure(store, paths, budgetFailureRecord(scanBudgetMapping, {
5472
+ eventRefs: attempts.map((a) => `attempts/${a.attemptId}/events.jsonl`),
5473
+ runDir: paths.root,
5474
+ }));
5475
+ }
5476
+ else {
5477
+ // #31: classify the scout failures; keep the scan-specific width hint but
5478
+ // drop the unconditional auth line unless the cause was a real auth failure.
5479
+ const scanCategory = dominantHarnessFailureCategory(attemptTelemetries.flatMap((a) => a.telemetry.transientFailures));
5480
+ writeFailure(store, paths, {
4624
5481
  phase: "harness",
4625
- error: message,
4626
- failure_ref: "final/failure.yaml",
5482
+ category: blocked ? "policy" : "harness_error",
5483
+ safeMessage: message || "all explorers failed",
5484
+ eventRefs: attempts.map((a) => `attempts/${a.attemptId}/events.jsonl`),
5485
+ runDir: paths.root,
5486
+ nextActions: [
5487
+ ...harnessFailureNextActions(scanCategory).filter((a) => !a.startsWith("Retry")),
5488
+ "Reduce explore width",
5489
+ "Retry after setup",
5490
+ ],
4627
5491
  });
5492
+ }
5493
+ // QA-036: with ZERO successful explorers there is no synthesizable
5494
+ // deliverable, so a blocked scan can no longer read as a succeeded
5495
+ // "needs review" run (exit 0). An empty scan is a failure whether the
5496
+ // explorers were blocked or errored.
5497
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Lifecycle: ${scanFailFacts.lifecycle}${scanFailFacts.reason ? ` (${scanFailFacts.reason})` : ""}\n\n${message}\n`);
5498
+ log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
5499
+ log.emit("run.failed", {
5500
+ lifecycle: scanFailFacts.lifecycle,
5501
+ facts: scanFailFacts,
5502
+ reason: scanFailFacts.reason,
5503
+ phase: scanBudgetMapping ? scanBudgetMapping.phase : "harness",
5504
+ error: message,
5505
+ failure_ref: "final/failure.yaml",
5506
+ });
4628
5507
  return {
4629
5508
  spendUsd: ledger.spend(),
4630
5509
  runId,
@@ -4643,45 +5522,54 @@ export class Orchestrator {
4643
5522
  };
4644
5523
  }
4645
5524
  const unsuccessful = attempts.filter((a) => a.status !== "success");
4646
- const report = opts.deepScan
4647
- ? [
4648
- `Explorers succeeded: ${succeeded.length}/${attempts.length}.`,
4649
- "",
4650
- "## Synthesis",
4651
- ...succeeded.map((a) => {
4652
- const warnings = toolWarnings(a.telemetry);
4653
- const warningText = warnings.length
4654
- ? `\n\n> Tool warnings: ${warnings.map((e) => `${e.tool}: ${e.summary}`).join("; ")}`
4655
- : "";
4656
- return `\n### ${a.attemptId} / ${a.harnessId}\n\n${a.report}${warningText}`;
4657
- }),
4658
- "",
4659
- "## Omissions / Uncertainty",
4660
- ...(unsuccessful.length
4661
- ? unsuccessful.map((a) => `- ${a.attemptId} / ${a.harnessId} ${a.status}: ${a.error}`)
4662
- : [
4663
- "- No explorer failures recorded. Claims still need evidence review before edit execution.",
4664
- ]),
4665
- "",
4666
- "## Follow-up Questions",
4667
- "- Which findings should become a frozen implementation spec?",
4668
- "- Should web research be allowed for a second Explore pass?",
4669
- ].join("\n")
4670
- : (succeeded[0]?.report ?? "(no output)");
5525
+ // #27 / D-6: a multi-scout deep scan runs ONE bounded synthesis reducer over
5526
+ // the raw scout reports so the final artifact is a real merge, not a
5527
+ // concatenation. A single report (width-1) needs no merge; a failed/denied
5528
+ // reducer degrades to an HONEST raw scout bundle, never a fake synthesis. The
5529
+ // whole decision + reducer spawn lives in deepScanReducer.ts (its owner).
5530
+ let deepScanSynthesis = null;
5531
+ let reducedReport = null;
5532
+ if (opts.deepScan) {
5533
+ ({ deepScanSynthesis, reducedReport } = await resolveDeepScanSynthesis(this.deepScanReducerDeps(input, contract, log), {
5534
+ succeeded,
5535
+ adapters,
5536
+ budgetStopped,
5537
+ aborted: Boolean(input.signal?.aborted),
5538
+ taskId,
5539
+ goal: prompt,
5540
+ findingsDir: paths.findingsDir,
5541
+ ledger,
5542
+ log,
5543
+ paths,
5544
+ signal: input.signal,
5545
+ onHarnessEvent: input.onHarnessEvent,
5546
+ attemptTelemetries,
5547
+ }));
5548
+ }
5549
+ // INV-116: a cancel that landed WHILE the bounded reducer ran (scouts done,
5550
+ // synthesis in flight) is a cancelled terminal — never a laundered success.
5551
+ if (input.signal?.aborted)
5552
+ return cancelledTerminal();
5553
+ const report = !opts.deepScan
5554
+ ? (succeeded[0]?.report ?? "(no output)")
5555
+ : reducedReport !== null
5556
+ ? reducedReport
5557
+ : rawScoutBundle({ succeeded, unsuccessful, status: deepScanSynthesis });
4671
5558
  store.writeText(join(paths.finalDir, opts.artifactName), `# ${opts.title}\n\n${report}\n`);
4672
5559
  // ask is the only read-only strategy that can carry a structured-output
4673
- // contract (the boundary refuses the rest); validate the RAW answer text,
4674
- // not the titled artifact wrapper.
5560
+ // contract (the boundary refuses the rest); validate the FINAL aggregate
5561
+ // (the reduced synthesis, or the honest bundle for a degraded scan) — never
5562
+ // the first scout's raw report — and never the titled artifact wrapper.
4675
5563
  if (opts.mode === "ask" && contract.output_schema) {
4676
5564
  finalizeStructuredOutput({
4677
5565
  store,
4678
5566
  finalDir: paths.finalDir,
4679
5567
  log,
4680
5568
  schema: contract.output_schema,
4681
- answerText: succeeded[0]?.report ?? "",
5569
+ answerText: opts.deepScan ? report : (succeeded[0]?.report ?? ""),
4682
5570
  });
4683
5571
  }
4684
- this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, opts.deepScan ? null : (succeeded[0]?.attemptId ?? null));
5572
+ this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, opts.deepScan ? null : (succeeded[0]?.attemptId ?? null), deepScanSynthesis);
4685
5573
  log.emit("output.ready", {
4686
5574
  kind: opts.mode === "ask" ? "answer" : "report",
4687
5575
  path: `final/${opts.artifactName}`,
@@ -4712,8 +5600,31 @@ export class Orchestrator {
4712
5600
  // non-clean terminal is an aggregate paid-budget stop.
4713
5601
  let terminalFacts = makeOutcomeFacts("succeeded");
4714
5602
  const reportBudgetTerminal = ledger.terminal();
4715
- if (reportBudgetTerminal)
5603
+ if (reportBudgetTerminal) {
4716
5604
  terminalFacts = makeOutcomeFacts("failed", { reason: reportBudgetTerminal });
5605
+ }
5606
+ else if (!opts.deepScan) {
5607
+ // D-16: fold the winning read-only attempt's work_state into the terminal.
5608
+ // A terminal context exhaustion with no completed report ⇒ interrupted;
5609
+ // a needs_input/incomplete report ⇒ a succeeded run whose work_state
5610
+ // vetoes applyability and a clean exit (INV-116). answer.md was already
5611
+ // persisted from the unwrapped OUTPUT.
5612
+ const winnerTelemetry = succeeded[0]?.telemetry;
5613
+ const winnerWorkState = winnerTelemetry?.outcome?.workState;
5614
+ if (winnerTelemetry?.contextExhausted && winnerWorkState?.state !== "completed") {
5615
+ terminalFacts = makeOutcomeFacts("interrupted", { reason: "context_capacity_exhausted" });
5616
+ }
5617
+ else if (winnerWorkState?.state === "needs_input" ||
5618
+ winnerWorkState?.state === "incomplete") {
5619
+ terminalFacts = makeOutcomeFacts("succeeded", {
5620
+ reason: winnerWorkState.state === "needs_input" ? "input_required" : "work_incomplete",
5621
+ work_state: winnerWorkState,
5622
+ });
5623
+ }
5624
+ else if (winnerWorkState) {
5625
+ terminalFacts = makeOutcomeFacts("succeeded", { work_state: winnerWorkState });
5626
+ }
5627
+ }
4717
5628
  const harnessLabel = attempts
4718
5629
  .map((a) => `${a.attemptId}:${a.harnessId}:${a.status}`)
4719
5630
  .join(", ");
@@ -4732,13 +5643,17 @@ export class Orchestrator {
4732
5643
  },
4733
5644
  });
4734
5645
  log.emit("work_product.emitted", { kind: "report", winner: succeeded[0]?.attemptId ?? null });
4735
- if (terminalFacts.lifecycle === "failed") {
5646
+ const workVetoed = terminalFacts.work_state?.state === "needs_input" ||
5647
+ terminalFacts.work_state?.state === "incomplete";
5648
+ if (terminalFacts.lifecycle !== "succeeded") {
4736
5649
  writeFailure(store, paths, {
4737
5650
  phase: "executor",
4738
- category: "budget",
5651
+ category: terminalFacts.reason === "context_capacity_exhausted" ? "harness_error" : "budget",
4739
5652
  safeMessage: `read-only report ended ${terminalFacts.lifecycle}${terminalFacts.reason ? ` (${terminalFacts.reason.replaceAll("_", " ")})` : ""}`,
4740
5653
  runDir: paths.root,
4741
- nextActions: ["Inspect the report artifacts", "Adjust the budget and retry"],
5654
+ nextActions: terminalFacts.reason === "context_capacity_exhausted"
5655
+ ? ["Inspect the partial report", "Re-run with a narrower scope"]
5656
+ : ["Inspect the report artifacts", "Adjust the budget and retry"],
4742
5657
  });
4743
5658
  log.emit("run.failed", {
4744
5659
  lifecycle: terminalFacts.lifecycle,
@@ -4748,6 +5663,16 @@ export class Orchestrator {
4748
5663
  failure_ref: "final/failure.yaml",
4749
5664
  });
4750
5665
  }
5666
+ else if (workVetoed) {
5667
+ // D-16: a succeeded lifecycle whose work_state vetoes is a needs-me
5668
+ // terminal — run.blocked (not run.completed); the outcome-aware exit
5669
+ // projection returns non-zero from the same facts.
5670
+ log.emit("run.blocked", {
5671
+ lifecycle: terminalFacts.lifecycle,
5672
+ facts: terminalFacts,
5673
+ reason: terminalFacts.reason,
5674
+ });
5675
+ }
4751
5676
  else {
4752
5677
  log.emit("run.completed", {
4753
5678
  lifecycle: terminalFacts.lifecycle,
@@ -4765,11 +5690,7 @@ export class Orchestrator {
4765
5690
  winner: null,
4766
5691
  runDir: paths.root,
4767
5692
  summary: redactSecrets(report).slice(0, 400),
4768
- candidates: attempts.map((a) => ({
4769
- attemptId: a.attemptId,
4770
- harnessId: a.harnessId,
4771
- status: a.status,
4772
- })),
5693
+ candidates: candidateSummaries,
4773
5694
  };
4774
5695
  }
4775
5696
  }
@@ -4778,7 +5699,4 @@ function assertNoSecretLikeTokens(label, text) {
4778
5699
  throw new Error(`${label} contains secret-like token; refusing to persist artifact`);
4779
5700
  }
4780
5701
  }
4781
- function safeErrorMessage(err) {
4782
- return redactSecrets(err instanceof Error ? err.message : String(err));
4783
- }
4784
5702
  //# sourceMappingURL=orchestrator.js.map