@claudexor/orchestrator 2.1.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/attemptTelemetry.d.ts +9 -0
  2. package/dist/attemptTelemetry.d.ts.map +1 -1
  3. package/dist/attemptTelemetry.js +25 -0
  4. package/dist/attemptTelemetry.js.map +1 -1
  5. package/dist/attemptUsage.d.ts +19 -0
  6. package/dist/attemptUsage.d.ts.map +1 -0
  7. package/dist/attemptUsage.js +29 -0
  8. package/dist/attemptUsage.js.map +1 -0
  9. package/dist/candidateEvidence.d.ts +22 -0
  10. package/dist/candidateEvidence.d.ts.map +1 -0
  11. package/dist/candidateEvidence.js +40 -0
  12. package/dist/candidateEvidence.js.map +1 -0
  13. package/dist/candidateOutputs.d.ts +56 -0
  14. package/dist/candidateOutputs.d.ts.map +1 -0
  15. package/dist/candidateOutputs.js +209 -0
  16. package/dist/candidateOutputs.js.map +1 -0
  17. package/dist/continuity-facts.d.ts +37 -0
  18. package/dist/continuity-facts.d.ts.map +1 -0
  19. package/dist/continuity-facts.js +100 -0
  20. package/dist/continuity-facts.js.map +1 -0
  21. package/dist/continuity-summary.d.ts +45 -0
  22. package/dist/continuity-summary.d.ts.map +1 -0
  23. package/dist/continuity-summary.js +113 -0
  24. package/dist/continuity-summary.js.map +1 -0
  25. package/dist/continuity.d.ts +119 -0
  26. package/dist/continuity.d.ts.map +1 -0
  27. package/dist/continuity.js +220 -0
  28. package/dist/continuity.js.map +1 -0
  29. package/dist/contract-gates.d.ts +2 -3
  30. package/dist/contract-gates.d.ts.map +1 -1
  31. package/dist/contract-gates.js +2 -3
  32. package/dist/contract-gates.js.map +1 -1
  33. package/dist/council.d.ts +57 -0
  34. package/dist/council.d.ts.map +1 -0
  35. package/dist/council.js +98 -0
  36. package/dist/council.js.map +1 -0
  37. package/dist/credential-profiles.d.ts +37 -1
  38. package/dist/credential-profiles.d.ts.map +1 -1
  39. package/dist/credential-profiles.js +111 -3
  40. package/dist/credential-profiles.js.map +1 -1
  41. package/dist/diffReview.d.ts +3 -0
  42. package/dist/diffReview.d.ts.map +1 -1
  43. package/dist/diffReview.js +3 -0
  44. package/dist/diffReview.js.map +1 -1
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +1 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/orchestrator.d.ts +218 -62
  50. package/dist/orchestrator.d.ts.map +1 -1
  51. package/dist/orchestrator.js +1078 -1236
  52. package/dist/orchestrator.js.map +1 -1
  53. package/dist/planQuestions.d.ts +11 -0
  54. package/dist/planQuestions.d.ts.map +1 -0
  55. package/dist/planQuestions.js +119 -0
  56. package/dist/planQuestions.js.map +1 -0
  57. package/dist/planRun.d.ts +114 -0
  58. package/dist/planRun.d.ts.map +1 -0
  59. package/dist/planRun.js +346 -0
  60. package/dist/planRun.js.map +1 -0
  61. package/dist/runSupport.d.ts +31 -80
  62. package/dist/runSupport.d.ts.map +1 -1
  63. package/dist/runSupport.js +33 -106
  64. package/dist/runSupport.js.map +1 -1
  65. package/dist/runTerminals.d.ts.map +1 -1
  66. package/dist/runTerminals.js +18 -7
  67. package/dist/runTerminals.js.map +1 -1
  68. package/package.json +18 -19
  69. package/dist/orchestrateExecutor.d.ts +0 -43
  70. package/dist/orchestrateExecutor.d.ts.map +0 -1
  71. package/dist/orchestrateExecutor.js +0 -207
  72. package/dist/orchestrateExecutor.js.map +0 -1
  73. package/dist/orchestratePlanner.d.ts +0 -12
  74. package/dist/orchestratePlanner.d.ts.map +0 -1
  75. package/dist/orchestratePlanner.js +0 -91
  76. package/dist/orchestratePlanner.js.map +0 -1
  77. package/dist/outcomeReducer.d.ts +0 -25
  78. package/dist/outcomeReducer.d.ts.map +0 -1
  79. package/dist/outcomeReducer.js +0 -121
  80. package/dist/outcomeReducer.js.map +0 -1
@@ -1,23 +1,25 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { observeNativeSessionEvent, preflightCredentialProfile, preflightDefaultSubject, resolveCredentialProfile, resumeSessionForProfile, rotateSpecOnTypedLimit, } from "./credential-profiles.js";
1
+ import { existsSync } from "node:fs";
2
+ import { observeNativeSessionEvent, preflightCredentialProfile, preflightDefaultSubject, resolveCredentialProfile, resumeSessionForProfile, rotateSpecOnTypedLimit, selectedProfileAvailability, } from "./credential-profiles.js";
3
3
  import { writeRunTelemetryArtifact } from "./runTelemetryWriter.js";
4
+ import { buildFileBackedSynthesisInput, materializeWinnerOutputs, stageFileBackedContext, writeCandidateAttemptArtifacts, } from "./candidateOutputs.js";
5
+ import { processAttemptUsage } from "./attemptUsage.js";
6
+ import { toCandidateEvidence } from "./candidateEvidence.js";
4
7
  import { capabilityIntents } from "@claudexor/gateway";
5
8
  import { policyFindings } from "./policyFindings.js";
6
9
  import { join } from "node:path";
7
- import { HarnessRunSpec, OrchestrateContract as OrchestrateContractSchema, DecisionRecord as DecisionRecordSchema, WorkProduct as WorkProductSchema, SessionReboundLineage as SessionReboundLineageSchema, SpecPack as SpecPackZ, ModeKind as ModeKindSchema, SCHEMA_VERSION, TRUST_FULL_ACCESS_CODE, FrozenTaskContractArtifact as TaskContractSchema, isBlocking, orchestratePlanJsonSchema, normalizeUserOutputSchema, strictifyOutputSchema, estimateEffectiveAuthRoute, } from "@claudexor/schema";
10
+ import { finalizePlanRun, runCouncilPlan, writePlanHarnessFailure, } from "./planRun.js";
11
+ import { HarnessRunSpec, ModeKind as ModeKindSchema, SCHEMA_VERSION, TRUST_FULL_ACCESS_CODE, FrozenTaskContractArtifact as TaskContractSchema, isBlocking, makeOutcomeFacts, normalizeUserOutputSchema, strictifyOutputSchema, estimateEffectiveAuthRoute, } from "@claudexor/schema";
8
12
  import { globalConfigDir, loadConfig, trustConfigPath } from "@claudexor/config";
9
- import { specPackToTaskContract } from "@claudexor/interview";
10
- import { AnswerAssembly, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
13
+ import { AnswerAssembly, CLAUDEXOR_ARTIFACT_DIR, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
11
14
  import { assertRouteModelsAllowed } from "./modelGovernance.js";
12
15
  import { RequestRequirementsResolver } from "./requestRequirements.js";
13
16
  import { cancelledResult, failTerminally, guardAnnouncedRun, writeFailure, } from "./runTerminals.js";
14
17
  import { assertOutputSchemaCompiles, finalizeStructuredOutput } from "./structuredOutput.js";
15
- import { transientRetryDelayMs, promptWithProtectedPathConstraint, sleep, redactHarnessEvent, harnessEventPayload, formatFindings, renderSummary, readRunPatch, observeBudgetSignals, rotateOnStall, recordCleanAttemptMetrics, buildEnvelopeSubInput, deliverPlanAnswer, envInheritance, transientRetryPolicy, reviewerTimeoutMs, harnessInactivityTimeoutMs, observeAuthSwitch, relayPriorPlansSection, deliveryRefusalFailure, writeRaceDeliveryDecision, } from "./runSupport.js";
18
+ import { transientRetryDelayMs, promptWithProtectedPathConstraint, sleep, redactHarnessEvent, harnessEventPayload, formatFindings, renderSummary, observeBudgetSignals, rotateOnStall, recordCleanAttemptMetrics, envInheritance, transientRetryPolicy, reviewerTimeoutMs, harnessInactivityTimeoutMs, observeAuthSwitch, emitPrimaryDivergence, deliveryRefusalFailure, writeRaceDeliveryDecision, } from "./runSupport.js";
16
19
  import { candidateStatusInRouteContext, resolveReadOnlyRouteContext, } from "./routeContext.js";
17
20
  import { resolveAutoReviewerPanel, resolveExplicitReviewerPanel } from "./reviewerPanel.js";
18
- import { buildOrchestratePlannerPrompt, extractOrchestratePlan } from "./orchestratePlanner.js";
19
- import { orchestrateFailureFor, readRunStatus } from "./outcomeReducer.js";
20
- import { executeOrchestratePlan, } from "./orchestrateExecutor.js";
21
+ import { buildContinuation, } from "./continuity.js";
22
+ import { activePlanPointer, resolveContinuitySummary, workspaceAnchor, } from "./continuity-facts.js";
21
23
  import { runDiffReview } from "./diffReview.js";
22
24
  import { createAttemptTelemetry, observeAttemptTelemetry, setAttemptOutcome, telemetrySummary, toolWarnings, unrecoveredToolErrors, webUnsatisfied, } from "./attemptTelemetry.js";
23
25
  import { interactionChannelFor } from "./interaction.js";
@@ -26,34 +28,26 @@ import { ArtifactStore } from "@claudexor/artifact-store";
26
28
  import { EventLog } from "@claudexor/event-log";
27
29
  import { assertMandatoryContext, buildContextPack, rawContextForEnvelope, preflightEvidence, writeEvidencePacket, } from "@claudexor/context";
28
30
  import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureGitRepository, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
29
- import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch, validateApplyGate, verifyAndDeliver, } from "@claudexor/delivery";
31
+ import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch, verifyAndDeliver, } from "@claudexor/delivery";
30
32
  import { HarnessGateway } from "@claudexor/gateway";
31
33
  import { ReadinessLedger, evaluateConvergence, failureSignature, gatesPassed, reviewCandidate, revalidateFindings, runGates, } from "@claudexor/review";
32
34
  import { arbitrate } from "@claudexor/arbitration";
33
35
  import { buildSynthesisPlan, decideSynthesis } from "@claudexor/synthesis";
