@claudexor/orchestrator 2.1.3 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/candidateEvidence.d.ts.map +1 -1
  2. package/dist/candidateEvidence.js +4 -6
  3. package/dist/candidateEvidence.js.map +1 -1
  4. package/dist/candidateOutputs.d.ts +13 -0
  5. package/dist/candidateOutputs.d.ts.map +1 -1
  6. package/dist/candidateOutputs.js +71 -8
  7. package/dist/candidateOutputs.js.map +1 -1
  8. package/dist/continuity-facts.d.ts +37 -0
  9. package/dist/continuity-facts.d.ts.map +1 -0
  10. package/dist/continuity-facts.js +100 -0
  11. package/dist/continuity-facts.js.map +1 -0
  12. package/dist/continuity-summary.d.ts +45 -0
  13. package/dist/continuity-summary.d.ts.map +1 -0
  14. package/dist/continuity-summary.js +113 -0
  15. package/dist/continuity-summary.js.map +1 -0
  16. package/dist/continuity.d.ts +119 -0
  17. package/dist/continuity.d.ts.map +1 -0
  18. package/dist/continuity.js +220 -0
  19. package/dist/continuity.js.map +1 -0
  20. package/dist/contract-gates.d.ts +2 -3
  21. package/dist/contract-gates.d.ts.map +1 -1
  22. package/dist/contract-gates.js +2 -3
  23. package/dist/contract-gates.js.map +1 -1
  24. package/dist/council.d.ts +57 -0
  25. package/dist/council.d.ts.map +1 -0
  26. package/dist/council.js +98 -0
  27. package/dist/council.js.map +1 -0
  28. package/dist/credential-profiles.d.ts +26 -0
  29. package/dist/credential-profiles.d.ts.map +1 -1
  30. package/dist/credential-profiles.js +21 -0
  31. package/dist/credential-profiles.js.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/orchestrator.d.ts +218 -57
  37. package/dist/orchestrator.d.ts.map +1 -1
  38. package/dist/orchestrator.js +968 -1077
  39. package/dist/orchestrator.js.map +1 -1
  40. package/dist/planQuestions.d.ts +11 -0
  41. package/dist/planQuestions.d.ts.map +1 -0
  42. package/dist/planQuestions.js +119 -0
  43. package/dist/planQuestions.js.map +1 -0
  44. package/dist/planRun.d.ts +114 -0
  45. package/dist/planRun.d.ts.map +1 -0
  46. package/dist/planRun.js +346 -0
  47. package/dist/planRun.js.map +1 -0
  48. package/dist/runSupport.d.ts +31 -80
  49. package/dist/runSupport.d.ts.map +1 -1
  50. package/dist/runSupport.js +33 -106
  51. package/dist/runSupport.js.map +1 -1
  52. package/dist/runTerminals.d.ts.map +1 -1
  53. package/dist/runTerminals.js +18 -7
  54. package/dist/runTerminals.js.map +1 -1
  55. package/package.json +18 -19
  56. package/dist/orchestrateExecutor.d.ts +0 -43
  57. package/dist/orchestrateExecutor.d.ts.map +0 -1
  58. package/dist/orchestrateExecutor.js +0 -207
  59. package/dist/orchestrateExecutor.js.map +0 -1
  60. package/dist/orchestratePlanner.d.ts +0 -12
  61. package/dist/orchestratePlanner.d.ts.map +0 -1
  62. package/dist/orchestratePlanner.js +0 -91
  63. package/dist/orchestratePlanner.js.map +0 -1
  64. package/dist/outcomeReducer.d.ts +0 -25
  65. package/dist/outcomeReducer.d.ts.map +0 -1
  66. package/dist/outcomeReducer.js +0 -121
  67. package/dist/outcomeReducer.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
  import { observeNativeSessionEvent, preflightCredentialProfile, preflightDefaultSubject, resolveCredentialProfile, resumeSessionForProfile, rotateSpecOnTypedLimit, selectedProfileAvailability, } from "./credential-profiles.js";
3
3
  import { writeRunTelemetryArtifact } from "./runTelemetryWriter.js";
4
4
  import { buildFileBackedSynthesisInput, materializeWinnerOutputs, stageFileBackedContext, writeCandidateAttemptArtifacts, } from "./candidateOutputs.js";
@@ -7,20 +7,19 @@ import { toCandidateEvidence } from "./candidateEvidence.js";
7
7
  import { capabilityIntents } from "@claudexor/gateway";
8
8
  import { policyFindings } from "./policyFindings.js";
9
9
  import { join } from "node:path";
10
- 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";
11
12
  import { globalConfigDir, loadConfig, trustConfigPath } from "@claudexor/config";
12
- import { specPackToTaskContract } from "@claudexor/interview";
13
- 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";
14
14
  import { assertRouteModelsAllowed } from "./modelGovernance.js";
15
15
  import { RequestRequirementsResolver } from "./requestRequirements.js";
16
16
  import { cancelledResult, failTerminally, guardAnnouncedRun, writeFailure, } from "./runTerminals.js";
17
17
  import { assertOutputSchemaCompiles, finalizeStructuredOutput } from "./structuredOutput.js";
18
- 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";
19
19
  import { candidateStatusInRouteContext, resolveReadOnlyRouteContext, } from "./routeContext.js";
20
20
  import { resolveAutoReviewerPanel, resolveExplicitReviewerPanel } from "./reviewerPanel.js";
21
- import { buildOrchestratePlannerPrompt, extractOrchestratePlan } from "./orchestratePlanner.js";
22
- import { orchestrateFailureFor, readRunStatus } from "./outcomeReducer.js";
23
- import { executeOrchestratePlan, } from "./orchestrateExecutor.js";
21
+ import { buildContinuation, } from "./continuity.js";
22
+ import { activePlanPointer, resolveContinuitySummary, workspaceAnchor, } from "./continuity-facts.js";
24
23
  import { runDiffReview } from "./diffReview.js";
25
24
  import { createAttemptTelemetry, observeAttemptTelemetry, setAttemptOutcome, telemetrySummary, toolWarnings, unrecoveredToolErrors, webUnsatisfied, } from "./attemptTelemetry.js";
26
25
  import { interactionChannelFor } from "./interaction.js";
@@ -29,34 +28,26 @@ import { ArtifactStore } from "@claudexor/artifact-store";
29
28
  import { EventLog } from "@claudexor/event-log";
30
29
  import { assertMandatoryContext, buildContextPack, rawContextForEnvelope, preflightEvidence, writeEvidencePacket, } from "@claudexor/context";
31
30
  import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureGitRepository, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
32
- import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch, validateApplyGate, verifyAndDeliver, } from "@claudexor/delivery";
31
+ import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch, verifyAndDeliver, } from "@claudexor/delivery";
33
32
  import { HarnessGateway } from "@claudexor/gateway";
34
33
  import { ReadinessLedger, evaluateConvergence, failureSignature, gatesPassed, reviewCandidate, revalidateFindings, runGates, } from "@claudexor/review";
35
34
  import { arbitrate } from "@claudexor/arbitration";
36
35
  import { buildSynthesisPlan, decideSynthesis } from "@claudexor/synthesis";
37
36
  import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, loadHarnessMetrics, promptFingerprint, unknownCostSettlement, rankHarnesses, reviewUsageCostSettlement, } from "@claudexor/budget";
38
- import { appendLine, assertNoInlineSecretValues, containsSecretLikeToken, hashJson, newId, noProjectRepoRoot, nowIso, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
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
+ }
39
45
  const LABELS = "ABCDEFGHIJ".split("");
40
46
  const NO_PROJECT_ROOT = noProjectRepoRoot();
41
47
  /** Concurrency cap for parallel candidates/explorers (locked decision: min(n, 4)). */
42
48
  const MAX_PARALLEL_CANDIDATES = 4;
43
49
  /** Default wait for one interactive answer before a benign decline. */
44
50
  const DEFAULT_INTERACTION_TIMEOUT_MS = 900_000;
45
- /**
46
- * SAFETY INVARIANT 1 (asserted, not convention): a sub-run spawned by the
47
- * orchestrate executor for a SAFE step (start_run/race) MUST run as an isolated
48
- * ENVELOPE — never a live in-place turn on a thread. Throws loudly if a caller
49
- * ever constructs a safe sub-run that could mutate the live tree.
50
- */
51
- function assertEnvelopeSubRun(sub) {
52
- if (sub.inPlace === true) {
53
- throw new Error("orchestrate safe sub-run must be an isolated envelope (inPlace must be false), refusing live-tree mutation");
54
- }
55
- if (sub.threadId !== undefined || sub.executionRoot !== undefined) {
56
- throw new Error("orchestrate safe sub-run must not bind a thread or in-place execution root (isolation envelope only)");
57
- }
58
- }
59
- /** Changed paths and +/- line counts parsed from a unified git diff. */
60
51
  /** Run `work` over `items` with bounded concurrency, preserving item order via index. */
