@claudexor/orchestrator 3.6.0 → 3.8.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 (60) hide show
  1. package/dist/account-resolution.d.ts +3 -0
  2. package/dist/account-resolution.d.ts.map +1 -1
  3. package/dist/account-resolution.js +23 -0
  4. package/dist/account-resolution.js.map +1 -1
  5. package/dist/candidateEvidence.d.ts +3 -3
  6. package/dist/candidateEvidence.d.ts.map +1 -1
  7. package/dist/candidateOutputs.d.ts +2 -2
  8. package/dist/candidateOutputs.d.ts.map +1 -1
  9. package/dist/candidateOutputs.js +11 -6
  10. package/dist/candidateOutputs.js.map +1 -1
  11. package/dist/contract-gates.d.ts +4 -4
  12. package/dist/contract-gates.d.ts.map +1 -1
  13. package/dist/contract-gates.js +2 -0
  14. package/dist/contract-gates.js.map +1 -1
  15. package/dist/credential-profiles.d.ts +12 -1
  16. package/dist/credential-profiles.d.ts.map +1 -1
  17. package/dist/credential-profiles.js +13 -3
  18. package/dist/credential-profiles.js.map +1 -1
  19. package/dist/deepScanReducer.d.ts.map +1 -1
  20. package/dist/deepScanReducer.js +2 -1
  21. package/dist/deepScanReducer.js.map +1 -1
  22. package/dist/delegatedHome.d.ts +21 -69
  23. package/dist/delegatedHome.d.ts.map +1 -1
  24. package/dist/delegatedHome.js +29 -85
  25. package/dist/delegatedHome.js.map +1 -1
  26. package/dist/modelGovernance.d.ts +35 -4
  27. package/dist/modelGovernance.d.ts.map +1 -1
  28. package/dist/modelGovernance.js +71 -27
  29. package/dist/modelGovernance.js.map +1 -1
  30. package/dist/orchestrator-credentials.d.ts.map +1 -1
  31. package/dist/orchestrator-credentials.js +7 -1
  32. package/dist/orchestrator-credentials.js.map +1 -1
  33. package/dist/orchestrator.d.ts +5 -6
  34. package/dist/orchestrator.d.ts.map +1 -1
  35. package/dist/orchestrator.js +210 -222
  36. package/dist/orchestrator.js.map +1 -1
  37. package/dist/planRun.d.ts +5 -5
  38. package/dist/planRun.d.ts.map +1 -1
  39. package/dist/planRun.js.map +1 -1
  40. package/dist/plannerAttempt.d.ts +2 -2
  41. package/dist/plannerAttempt.d.ts.map +1 -1
  42. package/dist/plannerAttempt.js +2 -1
  43. package/dist/plannerAttempt.js.map +1 -1
  44. package/dist/requestRequirements.d.ts +3 -15
  45. package/dist/requestRequirements.d.ts.map +1 -1
  46. package/dist/requestRequirements.js +11 -21
  47. package/dist/requestRequirements.js.map +1 -1
  48. package/dist/routeContext.d.ts +4 -1
  49. package/dist/routeContext.d.ts.map +1 -1
  50. package/dist/routeContext.js +8 -3
  51. package/dist/routeContext.js.map +1 -1
  52. package/dist/runAdmission.d.ts +29 -0
  53. package/dist/runAdmission.d.ts.map +1 -0
  54. package/dist/runAdmission.js +63 -0
  55. package/dist/runAdmission.js.map +1 -0
  56. package/dist/task-contract-builder.d.ts +2 -2
  57. package/dist/task-contract-builder.d.ts.map +1 -1
  58. package/dist/task-contract-builder.js +5 -6
  59. package/dist/task-contract-builder.js.map +1 -1
  60. package/package.json +17 -17
@@ -5,7 +5,7 @@ import { accountPoolRows } from "./account-pool.js";
5
5
  import { writeRunTelemetryArtifact } from "./runTelemetryWriter.js";
6
6
  import { buildFileBackedSynthesisInput, materializeWinnerOutputs, stageFileBackedContext, writeCandidateAttemptArtifacts, } from "./candidateOutputs.js";
7
7
  import { processAttemptUsage } from "./attemptUsage.js";
8
- import { appliedAttemptFacts, assertDelegatedEvidence, confinementNotice, externallyConfinedLane, scopedHarnessHome, } from "./delegatedHome.js";
8
+ import { appliedAttemptFacts, assertDelegatedEvidence, isMutatingAccess, outerBoundaryNotice, scopedHarnessHome, } from "./delegatedHome.js";
9
9
  import * as AC from "./attemptUsageCost.js";
10
10
  import { candidateRoster, convergenceOutcomeFacts, isWorkingCandidate, partitionCandidates, toCandidateEvidence, unanimousDeclaredFailure, } from "./candidateEvidence.js";
11
11
  import { capabilityIntents } from "@claudexor/gateway";
@@ -13,10 +13,10 @@ import { policyFindings } from "./policyFindings.js";
13
13
  import { join } from "node:path";
14
14
  import { finalizePlanRun, runCouncilPlan, writePlanHarnessFailure, } from "./planRun.js";
15
15
  import { runPlannerAttempt as executePlannerAttempt, } from "./plannerAttempt.js";
16
- import { HarnessRunSpec, ModeKind as ModeKindSchema, QuotaSnapshot as QuotaSnapshotSchema, isBlocking, makeOutcomeFacts, normalizeUserOutputSchema, strictifyOutputSchema, estimateEffectiveAuthRoute, } from "@claudexor/schema";
16
+ import { HarnessRunSpec, ModeKind as ModeKindSchema, QuotaSnapshot as QuotaSnapshotSchema, isBlocking, makeOutcomeFacts, strictifyOutputSchema, estimateEffectiveAuthRoute, } from "@claudexor/schema";
17
17
  import { globalConfigDir, loadConfig } from "@claudexor/config";