34
- import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, loadHarnessMetrics, promptFingerprint, unknownCostSettlement, usageCostSettlement, rankHarnesses, } from "@claudexor/budget";
35
- import { appendLine, assertNoInlineSecretValues, containsSecretLikeToken, hashJson, newId, noProjectRepoRoot, nowIso, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
36
+ import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, loadHarnessMetrics, promptFingerprint, unknownCostSettlement, rankHarnesses, reviewUsageCostSettlement, } from "@claudexor/budget";
37
+ import { readTextSafe, appendLine, assertNoInlineSecretValues, containsSecretLikeToken, DELEGATION_ENV, hashJson, newId, noProjectRepoRoot, nowIso, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
38
+ /** A routed candidate adapter plus its manifest capabilities and user settings. */
39
+ /** The two access profiles that map to codex `danger-full-access` / an
40
+ * unsandboxed lane — the only ones under which a full-access-requiring MCP
41
+ * injection (the belt on codex) can reach the daemon. */
42
+ export function isFullAccess(access) {
43
+ return access === "full" || access === "external_sandbox_full";
44
+ }
36
45
  const LABELS = "ABCDEFGHIJ".split("");
37
46
  const NO_PROJECT_ROOT = noProjectRepoRoot();
38
47
  /** Concurrency cap for parallel candidates/explorers (locked decision: min(n, 4)). */
39
48
  const MAX_PARALLEL_CANDIDATES = 4;
40
49
  /** Default wait for one interactive answer before a benign decline. */
41
50
  const DEFAULT_INTERACTION_TIMEOUT_MS = 900_000;
42
- /**
43
- * SAFETY INVARIANT 1 (asserted, not convention): a sub-run spawned by the
44
- * orchestrate executor for a SAFE step (start_run/race) MUST run as an isolated
45
- * ENVELOPE — never a live in-place turn on a thread. Throws loudly if a caller
46
- * ever constructs a safe sub-run that could mutate the live tree.
47
- */
48
- function assertEnvelopeSubRun(sub) {
49
- if (sub.inPlace === true) {
50
- throw new Error("orchestrate safe sub-run must be an isolated envelope (inPlace must be false), refusing live-tree mutation");
51
- }
52
- if (sub.threadId !== undefined || sub.executionRoot !== undefined) {
53
- throw new Error("orchestrate safe sub-run must not bind a thread or in-place execution root (isolation envelope only)");
54
- }
55
- }
56
- /** Changed paths and +/- line counts parsed from a unified git diff. */
57
51
  /** Run `work` over `items` with bounded concurrency, preserving item order via index. */
58
52
  async function runBounded(items, limit, work) {
59
53
  if (items.length === 0)
@@ -111,15 +105,6 @@ export class Orchestrator {
111
105
  throw new Error(`unknown mode: ${String(resolved.mode)}`);
112
106
  }
113
107
  const mode = parsedMode.data;
114
- // INV-023 at the ENGINE boundary too: maxToolCalls caps the orchestrate
115
- // executor's plan steps — on any other mode it would be a silent no-op
116
- // knob. The CLI and control API validate this already; a direct embedder
117
- // must get the same loud refusal, not quiet acceptance.
118
- if (resolved.maxToolCalls !== undefined &&
119
- resolved.maxToolCalls !== null &&
120
- mode !== "orchestrate") {
121
- throw new Error(`maxToolCalls caps the orchestrate EXECUTOR's plan steps and only applies to mode=orchestrate (got mode=${mode}); drop the knob or switch modes`);
122
- }
123
108
  // denyPaths is enforced by the post-diff policy gate BEFORE delivery, which
124
109
  // only exists on envelope/isolated runs — an in-place run mutates the live
125
110
  // tree directly, so the gate could not contain a violation. Refuse loudly
@@ -166,12 +151,11 @@ export class Orchestrator {
166
151
  return guardAnnouncedRun(resolved.signal, (announce) => {
167
152
  switch (mode) {
168
153
  case "ask":
169
- return this.runAsk(resolved, announce);
170
- case "audit":
171
- // `--swarm` selects the bounded read-only research swarm (old `explore`).
172
- return resolved.swarm
173
- ? this.runExplore(resolved, announce)
174
- : this.runAudit(resolved, announce);
154
+ // `--deep-scan` widens the answer into the bounded multi-scout
155
+ // research sweep with synthesis (the old `audit --swarm`/`explore`).
156
+ return resolved.deepScan
157
+ ? this.runDeepScan(resolved, announce)
158
+ : this.runAsk(resolved, announce);
175
159
  case "agent":
176
160
  // Engine strategies are FLAGS on agent (v0.9 collapse): `--until-clean`
177
161
  // and `--attempts` select the convergence loop; `--n` selects the race
@@ -184,14 +168,6 @@ export class Orchestrator {
184
168
  return this.runRace({ ...resolved, n: resolved.n ?? 1 }, mode, announce);
185
169
  case "plan":
186
170
  return this.runPlan(resolved, announce);
187
- case "orchestrate":
188
- // Recursion guard: a sub-run spawned by the orchestrate executor carries
189
- // orchestrateDepth>0 and must NOT itself orchestrate (no infinite planner
190
- // recursion). Fail loudly rather than silently degrade.
191
- if ((resolved.orchestrateDepth ?? 0) > 0) {
192
- throw new Error("orchestrate-within-orchestrate is forbidden: a sub-run spawned by the orchestrate executor cannot itself orchestrate");
193
- }
194
- return this.runOrchestrate(resolved, announce);
195
171
  }
196
172
  });
197
173
  }
@@ -231,10 +207,12 @@ export class Orchestrator {
231
207
  safeMessage: message,
232
208
  runDir: paths.root,
233
209
  });
234
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: failed\n- Phase: review preflight\n\n${message}\n`);
210
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: review preflight\n\n${message}\n`);
235
211
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
236
212
  log.emit("run.failed", {
237
- status: "failed",
213
+ lifecycle: "failed",
214
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
215
+ reason: "harness_failed",
238
216
  phase: "review_preflight",
239
217
  error: message,
240
218
  failure_ref: "final/failure.yaml",
@@ -244,7 +222,8 @@ export class Orchestrator {
244
222
  runId,
245
223
  taskId,
246
224
  mode,
247
- status: "failed",
225
+ lifecycle: "failed",
226
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
248
227
  winner: null,
249
228
  runDir: paths.root,
250
229
  summary: message,
@@ -308,11 +287,55 @@ export class Orchestrator {
308
287
  credential_profile: profile,
309
288
  };
310
289
  }
290
+ /**
291
+ * The DURABLE per-lane read-only HOME env for a THREAD turn (INV-034), or
292
+ * null for a non-thread one-shot (which keeps the disposable route-context
293
+ * home). Anchored to the PROJECT partition (`input.repoRoot`), not the
294
+ * per-turn execution root, so the home is the SAME across turns of the same
295
+ * lane and the lifecycle owners (which key off `thread.repo.root`) reach it.
296
+ * Keyed by the run's REQUESTED credential profile — the same key the daemon's
297
+ * `resumeMap` lookup uses (INV-135), so record and resume land in one home.
298
+ */
299
+ laneHomeEnvFor(input, harnessId) {
300
+ if (!input.threadId)
301
+ return null;
302
+ return new WorkspaceManager(input.repoRoot).laneHomeEnv(input.threadId, harnessId,
303
+ // The lane is keyed by the EFFECTIVE account (INV-135): an explicit pin,
304
+ // else null resolves the same home the recorded native session lives in.
305
+ this.effectiveProfileId(input, harnessId)).env;
306
+ }
307
+ /**
308
+ * The per-harness EFFECTIVE credential profile id (INV-135 accounts
309
+ * authority): an explicit per-run/per-thread pin wins; else null — POOL AUTO,
310
+ * the native/CLI login default subject (enabled profiles route only by
311
+ * explicit pin or quota rotation, never as a silent Active default).
312
+ */
313
+ effectiveProfileId(input, _harnessId) {
314
+ return input.credentialProfileId ?? null;
315
+ }
316
+ /** Whether the native/CLI login is EXCLUDED from this harness's credential
317
+ * ladder (INV-135). When excluded, a harness with no effective profile has
318
+ * nothing routable and must refuse — never silently fall back into it. */
319
+ nativeCredentialsDisabled(repoRoot, harnessId) {
320
+ return (this.config(repoRoot)?.global.harnesses?.[harnessId]?.native_credentials_enabled === false);
321
+ }
311
322
  resolveCredentialProfile(input, harnessId) {
312
- if (!input.credentialProfileId)
323
+ const explicit = input.credentialProfileId ?? null;
324
+ const wanted = this.effectiveProfileId(input, harnessId);
325
+ if (!wanted)
313
326
  return null;
314
327
  const registry = this.config(input.repoRoot)?.global.credential_profiles ?? [];
315
- return resolveCredentialProfile(registry, input.credentialProfileId, harnessId);
328
+ try {
329
+ return resolveCredentialProfile(registry, wanted, harnessId);
330
+ }
331
+ catch (err) {
332
+ // With Active removed, `wanted` is always the explicit pin; keep the
333
+ // fail-closed guard so any future non-pin source still refuses loudly.
334
+ if (!explicit) {
335
+ throw new Error(`harness "${harnessId}" credential profile "${wanted}" is unusable: ${err instanceof Error ? err.message : String(err)}`);
336
+ }
337
+ throw err;
338
+ }
316
339
  }
317
340
  /** The typed effective auth route for a SELECTED credential profile
318
341
  * (round-18 #2): adapters execute strictly by credential_kind, so routing,
@@ -334,29 +357,6 @@ export class Orchestrator {
334
357
  const policy = this.config(repoRoot)?.global.harnesses?.[harnessId]?.profile_policy;
335
358
  return policy ?? { limit_action: "fail", rotation_eligible: [], headroom_threshold: 0.9 };
336
359
  }
337
- /** null = no profile selected (default verdict stands); "available" = the
338
- * profile's own probe admits the route; any other string = typed refusal. */
339
- async profileAvailabilityOverride(input, harnessId) {
340
- if (!input.credentialProfileId)
341
- return null;
342
- let profile;
343
- try {
344
- profile = this.resolveCredentialProfile(input, harnessId);
345
- }
346
- catch (err) {
347
- return err instanceof Error ? err.message : String(err);
348
- }
349
- if (!profile)
350
- return null;
351
- const adapter = this.deps.registry.get(harnessId);
352
- if (!adapter?.probeCredentialProfile) {
353
- return `harness "${harnessId}" has no profile probe`;
354
- }
355
- const probe = await adapter.probeCredentialProfile(profile);
356
- if (probe.availability === "available")
357
- return "available";
358
- return probe.detail ?? `${probe.availability}/${probe.verification}`;
359
- }
360
360
  preflightProfile(input, harnessId, log) {
361
361
  const profile = this.resolveCredentialProfile(input, harnessId);
362
362
  const policy = this.profilePolicy(input.repoRoot, harnessId);
@@ -371,13 +371,6 @@ export class Orchestrator {
371
371
  }
372
372
  return preflightCredentialProfile({ profile, harnessId, policy, registry, snapshots, emit });
373
373
  }
374
- /**
375
- * Lift an adapter's auth-route override marker into the typed
376
- * `route.fallback.auth_switched` run event (validated payload). An explicit
377
- * subscription/api_key preference that could not be honored is never silent;
378
- * neither is an `auto` choice that selects a smoke-proven paid route over an
379
- * available native route.
380
- */
381
374
  /**
382
375
  * Resolve candidate adapters: explicit `--harness`, else available real harnesses, then
383
376
  * **capability-gate** to those that can actually produce work for `intent` (e.g. a
@@ -391,13 +384,7 @@ export class Orchestrator {
391
384
  }
392
385
  const cfg = this.config(input.repoRoot);
393
386
  const configuredPool = cfg?.global.routing.eligible_harnesses;
394
- const policy = cfg?.global.routing.default_policy;
395
- const harnesses = input.harnesses ??
396
- (configuredPool && configuredPool.length > 0
397
- ? configuredPool
398
- : policy === "primary" && cfg?.global.routing.primary_harness
399
- ? [cfg.global.routing.primary_harness]
400
- : undefined);
387
+ const harnesses = input.harnesses ?? (configuredPool && configuredPool.length > 0 ? configuredPool : undefined);
401
388
  const primaryHarness = input.primaryHarness ?? cfg?.global.routing.primary_harness ?? undefined;
402
389
  if (primaryHarness &&
403
390
  harnesses &&
@@ -449,7 +436,7 @@ export class Orchestrator {
449
436
  externalContextPolicy: web,
450
437
  };
451
438
  }
452
- async resolveCandidateAdapters(input, intent, ledger, routeContext) {
439
+ async resolveCandidateAdapters(input, intent, ledger, log, routeContext) {
453
440
  let ids = input.harnesses;
454
441
  const explicitPool = Boolean(ids && ids.length > 0);
455
442
  const harnessSettings = this.config(input.repoRoot)?.global.harnesses ?? {};
@@ -529,6 +516,18 @@ export class Orchestrator {
529
516
  dropped.push(why);
530
517
  continue;
531
518
  }
519
+ // INV-135 accounts authority: with the native/CLI login excluded and no
520
+ // explicit pin, an unpinned run has nothing routable. Refuse an explicit
521
+ // request naming the setting; drop it from an auto pool — never silently
522
+ // fall back INTO the disabled login.
523
+ if (this.effectiveProfileId(input, id) === null &&
524
+ this.nativeCredentialsDisabled(input.repoRoot, id)) {
525
+ 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);
529
+ continue;
530
+ }
532
531
  // W3.3 (TZ-1 §B): a route is admitted on readiness truth from the SAME
533
532
  // resolved env/cwd its run will spawn with (see routeContext.ts).
534
533
  let status = await candidateStatusInRouteContext(this.gateway, routeContext, id, this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), statusById);
@@ -537,9 +536,7 @@ export class Orchestrator {
537
536
  dropped.push(`${id} (unavailable)`);
538
537
  continue;
539
538
  }
540
- // Doctor status is the readiness truth: auto-pools take only doctor-OK
541
- // routes, and explicitly selecting an UNAVAILABLE harness fails loudly
542
- // with the doctor's reasons. A DEGRADED harness (e.g. key present but
539
+ // Doctor status is the readiness truth. A DEGRADED harness (e.g. key present but
543
540
  // unproven by isolated smoke) is admitted only by explicit user
544
541
  // selection — degraded means usable-with-caveats, and the caveats are
545
542
  // visible in doctor output and run events.
@@ -550,44 +547,47 @@ export class Orchestrator {
550
547
  // the profile's transport, so the default store's state is not the
551
548
  // routing truth). Capability/manifest gating above still applies.
552
549
  let profileAdmitted = false;
553
- if (status.status !== "ok") {
554
- const profileVerdict = await this.profileAvailabilityOverride(input, id);
550
+ const profileAdapter = this.deps.registry.get(id);
551
+ const profileVerdict = await selectedProfileAvailability({
552
+ registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
553
+ // The EFFECTIVE account (INV-135): an explicit pin is authenticated by ITS store.
554
+ profileId: this.effectiveProfileId(input, id),
555
+ harnessId: id,
556
+ probe: profileAdapter?.probeCredentialProfile?.bind(profileAdapter),
557
+ });
558
+ if (profileVerdict !== null) {
555
559
  if (profileVerdict === "available") {
556
- // The default-store failure may have zeroed enabled_intents; with
557
- // the profile probe as the auth verdict, MANIFEST capabilities are
558
- // the honest intent truth (degraded = usable-with-caveats).
559
- status = {
560
- ...status,
561
- status: "degraded",
562
- enabledIntents: capabilityIntents(manifest.capabilities),
563
- };
564
- statusById.set(id, status);
565
560
  profileAdmitted = true;
561
+ // A valid profile restores manifest intent truth when the default store failed.
562
+ if (status.status !== "ok") {
563
+ status = {
564
+ ...status,
565
+ status: "degraded",
566
+ enabledIntents: capabilityIntents(manifest.capabilities),
567
+ };
568
+ statusById.set(id, status);
569
+ }
566
570
  }
567
- else if (profileVerdict !== null) {
571
+ else {
568
572
  const why = `${id} credential profile is not ready: ${profileVerdict}`;
569
573
  if (explicitPool)
570
574
  throw new HarnessUnavailableError(why);
571
575
  dropped.push(why);
572
576
  continue;
573
577
  }
574
- else if (status.status === "unavailable") {
575
- const why = `${id} is unavailable${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`;
576
- if (explicitPool)
577
- throw new HarnessUnavailableError(why);
578
- dropped.push(why);
579
- continue;
580
- }
578
+ }
579
+ if (status.status === "unavailable" && !profileAdmitted) {
580
+ const why = `${id} is unavailable${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`;
581
+ if (explicitPool)
582
+ throw new HarnessUnavailableError(why);
583
+ dropped.push(why);
584
+ continue;
581
585
  }
582
586
  if (status.status !== "ok" && !explicitPool && !profileAdmitted) {
583
587
  dropped.push(`${id} is ${status.status}${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`);
584
588
  continue;
585
589
  }
586
- const readOnlyIntent = intent === "plan" ||
587
- intent === "spec" ||
588
- intent === "explain" ||
589
- intent === "audit" ||
590
- intent === "orchestrate";
590
+ const readOnlyIntent = intent === "plan" || intent === "spec" || intent === "explain" || intent === "audit";
591
591
  const requiredAccess = this.requestRequirements.adapterAccess(intent, manifest.capabilities.implementation_transport, readOnlyIntent
592
592
  ? "readonly"
593
593
  : (input.access ?? this.config(input.repoRoot).trust.access_default));
@@ -644,6 +644,8 @@ export class Orchestrator {
644
644
  estimateEffectiveAuthRoute(this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), status.authSources),
645
645
  supportsInteractive: manifest.capabilities.interactive,
646
646
  supportsJsonSchemaOutput: manifest.capabilities.json_schema_output,
647
+ supportsMcpInjection: manifest.capability_profile.mcp_injection,
648
+ mcpInjectionRequiresFullAccess: manifest.capability_profile.mcp_injection_requires_full_access,
647
649
  implementationTransport: manifest.capabilities.implementation_transport,
648
650
  settings: cfgEntry
649
651
  ? {
@@ -669,11 +671,33 @@ export class Orchestrator {
669
671
  if (ordered.length === 0) {
670
672
  throw new HarnessUnavailableError(`no harness remains eligible for '${intent}' after budget and quota routing`);
671
673
  }
674
+ emitPrimaryDivergence(log, input.primaryHarness, ordered, pool, dropped);
672
675
  const n = input.n ?? ordered.length;
673
676
  const out = [];
674
677
  for (let i = 0; i < n; i++)
675
678
  out.push(ordered[i % ordered.length]);
676
679
  this.requestRequirements.requireEffectiveBrowser(input.browser === true, out.map((lane) => lane.browserRequirement));
680
+ // Delegation belt (D32): agent-only, and only on a lane whose adapter can
681
+ // inject MCP servers. A requested delegate with NO injecting lane is a typed
682
+ // preflight refusal naming the harness(es) — never a silently dropped belt.
683
+ if (input.delegate === true && !out.some((lane) => lane.supportsMcpInjection)) {
684
+ const names = [...new Set(out.map((lane) => lane.adapter.id))].join(", ");
685
+ throw new HarnessUnavailableError(`--delegate requires a harness that can host the Claudexor delegation belt (capability_profile.mcp_injection); the routed harness(es) [${names}] cannot inject MCP servers — choose claude or codex, or drop --delegate`);
686
+ }
687
+ // A belt-injecting lane may still be UNABLE to reach the daemon at its
688
+ // access: codex's workspace-write seatbelt cancels the belt's daemon-crossing
689
+ // MCP call, so codex only hosts the belt at FULL access (same as its browser
690
+ // MCP). If EVERY injecting lane requires full access but runs below it, the
691
+ // belt would be injected only to be silently cancelled by the sandbox — the
692
+ // exact non-delegation this guard prevents. Refuse with the real remedy.
693
+ if (input.delegate === true) {
694
+ const injecting = out.filter((lane) => lane.supportsMcpInjection);
695
+ const canHostBelt = injecting.some((lane) => !lane.mcpInjectionRequiresFullAccess || isFullAccess(lane.adapterAccess));
696
+ if (!canHostBelt) {
697
+ const names = [...new Set(injecting.map((lane) => lane.adapter.id))].join(", ");
698
+ throw new HarnessUnavailableError(`--delegate needs a belt-hosting lane at full access: [${names}] can inject MCP servers but sandbox-cancel the delegation belt below full access (capability_profile.mcp_injection_requires_full_access) — re-run with --access full, or route a lane (e.g. claude) that hosts the belt at workspace_write`);
699
+ }
700
+ }
677
701
  // outputSchema is MANDATORY (Quiz-6a): a selected lane that cannot
678
702
  // natively constrain its final message would deliver best-effort text —
679
703
  // that is a typed preflight refusal, never silent degradation. The
@@ -859,7 +883,7 @@ export class Orchestrator {
859
883
  buildContract(input, taskId, mode) {
860
884
  const resolvedCfg = this.config(input.repoRoot);
861
885
  const cfg = resolvedCfg.project;
862
- const readOnlyMode = mode === "ask" || mode === "plan" || mode === "audit" || mode === "orchestrate";
886
+ const readOnlyMode = mode === "ask" || mode === "plan";
863
887
  const requestedAccess = input.access ?? (readOnlyMode ? "readonly" : resolvedCfg.trust.access_default);
864
888
  // Effective access is COMPUTED by the engine, never echoed from a client:
865
889
  // read-only modes clamp to readonly regardless of the request.
@@ -880,63 +904,19 @@ export class Orchestrator {
880
904
  { code: TRUST_FULL_ACCESS_CODE, status: 403 });
881
905
  }
882
906
  const externalContextPolicy = input.web ?? input.externalContextPolicy ?? "auto";
883
- // A frozen SpecPack's CONTENT reaches the contract (success criteria,
884
- // non-goals, forbidden approaches, tradeoffs, task graph) previously only
885
- // its metadata did, leaving the arbitration acceptance axis permanently
886
- // empty and the interview pipeline dead in production.
887
- let specFields = {};
888
- let specTestCommands = [];
889
- if (input.specPath) {
890
- try {
891
- const spec = SpecPackZ.parse(JSON.parse(readFileSync(input.specPath, "utf8")));
892
- // Tamper fence (INV-081): the frozen spec's recorded hash must
893
- // match what we just read — a spec.json edited AFTER freeze would
894
- // otherwise silently rewrite success criteria/tests/protected paths
895
- // while the contract records the stale hash as provenance.
896
- if (input.specHash && hashJson(spec) !== input.specHash) {
897
- throw new Error(`frozen SpecPack hash mismatch (expected ${input.specHash}, got ${hashJson(spec)}); the spec was modified after freeze — re-freeze it or drop --spec`);
898
- }
899
- const fromSpec = specPackToTaskContract(spec, {
900
- repoRoot: input.repoRoot,
901
- mode,
902
- baseRef: input.baseRef,
903
- paidBudget: input.paidBudget,
904
- });
905
- specFields = {
906
- success_criteria: fromSpec.success_criteria,
907
- non_goals: fromSpec.non_goals,
908
- forbidden_approaches: fromSpec.forbidden_approaches,
909
- decided_tradeoffs: fromSpec.decided_tradeoffs,
910
- task_graph: fromSpec.task_graph,
911
- constraints: fromSpec.constraints,
912
- };
913
- specTestCommands = fromSpec.tests.commands.map(({ program, args, cwd, envAllowlist }) => ({
914
- program,
915
- args,
916
- ...(cwd === undefined ? {} : { cwd }),
917
- envAllowlist,
918
- }));
919
- }
920
- catch (err) {
921
- // An unreadable/unfrozen spec must fail the run loudly, never silently
922
- // degrade into an unspecced contract.
923
- throw new Error(`failed to resolve frozen SpecPack at ${input.specPath}: ${safeErrorMessage(err)}`);
924
- }
925
- }
926
- // Deterministic gate commands come from the frozen SpecPack, explicit run
927
- // input, then versioned project config. Without these, gateSpecs is empty
928
- // and convergence is review-only; with them, convergence is test-driven.
907
+ // Deterministic gate commands come from explicit run input, then versioned
908
+ // project config. Without these, gateSpecs is empty and convergence is
909
+ // review-only; with them, convergence is test-driven.
929
910
  const resolvedGates = resolveContractGates({
930
911
  repoRoot: input.repoRoot,
931
912
  effectiveAccess,
932
913
  config: cfg,
933
914
  trustGrants: resolvedCfg.trust.test_command_grants,
934
- specCommands: specTestCommands,
935
915
  operatorCommands: input.tests ?? [],
936
916
  projectCommands: cfg.tests?.commands ?? [],
937
917
  });
938
918
  const commands = resolvedGates.commands;
939
- const protectedPaths = [...new Set(specFields.constraints?.protected_paths ?? [])];
919
+ const protectedPaths = [];
940
920
  const autoProtectedPaths = resolvedGates.autoProtectedPaths;
941
921
  const protectedPathApprovals = [
942
922
  ...new Map([...(input.protectedPathApprovals ?? [])].map((approval) => [approval.path, approval])).values(),
@@ -959,14 +939,6 @@ export class Orchestrator {
959
939
  auth_preference: input.authPreference ?? "auto",
960
940
  credential_profile_id: input.credentialProfileId ?? null,
961
941
  max_turns: input.maxTurns ?? null,
962
- spec: input.specId || input.specHash || input.specPath
963
- ? {
964
- id: input.specId,
965
- hash: input.specHash,
966
- path: input.specPath,
967
- }
968
- : undefined,
969
- ...specFields,
970
942
  constraints: {
971
943
  protected_paths: protectedPaths,
972
944
  deny_paths: [...new Set(input.denyPaths ?? [])],
@@ -1018,6 +990,39 @@ export class Orchestrator {
1018
990
  * execution — owner Quiz-5a); reviewers and the auth smoke build their own
1019
991
  * specs and never call this.
1020
992
  */
993
+ /** The extra MCP servers injected into one agent lane's sandbox. Today only
994
+ * the delegation belt (D32): present when `--delegate` is on, the daemon built
995
+ * a belt descriptor, the lane's adapter can inject MCP servers, and the lane is
996
+ * a WRITING agent intent (the delegator integrates results in its workspace;
997
+ * read lanes and reviewers have nothing to delegate). */
998
+ delegationBeltFor(input, intent, routed, resolvedBudget) {
999
+ if (!input?.delegate || !input.delegationBelt || !routed.supportsMcpInjection)
1000
+ return [];
1001
+ // A lane that sandbox-cancels the belt below full access (codex) must NOT
1002
+ // receive a belt it cannot use — that is the silent non-delegation. The
1003
+ // preflight already refused a run whose ONLY injecting lanes are such lanes
1004
+ // below full access; here we simply skip injecting into an individual lane
1005
+ // that cannot host it, so a mixed pool keeps the belt on the lanes that can.
1006
+ if (routed.mcpInjectionRequiresFullAccess && !isFullAccess(routed.adapterAccess))
1007
+ return [];
1008
+ const writingIntents = ["implement", "create_from_scratch", "repair"];
1009
+ if (!writingIntents.includes(intent))
1010
+ return [];
1011
+ // The CLI built the descriptor from the RAW request budget (undefined when
1012
+ // the caller relied on a config/dep default), which would leave the belt
1013
+ // unlimited while the real run is capped. Rebind the belt's parent-budget
1014
+ // env to the RESOLVED budget (resolvePaidBudget output) so sub-run draws are
1015
+ // bounded by the same headroom the parent run enforces — one budget owner.
1016
+ return [
1017
+ {
1018
+ ...input.delegationBelt,
1019
+ env: {
1020
+ ...input.delegationBelt.env,
1021
+ [DELEGATION_ENV.budget]: JSON.stringify(resolvedBudget),
1022
+ },
1023
+ },
1024
+ ];
1025
+ }
1021
1026
  harnessSpecKnobs(contract, knobs, intent) {
1022
1027
  return {
1023
1028
  external_context_policy: knobs.webPolicy,
@@ -1031,11 +1036,10 @@ export class Orchestrator {
1031
1036
  max_turns: knobs.maxTurns,
1032
1037
  ...(intent === "synthesize" ? {} : { instructions: contract.instructions }),
1033
1038
  // The user's answer contract rides every answer-producing lane INCLUDING
1034
- // synthesis (its answer can become the final one); the orchestrate
1035
- // planner owns its own plan schema instead (set at its spec site). The
1036
- // adapter gets the vendor-STRICT transport form; the engine validator
1037
- // keeps the ORIGINAL contract as the conformance authority.
1038
- ...(intent !== "orchestrate" && contract.output_schema
1039
+ // synthesis (its answer can become the final one). The adapter gets the
1040
+ // vendor-STRICT transport form; the engine validator keeps the ORIGINAL
1041
+ // contract as the conformance authority.
1042
+ ...(contract.output_schema
1039
1043
  ? { output_schema: strictifyOutputSchema(contract.output_schema) }
1040
1044
  : {}),
1041
1045
  };
@@ -1089,21 +1093,138 @@ export class Orchestrator {
1089
1093
  ignored,
1090
1094
  };
1091
1095
  }
1092
- /** Run one candidate inside an already-created envelope. Never creates/disposes the envelope. */
1093
- 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) {
1096
+ /**
1097
+ * Build the per-lane continuation packet for a thread turn (INV-137).
1098
+ * Resolves the lane (harness + effective profile), computes the delta since
1099
+ * the lane's checkpoint, reads prior outputs + the git anchor, and — for a
1100
+ * lane switch or gap — materializes `context/THREAD.md` and returns the
1101
+ * one-line prompt pointer. Emits `session.continuity` and stamps the turn.
1102
+ * Returns null (no packet, no pointer) for native resume, a fresh thread, or
1103
+ * a non-thread run. Never throws: continuity failure degrades to no packet.
1104
+ */
1105
+ async resolveContinuity(runInput, harnessId, resolvedProfileId, nativeResumeAvailable, store, paths, repoRoot, log) {
1106
+ const ctx = runInput.threadContinuity;
1107
+ if (!runInput.threadId || !ctx)
1108
+ return null;
1109
+ try {
1110
+ const profileId = resolvedProfileId ?? ctx.profileId ?? null;
1111
+ const lane = { harness: harnessId, profileId };
1112
+ const checkpoint = ctx.laneCheckpoints.find((c) => c.harness === harnessId && (c.profileId ?? null) === profileId);
1113
+ const headTurnId = ctx.priorTurns.length
1114
+ ? ctx.priorTurns[ctx.priorTurns.length - 1].id
1115
+ : null;
1116
+ const priorHeadOwner = headTurnId
1117
+ ? ctx.laneCheckpoints.find((c) => c.turnId === headTurnId)
1118
+ : undefined;
1119
+ const priorHeadLane = priorHeadOwner
1120
+ ? { harness: priorHeadOwner.harness, profileId: priorHeadOwner.profileId ?? null }
1121
+ : null;
1122
+ const priorTurns = ctx.priorTurns.map((t) => ({
1123
+ id: t.id,
1124
+ prompt: t.prompt,
1125
+ outputText: t.runId
1126
+ ? (readTextSafe(join(store.runPaths(t.runId).finalDir, "answer.md")) ?? "")
1127
+ : "",
1128
+ }));
1129
+ const req = {
1130
+ lane,
1131
+ priorTurns,
1132
+ laneCheckpointTurnId: checkpoint?.turnId ?? null,
1133
+ nativeResumeAvailable,
1134
+ priorHeadLane,
1135
+ activePlan: activePlanPointer(ctx.priorTurns, store),
1136
+ anchor: await workspaceAnchor(repoRoot),
1137
+ };
1138
+ // V9c: when the packet would collapse an older prefix, replace the
1139
+ // mechanical one-liners with a cached (or freshly summarized) prose
1140
+ // summary. Same credential route + scoped lane home a real read-only
1141
+ // thread turn uses (INV-034/135). Best-effort in its OWN guard — a summary
1142
+ // failure keeps the full mechanical packet, never drops it.
1143
+ const sessionFields = this.sessionSpecFields(runInput, harnessId);
1144
+ req.cachedSummary = await resolveContinuitySummary({
1145
+ req,
1146
+ threadId: runInput.threadId,
1147
+ projectRoot: runInput.repoRoot,
1148
+ cwd: repoRoot,
1149
+ adapter: this.deps.registry.get(harnessId),
1150
+ credentialProfile: sessionFields.credential_profile,
1151
+ authPreference: sessionFields.auth_preference ?? "auto",
1152
+ laneEnv: this.laneHomeEnvFor(runInput, harnessId) ?? {},
1153
+ envInheritance: envInheritance(this.config(runInput.repoRoot)),
1154
+ signal: runInput.signal,
1155
+ });
1156
+ const result = buildContinuation(req);
1157
+ // Disclose on every lane and stamp the turn (INV-137: never silent).
1158
+ log?.emit("session.continuity", {
1159
+ thread_id: runInput.threadId,
1160
+ harness_id: harnessId,
1161
+ kind: result.disclosure.kind,
1162
+ packet_turns: result.disclosure.packetTurns,
1163
+ summarized: result.disclosure.summarized,
1164
+ lane_switched_from: result.disclosure.laneSwitchedFrom,
1165
+ });
1166
+ runInput.onContinuityResolved?.(ctx.turnId, result.disclosure);
1167
+ if (!result.packetMarkdown)
1168
+ return { pointerLine: null };
1169
+ const briefPath = join(paths.contextDir, "THREAD.md");
1170
+ store.writeText(briefPath, result.packetMarkdown);
1171
+ return {
1172
+ pointerLine: `Earlier conversation context for this thread is at: ${briefPath} — read it before answering.`,
1173
+ };
1174
+ }
1175
+ catch (err) {
1176
+ // Continuity is best-effort — a packet-build failure must never fail the
1177
+ // run — but it is NEVER silent (INV-137). Disclose the degradation: emit
1178
+ // the session.continuity event carrying the reason (so the failure is in
1179
+ // the run log), and stamp the turn as fresh — it honestly ran WITHOUT the
1180
+ // thread packet. Absent this, a summarization/anchor/read failure vanished.
1181
+ const reason = err instanceof Error ? err.message : String(err);
1182
+ log?.emit("session.continuity", {
1183
+ thread_id: runInput.threadId,
1184
+ harness_id: harnessId,
1185
+ kind: "fresh",
1186
+ packet_turns: 0,
1187
+ summarized: false,
1188
+ lane_switched_from: null,
1189
+ degraded: true,
1190
+ reason,
1191
+ });
1192
+ runInput.onContinuityResolved?.(ctx.turnId, {
1193
+ kind: "fresh",
1194
+ packetTurns: 0,
1195
+ summarized: false,
1196
+ laneSwitchedFrom: null,
1197
+ });
1198
+ return { pointerLine: null };
1199
+ }
1200
+ }
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) {
1094
1202
  const adapter = routed.adapter;
1095
1203
  const knobs = this.routeSpecKnobs(routed, contract, modelHint, effortHint);
1096
- // In-place envelopes can resume native sessions; isolated scoped homes cannot,
1097
- // so their session ids are never retained after disposal.
1204
+ // Isolated scoped-home sessions are never retained after disposal.
1098
1205
  const inPlaceEnvelope = envelope.worktree_path === envelope.repo_root;
1099
1206
  const rawContextPacket = await rawContextForEnvelope(routed.implementationTransport, envelope);
1100
1207
  const sessionFields = runInput ? this.sessionSpecFields(runInput, adapter.id, log) : undefined;
1208
+ // Continuity (INV-137): once the lane (harness + resolved profile) is known,
1209
+ // build the continuation packet, materialize context/THREAD.md, and point
1210
+ // the prompt at it — never embed the packet body in the prompt. Replaces the
1211
+ // old static session.rebound "not_portable" phrase with a real disclosure.
1212
+ const laneContinuity = runInput
1213
+ ? 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
+ : null;
1215
+ const promptWithContinuity = laneContinuity?.pointerLine
1216
+ ? `${prompt}\n\n${laneContinuity.pointerLine}`
1217
+ : prompt;
1101
1218
  let spec = HarnessRunSpec.parse({
1102
1219
  session_id: newId("ses"),
1103
1220
  intent,
1104
- prompt: promptWithProtectedPathConstraint(prompt, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals),
1221
+ prompt: promptWithProtectedPathConstraint(promptWithContinuity, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals),
1105
1222
  attachments: runInput?.attachments ?? [],
1106
- browser: this.requestRequirements.browserSpec(routed.browserRequirement, join(paths.root, "browser")),
1223
+ browser: this.requestRequirements.browserSpec(routed.browserRequirement,
1224
+ // F4: browser-MCP screenshots land in the claudexor-owned
1225
+ // artifact dir inside the worktree — excluded from the diff, gallery-collected.
1226
+ join(envelope.worktree_path, CLAUDEXOR_ARTIFACT_DIR, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR)),
1227
+ extra_mcp_servers: this.delegationBeltFor(runInput, intent, routed, contract.budget.paid_budget),
1107
1228
  cwd: envelope.worktree_path,
1108
1229
  access: routed.adapterAccess,
1109
1230
  ...this.harnessSpecKnobs(contract, knobs, intent),
@@ -1123,16 +1244,6 @@ export class Orchestrator {
1123
1244
  raw_context_packet: rawContextPacket,
1124
1245
  stream_deltas: streamDeltas,
1125
1246
  });
1126
- if (!inPlaceEnvelope && runInput?.threadId && sessionFields?.resume_session_id) {
1127
- log?.emit("session.rebound", SessionReboundLineageSchema.parse({
1128
- thread_id: runInput.threadId,
1129
- harness_id: adapter.id,
1130
- from_native_session_id: sessionFields.resume_session_id,
1131
- to_session_id: null,
1132
- summary: "isolated envelope turn runs fresh: the native session is not portable into a scoped harness home; continuity rides on the thread prompt + repo state",
1133
- reason: "not_portable",
1134
- }));
1135
- }
1136
1247
  if (interaction)
1137
1248
  spec.extra["interactionChannel"] = interaction;
1138
1249
  const inactivityMs = harnessInactivityTimeoutMs(this.config(contract.repo.root));
@@ -1163,6 +1274,7 @@ export class Orchestrator {
1163
1274
  }
1164
1275
  try {
1165
1276
  for (let nativeTry = 0; !signal?.aborted; nativeTry += 1) {
1277
+ const clearFileBackedContext = stageFileBackedContext(envelope.worktree_path, fileBackedContext);
1166
1278
  const runSpec = nativeTry === 0
1167
1279
  ? spec
1168
1280
  : HarnessRunSpec.parse({ ...spec, session_id: newId("ses"), extra: { ...spec.extra } });
@@ -1238,31 +1350,23 @@ export class Orchestrator {
1238
1350
  items: safeEv.plan_progress.items,
1239
1351
  });
1240
1352
  }
1241
- if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
1242
- cost += safeEv.usage.cost_usd;
1243
- if (safeEv.usage.estimated)
1244
- costEstimated = true;
1245
- log?.emit("budget.observation", {
1246
- harness_id: adapter.id,
1247
- attempt_id: attemptId,
1248
- kind: "spend",
1249
- usd: safeEv.usage.cost_usd,
1250
- estimated: safeEv.usage.estimated === true,
1353
+ if (safeEv.type === "usage") {
1354
+ const usage = processAttemptUsage({
1355
+ event: safeEv,
1356
+ telemetry,
1357
+ harnessId: adapter.id,
1358
+ attemptId,
1359
+ cost,
1360
+ costEstimated,
1361
+ emit: (type, payload) => log?.emit(type, payload),
1362
+ budgetGuard,
1363
+ cancel: () => void adapter.cancel?.(runSpec.session_id)?.catch(() => { }),
1251
1364
  });
1252
- // Mid-flight cap enforcement: the guard raises this attempt's hold
1253
- // to the streamed cost; a hard tier aborts NOW instead of letting a
1254
- // streaming candidate overshoot the paid budget until settlement.
1255
- const valuationOnly = safeEv.usage.estimated === true && telemetry.authMode === "local_session";
1256
- if (!valuationOnly && budgetGuard?.(cost)) {
1365
+ cost = usage.cost;
1366
+ costEstimated = usage.costEstimated;
1367
+ if (usage.hardCapReached) {
1257
1368
  harnessErrored = true;
1258
1369
  errors.push("budget hard cap reached mid-attempt; stream aborted");
1259
- log?.emit("budget.observation", {
1260
- harness_id: adapter.id,
1261
- attempt_id: attemptId,
1262
- kind: "cooldown",
1263
- detail: "hard cap mid-flight abort",
1264
- });
1265
- void adapter.cancel?.(runSpec.session_id)?.catch(() => { });
1266
1370
  break;
1267
1371
  }
1268
1372
  }
@@ -1293,6 +1397,9 @@ export class Orchestrator {
1293
1397
  harnessErrored = true;
1294
1398
  errors.push(safeErrorMessage(err));
1295
1399
  }
1400
+ finally {
1401
+ clearFileBackedContext();
1402
+ }
1296
1403
  const transient = telemetry.transientFailures.at(-1) ?? null;
1297
1404
  const sawTransient = telemetry.transientFailures.length > transientStart;
1298
1405
  const sawTypedLimit = telemetry.rateLimits.length > rateLimitStart;
@@ -1416,9 +1523,6 @@ export class Orchestrator {
1416
1523
  // must carry it so the slot catch settles the TRUE cost, not 0.
1417
1524
  throw Object.assign(err instanceof Error ? err : new Error(String(err)), { costUsd: cost });
1418
1525
  }
1419
- store.writeText(join(attemptDir, "patch.diff"), diff);
1420
- // Routing metrics (one owner in runSupport; clean attempts only —
1421
- // auth-route evidence recorded regardless).
1422
1526
  recordCleanAttemptMetrics(globalConfigDir(), adapter.id, {
1423
1527
  costUsd: cost,
1424
1528
  streamMs: attemptStreamEndedMs - attemptStartedMs,
@@ -1426,26 +1530,25 @@ export class Orchestrator {
1426
1530
  aborted: signal?.aborted === true,
1427
1531
  authMode: telemetry.authMode,
1428
1532
  });
1429
- const attemptDiffstat = diffStats(diff);
1430
- store.writeYaml(join(attemptDir, "attempt.yaml"), {
1431
- attempt_id: attemptId,
1432
- harness_id: adapter.id,
1433
- label,
1434
- cost_usd: cost,
1435
- cost_estimated: costEstimated,
1436
- errored,
1437
- errors: errors.slice(0, 5),
1438
- ...telemetrySummary(telemetry),
1439
- outcome: telemetry.outcome,
1440
- gates: gates.map((g) => ({ id: g.id, status: g.status })),
1441
- // Candidate-card evidence: the Candidates tab renders per-attempt
1442
- // diffstat without re-parsing patch bytes client-side.
1443
- diffstat: {
1444
- files: attemptDiffstat.paths.length,
1445
- additions: attemptDiffstat.additions,
1446
- deletions: attemptDiffstat.deletions,
1533
+ const producedFiles = writeCandidateAttemptArtifacts({
1534
+ store,
1535
+ attemptDir,
1536
+ worktreePath: envelope.worktree_path,
1537
+ diff,
1538
+ answerText,
1539
+ record: {
1540
+ attempt_id: attemptId,
1541
+ harness_id: adapter.id,
1542
+ label,
1543
+ cost_usd: cost,
1544
+ cost_estimated: costEstimated,
1545
+ errored,
1546
+ errors: errors.slice(0, 5),
1547
+ ...telemetrySummary(telemetry),
1548
+ outcome: telemetry.outcome,
1549
+ gates: gates.map((g) => ({ id: g.id, status: g.status })),
1550
+ branch: envelope.branch_name,
1447
1551
  },
1448
- branch: envelope.branch_name,
1449
1552
  });
1450
1553
  return {
1451
1554
  attemptId,
@@ -1455,6 +1558,7 @@ export class Orchestrator {
1455
1558
  answerText,
1456
1559
  reviewCwd: envelope.worktree_path,
1457
1560
  baseSha: envelope.base_sha ?? undefined,
1561
+ producedFiles,
1458
1562
  gates,
1459
1563
  cost,
1460
1564
  errored,
@@ -1463,51 +1567,6 @@ export class Orchestrator {
1463
1567
  telemetry,
1464
1568
  };
1465
1569
  }
1466
- toEvidence(run, contract, findings, finalReviewClean, reviewVerified = false) {
1467
- const passed = gatesPassed(run.gates) && !run.errored;
1468
- // Honest acceptance evidence: 0/0 when the contract has no success criteria
1469
- // (no spec). The old code fabricated a 1/1 ("AC-implicit") cover, which made
1470
- // arbitration report a vacuous "acceptance=100%" that just restated gates.
1471
- const acTotal = contract.success_criteria.length;
1472
- const acCovered = passed && contract.success_criteria.length > 0
1473
- ? contract.success_criteria.map((c) => c.id)
1474
- : [];
1475
- // Treat a harness error as a failed required gate so it cannot win arbitration.
1476
- const gates = run.errored
1477
- ? [
1478
- ...run.gates,
1479
- {
1480
- id: "harness",
1481
- command: "harness",
1482
- exit_code: 1,
1483
- status: "failed",
1484
- duration_ms: 0,
1485
- required: true,
1486
- stdout_tail: null,
1487
- stderr_tail: null,
1488
- output_truncated: false,
1489
- },
1490
- ]
1491
- : run.gates;
1492
- return {
1493
- attemptId: run.attemptId,
1494
- label: run.label,
1495
- gates,
1496
- acceptanceCovered: acCovered,
1497
- acceptanceTotal: acTotal,
1498
- findings,
1499
- // Counted from the EVIDENCE gates (including the injected harness-failure
1500
- // gate), so an errored candidate scores 0/1 — never a vacuous 0/0.
1501
- testsPassed: gates.filter((g) => g.status === "passed").length,
1502
- testsTotal: gates.length,
1503
- finalReviewClean,
1504
- reviewVerified,
1505
- toolWarningsCount: run.telemetry.outcome?.toolWarningsCount ?? toolWarnings(run.telemetry).length,
1506
- diffSize: run.diff.split("\n").length,
1507
- diffBytes: Buffer.byteLength(run.diff, "utf8"),
1508
- costUsd: run.cost,
1509
- };
1510
- }
1511
1570
  interactionChannelFor(input, log, runId, taskId, attemptId, harnessId,
1512
1571
  // REQUIRED (no default): every call site must state the routed manifest's
1513
1572
  // `interactive` capability, or a future site would silently bypass the gate.
@@ -1552,10 +1611,12 @@ export class Orchestrator {
1552
1611
  "Retry the run",
1553
1612
  ],
1554
1613
  });
1555
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: failed\n- Phase: workspace\n\n${message}\n`);
1614
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: workspace\n\n${message}\n`);
1556
1615
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
1557
1616
  log.emit("run.failed", {
1558
- status: "failed",
1617
+ lifecycle: "failed",
1618
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
1619
+ reason: "harness_failed",
1559
1620
  phase: "workspace",
1560
1621
  error: message,
1561
1622
  failure_ref: "final/failure.yaml",
@@ -1563,6 +1624,37 @@ export class Orchestrator {
1563
1624
  return message;
1564
1625
  }
1565
1626
  }
1627
+ /**
1628
+ * Freeze-on-implement delivery (D17/D27): verify the frozen plan's hash and
1629
+ * materialize it as context/PLAN.md in the run artifact tree — OUTSIDE every
1630
+ * worktree, so it can never dirty a diff — then point the prompt at the
1631
+ * absolute path. A mismatched or unreadable plan fails LOUDLY before any
1632
+ * harness spawns (the tamper fence; retry replays planRef verbatim, so a
1633
+ * retried implement can never silently run without its plan).
1634
+ */
1635
+ withPlanBrief(input, store, paths, log) {
1636
+ if (!input.planRef)
1637
+ return input;
1638
+ const text = readTextSafe(input.planRef.path);
1639
+ if (!text || !text.trim()) {
1640
+ throw new Error(`implement plan: the frozen plan at ${input.planRef.path} is missing or unreadable`);
1641
+ }
1642
+ const digest = sha256(text).replace(/^sha256:/, "");
1643
+ if (digest !== input.planRef.sha256) {
1644
+ throw new Error(`implement plan: plan hash mismatch (expected ${input.planRef.sha256}, got ${digest}) — the plan was modified after freeze; re-run Implement from the plan turn`);
1645
+ }
1646
+ const briefPath = join(paths.contextDir, "PLAN.md");
1647
+ store.writeText(briefPath, text);
1648
+ log.emit("plan.brief.materialized", {
1649
+ plan_run_id: input.planRef.runId,
1650
+ sha256: input.planRef.sha256,
1651
+ path: "context/PLAN.md",
1652
+ });
1653
+ return {
1654
+ ...input,
1655
+ prompt: `${input.prompt}\n\nThe approved plan is at: ${briefPath} — read it before starting and re-read it as needed.`,
1656
+ };
1657
+ }
1566
1658
  async runRace(input, mode, announce) {
1567
1659
  const taskId = input.taskId ?? newId("task");
1568
1660
  const runId = input.runId ?? newId("run");
@@ -1573,6 +1665,7 @@ export class Orchestrator {
1573
1665
  const store = this.artifactStore(input);
1574
1666
  const paths = store.createRun(runId);
1575
1667
  const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
1668
+ input = this.withPlanBrief(input, store, paths, log);
1576
1669
  // The execution root is the tree the harness mutates: the project itself for
1577
1670
  // in-place threads/ordinary runs, or the thread's persistent worktree for an
1578
1671
  // isolated thread. Config/artifacts/contract stay anchored to repoRoot. Both
@@ -1606,7 +1699,8 @@ export class Orchestrator {
1606
1699
  runId,
1607
1700
  taskId,
1608
1701
  mode,
1609
- status: "failed",
1702
+ lifecycle: "failed",
1703
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
1610
1704
  winner: null,
1611
1705
  runDir: paths.root,
1612
1706
  summary: gitPreconditionError,
@@ -1636,6 +1730,7 @@ export class Orchestrator {
1636
1730
  });
1637
1731
  let adapters;
1638
1732
  try {
1733
+ // Best-of races the whole pool (no divergence `log`: the pool still runs).
1639
1734
  adapters = await this.resolveCandidateAdapters(input, this.candidateIntent(input), ledger);
1640
1735
  }
1641
1736
  catch (err) {
@@ -1647,10 +1742,12 @@ export class Orchestrator {
1647
1742
  safeMessage: message,
1648
1743
  runDir: paths.root,
1649
1744
  });
1650
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: failed\n- Phase: routing\n\n${message}\n`);
1745
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
1651
1746
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
1652
1747
  log.emit("run.failed", {
1653
- status: "failed",
1748
+ lifecycle: "failed",
1749
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
1750
+ reason: "harness_failed",
1654
1751
  phase: "routing",
1655
1752
  error: message,
1656
1753
  failure_ref: "final/failure.yaml",
@@ -1659,7 +1756,8 @@ export class Orchestrator {
1659
1756
  runId,
1660
1757
  taskId,
1661
1758
  mode,
1662
- status: "failed",
1759
+ lifecycle: "failed",
1760
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
1663
1761
  winner: null,
1664
1762
  runDir: paths.root,
1665
1763
  summary: message,
@@ -1789,7 +1887,7 @@ export class Orchestrator {
1789
1887
  budgetStopped = true;
1790
1888
  return true;
1791
1889
  }, input, requestedSingleCandidate);
1792
- ledger.settle(slot.leaseId, attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode));
1890
+ ledger.settle(slot.leaseId, attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode, run.telemetry.usageCost));
1793
1891
  log.emit("harness.completed", {
1794
1892
  harness_id: adapter.id,
1795
1893
  attempt_id: slot.attemptId,
@@ -1881,29 +1979,34 @@ export class Orchestrator {
1881
1979
  })), null), ledger.spend(), input.signal, store);
1882
1980
  }
1883
1981
  if (runs.length === 0) {
1884
- const status = ledger.terminal() ?? (budgetStopped ? "exhausted" : "failed");
1982
+ const budgetReason = ledger.terminal();
1983
+ const facts = makeOutcomeFacts("failed", {
1984
+ reason: budgetReason ?? (budgetStopped ? "budget_exhausted" : "harness_failed"),
1985
+ noChanges: true,
1986
+ });
1885
1987
  const why = budgetStopped
1886
1988
  ? "budget exhausted before any candidate run"
1887
1989
  : "no candidates produced";
1888
1990
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
1889
1991
  winner: null,
1890
- status,
1891
- outcome: "blocked",
1992
+ facts,
1892
1993
  why_winner: why,
1893
1994
  evidence_facts: ["no candidates were produced"],
1894
1995
  apply_recommendation: "continue",
1895
1996
  budget_summary: { spend_usd: ledger.spend(), estimated: false },
1896
1997
  });
1897
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: ${status}\n- Phase: budget\n\n${why}\n`);
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`);
1898
1999
  writeFailure(store, paths, {
1899
2000
  phase: "budget",
1900
- category: isBudgetTerminal(status) ? "budget" : "internal",
2001
+ category: isBudgetTerminal(facts.reason) ? "budget" : "internal",
1901
2002
  safeMessage: why,
1902
2003
  runDir: paths.root,
1903
2004
  });
1904
2005
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
1905
2006
  log.emit("run.failed", {
1906
- status,
2007
+ lifecycle: facts.lifecycle,
2008
+ facts,
2009
+ reason: facts.reason,
1907
2010
  phase: "budget",
1908
2011
  error: why,
1909
2012
  failure_ref: "final/failure.yaml",
@@ -1912,7 +2015,8 @@ export class Orchestrator {
1912
2015
  runId,
1913
2016
  taskId,
1914
2017
  mode,
1915
- status,
2018
+ lifecycle: facts.lifecycle,
2019
+ facts,
1916
2020
  winner: null,
1917
2021
  runDir: paths.root,
1918
2022
  summary: why,
@@ -1933,11 +2037,10 @@ export class Orchestrator {
1933
2037
  const rootCause = runs
1934
2038
  .map((r) => `${r.attemptId}/${r.harnessId}: ${r.errors[0] ?? "failed before producing work"}`)
1935
2039
  .join("; ");
1936
- const status = "failed";
2040
+ const facts = makeOutcomeFacts("failed", { reason: "harness_failed", noChanges: true });
1937
2041
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
1938
2042
  winner: null,
1939
- status,
1940
- outcome: "blocked",
2043
+ facts,
1941
2044
  why_winner: rootCause,
1942
2045
  evidence_facts: runs.map((r) => `${r.attemptId} produced no work: ${r.errors[0] ?? "unknown"}`),
1943
2046
  apply_recommendation: "continue",
@@ -1948,7 +2051,7 @@ export class Orchestrator {
1948
2051
  harnessId: r.harnessId,
1949
2052
  telemetry: r.telemetry,
1950
2053
  })), null);
1951
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: ${status}\n- Phase: ${phase}\n\n${rootCause}\n`);
2054
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}\n- Phase: ${phase}\n\n${rootCause}\n`);
1952
2055
  const existingEventRefs = runs
1953
2056
  .map((r) => `attempts/${r.attemptId}/events.jsonl`)
1954
2057
  .filter((rel) => existsSync(join(paths.root, rel)));
@@ -1967,7 +2070,9 @@ export class Orchestrator {
1967
2070
  });
1968
2071
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
1969
2072
  log.emit("run.failed", {
1970
- status,
2073
+ lifecycle: facts.lifecycle,
2074
+ facts,
2075
+ reason: facts.reason,
1971
2076
  phase,
1972
2077
  error: rootCause,
1973
2078
  failure_ref: "final/failure.yaml",
@@ -1976,7 +2081,8 @@ export class Orchestrator {
1976
2081
  runId,
1977
2082
  taskId,
1978
2083
  mode,
1979
- status,
2084
+ lifecycle: facts.lifecycle,
2085
+ facts,
1980
2086
  winner: null,
1981
2087
  runDir: paths.root,
1982
2088
  summary: rootCause,
@@ -2030,9 +2136,11 @@ export class Orchestrator {
2030
2136
  let envelope;
2031
2137
  try {
2032
2138
  const plan = buildSynthesisPlan(evidences);
2033
- const sourceDiffs = workingRuns
2034
- .map((r) => `### ${r.label} (${r.attemptId})\n${r.diff}`)
2035
- .join("\n\n");
2139
+ const synthesisInput = buildFileBackedSynthesisInput({
2140
+ instructions: plan.instructions,
2141
+ findings: plan.fixFindings,
2142
+ candidates: workingRuns,
2143
+ });
2036
2144
  const synthAdapter = synthRouted.adapter;
2037
2145
  // Disclose against the PER-ROUTE policy (per-harness web defaults
2038
2146
  // included), exactly like the candidate slots do.
@@ -2045,13 +2153,12 @@ export class Orchestrator {
2045
2153
  dirtyPolicy: "snapshot",
2046
2154
  accessProfile: candidateAccess,
2047
2155
  });
2048
- const synthPrompt = `${plan.instructions}\n\nFindings to fix:\n${plan.fixFindings.map((f) => `- ${f}`).join("\n") || "(none)"}\n\nCandidate diffs:\n${sourceDiffs}`;
2049
- const run = await this.runCandidateInEnvelope(synthRouted, envelope, "synth", "Synthesis", contract, synthPrompt, store, paths, wsm, ledger, candidateAccess, (ev) => {
2156
+ const run = await this.runCandidateInEnvelope(synthRouted, envelope, "synth", "Synthesis", contract, synthesisInput.prompt, store, paths, wsm, ledger, candidateAccess, (ev) => {
2050
2157
  const safeEv = redactHarnessEvent(ev);
2051
2158
  safeInvoke(input.onHarnessEvent, safeEv);
2052
2159
  log.emit("harness.event", harnessEventPayload(synthAdapter.id, "synth", safeEv));
2053
- }, input.signal, undefined, input.effort, "synthesize", log, effectiveWeb, this.interactionChannelFor(input, log, runId, taskId, "synth", synthAdapter.id, synthRouted.supportsInteractive), undefined, input);
2054
- ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode));
2160
+ }, input.signal, undefined, input.effort, "synthesize", log, effectiveWeb, this.interactionChannelFor(input, log, runId, taskId, "synth", synthAdapter.id, synthRouted.supportsInteractive), undefined, input, false, synthesisInput.content);
2161
+ ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode, run.telemetry.usageCost));
2055
2162
  reviewEnvelopes.push(envelope);
2056
2163
  envelope = undefined;
2057
2164
  try {
@@ -2105,7 +2212,7 @@ export class Orchestrator {
2105
2212
  }
2106
2213
  log.emit("arbitration.completed", {
2107
2214
  winner: result.decision.winner,
2108
- status: result.decision.status,
2215
+ lifecycle: result.decision.facts.lifecycle,
2109
2216
  });
2110
2217
  // Winner can only be a candidate that actually produced work; corpses are
2111
2218
  // excluded from arbitration upstream and from the fallback here.
@@ -2118,9 +2225,18 @@ export class Orchestrator {
2118
2225
  const actualReviewVerified = winnerRun
2119
2226
  ? (evidences.find((e) => e.attemptId === winnerRun.attemptId)?.reviewVerified ?? false)
2120
2227
  : evidences.length > 0 && evidences.every((e) => e.reviewVerified);
2121
- let status = needsHuman && result.decision.status !== "success" ? "blocked" : result.decision.status;
2122
- if (status === "success" && ledger.terminal() !== null)
2123
- status = ledger.terminal();
2228
+ let facts = result.decision.facts;
2229
+ // A reviewer NEEDS_HUMAN escalation forces the REVIEW axis to blocked (a
2230
+ // needs-decision terminal), unless the decision is already applyable-clean.
2231
+ if (needsHuman && facts.lifecycle === "succeeded" && facts.review !== "blocked") {
2232
+ facts = { ...facts, review: "blocked", reason: facts.reason ?? "review_blocked" };
2233
+ }
2234
+ // A budget terminal turns a succeeded lifecycle into a failed one (D8): the
2235
+ // budget reason IS a RunReason.
2236
+ const budgetTerminal = ledger.terminal();
2237
+ if (facts.lifecycle === "succeeded" && budgetTerminal) {
2238
+ facts = makeOutcomeFacts("failed", { reason: budgetTerminal, noChanges: facts.noChanges });
2239
+ }
2124
2240
  // FinalVerifier blocks adoption until the patch and gates pass on a fresh base.
2125
2241
  let finalVerify = null;
2126
2242
  let finalVerifyFailed = false;
@@ -2134,26 +2250,37 @@ export class Orchestrator {
2134
2250
  !inPlaceWinner &&
2135
2251
  !deferredRaceVerify &&
2136
2252
  winnerRun.diff.trim().length > 0 &&
2137
- (status === "success" || status === "ungated") &&
2253
+ facts.lifecycle === "succeeded" &&
2254
+ facts.review !== "blocked" &&
2138
2255
  !input.signal?.aborted) {
2139
2256
  finalVerify = await finalVerifyPatch(execRoot, winnerRun, gateSpecsFromContract(contract), log);
2140
2257
  // Verify errors block like proven failures; accept_risk stays available.
2258
+ // A failed fresh verify lands on the CHECKS axis (a needs-decision block).
2141
2259
  finalVerifyFailed = finalVerifyBlocks(finalVerify);
2142
2260
  if (finalVerifyFailed)
2143
- status = "blocked";
2261
+ facts = { ...facts, checks: "failed", reason: "checks_failed" };
2144
2262
  }
2263
+ // A needs-decision terminal (review blocked or checks failed) overrides the
2264
+ // persisted green arbitration fields; otherwise the facts pass through.
2265
+ const needsDec = facts.review === "blocked" || facts.checks === "failed";
2145
2266
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
2146
2267
  ...result.decision,
2147
- // A blocked terminal overrides the persisted green arbitration fields.
2148
- ...(status === "blocked"
2268
+ ...(needsDec
2149
2269
  ? blockedDecisionOverride(result.decision.evidence_facts, finalVerify)
2150
- : {}),
2270
+ : { facts }),
2151
2271
  review_verified: actualReviewVerified,
2152
2272
  final_verify: finalVerify,
2153
2273
  });
2154
2274
  store.writeYaml(join(paths.arbitrationDir, "pairwise.yaml"), result.pairwise);
2155
2275
  const decisionPath = join(paths.arbitrationDir, "decision.yaml");
2156
2276
  if (winnerRun) {
2277
+ for (const path of materializeWinnerOutputs({
2278
+ attemptDir: join(paths.attemptsDir, winnerRun.attemptId),
2279
+ runRoot: paths.root,
2280
+ paths: winnerRun.producedFiles ?? [],
2281
+ })) {
2282
+ log.emit("output.ready", { kind: "artifact", path });
2283
+ }
2157
2284
  assertNoSecretLikeTokens("final patch diff", winnerRun.diff);
2158
2285
  const patchSha256 = sha256(winnerRun.diff);
2159
2286
  store.writeText(join(paths.finalDir, "patch.diff"), winnerRun.diff);
@@ -2186,8 +2313,9 @@ export class Orchestrator {
2186
2313
  answerText: winnerAnswer,
2187
2314
  });
2188
2315
  }
2189
- // Only a fully verified success may auto-adopt; ungated remains an artifact.
2190
- const adoptable = status === "success";
2316
+ // Only a fully verified, applyable success may auto-adopt; a not-verified
2317
+ // or needs-decision terminal remains an inspectable artifact.
2318
+ const adoptable = facts.lifecycle === "succeeded" && facts.review === "approved" && facts.checks !== "failed";
2191
2319
  let adopted = null;
2192
2320
  let applyState = "not_applied";
2193
2321
  let postTurnSha = null;
@@ -2231,7 +2359,7 @@ export class Orchestrator {
2231
2359
  adopted = false;
2232
2360
  applyState = "not_applied";
2233
2361
  deliveryFailureReason = applied.detail ?? "race adoption delivery was refused";
2234
- status = "blocked";
2362
+ facts = { ...facts, checks: "failed", reason: "checks_failed" };
2235
2363
  if (finalVerifyBlocks(finalVerify))
2236
2364
  finalVerifyFailed = true;
2237
2365
  log.emit("work_product.adopted", {
@@ -2245,7 +2373,7 @@ export class Orchestrator {
2245
2373
  }
2246
2374
  writeRaceDeliveryDecision(store, decisionPath, {
2247
2375
  decision: result.decision,
2248
- status,
2376
+ facts,
2249
2377
  reviewVerified: actualReviewVerified,
2250
2378
  finalVerify,
2251
2379
  deliveryFailureReason,
@@ -2266,8 +2394,9 @@ export class Orchestrator {
2266
2394
  harness_id: winnerRun.harnessId,
2267
2395
  synthesis: synth,
2268
2396
  mode,
2269
- // Artifact-only apply reads the same terminal status as the daemon.
2270
- status,
2397
+ // Artifact-only apply reads the same terminal axes as the daemon (D8).
2398
+ lifecycle: facts.lifecycle,
2399
+ outcome_facts: facts,
2271
2400
  review_verified: actualReviewVerified,
2272
2401
  budget_stopped: budgetStopped,
2273
2402
  patch_sha256: patchSha256,
@@ -2285,18 +2414,31 @@ export class Orchestrator {
2285
2414
  revert_anchor_id: revertAnchorId,
2286
2415
  },
2287
2416
  });
2288
- store.writeText(join(paths.finalDir, "summary.md"), renderSummary(runId, mode, { ...result.decision, status }, evidences, synth.reason, actualReviewVerified));
2289
- // A non-success run's summary/patch is diagnostic context, not an applyable green output.
2417
+ store.writeText(join(paths.finalDir, "summary.md"), renderSummary(runId, mode, { ...result.decision, facts }, evidences, synth.reason, actualReviewVerified));
2418
+ // summary.md is a DIAGNOSTIC artifact only (V8/PLAN addendum 2): it no
2419
+ // longer carries primary-output authority. A clean applyable success or a
2420
+ // winner answer still marks it ready for legacy INV-116 ordering; any
2421
+ // other terminal is diagnostic context.
2290
2422
  log.emit("output.ready", {
2291
2423
  kind: "summary",
2292
2424
  path: "final/summary.md",
2293
- ...(status === "success" ? {} : { state: "diagnostic" }),
2425
+ state: (facts.lifecycle === "succeeded" &&
2426
+ facts.review === "approved" &&
2427
+ facts.checks !== "failed") ||
2428
+ winnerAnswer.length > 0
2429
+ ? "ready"
2430
+ : "diagnostic",
2294
2431
  });
2295
2432
  }
2296
- this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, runs.map((r) => ({ attemptId: r.attemptId, harnessId: r.harnessId, telemetry: r.telemetry })), result.decision.status === "success"
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"
2297
2434
  ? result.decision.winner
2298
2435
  : (winnerRun?.attemptId ?? null));
2299
- const honestTerminal = status === "no_op" || status === "ungated" || status === "review_not_run";
2436
+ // A needs-decision terminal (review blocked or checks failed) OR a
2437
+ // non-succeeded lifecycle writes a failure record and fires
2438
+ // run.blocked/run.failed; a succeeded, non-needs-decision terminal
2439
+ // (applyable, no_changes, or not-verified) is an honest completion.
2440
+ const needsDecisionTerminal = facts.review === "blocked" || facts.checks === "failed";
2441
+ const isFailureTerminal = facts.lifecycle !== "succeeded" || needsDecisionTerminal;
2300
2442
  if (deliveryFailureReason && !finalVerifyFailed) {
2301
2443
  writeFailure(store, paths, deliveryRefusalFailure(deliveryFailureReason, paths.root));
2302
2444
  }
@@ -2321,14 +2463,14 @@ export class Orchestrator {
2321
2463
  ],
2322
2464
  });
2323
2465
  }
2324
- else if (status !== "success" && !honestTerminal) {
2466
+ else if (isFailureTerminal) {
2325
2467
  writeFailure(store, paths, {
2326
2468
  phase: needsHuman ? "review" : "arbitration",
2327
2469
  category: needsHuman
2328
2470
  ? "policy"
2329
2471
  : winnerRun?.errored
2330
2472
  ? "harness_error"
2331
- : isBudgetTerminal(status)
2473
+ : isBudgetTerminal(facts.reason)
2332
2474
  ? "budget"
2333
2475
  : "internal",
2334
2476
  harnessId: winnerRun?.errored ? winnerRun.harnessId : undefined,
@@ -2353,7 +2495,7 @@ export class Orchestrator {
2353
2495
  ],
2354
2496
  });
2355
2497
  if (!winnerRun) {
2356
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: ${status}\n- Phase: arbitration\n\n${result.decision.why_winner}\n`);
2498
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""}\n- Phase: arbitration\n\n${result.decision.why_winner}\n`);
2357
2499
  log.emit("output.ready", {
2358
2500
  kind: "summary",
2359
2501
  path: "final/summary.md",
@@ -2362,14 +2504,15 @@ export class Orchestrator {
2362
2504
  }
2363
2505
  }
2364
2506
  log.emit("work_product.emitted", { winner: result.decision.winner });
2365
- if (status === "success" || honestTerminal) {
2366
- log.emit("run.completed", { status, outcome: result.decision.outcome });
2507
+ if (!isFailureTerminal) {
2508
+ log.emit("run.completed", { lifecycle: facts.lifecycle, facts, reason: facts.reason });
2367
2509
  }
2368
- else if (status === "blocked") {
2369
- // The event's phase must agree with failure.yaml (a verify block is
2370
- // phase "verification", not "review").
2510
+ else if (facts.lifecycle === "succeeded") {
2511
+ // needsDecision at terminal — the event's phase must agree with
2512
+ // failure.yaml (a verify block is phase "verification", not "review").
2371
2513
  log.emit("run.blocked", {
2372
- status,
2514
+ lifecycle: facts.lifecycle,
2515
+ facts,
2373
2516
  phase: deliveryFailureReason && !finalVerifyFailed
2374
2517
  ? "delivery"
2375
2518
  : finalVerifyFailed
@@ -2379,13 +2522,20 @@ export class Orchestrator {
2379
2522
  });
2380
2523
  }
2381
2524
  else {
2382
- log.emit("run.failed", { status, phase: "arbitration", failure_ref: "final/failure.yaml" });
2525
+ log.emit("run.failed", {
2526
+ lifecycle: facts.lifecycle,
2527
+ facts,
2528
+ reason: facts.reason,
2529
+ phase: "arbitration",
2530
+ failure_ref: "final/failure.yaml",
2531
+ });
2383
2532
  }
2384
2533
  return {
2385
2534
  runId,
2386
2535
  taskId,
2387
2536
  mode,
2388
- status,
2537
+ lifecycle: facts.lifecycle,
2538
+ facts,
2389
2539
  winner: result.decision.winner,
2390
2540
  runDir: paths.root,
2391
2541
  summary: result.decision.why_winner,
@@ -2477,15 +2627,21 @@ export class Orchestrator {
2477
2627
  distinctProviders: [],
2478
2628
  reviewSpendUsd: 0,
2479
2629
  reviewSpendEstimated: false,
2630
+ reviewCashUsd: 0,
2631
+ reviewValuationUsd: 0,
2632
+ reviewUnknownUsd: 0,
2480
2633
  };
2481
2634
  if (reviewLease?.granted) {
2482
- ledger?.settle(reviewLease.lease?.lease_id ?? "", usageCostSettlement(result.reviewSpendUsd, result.reviewSpendEstimated, "review-usage", [`attempt:${run.attemptId}`, "review:panel"]));
2635
+ ledger?.settle(reviewLease.lease?.lease_id ?? "", reviewUsageCostSettlement(result.reviewCashUsd, result.reviewValuationUsd, result.reviewSpendEstimated, [`attempt:${run.attemptId}`, "review:panel"], result.reviewUnknownUsd));
2483
2636
  if ((result.reviewSpendUsd ?? 0) > 0) {
2484
2637
  log.emit("budget.observation", {
2485
2638
  harness_id: "review-panel",
2486
2639
  attempt_id: run.attemptId,
2487
2640
  kind: "spend",
2488
2641
  usd: result.reviewSpendUsd,
2642
+ cash_usd: result.reviewCashUsd,
2643
+ valuation_usd: result.reviewValuationUsd,
2644
+ unknown_usd: result.reviewUnknownUsd,
2489
2645
  estimated: result.reviewSpendEstimated === true,
2490
2646
  });
2491
2647
  }
@@ -2517,6 +2673,7 @@ export class Orchestrator {
2517
2673
  store.writeYaml(join(paths.reviewsDir, `${run.attemptId}.yaml`), {
2518
2674
  attempt_id: run.attemptId,
2519
2675
  review_verified: candidateReviewVerified,
2676
+ final_review_clean: reviewClean,
2520
2677
  cross_family_healthy: result.crossFamilyHealthy,
2521
2678
  cross_family_verified: result.crossFamilyVerified,
2522
2679
  healthy_providers: result.healthyProviders,
@@ -2532,7 +2689,7 @@ export class Orchestrator {
2532
2689
  severity: f.severity,
2533
2690
  status: f.status,
2534
2691
  });
2535
- evidences.push(this.toEvidence(run, contract, allFindings, reviewClean, candidateReviewVerified));
2692
+ evidences.push(toCandidateEvidence(run, contract, allFindings, reviewClean, candidateReviewVerified));
2536
2693
  }
2537
2694
  finally {
2538
2695
  this.recordReviewEvidenceCleanup(store, join(paths.reviewsDir, `${run.attemptId}-evidence-cleanup.yaml`), run.attemptId, candidateEvidenceDir, candidateCwd);
@@ -2586,6 +2743,7 @@ export class Orchestrator {
2586
2743
  const store = this.artifactStore(input);
2587
2744
  const paths = store.createRun(runId);
2588
2745
  const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
2746
+ input = this.withPlanBrief(input, store, paths, log);
2589
2747
  // The execution root is the tree the harness mutates (thread worktree for an
2590
2748
  // isolated thread, else the project). The WorkspaceManager AND the git
2591
2749
  // boundary must resolve against the SAME root — the race path does so via the
@@ -2618,7 +2776,8 @@ export class Orchestrator {
2618
2776
  runId,
2619
2777
  taskId,
2620
2778
  mode,
2621
- status: "failed",
2779
+ lifecycle: "failed",
2780
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
2622
2781
  winner: null,
2623
2782
  runDir: paths.root,
2624
2783
  summary: gitPreconditionError,
@@ -2640,7 +2799,7 @@ export class Orchestrator {
2640
2799
  // One envelope carried forward across attempts so the harness can repair its own work.
2641
2800
  let adapterPool;
2642
2801
  try {
2643
- adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger);
2802
+ adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger, log);
2644
2803
  this.requestRequirements.assertConvergenceWorkspace(input.inPlace === true, adapterPool);
2645
2804
  }
2646
2805
  catch (err) {
@@ -2652,10 +2811,12 @@ export class Orchestrator {
2652
2811
  safeMessage: message,
2653
2812
  runDir: paths.root,
2654
2813
  });
2655
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: failed\n- Phase: routing\n\n${message}\n`);
2814
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
2656
2815
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
2657
2816
  log.emit("run.failed", {
2658
- status: "failed",
2817
+ lifecycle: "failed",
2818
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
2819
+ reason: "harness_failed",
2659
2820
  phase: "routing",
2660
2821
  error: message,
2661
2822
  failure_ref: "final/failure.yaml",
@@ -2665,7 +2826,8 @@ export class Orchestrator {
2665
2826
  runId,
2666
2827
  taskId,
2667
2828
  mode,
2668
- status: "failed",
2829
+ lifecycle: "failed",
2830
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
2669
2831
  winner: null,
2670
2832
  runDir: paths.root,
2671
2833
  summary: message,
@@ -2689,10 +2851,12 @@ export class Orchestrator {
2689
2851
  "Check harness doctor for reviewer readiness",
2690
2852
  ],
2691
2853
  });
2692
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: failed\n- Phase: review preflight\n\n${message}\n`);
2854
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: failed\n- Phase: review preflight\n\n${message}\n`);
2693
2855
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
2694
2856
  log.emit("run.failed", {
2695
- status: "failed",
2857
+ lifecycle: "failed",
2858
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
2859
+ reason: "harness_failed",
2696
2860
  phase: "review",
2697
2861
  error: message,
2698
2862
  failure_ref: "final/failure.yaml",
@@ -2702,7 +2866,8 @@ export class Orchestrator {
2702
2866
  runId,
2703
2867
  taskId,
2704
2868
  mode,
2705
- status: "failed",
2869
+ lifecycle: "failed",
2870
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
2706
2871
  winner: null,
2707
2872
  runDir: paths.root,
2708
2873
  summary: message,
@@ -2818,7 +2983,7 @@ export class Orchestrator {
2818
2983
  ledger.updateHold(lease.lease?.lease_id ?? "", streamedUsd);
2819
2984
  return ledger.tier() === "hard";
2820
2985
  }, input, true);
2821
- ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode));
2986
+ ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(run.cost, run.costEstimated, run.attemptId, run.harnessId, run.telemetry.authMode, run.telemetry.usageCost));
2822
2987
  log.emit("harness.completed", {
2823
2988
  harness_id: adapter.id,
2824
2989
  attempt_id: attemptId,
@@ -2906,15 +3071,21 @@ export class Orchestrator {
2906
3071
  distinctProviders: [],
2907
3072
  reviewSpendUsd: 0,
2908
3073
  reviewSpendEstimated: false,
3074
+ reviewCashUsd: 0,
3075
+ reviewValuationUsd: 0,
3076
+ reviewUnknownUsd: 0,
2909
3077
  };
2910
3078
  if (reviewLease?.granted) {
2911
- ledger.settle(reviewLease.lease?.lease_id ?? "", usageCostSettlement(reviewResult.reviewSpendUsd, reviewResult.reviewSpendEstimated, "review-usage", [`attempt:${attemptId}`, "review:panel"]));
3079
+ ledger.settle(reviewLease.lease?.lease_id ?? "", reviewUsageCostSettlement(reviewResult.reviewCashUsd, reviewResult.reviewValuationUsd, reviewResult.reviewSpendEstimated, [`attempt:${attemptId}`, "review:panel"], reviewResult.reviewUnknownUsd));
2912
3080
  if ((reviewResult.reviewSpendUsd ?? 0) > 0) {
2913
3081
  log.emit("budget.observation", {
2914
3082
  harness_id: "review-panel",
2915
3083
  attempt_id: attemptId,
2916
3084
  kind: "spend",
2917
3085
  usd: reviewResult.reviewSpendUsd,
3086
+ cash_usd: reviewResult.reviewCashUsd,
3087
+ valuation_usd: reviewResult.reviewValuationUsd,
3088
+ unknown_usd: reviewResult.reviewUnknownUsd,
2918
3089
  estimated: reviewResult.reviewSpendEstimated === true,
2919
3090
  });
2920
3091
  if (reviewResult.reviewSpendEstimated === true)
@@ -2941,9 +3112,15 @@ export class Orchestrator {
2941
3112
  const policy = policyFindings(run, actualReviewVerified, contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals, contract.constraints.deny_paths);
2942
3113
  const allFindings = [...policy.findings, ...revalidated];
2943
3114
  lastFindings = allFindings;
3115
+ const inconclusive = allFindings.some((f) => f.severity === "INSUFFICIENT_EVIDENCE" || f.status === "insufficient_evidence");
3116
+ const finalReviewClean = reviewResult.crossFamilyHealthy &&
3117
+ reviewResult.crossFamilyVerified &&
3118
+ !inconclusive &&
3119
+ !allFindings.some((f) => isBlocking(f));
2944
3120
  store.writeYaml(join(paths.reviewsDir, `${attemptId}.yaml`), {
2945
3121
  attempt_id: attemptId,
2946
3122
  review_verified: actualReviewVerified,
3123
+ final_review_clean: finalReviewClean,
2947
3124
  cross_family_healthy: reviewResult.crossFamilyHealthy,
2948
3125
  cross_family_verified: reviewResult.crossFamilyVerified,
2949
3126
  healthy_providers: reviewResult.healthyProviders,
@@ -2953,11 +3130,6 @@ export class Orchestrator {
2953
3130
  findings: allFindings,
2954
3131
  route_proofs: reviewResult.routeProofs,
2955
3132
  });
2956
- const inconclusive = allFindings.some((f) => f.severity === "INSUFFICIENT_EVIDENCE" || f.status === "insufficient_evidence");
2957
- const finalReviewClean = reviewResult.crossFamilyHealthy &&
2958
- reviewResult.crossFamilyVerified &&
2959
- !inconclusive &&
2960
- !allFindings.some((f) => isBlocking(f));
2961
3133
  lastFinalReviewClean = finalReviewClean;
2962
3134
  // Measure diff stability instead of asserting it: the tree must not have
2963
3135
  // changed between the candidate diff capture and the end of review.
@@ -3067,38 +3239,51 @@ export class Orchestrator {
3067
3239
  if (envelope)
3068
3240
  await wsm.dispose(envelope);
3069
3241
  }
3070
- let status = input.signal?.aborted
3071
- ? "cancelled"
3242
+ // Base terminal AXES (D8) from the convergence loop outcome. Attempts-cap
3243
+ // exhaustion maps to budget_exhausted (an attempt budget); the give-up
3244
+ // states map to their matching RunReason.
3245
+ let facts = input.signal?.aborted
3246
+ ? makeOutcomeFacts("cancelled", { reason: "user_cancelled" })
3072
3247
  : converged
3073
- ? "success"
3248
+ ? makeOutcomeFacts("succeeded")
3074
3249
  : stuckNoProgress
3075
- ? "stuck_no_progress"
3250
+ ? makeOutcomeFacts("failed", { reason: "stuck_no_progress" })
3076
3251
  : exhausted
3077
- ? "exhausted"
3078
- : "not_converged";
3079
- if (status === "success" && ledger.terminal() !== null)
3080
- status = ledger.terminal();
3252
+ ? makeOutcomeFacts("failed", { reason: "budget_exhausted" })
3253
+ : makeOutcomeFacts("failed", { reason: "not_converged" });
3081
3254
  let decision = null;
3082
3255
  if (lastRun) {
3083
3256
  const arb = arbitrate([
3084
- this.toEvidence(lastRun, contract, lastFindings, lastFinalReviewClean, actualReviewVerified),
3257
+ toCandidateEvidence(lastRun, contract, lastFindings, lastFinalReviewClean, actualReviewVerified),
3085
3258
  ], {
3086
3259
  spendUsd: ledger.spend(),
3087
3260
  estimatedSpend: lastRun.costEstimated || reviewSpendEstimated,
3088
3261
  });
3089
3262
  decision = arb.decision;
3090
3263
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
3091
- if (converged && decision.status !== "not_converged") {
3092
- status = decision.status;
3264
+ // A converged run adopts the arbitration axes (checks/review); an
3265
+ // otherwise not-converged loop that nonetheless produced an applyable
3266
+ // decision adopts it too.
3267
+ if (converged) {
3268
+ facts = decision.facts;
3093
3269
  }
3094
- else if (status === "not_converged" && decision.status !== "success") {
3095
- status = decision.status;
3270
+ else if (facts.reason === "not_converged" && decision.facts.lifecycle === "succeeded") {
3271
+ facts = decision.facts;
3096
3272
  }
3097
3273
  }
3098
- // A reviewer escalation to a human is a BLOCKED terminal, not a silent risk note.
3274
+ // A budget terminal turns a succeeded lifecycle into a failed one (D8).
3275
+ const convBudgetTerminal = ledger.terminal();
3276
+ if (facts.lifecycle === "succeeded" && convBudgetTerminal) {
3277
+ facts = makeOutcomeFacts("failed", {
3278
+ reason: convBudgetTerminal,
3279
+ noChanges: facts.noChanges,
3280
+ });
3281
+ }
3282
+ // A reviewer escalation to a human forces the REVIEW axis to blocked.
3099
3283
  const needsHuman = lastFindings.some((f) => f.severity === "NEEDS_HUMAN" && isBlocking(f));
3100
- if (needsHuman && status !== "success" && status !== "cancelled")
3101
- status = "blocked";
3284
+ if (needsHuman && facts.lifecycle === "succeeded" && facts.review !== "blocked") {
3285
+ facts = { ...facts, review: "blocked", reason: facts.reason ?? "review_blocked" };
3286
+ }
3102
3287
  // FinalVerifier (INV-115) applies to EVERY applyable envelope-mode patch,
3103
3288
  // not only race winners: a convergence run's delivered patch must also
3104
3289
  // survive a fresh tree at its own base + the deterministic gates there.
@@ -3109,20 +3294,22 @@ export class Orchestrator {
3109
3294
  if (input.inPlace !== true &&
3110
3295
  lastRun &&
3111
3296
  lastRun.diff.trim().length > 0 &&
3112
- (status === "success" || status === "ungated") &&
3297
+ facts.lifecycle === "succeeded" &&
3298
+ facts.review !== "blocked" &&
3113
3299
  !input.signal?.aborted) {
3114
3300
  convFinalVerify = await finalVerifyPatch(execRoot, lastRun, gateSpecsFromContract(contract), log);
3115
3301
  if (finalVerifyBlocks(convFinalVerify))
3116
- status = "blocked";
3302
+ facts = { ...facts, checks: "failed", reason: "checks_failed" };
3117
3303
  }
3304
+ const convNeedsDecision = facts.review === "blocked" || facts.checks === "failed";
3118
3305
  if (decision) {
3119
- // Shared honesty owner (same as the race path): a blocked terminal
3306
+ // Shared honesty owner (same as the race path): a needs-decision terminal
3120
3307
  // overrides the persisted decision; final_verify is recorded either way.
3121
3308
  decision = {
3122
3309
  ...decision,
3123
- ...(status === "blocked"
3310
+ ...(convNeedsDecision
3124
3311
  ? blockedDecisionOverride(decision.evidence_facts, convFinalVerify)
3125
- : {}),
3312
+ : { facts }),
3126
3313
  final_verify: convFinalVerify,
3127
3314
  };
3128
3315
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
@@ -3137,7 +3324,7 @@ export class Orchestrator {
3137
3324
  // convergence run with inPlace mutated the live tree directly across its
3138
3325
  // attempts, so it is "applied" even when review blocked (Revert offered).
3139
3326
  const convHasDiff = lastRun.diff.trim().length > 0;
3140
- const convAdoptable = status === "success" || status === "ungated";
3327
+ const convAdoptable = facts.lifecycle === "succeeded" && facts.review === "approved" && facts.checks !== "failed";
3141
3328
  const convAdopted = input.inPlace === true && convHasDiff ? true : null;
3142
3329
  const convApplyState = convAdopted === true
3143
3330
  ? convAdoptable
@@ -3157,7 +3344,8 @@ export class Orchestrator {
3157
3344
  result_kind: "patch",
3158
3345
  mode,
3159
3346
  attempts: attempt,
3160
- status,
3347
+ lifecycle: facts.lifecycle,
3348
+ outcome_facts: facts,
3161
3349
  review_verified: actualReviewVerified,
3162
3350
  patch_sha256: patchSha256,
3163
3351
  adopted: convAdopted,
@@ -3167,42 +3355,45 @@ export class Orchestrator {
3167
3355
  revert_anchor_id: revertAnchorId,
3168
3356
  },
3169
3357
  });
3170
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: ${status}\n- Attempts: ${attempt}\n- Winner: ${lastRun.attemptId}\n- Review verified (cross-family): ${actualReviewVerified}\n- Apply recommendation: ${decision?.apply_recommendation ?? "inspect"}${stuckNoProgressReason ? `\n- No-progress reason: ${stuckNoProgressReason}` : ""}\n`);
3358
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""}\n- Attempts: ${attempt}\n- Winner: ${lastRun.attemptId}\n- Review verified (cross-family): ${actualReviewVerified}\n- Apply recommendation: ${decision?.apply_recommendation ?? "inspect"}${stuckNoProgressReason ? `\n- No-progress reason: ${stuckNoProgressReason}` : ""}\n`);
3171
3359
  // Lifecycle invariant (all modes): output.ready precedes the terminal
3172
3360
  // event so a client that applied the terminal event has the output.
3173
3361
  log.emit("output.ready", {
3174
3362
  kind: "summary",
3175
3363
  path: "final/summary.md",
3176
- ...(status === "success" ? {} : { state: "diagnostic" }),
3364
+ ...(convAdoptable ? {} : { state: "diagnostic" }),
3177
3365
  });
3178
3366
  }
3179
- if (!converged) {
3367
+ // A needs-decision terminal (review blocked / checks failed) writes a
3368
+ // failure record + fires run.blocked even though the lifecycle succeeded.
3369
+ const convIsFailureTerminal = facts.lifecycle !== "succeeded" || convNeedsDecision;
3370
+ if (convIsFailureTerminal) {
3180
3371
  writeFailure(store, paths, {
3181
- phase: "convergence",
3182
- category: isBudgetTerminal(status)
3372
+ phase: convNeedsDecision ? "review" : "convergence",
3373
+ category: isBudgetTerminal(facts.reason)
3183
3374
  ? "budget"
3184
- : status === "cancelled"
3375
+ : facts.lifecycle === "cancelled"
3185
3376
  ? "cancelled"
3186
- : status === "blocked"
3377
+ : convNeedsDecision
3187
3378
  ? "policy"
3188
3379
  : "internal",
3189
- safeMessage: status === "blocked"
3380
+ safeMessage: convNeedsDecision
3190
3381
  ? `review escalated to a human decision after ${attempt} attempt(s)`
3191
- : status === "stuck_no_progress"
3382
+ : facts.reason === "stuck_no_progress"
3192
3383
  ? (stuckNoProgressReason ?? `stuck_no_progress after ${attempt} attempt(s)`)
3193
- : `${status} after ${attempt} attempt(s)${lastDiffStable ? "" : " (diff changed after review; review is stale)"}`,
3384
+ : `${facts.lifecycle}${facts.reason ? ` (${facts.reason})` : ""} after ${attempt} attempt(s)${lastDiffStable ? "" : " (diff changed after review; review is stale)"}`,
3194
3385
  harnessId: lastRun?.harnessId,
3195
3386
  attemptId: lastRun?.attemptId,
3196
3387
  runDir: paths.root,
3197
- nextActions: status === "cancelled"
3388
+ nextActions: facts.lifecycle === "cancelled"
3198
3389
  ? ["Retry if cancellation was accidental"]
3199
- : status === "blocked"
3390
+ : convNeedsDecision
3200
3391
  ? [
3201
3392
  "Open the review queue",
3202
3393
  "Decide the NEEDS_HUMAN findings",
3203
3394
  "Re-run after the decision",
3204
3395
  ]
3205
- : status === "stuck_no_progress"
3396
+ : facts.reason === "stuck_no_progress"
3206
3397
  ? [
3207
3398
  "Inspect the stable patch",
3208
3399
  "Inspect the failing gate output",
@@ -3215,7 +3406,7 @@ export class Orchestrator {
3215
3406
  ],
3216
3407
  });
3217
3408
  if (!lastRun) {
3218
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Status: ${status}\n- Attempts: ${attempt}\n`);
3409
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${mode})\n\n- Lifecycle: ${facts.lifecycle}\n- Attempts: ${attempt}\n`);
3219
3410
  log.emit("output.ready", {
3220
3411
  kind: "summary",
3221
3412
  path: "final/summary.md",
@@ -3224,13 +3415,18 @@ export class Orchestrator {
3224
3415
  }
3225
3416
  }
3226
3417
  log.emit("work_product.emitted", { winner: lastRun?.attemptId ?? null });
3227
- const completed = converged || status === "no_op" || status === "ungated" || status === "review_not_run";
3228
- if (completed) {
3229
- log.emit("run.completed", { status, attempts: attempt });
3418
+ if (!convIsFailureTerminal) {
3419
+ log.emit("run.completed", {
3420
+ lifecycle: facts.lifecycle,
3421
+ facts,
3422
+ reason: facts.reason,
3423
+ attempts: attempt,
3424
+ });
3230
3425
  }
3231
- else if (status === "blocked") {
3426
+ else if (facts.lifecycle === "succeeded") {
3232
3427
  log.emit("run.blocked", {
3233
- status,
3428
+ lifecycle: facts.lifecycle,
3429
+ facts,
3234
3430
  attempts: attempt,
3235
3431
  phase: "review",
3236
3432
  failure_ref: "final/failure.yaml",
@@ -3238,7 +3434,9 @@ export class Orchestrator {
3238
3434
  }
3239
3435
  else {
3240
3436
  log.emit("run.failed", {
3241
- status,
3437
+ lifecycle: facts.lifecycle,
3438
+ facts,
3439
+ reason: facts.reason,
3242
3440
  attempts: attempt,
3243
3441
  phase: "convergence",
3244
3442
  failure_ref: "final/failure.yaml",
@@ -3249,19 +3447,26 @@ export class Orchestrator {
3249
3447
  runId,
3250
3448
  taskId,
3251
3449
  mode,
3252
- status,
3450
+ lifecycle: facts.lifecycle,
3451
+ facts,
3253
3452
  winner: lastRun?.attemptId ?? null,
3254
3453
  runDir: paths.root,
3255
3454
  summary: converged
3256
3455
  ? `converged in ${attempt} attempt(s)`
3257
- : `${status} after ${attempt} attempt(s)`,
3456
+ : `${facts.lifecycle} after ${attempt} attempt(s)`,
3258
3457
  candidates: lastRun
3259
- ? [{ attemptId: lastRun.attemptId, harnessId: lastRun.harnessId, status }]
3458
+ ? [
3459
+ {
3460
+ attemptId: lastRun.attemptId,
3461
+ harnessId: lastRun.harnessId,
3462
+ status: facts.lifecycle,
3463
+ },
3464
+ ]
3260
3465
  : [],
3261
3466
  reviewVerified: actualReviewVerified,
3262
3467
  };
3263
3468
  }
3264
- /** plan mode: multi-harness planning -> aggregate -> (optional) plan review -> SpecPack. Read-only. */
3469
+ /** plan mode: multi-harness planning -> aggregate -> (optional) plan review -> plan. Read-only. */
3265
3470
  /**
3266
3471
  * Wrap the user's goal in an explicit "plan, do not implement" instruction.
3267
3472
  * Without this the raw prompt ("make a racing game") reaches the harness with
@@ -3280,10 +3485,222 @@ export class Orchestrator {
3280
3485
  `1. Approach — 2-3 sentences on how you'd solve this.`,
3281
3486
  `2. Steps — a numbered list; each step names the file(s) it touches and what changes.`,
3282
3487
  `3. Risks & edge cases.`,
3283
- `4. Open questions anything ambiguous that needs a decision before implementation.`,
3488
+ `4. End your response with a section titled exactly:`,
3489
+ ``,
3490
+ `## Open Questions`,
3491
+ ``,
3492
+ `List every decision the user must make before implementation, one per bullet, in EXACTLY this format:`,
3493
+ ``,
3494
+ `- [single] <question> :: <option A> :: <option B>`,
3495
+ `- [multi] <question> :: <option A> :: <option B>`,
3496
+ `- [text] <question that has no good fixed options>`,
3497
+ ``,
3498
+ `Rules: [single] = pick exactly one; [multi] = pick one or more; [text] = free-form (no "::" options). Ground every option in THIS repository. If nothing is ambiguous, write a single bullet: - (none)`,
3499
+ ``,
3284
3500
  `Keep it concise. Reference real paths you found. Do NOT paste large code blocks; describe the change instead.`,
3285
3501
  ].join("\n");
3286
3502
  }
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
+ */
3512
+ async runPlannerAttempt(args) {
3513
+ const { input, contract, taskId, runId, log, store, paths, ledger, routed, attemptId } = args;
3514
+ const adapter = routed.adapter;
3515
+ const lease = ledger.reserve({
3516
+ taskId,
3517
+ attemptId,
3518
+ intent: args.intent,
3519
+ harnessId: adapter.id,
3520
+ cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
3521
+ });
3522
+ if (!lease.granted) {
3523
+ log.emit("budget.lease.created", {
3524
+ granted: false,
3525
+ reason: lease.reason,
3526
+ attempt_id: attemptId,
3527
+ harness_id: adapter.id,
3528
+ });
3529
+ return {
3530
+ attemptId,
3531
+ harnessId: adapter.id,
3532
+ status: "failed",
3533
+ error: lease.reason ?? "budget lease denied",
3534
+ text: null,
3535
+ telemetry: null,
3536
+ budgetDenied: true,
3537
+ };
3538
+ }
3539
+ const knobs = this.routeSpecKnobs(routed, contract, undefined, input.effort);
3540
+ const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
3541
+ const planSessionFields = this.sessionSpecFields(input, adapter.id, log);
3542
+ // Continuity (INV-137): a thread PLAN turn is a chat turn — hydrate a
3543
+ // lane switch/gap with a packet and disclose it.
3544
+ const laneContinuity = args.laneRun
3545
+ ? await this.resolveContinuity(input, adapter.id, planSessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null, planSessionFields.resume_session_id !== null, store, paths, this.execRootOf(input), log)
3546
+ : null;
3547
+ const spec = HarnessRunSpec.parse({
3548
+ session_id: newId("ses"),
3549
+ intent: args.intent,
3550
+ prompt: laneContinuity?.pointerLine
3551
+ ? `${args.promptBody}\n\n${laneContinuity.pointerLine}`
3552
+ : args.promptBody,
3553
+ cwd: this.execRootOf(input),
3554
+ access: "readonly",
3555
+ // Planners must SEE any image/file the user attached (e.g. "plan a fix for
3556
+ // what's in this screenshot"), not just agent/race runs.
3557
+ attachments: input.attachments ?? [],
3558
+ ...planSessionFields,
3559
+ ...this.harnessSpecKnobs(contract, knobs, args.intent),
3560
+ env_inheritance: envInheritance(this.config(input.repoRoot)),
3561
+ // A thread plan turn spawns in its DURABLE per-lane home so its native
3562
+ // session is reachable for resume next turn (INV-034); a non-thread
3563
+ // plan keeps the disposable route-context home.
3564
+ env: (args.laneRun ? this.laneHomeEnvFor(input, adapter.id) : null) ?? args.fallbackHome,
3565
+ });
3566
+ const plannerAbort = new AbortController();
3567
+ spec.extra["abortSignal"] = input.signal
3568
+ ? AbortSignal.any([input.signal, plannerAbort.signal])
3569
+ : plannerAbort.signal;
3570
+ const planInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
3571
+ if (planInteraction)
3572
+ spec.extra["interactionChannel"] = planInteraction;
3573
+ const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
3574
+ const answer = new AnswerAssembly();
3575
+ const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
3576
+ knobs.webPolicy === "cached" ||
3577
+ knobs.webPolicy === "live", effectiveWeb, [],
3578
+ // Requested-model capture: a plan lane silently downgraded to another
3579
+ // model surfaces the mismatch in its route receipt, just like agent.
3580
+ knobs.model);
3581
+ const onAbort = () => {
3582
+ void adapter.cancel?.(spec.session_id)?.catch(() => { });
3583
+ };
3584
+ if (input.signal) {
3585
+ if (input.signal.aborted)
3586
+ onAbort();
3587
+ else
3588
+ input.signal.addEventListener("abort", onAbort, { once: true });
3589
+ }
3590
+ let cost = 0;
3591
+ let costEstimated = false;
3592
+ let harnessError = null;
3593
+ const budgetSignalState = { quotaPressureDisclosed: false };
3594
+ try {
3595
+ log.emit("harness.started", {
3596
+ harness_id: adapter.id,
3597
+ attempt_id: attemptId,
3598
+ external_context_policy: knobs.webPolicy,
3599
+ ...(knobs.ignored.length > 0 ? { ignored_settings: knobs.ignored } : {}),
3600
+ });
3601
+ if (!input.signal?.aborted) {
3602
+ const watchedPlan = withInactivityWatchdog(adapter.run(spec), {
3603
+ timeoutMs: harnessInactivityTimeoutMs(this.config(input.repoRoot)),
3604
+ onTimeout: () => {
3605
+ plannerAbort.abort();
3606
+ void adapter.cancel?.(spec.session_id)?.catch(() => { });
3607
+ },
3608
+ isSuspended: () => (planInteraction?.pendingCount?.() ?? 0) > 0,
3609
+ });
3610
+ for await (const ev of watchedPlan) {
3611
+ if (input.signal?.aborted)
3612
+ break;
3613
+ const safeEv = redactHarnessEvent(ev);
3614
+ safeInvoke(input.onHarnessEvent, safeEv);
3615
+ // A thread PLAN turn IS a chat turn now (INV-034): its native
3616
+ // session lives in the DURABLE per-lane home, so record it for the
3617
+ // next lane turn's resume. Council members are distinct lanes.
3618
+ if (args.laneRun)
3619
+ observeNativeSessionEvent(input, adapter.id, safeEv);
3620
+ observeAuthSwitch(log, adapter.id, attemptId, safeEv);
3621
+ log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
3622
+ appendLine(attemptEventsPath, JSON.stringify(safeEv));
3623
+ observeAttemptTelemetry(telemetry, safeEv);
3624
+ if (safeEv.plan_progress) {
3625
+ log.emit("plan.progress", {
3626
+ attempt_id: attemptId,
3627
+ harness_id: adapter.id,
3628
+ items: safeEv.plan_progress.items,
3629
+ });
3630
+ }
3631
+ // read-only routes burn quota too — same single owner as the agent loop.
3632
+ observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
3633
+ this.deps.quotaEventSink?.(adapter.id, safeEv);
3634
+ if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
3635
+ cost += safeEv.usage.cost_usd;
3636
+ if (safeEv.usage.estimated)
3637
+ costEstimated = true;
3638
+ log.emit("budget.observation", {
3639
+ harness_id: adapter.id,
3640
+ attempt_id: attemptId,
3641
+ kind: "spend",
3642
+ usd: safeEv.usage.cost_usd,
3643
+ estimated: safeEv.usage.estimated === true,
3644
+ });
3645
+ }
3646
+ // A TYPED final message wins verbatim over joined narration.
3647
+ answer.observe(safeEv);
3648
+ if (safeEv.type === "error")
3649
+ harnessError = safeEv.error ? redactSecrets(safeEv.error) : "harness emitted an error";
3650
+ }
3651
+ }
3652
+ }
3653
+ catch (err) {
3654
+ harnessError = safeErrorMessage(err);
3655
+ }
3656
+ finally {
3657
+ input.signal?.removeEventListener("abort", onAbort);
3658
+ ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode, telemetry.usageCost));
3659
+ }
3660
+ const unrecovered = unrecoveredToolErrors(telemetry);
3661
+ const webBlocked = webUnsatisfied(telemetry);
3662
+ 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")}`;
3664
+ }
3665
+ if (!harnessError && unrecovered.length > 0) {
3666
+ const first = unrecovered[0];
3667
+ harnessError = `${first.tool} failed without recovery: ${first.summary}`;
3668
+ }
3669
+ if (harnessError) {
3670
+ log.emit("harness.completed", {
3671
+ harness_id: adapter.id,
3672
+ attempt_id: attemptId,
3673
+ status: webBlocked ? "blocked" : "failed",
3674
+ error: harnessError,
3675
+ ...telemetrySummary(telemetry),
3676
+ });
3677
+ return {
3678
+ attemptId,
3679
+ harnessId: adapter.id,
3680
+ status: webBlocked ? "blocked" : "failed",
3681
+ error: harnessError,
3682
+ text: null,
3683
+ telemetry,
3684
+ budgetDenied: false,
3685
+ };
3686
+ }
3687
+ const text = answer.text() || "(no output)";
3688
+ log.emit("harness.completed", {
3689
+ harness_id: adapter.id,
3690
+ attempt_id: attemptId,
3691
+ status: "success",
3692
+ ...telemetrySummary(telemetry),
3693
+ });
3694
+ return {
3695
+ attemptId,
3696
+ harnessId: adapter.id,
3697
+ status: "success",
3698
+ error: null,
3699
+ text,
3700
+ telemetry,
3701
+ budgetDenied: false,
3702
+ };
3703
+ }
3287
3704
  async runPlan(input, announce) {
3288
3705
  const taskId = input.taskId ?? newId("task");
3289
3706
  const runId = input.runId ?? newId("run");
@@ -3308,16 +3725,19 @@ export class Orchestrator {
3308
3725
  });
3309
3726
  store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
3310
3727
  log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
3311
- const reviewersOutcome = await this.resolveReviewersWithArtifacts(input, log, store, paths, runId, taskId, "plan");
3312
- if ("failed" in reviewersOutcome)
3313
- return reviewersOutcome.failed;
3314
- const reviewers = reviewersOutcome.reviewers;
3315
3728
  // W3.3: ONE resolved read-only context — the routing point-probe and every
3316
- // planner spawn consume the SAME scoped env (see routeContext.ts).
3729
+ // planner spawn consume the SAME scoped env (see routeContext.ts). The
3730
+ // probe home stays a disposable throwaway even for a thread lane turn (auth
3731
+ // truth is home-independent); only the planner spawn swaps in the durable
3732
+ // per-lane home below so its recorded native session survives.
3317
3733
  const roHome = resolveReadOnlyRouteContext(this.execRootOf(input));
3734
+ // A thread PLAN turn is a chat turn (INV-034): plan candidates are distinct
3735
+ // harnesses run sequentially, so each records its own lane's native session
3736
+ // and the next lane turn resumes it via `sessionSpecFields.resume_session_id`.
3737
+ const laneRun = Boolean(input.threadId);
3318
3738
  let adapters;
3319
3739
  try {
3320
- adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, roHome);
3740
+ adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, log, roHome);
3321
3741
  }
3322
3742
  catch (err) {
3323
3743
  roHome.dispose();
@@ -3329,10 +3749,12 @@ export class Orchestrator {
3329
3749
  safeMessage: message,
3330
3750
  runDir: paths.root,
3331
3751
  });
3332
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Status: failed\n- Phase: routing\n\n${message}\n`);
3752
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
3333
3753
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
3334
3754
  log.emit("run.failed", {
3335
- status: "failed",
3755
+ lifecycle: "failed",
3756
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
3757
+ reason: "harness_failed",
3336
3758
  phase: "routing",
3337
3759
  error: message,
3338
3760
  failure_ref: "final/failure.yaml",
@@ -3342,7 +3764,8 @@ export class Orchestrator {
3342
3764
  runId,
3343
3765
  taskId,
3344
3766
  mode: "plan",
3345
- status: "failed",
3767
+ lifecycle: "failed",
3768
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
3346
3769
  winner: null,
3347
3770
  runDir: paths.root,
3348
3771
  summary: message,
@@ -3364,10 +3787,12 @@ export class Orchestrator {
3364
3787
  safeMessage: message,
3365
3788
  runDir: paths.root,
3366
3789
  });
3367
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Status: failed\n- Phase: context\n\n${message}\n`);
3790
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Lifecycle: failed\n- Phase: context\n\n${message}\n`);
3368
3791
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
3369
3792
  log.emit("run.failed", {
3370
- status: "failed",
3793
+ lifecycle: "failed",
3794
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
3795
+ reason: "harness_failed",
3371
3796
  phase: "context",
3372
3797
  error: message,
3373
3798
  failure_ref: "final/failure.yaml",
@@ -3377,188 +3802,106 @@ export class Orchestrator {
3377
3802
  runId,
3378
3803
  taskId,
3379
3804
  mode: "plan",
3380
- status: "failed",
3805
+ lifecycle: "failed",
3806
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
3381
3807
  winner: null,
3382
3808
  runDir: paths.root,
3383
3809
  summary: `context failed: ${message}`,
3384
3810
  candidates: [],
3385
3811
  };
3386
3812
  }
3813
+ // Council strategy (INV-031): N members draft in parallel, the primary
3814
+ // merges them into ONE plan + one question set. It owns roHome disposal.
3815
+ if (input.council) {
3816
+ return runCouncilPlan(this.planRunDeps(), {
3817
+ input,
3818
+ contract,
3819
+ taskId,
3820
+ runId,
3821
+ store,
3822
+ paths,
3823
+ log,
3824
+ ledger,
3825
+ adapters,
3826
+ roHome,
3827
+ contextSection,
3828
+ laneRun,
3829
+ });
3830
+ }
3387
3831
  const plans = [];
3832
+ let fallbackFrom = null;
3388
3833
  const planAttempts = [];
3389
3834
  const attemptTelemetries = [];
3390
3835
  try {
3391
3836
  for (const [idx, routed] of adapters.entries()) {
3392
3837
  if (input.signal?.aborted)
3393
3838
  break;
3394
- const adapter = routed.adapter;
3395
3839
  const attemptId = `p${String(idx + 1).padStart(2, "0")}`;
3396
- const lease = ledger.reserve({
3840
+ const outcome = await this.runPlannerAttempt({
3841
+ input,
3842
+ contract,
3397
3843
  taskId,
3844
+ runId,
3845
+ log,
3846
+ store,
3847
+ paths,
3848
+ ledger,
3849
+ routed,
3398
3850
  attemptId,
3851
+ laneRun,
3852
+ fallbackHome: roHome.env,
3853
+ promptBody: this.planPrompt(input.prompt) + contextSection,
3399
3854
  intent: "plan",
3400
- harnessId: adapter.id,
3401
- cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
3402
3855
  });
3403
- if (!lease.granted) {
3404
- log.emit("budget.lease.created", {
3405
- granted: false,
3406
- reason: lease.reason,
3407
- attempt_id: attemptId,
3408
- harness_id: adapter.id,
3409
- });
3856
+ if (outcome.budgetDenied)
3410
3857
  break;
3411
- }
3412
- const knobs = this.routeSpecKnobs(routed, contract, undefined, input.effort);
3413
- const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
3414
- const spec = HarnessRunSpec.parse({
3415
- session_id: newId("ses"),
3416
- intent: "plan",
3417
- prompt: this.planPrompt(input.prompt) + contextSection + relayPriorPlansSection(plans),
3418
- cwd: this.execRootOf(input),
3419
- access: "readonly",
3420
- // Planners must SEE any image/file the user attached (e.g. "plan a fix for
3421
- // what's in this screenshot"), not just agent/race runs.
3422
- attachments: input.attachments ?? [],
3423
- ...this.sessionSpecFields(input, adapter.id, log),
3424
- ...this.harnessSpecKnobs(contract, knobs, "plan"),
3425
- env_inheritance: envInheritance(this.config(input.repoRoot)),
3426
- env: roHome.env,
3427
- });
3428
- const plannerAbort = new AbortController();
3429
- spec.extra["abortSignal"] = input.signal
3430
- ? AbortSignal.any([input.signal, plannerAbort.signal])
3431
- : plannerAbort.signal;
3432
- const planInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
3433
- if (planInteraction)
3434
- spec.extra["interactionChannel"] = planInteraction;
3435
- const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
3436
- const answer = new AnswerAssembly();
3437
- const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
3438
- knobs.webPolicy === "cached" ||
3439
- knobs.webPolicy === "live", effectiveWeb, [],
3440
- // Requested-model capture: a plan lane silently downgraded to another
3441
- // model surfaces the mismatch in its route receipt, just like agent.
3442
- knobs.model);
3443
- const onAbort = () => {
3444
- void adapter.cancel?.(spec.session_id)?.catch(() => { });
3445
- };
3446
- if (input.signal) {
3447
- if (input.signal.aborted)
3448
- onAbort();
3449
- else
3450
- input.signal.addEventListener("abort", onAbort, { once: true });
3451
- }
3452
- let cost = 0;
3453
- let costEstimated = false;
3454
- let harnessError = null;
3455
- const budgetSignalState = { quotaPressureDisclosed: false };
3456
- try {
3457
- log.emit("harness.started", {
3458
- harness_id: adapter.id,
3459
- attempt_id: attemptId,
3460
- external_context_policy: knobs.webPolicy,
3461
- ...(knobs.ignored.length > 0 ? { ignored_settings: knobs.ignored } : {}),
3858
+ if (outcome.telemetry)
3859
+ attemptTelemetries.push({
3860
+ attemptId,
3861
+ harnessId: outcome.harnessId,
3862
+ telemetry: outcome.telemetry,
3462
3863
  });
3463
- if (!input.signal?.aborted) {
3464
- const watchedPlan = withInactivityWatchdog(adapter.run(spec), {
3465
- timeoutMs: harnessInactivityTimeoutMs(this.config(input.repoRoot)),
3466
- onTimeout: () => {
3467
- plannerAbort.abort();
3468
- void adapter.cancel?.(spec.session_id)?.catch(() => { });
3469
- },
3470
- isSuspended: () => (planInteraction?.pendingCount?.() ?? 0) > 0,
3864
+ planAttempts.push({
3865
+ attemptId,
3866
+ harnessId: outcome.harnessId,
3867
+ status: outcome.status,
3868
+ error: outcome.error,
3869
+ });
3870
+ if (outcome.status !== "success") {
3871
+ const next = adapters[idx + 1];
3872
+ if (next && !input.signal?.aborted) {
3873
+ fallbackFrom = outcome.harnessId;
3874
+ log.emit("route.fallback.started", {
3875
+ from_harness: outcome.harnessId,
3876
+ to_harness: next.adapter.id,
3877
+ attempt_id: attemptId,
3878
+ reason: "planner_failed",
3471
3879
  });
3472
- for await (const ev of watchedPlan) {
3473
- if (input.signal?.aborted)
3474
- break;
3475
- const safeEv = redactHarnessEvent(ev);
3476
- safeInvoke(input.onHarnessEvent, safeEv);
3477
- // NOT observed for resume: a read-only planner is not a chat turn,
3478
- // and attaching its session id would poison thread continuity (and
3479
- // race parallel planner/reviewer sessions), regardless of whether
3480
- // the vendor stored that session in the scoped or native store.
3481
- observeAuthSwitch(log, adapter.id, attemptId, safeEv);
3482
- log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
3483
- appendLine(attemptEventsPath, JSON.stringify(safeEv));
3484
- observeAttemptTelemetry(telemetry, safeEv);
3485
- if (safeEv.plan_progress) {
3486
- log.emit("plan.progress", {
3487
- attempt_id: attemptId,
3488
- harness_id: adapter.id,
3489
- items: safeEv.plan_progress.items,
3490
- });
3491
- }
3492
- // read-only routes burn quota too (the orchestrate PLANNER is
3493
- // the loudest) — same single owner as the agent loop.
3494
- observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
3495
- this.deps.quotaEventSink?.(adapter.id, safeEv);
3496
- if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
3497
- cost += safeEv.usage.cost_usd;
3498
- if (safeEv.usage.estimated)
3499
- costEstimated = true;
3500
- log.emit("budget.observation", {
3501
- harness_id: adapter.id,
3502
- attempt_id: attemptId,
3503
- kind: "spend",
3504
- usd: safeEv.usage.cost_usd,
3505
- estimated: safeEv.usage.estimated === true,
3506
- });
3507
- }
3508
- // A TYPED final message wins verbatim over joined narration.
3509
- answer.observe(safeEv);
3510
- if (safeEv.type === "error")
3511
- harnessError = safeEv.error
3512
- ? redactSecrets(safeEv.error)
3513
- : "harness emitted an error";
3514
- }
3515
3880
  }
3881
+ else if (fallbackFrom || next === undefined) {
3882
+ log.emit("route.fallback.exhausted", {
3883
+ harness_id: outcome.harnessId,
3884
+ attempt_id: attemptId,
3885
+ reason: "planner_failed",
3886
+ });
3887
+ }
3888
+ continue;
3516
3889
  }
3517
- catch (err) {
3518
- harnessError = safeErrorMessage(err);
3519
- }
3520
- finally {
3521
- input.signal?.removeEventListener("abort", onAbort);
3522
- ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode));
3523
- }
3524
- attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
3525
- const unrecovered = unrecoveredToolErrors(telemetry);
3526
- const webBlocked = webUnsatisfied(telemetry);
3527
- if (!harnessError && webBlocked) {
3528
- harnessError = `web evidence unsatisfied: ${telemetry.web.errorSummary ?? (telemetry.web.attempted ? "web tool failed without verified recovery" : "web evidence required but never attempted")}`;
3529
- }
3530
- if (!harnessError && unrecovered.length > 0) {
3531
- const first = unrecovered[0];
3532
- harnessError = `${first.tool} failed without recovery: ${first.summary}`;
3533
- }
3534
- if (harnessError) {
3535
- // One failed planner does not abort a multi-harness plan; the run fails
3536
- // only when EVERY planner fails (parity with explore).
3537
- log.emit("harness.completed", {
3538
- harness_id: adapter.id,
3890
+ const text = outcome.text ?? "(no output)";
3891
+ plans.push({ id: outcome.harnessId, text });
3892
+ store.writeText(join(paths.root, "plans", `${outcome.harnessId}.md`), redactSecrets(text) + "\n");
3893
+ // Solo planning (D31): the FIRST successful planner is the plan; later
3894
+ // pool members are a sequential fallback chain (ask parity), not
3895
+ // parallel co-authors. Council re-enables the multi-draft round.
3896
+ if (fallbackFrom) {
3897
+ log.emit("route.fallback.completed", {
3898
+ harness_id: outcome.harnessId,
3539
3899
  attempt_id: attemptId,
3540
- status: webBlocked ? "blocked" : "failed",
3541
- error: harnessError,
3542
- ...telemetrySummary(telemetry),
3900
+ status: "success",
3901
+ reason: "planner_failed",
3543
3902
  });
3544
- planAttempts.push({
3545
- attemptId,
3546
- harnessId: adapter.id,
3547
- status: webBlocked ? "blocked" : "failed",
3548
- error: harnessError,
3549
- });
3550
- continue;
3551
3903
  }
3552
- const text = answer.text() || "(no output)";
3553
- log.emit("harness.completed", {
3554
- harness_id: adapter.id,
3555
- attempt_id: attemptId,
3556
- status: "success",
3557
- ...telemetrySummary(telemetry),
3558
- });
3559
- planAttempts.push({ attemptId, harnessId: adapter.id, status: "success", error: null });
3560
- plans.push({ id: adapter.id, text });
3561
- store.writeText(join(paths.root, "plans", `${adapter.id}.md`), redactSecrets(text) + "\n");
3904
+ break;
3562
3905
  }
3563
3906
  }
3564
3907
  finally {
@@ -3573,116 +3916,18 @@ export class Orchestrator {
3573
3916
  })), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend(), input.signal, store);
3574
3917
  }
3575
3918
  if (plans.length === 0) {
3576
- const blocked = planAttempts.some((p) => p.status === "blocked");
3577
- const message = planAttempts
3578
- .map((p) => `${p.attemptId}/${p.harnessId}: ${p.error ?? "failed"}`)
3579
- .join("\n") || "all planners failed";
3580
- this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null);
3581
- store.writeText(join(paths.contextDir, "context_error.md"), `# Harness Error\n\n${message}\n`);
3582
- writeFailure(store, paths, {
3583
- phase: "harness",
3584
- category: blocked ? "policy" : "harness_error",
3585
- safeMessage: message,
3586
- eventRefs: planAttempts.map((p) => `attempts/${p.attemptId}/events.jsonl`),
3587
- runDir: paths.root,
3588
- nextActions: ["Open diagnostics", "Check harness authentication", "Retry after setup"],
3589
- });
3590
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Status: ${blocked ? "blocked" : "failed"}\n\n${message}\n`);
3591
- log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
3592
- if (blocked)
3593
- log.emit("run.blocked", {
3594
- status: "blocked",
3595
- phase: "harness",
3596
- error: message,
3597
- failure_ref: "final/failure.yaml",
3598
- });
3599
- else
3600
- log.emit("run.failed", {
3601
- status: "failed",
3602
- phase: "harness",
3603
- error: message,
3604
- failure_ref: "final/failure.yaml",
3605
- });
3606
- return {
3607
- spendUsd: ledger.spend(),
3608
- runId,
3609
- taskId,
3610
- mode: "plan",
3611
- status: blocked ? "blocked" : "failed",
3612
- winner: null,
3613
- runDir: paths.root,
3614
- summary: message,
3615
- candidates: planAttempts.map((p) => ({
3616
- attemptId: p.attemptId,
3617
- harnessId: p.harnessId,
3618
- status: p.status,
3619
- })),
3620
- };
3621
- }
3622
- let ambiguities = [];
3623
- let reviewFindings = [];
3624
- if (reviewers.length > 0 && plans.length > 0) {
3625
- const reviewDir = join(paths.root, "review-evidence");
3626
- const planEvidence = plans.map((p) => `## Plan from ${p.id}\n${p.text}`).join("\n\n");
3627
- const planReviewDiff = "(plan review — no code diff)\n";
3628
- writeEvidencePacket(reviewDir, {
3629
- userIntent: redactSecrets(input.prompt),
3630
- planAccepted: planEvidence,
3631
- diff: planReviewDiff,
3632
- tests: renderTestsEvidence(contract),
3633
- });
3634
- // Reserve BEFORE spending: a hard budget tier must stop the paid plan
3635
- // review from starting, not account for it after the fact.
3636
- const lease = ledger.reserve({
3919
+ return writePlanHarnessFailure(this.planRunDeps(), {
3920
+ input,
3921
+ contract,
3637
3922
  taskId,
3638
- attemptId: "plan-review",
3639
- intent: "review",
3640
- harnessId: "review-panel",
3641
- cost: attemptCostEvidence("review-panel", "plan-review"),
3642
- });
3643
- if (lease.granted) {
3644
- const res = await this.reviewScoped({
3645
- candidateLabel: "Plan",
3646
- diff: planReviewDiff,
3647
- evidenceDir: reviewDir,
3648
- artifactsDir: join(paths.reviewsDir, "plan-reviewers"),
3649
- cwd: this.execRootOf(input),
3650
- reviewers,
3651
- envInheritance: envInheritance(this.config(input.repoRoot)),
3652
- signal: input.signal,
3653
- onReviewerEvent: (event) => log.emit(event.type, { ...event }),
3654
- });
3655
- reviewFindings = await revalidateFindings(res.findings, {
3656
- candidateRoot: this.execRootOf(input),
3657
- evidenceDir: reviewDir,
3658
- });
3659
- ambiguities = reviewFindings.filter((f) => f.category === "spec_gap" || f.severity === "NEEDS_HUMAN");
3660
- store.writeYaml(join(paths.reviewsDir, "plan-review.yaml"), {
3661
- findings: reviewFindings,
3662
- route_proofs: res.routeProofs,
3663
- reviewer_requests: res.reviewerRequests,
3664
- });
3665
- ledger.settle(lease.lease?.lease_id ?? "", usageCostSettlement(res.reviewSpendUsd, res.reviewSpendEstimated, "review-usage", [
3666
- "attempt:plan-review",
3667
- "review:panel",
3668
- ]));
3669
- if ((res.reviewSpendUsd ?? 0) > 0) {
3670
- log.emit("budget.observation", {
3671
- harness_id: "review-panel",
3672
- kind: "spend",
3673
- usd: res.reviewSpendUsd,
3674
- estimated: res.reviewSpendEstimated,
3675
- });
3676
- }
3677
- }
3678
- else {
3679
- log.emit("budget.lease.created", {
3680
- granted: false,
3681
- reason: lease.reason,
3682
- attempt_id: "plan-review",
3683
- harness_id: "review-panel",
3684
- });
3685
- }
3923
+ runId,
3924
+ store,
3925
+ paths,
3926
+ log,
3927
+ ledger,
3928
+ planAttempts,
3929
+ attemptTelemetries,
3930
+ }, "all planners failed");
3686
3931
  }