61
52
  async function runBounded(items, limit, work) {
62
53
  if (items.length === 0)
@@ -114,15 +105,6 @@ export class Orchestrator {
114
105
  throw new Error(`unknown mode: ${String(resolved.mode)}`);
115
106
  }
116
107
  const mode = parsedMode.data;
117
- // INV-023 at the ENGINE boundary too: maxToolCalls caps the orchestrate
118
- // executor's plan steps — on any other mode it would be a silent no-op
119
- // knob. The CLI and control API validate this already; a direct embedder
120
- // must get the same loud refusal, not quiet acceptance.
121
- if (resolved.maxToolCalls !== undefined &&
122
- resolved.maxToolCalls !== null &&
123
- mode !== "orchestrate") {
124
- 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`);
125
- }
126
108
  // denyPaths is enforced by the post-diff policy gate BEFORE delivery, which
127
109
  // only exists on envelope/isolated runs — an in-place run mutates the live
128
110
  // tree directly, so the gate could not contain a violation. Refuse loudly
@@ -169,12 +151,11 @@ export class Orchestrator {
169
151
  return guardAnnouncedRun(resolved.signal, (announce) => {
170
152
  switch (mode) {
171
153
  case "ask":
172
- return this.runAsk(resolved, announce);
173
- case "audit":
174
- // `--swarm` selects the bounded read-only research swarm (old `explore`).
175
- return resolved.swarm
176
- ? this.runExplore(resolved, announce)
177
- : 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);
178
159
  case "agent":
179
160
  // Engine strategies are FLAGS on agent (v0.9 collapse): `--until-clean`
180
161
  // and `--attempts` select the convergence loop; `--n` selects the race
@@ -187,14 +168,6 @@ export class Orchestrator {
187
168
  return this.runRace({ ...resolved, n: resolved.n ?? 1 }, mode, announce);
188
169
  case "plan":
189
170
  return this.runPlan(resolved, announce);
190
- case "orchestrate":
191
- // Recursion guard: a sub-run spawned by the orchestrate executor carries
192
- // orchestrateDepth>0 and must NOT itself orchestrate (no infinite planner
193
- // recursion). Fail loudly rather than silently degrade.
194
- if ((resolved.orchestrateDepth ?? 0) > 0) {
195
- throw new Error("orchestrate-within-orchestrate is forbidden: a sub-run spawned by the orchestrate executor cannot itself orchestrate");
196
- }
197
- return this.runOrchestrate(resolved, announce);
198
171
  }
199
172
  });
200
173
  }
@@ -234,10 +207,12 @@ export class Orchestrator {
234
207
  safeMessage: message,
235
208
  runDir: paths.root,
236
209
  });
237
- 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`);
238
211
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
239
212
  log.emit("run.failed", {
240
- status: "failed",
213
+ lifecycle: "failed",
214
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
215
+ reason: "harness_failed",
241
216
  phase: "review_preflight",
242
217
  error: message,
243
218
  failure_ref: "final/failure.yaml",
@@ -247,7 +222,8 @@ export class Orchestrator {
247
222
  runId,
248
223
  taskId,
249
224
  mode,
250
- status: "failed",
225
+ lifecycle: "failed",
226
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
251
227
  winner: null,
252
228
  runDir: paths.root,
253
229
  summary: message,
@@ -311,11 +287,55 @@ export class Orchestrator {
311
287
  credential_profile: profile,
312
288
  };
313
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
+ }
314
322
  resolveCredentialProfile(input, harnessId) {
315
- if (!input.credentialProfileId)
323
+ const explicit = input.credentialProfileId ?? null;
324
+ const wanted = this.effectiveProfileId(input, harnessId);
325
+ if (!wanted)
316
326
  return null;
317
327
  const registry = this.config(input.repoRoot)?.global.credential_profiles ?? [];
318
- 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
+ }
319
339
  }
320
340
  /** The typed effective auth route for a SELECTED credential profile
321
341
  * (round-18 #2): adapters execute strictly by credential_kind, so routing,
@@ -351,13 +371,6 @@ export class Orchestrator {
351
371
  }
352
372
  return preflightCredentialProfile({ profile, harnessId, policy, registry, snapshots, emit });
353
373
  }
354
- /**
355
- * Lift an adapter's auth-route override marker into the typed
356
- * `route.fallback.auth_switched` run event (validated payload). An explicit
357
- * subscription/api_key preference that could not be honored is never silent;
358
- * neither is an `auto` choice that selects a smoke-proven paid route over an
359
- * available native route.
360
- */
361
374
  /**
362
375
  * Resolve candidate adapters: explicit `--harness`, else available real harnesses, then
363
376
  * **capability-gate** to those that can actually produce work for `intent` (e.g. a
@@ -371,13 +384,7 @@ export class Orchestrator {
371
384
  }
372
385
  const cfg = this.config(input.repoRoot);
373
386
  const configuredPool = cfg?.global.routing.eligible_harnesses;
374
- const policy = cfg?.global.routing.default_policy;
375
- const harnesses = input.harnesses ??
376
- (configuredPool && configuredPool.length > 0
377
- ? configuredPool
378
- : policy === "primary" && cfg?.global.routing.primary_harness
379
- ? [cfg.global.routing.primary_harness]
380
- : undefined);
387
+ const harnesses = input.harnesses ?? (configuredPool && configuredPool.length > 0 ? configuredPool : undefined);
381
388
  const primaryHarness = input.primaryHarness ?? cfg?.global.routing.primary_harness ?? undefined;
382
389
  if (primaryHarness &&
383
390
  harnesses &&
@@ -429,7 +436,7 @@ export class Orchestrator {
429
436
  externalContextPolicy: web,
430
437
  };
431
438
  }
432
- async resolveCandidateAdapters(input, intent, ledger, routeContext) {
439
+ async resolveCandidateAdapters(input, intent, ledger, log, routeContext) {
433
440
  let ids = input.harnesses;
434
441
  const explicitPool = Boolean(ids && ids.length > 0);
435
442
  const harnessSettings = this.config(input.repoRoot)?.global.harnesses ?? {};
@@ -509,6 +516,18 @@ export class Orchestrator {
509
516
  dropped.push(why);
510
517
  continue;
511
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
+ }
512
531
  // W3.3 (TZ-1 §B): a route is admitted on readiness truth from the SAME
513
532
  // resolved env/cwd its run will spawn with (see routeContext.ts).
514
533
  let status = await candidateStatusInRouteContext(this.gateway, routeContext, id, this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), statusById);
@@ -531,7 +550,8 @@ export class Orchestrator {
531
550
  const profileAdapter = this.deps.registry.get(id);
532
551
  const profileVerdict = await selectedProfileAvailability({
533
552
  registry: this.config(input.repoRoot)?.global.credential_profiles ?? [],
534
- profileId: input.credentialProfileId,
553
+ // The EFFECTIVE account (INV-135): an explicit pin is authenticated by ITS store.
554
+ profileId: this.effectiveProfileId(input, id),
535
555
  harnessId: id,
536
556
  probe: profileAdapter?.probeCredentialProfile?.bind(profileAdapter),
537
557
  });
@@ -567,11 +587,7 @@ export class Orchestrator {
567
587
  dropped.push(`${id} is ${status.status}${status.reasons.length ? `: ${status.reasons.join("; ")}` : ""}`);
568
588
  continue;
569
589
  }
570
- const readOnlyIntent = intent === "plan" ||
571
- intent === "spec" ||
572
- intent === "explain" ||
573
- intent === "audit" ||
574
- intent === "orchestrate";
590
+ const readOnlyIntent = intent === "plan" || intent === "spec" || intent === "explain" || intent === "audit";
575
591
  const requiredAccess = this.requestRequirements.adapterAccess(intent, manifest.capabilities.implementation_transport, readOnlyIntent
576
592
  ? "readonly"
577
593
  : (input.access ?? this.config(input.repoRoot).trust.access_default));
@@ -628,6 +644,8 @@ export class Orchestrator {
628
644
  estimateEffectiveAuthRoute(this.authPreferenceForHarness(input.repoRoot, id, input.authPreference), status.authSources),
629
645
  supportsInteractive: manifest.capabilities.interactive,
630
646
  supportsJsonSchemaOutput: manifest.capabilities.json_schema_output,
647
+ supportsMcpInjection: manifest.capability_profile.mcp_injection,
648
+ mcpInjectionRequiresFullAccess: manifest.capability_profile.mcp_injection_requires_full_access,
631
649
  implementationTransport: manifest.capabilities.implementation_transport,
632
650
  settings: cfgEntry
633
651
  ? {
@@ -653,11 +671,33 @@ export class Orchestrator {
653
671
  if (ordered.length === 0) {
654
672
  throw new HarnessUnavailableError(`no harness remains eligible for '${intent}' after budget and quota routing`);
655
673
  }
674
+ emitPrimaryDivergence(log, input.primaryHarness, ordered, pool, dropped);
656
675
  const n = input.n ?? ordered.length;
657
676
  const out = [];
658
677
  for (let i = 0; i < n; i++)
659
678
  out.push(ordered[i % ordered.length]);
660
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
+ }
661
701
  // outputSchema is MANDATORY (Quiz-6a): a selected lane that cannot
662
702
  // natively constrain its final message would deliver best-effort text —
663
703
  // that is a typed preflight refusal, never silent degradation. The
@@ -843,7 +883,7 @@ export class Orchestrator {
843
883
  buildContract(input, taskId, mode) {
844
884
  const resolvedCfg = this.config(input.repoRoot);
845
885
  const cfg = resolvedCfg.project;
846
- const readOnlyMode = mode === "ask" || mode === "plan" || mode === "audit" || mode === "orchestrate";
886
+ const readOnlyMode = mode === "ask" || mode === "plan";
847
887
  const requestedAccess = input.access ?? (readOnlyMode ? "readonly" : resolvedCfg.trust.access_default);
848
888
  // Effective access is COMPUTED by the engine, never echoed from a client:
849
889
  // read-only modes clamp to readonly regardless of the request.
@@ -864,63 +904,19 @@ export class Orchestrator {
864
904
  { code: TRUST_FULL_ACCESS_CODE, status: 403 });
865
905
  }
866
906
  const externalContextPolicy = input.web ?? input.externalContextPolicy ?? "auto";
867
- // A frozen SpecPack's CONTENT reaches the contract (success criteria,
868
- // non-goals, forbidden approaches, tradeoffs, task graph) previously only
869
- // its metadata did, leaving the arbitration acceptance axis permanently
870
- // empty and the interview pipeline dead in production.
871
- let specFields = {};
872
- let specTestCommands = [];
873
- if (input.specPath) {
874
- try {
875
- const spec = SpecPackZ.parse(JSON.parse(readFileSync(input.specPath, "utf8")));
876
- // Tamper fence (INV-081): the frozen spec's recorded hash must
877
- // match what we just read — a spec.json edited AFTER freeze would
878
- // otherwise silently rewrite success criteria/tests/protected paths
879
- // while the contract records the stale hash as provenance.
880
- if (input.specHash && hashJson(spec) !== input.specHash) {
881
- 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`);
882
- }
883
- const fromSpec = specPackToTaskContract(spec, {
884
- repoRoot: input.repoRoot,
885
- mode,
886
- baseRef: input.baseRef,
887
- paidBudget: input.paidBudget,
888
- });
889
- specFields = {
890
- success_criteria: fromSpec.success_criteria,
891
- non_goals: fromSpec.non_goals,
892
- forbidden_approaches: fromSpec.forbidden_approaches,
893
- decided_tradeoffs: fromSpec.decided_tradeoffs,
894
- task_graph: fromSpec.task_graph,
895
- constraints: fromSpec.constraints,
896
- };
897
- specTestCommands = fromSpec.tests.commands.map(({ program, args, cwd, envAllowlist }) => ({
898
- program,
899
- args,
900
- ...(cwd === undefined ? {} : { cwd }),
901
- envAllowlist,
902
- }));
903
- }
904
- catch (err) {
905
- // An unreadable/unfrozen spec must fail the run loudly, never silently
906
- // degrade into an unspecced contract.
907
- throw new Error(`failed to resolve frozen SpecPack at ${input.specPath}: ${safeErrorMessage(err)}`);
908
- }
909
- }
910
- // Deterministic gate commands come from the frozen SpecPack, explicit run
911
- // input, then versioned project config. Without these, gateSpecs is empty
912
- // 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.
913
910
  const resolvedGates = resolveContractGates({
914
911
  repoRoot: input.repoRoot,
915
912
  effectiveAccess,
916
913
  config: cfg,
917
914
  trustGrants: resolvedCfg.trust.test_command_grants,
918
- specCommands: specTestCommands,
919
915
  operatorCommands: input.tests ?? [],
920
916
  projectCommands: cfg.tests?.commands ?? [],
921
917
  });
922
918
  const commands = resolvedGates.commands;
923
- const protectedPaths = [...new Set(specFields.constraints?.protected_paths ?? [])];
919
+ const protectedPaths = [];
924
920
  const autoProtectedPaths = resolvedGates.autoProtectedPaths;
925
921
  const protectedPathApprovals = [
926
922
  ...new Map([...(input.protectedPathApprovals ?? [])].map((approval) => [approval.path, approval])).values(),
@@ -943,14 +939,6 @@ export class Orchestrator {
943
939
  auth_preference: input.authPreference ?? "auto",
944
940
  credential_profile_id: input.credentialProfileId ?? null,
945
941
  max_turns: input.maxTurns ?? null,
946
- spec: input.specId || input.specHash || input.specPath
947
- ? {
948
- id: input.specId,
949
- hash: input.specHash,
950
- path: input.specPath,
951
- }
952
- : undefined,
953
- ...specFields,
954
942
  constraints: {
955
943
  protected_paths: protectedPaths,
956
944
  deny_paths: [...new Set(input.denyPaths ?? [])],
@@ -1002,6 +990,39 @@ export class Orchestrator {
1002
990
  * execution — owner Quiz-5a); reviewers and the auth smoke build their own
1003
991
  * specs and never call this.
1004
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
+ }
1005
1026
  harnessSpecKnobs(contract, knobs, intent) {
1006
1027
  return {
1007
1028
  external_context_policy: knobs.webPolicy,
@@ -1015,11 +1036,10 @@ export class Orchestrator {
1015
1036
  max_turns: knobs.maxTurns,
1016
1037
  ...(intent === "synthesize" ? {} : { instructions: contract.instructions }),
1017
1038
  // The user's answer contract rides every answer-producing lane INCLUDING
1018
- // synthesis (its answer can become the final one); the orchestrate
1019
- // planner owns its own plan schema instead (set at its spec site). The
1020
- // adapter gets the vendor-STRICT transport form; the engine validator
1021
- // keeps the ORIGINAL contract as the conformance authority.
1022
- ...(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
1023
1043
  ? { output_schema: strictifyOutputSchema(contract.output_schema) }
1024
1044
  : {}),
1025
1045
  };
@@ -1073,6 +1093,111 @@ export class Orchestrator {
1073
1093
  ignored,
1074
1094
  };
1075
1095
  }
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
+ }
1076
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) {
1077
1202
  const adapter = routed.adapter;
1078
1203
  const knobs = this.routeSpecKnobs(routed, contract, modelHint, effortHint);
@@ -1080,12 +1205,26 @@ export class Orchestrator {
1080
1205
  const inPlaceEnvelope = envelope.worktree_path === envelope.repo_root;
1081
1206
  const rawContextPacket = await rawContextForEnvelope(routed.implementationTransport, envelope);
1082
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;
1083
1218
  let spec = HarnessRunSpec.parse({
1084
1219
  session_id: newId("ses"),
1085
1220
  intent,
1086
- 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),
1087
1222
  attachments: runInput?.attachments ?? [],
1088
- 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),
1089
1228
  cwd: envelope.worktree_path,
1090
1229
  access: routed.adapterAccess,
1091
1230
  ...this.harnessSpecKnobs(contract, knobs, intent),
@@ -1105,16 +1244,6 @@ export class Orchestrator {
1105
1244
  raw_context_packet: rawContextPacket,
1106
1245
  stream_deltas: streamDeltas,
1107
1246
  });
1108
- if (!inPlaceEnvelope && runInput?.threadId && sessionFields?.resume_session_id) {
1109
- log?.emit("session.rebound", SessionReboundLineageSchema.parse({
1110
- thread_id: runInput.threadId,
1111
- harness_id: adapter.id,
1112
- from_native_session_id: sessionFields.resume_session_id,
1113
- to_session_id: null,
1114
- 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",
1115
- reason: "not_portable",
1116
- }));
1117
- }
1118
1247
  if (interaction)
1119
1248
  spec.extra["interactionChannel"] = interaction;
1120
1249
  const inactivityMs = harnessInactivityTimeoutMs(this.config(contract.repo.root));
@@ -1482,10 +1611,12 @@ export class Orchestrator {
1482
1611
  "Retry the run",
1483
1612
  ],
1484
1613
  });
1485
- 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`);
1486
1615
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
1487
1616
  log.emit("run.failed", {
1488
- status: "failed",
1617
+ lifecycle: "failed",
1618
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
1619
+ reason: "harness_failed",
1489
1620
  phase: "workspace",
1490
1621
  error: message,
1491
1622
  failure_ref: "final/failure.yaml",
@@ -1493,6 +1624,37 @@ export class Orchestrator {
1493
1624
  return message;
1494
1625
  }
1495
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
+ }
1496
1658
  async runRace(input, mode, announce) {
1497
1659
  const taskId = input.taskId ?? newId("task");
1498
1660
  const runId = input.runId ?? newId("run");
@@ -1503,6 +1665,7 @@ export class Orchestrator {
1503
1665
  const store = this.artifactStore(input);
1504
1666
  const paths = store.createRun(runId);
1505
1667
  const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
1668
+ input = this.withPlanBrief(input, store, paths, log);
1506
1669
  // The execution root is the tree the harness mutates: the project itself for
1507
1670
  // in-place threads/ordinary runs, or the thread's persistent worktree for an
1508
1671
  // isolated thread. Config/artifacts/contract stay anchored to repoRoot. Both
@@ -1536,7 +1699,8 @@ export class Orchestrator {
1536
1699
  runId,
1537
1700
  taskId,
1538
1701
  mode,
1539
- status: "failed",
1702
+ lifecycle: "failed",
1703
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
1540
1704
  winner: null,
1541
1705
  runDir: paths.root,
1542
1706
  summary: gitPreconditionError,
@@ -1566,6 +1730,7 @@ export class Orchestrator {
1566
1730
  });
1567
1731
  let adapters;
1568
1732
  try {
1733
+ // Best-of races the whole pool (no divergence `log`: the pool still runs).
1569
1734
  adapters = await this.resolveCandidateAdapters(input, this.candidateIntent(input), ledger);
1570
1735
  }
1571
1736
  catch (err) {
@@ -1577,10 +1742,12 @@ export class Orchestrator {
1577
1742
  safeMessage: message,
1578
1743
  runDir: paths.root,
1579
1744
  });
1580
- 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`);
1581
1746
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
1582
1747
  log.emit("run.failed", {
1583
- status: "failed",
1748
+ lifecycle: "failed",
1749
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
1750
+ reason: "harness_failed",
1584
1751
  phase: "routing",
1585
1752
  error: message,
1586
1753
  failure_ref: "final/failure.yaml",
@@ -1589,7 +1756,8 @@ export class Orchestrator {
1589
1756
  runId,
1590
1757
  taskId,
1591
1758
  mode,
1592
- status: "failed",
1759
+ lifecycle: "failed",
1760
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
1593
1761
  winner: null,
1594
1762
  runDir: paths.root,
1595
1763
  summary: message,
@@ -1811,29 +1979,34 @@ export class Orchestrator {
1811
1979
  })), null), ledger.spend(), input.signal, store);
1812
1980
  }
1813
1981
  if (runs.length === 0) {
1814
- 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
+ });
1815
1987
  const why = budgetStopped
1816
1988
  ? "budget exhausted before any candidate run"
1817
1989
  : "no candidates produced";
1818
1990
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
1819
1991
  winner: null,
1820
- status,
1821
- outcome: "blocked",
1992
+ facts,
1822
1993
  why_winner: why,
1823
1994
  evidence_facts: ["no candidates were produced"],
1824
1995
  apply_recommendation: "continue",
1825
1996
  budget_summary: { spend_usd: ledger.spend(), estimated: false },
1826
1997
  });
1827
- 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`);
1828
1999
  writeFailure(store, paths, {
1829
2000
  phase: "budget",
1830
- category: isBudgetTerminal(status) ? "budget" : "internal",
2001
+ category: isBudgetTerminal(facts.reason) ? "budget" : "internal",
1831
2002
  safeMessage: why,
1832
2003
  runDir: paths.root,
1833
2004
  });
1834
2005
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
1835
2006
  log.emit("run.failed", {
1836
- status,
2007
+ lifecycle: facts.lifecycle,
2008
+ facts,
2009
+ reason: facts.reason,
1837
2010
  phase: "budget",
1838
2011
  error: why,
1839
2012
  failure_ref: "final/failure.yaml",
@@ -1842,7 +2015,8 @@ export class Orchestrator {
1842
2015
  runId,
1843
2016
  taskId,
1844
2017
  mode,
1845
- status,
2018
+ lifecycle: facts.lifecycle,
2019
+ facts,
1846
2020
  winner: null,
1847
2021
  runDir: paths.root,
1848
2022
  summary: why,
@@ -1863,11 +2037,10 @@ export class Orchestrator {
1863
2037
  const rootCause = runs
1864
2038
  .map((r) => `${r.attemptId}/${r.harnessId}: ${r.errors[0] ?? "failed before producing work"}`)
1865
2039
  .join("; ");
1866
- const status = "failed";
2040
+ const facts = makeOutcomeFacts("failed", { reason: "harness_failed", noChanges: true });
1867
2041
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
1868
2042
  winner: null,
1869
- status,
1870
- outcome: "blocked",
2043
+ facts,
1871
2044
  why_winner: rootCause,
1872
2045
  evidence_facts: runs.map((r) => `${r.attemptId} produced no work: ${r.errors[0] ?? "unknown"}`),
1873
2046
  apply_recommendation: "continue",
@@ -1878,7 +2051,7 @@ export class Orchestrator {
1878
2051
  harnessId: r.harnessId,
1879
2052
  telemetry: r.telemetry,
1880
2053
  })), null);
1881
- 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`);
1882
2055
  const existingEventRefs = runs
1883
2056
  .map((r) => `attempts/${r.attemptId}/events.jsonl`)
1884
2057
  .filter((rel) => existsSync(join(paths.root, rel)));
@@ -1897,7 +2070,9 @@ export class Orchestrator {
1897
2070
  });
1898
2071
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
1899
2072
  log.emit("run.failed", {
1900
- status,
2073
+ lifecycle: facts.lifecycle,
2074
+ facts,
2075
+ reason: facts.reason,
1901
2076
  phase,
1902
2077
  error: rootCause,
1903
2078
  failure_ref: "final/failure.yaml",
@@ -1906,7 +2081,8 @@ export class Orchestrator {
1906
2081
  runId,
1907
2082
  taskId,
1908
2083
  mode,
1909
- status,
2084
+ lifecycle: facts.lifecycle,
2085
+ facts,
1910
2086
  winner: null,
1911
2087
  runDir: paths.root,
1912
2088
  summary: rootCause,
@@ -2036,7 +2212,7 @@ export class Orchestrator {
2036
2212
  }
2037
2213
  log.emit("arbitration.completed", {
2038
2214
  winner: result.decision.winner,
2039
- status: result.decision.status,
2215
+ lifecycle: result.decision.facts.lifecycle,
2040
2216
  });
2041
2217
  // Winner can only be a candidate that actually produced work; corpses are
2042
2218
  // excluded from arbitration upstream and from the fallback here.
@@ -2049,9 +2225,18 @@ export class Orchestrator {
2049
2225
  const actualReviewVerified = winnerRun
2050
2226
  ? (evidences.find((e) => e.attemptId === winnerRun.attemptId)?.reviewVerified ?? false)
2051
2227
  : evidences.length > 0 && evidences.every((e) => e.reviewVerified);
2052
- let status = needsHuman && result.decision.status !== "success" ? "blocked" : result.decision.status;
2053
- if (status === "success" && ledger.terminal() !== null)
2054
- 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
+ }
2055
2240
  // FinalVerifier blocks adoption until the patch and gates pass on a fresh base.
2056
2241
  let finalVerify = null;
2057
2242
  let finalVerifyFailed = false;
@@ -2065,20 +2250,24 @@ export class Orchestrator {
2065
2250
  !inPlaceWinner &&
2066
2251
  !deferredRaceVerify &&
2067
2252
  winnerRun.diff.trim().length > 0 &&
2068
- (status === "success" || status === "ungated") &&
2253
+ facts.lifecycle === "succeeded" &&
2254
+ facts.review !== "blocked" &&
2069
2255
  !input.signal?.aborted) {
2070
2256
  finalVerify = await finalVerifyPatch(execRoot, winnerRun, gateSpecsFromContract(contract), log);
2071
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).
2072
2259
  finalVerifyFailed = finalVerifyBlocks(finalVerify);
2073
2260
  if (finalVerifyFailed)
2074
- status = "blocked";
2261
+ facts = { ...facts, checks: "failed", reason: "checks_failed" };
2075
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";
2076
2266
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), {
2077
2267
  ...result.decision,
2078
- // A blocked terminal overrides the persisted green arbitration fields.
2079
- ...(status === "blocked"
2268
+ ...(needsDec
2080
2269
  ? blockedDecisionOverride(result.decision.evidence_facts, finalVerify)
2081
- : {}),
2270
+ : { facts }),
2082
2271
  review_verified: actualReviewVerified,
2083
2272
  final_verify: finalVerify,
2084
2273
  });
@@ -2124,8 +2313,9 @@ export class Orchestrator {
2124
2313
  answerText: winnerAnswer,
2125
2314
  });
2126
2315
  }
2127
- // Only a fully verified success may auto-adopt; ungated remains an artifact.
2128
- 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";
2129
2319
  let adopted = null;
2130
2320
  let applyState = "not_applied";
2131
2321
  let postTurnSha = null;
@@ -2169,7 +2359,7 @@ export class Orchestrator {
2169
2359
  adopted = false;
2170
2360
  applyState = "not_applied";
2171
2361
  deliveryFailureReason = applied.detail ?? "race adoption delivery was refused";
2172
- status = "blocked";
2362
+ facts = { ...facts, checks: "failed", reason: "checks_failed" };
2173
2363
  if (finalVerifyBlocks(finalVerify))
2174
2364
  finalVerifyFailed = true;
2175
2365
  log.emit("work_product.adopted", {
@@ -2183,7 +2373,7 @@ export class Orchestrator {
2183
2373
  }
2184
2374
  writeRaceDeliveryDecision(store, decisionPath, {
2185
2375
  decision: result.decision,
2186
- status,
2376
+ facts,
2187
2377
  reviewVerified: actualReviewVerified,
2188
2378
  finalVerify,
2189
2379
  deliveryFailureReason,
@@ -2204,8 +2394,9 @@ export class Orchestrator {
2204
2394
  harness_id: winnerRun.harnessId,
2205
2395
  synthesis: synth,
2206
2396
  mode,
2207
- // Artifact-only apply reads the same terminal status as the daemon.
2208
- status,
2397
+ // Artifact-only apply reads the same terminal axes as the daemon (D8).
2398
+ lifecycle: facts.lifecycle,
2399
+ outcome_facts: facts,
2209
2400
  review_verified: actualReviewVerified,
2210
2401
  budget_stopped: budgetStopped,
2211
2402
  patch_sha256: patchSha256,
@@ -2223,18 +2414,31 @@ export class Orchestrator {
2223
2414
  revert_anchor_id: revertAnchorId,
2224
2415
  },
2225
2416
  });
2226
- store.writeText(join(paths.finalDir, "summary.md"), renderSummary(runId, mode, { ...result.decision, status }, evidences, synth.reason, actualReviewVerified));
2227
- // 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.
2228
2422
  log.emit("output.ready", {
2229
2423
  kind: "summary",
2230
2424
  path: "final/summary.md",
2231
- state: status === "success" || winnerAnswer.length > 0 ? "ready" : "diagnostic",
2425
+ state: (facts.lifecycle === "succeeded" &&
2426
+ facts.review === "approved" &&
2427
+ facts.checks !== "failed") ||
2428
+ winnerAnswer.length > 0
2429
+ ? "ready"
2430
+ : "diagnostic",
2232
2431
  });
2233
2432
  }
2234
- 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"
2235
2434
  ? result.decision.winner
2236
2435
  : (winnerRun?.attemptId ?? null));
2237
- 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;
2238
2442
  if (deliveryFailureReason && !finalVerifyFailed) {
2239
2443
  writeFailure(store, paths, deliveryRefusalFailure(deliveryFailureReason, paths.root));
2240
2444
  }
@@ -2259,14 +2463,14 @@ export class Orchestrator {
2259
2463
  ],
2260
2464
  });
2261
2465
  }
2262
- else if (status !== "success" && !honestTerminal) {
2466
+ else if (isFailureTerminal) {
2263
2467
  writeFailure(store, paths, {
2264
2468
  phase: needsHuman ? "review" : "arbitration",
2265
2469
  category: needsHuman
2266
2470
  ? "policy"
2267
2471
  : winnerRun?.errored
2268
2472
  ? "harness_error"
2269
- : isBudgetTerminal(status)
2473
+ : isBudgetTerminal(facts.reason)
2270
2474
  ? "budget"
2271
2475
  : "internal",
2272
2476
  harnessId: winnerRun?.errored ? winnerRun.harnessId : undefined,
@@ -2291,7 +2495,7 @@ export class Orchestrator {
2291
2495
  ],
2292
2496
  });
2293
2497
  if (!winnerRun) {
2294
- 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`);
2295
2499
  log.emit("output.ready", {
2296
2500
  kind: "summary",
2297
2501
  path: "final/summary.md",
@@ -2300,14 +2504,15 @@ export class Orchestrator {
2300
2504
  }
2301
2505
  }
2302
2506
  log.emit("work_product.emitted", { winner: result.decision.winner });
2303
- if (status === "success" || honestTerminal) {
2304
- log.emit("run.completed", { status, outcome: result.decision.outcome });
2507
+ if (!isFailureTerminal) {
2508
+ log.emit("run.completed", { lifecycle: facts.lifecycle, facts, reason: facts.reason });
2305
2509
  }
2306
- else if (status === "blocked") {
2307
- // The event's phase must agree with failure.yaml (a verify block is
2308
- // 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").
2309
2513
  log.emit("run.blocked", {
2310
- status,
2514
+ lifecycle: facts.lifecycle,
2515
+ facts,
2311
2516
  phase: deliveryFailureReason && !finalVerifyFailed
2312
2517
  ? "delivery"
2313
2518
  : finalVerifyFailed
@@ -2317,13 +2522,20 @@ export class Orchestrator {
2317
2522
  });
2318
2523
  }
2319
2524
  else {
2320
- 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
+ });
2321
2532
  }
2322
2533
  return {
2323
2534
  runId,
2324
2535
  taskId,
2325
2536
  mode,
2326
- status,
2537
+ lifecycle: facts.lifecycle,
2538
+ facts,
2327
2539
  winner: result.decision.winner,
2328
2540
  runDir: paths.root,
2329
2541
  summary: result.decision.why_winner,
@@ -2531,6 +2743,7 @@ export class Orchestrator {
2531
2743
  const store = this.artifactStore(input);
2532
2744
  const paths = store.createRun(runId);
2533
2745
  const log = new EventLog(paths.eventsPath, runId, taskId, input.onEvent, input.threadId);
2746
+ input = this.withPlanBrief(input, store, paths, log);
2534
2747
  // The execution root is the tree the harness mutates (thread worktree for an
2535
2748
  // isolated thread, else the project). The WorkspaceManager AND the git
2536
2749
  // boundary must resolve against the SAME root — the race path does so via the
@@ -2563,7 +2776,8 @@ export class Orchestrator {
2563
2776
  runId,
2564
2777
  taskId,
2565
2778
  mode,
2566
- status: "failed",
2779
+ lifecycle: "failed",
2780
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
2567
2781
  winner: null,
2568
2782
  runDir: paths.root,
2569
2783
  summary: gitPreconditionError,
@@ -2585,7 +2799,7 @@ export class Orchestrator {
2585
2799
  // One envelope carried forward across attempts so the harness can repair its own work.
2586
2800
  let adapterPool;
2587
2801
  try {
2588
- adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger);
2802
+ adapterPool = await this.resolveCandidateAdapters({ ...input, n: undefined }, this.candidateIntent(input), ledger, log);
2589
2803
  this.requestRequirements.assertConvergenceWorkspace(input.inPlace === true, adapterPool);
2590
2804
  }
2591
2805
  catch (err) {
@@ -2597,10 +2811,12 @@ export class Orchestrator {
2597
2811
  safeMessage: message,
2598
2812
  runDir: paths.root,
2599
2813
  });
2600
- 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`);
2601
2815
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
2602
2816
  log.emit("run.failed", {
2603
- status: "failed",
2817
+ lifecycle: "failed",
2818
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
2819
+ reason: "harness_failed",
2604
2820
  phase: "routing",
2605
2821
  error: message,
2606
2822
  failure_ref: "final/failure.yaml",
@@ -2610,7 +2826,8 @@ export class Orchestrator {
2610
2826
  runId,
2611
2827
  taskId,
2612
2828
  mode,
2613
- status: "failed",
2829
+ lifecycle: "failed",
2830
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
2614
2831
  winner: null,
2615
2832
  runDir: paths.root,
2616
2833
  summary: message,
@@ -2634,10 +2851,12 @@ export class Orchestrator {
2634
2851
  "Check harness doctor for reviewer readiness",
2635
2852
  ],
2636
2853
  });
2637
- 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`);
2638
2855
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
2639
2856
  log.emit("run.failed", {
2640
- status: "failed",
2857
+ lifecycle: "failed",
2858
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
2859
+ reason: "harness_failed",
2641
2860
  phase: "review",
2642
2861
  error: message,
2643
2862
  failure_ref: "final/failure.yaml",
@@ -2647,7 +2866,8 @@ export class Orchestrator {
2647
2866
  runId,
2648
2867
  taskId,
2649
2868
  mode,
2650
- status: "failed",
2869
+ lifecycle: "failed",
2870
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
2651
2871
  winner: null,
2652
2872
  runDir: paths.root,
2653
2873
  summary: message,
@@ -3019,17 +3239,18 @@ export class Orchestrator {
3019
3239
  if (envelope)
3020
3240
  await wsm.dispose(envelope);
3021
3241
  }
3022
- let status = input.signal?.aborted
3023
- ? "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" })
3024
3247
  : converged
3025
- ? "success"
3248
+ ? makeOutcomeFacts("succeeded")
3026
3249
  : stuckNoProgress
3027
- ? "stuck_no_progress"
3250
+ ? makeOutcomeFacts("failed", { reason: "stuck_no_progress" })
3028
3251
  : exhausted
3029
- ? "exhausted"
3030
- : "not_converged";
3031
- if (status === "success" && ledger.terminal() !== null)
3032
- status = ledger.terminal();
3252
+ ? makeOutcomeFacts("failed", { reason: "budget_exhausted" })
3253
+ : makeOutcomeFacts("failed", { reason: "not_converged" });
3033
3254
  let decision = null;
3034
3255
  if (lastRun) {
3035
3256
  const arb = arbitrate([
@@ -3040,17 +3261,29 @@ export class Orchestrator {
3040
3261
  });
3041
3262
  decision = arb.decision;
3042
3263
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
3043
- if (converged && decision.status !== "not_converged") {
3044
- 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;
3045
3269
  }
3046
- else if (status === "not_converged" && decision.status !== "success") {
3047
- status = decision.status;
3270
+ else if (facts.reason === "not_converged" && decision.facts.lifecycle === "succeeded") {
3271
+ facts = decision.facts;
3048
3272
  }
3049
3273
  }
3050
- // 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.
3051
3283
  const needsHuman = lastFindings.some((f) => f.severity === "NEEDS_HUMAN" && isBlocking(f));
3052
- if (needsHuman && status !== "success" && status !== "cancelled")
3053
- status = "blocked";
3284
+ if (needsHuman && facts.lifecycle === "succeeded" && facts.review !== "blocked") {
3285
+ facts = { ...facts, review: "blocked", reason: facts.reason ?? "review_blocked" };
3286
+ }
3054
3287
  // FinalVerifier (INV-115) applies to EVERY applyable envelope-mode patch,
3055
3288
  // not only race winners: a convergence run's delivered patch must also
3056
3289
  // survive a fresh tree at its own base + the deterministic gates there.
@@ -3061,20 +3294,22 @@ export class Orchestrator {
3061
3294
  if (input.inPlace !== true &&
3062
3295
  lastRun &&
3063
3296
  lastRun.diff.trim().length > 0 &&
3064
- (status === "success" || status === "ungated") &&
3297
+ facts.lifecycle === "succeeded" &&
3298
+ facts.review !== "blocked" &&
3065
3299
  !input.signal?.aborted) {
3066
3300
  convFinalVerify = await finalVerifyPatch(execRoot, lastRun, gateSpecsFromContract(contract), log);
3067
3301
  if (finalVerifyBlocks(convFinalVerify))
3068
- status = "blocked";
3302
+ facts = { ...facts, checks: "failed", reason: "checks_failed" };
3069
3303
  }
3304
+ const convNeedsDecision = facts.review === "blocked" || facts.checks === "failed";
3070
3305
  if (decision) {
3071
- // Shared honesty owner (same as the race path): a blocked terminal
3306
+ // Shared honesty owner (same as the race path): a needs-decision terminal
3072
3307
  // overrides the persisted decision; final_verify is recorded either way.
3073
3308
  decision = {
3074
3309
  ...decision,
3075
- ...(status === "blocked"
3310
+ ...(convNeedsDecision
3076
3311
  ? blockedDecisionOverride(decision.evidence_facts, convFinalVerify)
3077
- : {}),
3312
+ : { facts }),
3078
3313
  final_verify: convFinalVerify,
3079
3314
  };
3080
3315
  store.writeYaml(join(paths.arbitrationDir, "decision.yaml"), decision);
@@ -3089,7 +3324,7 @@ export class Orchestrator {
3089
3324
  // convergence run with inPlace mutated the live tree directly across its
3090
3325
  // attempts, so it is "applied" even when review blocked (Revert offered).
3091
3326
  const convHasDiff = lastRun.diff.trim().length > 0;
3092
- const convAdoptable = status === "success" || status === "ungated";
3327
+ const convAdoptable = facts.lifecycle === "succeeded" && facts.review === "approved" && facts.checks !== "failed";
3093
3328
  const convAdopted = input.inPlace === true && convHasDiff ? true : null;
3094
3329
  const convApplyState = convAdopted === true
3095
3330
  ? convAdoptable
@@ -3109,7 +3344,8 @@ export class Orchestrator {
3109
3344
  result_kind: "patch",
3110
3345
  mode,
3111
3346
  attempts: attempt,
3112
- status,
3347
+ lifecycle: facts.lifecycle,
3348
+ outcome_facts: facts,
3113
3349
  review_verified: actualReviewVerified,
3114
3350
  patch_sha256: patchSha256,
3115
3351
  adopted: convAdopted,
@@ -3119,42 +3355,45 @@ export class Orchestrator {
3119
3355
  revert_anchor_id: revertAnchorId,
3120
3356
  },
3121
3357
  });
3122
- 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`);
3123
3359
  // Lifecycle invariant (all modes): output.ready precedes the terminal
3124
3360
  // event so a client that applied the terminal event has the output.
3125
3361
  log.emit("output.ready", {
3126
3362
  kind: "summary",
3127
3363
  path: "final/summary.md",
3128
- ...(status === "success" ? {} : { state: "diagnostic" }),
3364
+ ...(convAdoptable ? {} : { state: "diagnostic" }),
3129
3365
  });
3130
3366
  }
3131
- 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) {
3132
3371
  writeFailure(store, paths, {
3133
- phase: "convergence",
3134
- category: isBudgetTerminal(status)
3372
+ phase: convNeedsDecision ? "review" : "convergence",
3373
+ category: isBudgetTerminal(facts.reason)
3135
3374
  ? "budget"
3136
- : status === "cancelled"
3375
+ : facts.lifecycle === "cancelled"
3137
3376
  ? "cancelled"
3138
- : status === "blocked"
3377
+ : convNeedsDecision
3139
3378
  ? "policy"
3140
3379
  : "internal",
3141
- safeMessage: status === "blocked"
3380
+ safeMessage: convNeedsDecision
3142
3381
  ? `review escalated to a human decision after ${attempt} attempt(s)`
3143
- : status === "stuck_no_progress"
3382
+ : facts.reason === "stuck_no_progress"
3144
3383
  ? (stuckNoProgressReason ?? `stuck_no_progress after ${attempt} attempt(s)`)
3145
- : `${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)"}`,
3146
3385
  harnessId: lastRun?.harnessId,
3147
3386
  attemptId: lastRun?.attemptId,
3148
3387
  runDir: paths.root,
3149
- nextActions: status === "cancelled"
3388
+ nextActions: facts.lifecycle === "cancelled"
3150
3389
  ? ["Retry if cancellation was accidental"]
3151
- : status === "blocked"
3390
+ : convNeedsDecision
3152
3391
  ? [
3153
3392
  "Open the review queue",
3154
3393
  "Decide the NEEDS_HUMAN findings",
3155
3394
  "Re-run after the decision",
3156
3395
  ]
3157
- : status === "stuck_no_progress"
3396
+ : facts.reason === "stuck_no_progress"
3158
3397
  ? [
3159
3398
  "Inspect the stable patch",
3160
3399
  "Inspect the failing gate output",
@@ -3167,7 +3406,7 @@ export class Orchestrator {
3167
3406
  ],
3168
3407
  });
3169
3408
  if (!lastRun) {
3170
- 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`);
3171
3410
  log.emit("output.ready", {
3172
3411
  kind: "summary",
3173
3412
  path: "final/summary.md",
@@ -3176,13 +3415,18 @@ export class Orchestrator {
3176
3415
  }
3177
3416
  }
3178
3417
  log.emit("work_product.emitted", { winner: lastRun?.attemptId ?? null });
3179
- const completed = converged || status === "no_op" || status === "ungated" || status === "review_not_run";
3180
- if (completed) {
3181
- 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
+ });
3182
3425
  }
3183
- else if (status === "blocked") {
3426
+ else if (facts.lifecycle === "succeeded") {
3184
3427
  log.emit("run.blocked", {
3185
- status,
3428
+ lifecycle: facts.lifecycle,
3429
+ facts,
3186
3430
  attempts: attempt,
3187
3431
  phase: "review",
3188
3432
  failure_ref: "final/failure.yaml",
@@ -3190,7 +3434,9 @@ export class Orchestrator {
3190
3434
  }
3191
3435
  else {
3192
3436
  log.emit("run.failed", {
3193
- status,
3437
+ lifecycle: facts.lifecycle,
3438
+ facts,
3439
+ reason: facts.reason,
3194
3440
  attempts: attempt,
3195
3441
  phase: "convergence",
3196
3442
  failure_ref: "final/failure.yaml",
@@ -3201,19 +3447,26 @@ export class Orchestrator {
3201
3447
  runId,
3202
3448
  taskId,
3203
3449
  mode,
3204
- status,
3450
+ lifecycle: facts.lifecycle,
3451
+ facts,
3205
3452
  winner: lastRun?.attemptId ?? null,
3206
3453
  runDir: paths.root,
3207
3454
  summary: converged
3208
3455
  ? `converged in ${attempt} attempt(s)`
3209
- : `${status} after ${attempt} attempt(s)`,
3456
+ : `${facts.lifecycle} after ${attempt} attempt(s)`,
3210
3457
  candidates: lastRun
3211
- ? [{ attemptId: lastRun.attemptId, harnessId: lastRun.harnessId, status }]
3458
+ ? [
3459
+ {
3460
+ attemptId: lastRun.attemptId,
3461
+ harnessId: lastRun.harnessId,
3462
+ status: facts.lifecycle,
3463
+ },
3464
+ ]
3212
3465
  : [],
3213
3466
  reviewVerified: actualReviewVerified,
3214
3467
  };
3215
3468
  }
3216
- /** 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. */
3217
3470
  /**
3218
3471
  * Wrap the user's goal in an explicit "plan, do not implement" instruction.
3219
3472
  * Without this the raw prompt ("make a racing game") reaches the harness with
@@ -3232,10 +3485,222 @@ export class Orchestrator {
3232
3485
  `1. Approach — 2-3 sentences on how you'd solve this.`,
3233
3486
  `2. Steps — a numbered list; each step names the file(s) it touches and what changes.`,
3234
3487
  `3. Risks & edge cases.`,
3235
- `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
+ ``,
3236
3500
  `Keep it concise. Reference real paths you found. Do NOT paste large code blocks; describe the change instead.`,
3237
3501
  ].join("\n");
3238
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
+ }
3239
3704
  async runPlan(input, announce) {
3240
3705
  const taskId = input.taskId ?? newId("task");
3241
3706
  const runId = input.runId ?? newId("run");
@@ -3260,16 +3725,19 @@ export class Orchestrator {
3260
3725
  });
3261
3726
  store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
3262
3727
  log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
3263
- const reviewersOutcome = await this.resolveReviewersWithArtifacts(input, log, store, paths, runId, taskId, "plan");
3264
- if ("failed" in reviewersOutcome)
3265
- return reviewersOutcome.failed;
3266
- const reviewers = reviewersOutcome.reviewers;
3267
3728
  // W3.3: ONE resolved read-only context — the routing point-probe and every
3268
- // 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.
3269
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);
3270
3738
  let adapters;
3271
3739
  try {
3272
- adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, roHome);
3740
+ adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, log, roHome);
3273
3741
  }
3274
3742
  catch (err) {
3275
3743
  roHome.dispose();
@@ -3281,10 +3749,12 @@ export class Orchestrator {
3281
3749
  safeMessage: message,
3282
3750
  runDir: paths.root,
3283
3751
  });
3284
- 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`);
3285
3753
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
3286
3754
  log.emit("run.failed", {
3287
- status: "failed",
3755
+ lifecycle: "failed",
3756
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
3757
+ reason: "harness_failed",
3288
3758
  phase: "routing",
3289
3759
  error: message,
3290
3760
  failure_ref: "final/failure.yaml",
@@ -3294,7 +3764,8 @@ export class Orchestrator {
3294
3764
  runId,
3295
3765
  taskId,
3296
3766
  mode: "plan",
3297
- status: "failed",
3767
+ lifecycle: "failed",
3768
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
3298
3769
  winner: null,
3299
3770
  runDir: paths.root,
3300
3771
  summary: message,
@@ -3316,10 +3787,12 @@ export class Orchestrator {
3316
3787
  safeMessage: message,
3317
3788
  runDir: paths.root,
3318
3789
  });
3319
- 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`);
3320
3791
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
3321
3792
  log.emit("run.failed", {
3322
- status: "failed",
3793
+ lifecycle: "failed",
3794
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
3795
+ reason: "harness_failed",
3323
3796
  phase: "context",
3324
3797
  error: message,
3325
3798
  failure_ref: "final/failure.yaml",
@@ -3329,188 +3802,106 @@ export class Orchestrator {
3329
3802
  runId,
3330
3803
  taskId,
3331
3804
  mode: "plan",
3332
- status: "failed",
3805
+ lifecycle: "failed",
3806
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
3333
3807
  winner: null,
3334
3808
  runDir: paths.root,
3335
3809
  summary: `context failed: ${message}`,
3336
3810
  candidates: [],
3337
3811
  };
3338
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
+ }
3339
3831
  const plans = [];
3832
+ let fallbackFrom = null;
3340
3833
  const planAttempts = [];
3341
3834
  const attemptTelemetries = [];
3342
3835
  try {
3343
3836
  for (const [idx, routed] of adapters.entries()) {
3344
3837
  if (input.signal?.aborted)
3345
3838
  break;
3346
- const adapter = routed.adapter;
3347
3839
  const attemptId = `p${String(idx + 1).padStart(2, "0")}`;
3348
- const lease = ledger.reserve({
3840
+ const outcome = await this.runPlannerAttempt({
3841
+ input,
3842
+ contract,
3349
3843
  taskId,
3844
+ runId,
3845
+ log,
3846
+ store,
3847
+ paths,
3848
+ ledger,
3849
+ routed,
3350
3850
  attemptId,
3851
+ laneRun,
3852
+ fallbackHome: roHome.env,
3853
+ promptBody: this.planPrompt(input.prompt) + contextSection,
3351
3854
  intent: "plan",
3352
- harnessId: adapter.id,
3353
- cost: attemptCostEvidence(adapter.id, attemptId, undefined, this.routeBillingKnowledge(input, adapter.id)),
3354
3855
  });
3355
- if (!lease.granted) {
3356
- log.emit("budget.lease.created", {
3357
- granted: false,
3358
- reason: lease.reason,
3359
- attempt_id: attemptId,
3360
- harness_id: adapter.id,
3361
- });
3856
+ if (outcome.budgetDenied)
3362
3857
  break;
3363
- }
3364
- const knobs = this.routeSpecKnobs(routed, contract, undefined, input.effort);
3365
- const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
3366
- const spec = HarnessRunSpec.parse({
3367
- session_id: newId("ses"),
3368
- intent: "plan",
3369
- prompt: this.planPrompt(input.prompt) + contextSection + relayPriorPlansSection(plans),
3370
- cwd: this.execRootOf(input),
3371
- access: "readonly",
3372
- // Planners must SEE any image/file the user attached (e.g. "plan a fix for
3373
- // what's in this screenshot"), not just agent/race runs.
3374
- attachments: input.attachments ?? [],
3375
- ...this.sessionSpecFields(input, adapter.id, log),
3376
- ...this.harnessSpecKnobs(contract, knobs, "plan"),
3377
- env_inheritance: envInheritance(this.config(input.repoRoot)),
3378
- env: roHome.env,
3379
- });
3380
- const plannerAbort = new AbortController();
3381
- spec.extra["abortSignal"] = input.signal
3382
- ? AbortSignal.any([input.signal, plannerAbort.signal])
3383
- : plannerAbort.signal;
3384
- const planInteraction = this.interactionChannelFor(input, log, runId, taskId, attemptId, adapter.id, routed.supportsInteractive);
3385
- if (planInteraction)
3386
- spec.extra["interactionChannel"] = planInteraction;
3387
- const attemptEventsPath = join(paths.attemptsDir, attemptId, "events.jsonl");
3388
- const answer = new AnswerAssembly();
3389
- const telemetry = createAttemptTelemetry(knobs.webPolicy, contract.external_context.web_required ||
3390
- knobs.webPolicy === "cached" ||
3391
- knobs.webPolicy === "live", effectiveWeb, [],
3392
- // Requested-model capture: a plan lane silently downgraded to another
3393
- // model surfaces the mismatch in its route receipt, just like agent.
3394
- knobs.model);
3395
- const onAbort = () => {
3396
- void adapter.cancel?.(spec.session_id)?.catch(() => { });
3397
- };
3398
- if (input.signal) {
3399
- if (input.signal.aborted)
3400
- onAbort();
3401
- else
3402
- input.signal.addEventListener("abort", onAbort, { once: true });
3403
- }
3404
- let cost = 0;
3405
- let costEstimated = false;
3406
- let harnessError = null;
3407
- const budgetSignalState = { quotaPressureDisclosed: false };
3408
- try {
3409
- log.emit("harness.started", {
3410
- harness_id: adapter.id,
3411
- attempt_id: attemptId,
3412
- external_context_policy: knobs.webPolicy,
3413
- ...(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,
3414
3863
  });
3415
- if (!input.signal?.aborted) {
3416
- const watchedPlan = withInactivityWatchdog(adapter.run(spec), {
3417
- timeoutMs: harnessInactivityTimeoutMs(this.config(input.repoRoot)),
3418
- onTimeout: () => {
3419
- plannerAbort.abort();
3420
- void adapter.cancel?.(spec.session_id)?.catch(() => { });
3421
- },
3422
- 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",
3423
3879
  });
3424
- for await (const ev of watchedPlan) {
3425
- if (input.signal?.aborted)
3426
- break;
3427
- const safeEv = redactHarnessEvent(ev);
3428
- safeInvoke(input.onHarnessEvent, safeEv);
3429
- // NOT observed for resume: a read-only planner is not a chat turn,
3430
- // and attaching its session id would poison thread continuity (and
3431
- // race parallel planner/reviewer sessions), regardless of whether
3432
- // the vendor stored that session in the scoped or native store.
3433
- observeAuthSwitch(log, adapter.id, attemptId, safeEv);
3434
- log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
3435
- appendLine(attemptEventsPath, JSON.stringify(safeEv));
3436
- observeAttemptTelemetry(telemetry, safeEv);
3437
- if (safeEv.plan_progress) {
3438
- log.emit("plan.progress", {
3439
- attempt_id: attemptId,
3440
- harness_id: adapter.id,
3441
- items: safeEv.plan_progress.items,
3442
- });
3443
- }
3444
- // read-only routes burn quota too (the orchestrate PLANNER is
3445
- // the loudest) — same single owner as the agent loop.
3446
- observeBudgetSignals(ledger, log, adapter.id, attemptId, safeEv, budgetSignalState);
3447
- this.deps.quotaEventSink?.(adapter.id, safeEv);
3448
- if (safeEv.type === "usage" && safeEv.usage?.cost_usd) {
3449
- cost += safeEv.usage.cost_usd;
3450
- if (safeEv.usage.estimated)
3451
- costEstimated = true;
3452
- log.emit("budget.observation", {
3453
- harness_id: adapter.id,
3454
- attempt_id: attemptId,
3455
- kind: "spend",
3456
- usd: safeEv.usage.cost_usd,
3457
- estimated: safeEv.usage.estimated === true,
3458
- });
3459
- }
3460
- // A TYPED final message wins verbatim over joined narration.
3461
- answer.observe(safeEv);
3462
- if (safeEv.type === "error")
3463
- harnessError = safeEv.error
3464
- ? redactSecrets(safeEv.error)
3465
- : "harness emitted an error";
3466
- }
3467
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;
3468
3889
  }
3469
- catch (err) {
3470
- harnessError = safeErrorMessage(err);
3471
- }
3472
- finally {
3473
- input.signal?.removeEventListener("abort", onAbort);
3474
- ledger.settle(lease.lease?.lease_id ?? "", attemptUsageCostSettlement(cost, costEstimated, attemptId, adapter.id, telemetry.authMode, telemetry.usageCost));
3475
- }
3476
- attemptTelemetries.push({ attemptId, harnessId: adapter.id, telemetry });
3477
- const unrecovered = unrecoveredToolErrors(telemetry);
3478
- const webBlocked = webUnsatisfied(telemetry);
3479
- if (!harnessError && webBlocked) {
3480
- harnessError = `web evidence unsatisfied: ${telemetry.web.errorSummary ?? (telemetry.web.attempted ? "web tool failed without verified recovery" : "web evidence required but never attempted")}`;
3481
- }
3482
- if (!harnessError && unrecovered.length > 0) {
3483
- const first = unrecovered[0];
3484
- harnessError = `${first.tool} failed without recovery: ${first.summary}`;
3485
- }
3486
- if (harnessError) {
3487
- // One failed planner does not abort a multi-harness plan; the run fails
3488
- // only when EVERY planner fails (parity with explore).
3489
- log.emit("harness.completed", {
3490
- 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,
3491
3899
  attempt_id: attemptId,
3492
- status: webBlocked ? "blocked" : "failed",
3493
- error: harnessError,
3494
- ...telemetrySummary(telemetry),
3495
- });
3496
- planAttempts.push({
3497
- attemptId,
3498
- harnessId: adapter.id,
3499
- status: webBlocked ? "blocked" : "failed",
3500
- error: harnessError,
3900
+ status: "success",
3901
+ reason: "planner_failed",
3501
3902
  });
3502
- continue;
3503
3903
  }
3504
- const text = answer.text() || "(no output)";
3505
- log.emit("harness.completed", {
3506
- harness_id: adapter.id,
3507
- attempt_id: attemptId,
3508
- status: "success",
3509
- ...telemetrySummary(telemetry),
3510
- });
3511
- planAttempts.push({ attemptId, harnessId: adapter.id, status: "success", error: null });
3512
- plans.push({ id: adapter.id, text });
3513
- store.writeText(join(paths.root, "plans", `${adapter.id}.md`), redactSecrets(text) + "\n");
3904
+ break;
3514
3905
  }
3515
3906
  }
3516
3907
  finally {
@@ -3525,117 +3916,18 @@ export class Orchestrator {
3525
3916
  })), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend(), input.signal, store);
3526
3917
  }
3527
3918
  if (plans.length === 0) {
3528
- const blocked = planAttempts.some((p) => p.status === "blocked");
3529
- const message = planAttempts
3530
- .map((p) => `${p.attemptId}/${p.harnessId}: ${p.error ?? "failed"}`)
3531
- .join("\n") || "all planners failed";
3532
- this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null);
3533
- store.writeText(join(paths.contextDir, "context_error.md"), `# Harness Error\n\n${message}\n`);
3534
- writeFailure(store, paths, {
3535
- phase: "harness",
3536
- category: blocked ? "policy" : "harness_error",
3537
- safeMessage: message,
3538
- eventRefs: planAttempts.map((p) => `attempts/${p.attemptId}/events.jsonl`),
3539
- runDir: paths.root,
3540
- nextActions: ["Open diagnostics", "Check harness authentication", "Retry after setup"],
3541
- });
3542
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (plan)\n\n- Status: ${blocked ? "blocked" : "failed"}\n\n${message}\n`);
3543
- log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
3544
- if (blocked)
3545
- log.emit("run.blocked", {
3546
- status: "blocked",
3547
- phase: "harness",
3548
- error: message,
3549
- failure_ref: "final/failure.yaml",
3550
- });
3551
- else
3552
- log.emit("run.failed", {
3553
- status: "failed",
3554
- phase: "harness",
3555
- error: message,
3556
- failure_ref: "final/failure.yaml",
3557
- });
3558
- return {
3559
- spendUsd: ledger.spend(),
3560
- runId,
3561
- taskId,
3562
- mode: "plan",
3563
- status: blocked ? "blocked" : "failed",
3564
- winner: null,
3565
- runDir: paths.root,
3566
- summary: message,
3567
- candidates: planAttempts.map((p) => ({
3568
- attemptId: p.attemptId,
3569
- harnessId: p.harnessId,
3570
- status: p.status,
3571
- })),
3572
- };
3573
- }
3574
- let ambiguities = [];
3575
- let reviewFindings = [];
3576
- if (reviewers.length > 0 && plans.length > 0) {
3577
- const reviewDir = join(paths.root, "review-evidence");
3578
- const planEvidence = plans.map((p) => `## Plan from ${p.id}\n${p.text}`).join("\n\n");
3579
- const planReviewDiff = "(plan review — no code diff)\n";
3580
- writeEvidencePacket(reviewDir, {
3581
- userIntent: redactSecrets(input.prompt),
3582
- planAccepted: planEvidence,
3583
- diff: planReviewDiff,
3584
- tests: renderTestsEvidence(contract),
3585
- });
3586
- // Reserve BEFORE spending: a hard budget tier must stop the paid plan
3587
- // review from starting, not account for it after the fact.
3588
- const lease = ledger.reserve({
3919
+ return writePlanHarnessFailure(this.planRunDeps(), {
3920
+ input,
3921
+ contract,
3589
3922
  taskId,
3590
- attemptId: "plan-review",
3591
- intent: "review",
3592
- harnessId: "review-panel",
3593
- cost: attemptCostEvidence("review-panel", "plan-review"),
3594
- });
3595
- if (lease.granted) {
3596
- const res = await this.reviewScoped({
3597
- candidateLabel: "Plan",
3598
- reviewSubject: "plan",
3599
- diff: planReviewDiff,
3600
- evidenceDir: reviewDir,
3601
- artifactsDir: join(paths.reviewsDir, "plan-reviewers"),
3602
- cwd: this.execRootOf(input),
3603
- reviewers,
3604
- envInheritance: envInheritance(this.config(input.repoRoot)),
3605
- signal: input.signal,
3606
- onReviewerEvent: (event) => log.emit(event.type, { ...event }),
3607
- });
3608
- reviewFindings = await revalidateFindings(res.findings, {
3609
- candidateRoot: this.execRootOf(input),
3610
- evidenceDir: reviewDir,
3611
- });
3612
- ambiguities = reviewFindings.filter((f) => f.category === "spec_gap" || f.severity === "NEEDS_HUMAN");
3613
- store.writeYaml(join(paths.reviewsDir, "plan-review.yaml"), {
3614
- findings: reviewFindings,
3615
- route_proofs: res.routeProofs,
3616
- reviewer_requests: res.reviewerRequests,
3617
- });
3618
- ledger.settle(lease.lease?.lease_id ?? "", reviewUsageCostSettlement(res.reviewCashUsd, res.reviewValuationUsd, res.reviewSpendEstimated, ["attempt:plan-review", "review:panel"], res.reviewUnknownUsd));
3619
- if ((res.reviewSpendUsd ?? 0) > 0) {
3620
- log.emit("budget.observation", {
3621
- harness_id: "review-panel",
3622
- kind: "spend",
3623
- usd: res.reviewSpendUsd,
3624
- cash_usd: res.reviewCashUsd,
3625
- valuation_usd: res.reviewValuationUsd,
3626
- unknown_usd: res.reviewUnknownUsd,
3627
- estimated: res.reviewSpendEstimated,
3628
- });
3629
- }
3630
- }
3631
- else {
3632
- log.emit("budget.lease.created", {
3633
- granted: false,
3634
- reason: lease.reason,
3635
- attempt_id: "plan-review",
3636
- harness_id: "review-panel",
3637
- });
3638
- }
3923
+ runId,
3924
+ store,
3925
+ paths,
3926
+ log,
3927
+ ledger,
3928
+ planAttempts,
3929
+ attemptTelemetries,
3930
+ }, "all planners failed");
3639
3931
  }
3640
3932
  if (input.signal?.aborted) {
3641
3933
  return cancelledResult(log, runId, taskId, "plan", paths.root, planAttempts.map((p) => ({
@@ -3644,132 +3936,69 @@ export class Orchestrator {
3644
3936
  status: p.status,
3645
3937
  })), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, null), ledger.spend(), input.signal, store);
3646
3938
  }
3647
- const failedPlanners = planAttempts.filter((p) => p.status !== "success");
3648
- // ALL review findings are shown (severity-marked), so a BLOCK like "the
3649
- // requested feature is not delivered" is visible on the plan itself — not
3650
- // silently filtered down to spec_gap/NEEDS_HUMAN the way v0.9 hid it.
3651
- const blockingFindings = reviewFindings.filter((f) => isBlocking(f));
3652
- const sevMark = {
3653
- BLOCK: "🔴 BLOCK",
3654
- FIX_FIRST: "🟠 FIX_FIRST",
3655
- NEEDS_HUMAN: "🟠 NEEDS_HUMAN",
3656
- };
3657
- const planDoc = [
3658
- `# Plan`,
3659
- "",
3660
- `## Goal`,
3661
- redactSecrets(input.prompt),
3662
- "",
3663
- `## Plan${plans.length > 1 ? "s" : ""} (${plans.length}/${planAttempts.length} planner${planAttempts.length === 1 ? "" : "s"})`,
3664
- ...plans.map((p) => `\n### Plan — ${p.id}\n${redactSecrets(p.text)}`),
3665
- ...(reviewFindings.length > 0
3666
- ? [
3667
- "",
3668
- "## Review findings",
3669
- ...reviewFindings.map((f) => `- ${sevMark[f.severity] ?? f.severity}: ${redactSecrets(f.claim)}`),
3670
- ]
3671
- : []),
3672
- ...(ambiguities.length > 0
3673
- ? ["", "## Open questions", ...ambiguities.map((a) => `- ${redactSecrets(a.claim)}`)]
3674
- : []),
3675
- ...(failedPlanners.length > 0
3676
- ? [
3677
- "",
3678
- "## Planner omissions",
3679
- ...failedPlanners.map((p) => `- ${p.attemptId} / ${p.harnessId} ${p.status}: ${p.error}`),
3680
- ]
3681
- : []),
3682
- "",
3683
- ].join("\n");
3684
- store.writeText(join(paths.finalDir, "plan.md"), planDoc + "\n");
3685
- // A plan is a delivered work product (a report), even with risks — parity
3686
- // with the other read-only modes (removes the "only successful mode with no
3687
- // work_product" anomaly). result_kind=plan tells surfaces NO files changed.
3688
- store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
3689
- id: newId("wp"),
3690
- kind: "report",
3691
- source_task_id: taskId,
3692
- producer_attempt_id: planAttempts.find((p) => p.status === "success")?.attemptId ?? null,
3693
- meta: {
3694
- mode: "plan",
3695
- result_kind: "plan",
3696
- planners: plans.length,
3697
- diffstat: { files: 0, additions: 0, deletions: 0 },
3698
- blockers: blockingFindings.length,
3699
- adopted: null,
3700
- },
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,
3701
3952
  });
3702
- // Canonical summary artifact (parity with every other mode's final/ layout).
3703
- 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` : ""}`);
3704
- this.writeRunTelemetry(store, paths, contract, runId, taskId, "plan", attemptTelemetries, planAttempts.find((p) => p.status === "success")?.attemptId ?? null);
3705
- log.emit("output.ready", { kind: "plan", path: "final/plan.md" });
3706
- 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() {
3707
3958
  return {
3708
- spendUsd: ledger.spend(),
3709
- runId,
3710
- taskId,
3711
- mode: "plan",
3712
- status: "success",
3713
- winner: null,
3714
- runDir: paths.root,
3715
- summary: `Plan from ${plans.length} planner(s); ${blockingFindings.length} blocker(s), ${ambiguities.length} open question(s).`,
3716
- candidates: planAttempts.map((p) => ({
3717
- attemptId: p.attemptId,
3718
- harnessId: p.harnessId,
3719
- status: p.status,
3720
- })),
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),
3721
3963
  };
3722
3964
  }
3723
3965
  /** ask: one selected harness answers read-only questions; no patch/apply controls. */
3724
3966
  async runAsk(input, announce) {
3725
3967
  return this.runReadOnlyReport(input, {
3726
3968
  mode: "ask",
3727
- swarm: false,
3969
+ deepScan: false,
3728
3970
  intent: "explain",
3729
3971
  title: "Answer",
3730
3972
  artifactName: "answer.md",
3731
3973
  defaultPrompt: "Answer the user's question.",
3732
3974
  }, announce);
3733
3975
  }
3734
- /** audit --swarm: bounded read-only research swarm (the old `explore` mode). */
3735
- async runExplore(input, announce) {
3736
- return this.runReadOnlyReport(input, {
3737
- mode: "audit",
3738
- swarm: true,
3739
- intent: "audit",
3740
- title: "Explore synthesis",
3741
- artifactName: "explore.md",
3742
- defaultPrompt: "Explore this repository and synthesize evidence-cited findings, omissions, and follow-up questions.",
3743
- }, announce);
3744
- }
3745
- /** audit: single read-only audit/map report. */
3746
- 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) {
3747
3979
  return this.runReadOnlyReport(input, {
3748
- mode: "audit",
3749
- swarm: false,
3980
+ mode: "ask",
3981
+ deepScan: true,
3750
3982
  intent: "audit",
3751
- title: "Audit report",
3983
+ title: "Deep scan synthesis",
3752
3984
  artifactName: "report.md",
3753
- defaultPrompt: "audit this repository",
3985
+ defaultPrompt: "Explore this repository and synthesize evidence-cited findings, omissions, and follow-up questions.",
3754
3986
  }, announce);
3755
3987
  }
3756
3988
  resolvePaidBudget(inputBudget, cfg) {
3757
3989
  return inputBudget ?? this.deps.paidBudget ?? cfg.global.budget.paid_budget_per_run;
3758
3990
  }
3759
- rootLedger(input, contract, log) {
3760
- // A passed-in ledger (orchestrate sub-runs) keeps its OWNER's cash
3761
- // disclosure the parent run owns the budget, so its event log gets the
3762
- // budget.cash events. A fresh root ledger discloses into THIS run's log:
3763
- // the ledger is the one owner of the cash fact (subscription-entitled
3764
- // work settles to 0 there), and the UI renders `budget.cash` verbatim
3765
- // never inferring money from route labels (W4.3 sol #15).
3766
- const ledger = input.budgetLedger ??
3767
- new BudgetLedger(contract.budget.paid_budget, undefined, {
3768
- onCashSettled: (cashSpendUsd, valuationUsd) => log.emit("budget.cash", {
3769
- cash_spend_usd: cashSpendUsd,
3770
- valuation_usd: valuationUsd,
3771
- }),
3772
- });
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
+ });
3773
4002
  for (const snapshot of this.deps.quotaSnapshots?.() ?? []) {
3774
4003
  ledger.observeQuotaSnapshot(snapshot);
3775
4004
  }