18
- import { acceptedTryOutput, AnswerAssembly, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR, countsAsAgentProgress, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
19
- import { assertRouteModelsAllowed } from "./modelGovernance.js";
18
+ import { acceptedTryOutput, AccessProfileIncompatibleError, AnswerAssembly, CLAUDEXOR_BROWSER_ARTIFACT_SUBDIR, countsAsAgentProgress, HarnessUnavailableError, summarizeDiffPaths as diffStats, withInactivityWatchdog, } from "@claudexor/core";
19
+ import { assertRouteModelsAllowed, runModelGovernedRoute } from "./modelGovernance.js";
20
20
  import { authModeForCredentialRoute, authModeForPreference } from "./auth-route-classification.js";
21
21
  import { governRouteEffort } from "./effortGovernance.js";
22
22
  import { isFullAccess, RequestRequirementsResolver } from "./requestRequirements.js";
@@ -32,7 +32,8 @@ import { arbitrationBudgetOptions, decisionBudgetSummary } from "./decisionBudge
32
32
  import { buildRevisePrompt } from "./revisePrompt.js";
33
33
  import { cancelledResult, declaredFailure, failTerminally, guardAnnouncedRun, writeFailure, } from "./runTerminals.js";
34
34
  import { budgetFailureRecord, classifyBudgetFailure } from "./budgetFailure.js";
35
- import { assertOutputSchemaCompiles, finalizeStructuredOutput } from "./structuredOutput.js";
35
+ import { finalizeStructuredOutput } from "./structuredOutput.js";
36
+ import { admitRun } from "./runAdmission.js";
36
37
  import { dropDeltaPastBudget, emitPlanProgress, emitTransientExhausted, emitTransientRetryPlan, observeReadonlySpend, } from "./laneStreamEvents.js";
37
38
  import { promptWithEngineConstraints, sleep, redactHarnessEvent, harnessEventPayload, safeErrorMessage, renderSummary, observeBudgetSignals, rotateOnStall, recordCleanAttemptMetrics, envInheritance, transientRetryPolicy, reviewerTimeoutMs, harnessInactivityTimeoutMs, observeAuthSwitch, emitPrimaryDivergence, emitPoolDegraded, deliveryRefusalFailure, winnerNeedsHuman, writeRaceDeliveryDecision, } from "./runSupport.js";
38
39
  import { candidateStatusInRouteContext, resolveReadOnlyRouteContext, } from "./routeContext.js";
@@ -53,7 +54,7 @@ import { interactionChannelFor } from "./interaction.js";
53
54
  import { gateSpecsFromContract, renderTestsEvidence } from "./contract-gates.js";
54
55
  import { buildTaskContract } from "./task-contract-builder.js";
55
56
  import { ArtifactStore } from "@claudexor/artifact-store";
56
- import { assertMandatoryContext, buildContextPack, rawContextForEnvelope, preflightEvidence, writeEvidencePacket, } from "@claudexor/context";
57
+ import { buildContextPack, rawContextForEnvelope, preflightEvidence, writeEvidencePacket, } from "@claudexor/context";
57
58
  import { WorkspaceManager, captureRawPatchEnvelope, createRevertAnchorFromPatchOrNull, createRevertAnchorOrNull, ensureClaudeBridge, consumeRawPatchEnvelope, snapshotTree, } from "@claudexor/workspace";
58
59
  import { blockedDecisionOverride, finalVerifyBlocks, finalVerifyPatch, verifyAndDeliver, } from "@claudexor/delivery";
59
60
  import { HarnessGateway } from "@claudexor/gateway";
@@ -62,7 +63,6 @@ import { arbitrate } from "@claudexor/arbitration";
62
63
  import { buildSynthesisPlan, decideSynthesis } from "@claudexor/synthesis";
63
64
  import { attemptCostEvidence, attemptUsageCostSettlement, BudgetLedger, isBudgetTerminal, explainRanking, loadHarnessMetrics, promptFingerprint, rankHarnesses, reviewUsageCostSettlement, } from "@claudexor/budget";
64
65
  import { readTextSafe, appendLine, assertNoInlineSecretValues, DELEGATION_ENV, hashJson, newId, noProjectRepoRoot, redactSecrets, safeInvoke, sha256, userConfigDir, writeText, } from "@claudexor/util";
65
- import { assertWriteIsolation } from "./write-isolation.js";
66
66
  const LABELS = "ABCDEFGHIJ".split("");
67
67
  const NO_PROJECT_ROOT = noProjectRepoRoot();
68
68
  /** Concurrency cap for parallel candidates/explorers (locked decision: min(n, 4)). */
@@ -134,42 +134,11 @@ export class Orchestrator {
134
134
  delegationParentRunId: runId,
135
135
  };
136
136
  }