3687
3932
  if (input.signal?.aborted) {
3688
3933
  return cancelledResult(log, runId, taskId, "plan", paths.root, planAttempts.map((p) => ({
@@ -3691,132 +3936,69 @@ export class Orchestrator {
3691
3936
  status: p.status,
3692
3937
  })), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend(), input.signal, store);
3693
3938
  }
3694
- const failedPlanners = planAttempts.filter((p) => p.status !== "success");
3695
- // ALL review findings are shown (severity-marked), so a BLOCK like "the
3696
- // requested feature is not delivered" is visible on the plan itself — not
3697
- // silently filtered down to spec_gap/NEEDS_HUMAN the way v0.9 hid it.
3698
- const blockingFindings = reviewFindings.filter((f) => isBlocking(f));
3699
- const sevMark = {
3700
- BLOCK: "🔴 BLOCK",
3701
- FIX_FIRST: "🟠 FIX_FIRST",
3702
- NEEDS_HUMAN: "🟠 NEEDS_HUMAN",
3703
- };
3704
- const planDoc = [
3705
- `# Plan`,
3706
- "",
3707
- `## Goal`,
3708
- redactSecrets(input.prompt),
3709
- "",
3710
- `## Plan${plans.length > 1 ? "s" : ""} (${plans.length}/${planAttempts.length} planner${planAttempts.length === 1 ? "" : "s"})`,
3711
- ...plans.map((p) => `\n### Plan — ${p.id}\n${redactSecrets(p.text)}`),
3712
- ...(reviewFindings.length > 0
3713
- ? [
3714
- "",
3715
- "## Review findings",
3716
- ...reviewFindings.map((f) => `- ${sevMark[f.severity] ?? f.severity}: ${redactSecrets(f.claim)}`),
3717
- ]
3718
- : []),
3719
- ...(ambiguities.length > 0
3720
- ? ["", "## Open questions", ...ambiguities.map((a) => `- ${redactSecrets(a.claim)}`)]
3721
- : []),
3722
- ...(failedPlanners.length > 0
3723
- ? [
3724
- "",
3725
- "## Planner omissions",
3726
- ...failedPlanners.map((p) => `- ${p.attemptId} / ${p.harnessId} ${p.status}: ${p.error}`),
3727
- ]
3728
- : []),
3729
- "",
3730
- ].join("\n");
3731
- store.writeText(join(paths.finalDir, "plan.md"), planDoc + "\n");
3732
- // A plan is a delivered work product (a report), even with risks — parity
3733
- // with the other read-only modes (removes the "only successful mode with no
3734
- // work_product" anomaly). result_kind=plan tells surfaces NO files changed.
3735
- store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
3736
- id: newId("wp"),
3737
- kind: "report",
3738
- source_task_id: taskId,
3739
- producer_attempt_id: planAttempts.find((p) => p.status === "success")?.attemptId ?? null,
3740
- meta: {
3741
- mode: "plan",
3742
- result_kind: "plan",
3743
- planners: plans.length,
3744
- diffstat: { files: 0, additions: 0, deletions: 0 },
3745
- blockers: blockingFindings.length,
3746
- adopted: null,
3747
- },
3939
+ return finalizePlanRun(this.planRunDeps(), {
3940
+ input,
3941
+ contract,
3942
+ taskId,
3943
+ runId,
3944
+ store,
3945
+ paths,
3946
+ log,
3947
+ ledger,
3948
+ plans,
3949
+ planAttempts,
3950
+ attemptTelemetries,
3951
+ council: null,
3748
3952
  });
3749
- // Canonical summary artifact (parity with every other mode's final/ layout).
3750
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Status: success (plan only — no files changed)\n- Planners: ${plans.length}/${planAttempts.length} succeeded\n- Plan: final/plan.md\n- Review blockers: ${blockingFindings.length}\n- Open questions: ${ambiguities.length}\n${failedPlanners.length > 0 ? `- Omissions: ${failedPlanners.map((p) => `${p.harnessId} ${p.status}`).join(", ")}\n` : ""}`);
3751
- this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, planAttempts.find((p) => p.status === "success")?.attemptId ?? null);
3752
- log.emit("output.ready", { kind: "plan", path: "final/plan.md" });
3753
- log.emit("run.completed", { status: "success" });
3953
+ }
3954
+ /** Bind the few orchestrator methods planRun.ts needs (the rest of its
3955
+ * collaborators are module-level imports). Kept as a factory so each call
3956
+ * gets correctly-bound `this` without leaking the whole orchestrator. */
3957
+ planRunDeps() {
3754
3958
  return {
3755
- spendUsd: ledger.spend(),
3756
- runId,
3757
- taskId,
3758
- mode: "plan",
3759
- status: "success",
3760
- winner: null,
3761
- runDir: paths.root,
3762
- summary: `Plan from ${plans.length} planner(s); ${blockingFindings.length} blocker(s), ${ambiguities.length} open question(s).`,
3763
- candidates: planAttempts.map((p) => ({
3764
- attemptId: p.attemptId,
3765
- harnessId: p.harnessId,
3766
- status: p.status,
3767
- })),
3959
+ runPlannerAttempt: (a) => this.runPlannerAttempt(a),
3960
+ writeRunTelemetry: (store, paths, contract, runId, taskId, mode, attempts, finalAttemptId) => this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, attempts, finalAttemptId),
3961
+ execRootOf: (input) => this.execRootOf(input),
3962
+ planPrompt: (goal) => this.planPrompt(goal),
3768
3963
  };
3769
3964
  }
3770
3965
  /** ask: one selected harness answers read-only questions; no patch/apply controls. */
3771
3966
  async runAsk(input, announce) {
3772
3967
  return this.runReadOnlyReport(input, {
3773
3968
  mode: "ask",
3774
- swarm: false,
3969
+ deepScan: false,
3775
3970
  intent: "explain",
3776
3971
  title: "Answer",
3777
3972
  artifactName: "answer.md",
3778
3973
  defaultPrompt: "Answer the user's question.",
3779
3974
  }, announce);
3780
3975
  }
3781
- /** audit --swarm: bounded read-only research swarm (the old `explore` mode). */
3782
- async runExplore(input, announce) {
3783
- return this.runReadOnlyReport(input, {
3784
- mode: "audit",
3785
- swarm: true,
3786
- intent: "audit",
3787
- title: "Explore synthesis",
3788
- artifactName: "explore.md",
3789
- defaultPrompt: "Explore this repository and synthesize evidence-cited findings, omissions, and follow-up questions.",
3790
- }, announce);
3791
- }
3792
- /** audit: single read-only audit/map report. */
3793
- async runAudit(input, announce) {
3976
+ /** ask --deep-scan: bounded multi-scout research sweep with synthesis
3977
+ * (the old `audit --swarm` / `explore`). */
3978
+ async runDeepScan(input, announce) {
3794
3979
  return this.runReadOnlyReport(input, {
3795
- mode: "audit",
3796
- swarm: false,
3980
+ mode: "ask",
3981
+ deepScan: true,
3797
3982
  intent: "audit",
3798
- title: "Audit report",
3983
+ title: "Deep scan synthesis",
3799
3984
  artifactName: "report.md",
3800
- defaultPrompt: "audit this repository",
3985
+ defaultPrompt: "Explore this repository and synthesize evidence-cited findings, omissions, and follow-up questions.",
3801
3986
  }, announce);
3802
3987
  }
3803
3988
  resolvePaidBudget(inputBudget, cfg) {
3804
3989
  return inputBudget ?? this.deps.paidBudget ?? cfg.global.budget.paid_budget_per_run;
3805
3990
  }
3806
- rootLedger(input, contract, log) {
3807
- // A passed-in ledger (orchestrate sub-runs) keeps its OWNER's cash
3808
- // disclosure the parent run owns the budget, so its event log gets the
3809
- // budget.cash events. A fresh root ledger discloses into THIS run's log:
3810
- // the ledger is the one owner of the cash fact (subscription-entitled
3811
- // work settles to 0 there), and the UI renders `budget.cash` verbatim
3812
- // never inferring money from route labels (W4.3 sol #15).
3813
- const ledger = input.budgetLedger ??
3814
- new BudgetLedger(contract.budget.paid_budget, undefined, {
3815
- onCashSettled: (cashSpendUsd, valuationUsd) => log.emit("budget.cash", {
3816
- cash_spend_usd: cashSpendUsd,
3817
- valuation_usd: valuationUsd,
3818
- }),
3819
- });
3991
+ rootLedger(_input, contract, log) {
3992
+ // The root ledger discloses into THIS run's log: the ledger is the one
3993
+ // owner of the cash fact (subscription-entitled work settles to 0 there),
3994
+ // and the UI renders `budget.cash` verbatim never inferring money from
3995
+ // route labels (W4.3 sol #15).
3996
+ const ledger = new BudgetLedger(contract.budget.paid_budget, undefined, {
3997
+ onCashSettled: (cashSpendUsd, valuationUsd) => log.emit("budget.cash", {
3998
+ cash_spend_usd: cashSpendUsd,
3999
+ valuation_usd: valuationUsd,
4000
+ }),
4001
+ });
3820
4002
  for (const snapshot of this.deps.quotaSnapshots?.() ?? []) {
3821
4003
  ledger.observeQuotaSnapshot(snapshot);
3822
4004
  }
@@ -3835,55 +4017,12 @@ export class Orchestrator {
3835
4017
  ? "metered"
3836
4018
  : "unknown";
3837
4019
  }
3838
- async runOrchestrate(input, announce) {
3839
- // "Doctor-verified" must mean status ok — degraded key-present routes are
3840
- // excluded from the pool the planner plans over (readiness honesty).
3841
- const pool = await this.gateway.doctorOkReal({ cwd: input.repoRoot }, "orchestrate");
3842
- const crossFamily = pool.length >= 2;
3843
- const goal = input.prompt || "Plan the next move for this repository.";
3844
- // The typed orchestration contract is a REAL persisted artifact (producer
3845
- // here, consumers: the planner prompt below + the plan validator).
3846
- // Autonomy is producer-supplied (control-api/CLI -> daemon -> RunInput);
3847
- // the executor below is its consumer. Default `suggest` (plan-only) preserves
3848
- // the read-only contract when no autonomy is requested.
3849
- const autonomy = input.autonomy ?? "suggest";
3850
- const paidBudget = this.resolvePaidBudget(input.paidBudget, this.config(input.repoRoot));
3851
- const orchestrateContract = OrchestrateContractSchema.parse({
3852
- thread_id: input.threadId ?? newId("th"),
3853
- goal,
3854
- budget: { paid_budget: paidBudget, max_tool_calls: input.maxToolCalls ?? null },
3855
- autonomy,
3856
- });
3857
- const plannerPrompt = buildOrchestratePlannerPrompt(goal, pool, crossFamily, orchestrateContract);
3858
- return this.runReadOnlyReport(
3859
- // The executed pool is pinned to the PLANNED pool (no double doctor
3860
- // resolution drift between the prompt's claims and the actual route).
3861
- // The planner must NOT resume or overwrite the thread's conversational
3862
- // session — it speaks its own tool-belt framing, not the user's chat.
3863
- {
3864
- ...input,
3865
- resumeSessions: undefined,
3866
- onSessionObserved: undefined,
3867
- harnesses: input.harnesses ?? (pool.length > 0 ? pool : undefined),
3868
- prompt: plannerPrompt,
3869
- }, {
3870
- mode: "orchestrate",
3871
- swarm: false,
3872
- intent: "orchestrate",
3873
- title: "Orchestration plan",
3874
- artifactName: "orchestration.md",
3875
- defaultPrompt: plannerPrompt,
3876
- contractIntent: goal,
3877
- orchestrateContract,
3878
- }, announce);
3879
- }
3880
4020
  async runReadOnlyReport(input, opts, announce) {
3881
4021
  const taskId = input.taskId ?? newId("task");
3882
4022
  const runId = input.runId ?? newId("run");
3883
4023
  const prompt = input.prompt || opts.defaultPrompt;
3884
4024
  // Contract validation BEFORE the run is announced (see runRace). The
3885
- // recorded user intent is the CALLER's goal, not a synthesized wrapper
3886
- // prompt (orchestrate wraps the goal in a planner prompt).
4025
+ // recorded user intent is the CALLER's goal.
3887
4026
  const contract = this.buildContract({ ...input, prompt: opts.contractIntent ?? prompt }, taskId, opts.mode);
3888
4027
  const store = this.artifactStore(input);
3889
4028
  const paths = store.createRun(runId);
@@ -3903,61 +4042,32 @@ export class Orchestrator {
3903
4042
  });
3904
4043
  store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
3905
4044
  log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
3906
- if (opts.orchestrateContract) {
3907
- store.writeYaml(join(paths.contextDir, "orchestrate_contract.yaml"), opts.orchestrateContract);
3908
- }
3909
- // Lazy ContextPack: explore/audit attach the compact scope atlas; ask stays bare.
3910
- let contextSection = "";
3911
- if (opts.mode !== "ask") {
3912
- try {
3913
- contextSection = await this.lazyContextSection(input, contract, store, paths, log);
3914
- }
3915
- catch (err) {
3916
- const message = safeErrorMessage(err);
3917
- store.writeText(join(paths.contextDir, "context_error.md"), `# Context Error\n\n${message}\n`);
3918
- writeFailure(store, paths, {
3919
- phase: "context",
3920
- category: "project",
3921
- safeMessage: message,
3922
- runDir: paths.root,
3923
- });
3924
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Status: failed\n- Phase: context\n\n${message}\n`);
3925
- log.emit("output.ready", {
3926
- kind: "summary",
3927
- path: "final/summary.md",
3928
- state: "diagnostic",
3929
- });
3930
- log.emit("run.failed", {
3931
- status: "failed",
3932
- phase: "context",
3933
- error: message,
3934
- failure_ref: "final/failure.yaml",
3935
- });
3936
- return {
3937
- runId,
3938
- taskId,
3939
- mode: opts.mode,
3940
- status: "failed",
3941
- winner: null,
3942
- runDir: paths.root,
3943
- summary: `context failed: ${message}`,
3944
- candidates: [],
3945
- };
3946
- }
3947
- }
4045
+ // The ask/deep-scan report stays bare (its scouts read the tree themselves);
4046
+ // no lazy ContextPack section is attached here.
4047
+ const contextSection = "";
3948
4048
  const externalContextPolicy = contract.external_context.policy;
3949
- const width = opts.swarm
4049
+ const width = opts.deepScan
3950
4050
  ? Math.min(Math.max(input.n ?? 4, 1), 8)
3951
4051
  : externalContextPolicy === "off"
3952
4052
  ? 1
3953
4053
  : Math.min(Math.max(input.n ?? 2, 1), 3);
3954
4054
  // W3.3: ONE resolved read-only context — the routing point-probe and every
3955
4055
  // read-only attempt spawn consume the SAME scoped env (see routeContext.ts).
4056
+ // The point-probe home is a disposable throwaway even for a thread lane
4057
+ // turn: readiness auth truth is home-INDEPENDENT (credentials come from the
4058
+ // profile/keychain/default store, never the scoped home), so the probe and
4059
+ // the run share the same auth source; only the ACTUAL spawn swaps in the
4060
+ // durable per-lane home below so the recorded native session survives.
3956
4061
  const roHome = resolveReadOnlyRouteContext(this.execRootOf(input));
4062
+ // A thread ASK turn is a chat turn: its native session is recorded per lane
4063
+ // and the next lane turn resumes it (INV-034). Deep-scan (multi-scout
4064
+ // research) and orchestrate (tool-belt planner, not the user's chat) are
4065
+ // NOT lane chat turns — they keep the disposable home and record nothing.
4066
+ const laneRun = Boolean(input.threadId) && opts.mode === "ask" && !opts.deepScan;
3957
4067
  let adapters;
3958
4068
  try {
3959
- adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, roHome);
3960
- if (!opts.swarm) {
4069
+ adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, log, roHome);
4070
+ if (!opts.deepScan) {
3961
4071
  const seen = new Set();
3962
4072
  adapters = adapters.filter((routed) => {
3963
4073
  if (seen.has(routed.adapter.id))
@@ -3977,10 +4087,12 @@ export class Orchestrator {
3977
4087
  safeMessage: message,
3978
4088
  runDir: paths.root,
3979
4089
  });
3980
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Status: failed\n- Phase: routing\n\n${message}\n`);
4090
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Lifecycle: failed\n- Phase: routing\n\n${message}\n`);
3981
4091
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
3982
4092
  log.emit("run.failed", {
3983
- status: "failed",
4093
+ lifecycle: "failed",
4094
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
4095
+ reason: "harness_failed",
3984
4096
  phase: "routing",
3985
4097
  error: message,
3986
4098
  failure_ref: "final/failure.yaml",
@@ -3989,7 +4101,8 @@ export class Orchestrator {
3989
4101
  runId,
3990
4102
  taskId,
3991
4103
  mode: opts.mode,
3992
- status: "failed",
4104
+ lifecycle: "failed",
4105
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
3993
4106
  winner: null,
3994
4107
  runDir: paths.root,
3995
4108
  summary: message,
@@ -4030,17 +4143,26 @@ export class Orchestrator {
4030
4143
  }
4031
4144
  const knobs = this.routeSpecKnobs(routed, contract, modelOverride, input.effort);
4032
4145
  const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
4033
- const explorerPrompt = (opts.swarm
4146
+ const explorerPrompt = (opts.deepScan
4034
4147
  ? `${prompt}\n\nExplorer ${idx + 1}/${adapters.length}: focus on a distinct slice. Emit evidence-cited findings, explicit unknowns/omissions, and follow-up questions. Do not edit files.`
4035
4148
  : prompt) + contextSection;
4036
4149
  const sessionFields = this.sessionSpecFields(input, adapter.id, log);
4037
4150
  const grantResume = sessionFields.resume_session_id !== null && !resumeGranted.has(adapter.id);
4038
4151
  if (grantResume)
4039
4152
  resumeGranted.add(adapter.id);
4153
+ // Continuity (INV-137): a thread ASK turn is a chat turn — hydrate a lane
4154
+ // switch/gap with a packet and disclose it. Gated on laneRun (deep-scan
4155
+ // scouts are excluded from laneRun); native resume is available only when
4156
+ // this slot was granted the lane's recorded session.
4157
+ const laneContinuity = laneRun
4158
+ ? await this.resolveContinuity(input, adapter.id, sessionFields.credential_profile?.profile_id ?? input.credentialProfileId ?? null, grantResume, store, paths, this.execRootOf(input), log)
4159
+ : null;
4040
4160
  let spec = HarnessRunSpec.parse({
4041
4161
  session_id: newId("ses"),
4042
4162
  intent: opts.intent,
4043
- prompt: explorerPrompt,
4163
+ prompt: laneContinuity?.pointerLine
4164
+ ? `${explorerPrompt}\n\n${laneContinuity.pointerLine}`
4165
+ : explorerPrompt,
4044
4166
  cwd: this.execRootOf(input),
4045
4167
  access: "readonly",
4046
4168
  // ASK/EXPLORE/AUDIT read-only runs must forward the user's attachments —
@@ -4052,19 +4174,10 @@ export class Orchestrator {
4052
4174
  resume_session_id: grantResume ? sessionFields.resume_session_id : null,
4053
4175
  ...this.harnessSpecKnobs(contract, knobs, opts.intent),
4054
4176
  env_inheritance: envInheritance(this.config(input.repoRoot)),
4055
- env: roHome.env,
4056
- // Structured output: the orchestrate PLANNER's deliverable IS the
4057
- // typed plan constrain schema-capable routes to it. Capability-gated:
4058
- // routes without json_schema_output keep fenced-JSON parsing. ALSO
4059
- // gated off when this spec will ride the INTERACTIVE stream-json
4060
- // transport (an interaction channel will be offered): --json-schema x
4061
- // interactive is an unverified vendor combination — fenced parsing
4062
- // carries those runs until it is live-verified.
4063
- ...(opts.intent === "orchestrate" &&
4064
- routed.supportsJsonSchemaOutput &&
4065
- !(Boolean(input.onInteraction) && routed.supportsInteractive)
4066
- ? { output_schema: orchestratePlanJsonSchema() }
4067
- : {}),
4177
+ // A thread lane turn spawns in its DURABLE per-lane home so the native
4178
+ // session it records is reachable for resume next turn; everything else
4179
+ // uses the disposable route-context home.
4180
+ env: (laneRun ? this.laneHomeEnvFor(input, adapter.id) : null) ?? roHome.env,
4068
4181
  });
4069
4182
  const reportAbort = new AbortController();
4070
4183
  spec.extra["abortSignal"] = input.signal
@@ -4131,9 +4244,14 @@ export class Orchestrator {
4131
4244
  break;
4132
4245
  const safeEv = redactHarnessEvent(ev);
4133
4246
  safeInvoke(input.onHarnessEvent, safeEv);
4134
- // NOT observed for resume: this read-only attempt is not a chat
4135
- // turn. Recording its id would poison thread continuity and can
4136
- // race parallel read-only sessions, regardless of storage route.
4247
+ // A thread ASK turn IS a chat turn now (INV-034): its native
4248
+ // session lives in the DURABLE per-lane home, so record it for the
4249
+ // next lane turn's resume. The read-only fallback chain is
4250
+ // sequential (never the parallel deep-scan swarm, which is
4251
+ // excluded from `laneRun`), so recordSession's upsert keeps the
4252
+ // latest lane session without a race.
4253
+ if (laneRun)
4254
+ observeNativeSessionEvent(input, adapter.id, safeEv);
4137
4255
  observeAuthSwitch(log, adapter.id, attemptId, safeEv);
4138
4256
  log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
4139
4257
  appendLine(attemptEventsPath, JSON.stringify(safeEv));
@@ -4226,7 +4344,7 @@ export class Orchestrator {
4226
4344
  }
4227
4345
  finally {
4228
4346
  input.signal?.removeEventListener("abort", onAbort);
4229
- ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode));
4347
+ ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode, telemetry.usageCost));
4230
4348
  }
4231
4349
  if (harnessError && telemetry.transientFailures.length > 0) {
4232
4350
  log.emit("route.transient.exhausted", {
@@ -4269,7 +4387,7 @@ export class Orchestrator {
4269
4387
  error: harnessError,
4270
4388
  telemetry,
4271
4389
  });
4272
- if (opts.swarm) {
4390
+ if (opts.deepScan) {
4273
4391
  store.writeText(join(paths.findingsDir, `${attemptId}-error.md`), `# Explorer ${attemptId} failed\n\n${harnessError}\n`);