@@ -3788,55 +4017,12 @@ export class Orchestrator {
3788
4017
  ? "metered"
3789
4018
  : "unknown";
3790
4019
  }
3791
- async runOrchestrate(input, announce) {
3792
- // "Doctor-verified" must mean status ok — degraded key-present routes are
3793
- // excluded from the pool the planner plans over (readiness honesty).
3794
- const pool = await this.gateway.doctorOkReal({ cwd: input.repoRoot }, "orchestrate");
3795
- const crossFamily = pool.length >= 2;
3796
- const goal = input.prompt || "Plan the next move for this repository.";
3797
- // The typed orchestration contract is a REAL persisted artifact (producer
3798
- // here, consumers: the planner prompt below + the plan validator).
3799
- // Autonomy is producer-supplied (control-api/CLI -> daemon -> RunInput);
3800
- // the executor below is its consumer. Default `suggest` (plan-only) preserves
3801
- // the read-only contract when no autonomy is requested.
3802
- const autonomy = input.autonomy ?? "suggest";
3803
- const paidBudget = this.resolvePaidBudget(input.paidBudget, this.config(input.repoRoot));
3804
- const orchestrateContract = OrchestrateContractSchema.parse({
3805
- thread_id: input.threadId ?? newId("th"),
3806
- goal,
3807
- budget: { paid_budget: paidBudget, max_tool_calls: input.maxToolCalls ?? null },
3808
- autonomy,
3809
- });
3810
- const plannerPrompt = buildOrchestratePlannerPrompt(goal, pool, crossFamily, orchestrateContract);
3811
- return this.runReadOnlyReport(
3812
- // The executed pool is pinned to the PLANNED pool (no double doctor
3813
- // resolution drift between the prompt's claims and the actual route).
3814
- // The planner must NOT resume or overwrite the thread's conversational
3815
- // session — it speaks its own tool-belt framing, not the user's chat.
3816
- {
3817
- ...input,
3818
- resumeSessions: undefined,
3819
- onSessionObserved: undefined,
3820
- harnesses: input.harnesses ?? (pool.length > 0 ? pool : undefined),
3821
- prompt: plannerPrompt,
3822
- }, {
3823
- mode: "orchestrate",
3824
- swarm: false,
3825
- intent: "orchestrate",
3826
- title: "Orchestration plan",
3827
- artifactName: "orchestration.md",
3828
- defaultPrompt: plannerPrompt,
3829
- contractIntent: goal,
3830
- orchestrateContract,
3831
- }, announce);
3832
- }
3833
4020
  async runReadOnlyReport(input, opts, announce) {
3834
4021
  const taskId = input.taskId ?? newId("task");
3835
4022
  const runId = input.runId ?? newId("run");
3836
4023
  const prompt = input.prompt || opts.defaultPrompt;
3837
4024
  // Contract validation BEFORE the run is announced (see runRace). The
3838
- // recorded user intent is the CALLER's goal, not a synthesized wrapper
3839
- // prompt (orchestrate wraps the goal in a planner prompt).
4025
+ // recorded user intent is the CALLER's goal.
3840
4026
  const contract = this.buildContract({ ...input, prompt: opts.contractIntent ?? prompt }, taskId, opts.mode);
3841
4027
  const store = this.artifactStore(input);
3842
4028
  const paths = store.createRun(runId);
@@ -3856,61 +4042,32 @@ export class Orchestrator {
3856
4042
  });