137
- const projectProtectedPaths = mode === "agent" ? this.projectConfig(resolved.repoRoot).constraints.protected_paths : [];
138
- assertWriteIsolation({
139
- mode,
140
- protectedPaths: projectProtectedPaths,
141
- denyPaths: resolved.denyPaths,
142
- inPlace: resolved.inPlace,
143
- repoRoot: resolved.repoRoot,
144
- executionRoot: this.execRootOf(resolved),
137
+ resolved.outputSchema = admitRun(resolved, mode, {
138
+ accessDefault: this.config(resolved.repoRoot).trust.access_default,
139
+ projectProtectedPaths: () => this.projectConfig(resolved.repoRoot).constraints.protected_paths,
140
+ mandatoryFiles: () => this.projectConfig(resolved.repoRoot).context.mandatory_files,
145
141
  });
146
- // outputSchema constrains the run's final ANSWER. It is honored exactly
147
- // where a final answer is delivered (agent race incl. synthesis, and ask);
148
- // every other strategy refuses loudly rather than carrying a contract the
149
- // engine would not validate (INV-023). The schema itself is normalized for
150
- // the native structured-output routes here at the boundary — unsupported
151
- // shapes (external/cyclic $ref, non-object root) are a typed refusal, not a mid-run 400.
152
- if (resolved.outputSchema !== undefined && resolved.outputSchema !== null) {
153
- if (mode !== "agent" && mode !== "ask") {
154
- throw new Error(`outputSchema constrains the final answer and applies to agent/ask runs (got mode=${mode}); drop the schema or switch modes`);
155
- }
156
- if (resolved.untilClean || (resolved.attempts !== undefined && resolved.attempts !== null)) {
157
- throw new Error("outputSchema is not supported with convergence flags (--until-clean/--attempts): convergence delivers a gated patch, not a structured answer; drop the schema or the convergence flags");
158
- }
159
- // Shape-refuse unsupported schemas, then PROVE it compiles under the same
160
- // ajv the engine validator uses — a malformed schema is a preflight
161
- // refusal here (before any run dir), never a mid-run validator crash. The
162
- // contract keeps the ORIGINAL (conformance authority); local-ref inlining
163
- // and strictification are transport-only transforms in harnessSpecKnobs.
164
- resolved.outputSchema = normalizeUserOutputSchema(resolved.outputSchema);
165
- assertOutputSchemaCompiles(resolved.outputSchema);
166
- }
167
- // P1: a versioned `mandatory_files` contract is enforced UNIFORMLY here, for
168
- // every mode, so the same repo state can't pass `run`/`ask` while failing
169
- // `audit`. No-op when the list is empty (the default) or for no-project runs.
170
- if (resolved.repoRoot !== NO_PROJECT_ROOT) {
171
- assertMandatoryContext(resolved.repoRoot, this.projectConfig(resolved.repoRoot).context.mandatory_files);
172
- }
173
142
  // Reviewer panels are validated only inside Agent strategies that actually
174
143
  // review (race/convergence; Plan Council is the plan critique path) — AFTER run-dir
175
144
  // creation, so a doomed explicit panel yields typed failure ARTIFACTS
@@ -491,8 +460,7 @@ export class Orchestrator {
491
460
  const policy = input.web ?? input.externalContextPolicy ?? "auto";
492
461
  const pool = [];
493
462
  const dropped = [];
494
- // Structured requested-vs-effective route receipt (QA-043): every auto-pool
495
- // drop is recorded with its typed STAGE so the disclosure preserves the
463
+ // Every auto-pool drop is recorded with its typed STAGE so the disclosure preserves the
496
464
  // real cause instead of collapsing to one reason.
497
465
  const droppedLanes = [];
498
466
  // The ONE explicit-lane admission gate shared by every drop site (QA-043 /
@@ -501,14 +469,18 @@ export class Orchestrator {
501
469
  // substitution or self-race duplication; an AUTO lane is dropped with a
502
470
  // typed omission recorded for the degradation receipt.
503
471
  const dropLane = (harnessId, stage, detail) => {
504
- if (explicitPool)
472
+ if (explicitPool) {
473
+ if (stage === "access")
474
+ throw new AccessProfileIncompatibleError(detail);
505
475
  throw new HarnessUnavailableError(detail);
476
+ }
506
477
  dropped.push(detail);
507
478
  droppedLanes.push({ harnessId, stage, detail });
508
479
  };
509
480
  // One observation set for the whole admission pass, so two lanes cannot be
510
481
  // judged against different vendor epochs.
511
482
  const vendorQuota = this.credentials.vendorQuotaObservations();
483
+ const liveUnusable = this.deps.credentialUnusable?.() ?? [];
512
484
  for (const id of ids) {
513
485
  const adapter = this.deps.registry.get(id);
514
486
  if (!adapter) {
@@ -577,14 +549,8 @@ export class Orchestrator {
577
549
  const profileAdapter = this.deps.registry.get(id);
578
550
  const profileProbe = profileAdapter?.probeCredentialProfile?.bind(profileAdapter);
579
551
  const explicitPin = this.credentials.effectiveProfileId(input, id);
580
- // The account candidates whose readiness may overlay the default-store
581
- // verdict: the explicit pin when set; otherwise row-aware UNPINNED
582
- // admission — the thread-bound row followed by the harness's enabled
583
- // pool rows, consulted only when the default doctor did not already
584
- // admit the lane. A signed-in registry row is invisible to the default
585
- // doctor (cursor after the host-Keychain retirement, or a named-rows-
586
- // only claude/codex install), so an unpinned run must be admitted on
587
- // row readiness exactly the way explicit pins already are.
552
+ const model = input.models?.[id] ?? cfgEntry?.default_model ?? null;
553
+ // Pins and bound/pool rows are checked against their own readiness.
588
554
  const rowCandidateIds = [];
589
555
  if (explicitPin) {
590
556
  rowCandidateIds.push(explicitPin);
@@ -603,9 +569,12 @@ export class Orchestrator {
603
569
  probe: profileProbe,
604
570
  // `verification: passed` from the local store only means a login file
605
571
  // is present. The poller's authenticated vendor call is the only
606
- // liveness evidence we have, and admission is the surface that must
607
- // act on it — otherwise the run dispatches into a revoked token.
572
+ // liveness evidence; admission must act on it or dispatch may use a revoked token.
608
573
  quota: vendorQuota,
574
+ unusable: liveUnusable,
575
+ model,
576
+ // Only an explicit/bound route may consume bounded stale LKG evidence.
577
+ allowStale: explicitPin !== null || input.threadAccountBindings?.[id] === candidateId,
609
578
  });
610
579
  if (verdict === "available") {
611
580
  profileAdmitted = true;
@@ -645,7 +614,7 @@ export class Orchestrator {
645
614
  const readOnlyIntent = intent === "plan" || intent === "spec" || intent === "explain" || intent === "audit";
646
615
  const requiredAccess = this.requestRequirements.adapterAccess(intent, manifest.capabilities.implementation_transport, readOnlyIntent
647
616
  ? "readonly"
648
- : (input.access ?? this.config(input.repoRoot).trust.access_default), externallyConfinedLane(input.delegated === true));
617
+ : (input.access ?? this.config(input.repoRoot).trust.access_default));
649
618
  const accessSupported = !requiredAccess || manifest.access_profiles_supported.includes(requiredAccess);
650
619
  const webSupport = manifest.capabilities.web_policy;
651
620
  // Match routeSpecKnobs: a per-harness web default upgrades run-level auto,
@@ -679,6 +648,7 @@ export class Orchestrator {
679
648
  harnessId: id,
680
649
  requested: input.browser === true,
681
650
  manifestCapable: manifest.capabilities.browser_tool,
651
+ requiresFullAccess: manifest.capability_profile.mcp_injection_requires_full_access,
682
652
  webPolicy: routePolicy,
683
653
  access: requiredAccess,
684
654
  }),
@@ -730,6 +700,9 @@ export class Orchestrator {
730
700
  }
731
701
  }
732
702
  if (pool.length === 0) {
703
+ if (droppedLanes.length > 0 && droppedLanes.every((lane) => lane.stage === "access")) {
704
+ throw new AccessProfileIncompatibleError(`no harness can enforce the requested access profile (${droppedLanes.map((lane) => lane.detail).join(", ")})`);
705
+ }
733
706
  throw new HarnessUnavailableError(`no harness can perform '${intent}' for this mode${dropped.length ? ` (skipped: ${dropped.join(", ")})` : ""}`);
734
707
  }
735
708
  // Quota admission must use the account that will actually spawn. In
@@ -850,7 +823,7 @@ export class Orchestrator {
850
823
  }
851
824
  }
852
825
  // Strict pre-run model gate (INV-104) — see modelGovernance.ts.
853
- await assertRouteModelsAllowed(out, input.models, this.execRootOf(input));
826
+ await assertRouteModelsAllowed(out, input.models, this.execRootOf(input), (id) => routeContext ? (routeContext.envForHarness?.(id) ?? routeContext.env) : undefined);
854
827
  return out;
855
828
  }
856
829
  /**
@@ -1382,8 +1355,8 @@ export class Orchestrator {
1382
1355
  /** Decided by `harnessHomeFor` in the CALLER, so the per-attempt applied
1383
1356
  * facts exist in the caller's catch too: an attempt that ran and then threw
1384
1357
  * must record what it ran under, not just why it stopped. REQUIRED (no
1385
- * default): a silent fallback here would spawn a delegated child on the
1386
- * operator's real home while the record still claimed confinement. */
1358
+ * default): a silent fallback here would spawn a delegated attempt on the
1359
+ * operator's real home while the record still claimed scoped state. */
1387
1360
  harnessHome) {
1388
1361
  const adapter = routed.adapter;
1389
1362
  const knobs = this.routeSpecKnobs(routed, contract, modelHint, effortHint);
@@ -1412,8 +1385,8 @@ export class Orchestrator {
1412
1385
  // Engine-derived read-only prompt constraints: protected/auto-protected
1413
1386
  // paths PLUS the exact typed gate argv the run will execute (QA-022 FIX B).
1414
1387
  prompt: promptWithEngineConstraints(
1415
- // A child running without an OS boundary is TOLD so (disclosure 2 of 3).
1416
- [prompt, confinementNotice(harnessHome), laneContinuity?.pointerLine, continuationPointer]
1388
+ // The child is told that Claudexor adds no outer OS boundary (disclosure 2 of 3).
1389
+ [prompt, outerBoundaryNotice(harnessHome), laneContinuity?.pointerLine, continuationPointer]
1417
1390
  .filter((s) => typeof s === "string" && s.length > 0)
1418
1391
  .join("\n\n"), contract.constraints.protected_paths, contract.constraints.auto_protected_paths, contract.constraints.protected_path_approvals, contract.tests.commands),
1419
1392
  attachments: runInput?.attachments ?? [],
@@ -1440,11 +1413,8 @@ export class Orchestrator {
1440
1413
  // Scoped harness home for isolated envelopes AND for every delegated run;
1441
1414
  // an ordinary in-place run keeps the native environment so the resumed
1442
1415
  // vendor session is reachable. See scopedHarnessHome for the cost a
1443
- // delegated in-place attempt pays for that confinement.
1416
+ // delegated in-place attempt pays for that scoped state.
1444
1417
  ...(harnessHome.env ? { env: harnessHome.env } : {}),
1445
- // The OS boundary itself. The shared CLI run loop wraps the argv with it;
1446
- // no adapter opts in, so no adapter can forget.
1447
- confinement: harnessHome.confinement,
1448
1418
  raw_context_packet: rawContextPacket,
1449
1419
  stream_deltas: streamDeltas,
1450
1420
  });
@@ -1523,7 +1493,7 @@ export class Orchestrator {
1523
1493
  const rateLimitStart = telemetry.rateLimits.length;
1524
1494
  let rawPatch = null;
1525
1495
  try {
1526
- const watched = withInactivityWatchdog(adapter.run(runSpec), {
1496
+ const watched = withInactivityWatchdog(runModelGovernedRoute(routed, runSpec), {
1527
1497
  timeoutMs: inactivityMs,
1528
1498
  countsAsProgress: countsAsAgentProgress,
1529
1499
  onTimeout: () => {
@@ -1791,7 +1761,7 @@ export class Orchestrator {
1791
1761
  worktreePath: envelope.worktree_path,
1792
1762
  artifactRelativeDir,
1793
1763
  diff,
1794
- persistPatch: secretDiffRefusal === undefined,
1764
+ persistPatch: secretDiffRefusal === undefined && isMutatingAccess(access),
1795
1765
  persistProducedMedia: secretDiffRefusal === undefined,
1796
1766
  answerText,
1797
1767
  record: {
@@ -1807,8 +1777,8 @@ export class Orchestrator {
1807
1777
  ...(secretDiffRefusal ? { secret_diff_refusal: secretDiffRefusal } : {}),
1808
1778
  gates: gates.map((g) => ({ id: g.id, status: g.status })),
1809
1779
  branch: envelope.branch_name,
1810
- // Applied facts, not promises: the caller of a delegated run
1811
- // verifies the confinement it asked for instead of trusting it.
1780
+ // Applied facts, not promises: historical proofs stay readable and
1781
+ // current delegated runs state deliberate outer-boundary absence.
1812
1782
  // Built by the SAME shape the failure path writes.
1813
1783
  ...applied,
1814
1784
  },
@@ -1888,6 +1858,7 @@ export class Orchestrator {
1888
1858
  // announced: a refused run must fail the request loudly, not 200 a runId
1889
1859
  // and leave an orphaned run dir without a terminal event.
1890
1860
  const contract = this.buildContract(input, taskId, mode);
1861
+ const mutatingRun = isMutatingAccess(contract.access.effective_profile);
1891
1862
  const planBrief = verifiedPlanBrief(input);
1892
1863
  const quotaSnapshots = this.quotaSnapshotPreflight();
1893
1864
  const { store, paths, log, ledger } = beginAnnouncedRun({
@@ -1918,28 +1889,32 @@ export class Orchestrator {
1918
1889
  // directory, a filesystem root, or one that cannot be classified), which
1919
1890
  // gets a typed refusal BEFORE any mutation (INV-075). For an isolated
1920
1891
  // thread the execution root is already a git worktree: a no-op there.
1921
- const gitPreconditionError = await ensureWriteModeGitBoundary(execRoot, log, store, paths, runId, mode);
1922
- if (gitPreconditionError) {
1923
- return {
1924
- runId,
1925
- taskId,
1926
- mode,
1927
- lifecycle: "failed",
1928
- facts: makeOutcomeFacts("failed", { reason: gitPreconditionError.reason }),
1929
- winner: null,
1930
- runDir: paths.root,
1931
- summary: gitPreconditionError.message,
1932
- candidates: [],
1933
- };
1892
+ if (mutatingRun) {
1893
+ const gitPreconditionError = await ensureWriteModeGitBoundary(execRoot, log, store, paths, runId, mode);
1894
+ if (gitPreconditionError) {
1895
+ return {
1896
+ runId,
1897
+ taskId,
1898
+ mode,
1899
+ lifecycle: "failed",
1900
+ facts: makeOutcomeFacts("failed", { reason: gitPreconditionError.reason }),
1901
+ winner: null,
1902
+ runDir: paths.root,
1903
+ summary: gitPreconditionError.message,
1904
+ candidates: [],
1905
+ };
1906
+ }
1934
1907
  }
1935
1908
  // Same run-prep stage as the git boundary: if the PROJECT root uses AGENTS.md
1936
1909
  // with no CLAUDE.md, bridge it so a Claude Code candidate reads it (INV-113).
1937
- this.ensureClaudeBridgeForRun(input.repoRoot, input.inPlace === true, log);
1910
+ if (mutatingRun) {
1911
+ this.ensureClaudeBridgeForRun(input.repoRoot, input.inPlace === true, log);
1912
+ }
1938
1913
  // Pre-turn snapshot of the live tree for in-place runs: the revert restore
1939
1914
  // target (server-owned revertInPlace). A snapshot failure must never fail the
1940
1915
  // run — revert is simply unavailable then.
1941
1916
  let preTurnSha = null;
1942
- if (input.inPlace === true) {
1917
+ if (mutatingRun && input.inPlace === true) {
1943
1918
  try {
1944
1919
  preTurnSha = await snapshotTree(execRoot);
1945
1920
  }
@@ -2109,9 +2084,10 @@ export class Orchestrator {
2109
2084
  accessProfile: candidateAccess,
2110
2085
  // Direct-workspace singletons run in place. Races and patch-envelope
2111
2086
  // transports stay isolated and adopt through the delivery service.
2112
- inPlace: input.inPlace === true &&
2113
- requestedSingleCandidate &&
2114
- slot.routed.implementationTransport !== "git_patch_envelope",
2087
+ inPlace: !mutatingRun ||
2088
+ (input.inPlace === true &&
2089
+ requestedSingleCandidate &&
2090
+ slot.routed.implementationTransport !== "git_patch_envelope"),
2115
2091
  });
2116
2092
  harnessHome = this.harnessHomeFor(wsm, envelope, slot.routed, input);
2117
2093
  const run = await this.runCandidateInEnvelope(slot.routed, envelope, slot.attemptId, slot.label, contract, input.prompt, store, paths, wsm, ledger, candidateAccess, (ev) => {
@@ -2287,8 +2263,8 @@ export class Orchestrator {
2287
2263
  };
2288
2264
  await runBounded(slots, Math.min(slots.length, MAX_PARALLEL_CANDIDATES), runSlot);
2289
2265
  const runs = runsBySlot.filter((r) => r !== undefined);
2290
- // Fail-closed terminal: a delegated mutating run whose attempts cannot state
2291
- // their applied confinement refuses instead of passing.
2266
+ // Fail-closed terminal: a delegated mutating run whose attempts state
2267
+ // neither historical proof nor deliberate absence refuses instead of passing.
2292
2268
  assertDelegatedEvidence(input.delegated === true, candidateAccess, runs);
2293
2269
  const cancelledCandidates = () => runs.map((r) => ({
2294
2270
  attemptId: r.attemptId,
@@ -2306,7 +2282,10 @@ export class Orchestrator {
2306
2282
  // arbitration (as the race-adoption path does) would fold those user edits
2307
2283
  // into the revert target and let a later revert clobber them.
2308
2284
  let earlyPostTurnSha = null;
2309
- if (input.inPlace && requestedSingleCandidate && runs.every((run) => !run.secretDiffRefusal)) {
2285
+ if (mutatingRun &&
2286
+ input.inPlace &&
2287
+ requestedSingleCandidate &&
2288
+ runs.every((run) => !run.secretDiffRefusal)) {
2310
2289
  try {
2311
2290
  earlyPostTurnSha = await snapshotTree(execRoot);
2312
2291
  }
@@ -2322,13 +2301,15 @@ export class Orchestrator {
2322
2301
  if (failedDelegation) {
2323
2302
  const failure = delegateFailure.candidateFailureTerminal(failedDelegation, "race");
2324
2303
  await disposeReviewEnvelopes();
2325
- await delegateFailure.persistFailedInPlaceWorkProduct({
2326
- ...{ store, log, paths, execRoot, preTurnSha, taskId, mode },
2327
- live: input.inPlace === true && failedDelegation.reviewCwd === execRoot,
2328
- run: failedDelegation,
2329
- postTurnSha: earlyPostTurnSha,
2330
- kind: input.create === true ? "new_repo" : "patch",
2331
- });
2304
+ if (mutatingRun) {
2305
+ await delegateFailure.persistFailedInPlaceWorkProduct({
2306
+ ...{ store, log, paths, execRoot, preTurnSha, taskId, mode },
2307
+ live: input.inPlace === true && failedDelegation.reviewCwd === execRoot,
2308
+ run: failedDelegation,
2309
+ postTurnSha: earlyPostTurnSha,
2310
+ kind: input.create === true ? "new_repo" : "patch",
2311
+ });
2312
+ }
2332
2313
  this.writeRunTelemetry(store, paths, contract, runId, taskId, mode, candidateRoster(runs), null);
2333
2314
  return failTerminally(log, store, paths, runId, taskId, mode, failure.phase, failure.error, ledger.spend(), failure.metadata);
2334
2315
  }
@@ -2510,7 +2491,13 @@ export class Orchestrator {
2510
2491
  return cancelledRaceResult();
2511
2492
  }
2512
2493
  // Synthesis: if worthwhile, run a synthesizer as a NEW, re-checked candidate.
2513
- const synth = decideSynthesis(evidences, input.synthesis ?? "auto");
2494
+ const synth = mutatingRun
2495
+ ? decideSynthesis(evidences, input.synthesis ?? "auto")
2496
+ : {
2497
+ synthesize: false,
2498
+ reason: "readonly access has no write-backed synthesis lifecycle",
2499
+ sources: evidences.map((evidence) => evidence.attemptId),
2500
+ };
2514
2501
  store.writeYaml(join(paths.arbitrationDir, "synthesis.yaml"), synth);
2515
2502
  log.emit("synthesis.started", { synthesize: synth.synthesize, reason: synth.reason });
2516
2503
  if (synth.synthesize && !budgetStopped) {
@@ -2639,7 +2626,8 @@ export class Orchestrator {
2639
2626
  // instead defers verification until immediately before delivery.
2640
2627
  const inPlaceWinner = winnerRun?.reviewCwd === execRoot;
2641
2628
  const deferredRaceVerify = input.inPlace === true && !inPlaceWinner;
2642
- if (winnerRun &&
2629
+ if (mutatingRun &&
2630
+ winnerRun &&
2643
2631
  !inPlaceWinner &&
2644
2632
  !deferredRaceVerify &&
2645
2633
  winnerRun.diff.trim().length > 0 &&
@@ -2674,17 +2662,7 @@ export class Orchestrator {
2674
2662
  })) {
2675
2663
  log.emit("output.ready", { kind: "artifact", path });
2676
2664
  }
2677
- secretDiff.assertNoSecretLikeTokens("final patch diff", winnerRun.diff);
2678
- const patchSha256 = sha256(winnerRun.diff);
2679
- store.writeText(join(paths.finalDir, "patch.diff"), winnerRun.diff);
2680
- const wstats = diffStats(winnerRun.diff);
2681
- const hasDiff = winnerRun.diff.trim().length > 0;
2682
- const blockers = winnerEvidence
2683
- ? winnerEvidence.findings.filter((f) => isBlocking(f)).length
2684
- : 0;
2685
- // Prose from an empty-diff winner is an answer, never a patch.
2686
2665
  const winnerAnswer = winnerRun.answerText ?? "";
2687
- const resultKind = hasDiff ? "patch" : winnerAnswer.length > 0 ? "answer" : "none";
2688
2666
  // The winner's final MESSAGE is the human-facing answer and materializes
2689
2667
  // for diff-ful runs too: the chat renders final/answer.md (the projection
2690
2668
  // prefers it), never the arbitration summary — "Run … Winner: a01 …" is
@@ -2705,107 +2683,120 @@ export class Orchestrator {
2705
2683
  answerText: winnerAnswer,
2706
2684
  });
2707
2685
  }
2708
- // Only a fully verified, applyable success may auto-adopt; a not-verified
2709
- // or needs-decision terminal remains an inspectable artifact.
2710
- const adoptable = facts.lifecycle === "succeeded" && facts.review === "approved" && facts.checks !== "failed";
2711
- let adopted = null;
2712
- let applyState = "not_applied";
2713
- let postTurnSha = null;
2714
- let revertAnchorId = null;
2715
- if (input.inPlace === true && hasDiff) {
2716
- if (inPlaceWinner) {
2717
- // Already live: the candidate ran in-place and wrote the tree itself.
2718
- adopted = true;
2719
- applyState = adoptable ? "applied" : "applied_review_blocked";
2720
- // The pre-review fence excludes later user edits from the target.
2721
- postTurnSha = earlyPostTurnSha;
2722
- }
2723
- else if (adoptable) {
2724
- // Protected apply preserves the live tree or reports tree_mutated.
2725
- const applied = await verifyAndDeliver(execRoot, winnerRun.diff, { mode: "apply", protectedApply: true }, gateSpecsFromContract(contract), (freshVerify) => {
2726
- finalVerify = freshVerify;
2727
- return finalVerifyBlocks(freshVerify)
2728
- ? (freshVerify.reason ?? "final verify failed before race adoption")
2729
- : null;
2730
- }, log);
2731
- raceDeliveryReceipt = applied;
2732
- store.writeYaml(join(paths.finalDir, "delivery_receipt.yaml"), applied);
2733
- finalVerify = applied.finalVerify;
2734
- if (applied.applied) {
2686
+ if (mutatingRun) {
2687
+ secretDiff.assertNoSecretLikeTokens("final patch diff", winnerRun.diff);
2688
+ const patchSha256 = sha256(winnerRun.diff);
2689
+ store.writeText(join(paths.finalDir, "patch.diff"), winnerRun.diff);
2690
+ const wstats = diffStats(winnerRun.diff);
2691
+ const hasDiff = winnerRun.diff.trim().length > 0;
2692
+ const blockers = winnerEvidence
2693
+ ? winnerEvidence.findings.filter((f) => isBlocking(f)).length
2694
+ : 0;
2695
+ const resultKind = hasDiff ? "patch" : winnerAnswer.length > 0 ? "answer" : "none";
2696
+ // Only a fully verified, applyable success may auto-adopt; a not-verified
2697
+ // or needs-decision terminal remains an inspectable artifact.
2698
+ const adoptable = facts.lifecycle === "succeeded" &&
2699
+ facts.review === "approved" &&
2700
+ facts.checks !== "failed";
2701
+ let adopted = null;
2702
+ let applyState = "not_applied";
2703
+ let postTurnSha = null;
2704
+ let revertAnchorId = null;
2705
+ if (input.inPlace === true && hasDiff) {
2706
+ if (inPlaceWinner) {
2707
+ // Already live: the candidate ran in-place and wrote the tree itself.
2735
2708
  adopted = true;
2736
- applyState = "applied";
2737
- log.emit("work_product.adopted", {
2738
- applied: true,
2739
- patch_sha256: patchSha256,
2740
- winner: winnerRun.attemptId,
2741
- });
2742
- try {
2743
- postTurnSha = await snapshotTree(execRoot);
2709
+ applyState = adoptable ? "applied" : "applied_review_blocked";
2710
+ // The pre-review fence excludes later user edits from the target.
2711
+ postTurnSha = earlyPostTurnSha;
2712
+ }
2713
+ else if (adoptable) {
2714
+ // Protected apply preserves the live tree or reports tree_mutated.
2715
+ const applied = await verifyAndDeliver(execRoot, winnerRun.diff, { mode: "apply", protectedApply: true }, gateSpecsFromContract(contract), (freshVerify) => {
2716
+ finalVerify = freshVerify;
2717
+ return finalVerifyBlocks(freshVerify)
2718
+ ? (freshVerify.reason ?? "final verify failed before race adoption")
2719
+ : null;
2720
+ }, log);
2721
+ raceDeliveryReceipt = applied;
2722
+ store.writeYaml(join(paths.finalDir, "delivery_receipt.yaml"), applied);
2723
+ finalVerify = applied.finalVerify;
2724
+ if (applied.applied) {
2725
+ adopted = true;
2726
+ applyState = "applied";
2727
+ log.emit("work_product.adopted", {
2728
+ applied: true,
2729
+ patch_sha256: patchSha256,
2730
+ winner: winnerRun.attemptId,
2731
+ });
2732
+ try {
2733
+ postTurnSha = await snapshotTree(execRoot);
2734
+ }
2735
+ catch {
2736
+ postTurnSha = null;
2737
+ }
2738
+ revertAnchorId = createRevertAnchorFromPatchOrNull(execRoot, winnerRun.diff);
2744
2739
  }
2745
- catch {
2746
- postTurnSha = null;
2740
+ else {
2741
+ adopted = false;
2742
+ applyState = "not_applied";
2743
+ deliveryFailureReason = applied.detail ?? "race adoption delivery was refused";
2744
+ facts = { ...facts, checks: "failed", reason: "checks_failed" };
2745
+ if (finalVerifyBlocks(finalVerify))
2746
+ finalVerifyFailed = true;
2747
+ log.emit("work_product.adopted", {
2748
+ applied: false,
2749
+ patch_sha256: patchSha256,
2750
+ detail: redactSecrets(applied.detail ?? "apply failed"),
2751
+ tree_mutated: applied.treeMutated,
2752
+ });
2747
2753
  }
2748
- revertAnchorId = createRevertAnchorFromPatchOrNull(execRoot, winnerRun.diff);
2749
- }
2750
- else {
2751
- adopted = false;
2752
- applyState = "not_applied";
2753
- deliveryFailureReason = applied.detail ?? "race adoption delivery was refused";
2754
- facts = { ...facts, checks: "failed", reason: "checks_failed" };
2755
- if (finalVerifyBlocks(finalVerify))
2756
- finalVerifyFailed = true;
2757
- log.emit("work_product.adopted", {
2758
- applied: false,
2759
- patch_sha256: patchSha256,
2760
- detail: redactSecrets(applied.detail ?? "apply failed"),
2761
- tree_mutated: applied.treeMutated,
2762
- });
2763
2754
  }
2764
2755
  }
2765
- }
2766
- writeRaceDeliveryDecision(store, decisionPath, {
2767
- decision: result.decision,
2768
- facts,
2769
- reviewVerified: actualReviewVerified,
2770
- finalVerify,
2771
- deliveryFailureReason,
2772
- deliveryReceiptPath: raceDeliveryReceipt ? "final/delivery_receipt.yaml" : null,
2773
- });
2774
- if (inPlaceWinner && requestedSingleCandidate && adopted === true) {
2775
- revertAnchorId = await createRevertAnchorOrNull(execRoot, preTurnSha, postTurnSha);
2776
- }
2777
- store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
2778
- id: newId("wp"),
2779
- kind: input.create === true ? "new_repo" : "patch",
2780
- source_task_id: taskId,
2781
- producer_attempt_id: winnerRun.attemptId,
2782
- ...(raceDeliveryReceipt
2783
- ? { files: { delivery_receipt: "final/delivery_receipt.yaml" } }
2784
- : {}),
2785
- meta: {
2786
- harness_id: winnerRun.harnessId,
2787
- synthesis: synth,
2788
- mode,
2789
- // Artifact-only apply reads the same terminal axes as the daemon (D8).
2790
- lifecycle: facts.lifecycle,
2791
- outcome_facts: facts,
2792
- review_verified: actualReviewVerified,
2793
- budget_stopped: budgetStopped,
2794
- patch_sha256: patchSha256,
2795
- result_kind: resultKind,
2796
- diffstat: {
2797
- files: wstats.paths.length,
2798
- additions: wstats.additions,
2799
- deletions: wstats.deletions,
2756
+ writeRaceDeliveryDecision(store, decisionPath, {
2757
+ decision: result.decision,
2758
+ facts,
2759
+ reviewVerified: actualReviewVerified,
2760
+ finalVerify,
2761
+ deliveryFailureReason,
2762
+ deliveryReceiptPath: raceDeliveryReceipt ? "final/delivery_receipt.yaml" : null,
2763
+ });
2764
+ if (inPlaceWinner && requestedSingleCandidate && adopted === true) {
2765
+ revertAnchorId = await createRevertAnchorOrNull(execRoot, preTurnSha, postTurnSha);
2766
+ }
2767
+ store.writeYaml(join(paths.finalDir, "work_product.yaml"), {
2768
+ id: newId("wp"),
2769
+ kind: input.create === true ? "new_repo" : "patch",
2770
+ source_task_id: taskId,
2771
+ producer_attempt_id: winnerRun.attemptId,
2772
+ ...(raceDeliveryReceipt
2773
+ ? { files: { delivery_receipt: "final/delivery_receipt.yaml" } }
2774
+ : {}),
2775
+ meta: {
2776
+ harness_id: winnerRun.harnessId,
2777
+ synthesis: synth,
2778
+ mode,
2779
+ // Artifact-only apply reads the same terminal axes as the daemon (D8).
2780
+ lifecycle: facts.lifecycle,
2781
+ outcome_facts: facts,
2782
+ review_verified: actualReviewVerified,
2783
+ budget_stopped: budgetStopped,
2784
+ patch_sha256: patchSha256,
2785
+ result_kind: resultKind,
2786
+ diffstat: {
2787
+ files: wstats.paths.length,
2788
+ additions: wstats.additions,
2789
+ deletions: wstats.deletions,
2790
+ },
2791
+ blockers,
2792
+ adopted,
2793
+ apply_state: applyState,
2794
+ pre_turn_sha: preTurnSha,
2795
+ post_turn_sha: postTurnSha,
2796
+ revert_anchor_id: revertAnchorId,
2800
2797
  },
2801
- blockers,
2802
- adopted,
2803
- apply_state: applyState,
2804
- pre_turn_sha: preTurnSha,
2805
- post_turn_sha: postTurnSha,
2806
- revert_anchor_id: revertAnchorId,
2807
- },
2808
- });
2798
+ });
2799
+ }
2809
2800
  store.writeText(join(paths.finalDir, "summary.md"), renderSummary(runId, mode, { ...result.decision, facts }, evidences, synth.reason, actualReviewVerified));
2810
2801
  // summary.md is a DIAGNOSTIC artifact only (V8/PLAN addendum 2): it no
2811
2802
  // longer carries primary-output authority. A clean applyable success or a
@@ -2906,7 +2897,9 @@ export class Orchestrator {
2906
2897
  });
2907
2898
  }
2908
2899
  }
2909
- log.emit("work_product.emitted", { winner: result.decision.winner });
2900
+ if (mutatingRun && winnerRun) {
2901
+ log.emit("work_product.emitted", { winner: result.decision.winner });
2902
+ }
2910
2903
  if (!isFailureTerminal) {
2911
2904
  log.emit("run.completed", { lifecycle: facts.lifecycle, facts, reason: facts.reason });
2912
2905
  }
@@ -4032,15 +4025,14 @@ export class Orchestrator {
4032
4025
  store.writeYaml(join(paths.contextDir, "task.yaml"), contract);
4033
4026
  log.emit("task.contract.created", { task_contract_hash: hashJson(contract) });
4034
4027
  // W3.3: ONE resolved read-only context — the routing point-probe and every
4035
- // planner spawn consume the SAME scoped env (see routeContext.ts). The
4036
- // probe home stays a disposable throwaway even for a thread lane turn (auth
4037
- // truth is home-independent); only the planner spawn swaps in the durable
4038
- // per-lane home below so its recorded native session survives.
4039
- const roHome = resolveReadOnlyRouteContext(this.execRootOf(input));
4028
+ // planner spawn consume the SAME scoped env (see routeContext.ts).
4040
4029
  // A thread PLAN turn is a chat turn (INV-034): plan candidates are distinct
4041
- // harnesses run sequentially, so each records its own lane's native session
4042
- // and the next lane turn resumes it via `sessionSpecFields.resume_session_id`.
4030
+ // harnesses run sequentially. Doctor, model inventory, and spawn share each
4031
+ // lane's durable HOME; the disposable context remains the non-thread fallback.
4043
4032
  const laneRun = Boolean(input.threadId);
4033
+ const roHome = resolveReadOnlyRouteContext(this.execRootOf(input), laneRun
4034
+ ? (id) => this.laneHomeEnvFor(input, id, input.credentialProfileId ?? null)
4035
+ : undefined);
4044
4036
  let adapters;
4045
4037
  try {
4046
4038
  adapters = await this.resolveCandidateAdapters({ ...input, n: undefined }, "plan", ledger, log, roHome, runId);
@@ -4401,17 +4393,13 @@ export class Orchestrator {
4401
4393
  : Math.min(Math.max(input.n ?? 2, 1), 3);
4402
4394
  // W3.3: ONE resolved read-only context — the routing point-probe and every
4403
4395
  // read-only attempt spawn consume the SAME scoped env (see routeContext.ts).
4404
- // The point-probe home is a disposable throwaway even for a thread lane
4405
- // turn: readiness auth truth is home-INDEPENDENT (credentials come from the
4406
- // profile/keychain/default store, never the scoped home), so the probe and
4407
- // the run share the same auth source; only the ACTUAL spawn swaps in the
4408
- // durable per-lane home below so the recorded native session survives.
4409
- const roHome = resolveReadOnlyRouteContext(this.execRootOf(input));
4410
4396
  // A thread ASK turn is a chat turn: its native session is recorded per lane
4411
- // and the next lane turn resumes it (INV-034). Deep-scan (multi-scout
4412
- // research) and orchestrate (tool-belt planner, not the user's chat) are
4413
- // NOT lane chat turns — they keep the disposable home and record nothing.
4397
+ // and the next lane turn resumes it (INV-034). Its doctor/model/spawn path
4398
+ // shares that lane HOME; deep-scan and one-shot asks keep the disposable one.
4414
4399
  const laneRun = Boolean(input.threadId) && opts.mode === "ask" && !opts.deepScan;
4400
+ const roHome = resolveReadOnlyRouteContext(this.execRootOf(input), laneRun
4401
+ ? (id) => this.laneHomeEnvFor(input, id, input.credentialProfileId ?? null)
4402
+ : undefined);
4415
4403
  let adapters;
4416
4404
  try {
4417
4405
  adapters = await this.resolveCandidateAdapters({ ...input, prompt, n: width }, opts.intent, ledger, log, roHome, runId,
@@ -4707,7 +4695,7 @@ export class Orchestrator {
4707
4695
  ...(knobs.ignored.length > 0 ? { ignored_settings: knobs.ignored } : {}),
4708
4696
  });
4709
4697
  try {
4710
- const watchedReport = withInactivityWatchdog(adapter.run(runSpec), {
4698
+ const watchedReport = withInactivityWatchdog(runModelGovernedRoute(routed, runSpec), {
4711
4699
  timeoutMs: harnessInactivityTimeoutMs(this.config(input.repoRoot)),
4712
4700
  countsAsProgress: countsAsAgentProgress,
4713
4701
  onTimeout: () => {