4274
4392
  }
4275
4393
  return;
@@ -4288,7 +4406,7 @@ export class Orchestrator {
4288
4406
  error: null,
4289
4407
  telemetry,
4290
4408
  });
4291
- if (opts.swarm) {
4409
+ if (opts.deepScan) {
4292
4410
  const warningNote = toolWarnings(telemetry).length
4293
4411
  ? `\n\n> Tool warnings: ${toolWarnings(telemetry)
4294
4412
  .map((e) => `${e.tool}: ${e.summary}`)
@@ -4298,7 +4416,7 @@ export class Orchestrator {
4298
4416
  }
4299
4417
  };
4300
4418
  try {
4301
- if (opts.swarm) {
4419
+ if (opts.deepScan) {
4302
4420
  // Explorer swarm runs in parallel (bounded), mirroring parallel candidates.
4303
4421
  await runBounded(adapters, Math.min(adapters.length, MAX_PARALLEL_CANDIDATES), runReadonlyAttempt);
4304
4422
  }
@@ -4388,7 +4506,7 @@ export class Orchestrator {
4388
4506
  })), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null), ledger.spend(), input.signal, store);
4389
4507
  }
4390
4508
  const succeededReadonly = attempts.filter((a) => a.status === "success");
4391
- if (!opts.swarm && succeededReadonly.length === 0) {
4509
+ if (!opts.deepScan && succeededReadonly.length === 0) {
4392
4510
  const last = attempts[attempts.length - 1];
4393
4511
  const webBlocked = attempts.some((a) => a.status === "blocked");
4394
4512
  const singleError = last?.error ?? (budgetStopped ? "budget exhausted before any attempt" : "harness failed");
@@ -4422,16 +4540,17 @@ export class Orchestrator {
4422
4540
  runDir: paths.root,
4423
4541
  nextActions: ["Open diagnostics", "Check harness authentication", "Retry after setup"],
4424
4542
  });
4425
- const terminal = webBlocked
4426
- ? "blocked"
4543
+ const terminalFacts = webBlocked
4544
+ ? makeOutcomeFacts("succeeded", { review: "blocked", reason: "review_blocked" })
4427
4545
  : budgetStopped && attempts.length === 0
4428
- ? "exhausted"
4429
- : "failed";
4430
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Harness: ${last?.harnessId ?? "none"}\n- Status: ${terminal}\n\n${singleError}\n`);
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`);
4431
4549
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
4432
- if (terminal === "blocked") {
4550
+ if (terminalFacts.lifecycle === "succeeded") {
4433
4551
  log.emit("run.blocked", {
4434
- status: terminal,
4552
+ lifecycle: terminalFacts.lifecycle,
4553
+ facts: terminalFacts,
4435
4554
  harness_id: last?.harnessId,
4436
4555
  error: singleError,
4437
4556
  failure_ref: "final/failure.yaml",
@@ -4439,7 +4558,9 @@ export class Orchestrator {
4439
4558
  }
4440
4559
  else {
4441
4560
  log.emit("run.failed", {
4442
- status: terminal,
4561
+ lifecycle: terminalFacts.lifecycle,
4562
+ facts: terminalFacts,
4563
+ reason: terminalFacts.reason,
4443
4564
  harness_id: last?.harnessId,
4444
4565
  error: singleError,
4445
4566
  failure_ref: "final/failure.yaml",
@@ -4450,7 +4571,8 @@ export class Orchestrator {
4450
4571
  runId,
4451
4572
  taskId,
4452
4573
  mode: opts.mode,
4453
- status: terminal,
4574
+ lifecycle: terminalFacts.lifecycle,
4575
+ facts: terminalFacts,
4454
4576
  winner: null,
4455
4577
  runDir: paths.root,
4456
4578
  summary: singleError,
@@ -4462,7 +4584,7 @@ export class Orchestrator {
4462
4584
  };
4463
4585
  }
4464
4586
  const succeeded = succeededReadonly;
4465
- if (opts.swarm && succeeded.length === 0) {
4587
+ if (opts.deepScan && succeeded.length === 0) {
4466
4588
  const message = attempts
4467
4589
  .map((a) => `${a.attemptId}/${a.harnessId}: ${a.error ?? "failed"}`)
4468
4590
  .join("\n");
@@ -4481,18 +4603,24 @@ export class Orchestrator {
4481
4603
  "Retry after setup",
4482
4604
  ],
4483
4605
  });
4484
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Status: ${blocked ? "blocked" : "failed"}\n\n${message}\n`);
4606
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Lifecycle: ${blocked ? "succeeded (needs review)" : "failed"}\n\n${message}\n`);
4485
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" });
4486
4611
  if (blocked)
4487
4612
  log.emit("run.blocked", {
4488
- status: "blocked",
4613
+ lifecycle: scanFailFacts.lifecycle,
4614
+ facts: scanFailFacts,
4489
4615
  phase: "harness",
4490
4616
  error: message,
4491
4617
  failure_ref: "final/failure.yaml",
4492
4618
  });
4493
4619
  else
4494
4620
  log.emit("run.failed", {
4495
- status: "failed",
4621
+ lifecycle: scanFailFacts.lifecycle,
4622
+ facts: scanFailFacts,
4623
+ reason: scanFailFacts.reason,
4496
4624
  phase: "harness",
4497
4625
  error: message,
4498
4626
  failure_ref: "final/failure.yaml",
@@ -4502,7 +4630,8 @@ export class Orchestrator {
4502
4630
  runId,
4503
4631
  taskId,
4504
4632
  mode: opts.mode,
4505
- status: blocked ? "blocked" : "failed",
4633
+ lifecycle: scanFailFacts.lifecycle,
4634
+ facts: scanFailFacts,
4506
4635
  winner: null,
4507
4636
  runDir: paths.root,
4508
4637
  summary: message,
@@ -4514,7 +4643,7 @@ export class Orchestrator {
4514
4643
  };
4515
4644
  }
4516
4645
  const unsuccessful = attempts.filter((a) => a.status !== "success");
4517
- const report = opts.swarm
4646
+ const report = opts.deepScan
4518
4647
  ? [
4519
4648
  `Explorers succeeded: ${succeeded.length}/${attempts.length}.`,
4520
4649
  "",
@@ -4552,36 +4681,12 @@ export class Orchestrator {
4552
4681
  answerText: succeeded[0]?.report ?? "",
4553
4682
  });
4554
4683
  }
4555
- // orchestrate: the planner's plan is a TYPED artifact, not just prose. Extract
4556
- // the required fenced JSON block, validate it against the tool belt, and
4557
- // persist final/orchestration.yaml; a missing/invalid block is disclosed in
4558
- // the summary and events (suggest autonomy: the plan is the work product).
4559
- let typedPlanNote = "";
4560
- let orchestratePlan = null;
4561
- if (opts.mode === "orchestrate") {
4562
- const extracted = extractOrchestratePlan(report);
4563
- if (extracted.plan) {
4564
- orchestratePlan = extracted.plan;
4565
- store.writeYaml(join(paths.finalDir, "orchestration.yaml"), extracted.plan);
4566
- log.emit("output.ready", { kind: "report", path: "final/orchestration.yaml" });
4567
- typedPlanNote = `\n- Typed plan: final/orchestration.yaml (${extracted.plan.tool_calls.length} tool call(s))`;
4568
- }
4569
- else {
4570
- store.writeText(join(paths.finalDir, "orchestration_parse_error.md"), `# Typed plan missing\n\n${extracted.error}\n`);
4571
- log.emit("output.ready", {
4572
- kind: "report",
4573
- path: "final/orchestration_parse_error.md",
4574
- state: "diagnostic",
4575
- });
4576
- typedPlanNote = `\n- Typed plan: MISSING (${extracted.error}); the markdown plan above is the only artifact`;
4577
- }
4578
- }
4579
- this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, opts.swarm ? null : (succeeded[0]?.attemptId ?? null));
4684
+ this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, opts.deepScan ? null : (succeeded[0]?.attemptId ?? null));
4580
4685
  log.emit("output.ready", {
4581
4686
  kind: opts.mode === "ask" ? "answer" : "report",
4582
4687
  path: `final/${opts.artifactName}`,
4583
4688
  });