3857
4043
  store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
3858
4044
  log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
3859
- if (opts.orchestrateContract) {
3860
- store.writeYaml(join(paths.contextDir, "orchestrate_contract.yaml"), opts.orchestrateContract);
3861
- }
3862
- // Lazy ContextPack: explore/audit attach the compact scope atlas; ask stays bare.
3863
- let contextSection = "";
3864
- if (opts.mode !== "ask") {
3865
- try {
3866
- contextSection = await this.lazyContextSection(input, contract, store, paths, log);
3867
- }
3868
- catch (err) {
3869
- const message = safeErrorMessage(err);
3870
- store.writeText(join(paths.contextDir, "context_error.md"), `# Context Error\n\n${message}\n`);
3871
- writeFailure(store, paths, {
3872
- phase: "context",
3873
- category: "project",
3874
- safeMessage: message,
3875
- runDir: paths.root,
3876
- });
3877
- store.writeText(join(paths.finalDir, "summary.md"), `# Run ${runId} (${opts.mode})\n\n- Status: failed\n- Phase: context\n\n${message}\n`);
3878
- log.emit("output.ready", {
3879
- kind: "summary",
3880
- path: "final/summary.md",
3881
- state: "diagnostic",
3882
- });
3883
- log.emit("run.failed", {
3884
- status: "failed",
3885
- phase: "context",
3886
- error: message,
3887
- failure_ref: "final/failure.yaml",
3888
- });
3889
- return {
3890
- runId,
3891
- taskId,
3892
- mode: opts.mode,
3893
- status: "failed",
3894
- winner: null,
3895
- runDir: paths.root,
3896
- summary: `context failed: ${message}`,
3897
- candidates: [],
3898
- };
3899
- }
3900
- }
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 = "";
3901
4048
  const externalContextPolicy = contract.external_context.policy;