4584
- if (opts.swarm) {
4689
+ if (opts.deepScan) {
4585
4690
  store.writeYaml(join(paths.finalDir, "explore-findings.yaml"), {
4586
4691
  mode: "explore",
4587
4692
  width,
@@ -4603,99 +4708,60 @@ export class Orchestrator {
4603
4708
  });
4604
4709
  store.writeText(join(paths.finalDir, "omissions.md"), `# Omissions\n\n${unsuccessful.map((a) => `- ${a.attemptId} / ${a.harnessId} (${a.status}): ${a.error}`).join("\n") || "- None recorded by the runner. Synthesis claims still require evidence checks."}\n`);
4605
4710
  }
4606
- // orchestrate executor (auto_safe/auto_full): the plan is no longer just a
4607
- // suggestion run its tool_calls in order, classifying each via toolRisk
4608
- // (fail-closed). SAFE steps run as isolated envelope sub-runs / pure reads;
4609
- // a RISKY step (apply) blocks under auto_safe (awaiting a human decision) and
4610
- // applies through the single existing gate under auto_full. The executor's
4611
- // terminal outcome (success / blocked / failed) becomes the run's terminal.
4612
- const autonomy = opts.orchestrateContract?.autonomy ?? input.autonomy ?? "suggest";
4613
- let terminal = "success";
4614
- let orchestrateReadOnly = true;
4615
- let orchestrateReceiptRefs = [];
4616
- if (ledger.terminal() !== null)
4617
- terminal = ledger.terminal();
4618
- // orchestrate's contract output IS the typed plan. If the planner failed to
4619
- // produce a valid one, the run is NOT a clean success — disclose it honestly
4620
- // (the markdown plan stays as a diagnostic artifact) rather than reporting
4621
- // success alongside an orchestration_parse_error.md.
4622
- if (opts.mode === "orchestrate" && !orchestratePlan)
4623
- terminal = "not_converged";
4624
- if (opts.mode === "orchestrate" && autonomy !== "suggest" && orchestratePlan) {
4625
- // Thread the GENERATED runId onto input so the executor's answer_question
4626
- // step keys the interaction registry by this orchestrate run's id (callers
4627
- // often invoke run() without a preassigned runId).
4628
- const executionInput = { ...input, runId };
4629
- const exec = await executeOrchestratePlan({
4630
- plan: orchestratePlan,
4631
- autonomy,
4632
- maxToolCalls: opts.orchestrateContract?.budget.max_tool_calls ?? null,
4633
- signal: input.signal,
4634
- store,
4635
- paths,
4636
- log,
4637
- ledger,
4638
- executeSafeStep: (call) => this.executeSafeStep(executionInput, call, log, store, paths, ledger),
4639
- executeApplyStep: (call) => this.executeApplyStep(executionInput, call, log),
4640
- });
4641
- terminal = exec.terminal;
4642
- orchestrateReadOnly = exec.readOnly;
4643
- orchestrateReceiptRefs = exec.receiptRefs;
4644
- typedPlanNote += `\n- Executor (${autonomy}): ${exec.note}`;
4645
- }
4711
+ // A read-only report (ask / deep-scan) has no live-tree work; the only
4712
+ // non-clean terminal is an aggregate paid-budget stop.
4713
+ let terminalFacts = makeOutcomeFacts("succeeded");
4714
+ const reportBudgetTerminal = ledger.terminal();
4715
+ if (reportBudgetTerminal)
4716
+ terminalFacts = makeOutcomeFacts("failed", { reason: reportBudgetTerminal });
4646
4717
  const harnessLabel = attempts
4647
4718
  .map((a) => `${a.attemptId}:${a.harnessId}:${a.status}`)
4648
4719
  .join(", ");
4649
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Harnesses: ${harnessLabel}\n- Status: ${terminal}${typedPlanNote}\n\n${report}\n`);
4720
+ store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Harnesses: ${harnessLabel}\n- Lifecycle: ${terminalFacts.lifecycle}${terminalFacts.reason ? ` (${terminalFacts.reason})` : ""}\n\n${report}\n`);
4650
4721
  store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
4651
4722
  id: newId("wp"),
4652
4723
  kind: "report",
4653
4724
  source_task_id: taskId,
4654
4725
  producer_attempt_id: succeeded[0]?.attemptId ?? "a01",
4655
- files: Object.fromEntries([
4656
- [opts.artifactName, join(paths.finalDir, opts.artifactName)],
4657
- ...orchestrateReceiptRefs.map((ref, index) => [`delivery_receipt_${index + 1}`, ref]),
4658
- ]),
4726
+ files: Object.fromEntries([[opts.artifactName, join(paths.finalDir, opts.artifactName)]]),
4659
4727
  meta: {
4660
4728
  harnesses: attempts.map((a) => a.harnessId),
4661
4729
  mode: opts.mode,
4662
4730
  intent: opts.intent,
4663
- read_only: orchestrateReadOnly,
4731
+ read_only: true,
4664
4732
  },
4665
4733
  });
4666
4734
  log.emit("work_product.emitted", { kind: "report", winner: succeeded[0]?.attemptId ?? null });
4667
- const orchestrateFailure = orchestrateFailureFor(terminal);
4668
- if (terminal === "blocked" && orchestrateFailure) {
4669
- writeFailure(store, paths, { ...orchestrateFailure, runDir: paths.root });
4670
- log.emit("run.blocked", {
4671
- status: terminal,
4672
- phase: orchestrateFailure.phase,
4673
- failure_ref: "final/failure.yaml",
4735
+ if (terminalFacts.lifecycle === "failed") {
4736
+ writeFailure(store, paths, {
4737
+ phase: "executor",
4738
+ category: "budget",
4739
+ safeMessage: `read-only report ended ${terminalFacts.lifecycle}${terminalFacts.reason ? ` (${terminalFacts.reason.replaceAll("_", " ")})` : ""}`,
4740
+ runDir: paths.root,
4741
+ nextActions: ["Inspect the report artifacts", "Adjust the budget and retry"],
4674
4742
  });
4675
- }
4676
- else if (orchestrateFailure) {
4677
- writeFailure(store, paths, { ...orchestrateFailure, runDir: paths.root });
4678
4743
  log.emit("run.failed", {
4679
- status: terminal,
4680
- phase: orchestrateFailure.phase,
4744
+ lifecycle: terminalFacts.lifecycle,
4745
+ facts: terminalFacts,
4746
+ reason: terminalFacts.reason,
4747
+ phase: "executor",
4681
4748
  failure_ref: "final/failure.yaml",
4682
4749
  });
4683
4750
  }
4684
- else if (terminal === "cancelled") {
4685
- // Cancel is failure-shaped (parity with every other mode's cancel
4686
- // terminal): tailers waiting for run.completed-as-success must not
4687
- // mistake an operator abort for a clean report.
4688
- log.emit("run.failed", { status: terminal });
4689
- }
4690
4751
  else {
4691
- log.emit("run.completed", { status: terminal });
4752
+ log.emit("run.completed", {
4753
+ lifecycle: terminalFacts.lifecycle,
4754
+ facts: terminalFacts,
4755
+ reason: terminalFacts.reason,
4756
+ });
4692
4757
  }
4693
4758
  return {
4694
4759
  spendUsd: ledger.spend(),
4695
4760
  runId,
4696
4761
  taskId,
4697
4762
  mode: opts.mode,
4698
- status: terminal,
4763
+ lifecycle: terminalFacts.lifecycle,
4764
+ facts: terminalFacts,
4699
4765
  winner: null,
4700
4766
  runDir: paths.root,
4701
4767
  summary: redactSecrets(report).slice(0, 400),
@@ -4706,230 +4772,6 @@ export class Orchestrator {
4706
4772
  })),
4707
4773
  };
4708
4774
  }