3902
- const width = opts.swarm
4049
+ const width = opts.deepScan
3903
4050
  ? Math.min(Math.max(input.n ?? 4, 1), 8)
3904
4051
  : externalContextPolicy === "off"
3905
4052
  ? 1
3906
4053
  : Math.min(Math.max(input.n ?? 2, 1), 3);
3907
4054
  // W3.3: ONE resolved read-only context — the routing point-probe and every
3908
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.
3909
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;
3910
4067
  let adapters;
3911
4068
  try {
3912
- adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, roHome);
3913
- if (!opts.swarm) {
4069
+ adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, log, roHome);
4070
+ if (!opts.deepScan) {
3914
4071
  const seen = new Set();
3915
4072
  adapters = adapters.filter((routed) => {
3916
4073
  if (seen.has(routed.adapter.id))
@@ -3930,10 +4087,12 @@ export class Orchestrator {
3930
4087
  safeMessage: message,
3931
4088
  runDir: paths.root,
3932
4089
  });
3933
- 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`);
3934
4091
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
3935
4092
  log.emit("run.failed", {
3936
- status: "failed",
4093
+ lifecycle: "failed",
4094
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
4095
+ reason: "harness_failed",
3937
4096
  phase: "routing",
3938
4097
  error: message,
3939
4098
  failure_ref: "final/failure.yaml",
@@ -3942,7 +4101,8 @@ export class Orchestrator {
3942
4101
  runId,
3943
4102
  taskId,
3944
4103
  mode: opts.mode,
3945
- status: "failed",
4104
+ lifecycle: "failed",
4105
+ facts: makeOutcomeFacts("failed", { reason: "harness_failed" }),
3946
4106
  winner: null,
3947
4107
  runDir: paths.root,
3948
4108
  summary: message,
@@ -3983,17 +4143,26 @@ export class Orchestrator {
3983
4143
  }
3984
4144
  const knobs = this.routeSpecKnobs(routed, contract, modelOverride, input.effort);
3985
4145
  const effectiveWeb = this.discloseWebUpgrade(log, routed, knobs.webPolicy, attemptId);
3986
- const explorerPrompt = (opts.swarm
4146
+ const explorerPrompt = (opts.deepScan
3987
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.`
3988
4148
  : prompt) + contextSection;
3989
4149
  const sessionFields = this.sessionSpecFields(input, adapter.id, log);
3990
4150
  const grantResume = sessionFields.resume_session_id !== null && !resumeGranted.has(adapter.id);
3991
4151
  if (grantResume)
3992
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;
3993
4160
  let spec = HarnessRunSpec.parse({
3994
4161
  session_id: newId("ses"),
3995
4162
  intent: opts.intent,
3996
- prompt: explorerPrompt,
4163
+ prompt: laneContinuity?.pointerLine
4164
+ ? `${explorerPrompt}\n\n${laneContinuity.pointerLine}`
4165
+ : explorerPrompt,
3997
4166
  cwd: this.execRootOf(input),
3998
4167
  access: "readonly",
3999
4168
  // ASK/EXPLORE/AUDIT read-only runs must forward the user's attachments —
@@ -4005,19 +4174,10 @@ export class Orchestrator {
4005
4174
  resume_session_id: grantResume ? sessionFields.resume_session_id : null,
4006
4175
  ...this.harnessSpecKnobs(contract, knobs, opts.intent),
4007
4176
  env_inheritance: envInheritance(this.config(input.repoRoot)),
4008
- env: roHome.env,
4009
- // Structured output: the orchestrate PLANNER's deliverable IS the
4010
- // typed plan constrain schema-capable routes to it. Capability-gated:
4011
- // routes without json_schema_output keep fenced-JSON parsing. ALSO
4012
- // gated off when this spec will ride the INTERACTIVE stream-json
4013
- // transport (an interaction channel will be offered): --json-schema x
4014
- // interactive is an unverified vendor combination — fenced parsing
4015
- // carries those runs until it is live-verified.
4016
- ...(opts.intent === "orchestrate" &&
4017
- routed.supportsJsonSchemaOutput &&
4018
- !(Boolean(input.onInteraction) && routed.supportsInteractive)
4019
- ? { output_schema: orchestratePlanJsonSchema() }
4020
- : {}),
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,
4021
4181
  });
4022
4182
  const reportAbort = new AbortController();
4023
4183
  spec.extra["abortSignal"] = input.signal
@@ -4084,9 +4244,14 @@ export class Orchestrator {
4084
4244
  break;
4085
4245
  const safeEv = redactHarnessEvent(ev);
4086
4246
  safeInvoke(input.onHarnessEvent, safeEv);
4087
- // NOT observed for resume: this read-only attempt is not a chat
4088
- // turn. Recording its id would poison thread continuity and can
4089
- // 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);
4090
4255
  observeAuthSwitch(log, adapter.id, attemptId, safeEv);
4091
4256
  log.emit("harness.event", harnessEventPayload(adapter.id, attemptId, safeEv));
4092
4257
  appendLine(attemptEventsPath, JSON.stringify(safeEv));
@@ -4222,7 +4387,7 @@ export class Orchestrator {
4222
4387
  error: harnessError,
4223
4388
  telemetry,
4224
4389
  });
4225
- if (opts.swarm) {
4390
+ if (opts.deepScan) {
4226
4391
  store.writeText(join(paths.findingsDir, `${attemptId}-error.md`), `# Explorer ${attemptId} failed\n\n${harnessError}\n`);
4227
4392
  }
4228
4393
  return;
@@ -4241,7 +4406,7 @@ export class Orchestrator {
4241
4406
  error: null,
4242
4407
  telemetry,
4243
4408
  });
4244
- if (opts.swarm) {
4409
+ if (opts.deepScan) {
4245
4410
  const warningNote = toolWarnings(telemetry).length
4246
4411
  ? `\n\n> Tool warnings: ${toolWarnings(telemetry)
4247
4412
  .map((e) => `${e.tool}: ${e.summary}`)
@@ -4251,7 +4416,7 @@ export class Orchestrator {
4251
4416
  }
4252
4417
  };
4253
4418
  try {
4254
- if (opts.swarm) {
4419
+ if (opts.deepScan) {
4255
4420
  // Explorer swarm runs in parallel (bounded), mirroring parallel candidates.
4256
4421
  await runBounded(adapters, Math.min(adapters.length, MAX_PARALLEL_CANDIDATES), runReadonlyAttempt);
4257
4422
  }
@@ -4341,7 +4506,7 @@ export class Orchestrator {
4341
4506
  })), () => this.writeRunTelemetry(store, paths, contract, runId, taskId, opts.mode, attemptTelemetries, null), ledger.spend(), input.signal, store);
4342
4507
  }
4343
4508
  const succeededReadonly = attempts.filter((a) => a.status === "success");
4344
- if (!opts.swarm && succeededReadonly.length === 0) {
4509
+ if (!opts.deepScan && succeededReadonly.length === 0) {
4345
4510
  const last = attempts[attempts.length - 1];
4346
4511
  const webBlocked = attempts.some((a) => a.status === "blocked");
4347
4512
  const singleError = last?.error ?? (budgetStopped ? "budget exhausted before any attempt" : "harness failed");
@@ -4375,16 +4540,17 @@ export class Orchestrator {
4375
4540
  runDir: paths.root,
4376
4541
  nextActions: ["Open diagnostics", "Check harness authentication", "Retry after setup"],
4377
4542
  });
4378
- const terminal = webBlocked
4379
- ? "blocked"
4543
+ const terminalFacts = webBlocked
4544
+ ? makeOutcomeFacts("succeeded", { review: "blocked", reason: "review_blocked" })
4380
4545
  : budgetStopped && attempts.length === 0
4381
- ? "exhausted"
4382
- : "failed";
4383
- 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`);
4384
4549
  log.emit("output.ready", { kind: "summary", path: "final/summary.md", state: "diagnostic" });
4385
- if (terminal === "blocked") {
4550
+ if (terminalFacts.lifecycle === "succeeded") {
4386
4551
  log.emit("run.blocked", {
4387
- status: terminal,
4552
+ lifecycle: terminalFacts.lifecycle,
4553
+ facts: terminalFacts,
4388
4554
  harness_id: last?.harnessId,
4389
4555
  error: singleError,
4390
4556
  failure_ref: "final/failure.yaml",
@@ -4392,7 +4558,9 @@ export class Orchestrator {
4392
4558
  }
4393
4559
  else {
4394
4560
  log.emit("run.failed", {
4395
- status: terminal,
4561
+ lifecycle: terminalFacts.lifecycle,
4562
+ facts: terminalFacts,
4563
+ reason: terminalFacts.reason,
4396
4564
  harness_id: last?.harnessId,
4397
4565
  error: singleError,
4398
4566
  failure_ref: "final/failure.yaml",
@@ -4403,7 +4571,8 @@ export class Orchestrator {
4403
4571
  runId,
4404
4572
  taskId,
4405
4573
  mode: opts.mode,
4406
- status: terminal,
4574
+ lifecycle: terminalFacts.lifecycle,
4575
+ facts: terminalFacts,
4407
4576
  winner: null,
4408
4577
  runDir: paths.root,
4409
4578
  summary: singleError,
@@ -4415,7 +4584,7 @@ export class Orchestrator {
4415
4584
  };
4416
4585
  }
4417
4586
  const succeeded = succeededReadonly;
4418
- if (opts.swarm && succeeded.length === 0) {
4587
+ if (opts.deepScan && succeeded.length === 0) {
4419
4588
  const message = attempts
4420
4589
  .map((a) => `${a.attemptId}/${a.harnessId}: ${a.error ?? "failed"}`)
4421
4590
  .join("\n");
@@ -4434,18 +4603,24 @@ export class Orchestrator {
4434
4603
  "Retry after setup",
4435
4604
  ],
4436
4605
  });
4437
- 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`);
4438
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" });
4439
4611
  if (blocked)
4440
4612
  log.emit("run.blocked", {
4441
- status: "blocked",
4613
+ lifecycle: scanFailFacts.lifecycle,
4614
+ facts: scanFailFacts,
4442
4615
  phase: "harness",
4443
4616
  error: message,
4444
4617
  failure_ref: "final/failure.yaml",
4445
4618
  });
4446
4619
  else
4447
4620
  log.emit("run.failed", {
4448
- status: "failed",
4621
+ lifecycle: scanFailFacts.lifecycle,
4622
+ facts: scanFailFacts,
4623
+ reason: scanFailFacts.reason,
4449
4624
  phase: "harness",
4450
4625
  error: message,
4451
4626
  failure_ref: "final/failure.yaml",
@@ -4455,7 +4630,8 @@ export class Orchestrator {
4455
4630
  runId,
4456
4631
  taskId,
4457
4632
  mode: opts.mode,
4458
- status: blocked ? "blocked" : "failed",
4633
+ lifecycle: scanFailFacts.lifecycle,
4634
+ facts: scanFailFacts,
4459
4635
  winner: null,
4460
4636
  runDir: paths.root,
4461
4637
  summary: message,
@@ -4467,7 +4643,7 @@ export class Orchestrator {
4467
4643
  };
4468
4644
  }
4469
4645
  const unsuccessful = attempts.filter((a) => a.status !== "success");
4470
- const report = opts.swarm
4646
+ const report = opts.deepScan
4471
4647
  ? [
4472
4648
  `Explorers succeeded: ${succeeded.length}/${attempts.length}.`,
4473
4649
  "",
@@ -4505,36 +4681,12 @@ export class Orchestrator {
4505
4681
  answerText: succeeded[0]?.report ?? "",
4506
4682
  });
4507
4683
  }
4508
- // orchestrate: the planner's plan is a TYPED artifact, not just prose. Extract
4509
- // the required fenced JSON block, validate it against the tool belt, and
4510
- // persist final/orchestration.yaml; a missing/invalid block is disclosed in
4511
- // the summary and events (suggest autonomy: the plan is the work product).
4512
- let typedPlanNote = "";
4513
- let orchestratePlan = null;
4514
- if (opts.mode === "orchestrate") {
4515
- const extracted = extractOrchestratePlan(report);
4516
- if (extracted.plan) {
4517
- orchestratePlan = extracted.plan;
4518
- store.writeYaml(join(paths.finalDir, "orchestration.yaml"), extracted.plan);
4519
- log.emit("output.ready", { kind: "report", path: "final/orchestration.yaml" });
4520
- typedPlanNote = `\n- Typed plan: final/orchestration.yaml (${extracted.plan.tool_calls.length} tool call(s))`;
4521
- }
4522
- else {
4523
- store.writeText(join(paths.finalDir, "orchestration_parse_error.md"), `# Typed plan missing\n\n${extracted.error}\n`);
4524
- log.emit("output.ready", {
4525
- kind: "report",
4526
- path: "final/orchestration_parse_error.md",
4527
- state: "diagnostic",
4528
- });
4529
- typedPlanNote = `\n- Typed plan: MISSING (${extracted.error}); the markdown plan above is the only artifact`;
4530
- }
4531
- }
4532
- 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));
4533
4685
  log.emit("output.ready", {
4534
4686
  kind: opts.mode === "ask" ? "answer" : "report",
4535
4687
  path: `final/${opts.artifactName}`,
4536
4688
  });
4537
- if (opts.swarm) {
4689
+ if (opts.deepScan) {
4538
4690
  store.writeYaml(join(paths.finalDir, "explore-findings.yaml"), {
4539
4691
  mode: "explore",
4540
4692
  width,
@@ -4556,99 +4708,60 @@ export class Orchestrator {
4556
4708
  });
4557
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`);
4558
4710
  }
4559
- // orchestrate executor (auto_safe/auto_full): the plan is no longer just a
4560
- // suggestion run its tool_calls in order, classifying each via toolRisk
4561
- // (fail-closed). SAFE steps run as isolated envelope sub-runs / pure reads;
4562
- // a RISKY step (apply) blocks under auto_safe (awaiting a human decision) and
4563
- // applies through the single existing gate under auto_full. The executor's
4564
- // terminal outcome (success / blocked / failed) becomes the run's terminal.
4565
- const autonomy = opts.orchestrateContract?.autonomy ?? input.autonomy ?? "suggest";
4566
- let terminal = "success";
4567
- let orchestrateReadOnly = true;
4568
- let orchestrateReceiptRefs = [];
4569
- if (ledger.terminal() !== null)
4570
- terminal = ledger.terminal();
4571
- // orchestrate's contract output IS the typed plan. If the planner failed to
4572
- // produce a valid one, the run is NOT a clean success — disclose it honestly
4573
- // (the markdown plan stays as a diagnostic artifact) rather than reporting
4574
- // success alongside an orchestration_parse_error.md.
4575
- if (opts.mode === "orchestrate" && !orchestratePlan)
4576
- terminal = "not_converged";
4577
- if (opts.mode === "orchestrate" && autonomy !== "suggest" && orchestratePlan) {
4578
- // Thread the GENERATED runId onto input so the executor's answer_question
4579
- // step keys the interaction registry by this orchestrate run's id (callers
4580
- // often invoke run() without a preassigned runId).
4581
- const executionInput = { ...input, runId };
4582
- const exec = await executeOrchestratePlan({
4583
- plan: orchestratePlan,
4584
- autonomy,
4585
- maxToolCalls: opts.orchestrateContract?.budget.max_tool_calls ?? null,
4586
- signal: input.signal,
4587
- store,
4588
- paths,
4589
- log,
4590
- ledger,
4591
- executeSafeStep: (call) => this.executeSafeStep(executionInput, call, log, store, paths, ledger),
4592
- executeApplyStep: (call) => this.executeApplyStep(executionInput, call, log),
4593
- });
4594
- terminal = exec.terminal;
4595
- orchestrateReadOnly = exec.readOnly;
4596
- orchestrateReceiptRefs = exec.receiptRefs;
4597
- typedPlanNote += `\n- Executor (${autonomy}): ${exec.note}`;
4598
- }
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 });
4599
4717
  const harnessLabel = attempts
4600
4718
  .map((a) => `${a.attemptId}:${a.harnessId}:${a.status}`)
4601
4719
  .join(", ");
4602
- 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`);
4603
4721
  store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
4604
4722
  id: newId("wp"),
4605
4723
  kind: "report",
4606
4724
  source_task_id: taskId,
4607
4725
  producer_attempt_id: succeeded[0]?.attemptId ?? "a01",
4608
- files: Object.fromEntries([
4609
- [opts.artifactName, join(paths.finalDir, opts.artifactName)],
4610
- ...orchestrateReceiptRefs.map((ref, index) => [`delivery_receipt_${index + 1}`, ref]),
4611
- ]),
4726
+ files: Object.fromEntries([[opts.artifactName, join(paths.finalDir, opts.artifactName)]]),
4612
4727
  meta: {
4613
4728
  harnesses: attempts.map((a) => a.harnessId),
4614
4729
  mode: opts.mode,
4615
4730
  intent: opts.intent,
4616
- read_only: orchestrateReadOnly,
4731
+ read_only: true,
4617
4732
  },
4618
4733
  });
4619
4734
  log.emit("work_product.emitted", { kind: "report", winner: succeeded[0]?.attemptId ?? null });
4620
- const orchestrateFailure = orchestrateFailureFor(terminal);
4621
- if (terminal === "blocked" && orchestrateFailure) {
4622
- writeFailure(store, paths, { ...orchestrateFailure, runDir: paths.root });
4623
- log.emit("run.blocked", {
4624
- status: terminal,
4625
- phase: orchestrateFailure.phase,
4626
- 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"],
4627
4742
  });
4628
- }
4629
- else if (orchestrateFailure) {
4630
- writeFailure(store, paths, { ...orchestrateFailure, runDir: paths.root });
4631
4743
  log.emit("run.failed", {
4632
- status: terminal,
4633
- phase: orchestrateFailure.phase,
4744
+ lifecycle: terminalFacts.lifecycle,
4745
+ facts: terminalFacts,
4746
+ reason: terminalFacts.reason,
4747
+ phase: "executor",
4634
4748
  failure_ref: "final/failure.yaml",
4635
4749
  });
4636
4750
  }
4637
- else if (terminal === "cancelled") {
4638
- // Cancel is failure-shaped (parity with every other mode's cancel
4639
- // terminal): tailers waiting for run.completed-as-success must not
4640
- // mistake an operator abort for a clean report.
4641
- log.emit("run.failed", { status: terminal });
4642
- }
4643
4751
  else {
4644
- log.emit("run.completed", { status: terminal });
4752
+ log.emit("run.completed", {
4753
+ lifecycle: terminalFacts.lifecycle,
4754
+ facts: terminalFacts,
4755
+ reason: terminalFacts.reason,
4756
+ });
4645
4757
  }
4646
4758
  return {
4647
4759
  spendUsd: ledger.spend(),
4648
4760
  runId,
4649
4761
  taskId,
4650
4762
  mode: opts.mode,
4651
- status: terminal,
4763
+ lifecycle: terminalFacts.lifecycle,
4764
+ facts: terminalFacts,
4652
4765
  winner: null,
4653
4766
  runDir: paths.root,
4654
4767
  summary: redactSecrets(report).slice(0, 400),
@@ -4659,228 +4772,6 @@ export class Orchestrator {
4659
4772
  })),
4660
4773
  };
4661
4774
  }
4662
- /**
4663
- * Run one SAFE plan step. start_run/race spawn ISOLATED ENVELOPE sub-runs
4664
- * (inPlace=false, ASSERTED); review/status/answer_question are pure reads /
4665
- * answer delivery that never mutate the live tree.
4666
- */
4667
- async executeSafeStep(input, call, log, store, paths, ledger) {
4668
- switch (call.tool) {
4669
- case "start_run":
4670
- case "race": {
4671
- // Isolated envelope sub-run (construction owned by runSupport);
4672
- // recursion guard via orchestrateDepth+1, may NOT orchestrate.
4673
- const subInput = {
4674
- ...buildEnvelopeSubInput(input, call, ledger),
4675
- };
4676
- // SAFETY INVARIANT 1 (asserted, not convention): a safe sub-run is an
4677
- // isolated envelope — never a live in-place turn.
4678
- assertEnvelopeSubRun(subInput);
4679
- log.emit("orchestrate.subrun.started", {
4680
- tool: call.tool,
4681
- mode: subInput.mode,
4682
- n: subInput.n ?? null,
4683
- });
4684
- const res = await this.run(subInput);
4685
- return {
4686
- status: res.status === "failed" || res.status === "cancelled" ? "failed" : "done",
4687
- terminalStatus: res.status,
4688
- terminalSource: "subrun",
4689
- evidenceRefs: [`run:${res.runId}`],
4690
- runId: res.runId,
4691
- spendUsd: res.spendUsd ?? null,
4692
- detail: `${call.tool} sub-run ${res.runId} -> ${res.status}`,
4693
- };
4694
- }
4695
- case "status": {
4696
- // Pure read of the referenced run's decision/work_product artifacts.
4697
- const read = readRunStatus(input.repoRoot, call.run_id);
4698
- return {
4699
- status: read ? "done" : "skipped",
4700
- terminalStatus: read?.status ?? null,
4701
- terminalSource: "subrun",
4702
- evidenceRefs: read?.evidenceRefs ?? [],
4703
- runId: call.run_id,
4704
- detail: read?.detail ?? `run ${call.run_id} has no readable status artifacts`,
4705
- };
4706
- }
4707
- case "review": {
4708
- // Read-only review over the referenced run's recorded patch diff. The
4709
- // step ACTUALLY runs the reviewer panel (evidence beats summaries — a
4710
- // "done" review must mean a review happened), persists its artifacts, and
4711
- // reports the real outcome; eligibility alone is never reported as done.
4712
- const diff = readRunPatch(input.repoRoot, call.run_id);
4713
- if (diff === null)
4714
- return {
4715
- status: "skipped",
4716
- terminalStatus: null,
4717
- terminalSource: "review",
4718
- evidenceRefs: [],
4719
- runId: call.run_id,
4720
- detail: `run ${call.run_id} has no patch.diff to review`,
4721
- };
4722
- // Aggregate honesty: reviewer panels spend real money on
4723
- // API-keyed routes and the spend is charged AFTER the fact — with no
4724
- // remaining headroom the review must not start at all.
4725
- if (ledger.terminal() !== null) {
4726
- return {
4727
- status: "skipped",
4728
- terminalStatus: null,
4729
- terminalSource: "review",
4730
- evidenceRefs: [],
4731
- runId: call.run_id,
4732
- detail: "aggregate budget exhausted before the review step",
4733
- };
4734
- }
4735
- const reviewers = await this.resolveReviewers(input.repoRoot, input.authPreference);
4736
- if (reviewers.length === 0)
4737
- return {
4738
- status: "skipped",
4739
- terminalStatus: null,
4740
- terminalSource: "review",
4741
- evidenceRefs: [],
4742
- runId: call.run_id,
4743
- detail: "no doctor-OK reviewers available",
4744
- };
4745
- const reviewLease = ledger.reserve({
4746
- taskId: input.taskId ?? "orchestrate",
4747
- attemptId: `review-${call.run_id}`,
4748
- intent: "review",
4749
- harnessId: "review-panel",
4750
- cost: attemptCostEvidence("review-panel", `review-${call.run_id}`),
4751
- });
4752
- if (!reviewLease.granted)
4753
- return {
4754
- status: "skipped",
4755
- terminalStatus: ledger.terminal(),
4756
- terminalSource: "review",
4757
- evidenceRefs: [],
4758
- runId: call.run_id,
4759
- detail: reviewLease.reason ?? "root paid budget refused the review step",
4760
- };
4761
- const evidenceDir = join(paths.reviewsDir, `orchestrate-${call.run_id}`, "evidence");
4762
- writeEvidencePacket(evidenceDir, {
4763
- userIntent: redactSecrets(input.prompt),
4764
- planAccepted: `orchestrate review tool requested a read-only review of run ${call.run_id}.`,
4765
- diff,
4766
- tests: input.tests?.join("\n") || "(no test commands configured)",
4767
- decidedTradeoffs: "This review is scoped to the referenced run patch and must use typed reviewer artifacts, not summary-only evidence.",
4768
- });
4769
- const result = await this.reviewScoped({
4770
- candidateLabel: `Run ${call.run_id}`,
4771
- diff,
4772
- evidenceDir,
4773
- artifactsDir: join(paths.reviewsDir, `orchestrate-${call.run_id}`),
4774
- cwd: input.repoRoot,
4775
- reviewers,
4776
- envInheritance: envInheritance(this.config(input.repoRoot)),
4777
- signal: input.signal,
4778
- onReviewerEvent: (event) => log.emit(event.type, { ...event }),
4779
- }).catch((error) => {
4780
- ledger.settle(reviewLease.lease?.lease_id ?? "", unknownCostSettlement("review-error"));
4781
- throw error;
4782
- });
4783
- ledger.settle(reviewLease.lease?.lease_id ?? "", reviewUsageCostSettlement(result.reviewCashUsd, result.reviewValuationUsd, result.reviewSpendEstimated, [`orchestrate:review:${call.run_id}`], result.reviewUnknownUsd));
4784
- const revalidated = await revalidateFindings(result.findings, {
4785
- candidateRoot: input.repoRoot,
4786
- evidenceDir,
4787
- });
4788
- store.writeYaml(join(paths.reviewsDir, `orchestrate-${call.run_id}.yaml`), {
4789
- target_run_id: call.run_id,
4790
- cross_family_healthy: result.crossFamilyHealthy,
4791
- cross_family_verified: result.crossFamilyVerified,
4792
- findings: revalidated,
4793
- route_proofs: result.routeProofs,
4794
- });
4795
- const blockers = revalidated.filter((f) => isBlocking(f)).length;
4796
- return {
4797
- status: "done",
4798
- terminalStatus: result.crossFamilyVerified && blockers === 0 ? "success" : "blocked",
4799
- terminalSource: "review",
4800
- evidenceRefs: [`reviews/orchestrate-${call.run_id}.yaml`],
4801
- runId: call.run_id,
4802
- detail: `reviewed ${call.run_id}: ${result.distinctProviders.length} family(ies), ${revalidated.length} finding(s), ${blockers} blocker(s)`,
4803
- // Reviewer panels can spend real money on API-keyed routes; the
4804
- // aggregate cap must charge it like any other step.
4805
- spendUsd: result.reviewCashUsd,
4806
- };
4807
- }
4808
- case "answer_question": {
4809
- // Delivery + registry-keying rationale owned by runSupport.
4810
- const answer = await deliverPlanAnswer(input, call);
4811
- return {
4812
- ...answer,
4813
- terminalStatus: answer.status === "done" ? "success" : null,
4814
- terminalSource: "executor",
4815
- evidenceRefs: [],
4816
- };
4817
- }
4818
- default: {
4819
- // FAIL-CLOSED: a risky tool (apply) must never reach the safe executor;
4820
- // the caller routes risky steps to executeApplyStep / the auto_safe block.
4821
- throw new Error(`executeSafeStep refused a non-safe tool '${call.tool}' (risky tools must not run as safe steps)`);
4822
- }
4823
- }
4824
- }
4825
- /** Execute an auto_full apply through the shared fresh-verification gate. */
4826
- async executeApplyStep(input, call, log) {
4827
- const store = new ArtifactStore(input.repoRoot);
4828
- const sub = store.runPaths(call.run_id);
4829
- const patchPath = join(sub.finalDir, "patch.diff");
4830
- const patchText = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : null;
4831
- if (patchText === null)
4832
- return {
4833
- ok: false,
4834
- runId: call.run_id,
4835
- detail: `run ${call.run_id} has no patch.diff`,
4836
- receipt: null,
4837
- };
4838
- if (containsSecretLikeToken(patchText))
4839
- return {
4840
- ok: false,
4841
- runId: call.run_id,
4842
- detail: "patch contains a secret-like token; refusing apply",
4843
- receipt: null,
4844
- };
4845
- const decision = store.readYaml(join(sub.arbitrationDir, "decision.yaml"));
4846
- const workProduct = store.readYaml(join(sub.finalDir, "work_product.yaml"));
4847
- const taskContract = TaskContractSchema.safeParse(store.readYaml(join(sub.contextDir, "task.yaml")));
4848
- const parsedDecision = decision ? DecisionRecordSchema.safeParse(decision) : null;
4849
- const parsedWp = workProduct ? WorkProductSchema.safeParse(workProduct) : null;
4850
- if (!taskContract.success)
4851
- return {
4852
- ok: false,
4853
- runId: call.run_id,
4854
- detail: "fresh verification contract is missing",
4855
- receipt: null,
4856
- };
4857
- const applyGateInput = {
4858
- state: null,
4859
- decision: parsedDecision?.success ? parsedDecision.data : null,
4860
- workProduct: parsedWp?.success ? parsedWp.data : null,
4861
- patch: patchText,
4862
- originalRepoRoot: input.repoRoot,
4863
- targetRepoRoot: input.repoRoot,
4864
- operatorDecision: null,
4865
- };
4866
- const gateError = validateApplyGate(applyGateInput);
4867
- if (gateError)
4868
- return {
4869
- ok: false,
4870
- runId: call.run_id,
4871
- detail: `apply gate refused: ${gateError}`,
4872
- receipt: null,
4873
- };
4874
- const delivered = await verifyAndDeliver(input.repoRoot, patchText, { mode: call.mode }, gateSpecsFromContract(taskContract.data), (finalVerify) => validateApplyGate({ ...applyGateInput, finalVerify }), log);
4875
- return {
4876
- ok: delivered.applied,
4877
- runId: call.run_id,
4878
- detail: delivered.applied
4879
- ? `applied (${call.mode})`
4880
- : `deliver failed: ${delivered.detail ?? "unknown"}`,
4881
- receipt: delivered,
4882
- };
4883
- }
4884
4775
  }
4885
4776
  function assertNoSecretLikeTokens(label, text) {
4886
4777
  if (containsSecretLikeToken(text)) {