4709
- /**
4710
- * Run one SAFE plan step. start_run/race spawn ISOLATED ENVELOPE sub-runs
4711
- * (inPlace=false, ASSERTED); review/status/answer_question are pure reads /
4712
- * answer delivery that never mutate the live tree.
4713
- */
4714
- async executeSafeStep(input, call, log, store, paths, ledger) {
4715
- switch (call.tool) {
4716
- case "start_run":
4717
- case "race": {
4718
- // Isolated envelope sub-run (construction owned by runSupport);
4719
- // recursion guard via orchestrateDepth+1, may NOT orchestrate.
4720
- const subInput = {
4721
- ...buildEnvelopeSubInput(input, call, ledger),
4722
- };
4723
- // SAFETY INVARIANT 1 (asserted, not convention): a safe sub-run is an
4724
- // isolated envelope — never a live in-place turn.
4725
- assertEnvelopeSubRun(subInput);
4726
- log.emit("orchestrate.subrun.started", {
4727
- tool: call.tool,
4728
- mode: subInput.mode,
4729
- n: subInput.n ?? null,
4730
- });
4731
- const res = await this.run(subInput);
4732
- return {
4733
- status: res.status === "failed" || res.status === "cancelled" ? "failed" : "done",
4734
- terminalStatus: res.status,
4735
- terminalSource: "subrun",
4736
- evidenceRefs: [`run:${res.runId}`],
4737
- runId: res.runId,
4738
- spendUsd: res.spendUsd ?? null,
4739
- detail: `${call.tool} sub-run ${res.runId} -> ${res.status}`,
4740
- };
4741
- }
4742
- case "status": {
4743
- // Pure read of the referenced run's decision/work_product artifacts.
4744
- const read = readRunStatus(input.repoRoot, call.run_id);
4745
- return {
4746
- status: read ? "done" : "skipped",
4747
- terminalStatus: read?.status ?? null,
4748
- terminalSource: "subrun",
4749
- evidenceRefs: read?.evidenceRefs ?? [],
4750
- runId: call.run_id,
4751
- detail: read?.detail ?? `run ${call.run_id} has no readable status artifacts`,
4752
- };
4753
- }
4754
- case "review": {
4755
- // Read-only review over the referenced run's recorded patch diff. The
4756
- // step ACTUALLY runs the reviewer panel (evidence beats summaries — a
4757
- // "done" review must mean a review happened), persists its artifacts, and
4758
- // reports the real outcome; eligibility alone is never reported as done.
4759
- const diff = readRunPatch(input.repoRoot, call.run_id);
4760
- if (diff === null)
4761
- return {
4762
- status: "skipped",
4763
- terminalStatus: null,
4764
- terminalSource: "review",
4765
- evidenceRefs: [],
4766
- runId: call.run_id,
4767
- detail: `run ${call.run_id} has no patch.diff to review`,
4768
- };
4769
- // Aggregate honesty: reviewer panels spend real money on
4770
- // API-keyed routes and the spend is charged AFTER the fact — with no
4771
- // remaining headroom the review must not start at all.
4772
- if (ledger.terminal() !== null) {
4773
- return {
4774
- status: "skipped",
4775
- terminalStatus: null,
4776
- terminalSource: "review",
4777
- evidenceRefs: [],
4778
- runId: call.run_id,
4779
- detail: "aggregate budget exhausted before the review step",
4780
- };
4781
- }
4782
- const reviewers = await this.resolveReviewers(input.repoRoot, input.authPreference);
4783
- if (reviewers.length === 0)
4784
- return {
4785
- status: "skipped",
4786
- terminalStatus: null,
4787
- terminalSource: "review",
4788
- evidenceRefs: [],
4789
- runId: call.run_id,
4790
- detail: "no doctor-OK reviewers available",
4791
- };
4792
- const reviewLease = ledger.reserve({
4793
- taskId: input.taskId ?? "orchestrate",
4794
- attemptId: `review-${call.run_id}`,
4795
- intent: "review",
4796
- harnessId: "review-panel",
4797
- cost: attemptCostEvidence("review-panel", `review-${call.run_id}`),
4798
- });
4799
- if (!reviewLease.granted)
4800
- return {
4801
- status: "skipped",
4802
- terminalStatus: ledger.terminal(),
4803
- terminalSource: "review",
4804
- evidenceRefs: [],
4805
- runId: call.run_id,
4806
- detail: reviewLease.reason ?? "root paid budget refused the review step",
4807
- };
4808
- const evidenceDir = join(paths.reviewsDir, `orchestrate-${call.run_id}`, "evidence");
4809
- writeEvidencePacket(evidenceDir, {
4810
- userIntent: redactSecrets(input.prompt),
4811
- planAccepted: `orchestrate review tool requested a read-only review of run ${call.run_id}.`,
4812
- diff,
4813
- tests: input.tests?.join("\n") || "(no test commands configured)",
4814
- decidedTradeoffs: "This review is scoped to the referenced run patch and must use typed reviewer artifacts, not summary-only evidence.",
4815
- });
4816
- const result = await this.reviewScoped({
4817
- candidateLabel: `Run ${call.run_id}`,
4818
- diff,
4819
- evidenceDir,
4820
- artifactsDir: join(paths.reviewsDir, `orchestrate-${call.run_id}`),
4821
- cwd: input.repoRoot,
4822
- reviewers,
4823
- envInheritance: envInheritance(this.config(input.repoRoot)),
4824
- signal: input.signal,
4825
- onReviewerEvent: (event) => log.emit(event.type, { ...event }),
4826
- }).catch((error) => {
4827
- ledger.settle(reviewLease.lease?.lease_id ?? "", unknownCostSettlement("review-error"));
4828
- throw error;
4829
- });
4830
- ledger.settle(reviewLease.lease?.lease_id ?? "", usageCostSettlement(result.reviewSpendUsd, result.reviewSpendEstimated, "review-usage", [
4831
- `orchestrate:review:${call.run_id}`,
4832
- ]));
4833
- const revalidated = await revalidateFindings(result.findings, {
4834
- candidateRoot: input.repoRoot,
4835
- evidenceDir,
4836
- });
4837
- store.writeYaml(join(paths.reviewsDir, `orchestrate-${call.run_id}.yaml`), {
4838
- target_run_id: call.run_id,
4839
- cross_family_healthy: result.crossFamilyHealthy,
4840
- cross_family_verified: result.crossFamilyVerified,
4841
- findings: revalidated,
4842
- route_proofs: result.routeProofs,
4843
- });
4844
- const blockers = revalidated.filter((f) => isBlocking(f)).length;
4845
- return {
4846
- status: "done",
4847
- terminalStatus: result.crossFamilyVerified && blockers === 0 ? "success" : "blocked",
4848
- terminalSource: "review",
4849
- evidenceRefs: [`reviews/orchestrate-${call.run_id}.yaml`],
4850
- runId: call.run_id,
4851
- detail: `reviewed ${call.run_id}: ${result.distinctProviders.length} family(ies), ${revalidated.length} finding(s), ${blockers} blocker(s)`,
4852
- // Reviewer panels can spend real money on API-keyed routes; the
4853
- // aggregate cap must charge it like any other step.
4854
- spendUsd: result.reviewSpendUsd ?? null,
4855
- };
4856
- }
4857
- case "answer_question": {
4858
- // Delivery + registry-keying rationale owned by runSupport.
4859
- const answer = await deliverPlanAnswer(input, call);
4860
- return {
4861
- ...answer,
4862
- terminalStatus: answer.status === "done" ? "success" : null,
4863
- terminalSource: "executor",
4864
- evidenceRefs: [],
4865
- };
4866
- }
4867
- default: {
4868
- // FAIL-CLOSED: a risky tool (apply) must never reach the safe executor;
4869
- // the caller routes risky steps to executeApplyStep / the auto_safe block.
4870
- throw new Error(`executeSafeStep refused a non-safe tool '${call.tool}' (risky tools must not run as safe steps)`);
4871
- }
4872
- }
4873
- }
4874
- /** Execute an auto_full apply through the shared fresh-verification gate. */
4875
- async executeApplyStep(input, call, log) {
4876
- const store = new ArtifactStore(input.repoRoot);
4877
- const sub = store.runPaths(call.run_id);
4878
- const patchPath = join(sub.finalDir, "patch.diff");
4879
- const patchText = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : null;
4880
- if (patchText === null)
4881
- return {
4882
- ok: false,
4883
- runId: call.run_id,
4884
- detail: `run ${call.run_id} has no patch.diff`,
4885
- receipt: null,
4886
- };
4887
- if (containsSecretLikeToken(patchText))
4888
- return {
4889
- ok: false,
4890
- runId: call.run_id,
4891
- detail: "patch contains a secret-like token; refusing apply",
4892
- receipt: null,
4893
- };
4894
- const decision = store.readYaml(join(sub.arbitrationDir, "decision.yaml"));
4895
- const workProduct = store.readYaml(join(sub.finalDir, "work_product.yaml"));
4896
- const taskContract = TaskContractSchema.safeParse(store.readYaml(join(sub.contextDir, "task.yaml")));
4897
- const parsedDecision = decision ? DecisionRecordSchema.safeParse(decision) : null;
4898
- const parsedWp = workProduct ? WorkProductSchema.safeParse(workProduct) : null;
4899
- if (!taskContract.success)
4900
- return {
4901
- ok: false,
4902
- runId: call.run_id,
4903
- detail: "fresh verification contract is missing",
4904
- receipt: null,
4905
- };
4906
- const applyGateInput = {
4907
- state: null,
4908
- decision: parsedDecision?.success ? parsedDecision.data : null,
4909
- workProduct: parsedWp?.success ? parsedWp.data : null,
4910
- patch: patchText,
4911
- originalRepoRoot: input.repoRoot,
4912
- targetRepoRoot: input.repoRoot,
4913
- operatorDecision: null,
4914
- };
4915
- const gateError = validateApplyGate(applyGateInput);
4916
- if (gateError)
4917
- return {
4918
- ok: false,
4919
- runId: call.run_id,
4920
- detail: `apply gate refused: ${gateError}`,
4921
- receipt: null,
4922
- };
4923
- const delivered = await verifyAndDeliver(input.repoRoot, patchText, { mode: call.mode }, gateSpecsFromContract(taskContract.data), (finalVerify) => validateApplyGate({ ...applyGateInput, finalVerify }), log);
4924
- return {
4925
- ok: delivered.applied,
4926
- runId: call.run_id,
4927
- detail: delivered.applied
4928
- ? `applied (${call.mode})`
4929
- : `deliver failed: ${delivered.detail ?? "unknown"}`,
4930
- receipt: delivered,
4931
- };
4932
- }
4933
4775
  }
4934
4776
  function assertNoSecretLikeTokens(label, text) {
4935
4777
  if (containsSecretLikeToken(text)) {