@moretea-labs/forge 1.7.0 → 1.7.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 (175) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +13 -0
  3. package/CLAUDE.md +2 -0
  4. package/THIRD_PARTY_NOTICES.md +1 -1
  5. package/agents/openai.yaml +1 -1
  6. package/assets/partials-agents/02-operating-mode.partial.md +2 -0
  7. package/assets/plugin-registry.v1.json +3 -3
  8. package/assets/skill-version.json +2 -2
  9. package/assets/templates/helpers/check-architecture-sync.sh +17 -0
  10. package/assets/templates/helpers/inspect-project-state.ts +8 -6
  11. package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
  12. package/assets/templates/helpers/workflow-contract.ts +5 -1
  13. package/docs/architecture/CURRENT.md +12 -4
  14. package/docs/forge-plugin-management.md +1 -1
  15. package/docs/operations/controller-performance-and-502.md +19 -0
  16. package/docs/operations/homebrew.md +2 -2
  17. package/package.json +5 -2
  18. package/scripts/architecture-event.ts +2 -820
  19. package/scripts/architecture-queue.sh +2 -645
  20. package/scripts/archive-architecture-request.sh +2 -242
  21. package/scripts/archive-workflow.sh +1 -291
  22. package/scripts/benchmark-work-efficiency.ts +0 -0
  23. package/scripts/capability-config.ts +2 -392
  24. package/scripts/capability-resolver.ts +2 -503
  25. package/scripts/capture-plan.sh +1 -332
  26. package/scripts/check-agent-tooling.sh +2 -1531
  27. package/scripts/check-architecture-sync.sh +2 -302
  28. package/scripts/check-brain-manifest.sh +2 -253
  29. package/scripts/check-context-files.sh +2 -47
  30. package/scripts/check-deploy-sql-order.sh +2 -115
  31. package/scripts/check-runtime-architecture.mjs +243 -16
  32. package/scripts/check-skill-version.ts +3 -191
  33. package/scripts/check-source-duplication.mjs +61 -0
  34. package/scripts/check-stable-baseline.ts +0 -0
  35. package/scripts/check-task-sync.sh +2 -63
  36. package/scripts/check-task-workflow.sh +2 -1000
  37. package/scripts/codex-handoff-resume.sh +2 -267
  38. package/scripts/context-contract-sync.sh +2 -514
  39. package/scripts/contract-run.ts +2 -463
  40. package/scripts/contract-worktree.sh +1 -702
  41. package/scripts/ensure-task-workflow.sh +1 -1192
  42. package/scripts/inspect-project-state.ts +4 -279
  43. package/scripts/lib/project-init-lib.sh +7 -2
  44. package/scripts/lib/run-forge-helper.sh +19 -0
  45. package/scripts/lib/run-forge-helper.ts +26 -0
  46. package/scripts/migrate-project-template.sh +1 -33
  47. package/scripts/migrate-workflow-docs.ts +4 -412
  48. package/scripts/new-plan.sh +1 -180
  49. package/scripts/new-spec.sh +1 -47
  50. package/scripts/new-sprint.sh +1 -46
  51. package/scripts/plan-to-todo.sh +1 -771
  52. package/scripts/prepare-codex-handoff.sh +2 -201
  53. package/scripts/prepare-handoff.sh +1 -78
  54. package/scripts/refresh-current-status.sh +2 -467
  55. package/scripts/run-governed-gate.ts +3 -0
  56. package/scripts/select-agent-context-blocks.sh +2 -64
  57. package/scripts/ship-worktrees.sh +1 -531
  58. package/scripts/sprint-backlog.sh +1 -834
  59. package/scripts/stage-runtime-release.ts +2 -1
  60. package/scripts/summarize-failures.sh +2 -123
  61. package/scripts/switch-plan.sh +1 -150
  62. package/scripts/sync-brain-docs.sh +2 -379
  63. package/scripts/sync-generated-authority.mjs +175 -0
  64. package/scripts/verify-contract.sh +2 -593
  65. package/scripts/verify-sprint.sh +1 -500
  66. package/scripts/workflow-contract.ts +4 -201
  67. package/scripts/workstream-sync.sh +2 -261
  68. package/src/cli/agent-jobs/executor-health.ts +1 -1
  69. package/src/cli/commands/plugin.ts +12 -3
  70. package/src/cli/controller/check-runner.ts +7 -2
  71. package/src/cli/controller/context-pack.ts +46 -21
  72. package/src/cli/github/contracts.ts +16 -0
  73. package/src/cli/github/github.ts +2 -16
  74. package/src/cli/github/session.ts +2 -1
  75. package/src/cli/local-bridge/facade-api.ts +37 -47
  76. package/src/cli/local-bridge/server.ts +2 -2
  77. package/src/cli/mcp/legacy-tool-service.ts +10 -33
  78. package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
  79. package/src/cli/mcp/repository-tools.ts +85 -89
  80. package/src/cli/mcp/server.ts +57 -13
  81. package/src/cli/mcp/tool-contract.ts +14 -0
  82. package/src/cli/mcp/toolset.ts +2 -2
  83. package/src/cli/mcp/transports/http.ts +23 -0
  84. package/src/cli/repositories/command-classifier.ts +2 -2
  85. package/src/cli/repositories/command-executor.ts +24 -6
  86. package/src/cli/repositories/repository-snapshot.ts +5 -2
  87. package/src/cli/repositories/structured-git.ts +57 -3
  88. package/src/cli/repository/inspector.ts +11 -3
  89. package/src/runtime/context/generic-lsp-provider.ts +1 -1
  90. package/src/runtime/context/semantic-navigation-contract.ts +61 -0
  91. package/src/runtime/context/semantic-navigation.ts +9 -61
  92. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +18 -25
  93. package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
  94. package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
  95. package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
  96. package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
  97. package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
  98. package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
  99. package/src/runtime/control-plane/execution/work-handle-authority.ts +96 -0
  100. package/src/runtime/control-plane/execution/work-handle-store.ts +1 -1
  101. package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
  102. package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
  103. package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
  104. package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
  105. package/src/runtime/control-plane/execution/work-task-receipt.ts +4 -43
  106. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +13 -3
  107. package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
  108. package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
  109. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +5 -1
  110. package/src/runtime/control-plane/facade/controller-round-relay.ts +164 -37
  111. package/src/runtime/control-plane/facade/controller-session-store.ts +183 -24
  112. package/src/runtime/control-plane/facade/goal-workloop-access.ts +15 -1
  113. package/src/runtime/control-plane/facade/goal-workloop.ts +329 -148
  114. package/src/runtime/control-plane/facade/index.ts +2 -0
  115. package/src/runtime/control-plane/facade/plan-contract-store.ts +90 -22
  116. package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
  117. package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
  118. package/src/runtime/control-plane/facade/types.ts +68 -5
  119. package/src/runtime/control-plane/facade/work-contract-store.ts +3 -209
  120. package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
  121. package/src/runtime/control-plane/global-scheduler/maintenance.ts +14 -7
  122. package/src/runtime/control-plane/global-scheduler/scheduler.ts +5 -1
  123. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +13 -0
  124. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +15 -6
  125. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +54 -5
  126. package/src/runtime/control-plane/launcher/thin-launcher.ts +56 -7
  127. package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
  128. package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
  129. package/src/runtime/control-plane/routing/route-policy.ts +18 -6
  130. package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
  131. package/src/runtime/evidence/process-check-execution.ts +18 -0
  132. package/src/runtime/execution/jobs/child-reference.ts +6 -2
  133. package/src/runtime/execution/process-runtime/check-result.ts +53 -18
  134. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
  135. package/src/runtime/execution/process-runtime/command-facade.ts +39 -17
  136. package/src/runtime/execution/process-runtime/lightweight-managed.ts +20 -3
  137. package/src/runtime/execution/process-runtime/persisted-check.ts +4 -1
  138. package/src/runtime/execution/process-runtime/runtime.ts +10 -0
  139. package/src/runtime/execution/process-runtime/types.ts +2 -12
  140. package/src/runtime/execution/work-activity.ts +21 -0
  141. package/src/runtime/execution/workers/executor.ts +18 -13
  142. package/src/runtime/gateway/mcp/execution-tools.ts +35 -2930
  143. package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
  144. package/src/runtime/gateway/mcp/router.ts +2 -2
  145. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +21 -2
  146. package/src/runtime/gateway/mcp/runtime-tools.ts +805 -1009
  147. package/src/runtime/plugins/app-store-connect-adapter.ts +93 -28
  148. package/src/runtime/plugins/browser-adapter.ts +256 -1083
  149. package/src/runtime/plugins/browser-handoff-host.ts +2 -25
  150. package/src/runtime/plugins/browser-handoff.ts +18 -3
  151. package/src/runtime/plugins/browser-macos-bridge.ts +121 -16
  152. package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
  153. package/src/runtime/plugins/browser-native-session-service.ts +222 -0
  154. package/src/runtime/plugins/browser-runtime-contract.ts +2 -0
  155. package/src/runtime/plugins/browser-runtime.ts +10 -0
  156. package/src/runtime/plugins/browser-session-store.ts +121 -0
  157. package/src/runtime/plugins/browser-session-types.ts +98 -0
  158. package/src/runtime/plugins/desktop-operator-registration.ts +4 -4
  159. package/src/runtime/plugins/external-adapter.ts +92 -58
  160. package/src/runtime/plugins/macos-capability-broker.ts +0 -8
  161. package/src/runtime/plugins/store.ts +66 -9
  162. package/src/runtime/plugins/types.ts +3 -1
  163. package/src/runtime/recovery/maintenance-executor.ts +61 -25
  164. package/src/runtime/root/package-connector-service.ts +46 -4
  165. package/src/runtime/root/package-runtime-release.ts +1 -1
  166. package/src/runtime/root/package-runtime-service.ts +33 -2
  167. package/src/runtime/root/release-manifest.ts +137 -16
  168. package/src/runtime/root/release-materialize.ts +109 -35
  169. package/src/runtime/root/types.ts +10 -0
  170. package/src/runtime/standalone-recovery/core.ts +425 -194
  171. package/src/runtime/standalone-recovery/entry.ts +1 -1
  172. package/src/runtime/standalone-recovery/installer.ts +1 -1
  173. package/src/runtime/workflow/schedules/engine.ts +113 -87
  174. package/src/runtime/workflow/schedules/settlement.ts +26 -23
  175. package/src/runtime/workflow/schedules/store.ts +79 -10
@@ -6,8 +6,10 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
6
6
  import type { CallToolResult as SdkCallToolResult } from '@modelcontextprotocol/sdk/types.js';
7
7
  import { collectRuntimePerformanceDiagnostics, inferLocalControllerProcess } from '../../diagnostics/performance';
8
8
  import { defaultSemanticProviderRegistry, type SemanticNavigationKind, type SemanticNavigationRequest } from '../../context/semantic-navigation';
9
- import type { McpToolDefinition, CallToolResult } from '../../../cli/mcp/tools';
9
+ import type { McpToolDefinition, CallToolResult } from '../../../cli/mcp/tool-contract';
10
10
  import type { MultiRepositoryMcpToolContext } from '../../../cli/mcp/multi-repository';
11
+ import { allControllerToolDefinitions, controllerExposureSnapshot, controllerToolSurfaceStatus } from '../../../cli/mcp/toolset';
12
+ import { legacyIosPluginInvocation } from './legacy-ios-tool-adapter';
11
13
  import { repositoryScopedToolArgs } from '../../../cli/mcp/multi-repository';
12
14
  import { resolveMcpPath } from '../../../cli/mcp/paths';
13
15
  import { freshGitIdentity } from '../../../cli/repository/inspector';
@@ -18,14 +20,19 @@ import { repositoryControllerRoot } from '../../../cli/repositories/controller-h
18
20
  import { cancelExecutionJob, findExecutionJob, getExecutionJob, getExecutionJobByRequestId, listExecutionJobs } from '../../execution/jobs/store';
19
21
  import { waitForExecutionJob } from '../../execution/jobs/wait';
20
22
  import type { ExecutionJob } from '../../execution/jobs/types';
21
- import { DEFAULT_WORK_CHECK_LEASE_WAIT_MS, checkRequiresDurableWorkflow, getProcessHandle, getProcessRecord, isManagedProcessActive, listProcessRecords, listRecoverableProcessRecords, processCheckCompletionReceipt, processRuntimeResourceDiagnostics, readPersistedCheckResultReceipt, runPersistedCheckViaProcessRuntime, waitForProcess } from '../../execution/process-runtime';
23
+ import { DEFAULT_WORK_CHECK_LEASE_WAIT_MS, getProcessHandle, getProcessRecord, isManagedProcessActive, listProcessRecords, listRecoverableProcessRecords, processCheckCompletionReceipt, processRuntimeResourceDiagnostics } from '../../execution/process-runtime';
24
+ import { classifyPersistedCheckTerminalEvidence } from '../../execution/process-runtime/check-result';
25
+ export { classifyTerminalCheckEvidence } from '../../execution/process-runtime/check-result';
26
+ import { listWorkBoundRepositoryProcessEvidence, listWorkBoundRepositoryRemoteEffectProcessEvidence } from '../../control-plane/execution/work-process-evidence';
27
+ import { appendWorkEvidence, recordWorkCompletionReceipt } from '../../control-plane/facade/work-contract-store';
22
28
  import { getRepositoryCommandProcess, waitRepositoryCommandProcess } from '../../execution/process-runtime/command-facade';
23
29
  import { buildJobOperationDigest } from '../../control-plane/facade/operation-digest';
24
- import { readWorkHandle, transitionWorkHandle, workDeliveryBaseRevision, writeWorkHandle, type WorkHandleState } from '../../control-plane/execution/work-handle-store';
30
+ import { readWorkHandle, transitionWorkHandle, workDeliveryBaseRevision, type WorkHandleState } from '../../control-plane/execution/work-handle-store';
31
+ import { ensureRepositoryWorkHandle, rebindRepositoryWorkHandleControllerIdentity } from '../../control-plane/execution/work-handle-authority';
25
32
  import { recoverTerminalWorkHandle } from '../../control-plane/execution/work-terminal-cleanup';
26
- import { gitCommitAtRef, gitWorktreeSnapshot } from '../../control-plane/execution/work-lifecycle-audit';
27
- import { executionIdentityForRepository } from '../../control-plane/execution/execution-identity';
28
33
  import { commandFingerprint, verificationInputFingerprint, workspaceValidationFingerprint } from '../../control-plane/execution/verification-evidence';
34
+ import { resolveWorkVerificationContext } from '../../control-plane/execution/work-verification-context';
35
+ import { executeWorkVerification } from '../../control-plane/execution/work-verification-service';
29
36
  import { acceptReviewedDirectEditWorkReconciliation, reconcileFinalizedDirectEditWorksAfterCommit } from '../../control-plane/execution/direct-edit-work-completion';
30
37
  import { readJobEvents } from '../../evidence/event-ledger';
31
38
  import { readExecutionArtifact } from '../../evidence/artifact-store';
@@ -58,12 +65,6 @@ import {
58
65
  triggerWorkContinuationSchedule,
59
66
  type ContinuationControllerType,
60
67
  } from '../../workflow/schedules/work-continuation';
61
- import {
62
- gatewayRouteBehaviorSnapshot,
63
- getMcpToolDefinition,
64
- RETIRED_AGENT_OPERATIONS,
65
- routeDurableMcpCall,
66
- } from './router';
67
68
  import { assertAutomatedOperationAllowed } from '../../control-plane/governance/external-effects';
68
69
  import { ensureRepositoryRuntimeStorage } from '../../../cli/repositories/runtime-storage';
69
70
  import { assessWorkMode, parseExplicitTaskMode } from '../../../cli/controller/work-mode';
@@ -174,8 +175,6 @@ import {
174
175
  listWorkContracts,
175
176
  getWorkContract,
176
177
  getWorkContractByRequestId,
177
- updateWorkContract,
178
- resumeRetainedCancelledWorkContract,
179
178
  buildWorkContinuationSnapshot,
180
179
  acceptPlanStepEvidence,
181
180
  admitPlanContractAsync,
@@ -185,40 +184,48 @@ import {
185
184
  resolvePlanAdmission,
186
185
  withPrimaryWorkAdmissionLockAsync,
187
186
  repairDanglingPlanStepWorkBinding,
187
+ repairDraftPlanContractAsync,
188
188
  completePlanStepForWork,
189
189
  summarizePlanContract,
190
190
  supersedePlanContract,
191
191
  verifyGoalWorkloop,
192
192
  type FacadeTool,
193
+ bindControllerSessionToCurrentRuntime,
193
194
  claimControllerSession,
195
+ controllerSessionPrincipalId,
194
196
  getControllerSession,
195
- releaseControllerSession,
196
197
  releaseControllerSessionWithAuthority,
198
+ releaseObservedControllerSession,
197
199
  resumeControllerSession,
198
200
  withControllerSessionTerminalizationFence,
199
201
  type ControllerTerminalizationAuthority,
200
202
  } from '../../control-plane/facade';
201
203
  import { currentControllerInstanceId, readExecutionSession, startExecutionSession, updateExecutionSession } from '../../control-plane/execution/session-store';
202
204
  import { changedPaths as workChangedPaths } from '../../control-plane/execution/work-task-receipt';
203
- import { createRequirement, readRequirement } from '../../control-plane/persistence/requirement-store';
205
+ import { readRequirement } from '../../control-plane/persistence/requirement-store';
206
+ import { admitRequirement } from '../../control-plane/facade/requirement-authority';
204
207
  import { ensureManagedWorkspace } from '../../execution/managed-workspace';
208
+ import { materializeRepositoryWorkPlacement } from '../../control-plane/facade/repository-work-admission';
209
+ import { reauthorizeRetainedCancelledRepositoryWork } from '../../control-plane/execution/retained-work-resume';
205
210
  import { currentPermissionSnapshotVersion } from '../../control-plane/execution/validation';
206
211
  import { observeRuntimeStatus } from '../../root/status';
207
212
  import { reconcileWorkValidation } from './work-validation-reconciler';
208
213
  import { callExecutionTool } from './execution-tools';
209
214
  import { launchSuperController } from '../../control-plane/launcher/thin-launcher';
210
215
  import { runStandaloneChatgptPrompt, runWorkChatgptContinuation, settleWorkChatgptAutomationTab } from '../../control-plane/launcher/chatgpt-work-continuation';
211
- import { getChatgptWorkConversationBinding } from '../../control-plane/launcher/chatgpt-work-binding-store';
216
+ import { getChatgptWorkConversationBinding, hasChatgptConversationIdentity } from '../../control-plane/launcher/chatgpt-work-binding-store';
212
217
  import {
213
218
  acknowledgeControllerRoundClaim,
214
219
  beginControllerRoundRelayAfterRelease,
215
220
  beginInitialControllerRoundDispatch,
221
+ reconcileControllerRoundAfterAbandonedRelease,
216
222
  buildControllerRoundRelayPrompt,
217
223
  finishControllerRoundRelayDispatch,
218
224
  getControllerRoundRelay,
219
225
  parseControllerDispositionCompatibilityCapability,
220
226
  recordControllerRoundTabSettlement,
221
227
  submitControllerRoundDisposition,
228
+ type ControllerRoundRelayRecord,
222
229
  type ControllerRoundDisposition,
223
230
  } from '../../control-plane/facade/controller-round-relay';
224
231
 
@@ -617,30 +624,6 @@ async function callStandaloneRecoveryTool(
617
624
 
618
625
  export const RH_WORK_VERIFY_LEASE_WAIT_MS = DEFAULT_WORK_CHECK_LEASE_WAIT_MS;
619
626
 
620
- type TerminalCheckEvidenceState = 'matched' | 'process_runtime_failed_before_result' | 'missing' | 'mismatch';
621
-
622
- export function classifyTerminalCheckEvidence(input: {
623
- processError?: { code: string; message: string };
624
- structuredPresent: boolean;
625
- structuredMatches: boolean;
626
- legacyPresent: boolean;
627
- legacyMatches: boolean;
628
- }): { state: TerminalCheckEvidenceState; warning?: string; infrastructureReason?: string } {
629
- if (input.structuredMatches || input.legacyMatches) return { state: 'matched' };
630
- if (!input.structuredPresent && !input.legacyPresent && input.processError?.message?.trim()) {
631
- const reason = input.processError.message.trim().slice(0, 512);
632
- return {
633
- state: 'process_runtime_failed_before_result',
634
- warning: `check process failed before structured result receipt: ${reason}`,
635
- infrastructureReason: reason,
636
- };
637
- }
638
- if (input.structuredPresent || input.legacyPresent) {
639
- return { state: 'mismatch', warning: 'check result receipt did not match the terminal Process semantic identity' };
640
- }
641
- return { state: 'missing', warning: 'check result receipt is missing for the terminal Check Process' };
642
- }
643
-
644
627
  function result(value: Record<string, unknown>, isError = false): CallToolResult {
645
628
  // Compact text channel by default (no pretty-print bloat).
646
629
  return { content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value, ...(isError ? { isError: true } : {}) };
@@ -849,6 +832,7 @@ export interface RuntimeIdentitySnapshot {
849
832
  runtimeInstanceId?: string;
850
833
  controllerInstanceId?: string;
851
834
  endpoint?: string;
835
+ running?: boolean;
852
836
  ready?: boolean;
853
837
  reasonCodes?: string[];
854
838
  toolset?: string;
@@ -869,6 +853,7 @@ export function runtimeIdentitySnapshot(ctx: MultiRepositoryMcpToolContext): Run
869
853
  runtimeInstanceId: snapshot?.runtimeInstanceId,
870
854
  controllerInstanceId: snapshot?.runtimeInstanceId,
871
855
  endpoint: snapshot?.endpoint,
856
+ running: observation.running,
872
857
  ready: observation.ready,
873
858
  reasonCodes: observation.reasonCodes,
874
859
  toolset: ctx.toolset,
@@ -1118,6 +1103,7 @@ function compactControllerContextSummaryPayload(payload: Record<string, unknown>
1118
1103
  function authenticatedFacadeControllerIdentity(
1119
1104
  ctx: MultiRepositoryMcpToolContext,
1120
1105
  args: Record<string, unknown>,
1106
+ options: { allowTransportSessionRollover?: boolean } = {},
1121
1107
  ): { controllerId: string; principalId: string; sessionId: string; controllerInstanceId: string; controllerType: 'chatgpt' | 'codex' | 'claude' | 'grok' | 'human' } {
1122
1108
  const principalId = ctx.principalId?.trim();
1123
1109
  const sessionId = ctx.sessionId?.trim();
@@ -1129,7 +1115,7 @@ function authenticatedFacadeControllerIdentity(
1129
1115
  if (requestedControllerId && requestedControllerId !== principalId) {
1130
1116
  throw new Error('CONTROLLER_ID_CONTEXT_MISMATCH: controller_id must match the authenticated principal');
1131
1117
  }
1132
- if (requestedSessionId && requestedSessionId !== sessionId) {
1118
+ if (!options.allowTransportSessionRollover && requestedSessionId && requestedSessionId !== sessionId) {
1133
1119
  throw new Error('CONTROLLER_SESSION_CONTEXT_MISMATCH: session_id must match the authenticated MCP session');
1134
1120
  }
1135
1121
  const requestedControllerType = typeof args.controller_type === 'string' && ['chatgpt', 'codex', 'claude', 'grok', 'human'].includes(args.controller_type)
@@ -1148,245 +1134,174 @@ function authenticatedFacadeControllerIdentity(
1148
1134
  };
1149
1135
  }
1150
1136
 
1151
- function ensureFacadeWorkHandle(
1137
+ export function dispatchedChatgptRelayAuthorizesStaleControllerRecovery(
1138
+ relay: ControllerRoundRelayRecord | undefined,
1139
+ controllerType: 'chatgpt' | 'codex' | 'claude' | 'grok' | 'human',
1140
+ ): boolean {
1141
+ if (controllerType !== 'chatgpt' || relay?.status !== 'dispatched' || !relay.browserSessionId || !relay.conversationUrl) {
1142
+ return false;
1143
+ }
1144
+ try {
1145
+ return hasChatgptConversationIdentity(relay.conversationUrl);
1146
+ } catch {
1147
+ return false;
1148
+ }
1149
+ }
1150
+
1151
+ function assertFacadeControllerRoundAuthority(
1152
1152
  ctx: MultiRepositoryMcpToolContext,
1153
- repository: ReturnType<typeof selected>,
1153
+ store: { controllerHome: string; repoId: string },
1154
1154
  workId: string,
1155
1155
  args: Record<string, unknown>,
1156
- ): WorkHandleState | undefined {
1157
- const existing = readWorkHandle(ctx.controllerHome, repository.repoId, workId);
1158
- if (existing) return existing;
1159
- const contract = getWorkContract({ controllerHome: ctx.controllerHome, repoId: repository.repoId }, workId);
1160
- if (!contract || contract.workKind !== 'repository_change' || contract.mode !== 'goal_workloop' || !contract.checkoutId) return undefined;
1161
- const executionRepository = resolveRepositorySelection({ repoId: repository.repoId, checkoutId: contract.checkoutId, controllerHome: ctx.controllerHome, allowSoleRepository: false });
1162
- const checkout = selectRepositoryCheckout(executionRepository, contract.checkoutId, { allowArchived: true });
1163
- const status = repositoryGitStatus(checkout);
1164
- if (!status.branch) throw new Error(`WORKTREE_DETACHED: ${contract.checkoutId} has no branch`);
1165
- const identity = authenticatedFacadeControllerIdentity(ctx, args);
1166
- const at = new Date().toISOString();
1167
- const managedWorktree = Boolean(contract.worktreeRef) && resolve(contract.worktreeRef!) === resolve(checkout.canonicalRoot) && resolve(checkout.canonicalRoot) !== resolve(repository.canonicalRoot);
1168
- return writeWorkHandle(ctx.controllerHome, {
1169
- schemaVersion: 1,
1170
- workId,
1171
- sessionId: identity.sessionId,
1172
- principalId: identity.principalId,
1173
- repositoryId: repository.repoId,
1174
- checkoutId: contract.checkoutId,
1175
- worktreePath: checkout.canonicalRoot,
1176
- branch: status.branch,
1177
- sourceCheckoutId: repository.activeCheckoutId,
1178
- managedWorktree,
1179
- workContractId: contract.workId,
1180
- baseCommit: contract.baseRevision ?? status.head ?? undefined,
1181
- deliveryBaseCommit: contract.baseRevision ?? status.head ?? undefined,
1182
- expectedHead: status.head ?? contract.baseRevision,
1183
- permissionSnapshotVersion: currentPermissionSnapshotVersion(ctx.controllerHome, repository.repoId),
1184
- state: 'prepared',
1185
- createdAt: at,
1186
- updatedAt: at,
1187
- finalization: { validation: 'pending', commit: 'pending', merge: 'pending', branchCleanup: 'pending', worktreeCleanup: 'pending' },
1188
- cleanupResponsibility: { owner: 'work_finalizer', registeredAt: at },
1189
- });
1156
+ ): ControllerRoundRelayRecord | undefined {
1157
+ const relay = getControllerRoundRelay(store, workId);
1158
+ if (!relay) return undefined;
1159
+
1160
+ const expectedAuthorityId = relay.authorityId?.trim() || '';
1161
+ if (expectedAuthorityId) {
1162
+ const requestedScopeId = typeof args.relay_scope_id === 'string' ? args.relay_scope_id.trim() : '';
1163
+ const requestedAuthorityId = typeof args.controller_authority_id === 'string' ? args.controller_authority_id.trim() : '';
1164
+ if (!requestedScopeId || requestedScopeId !== relay.relayScopeId) {
1165
+ throw new Error(`WORK_CONTROLLER_RELAY_SCOPE_MISMATCH: ${workId}:expected=${relay.relayScopeId}`);
1166
+ }
1167
+ if (!requestedAuthorityId) {
1168
+ throw new Error(`WORK_CONTROLLER_ROUND_AUTHORITY_REQUIRED: ${workId}`);
1169
+ }
1170
+ if (requestedAuthorityId !== expectedAuthorityId) {
1171
+ throw new Error(`WORK_CONTROLLER_ROUND_AUTHORITY_MISMATCH: ${workId}`);
1172
+ }
1173
+ return relay;
1174
+ }
1175
+
1176
+ // Legacy relay records predate the per-round capability. Preserve only the
1177
+ // exact already-claimed durable epoch; never rebind a replacement transport by
1178
+ // shared OAuth principal alone. A fresh launcher/recovery round upgrades the
1179
+ // record and receives an opaque authority capability.
1180
+ const owner = getControllerSession(store, workId);
1181
+ const principalId = ctx.principalId?.trim() || '';
1182
+ const sessionId = ctx.sessionId?.trim() || '';
1183
+ const controllerInstanceId = ctx.controllerInstanceId?.trim() || '';
1184
+ if (
1185
+ owner
1186
+ && owner.sessionId === sessionId
1187
+ && controllerSessionPrincipalId(owner) === principalId
1188
+ && (owner.controllerInstanceId?.trim() || '') === controllerInstanceId
1189
+ ) return relay;
1190
+ throw new Error(`WORK_CONTROLLER_ROUND_AUTHORITY_UPGRADE_REQUIRED: ${workId}`);
1190
1191
  }
1191
1192
 
1192
- function materializeFacadeWorkPlacement(
1193
+ function bindFacadeControllerOwnership(
1193
1194
  ctx: MultiRepositoryMcpToolContext,
1194
- repository: ReturnType<typeof selected>,
1195
+ store: { controllerHome: string; repoId: string },
1195
1196
  workId: string,
1196
- args: Record<string, unknown>,
1197
+ identity: ReturnType<typeof authenticatedFacadeControllerIdentity>,
1198
+ options: { allowClaimIfMissing?: boolean; leaseMs?: number } = {},
1197
1199
  ) {
1198
- const store = { controllerHome: ctx.controllerHome, repoId: repository.repoId };
1199
- const contract = getWorkContract(store, workId);
1200
- if (!contract || contract.worktreePolicy.required !== true || contract.worktreeRef) return contract;
1201
- const workspace = ensureManagedWorkspace(ctx.controllerHome, repository, {
1202
- requestId: workId,
1203
- title: contract.objective,
1204
- baseRef: contract.baseRevision,
1205
- prepareDependencies: args.needs_dependencies === true,
1206
- });
1207
- if (!workspace.managed || !workspace.checkoutId || !workspace.root || workspace.checkoutId === repository.activeCheckoutId) {
1208
- throw new Error('MANAGED_WORKSPACE_NOT_MATERIALIZED');
1209
- }
1210
- return updateWorkContract(store, workId, {
1211
- checkoutId: workspace.checkoutId,
1212
- baseRevision: workspace.baseRevision ?? contract.baseRevision,
1213
- worktreeRef: workspace.root,
1214
- driver: { ...contract.driver, preferred: 'isolated_worktree', allowDirectEdit: false },
1200
+ const runtime = runtimeIdentitySnapshot(ctx);
1201
+ return bindControllerSessionToCurrentRuntime(store, {
1202
+ workId,
1203
+ controllerId: identity.controllerId,
1204
+ controllerType: identity.controllerType,
1205
+ sessionId: identity.sessionId,
1206
+ principalId: identity.principalId,
1207
+ controllerInstanceId: identity.controllerInstanceId,
1208
+ currentRuntimeInstanceId: runtime.running ? runtime.runtimeInstanceId : undefined,
1209
+ allowClaimIfMissing: options.allowClaimIfMissing,
1210
+ leaseMs: options.leaseMs ?? 3_600_000,
1215
1211
  });
1216
1212
  }
1217
1213
 
1218
- function reauthorizeCancelledFacadeWork(
1214
+ function currentFacadeTerminalizationAuthority(
1219
1215
  ctx: MultiRepositoryMcpToolContext,
1220
- repository: ReturnType<typeof selected>,
1216
+ store: { controllerHome: string; repoId: string },
1221
1217
  workId: string,
1222
1218
  args: Record<string, unknown>,
1223
- ): { reconstructedCheckout: boolean } {
1224
- const store = { controllerHome: ctx.controllerHome, repoId: repository.repoId };
1225
- let work = getWorkContract(store, workId);
1226
- if (!work || work.status !== 'cancelled') return { reconstructedCheckout: false };
1227
- if (args.approval_confirmed !== true || args.requested_by !== 'user') {
1228
- throw new Error(`WORK_CANCELLED_RESUME_REAUTHORIZATION_REQUIRED: ${workId}; use exact work_id with requested_by=user and approval_confirmed=true`);
1219
+ ): ControllerTerminalizationAuthority {
1220
+ let owner = getControllerSession(store, workId);
1221
+ if (!owner) {
1222
+ throw new Error(`WORK_CONTROLLER_OWNER_REQUIRED: ${workId}; terminalization requires an explicit controller_claim for this exact Work.`);
1229
1223
  }
1230
1224
  const identity = authenticatedFacadeControllerIdentity(ctx, args);
1231
- if (!work.principalId?.trim() || work.principalId.trim() !== identity.principalId) {
1232
- throw new Error(`WORK_CANCELLED_RESUME_PRINCIPAL_MISMATCH: ${workId}`);
1233
- }
1234
- const owner = getControllerSession(store, workId);
1235
- if (owner && (owner.principalId?.trim() || owner.controllerId) !== identity.principalId) {
1236
- throw new Error(`WORK_CANCELLED_RESUME_CONTROLLER_OWNERSHIP_MISMATCH: ${workId}`);
1225
+ const ownerPrincipal = controllerSessionPrincipalId(owner);
1226
+ if (owner.controllerId !== identity.controllerId) {
1227
+ throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${workId} is owned by ${owner.controllerId}`);
1237
1228
  }
1238
- if (work.completionReceipt || work.completionOutcome) throw new Error(`WORK_CANCELLED_RESUME_COMPLETION_CONFLICT: ${workId}`);
1239
- if (work.reconciliations.length > 0) throw new Error(`WORK_CANCELLED_RESUME_DELIVERY_HISTORY_AMBIGUOUS: ${workId}`);
1240
- if (work.phase !== 'cleanup' || work.phaseEvidence.cleanup.state !== 'skipped' || work.phaseEvidence.cleanup.source !== 'recorded') {
1241
- throw new Error(`WORK_CANCELLED_RESUME_HISTORY_AMBIGUOUS: ${workId}`);
1229
+ if (owner.controllerType !== identity.controllerType) {
1230
+ throw new Error(`WORK_CONTROLLER_TYPE_MISMATCH: ${workId} is owned by ${owner.controllerType}`);
1242
1231
  }
1243
- if (work.workKind !== 'repository_change' || work.worktreePolicy.required !== true) {
1244
- throw new Error(`WORK_CANCELLED_RESUME_ISOLATED_REPOSITORY_WORK_REQUIRED: ${workId}`);
1232
+ if (ownerPrincipal !== identity.principalId) {
1233
+ throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${workId}`);
1245
1234
  }
1246
-
1247
- let handle = readWorkHandle(ctx.controllerHome, repository.repoId, workId);
1248
- if (handle) {
1249
- if (handle.repositoryId !== repository.repoId || (handle.workContractId && handle.workContractId !== workId)) {
1250
- throw new Error(`WORK_CANCELLED_RESUME_HANDLE_IDENTITY_MISMATCH: ${workId}`);
1251
- }
1252
- if (handle.principalId !== identity.principalId) throw new Error(`WORK_CANCELLED_RESUME_HANDLE_PRINCIPAL_MISMATCH: ${workId}`);
1253
- if (['committed', 'merged', 'cleaned', 'failed_terminal_cleanup'].includes(handle.state)
1254
- || handle.finalization.commit === 'done'
1255
- || handle.finalization.merge === 'done'
1256
- || handle.cleanupReceipt?.complete === true) {
1257
- throw new Error(`WORK_CANCELLED_RESUME_DELIVERY_OR_CLEANUP_CONFLICT: ${workId}`);
1258
- }
1259
- }
1260
-
1261
- const registryRepository = listRepositories(ctx.controllerHome, { includeRemoved: true })
1262
- .find((candidate) => candidate.repoId === repository.repoId);
1263
- if (!registryRepository) throw new Error(`WORK_CANCELLED_RESUME_REPOSITORY_MISSING: ${workId}`);
1264
- const recordedCheckoutId = work.checkoutId?.trim();
1265
- const recordedWorktree = work.worktreeRef?.trim();
1266
- const checkoutRecord = recordedCheckoutId
1267
- ? registryRepository.checkouts.find((candidate) => candidate.checkoutId === recordedCheckoutId)
1268
- : undefined;
1269
- const checkoutActive = checkoutRecord?.lifecycle === 'active';
1270
- const worktreePresent = Boolean(recordedWorktree && existsSync(recordedWorktree));
1271
-
1272
- if (checkoutActive && worktreePresent && recordedCheckoutId && recordedWorktree) {
1273
- const retained = selectRepositoryCheckout(registryRepository, recordedCheckoutId);
1274
- if (resolve(retained.canonicalRoot) !== resolve(recordedWorktree)) {
1275
- throw new Error(`WORK_CANCELLED_RESUME_CHECKOUT_REUSED: ${workId}`);
1276
- }
1277
- if (!handle) throw new Error(`WORK_CANCELLED_RESUME_HANDLE_REQUIRED: ${workId}`);
1278
- if (handle.checkoutId !== recordedCheckoutId || resolve(handle.worktreePath) !== resolve(recordedWorktree)) {
1279
- throw new Error(`WORK_CANCELLED_RESUME_CHECKOUT_OWNERSHIP_MISMATCH: ${workId}`);
1280
- }
1281
- const status = repositoryGitStatus(retained);
1282
- if (!status.branch || status.branch !== handle.branch) throw new Error(`WORK_CANCELLED_RESUME_BRANCH_OWNERSHIP_MISMATCH: ${workId}`);
1283
- writeWorkHandle(ctx.controllerHome, {
1284
- ...handle,
1285
- principalId: identity.principalId,
1286
- sessionId: identity.sessionId,
1287
- updatedAt: new Date().toISOString(),
1288
- });
1289
- resumeRetainedCancelledWorkContract(store, workId, {
1290
- principalId: identity.principalId,
1291
- controllerInstanceId: identity.controllerInstanceId,
1292
- summary: 'Explicit current user re-authorized the same retained cancelled Work; exact managed checkout ownership was revalidated.',
1293
- });
1294
- return { reconstructedCheckout: false };
1295
- }
1296
-
1297
- if (checkoutActive) throw new Error(`WORK_CANCELLED_RESUME_PRESERVATION_AMBIGUOUS: ${workId}`);
1298
- if (!recordedCheckoutId || !recordedWorktree || !handle) throw new Error(`WORK_CANCELLED_RESUME_HANDLE_REQUIRED: ${workId}`);
1299
-
1300
- const actualChangedPaths = work.scopeEvidence?.actualChangedPaths;
1301
- if (!actualChangedPaths || actualChangedPaths.length !== 0
1302
- || work.checks.length !== 0
1303
- || work.checkRefs.length !== 0
1304
- || work.evidenceState !== 'none') {
1305
- throw new Error(`WORK_CANCELLED_RESUME_ZERO_DELTA_PROOF_REQUIRED: ${workId}`);
1306
- }
1307
- const recordedBase = work.baseRevision?.trim();
1308
- if (!recordedBase) throw new Error(`WORK_CANCELLED_RESUME_BASE_REVISION_REQUIRED: ${workId}`);
1309
- const baseRevision = gitCommitAtRef(repository.canonicalRoot, recordedBase);
1310
- if (!baseRevision) throw new Error(`WORK_CANCELLED_RESUME_BASE_REVISION_MISSING: ${workId}`);
1311
- const handleBase = handle.baseCommit ? gitCommitAtRef(repository.canonicalRoot, handle.baseCommit) : undefined;
1312
- const handleHead = handle.expectedHead ? gitCommitAtRef(repository.canonicalRoot, handle.expectedHead) : undefined;
1313
- if ((handle.baseCommit && handleBase !== baseRevision) || (handle.expectedHead && handleHead !== baseRevision)) {
1314
- throw new Error(`WORK_CANCELLED_RESUME_REVISION_AMBIGUOUS: ${workId}`);
1235
+ const ownerInstanceId = owner.controllerInstanceId?.trim() || '';
1236
+ if (!ownerInstanceId) {
1237
+ throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${workId}`);
1315
1238
  }
1316
- if (handle.checkoutId !== recordedCheckoutId || resolve(handle.worktreePath) !== resolve(recordedWorktree)) {
1317
- throw new Error(`WORK_CANCELLED_RESUME_CHECKOUT_OWNERSHIP_MISMATCH: ${workId}`);
1318
- }
1319
- if (handle.branch) {
1320
- const historicalBranchHead = gitCommitAtRef(repository.canonicalRoot, `refs/heads/${handle.branch}`);
1321
- if (historicalBranchHead && historicalBranchHead !== baseRevision) {
1322
- throw new Error(`WORK_CANCELLED_RESUME_UNIQUE_COMMIT_CONFLICT: ${workId}`);
1239
+ if (ownerInstanceId !== identity.controllerInstanceId || owner.sessionId !== identity.sessionId) {
1240
+ const relay = assertFacadeControllerRoundAuthority(ctx, store, workId, args);
1241
+ if (!relay?.authorityId?.trim()) {
1242
+ if (ownerInstanceId !== identity.controllerInstanceId) {
1243
+ throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${workId}`);
1244
+ }
1245
+ throw new Error(`WORK_CONTROLLER_SCOPE_MISMATCH: ${workId}; terminalization cannot rebind ownership implicitly. Call controller_claim for this exact Work in the current controller scope, then retry.`);
1323
1246
  }
1247
+ // The opaque per-round relay capability is the durable conversation-round
1248
+ // authority. MCP transport sessions are replaceable execution bindings, so
1249
+ // an exact upgraded relay may reconcile the same authenticated owner onto
1250
+ // the current canonical Runtime/session before the claim-generation fence is
1251
+ // captured for terminalization. Foreign or legacy rounds fail above.
1252
+ owner = bindFacadeControllerOwnership(ctx, store, workId, identity);
1324
1253
  }
1325
- if (worktreePresent) {
1326
- const retainedSnapshot = gitWorktreeSnapshot(recordedWorktree);
1327
- if (!retainedSnapshot || !retainedSnapshot.clean || retainedSnapshot.head !== baseRevision || (handle.branch && retainedSnapshot.branch !== handle.branch)) {
1328
- throw new Error(`WORK_CANCELLED_RESUME_ZERO_DELTA_PHYSICAL_CONFLICT: ${workId}`);
1329
- }
1254
+ const reboundPrincipal = controllerSessionPrincipalId(owner);
1255
+ const reboundInstanceId = owner.controllerInstanceId?.trim() || '';
1256
+ if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) {
1257
+ throw new Error(`WORK_CONTROLLER_CLAIM_GENERATION_REQUIRED: ${workId}`);
1330
1258
  }
1259
+ return {
1260
+ controllerId: owner.controllerId,
1261
+ controllerType: owner.controllerType,
1262
+ principalId: reboundPrincipal,
1263
+ controllerInstanceId: reboundInstanceId,
1264
+ claimGeneration: owner.claimGeneration,
1265
+ };
1266
+ }
1331
1267
 
1332
- const branchName = `work/resume-${workId.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 42)}-${baseRevision.slice(0, 12)}`;
1333
- const existingResumeBranch = gitCommitAtRef(repository.canonicalRoot, `refs/heads/${branchName}`);
1334
- if (existingResumeBranch && existingResumeBranch !== baseRevision) {
1335
- throw new Error(`WORK_CANCELLED_RESUME_RECONSTRUCTION_BRANCH_CONFLICT: ${workId}`);
1336
- }
1337
- const workspace = ensureManagedWorkspace(ctx.controllerHome, repository, {
1338
- requestId: `${workId}:explicit-user-resume:${baseRevision}`,
1339
- title: `${work.objective} explicit user resume`,
1340
- branchName,
1341
- baseRef: baseRevision,
1342
- prepareDependencies: args.needs_dependencies === true,
1268
+ function ensureFacadeWorkHandle(
1269
+ ctx: MultiRepositoryMcpToolContext,
1270
+ repository: ReturnType<typeof selected>,
1271
+ workId: string,
1272
+ args: Record<string, unknown>,
1273
+ ): WorkHandleState | undefined {
1274
+ const identity = authenticatedFacadeControllerIdentity(ctx, args);
1275
+ return ensureRepositoryWorkHandle({
1276
+ controllerHome: ctx.controllerHome,
1277
+ repository,
1278
+ workId,
1279
+ identity: { sessionId: identity.sessionId, principalId: identity.principalId },
1343
1280
  });
1344
- if (!workspace.managed || !workspace.checkoutId || !workspace.root || !workspace.branch) {
1345
- throw new Error(`WORK_CANCELLED_RESUME_RECONSTRUCTION_FAILED: ${workId}`);
1346
- }
1347
- const refreshedRepository = listRepositories(ctx.controllerHome, { includeRemoved: true })
1348
- .find((candidate) => candidate.repoId === repository.repoId);
1349
- if (!refreshedRepository) throw new Error(`WORK_CANCELLED_RESUME_REPOSITORY_MISSING: ${workId}`);
1350
- const reconstructed = selectRepositoryCheckout(refreshedRepository, workspace.checkoutId);
1351
- const reconstructedStatus = repositoryGitStatus(reconstructed);
1352
- if (!reconstructedStatus.clean || reconstructedStatus.head !== baseRevision || reconstructedStatus.branch !== workspace.branch) {
1353
- throw new Error(`WORK_CANCELLED_RESUME_RECONSTRUCTION_REVISION_MISMATCH: ${workId}`);
1354
- }
1281
+ }
1355
1282
 
1356
- work = updateWorkContract(store, workId, {
1357
- checkoutId: workspace.checkoutId,
1358
- worktreeRef: workspace.root,
1359
- baseRevision,
1360
- controllerInstanceId: identity.controllerInstanceId,
1361
- });
1362
- const now = new Date().toISOString();
1363
- handle = writeWorkHandle(ctx.controllerHome, {
1364
- ...handle,
1365
- principalId: identity.principalId,
1366
- sessionId: identity.sessionId,
1367
- checkoutId: workspace.checkoutId,
1368
- worktreePath: workspace.root,
1369
- branch: workspace.branch,
1370
- sourceCheckoutId: repository.activeCheckoutId,
1371
- managedWorktree: true,
1372
- baseCommit: baseRevision,
1373
- deliveryBaseCommit: baseRevision,
1374
- expectedHead: baseRevision,
1375
- state: 'prepared',
1376
- validatedInputFingerprint: undefined,
1377
- failureReason: undefined,
1378
- cleanupReceipt: undefined,
1379
- finalization: { validation: 'pending', commit: 'pending', merge: 'pending', branchCleanup: 'pending', worktreeCleanup: 'pending' },
1380
- updatedAt: now,
1381
- });
1382
- resumeRetainedCancelledWorkContract(store, workId, {
1383
- principalId: identity.principalId,
1384
- controllerInstanceId: identity.controllerInstanceId,
1385
- summary: 'Explicit current user re-authorized the same cancelled Work; prior managed checkout was absent and a fresh isolated checkout was reconstructed at the exact zero-delta base revision.',
1386
- checkoutId: workspace.checkoutId,
1387
- worktreeRef: workspace.root,
1388
- });
1389
- return { reconstructedCheckout: true };
1283
+ function materializeFacadeWorkPlacement(
1284
+ ctx: MultiRepositoryMcpToolContext,
1285
+ repository: ReturnType<typeof selected>,
1286
+ workId: string,
1287
+ args: Record<string, unknown>,
1288
+ ) {
1289
+ return materializeRepositoryWorkPlacement(
1290
+ { controllerHome: ctx.controllerHome, repoId: repository.repoId },
1291
+ workId,
1292
+ (contract) => {
1293
+ const workspace = ensureManagedWorkspace(ctx.controllerHome, repository, {
1294
+ requestId: workId,
1295
+ title: contract.objective,
1296
+ baseRef: contract.baseRevision,
1297
+ prepareDependencies: args.needs_dependencies === true,
1298
+ });
1299
+ if (workspace.checkoutId === repository.activeCheckoutId) {
1300
+ throw new Error('MANAGED_WORKSPACE_NOT_MATERIALIZED');
1301
+ }
1302
+ return workspace;
1303
+ },
1304
+ );
1390
1305
  }
1391
1306
 
1392
1307
  function claimNewFacadeWork(
@@ -1465,11 +1380,42 @@ async function finalizeFacadeWorkHandle(
1465
1380
  });
1466
1381
  }
1467
1382
 
1468
- const worktree = selectRepositoryCheckout(repository, handle.checkoutId, { allowArchived: true });
1383
+ let worktree: ReturnType<typeof selectRepositoryCheckout>;
1384
+ try {
1385
+ worktree = selectRepositoryCheckout(repository, handle.checkoutId, { allowArchived: true });
1386
+ } catch (error) {
1387
+ const message = error instanceof Error ? error.message : String(error);
1388
+ const exactChangedHead = Boolean(handle.managedWorktree
1389
+ && handle.expectedHead
1390
+ && handle.baseCommit
1391
+ && handle.expectedHead !== handle.baseCommit);
1392
+ if (
1393
+ !exactChangedHead
1394
+ || args.cleanup === false
1395
+ || (!message.startsWith('CHECKOUT_NOT_ACTIVE:') && !message.startsWith('checkout not found for '))
1396
+ ) throw error;
1397
+ // Physical cleanup can succeed before the Work completion receipt is
1398
+ // persisted. Retry through the lower finalizer's exact target-containment
1399
+ // reconciliation instead of requiring a checkout that is already gone.
1400
+ // commit/merge are intentionally disabled here: the missing checkout may
1401
+ // never become permission to replay mutation. If expectedHead is not
1402
+ // already contained by targetBranch, work_finalize fails closed.
1403
+ return callExecutionTool(ctx, 'work_finalize', {
1404
+ ...common,
1405
+ commit: false,
1406
+ merge: false,
1407
+ completion_outcome: 'completed_changed',
1408
+ });
1409
+ }
1469
1410
  const status = repositoryGitStatus(worktree);
1470
1411
  const head = status.head ?? handle.expectedHead ?? handle.baseCommit;
1471
1412
  const committedDelta = Boolean(head && handle.baseCommit && head !== handle.baseCommit);
1472
- const commit = typeof args.commit === 'boolean' ? args.commit : !status.clean;
1413
+ const requestedCommit = typeof args.commit === 'boolean' ? args.commit : !status.clean;
1414
+ // A clean checkout whose HEAD already differs from base is already committed.
1415
+ // Treat commit=true as satisfied by that exact candidate instead of trying to
1416
+ // manufacture an empty commit and recording GIT_NOTHING_STAGED as a delivery
1417
+ // failure. The candidate is still adopted/revalidated and merge-fenced below.
1418
+ const commit = status.clean && committedDelta ? false : requestedCommit;
1473
1419
  const workContract = getWorkContract({ controllerHome: ctx.controllerHome, repoId: repository.repoId }, workId);
1474
1420
  if (
1475
1421
  status.clean
@@ -1585,29 +1531,22 @@ function pluginRepository(
1585
1531
  : selected(ctx, args);
1586
1532
  }
1587
1533
 
1588
- function definedArguments(value: Record<string, unknown>): Record<string, unknown> {
1589
- return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
1590
- }
1591
-
1592
- function legacyIosPluginAction(
1534
+ async function legacyIosPluginAction(
1593
1535
  ctx: MultiRepositoryMcpToolContext,
1594
1536
  legacyTool: string,
1595
1537
  args: Record<string, unknown>,
1596
- actionId: string,
1597
- actionArgs: Record<string, unknown>,
1598
1538
  ): Promise<CallToolResult | undefined> {
1599
1539
  const repository = selected(ctx, args);
1600
- const requestId = typeof args.request_id === 'string' && args.request_id.trim()
1601
- ? args.request_id.trim()
1602
- : `legacy-${legacyTool}-${Date.now()}`;
1540
+ const invocation = legacyIosPluginInvocation(legacyTool, args);
1541
+ if (!invocation) return undefined;
1603
1542
  return callRuntimeTool(ctx, 'plugin_action_execute', {
1604
1543
  repo_id: repository.repoId,
1605
1544
  checkout_id: repository.activeCheckoutId,
1606
1545
  plugin_id: 'ios',
1607
- action_id: actionId,
1608
- request_id: requestId,
1609
- arguments: definedArguments(actionArgs),
1610
- ...(args.confirm_authorization === true ? { confirm_authorization: true } : {}),
1546
+ action_id: invocation.actionId,
1547
+ request_id: invocation.requestId,
1548
+ arguments: invocation.arguments,
1549
+ ...(invocation.confirmAuthorization ? { confirm_authorization: true } : {}),
1611
1550
  });
1612
1551
  }
1613
1552
 
@@ -1817,6 +1756,111 @@ export async function controllerReadinessEvidence(
1817
1756
  };
1818
1757
  }
1819
1758
 
1759
+ /**
1760
+ * Runtime Source drift for MCP readiness.
1761
+ *
1762
+ * `currentRuntimeRoot` is only for tests that pin a Controller Runtime Source
1763
+ * fixture. Callers must never pass an execution repository canonicalRoot here.
1764
+ */
1765
+ export function runtimeSourceSnapshotStatus(
1766
+ active: RuntimeSourceIdentity | undefined,
1767
+ currentRuntimeRoot?: string,
1768
+ ) {
1769
+ const drift = evaluateActiveRuntimeSourceDrift(active, {
1770
+ currentRuntimeRoot,
1771
+ });
1772
+ return {
1773
+ current: drift.current,
1774
+ restartRequired: drift.restartRequired,
1775
+ reasons: drift.reasons,
1776
+ code: drift.code,
1777
+ };
1778
+ }
1779
+
1780
+ export function repositoryExecutionReadiness(
1781
+ repoRoot: string,
1782
+ availableChecks: ReturnType<typeof listControllerChecks>,
1783
+ requestedCheckIds: string[] = [],
1784
+ schedulingScope: { repoId?: string; checkoutId?: string } = {},
1785
+ ): Record<string, unknown> {
1786
+ const git = gitSnapshot(repoRoot);
1787
+ const registeredCheckIds = availableChecks.map((check) => check.id);
1788
+ const normalizedChecks = normalizeCheckIds(requestedCheckIds, availableChecks);
1789
+ const hasPackageJson = existsSync(join(repoRoot, 'package.json'));
1790
+ const nodeModulesReady = !hasPackageJson || existsSync(join(repoRoot, 'node_modules'));
1791
+ const lockCandidates = [
1792
+ ['bun', 'bun.lock'],
1793
+ ['bun', 'bun.lockb'],
1794
+ ['pnpm', 'pnpm-lock.yaml'],
1795
+ ['npm', 'package-lock.json'],
1796
+ ['yarn', 'yarn.lock'],
1797
+ ] as const;
1798
+ const detectedLock = lockCandidates.find(([, path]) => existsSync(join(repoRoot, path)));
1799
+ const packageManager = detectedLock?.[0];
1800
+ const bootstrapCommand = hasPackageJson && !nodeModulesReady
1801
+ ? packageManager === 'bun' ? ['bun', 'install', '--frozen-lockfile']
1802
+ : packageManager === 'pnpm' ? ['pnpm', 'install', '--frozen-lockfile']
1803
+ : packageManager === 'npm' ? ['npm', 'ci']
1804
+ : packageManager === 'yarn' ? ['yarn', 'install', '--frozen-lockfile']
1805
+ : undefined
1806
+ : undefined;
1807
+ const hasPythonManifest = existsSync(join(repoRoot, 'pyproject.toml'))
1808
+ || existsSync(join(repoRoot, 'requirements.txt'))
1809
+ || existsSync(join(repoRoot, 'requirements-dev.txt'));
1810
+ const localPythonReady = !hasPythonManifest
1811
+ || existsSync(join(repoRoot, '.venv', 'bin', 'python'))
1812
+ || existsSync(join(repoRoot, '.venv', 'Scripts', 'python.exe'));
1813
+ const checkScheduling = buildCheckExecutionSchedule({
1814
+ checks: availableChecks,
1815
+ requestedCheckIds,
1816
+ repoId: schedulingScope.repoId?.trim() || 'selected-repository',
1817
+ checkoutId: schedulingScope.checkoutId?.trim() || 'active',
1818
+ });
1819
+ const blockers = [
1820
+ ...(!nodeModulesReady ? [{ code: 'NODE_DEPENDENCIES_MISSING', message: 'package.json is present but node_modules is not materialized in this checkout.' }] : []),
1821
+ ...normalizedChecks.invalidCheckIds.map((checkId) => ({ code: 'CHECK_NOT_REGISTERED', message: `Requested check is not registered: ${checkId}`, checkId })),
1822
+ ];
1823
+ return {
1824
+ readyForFocusedExecution: blockers.length === 0,
1825
+ git: { head: git.head, branch: git.branch, dirty: git.dirty },
1826
+ checks: {
1827
+ registeredCount: registeredCheckIds.length,
1828
+ registeredCheckIds: registeredCheckIds.slice(0, 80),
1829
+ requestedCheckIds: requestedCheckIds.slice(0, 40),
1830
+ normalized: normalizedChecks,
1831
+ },
1832
+ checkScheduling: {
1833
+ waveCount: checkScheduling.waves.length,
1834
+ maxParallel: checkScheduling.maxParallel,
1835
+ waveSummaries: checkScheduling.waves.map((wave) => `wave ${wave.wave}: ${wave.checkIds.join(', ')}`),
1836
+ conflictSummaries: checkScheduling.conflicts.map((conflict) => {
1837
+ const resources = [...new Set(conflict.resources.flatMap(({ left, right }) => [left.resourceKey, right.resourceKey]))];
1838
+ return `${conflict.leftCheckId} <> ${conflict.rightCheckId}: ${resources.join(', ')}`;
1839
+ }),
1840
+ invalidCheckIds: checkScheduling.invalidCheckIds,
1841
+ guidance: checkScheduling.guidance,
1842
+ },
1843
+ dependencies: {
1844
+ node: {
1845
+ applicable: hasPackageJson,
1846
+ ready: nodeModulesReady,
1847
+ packageManager: packageManager ?? null,
1848
+ lockfile: detectedLock?.[1] ?? null,
1849
+ ...(bootstrapCommand ? { bootstrapCommand } : {}),
1850
+ },
1851
+ python: {
1852
+ applicable: hasPythonManifest,
1853
+ localVirtualEnvReady: localPythonReady,
1854
+ advisoryOnly: true,
1855
+ },
1856
+ },
1857
+ blockers,
1858
+ guidance: bootstrapCommand
1859
+ ? [`Materialize checkout dependencies before tests/builds: ${bootstrapCommand.join(' ')}`]
1860
+ : [],
1861
+ };
1862
+ }
1863
+
1820
1864
  export async function controllerReadiness(
1821
1865
  ctx: MultiRepositoryMcpToolContext,
1822
1866
  repository = ctx.explicitRepository,
@@ -2308,11 +2352,85 @@ async function runFacadeRepair(
2308
2352
  const planId = typeof args.plan_id === 'string' ? args.plan_id.trim() : '';
2309
2353
  const planStepId = typeof args.plan_step_id === 'string' ? args.plan_step_id.trim() : '';
2310
2354
 
2311
- if (planId || planStepId) {
2312
- if (!planId || !planStepId) {
2313
- const facade = buildFacadeResult({ status: 'blocked', summary: 'PLAN_STEP_REPAIR_CONTEXT_REQUIRED: plan_id and plan_step_id are both required.', data: { operation: repairOperation, dryRun, repaired: false } });
2355
+ if (planId && !planStepId) {
2356
+ const plan = getPlanContract(store, planId);
2357
+ if (!plan) {
2358
+ const facade = buildFacadeResult({ status: 'not_found', summary: `PlanContract ${planId} not found.`, data: { operation: repairOperation, dryRun, planId, repaired: false } });
2359
+ return result(facade as unknown as Record<string, unknown>, true);
2360
+ }
2361
+ if (plan.status !== 'draft') {
2362
+ const facade = buildFacadeResult({ status: 'blocked', summary: `PLAN_DRAFT_REPAIR_STATUS_INVALID: ${plan.planId}:${plan.status}`, data: { operation: repairOperation, dryRun, planId, repaired: false } });
2363
+ return result(facade as unknown as Record<string, unknown>, true);
2364
+ }
2365
+ if (repairOperation !== 'repair' || dryRun) {
2366
+ const facade = buildFacadeResult({
2367
+ summary: `PlanContract ${plan.planId} is a draft. Exact in-place repair is available; the Plan identity and Requirement authority are preserved and only a fully valid draft may be persisted.`,
2368
+ data: { operation: repairOperation, dryRun, plan: summarizePlanContract(plan), repaired: false, repairRequired: true },
2369
+ suggestedNextActions: [{ label: 'Repair this exact draft Plan', tool: 'rh_work', operation: 'repair', payload: { plan_id: plan.planId, repair_operation: 'repair', dry_run: false }, risk: 'workspace_write', confidence: 'high' }],
2370
+ });
2371
+ return result(facade as unknown as Record<string, unknown>);
2372
+ }
2373
+ const rawSteps = Array.isArray(args.plan_steps)
2374
+ ? args.plan_steps
2375
+ : plan.steps.map((step) => ({
2376
+ id: step.id, objective: step.objective, dependencies: step.dependencies, authoritative_files: step.authoritativeFiles,
2377
+ allowed_paths: step.allowedPaths, forbidden_paths: step.forbiddenPaths, check_ids: step.checks, acceptance_criteria: step.acceptanceCriteria,
2378
+ }));
2379
+ const steps = rawSteps
2380
+ .filter((step): step is Record<string, unknown> => Boolean(step) && typeof step === 'object' && !Array.isArray(step))
2381
+ .map((step) => ({
2382
+ id: String(step.id ?? ''),
2383
+ objective: String(step.objective ?? ''),
2384
+ dependencies: Array.isArray(step.dependencies) ? step.dependencies.map(String) : [],
2385
+ authoritativeFiles: Array.isArray(step.authoritative_files) ? step.authoritative_files.map(String) : [],
2386
+ allowedPaths: Array.isArray(step.allowed_paths) ? step.allowed_paths.map(String) : [],
2387
+ forbiddenPaths: Array.isArray(step.forbidden_paths) ? step.forbidden_paths.map(String) : [],
2388
+ checks: Array.isArray(step.check_ids) ? step.check_ids.map(String) : [],
2389
+ acceptanceCriteria: Array.isArray(step.acceptance_criteria) ? step.acceptance_criteria.map(String) : [],
2390
+ }));
2391
+ const availableChecks = listControllerChecks(repository.canonicalRoot);
2392
+ const normalizedPlanChecks = normalizeCheckIds(steps.flatMap((step) => step.checks), availableChecks);
2393
+ if (normalizedPlanChecks.invalidCheckIds.length > 0) {
2394
+ const facade = buildFacadeResult({
2395
+ status: 'failed',
2396
+ summary: `PLAN_CHECKS_INVALID: ${normalizedPlanChecks.invalidCheckIds.join(', ')}. Draft repair was not persisted.`,
2397
+ data: { operation: repairOperation, dryRun: false, planId, repaired: false, normalizedChecks: normalizedPlanChecks, registeredCheckIds: availableChecks.map((check) => check.id).slice(0, 80) },
2398
+ suggestedNextActions: [],
2399
+ });
2400
+ return result(facade as unknown as Record<string, unknown>, true);
2401
+ }
2402
+ try {
2403
+ const repaired = await repairDraftPlanContractAsync(store, planId, {
2404
+ expectedSourceRevision: plan.sourceRevision,
2405
+ scopeKey: typeof args.scope_key === 'string' ? args.scope_key : plan.scopeKey,
2406
+ sourceRevision: typeof args.source_revision === 'string' ? args.source_revision : plan.sourceRevision,
2407
+ goal: typeof args.objective === 'string' ? args.objective : plan.goal,
2408
+ nonGoals: Array.isArray(args.non_goals) ? args.non_goals.map(String) : plan.nonGoals,
2409
+ assumptions: Array.isArray(args.assumptions) ? args.assumptions.map(String) : plan.assumptions,
2410
+ resolvedDecisions: Array.isArray(args.resolved_decisions) ? args.resolved_decisions.map(String) : plan.resolvedDecisions,
2411
+ stopConditions: Array.isArray(args.stop_conditions) ? args.stop_conditions.map(String) : plan.stopConditions,
2412
+ replanConditions: Array.isArray(args.replan_conditions) ? args.replan_conditions.map(String) : plan.replanConditions,
2413
+ integrationStrategy: typeof args.integration_strategy === 'string' ? args.integration_strategy : plan.integrationStrategy,
2414
+ steps,
2415
+ });
2416
+ const facade = buildFacadeResult({
2417
+ summary: `PlanContract ${repaired.planId} draft repaired in place; identity and Requirement authority were preserved.`,
2418
+ data: { operation: repairOperation, dryRun: false, plan: summarizePlanContract(repaired), repaired: true, replacementPlanCreated: false },
2419
+ suggestedNextActions: [{ label: 'Approve reviewed plan', tool: 'rh_work', operation: 'plan_approve', payload: { plan_id: repaired.planId }, risk: 'workspace_write', confidence: 'medium' }],
2420
+ });
2421
+ return result(facade as unknown as Record<string, unknown>);
2422
+ } catch (error) {
2423
+ const facade = buildFacadeResult({ status: 'blocked', summary: error instanceof Error ? error.message : 'PLAN_DRAFT_REPAIR_FAILED', data: { operation: repairOperation, dryRun: false, planId, repaired: false } });
2314
2424
  return result(facade as unknown as Record<string, unknown>, true);
2315
2425
  }
2426
+ }
2427
+
2428
+ if (planStepId && !planId) {
2429
+ const facade = buildFacadeResult({ status: 'blocked', summary: 'PLAN_STEP_REPAIR_CONTEXT_REQUIRED: plan_id and plan_step_id are both required.', data: { operation: repairOperation, dryRun, repaired: false } });
2430
+ return result(facade as unknown as Record<string, unknown>, true);
2431
+ }
2432
+
2433
+ if (planId && planStepId) {
2316
2434
  const plan = getPlanContract(store, planId);
2317
2435
  const step = plan?.steps.find((candidate) => candidate.id === planStepId);
2318
2436
  if (!plan || !step) {
@@ -2496,26 +2614,85 @@ async function runFacadeRepair(
2496
2614
  return result(facade as unknown as Record<string, unknown>, facade.status === 'blocked' || facade.status === 'approval_required' || facade.status === 'failed');
2497
2615
  }
2498
2616
 
2499
- function reconcileTerminalFacadeWorkVerifications(
2617
+ function repositoryWorkHandleHasSourceDelta(
2618
+ repository: ReturnType<typeof selected>,
2619
+ handle: WorkHandleState,
2620
+ ): boolean {
2621
+ try {
2622
+ const checkout = selectRepositoryCheckout(repository, handle.checkoutId, { allowArchived: true });
2623
+ const status = repositoryGitStatus(checkout);
2624
+ const head = status.head ?? handle.expectedHead ?? handle.baseCommit;
2625
+ return !status.clean || Boolean(head && handle.baseCommit && head !== handle.baseCommit);
2626
+ } catch (_error) {
2627
+ // A checkout may already have been removed by an earlier bounded cleanup
2628
+ // attempt. The WorkHandle's fenced expectedHead remains authoritative for
2629
+ // whether repository delivery existed and must not be downgraded to an
2630
+ // effect-only semantic completion.
2631
+ return Boolean(handle.expectedHead && handle.baseCommit && handle.expectedHead !== handle.baseCommit);
2632
+ }
2633
+ }
2634
+
2635
+ function finalizeRemoteEffectWorkFromRepositoryProcessReceipt(
2500
2636
  ctx: MultiRepositoryMcpToolContext,
2501
2637
  repository: ReturnType<typeof selected>,
2502
2638
  workId: string,
2503
- ): { sourceRevision?: string; workspaceFingerprint?: string; workspaceChangedPaths?: string[]; reconciledProcessIds: string[] } {
2639
+ checkoutId: string,
2640
+ ) {
2504
2641
  const store = { controllerHome: ctx.controllerHome, repoId: repository.repoId };
2505
- const workContract = getWorkContract(store, workId);
2506
- if (!workContract || workContract.completionReceipt) return { reconciledProcessIds: [] };
2507
-
2508
- let verificationRepository: ReturnType<typeof selectRepositoryCheckout>;
2509
- try {
2510
- verificationRepository = workContract.checkoutId
2511
- ? selectRepositoryCheckout(repository, workContract.checkoutId, { allowArchived: true })
2512
- : repository;
2513
- } catch {
2514
- return { reconciledProcessIds: [] };
2642
+ const work = getWorkContract(store, workId);
2643
+ if (!work) throw new Error(`WORK_REMOTE_EFFECT_PROCESS_BINDING_NOT_FOUND: ${workId}`);
2644
+ if (work.workKind !== 'remote_effect') {
2645
+ throw new Error(`WORK_REMOTE_EFFECT_PROCESS_KIND_MISMATCH: ${workId} is ${work.workKind}, expected remote_effect`);
2646
+ }
2647
+ if (work.status === 'completed' && work.completionReceipt?.source === 'remote_effect') return work;
2648
+ const evidence = listWorkBoundRepositoryRemoteEffectProcessEvidence({
2649
+ controllerHome: ctx.controllerHome,
2650
+ repoId: repository.repoId,
2651
+ checkoutId,
2652
+ workId,
2653
+ })[0];
2654
+ if (!evidence) return undefined;
2655
+ if (!work.evidenceRefs.some((candidate) => candidate.evidenceId === evidence.processId)) {
2656
+ appendWorkEvidence(store, workId, {
2657
+ evidenceId: evidence.processId,
2658
+ title: 'trusted repository remote effect completed',
2659
+ summary: `Trusted Work-attributed git push completed with durable Process ${evidence.processId}.`,
2660
+ detailLevel: 'summary',
2661
+ });
2515
2662
  }
2663
+ return recordWorkCompletionReceipt(
2664
+ store,
2665
+ workId,
2666
+ {
2667
+ schemaVersion: 1,
2668
+ receiptId: evidence.processId,
2669
+ source: 'remote_effect',
2670
+ workId,
2671
+ authority: 'repository_process',
2672
+ actionId: evidence.actionId,
2673
+ requestId: evidence.requestId,
2674
+ semanticKey: evidence.semanticKey,
2675
+ resultDigest: evidence.resultDigest,
2676
+ processId: evidence.processId,
2677
+ recordedAt: evidence.finishedAt ?? new Date().toISOString(),
2678
+ },
2679
+ 'completed_remote',
2680
+ 'remote_effect',
2681
+ );
2682
+ }
2683
+
2684
+ function reconcileTerminalFacadeWorkVerifications(
2685
+ ctx: MultiRepositoryMcpToolContext,
2686
+ repository: ReturnType<typeof selected>,
2687
+ workId: string,
2688
+ ): { sourceRevision?: string; workspaceFingerprint?: string; workspaceChangedPaths?: string[]; reconciledProcessIds: string[]; workBoundProcessEvidenceIds: string[] } {
2689
+ const resolvedVerification = resolveWorkVerificationContext({ controllerHome: ctx.controllerHome, repository, workId });
2690
+ if (!resolvedVerification.ok) return { reconciledProcessIds: [], workBoundProcessEvidenceIds: [] };
2691
+ const { store, workContract, repository: verificationRepository, checks: availableChecks } = resolvedVerification.context;
2692
+ if (!workContract || workContract.completionReceipt) return { reconciledProcessIds: [], workBoundProcessEvidenceIds: [] };
2516
2693
  const verificationStatus = repositoryGitStatus(verificationRepository);
2517
2694
  const sourceRevision = verificationStatus.head ?? undefined;
2518
- if (!sourceRevision) return { reconciledProcessIds: [] };
2695
+ if (!sourceRevision) return { reconciledProcessIds: [], workBoundProcessEvidenceIds: [] };
2519
2696
  const verificationHandle = readWorkHandle(ctx.controllerHome, repository.repoId, workId);
2520
2697
  const deliveryBaseRevision = verificationHandle
2521
2698
  ? workDeliveryBaseRevision(verificationHandle)
@@ -2530,7 +2707,22 @@ function reconcileTerminalFacadeWorkVerifications(
2530
2707
  ...verificationStatus.untracked,
2531
2708
  ])].sort();
2532
2709
  const workspaceFingerprint = workspaceValidationFingerprint(verificationRepository.canonicalRoot, verificationStatus);
2533
- const availableChecks = listControllerChecks(repository.canonicalRoot);
2710
+ // Work-bound repository Process evidence remains semantic/result evidence,
2711
+ // never a typed check receipt. repository_change may use it only when no
2712
+ // checks are declared. local_effect may bind the same exact durable Process
2713
+ // ids for Controller semantic review even when checks are declared; the
2714
+ // normal missing-check gate below still requires typed verification receipts.
2715
+ const workBoundProcessEvidenceIds = (
2716
+ workContract.workKind === 'local_effect'
2717
+ || (workContract.workKind === 'repository_change' && workContract.checks.length === 0)
2718
+ )
2719
+ ? listWorkBoundRepositoryProcessEvidence({
2720
+ controllerHome: ctx.controllerHome,
2721
+ repoId: repository.repoId,
2722
+ checkoutId: verificationRepository.activeCheckoutId,
2723
+ workId,
2724
+ }).map((evidence) => evidence.processId)
2725
+ : [];
2534
2726
  const workloopCtx = {
2535
2727
  workStore: store,
2536
2728
  handoffStore: store,
@@ -2585,29 +2777,11 @@ function reconcileTerminalFacadeWorkVerifications(
2585
2777
  const latestContract = getWorkContract(store, workId);
2586
2778
  if (latestContract?.checkRefs.some((entry) => entry.receipt?.receiptId === receipt.receiptId)) continue;
2587
2779
 
2588
- const structuredCheckResult = readPersistedCheckResultReceipt(record.origin?.checkResultReceiptPath);
2589
- const structuredResultMatchesProcess = Boolean(
2590
- structuredCheckResult
2591
- && structuredCheckResult.checkId === normalizedCheckId
2592
- && structuredCheckResult.cacheKey === record.checkExecution.cacheKey,
2593
- );
2594
2780
  const legacyEvidence = record.origin?.checkResultReceiptPath
2595
2781
  ? undefined
2596
2782
  : readLatestControllerCheckEvidence(verificationRepository.canonicalRoot, normalizedCheckId);
2597
- const legacyEvidenceMatchesProcess = Boolean(
2598
- legacyEvidence?.cacheKey
2599
- && legacyEvidence.cacheKey === record.checkExecution.cacheKey,
2600
- );
2601
- const evidenceState = classifyTerminalCheckEvidence({
2602
- processError: record.error,
2603
- structuredPresent: Boolean(structuredCheckResult),
2604
- structuredMatches: structuredResultMatchesProcess,
2605
- legacyPresent: Boolean(legacyEvidence),
2606
- legacyMatches: legacyEvidenceMatchesProcess,
2607
- });
2608
- const failureClass = structuredResultMatchesProcess
2609
- ? structuredCheckResult?.failureClass
2610
- : legacyEvidenceMatchesProcess ? legacyEvidence?.failureClass : undefined;
2783
+ const evidenceState = classifyPersistedCheckTerminalEvidence(record, normalizedCheckId, { legacyEvidence });
2784
+ const failureClass = evidenceState.failureClass;
2611
2785
  const infrastructureFailed = receipt.timedOut
2612
2786
  || receipt.cancelled
2613
2787
  || evidenceState.state !== 'matched'
@@ -2619,500 +2793,59 @@ function reconcileTerminalFacadeWorkVerifications(
2619
2793
  sourceRevision,
2620
2794
  workspaceFingerprint,
2621
2795
  verificationInputFingerprint: currentFingerprint,
2622
- commandFingerprint: commandFingerprint(normalizedCheckId, receipt.commandId),
2623
- receipt,
2624
- infrastructureFailed,
2625
- checkFailed,
2626
- });
2627
- reconciledProcessIds.push(record.processId);
2628
- } catch {
2629
- // Exact receipt/process identity is mandatory. Any malformed, stale, or
2630
- // mismatched terminal Process remains non-authoritative and is ignored.
2631
- }
2632
- }
2633
-
2634
- return { sourceRevision, workspaceFingerprint, workspaceChangedPaths, reconciledProcessIds };
2635
- }
2636
-
2637
- async function runFacadeVerify(
2638
- ctx: MultiRepositoryMcpToolContext,
2639
- repository: ReturnType<typeof selected>,
2640
- args: Record<string, unknown>,
2641
- ): Promise<CallToolResult> {
2642
- const store = { controllerHome: ctx.controllerHome, repoId: repository.repoId };
2643
- const checks = listControllerChecks(repository.canonicalRoot);
2644
- const workloopCtx = {
2645
- workStore: store,
2646
- handoffStore: store,
2647
- repoId: repository.repoId,
2648
- availableChecks: checks,
2649
- };
2650
- const workId = typeof args.work_id === 'string' ? args.work_id : '';
2651
- const checkId = String(args.check_id ?? args.checkId ?? '').trim();
2652
- if (!checkId) {
2653
- return result(buildFacadeResult({
2654
- status: 'blocked',
2655
- summary: 'rh_work verify requires a registered check_id.',
2656
- data: {
2657
- verification: {
2658
- outcome: 'check_id_required',
2659
- isAcceptanceFailure: false,
2660
- isInfrastructureIssue: true,
2661
- doesNotRequestTaskChanges: true,
2662
- },
2663
- registeredCheckCount: checks.length,
2664
- },
2665
- warnings: ['CHECK_ID_REQUIRED: pass check_id for one registered repository check.'],
2666
- suggestedNextActions: normalizeCheckIds(checks.slice(0, 3).map((check) => check.id), checks).suggestedNextActions,
2667
- }) as unknown as Record<string, unknown>, true);
2668
- }
2669
- const workContract = workId ? getWorkContract(store, workId) : undefined;
2670
- if (workId && (!workContract || workContract.status === 'completed' || workContract.status === 'cancelled' || workContract.status === 'failed')) {
2671
- const facade = verifyGoalWorkloop(workloopCtx, { workId, checkId });
2672
- return result(facade as unknown as Record<string, unknown>, facade.status === 'failed');
2673
- }
2674
-
2675
- const classified = classifyVerificationOutcome({
2676
- checkId,
2677
- available: checks,
2678
- });
2679
-
2680
- if (classified.outcome === 'invalid_check_id') {
2681
- if (workId) {
2682
- const facade = verifyGoalWorkloop(workloopCtx, { workId, checkId });
2683
- return result(facade as unknown as Record<string, unknown>);
2684
- }
2685
- return result(buildFacadeResult({
2686
- status: 'ok',
2687
- summary: classified.summary,
2688
- data: {
2689
- verification: {
2690
- checkId,
2691
- outcome: 'invalid_check_id',
2692
- isAcceptanceFailure: false,
2693
- isInfrastructureIssue: true,
2694
- doesNotRequestTaskChanges: true,
2695
- },
2696
- registeredCheckCount: checks.length,
2697
- },
2698
- warnings: classified.warnings,
2699
- suggestedNextActions: normalizeCheckIds(checks.slice(0, 3).map((check) => check.id), checks).suggestedNextActions,
2700
- }) as unknown as Record<string, unknown>);
2701
- }
2702
-
2703
- // Simulation path for unit tests / explicit dry verification without process execution.
2704
- if (args.simulate_check === true || args.infrastructure_failed === true || args.check_failed === true || args.skipped === true) {
2705
- if (!workId) {
2706
- return result(buildFacadeResult({
2707
- status: args.check_failed === true ? 'failed' : 'ok',
2708
- summary: 'Simulated verification without WorkContract.',
2709
- data: {
2710
- verification: {
2711
- checkId: classified.normalizedCheckId,
2712
- outcome: args.skipped ? 'skipped' : args.infrastructure_failed ? 'infrastructure_failure' : args.check_failed ? 'valid_fail' : 'valid_pass',
2713
- isAcceptanceFailure: args.check_failed === true,
2714
- simulated: true,
2715
- },
2716
- },
2717
- }) as unknown as Record<string, unknown>, args.check_failed === true);
2718
- }
2719
- const facade = verifyGoalWorkloop(workloopCtx, {
2720
- workId,
2721
- checkId: classified.normalizedCheckId ?? checkId,
2722
- infrastructureFailed: args.infrastructure_failed === true,
2723
- checkFailed: args.check_failed === true,
2724
- skipped: args.skipped === true,
2725
- });
2726
- return result(facade as unknown as Record<string, unknown>, facade.status === 'failed');
2727
- }
2728
-
2729
- // Real checks share the same persisted Process Runtime path as run_check and
2730
- // work_validate. Never synchronously block the MCP facade on long native tests.
2731
- try {
2732
- const normalizedCheckId = classified.normalizedCheckId!;
2733
- const requestId = typeof args.request_id === 'string' && args.request_id.trim()
2734
- ? args.request_id.trim()
2735
- : undefined;
2736
- const verificationRepository = workContract?.checkoutId
2737
- ? selectRepositoryCheckout(repository, workContract.checkoutId, { allowArchived: true })
2738
- : repository;
2739
- const verificationStatus = repositoryGitStatus(verificationRepository);
2740
- const observedGitHead = verificationStatus.head;
2741
- const workspaceFingerprint = workspaceValidationFingerprint(verificationRepository.canonicalRoot, verificationStatus);
2742
- const requestedChecks = workContract?.checks.length ? workContract.checks : [normalizedCheckId];
2743
- const verificationRequestFingerprint = observedGitHead ? verificationInputFingerprint({
2744
- sourceRevision: observedGitHead,
2745
- workspaceFingerprint,
2746
- checkId: normalizedCheckId,
2747
- requestedChecks,
2748
- }) : undefined;
2749
- const registeredCheck = checks.find((entry) => entry.id === normalizedCheckId);
2750
- const durableClassCheck = checkRequiresDurableWorkflow(normalizedCheckId, registeredCheck);
2751
- let allowDurableCheckExecution = false;
2752
- if (durableClassCheck && workContract?.checks.includes(normalizedCheckId) && !workContract.completionReceipt && workContract.status === 'running') {
2753
- try {
2754
- const identity = authenticatedFacadeControllerIdentity(ctx, args);
2755
- const owner = getControllerSession(store, workId);
2756
- const ownerPrincipal = owner?.principalId?.trim() || owner?.controllerId;
2757
- allowDurableCheckExecution = Boolean(
2758
- owner
2759
- && owner.controllerId === identity.controllerId
2760
- && ownerPrincipal === identity.principalId
2761
- && owner.controllerInstanceId === identity.controllerInstanceId,
2762
- );
2763
- } catch {
2764
- // Durable-class checks remain deferred when the authenticated claim cannot
2765
- // be proven exactly. The lower Process Runtime never infers authority.
2766
- allowDurableCheckExecution = false;
2767
- }
2768
- }
2769
- const executed = await runPersistedCheckViaProcessRuntime({
2770
- controllerHome: ctx.controllerHome,
2771
- repoId: verificationRepository.repoId,
2772
- checkoutId: verificationRepository.activeCheckoutId,
2773
- repoRoot: verificationRepository.canonicalRoot,
2774
- executionIdentity: executionIdentityForRepository(verificationRepository, workId ? { workId } : {}),
2775
- checkId: normalizedCheckId,
2776
- timeoutMs: typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined,
2777
- // rh_work.verify is a control-plane continuation primitive: callers should
2778
- // be able to keep working and later reattach to the exact same Process.
2779
- // Absorb brief shared build-cache/resource contention during admission so
2780
- // a lifecycle verification does not turn a few seconds of contention into
2781
- // a terminal failed Check that the controller must manually retry.
2782
- interactiveWaitMs: 0,
2783
- leaseWaitMs: RH_WORK_VERIFY_LEASE_WAIT_MS,
2784
- requestId,
2785
- requestSemanticFingerprint: verificationRequestFingerprint,
2786
- workId: workId || undefined,
2787
- commandId: requestId,
2788
- verificationSnapshot: workContract ? {
2789
- workId: workContract.workId,
2790
- allowedPaths: workContract.allowedPaths,
2791
- forbiddenPaths: workContract.forbiddenPaths,
2792
- } : undefined,
2793
- allowDurableCheckExecution,
2794
- });
2795
-
2796
- if (executed.mode === 'durable') {
2797
- return result(buildFacadeResult({
2798
- status: 'blocked',
2799
- summary: `Check ${normalizedCheckId} requires an explicit durable workflow; no acceptance result was recorded.`,
2800
- data: {
2801
- verification: {
2802
- checkId: normalizedCheckId,
2803
- outcome: 'deferred',
2804
- isAcceptanceFailure: false,
2805
- isInfrastructureIssue: false,
2806
- durable: executed.durable,
2807
- observedGitHead,
2808
- },
2809
- },
2810
- suggestedNextActions: [{
2811
- label: 'Continue Work with the durable check requirement',
2812
- tool: 'rh_work',
2813
- operation: 'continue',
2814
- payload: { work_id: workId || undefined },
2815
- risk: 'workspace_write',
2816
- confidence: 'high',
2817
- }],
2818
- }) as unknown as Record<string, unknown>, true);
2819
- }
2820
-
2821
- const handle = executed.process;
2822
- if (!handle) throw new Error(`PROCESS_CHECK_HANDLE_MISSING: ${normalizedCheckId}`);
2823
- const record = getProcessRecord(ctx.controllerHome, verificationRepository.repoId, handle.processId);
2824
- const checkContentRevision = record?.checkExecution?.revision;
2825
-
2826
- if (!handle.completed) {
2827
- return result(buildFacadeResult({
2828
- status: 'ok',
2829
- summary: `Check ${normalizedCheckId} is running through Process Runtime; continue other work and reattach to ${handle.processId}.`,
2830
- data: {
2831
- verification: {
2832
- checkId: normalizedCheckId,
2833
- outcome: 'running',
2834
- isAcceptanceFailure: false,
2835
- isInfrastructureIssue: false,
2836
- executed: true,
2837
- completed: false,
2838
- processId: handle.processId,
2839
- processStatus: handle.status,
2840
- deduplicated: handle.deduplicated === true,
2841
- semanticDeduplicated: handle.semanticDeduplicated === true,
2842
- checkContentRevision,
2843
- observedGitHead,
2844
- verificationIsolation: workContract ? 'work_snapshot' : 'shared_checkout',
2845
- revisionSemantics: 'checkContentRevision is a content-bound Check identity; observedGitHead is Git HEAD and is not interchangeable.',
2846
- },
2847
- },
2848
- rawAvailable: false,
2849
- }) as unknown as Record<string, unknown>);
2850
- }
2851
-
2852
- if (!record) throw new Error(`PROCESS_CHECK_RECORD_MISSING: ${handle.processId}`);
2853
- const receipt = processCheckCompletionReceipt(record, {
2854
- repoId: verificationRepository.repoId,
2855
- checkId: normalizedCheckId,
2856
- processId: handle.processId,
2857
- ...(record.checkExecution ? {
2858
- checkoutId: verificationRepository.activeCheckoutId,
2859
- workId: workId || undefined,
2860
- requestId,
2861
- checkExecution: {
2862
- cacheKey: record.checkExecution.cacheKey,
2863
- revision: record.checkExecution.revision,
2864
- definitionDigest: record.checkExecution.definitionDigest,
2865
- environmentFingerprint: record.checkExecution.environmentFingerprint,
2866
- timeoutMs: record.checkExecution.timeoutMs,
2867
- scopeKey: record.checkExecution.scopeKey,
2868
- },
2869
- } : {}),
2870
- });
2871
- const structuredCheckResult = readPersistedCheckResultReceipt(record.origin?.checkResultReceiptPath);
2872
- const structuredResultMatchesProcess = Boolean(
2873
- structuredCheckResult
2874
- && record.checkExecution?.cacheKey
2875
- && structuredCheckResult.checkId === normalizedCheckId
2876
- && structuredCheckResult.cacheKey === record.checkExecution.cacheKey,
2877
- );
2878
- const legacyEvidence = record.origin?.checkResultReceiptPath
2879
- ? undefined
2880
- : readLatestControllerCheckEvidence(verificationRepository.canonicalRoot, normalizedCheckId);
2881
- const legacyEvidenceMatchesProcess = Boolean(
2882
- legacyEvidence?.cacheKey
2883
- && record.checkExecution?.cacheKey
2884
- && legacyEvidence.cacheKey === record.checkExecution.cacheKey,
2885
- );
2886
- const evidenceState = classifyTerminalCheckEvidence({
2887
- processError: record.error,
2888
- structuredPresent: Boolean(structuredCheckResult),
2889
- structuredMatches: structuredResultMatchesProcess,
2890
- legacyPresent: Boolean(legacyEvidence),
2891
- legacyMatches: legacyEvidenceMatchesProcess,
2892
- });
2893
- const resultMatchesProcess = evidenceState.state === 'matched';
2894
- const failureClass = structuredResultMatchesProcess
2895
- ? structuredCheckResult?.failureClass
2896
- : legacyEvidenceMatchesProcess ? legacyEvidence?.failureClass : undefined;
2897
- const infrastructureFailed = receipt.timedOut
2898
- || receipt.cancelled
2899
- || !resultMatchesProcess
2900
- || (!receipt.ok && failureClass !== 'acceptance_failure');
2901
- const checkFailed = !receipt.ok && !infrastructureFailed;
2902
- const boundedStatus = receipt.ok ? 'pass' : infrastructureFailed ? 'infrastructure_failure' : 'fail';
2903
- const commonVerification = {
2904
- checkId: normalizedCheckId,
2905
- outcome: infrastructureFailed ? 'infrastructure_failure' : receipt.ok ? 'valid_pass' : 'valid_fail',
2906
- isAcceptanceFailure: checkFailed,
2907
- isInfrastructureIssue: infrastructureFailed,
2908
- executed: true,
2909
- completed: true,
2910
- processId: receipt.processId,
2911
- processStatus: receipt.runtimeStatus,
2912
- ok: receipt.ok,
2913
- timedOut: receipt.timedOut,
2914
- cancelled: receipt.cancelled,
2915
- failureClass: infrastructureFailed ? 'infrastructure_failure' : failureClass,
2916
- deduplicated: handle.deduplicated === true,
2917
- semanticDeduplicated: handle.semanticDeduplicated === true,
2918
- checkContentRevision: receipt.checkRevision,
2919
- observedGitHead,
2920
- revisionSemantics: 'checkContentRevision is a content-bound Check identity; observedGitHead is Git HEAD and is not interchangeable.',
2921
- evidenceArtifactPath: record.origin?.workVerificationSnapshot ? undefined : receipt.artifactPath,
2922
- evidenceReceiptId: receipt.receiptId,
2923
- checkResultReceiptId: structuredCheckResult?.receiptId,
2924
- verificationIsolation: record.origin?.workVerificationSnapshot ? 'work_snapshot' : 'shared_checkout',
2925
- boundedStatus,
2926
- evidenceState: evidenceState.state,
2927
- ...(evidenceState.infrastructureReason ? { infrastructureReason: evidenceState.infrastructureReason } : {}),
2928
- ...(record.error?.code ? { processErrorCode: record.error.code } : {}),
2929
- };
2930
-
2931
- if (workId) {
2932
- const sourceRevision = observedGitHead ?? undefined;
2933
- const facade = verifyGoalWorkloop(workloopCtx, {
2934
- workId,
2935
- checkId: normalizedCheckId,
2936
- sourceRevision,
2937
- workspaceFingerprint,
2938
- verificationInputFingerprint: sourceRevision ? verificationRequestFingerprint : undefined,
2939
- commandFingerprint: commandFingerprint(normalizedCheckId, receipt.commandId),
2940
- receipt,
2941
- infrastructureFailed,
2942
- checkFailed,
2943
- });
2944
- const data = facade.data as Record<string, unknown>;
2945
- return result({
2946
- ...facade,
2947
- data: {
2948
- ...data,
2949
- verification: {
2950
- ...(typeof data.verification === 'object' && data.verification ? data.verification as Record<string, unknown> : {}),
2951
- ...commonVerification,
2952
- },
2953
- },
2954
- warnings: infrastructureFailed
2955
- ? [...facade.warnings, evidenceState.warning ?? 'infrastructure_failure is distinct from acceptance failure']
2956
- : facade.warnings,
2957
- } as unknown as Record<string, unknown>, facade.status === 'failed');
2958
- }
2959
-
2960
- return result(buildFacadeResult({
2961
- status: checkFailed ? 'failed' : 'ok',
2962
- summary: infrastructureFailed
2963
- ? `Infrastructure failure while running ${normalizedCheckId}; not an acceptance failure.`
2964
- : receipt.ok
2965
- ? `Check ${normalizedCheckId} passed with persisted Process evidence.`
2966
- : `Check ${normalizedCheckId} failed acceptance.`,
2967
- data: { verification: commonVerification },
2968
- warnings: infrastructureFailed
2969
- ? [evidenceState.warning ?? 'infrastructure_failure is distinct from acceptance failure']
2970
- : [],
2971
- rawAvailable: false,
2972
- }) as unknown as Record<string, unknown>, checkFailed);
2973
- } catch (error) {
2974
- const message = error instanceof Error ? error.message : String(error);
2975
- if (workId) {
2976
- const facade = verifyGoalWorkloop(workloopCtx, {
2977
- workId,
2978
- checkId: classified.normalizedCheckId ?? checkId,
2979
- infrastructureFailed: true,
2796
+ commandFingerprint: commandFingerprint(normalizedCheckId, receipt.commandId),
2797
+ receipt,
2798
+ infrastructureFailed,
2799
+ checkFailed,
2980
2800
  });
2981
- return result({
2982
- ...facade,
2983
- warnings: [...facade.warnings, `check_runner_error: ${message.slice(0, 200)}`],
2984
- data: {
2985
- ...(facade.data as Record<string, unknown>),
2986
- isAcceptanceFailure: false,
2987
- },
2988
- } as unknown as Record<string, unknown>);
2801
+ reconciledProcessIds.push(record.processId);
2802
+ } catch {
2803
+ // Exact receipt/process identity is mandatory. Any malformed, stale, or
2804
+ // mismatched terminal Process remains non-authoritative and is ignored.
2989
2805
  }
2990
- return result(buildFacadeResult({
2991
- status: 'ok',
2992
- summary: `Infrastructure failure invoking Process Runtime for ${classified.normalizedCheckId}; not acceptance failure.`,
2993
- data: {
2994
- verification: {
2995
- checkId: classified.normalizedCheckId,
2996
- outcome: 'infrastructure_failure',
2997
- isAcceptanceFailure: false,
2998
- isInfrastructureIssue: true,
2999
- },
3000
- },
3001
- warnings: [`check_runner_error: ${message.slice(0, 200)}`],
3002
- suggestedNextActions: [{
3003
- label: 'Diagnose runtime (dry-run)',
3004
- tool: 'rh_work',
3005
- operation: 'repair',
3006
- payload: { repair_operation: 'diagnose', dry_run: true },
3007
- risk: 'readonly',
3008
- }],
3009
- }) as unknown as Record<string, unknown>);
3010
2806
  }
3011
- }
3012
2807
 
3013
- /**
3014
- * Runtime Source drift for MCP readiness.
3015
- *
3016
- * `currentRuntimeRoot` is only for tests that pin a Controller Runtime Source
3017
- * fixture. Callers must never pass an execution repository canonicalRoot here.
3018
- */
3019
- export function runtimeSourceSnapshotStatus(
3020
- active: RuntimeSourceIdentity | undefined,
3021
- currentRuntimeRoot?: string,
3022
- ) {
3023
- const drift = evaluateActiveRuntimeSourceDrift(active, {
3024
- currentRuntimeRoot,
3025
- });
3026
- return {
3027
- current: drift.current,
3028
- restartRequired: drift.restartRequired,
3029
- reasons: drift.reasons,
3030
- code: drift.code,
3031
- };
2808
+ return { sourceRevision, workspaceFingerprint, workspaceChangedPaths, reconciledProcessIds, workBoundProcessEvidenceIds };
3032
2809
  }
3033
2810
 
3034
- export function repositoryExecutionReadiness(
3035
- repoRoot: string,
3036
- availableChecks: ReturnType<typeof listControllerChecks>,
3037
- requestedCheckIds: string[] = [],
3038
- schedulingScope: { repoId?: string; checkoutId?: string } = {},
3039
- ): Record<string, unknown> {
3040
- const git = gitSnapshot(repoRoot);
3041
- const registeredCheckIds = availableChecks.map((check) => check.id);
3042
- const normalizedChecks = normalizeCheckIds(requestedCheckIds, availableChecks);
3043
- const hasPackageJson = existsSync(join(repoRoot, 'package.json'));
3044
- const nodeModulesReady = !hasPackageJson || existsSync(join(repoRoot, 'node_modules'));
3045
- const lockCandidates = [
3046
- ['bun', 'bun.lock'],
3047
- ['bun', 'bun.lockb'],
3048
- ['pnpm', 'pnpm-lock.yaml'],
3049
- ['npm', 'package-lock.json'],
3050
- ['yarn', 'yarn.lock'],
3051
- ] as const;
3052
- const detectedLock = lockCandidates.find(([, path]) => existsSync(join(repoRoot, path)));
3053
- const packageManager = detectedLock?.[0];
3054
- const bootstrapCommand = hasPackageJson && !nodeModulesReady
3055
- ? packageManager === 'bun' ? ['bun', 'install', '--frozen-lockfile']
3056
- : packageManager === 'pnpm' ? ['pnpm', 'install', '--frozen-lockfile']
3057
- : packageManager === 'npm' ? ['npm', 'ci']
3058
- : packageManager === 'yarn' ? ['yarn', 'install', '--frozen-lockfile']
3059
- : undefined
3060
- : undefined;
3061
- const hasPythonManifest = existsSync(join(repoRoot, 'pyproject.toml'))
3062
- || existsSync(join(repoRoot, 'requirements.txt'))
3063
- || existsSync(join(repoRoot, 'requirements-dev.txt'));
3064
- const localPythonReady = !hasPythonManifest
3065
- || existsSync(join(repoRoot, '.venv', 'bin', 'python'))
3066
- || existsSync(join(repoRoot, '.venv', 'Scripts', 'python.exe'));
3067
- const checkScheduling = buildCheckExecutionSchedule({
3068
- checks: availableChecks,
3069
- requestedCheckIds,
3070
- repoId: schedulingScope.repoId?.trim() || 'selected-repository',
3071
- checkoutId: schedulingScope.checkoutId?.trim() || 'active',
2811
+ async function runFacadeVerify(
2812
+ ctx: MultiRepositoryMcpToolContext,
2813
+ repository: ReturnType<typeof selected>,
2814
+ args: Record<string, unknown>,
2815
+ ): Promise<CallToolResult> {
2816
+ const workId = typeof args.work_id === 'string' ? args.work_id.trim() : '';
2817
+ const verification = await executeWorkVerification({
2818
+ controllerHome: ctx.controllerHome,
2819
+ repository,
2820
+ workId: workId || undefined,
2821
+ checkId: String(args.check_id ?? args.checkId ?? '').trim(),
2822
+ requestId: typeof args.request_id === 'string' && args.request_id.trim() ? args.request_id.trim() : undefined,
2823
+ timeoutMs: typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined,
2824
+ interactiveWaitMs: 0,
2825
+ leaseWaitMs: RH_WORK_VERIFY_LEASE_WAIT_MS,
2826
+ simulate: args.simulate_check === true || args.infrastructure_failed === true || args.check_failed === true || args.skipped === true
2827
+ ? {
2828
+ infrastructureFailed: args.infrastructure_failed === true,
2829
+ checkFailed: args.check_failed === true,
2830
+ skipped: args.skipped === true,
2831
+ }
2832
+ : undefined,
2833
+ allowDurableCheckExecution: workId ? ({ work }) => {
2834
+ try {
2835
+ const identity = authenticatedFacadeControllerIdentity(ctx, args);
2836
+ const owner = getControllerSession({ controllerHome: ctx.controllerHome, repoId: repository.repoId }, work.workId);
2837
+ return Boolean(
2838
+ owner
2839
+ && owner.controllerId === identity.controllerId
2840
+ && controllerSessionPrincipalId(owner) === identity.principalId
2841
+ && owner.controllerInstanceId === identity.controllerInstanceId,
2842
+ );
2843
+ } catch {
2844
+ return false;
2845
+ }
2846
+ } : undefined,
3072
2847
  });
3073
- const blockers = [
3074
- ...(!nodeModulesReady ? [{ code: 'NODE_DEPENDENCIES_MISSING', message: 'package.json is present but node_modules is not materialized in this checkout.' }] : []),
3075
- ...normalizedChecks.invalidCheckIds.map((checkId) => ({ code: 'CHECK_NOT_REGISTERED', message: `Requested check is not registered: ${checkId}`, checkId })),
3076
- ];
3077
- return {
3078
- readyForFocusedExecution: blockers.length === 0,
3079
- git: { head: git.head, branch: git.branch, dirty: git.dirty },
3080
- checks: {
3081
- registeredCount: registeredCheckIds.length,
3082
- registeredCheckIds: registeredCheckIds.slice(0, 80),
3083
- requestedCheckIds: requestedCheckIds.slice(0, 40),
3084
- normalized: normalizedChecks,
3085
- },
3086
- checkScheduling: {
3087
- waveCount: checkScheduling.waves.length,
3088
- maxParallel: checkScheduling.maxParallel,
3089
- waveSummaries: checkScheduling.waves.map((wave) => `wave ${wave.wave}: ${wave.checkIds.join(', ')}`),
3090
- conflictSummaries: checkScheduling.conflicts.map((conflict) => {
3091
- const resources = [...new Set(conflict.resources.flatMap(({ left, right }) => [left.resourceKey, right.resourceKey]))];
3092
- return `${conflict.leftCheckId} <> ${conflict.rightCheckId}: ${resources.join(', ')}`;
3093
- }),
3094
- invalidCheckIds: checkScheduling.invalidCheckIds,
3095
- guidance: checkScheduling.guidance,
3096
- },
3097
- dependencies: {
3098
- node: {
3099
- applicable: hasPackageJson,
3100
- ready: nodeModulesReady,
3101
- packageManager: packageManager ?? null,
3102
- lockfile: detectedLock?.[1] ?? null,
3103
- ...(bootstrapCommand ? { bootstrapCommand } : {}),
3104
- },
3105
- python: {
3106
- applicable: hasPythonManifest,
3107
- localVirtualEnvReady: localPythonReady,
3108
- advisoryOnly: true,
3109
- },
3110
- },
3111
- blockers,
3112
- guidance: bootstrapCommand
3113
- ? [`Materialize checkout dependencies before tests/builds: ${bootstrapCommand.join(' ')}`]
3114
- : [],
3115
- };
2848
+ return result(verification.facade as unknown as Record<string, unknown>, verification.isError);
3116
2849
  }
3117
2850
 
3118
2851
  export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name: string, args: Record<string, unknown>): Promise<CallToolResult | undefined> {
@@ -3142,8 +2875,7 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
3142
2875
  ctx.runtimeSourceRoot,
3143
2876
  );
3144
2877
  const sourceSnapshotStale = runtimeSource.restartRequired;
3145
- const toolset = await import('../../../cli/mcp/toolset');
3146
- const exposure = toolset.controllerToolSurfaceStatus(ctx);
2878
+ const exposure = controllerToolSurfaceStatus(ctx);
3147
2879
  const toolSurfaceReady = exposure.ready && exposure.missingToolNames.length === 0;
3148
2880
  const ready = observation.ready && toolSurfaceReady && !sourceSnapshotStale;
3149
2881
  const reasonCodes = [...observation.reasonCodes];
@@ -3284,10 +3016,8 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
3284
3016
  // never against the selected execution repository.
3285
3017
  const runtimeSource = runtimeSourceSnapshotStatus(readiness.daemon.source, ctx.runtimeSourceRoot);
3286
3018
  const sourceSnapshotStale = runtimeSource.restartRequired;
3287
- // Dynamic import avoids a static cycle: toolset.ts composes runtimeToolDefinitions.
3288
- const toolset = await import('../../../cli/mcp/toolset');
3289
- const exposure = toolset.controllerExposureSnapshot(ctx);
3290
- const localRegisteredToolNames = toolset.allControllerToolDefinitions(ctx).map((tool) => tool.name).sort();
3019
+ const exposure = controllerExposureSnapshot(ctx);
3020
+ const localRegisteredToolNames = allControllerToolDefinitions(ctx).map((tool) => tool.name).sort();
3291
3021
  markDetailPhase('tool_surface');
3292
3022
  const toolSurfaceReady = exposure.ready && exposure.missingToolNames.length === 0;
3293
3023
  // Ordinary interactive execution depends on the execution axis only.
@@ -3526,8 +3256,12 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
3526
3256
  if (item.workId && ctx.principalId?.trim() && ctx.sessionId?.trim()) {
3527
3257
  const owner = getControllerSession(store, item.workId);
3528
3258
  if (owner && owner.controllerId === ctx.principalId.trim() && owner.sessionId === ctx.sessionId.trim()) {
3529
- releaseControllerSession(store, item.workId, owner.controllerId);
3530
- ownershipReleased = true;
3259
+ const released = releaseObservedControllerSession(store, {
3260
+ workId: item.workId,
3261
+ actor: `handoff-create:${ctx.principalId.trim()}`,
3262
+ owner,
3263
+ });
3264
+ ownershipReleased = released.allowed;
3531
3265
  }
3532
3266
  }
3533
3267
  return result(buildFacadeResult({ summary: `Created handoff ${item.id}.`, data: { item: summarizeHandoffItem(item), ownershipReleased } }) as unknown as Record<string, unknown>);
@@ -4190,13 +3924,28 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4190
3924
  const work = getWorkContract(store, workId);
4191
3925
  if (!work) throw new Error(`WORK_NOT_FOUND: ${workId}`);
4192
3926
  const identity = authenticatedFacadeControllerIdentity(ctx, args);
3927
+ assertFacadeControllerRoundAuthority(ctx, store, workId, args);
3928
+ const observedOwner = getControllerSession(store, workId);
3929
+ const dispatchedRelay = getControllerRoundRelay(store, workId);
3930
+ const crossOwnerRecovery = Boolean(observedOwner)
3931
+ && (
3932
+ observedOwner!.controllerId !== identity.controllerId
3933
+ || controllerSessionPrincipalId(observedOwner!) !== identity.principalId
3934
+ )
3935
+ && dispatchedChatgptRelayAuthorizesStaleControllerRecovery(dispatchedRelay, identity.controllerType);
4193
3936
  const session = resumeControllerSession(store, {
4194
3937
  workId,
4195
3938
  controllerId: identity.controllerId,
4196
- controllerType: ['chatgpt', 'codex', 'grok', 'claude', 'human'].includes(String(args.controller_type)) ? String(args.controller_type) as 'chatgpt' | 'codex' | 'grok' | 'claude' | 'human' : 'chatgpt',
3939
+ controllerType: identity.controllerType,
4197
3940
  sessionId: identity.sessionId,
4198
3941
  principalId: identity.principalId,
4199
3942
  controllerInstanceId: identity.controllerInstanceId,
3943
+ ...(crossOwnerRecovery
3944
+ ? {
3945
+ expectedClaimGeneration: observedOwner!.claimGeneration,
3946
+ allowStaleRecovery: true,
3947
+ }
3948
+ : {}),
4200
3949
  leaseMs: typeof args.lease_ms === 'number' ? args.lease_ms : undefined,
4201
3950
  });
4202
3951
  const permissionSnapshotVersion = currentPermissionSnapshotVersion(ctx.controllerHome, repository.repoId);
@@ -4238,7 +3987,7 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4238
3987
  if (!['continue_immediately', 'wait', 'wait_for_user', 'goal_complete'].includes(disposition)) {
4239
3988
  throw new Error('CONTROLLER_RELAY_DISPOSITION_INVALID');
4240
3989
  }
4241
- const identity = authenticatedFacadeControllerIdentity(ctx, args);
3990
+ const identity = authenticatedFacadeControllerIdentity(ctx, args, { allowTransportSessionRollover: true });
4242
3991
  const work = getWorkContract(store, workId);
4243
3992
  if (!work) throw new Error(`WORK_NOT_FOUND: ${workId}`);
4244
3993
  const terminalGoalComplete = work.status === 'completed' && disposition === 'goal_complete';
@@ -4252,7 +4001,7 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4252
4001
  if (currentOwner) {
4253
4002
  if (currentOwner.controllerType !== 'chatgpt') throw new Error(`CONTROLLER_RELAY_CHATGPT_ONLY: ${workId}`);
4254
4003
  if (currentOwner.controllerId !== identity.controllerId) throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${workId}`);
4255
- if ((currentOwner.principalId?.trim() || currentOwner.controllerId) !== identity.principalId) {
4004
+ if ((controllerSessionPrincipalId(currentOwner)) !== identity.principalId) {
4256
4005
  throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${workId}`);
4257
4006
  }
4258
4007
  // Finalization may terminalize the Work before the prompt-required
@@ -4261,21 +4010,7 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4261
4010
  // rewrite a terminal Work lease here; terminal authorization is fenced
4262
4011
  // by the already-claimed relay lineage in submitControllerRoundDisposition.
4263
4012
  if (!terminalGoalComplete) {
4264
- if (!currentOwner.controllerInstanceId?.trim() || currentOwner.controllerInstanceId.trim() !== identity.controllerInstanceId) {
4265
- throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${workId}`);
4266
- }
4267
- if (currentOwner.sessionId !== identity.sessionId) {
4268
- resumeControllerSession(store, {
4269
- workId,
4270
- controllerId: identity.controllerId,
4271
- controllerType: 'chatgpt',
4272
- sessionId: identity.sessionId,
4273
- principalId: identity.principalId,
4274
- controllerInstanceId: identity.controllerInstanceId,
4275
- expectedClaimGeneration: currentOwner.claimGeneration,
4276
- leaseMs: 3_600_000,
4277
- });
4278
- }
4013
+ bindFacadeControllerOwnership(ctx, store, workId, { ...identity, controllerType: 'chatgpt' });
4279
4014
  }
4280
4015
  }
4281
4016
  const chatgptBinding = getChatgptWorkConversationBinding(store, workId);
@@ -4308,20 +4043,42 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4308
4043
  if (operation === 'controller_release') {
4309
4044
  try {
4310
4045
  const workId = String(args.work_id ?? '').trim();
4311
- const identity = authenticatedFacadeControllerIdentity(ctx, args);
4312
- const owner = getControllerSession(store, workId);
4046
+ const identity = authenticatedFacadeControllerIdentity(ctx, args, { allowTransportSessionRollover: true });
4047
+ assertFacadeControllerRoundAuthority(ctx, store, workId, args);
4048
+ const observedOwner = getControllerSession(store, workId);
4049
+ const work = getWorkContract(store, workId);
4050
+ const terminalWork = work ? ['completed', 'failed', 'cancelled'].includes(work.status) : false;
4051
+ const owner = observedOwner
4052
+ ? terminalWork
4053
+ ? (() => {
4054
+ // A terminal Work cannot be rebound to a replacement MCP transport.
4055
+ // Authenticate the caller against the observed durable controller epoch
4056
+ // without mutating it; releaseControllerSessionWithAuthority remains the
4057
+ // exact claim-generation CAS authority for physical lease release.
4058
+ const ownerPrincipal = observedOwner.principalId?.trim() || observedOwner.controllerId;
4059
+ const ownerInstanceId = observedOwner.controllerInstanceId?.trim() || '';
4060
+ if (observedOwner.controllerId !== identity.controllerId) {
4061
+ throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${workId}`);
4062
+ }
4063
+ if (observedOwner.controllerType !== identity.controllerType) {
4064
+ throw new Error(`WORK_CONTROLLER_TYPE_MISMATCH: ${workId}`);
4065
+ }
4066
+ if (ownerPrincipal !== identity.principalId) {
4067
+ throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${workId}`);
4068
+ }
4069
+ if (!ownerInstanceId || ownerInstanceId !== identity.controllerInstanceId) {
4070
+ throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${workId}`);
4071
+ }
4072
+ if (typeof observedOwner.claimGeneration !== 'number' || observedOwner.claimGeneration < 1) {
4073
+ throw new Error(`WORK_CONTROLLER_CLAIM_GENERATION_REQUIRED: ${workId}`);
4074
+ }
4075
+ return observedOwner;
4076
+ })()
4077
+ : bindFacadeControllerOwnership(ctx, store, workId, identity)
4078
+ : undefined;
4313
4079
  if (owner) {
4314
- const ownerPrincipal = owner.principalId?.trim() || owner.controllerId;
4080
+ const ownerPrincipal = controllerSessionPrincipalId(owner);
4315
4081
  const ownerInstanceId = owner.controllerInstanceId?.trim() || '';
4316
- if (owner.controllerId !== identity.controllerId) {
4317
- throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${workId} is owned by ${owner.controllerId}`);
4318
- }
4319
- if (ownerPrincipal !== identity.principalId) {
4320
- throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${workId}`);
4321
- }
4322
- if (!ownerInstanceId || ownerInstanceId !== identity.controllerInstanceId) {
4323
- throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${workId}`);
4324
- }
4325
4082
  if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) {
4326
4083
  throw new Error(`WORK_CONTROLLER_CLAIM_GENERATION_REQUIRED: ${workId}`);
4327
4084
  }
@@ -4349,6 +4106,9 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4349
4106
  relayStore,
4350
4107
  { workId, releasedSession: owner },
4351
4108
  ) : undefined;
4109
+ const abandonedRelay = owner && !relay
4110
+ ? reconcileControllerRoundAfterAbandonedRelease(relayStore, { workId, releasedSession: owner })
4111
+ : undefined;
4352
4112
  if (relay?.status === 'dispatching') {
4353
4113
  try {
4354
4114
  assertAutomatedOperationAllowed('external_controller_wake', {
@@ -4418,8 +4178,10 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4418
4178
  }
4419
4179
  }
4420
4180
  return result(buildFacadeResult({
4421
- summary: 'Controller lease released.',
4422
- data: { relay: getControllerRoundRelay(relayStore, workId) ?? relay, tabSettlement },
4181
+ summary: abandonedRelay
4182
+ ? 'Controller lease released; the claimed round was mechanically marked abandoned without semantic completion and can be relaunched through the bounded launcher path.'
4183
+ : 'Controller lease released.',
4184
+ data: { relay: getControllerRoundRelay(relayStore, workId) ?? abandonedRelay ?? relay, tabSettlement },
4423
4185
  }) as unknown as Record<string, unknown>);
4424
4186
  } catch (error) {
4425
4187
  return result(buildFacadeResult({ status: 'blocked', summary: error instanceof Error ? error.message : 'Controller release failed.', data: {} }) as unknown as Record<string, unknown>, true);
@@ -4576,57 +4338,46 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4576
4338
  };
4577
4339
 
4578
4340
  if (operation === 'requirement_create') {
4579
- const requirementId = typeof args.requirement_id === 'string' ? args.requirement_id.trim() : '';
4580
- const title = typeof args.requirement_title === 'string' ? args.requirement_title.trim().slice(0, 500) : '';
4581
- const outcomeStatement = typeof args.requirement_outcome === 'string' ? args.requirement_outcome.trim().slice(0, 2_000) : '';
4582
- const acceptanceCriteria = Array.isArray(args.requirement_acceptance_criteria)
4583
- ? args.requirement_acceptance_criteria.map((value) => String(value).trim()).filter(Boolean).slice(0, 50).map((value) => value.slice(0, 500))
4584
- : [];
4585
- const requiredDeliveryReferences = Array.isArray(args.requirement_delivery_references)
4586
- ? args.requirement_delivery_references.map((value) => String(value).trim()).filter(Boolean).slice(0, 50).map((value) => value.slice(0, 500))
4587
- : [];
4588
- const legacyAliases = Array.isArray(args.requirement_legacy_aliases)
4589
- ? args.requirement_legacy_aliases.map((value) => String(value).trim()).filter(Boolean).slice(0, 20).map((value) => value.slice(0, 160))
4590
- : [];
4591
- if (!requirementId || !title || !outcomeStatement) {
4341
+ const requirementId = typeof args.requirement_id === 'string' ? args.requirement_id : '';
4342
+ const title = typeof args.requirement_title === 'string' ? args.requirement_title : '';
4343
+ const outcomeStatement = typeof args.requirement_outcome === 'string' ? args.requirement_outcome : '';
4344
+ if (!requirementId.trim() || !title.trim() || !outcomeStatement.trim()) {
4592
4345
  return result(buildFacadeResult({
4593
4346
  status: 'blocked',
4594
4347
  summary: 'REQUIREMENT_CREATE_INPUT_REQUIRED: requirement_id, requirement_title, and requirement_outcome are required.',
4595
4348
  data: { requirementCreated: false },
4596
4349
  }) as unknown as Record<string, unknown>, true);
4597
4350
  }
4598
- const existing = readRequirement({ controllerHome: ctx.controllerHome }, requirementId)?.value;
4599
- if (existing) {
4600
- const identical = existing.title === title
4601
- && existing.outcomeStatement === outcomeStatement
4602
- && JSON.stringify(existing.acceptanceCriteria) === JSON.stringify(acceptanceCriteria)
4603
- && JSON.stringify(existing.requiredDeliveryReferences) === JSON.stringify(requiredDeliveryReferences)
4604
- && JSON.stringify(existing.legacyAliases) === JSON.stringify(legacyAliases);
4605
- if (!identical) {
4606
- return result(buildFacadeResult({
4607
- status: 'blocked',
4608
- summary: `REQUIREMENT_ALREADY_EXISTS_CONFLICT: ${requirementId}. Existing Requirement authority was not changed.`,
4609
- data: { requirement: existing, requirementCreated: false, admissionDecision: 'existing_conflict' },
4610
- suggestedNextActions: [],
4611
- }) as unknown as Record<string, unknown>, true);
4612
- }
4351
+ let admission;
4352
+ try {
4353
+ admission = admitRequirement({ controllerHome: ctx.controllerHome }, {
4354
+ requirementId,
4355
+ title,
4356
+ outcomeStatement,
4357
+ acceptanceCriteria: Array.isArray(args.requirement_acceptance_criteria) ? args.requirement_acceptance_criteria.map(String) : [],
4358
+ requiredDeliveryReferences: Array.isArray(args.requirement_delivery_references) ? args.requirement_delivery_references.map(String) : [],
4359
+ legacyAliases: Array.isArray(args.requirement_legacy_aliases) ? args.requirement_legacy_aliases.map(String) : [],
4360
+ });
4361
+ } catch (error) {
4362
+ return result(buildFacadeResult({
4363
+ status: 'blocked',
4364
+ summary: error instanceof Error ? error.message : String(error),
4365
+ data: { requirementCreated: false },
4366
+ }) as unknown as Record<string, unknown>, true);
4367
+ }
4368
+ if (admission.decision === 'existing_conflict') {
4613
4369
  return result(buildFacadeResult({
4614
- summary: `REQUIREMENT_AUTHORITY_REUSED: ${requirementId}. Requirement authority does not imply a Plan; Controller chooses the next action.`,
4615
- data: { requirement: existing, requirementCreated: false, admissionDecision: 'reuse_existing' },
4370
+ status: 'blocked',
4371
+ summary: `REQUIREMENT_ALREADY_EXISTS_CONFLICT: ${admission.requirement.requirementId}. Existing Requirement authority was not changed.`,
4372
+ data: { requirement: admission.requirement, requirementCreated: false, admissionDecision: admission.decision },
4616
4373
  suggestedNextActions: [],
4617
- }) as unknown as Record<string, unknown>);
4374
+ }) as unknown as Record<string, unknown>, true);
4618
4375
  }
4619
- const requirement = createRequirement({ controllerHome: ctx.controllerHome }, {
4620
- requirementId,
4621
- title,
4622
- outcomeStatement,
4623
- acceptanceCriteria,
4624
- requiredDeliveryReferences,
4625
- legacyAliases,
4626
- });
4627
4376
  return result(buildFacadeResult({
4628
- summary: `Requirement ${requirementId} created. Requirement authority does not imply a Plan; Controller chooses the next action.`,
4629
- data: { requirement, requirementCreated: true, admissionDecision: 'created' },
4377
+ summary: admission.decision === 'created'
4378
+ ? `Requirement ${admission.requirement.requirementId} created. Requirement authority does not imply a Plan; Controller chooses the next action.`
4379
+ : `REQUIREMENT_AUTHORITY_REUSED: ${admission.requirement.requirementId}. Requirement authority does not imply a Plan; Controller chooses the next action.`,
4380
+ data: { requirement: admission.requirement, requirementCreated: admission.created, admissionDecision: admission.decision },
4630
4381
  suggestedNextActions: [],
4631
4382
  }) as unknown as Record<string, unknown>);
4632
4383
  }
@@ -4635,6 +4386,7 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4635
4386
  try {
4636
4387
  if (operation === 'plan_create') {
4637
4388
  const rawSteps = Array.isArray(args.plan_steps) ? args.plan_steps : [];
4389
+ const requestedPlanId = String(args.plan_id ?? '').trim();
4638
4390
  const requestedRequirementId = typeof args.requirement_id === 'string' && args.requirement_id.trim() ? args.requirement_id.trim() : undefined;
4639
4391
  const requestedPlanRelation: 'extend' | 'parallel' | undefined = args.plan_relation === 'extend' || args.plan_relation === 'parallel'
4640
4392
  ? args.plan_relation
@@ -4657,16 +4409,43 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4657
4409
  const renderPlanAdmission = (admission: ReturnType<typeof resolvePlanAdmission>): CallToolResult | undefined => {
4658
4410
  if (admission.admissionDecision === 'create_new') return undefined;
4659
4411
  if (admission.reason === 'exact_scope_authority' && admission.plan) {
4412
+ const exactDraftRepair = admission.plan.status === 'draft' && requestedPlanId === admission.plan.planId;
4660
4413
  const facade = buildFacadeResult({
4661
- summary: `PLAN_AUTHORITY_REUSED: active Plan ${admission.plan.planId} already owns scope ${admission.normalizedScopeKey}; no duplicate draft was created.`,
4414
+ summary: exactDraftRepair
4415
+ ? `PLAN_DRAFT_REPAIR_REQUIRED: draft Plan ${admission.plan.planId} already owns scope ${admission.normalizedScopeKey}; preserve that authority and amend it through rh_work repair.`
4416
+ : `PLAN_AUTHORITY_REUSED: active Plan ${admission.plan.planId} already owns scope ${admission.normalizedScopeKey}; no duplicate draft was created.`,
4662
4417
  data: {
4663
4418
  plan: summarizePlanContract(admission.plan),
4664
4419
  executionStarted: false,
4665
4420
  planContractCreated: false,
4666
4421
  admissionDecision: 'reuse_existing',
4667
4422
  resolutionRequired: false,
4423
+ ...(exactDraftRepair ? { repairRequired: true } : {}),
4668
4424
  },
4669
- suggestedNextActions: [{ label: 'Read active Plan', tool: 'rh_work', operation: 'plan_get', payload: { plan_id: admission.plan.planId }, risk: 'readonly', confidence: 'high' }],
4425
+ suggestedNextActions: exactDraftRepair
4426
+ ? [{
4427
+ label: 'Repair this exact draft Plan',
4428
+ tool: 'rh_work',
4429
+ operation: 'repair',
4430
+ payload: {
4431
+ plan_id: admission.plan.planId,
4432
+ repair_operation: 'repair',
4433
+ dry_run: false,
4434
+ scope_key: args.scope_key,
4435
+ source_revision: args.source_revision,
4436
+ objective: args.objective,
4437
+ plan_steps: args.plan_steps,
4438
+ non_goals: args.non_goals,
4439
+ assumptions: args.assumptions,
4440
+ resolved_decisions: args.resolved_decisions,
4441
+ stop_conditions: args.stop_conditions,
4442
+ replan_conditions: args.replan_conditions,
4443
+ integration_strategy: args.integration_strategy,
4444
+ },
4445
+ risk: 'workspace_write',
4446
+ confidence: 'high',
4447
+ }]
4448
+ : [{ label: 'Read active Plan', tool: 'rh_work', operation: 'plan_get', payload: { plan_id: admission.plan.planId }, risk: 'readonly', confidence: 'high' }],
4670
4449
  });
4671
4450
  return result(facade as unknown as Record<string, unknown>);
4672
4451
  }
@@ -4828,41 +4607,23 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4828
4607
 
4829
4608
  if (operation === 'stop') {
4830
4609
  const workId = String(args.work_id ?? '').trim();
4831
- const currentOwner = workId ? getControllerSession(store, workId) : undefined;
4832
- let terminalizationAuthority: ControllerTerminalizationAuthority | undefined;
4833
- if (currentOwner) {
4834
- try {
4835
- const identity = authenticatedFacadeControllerIdentity(ctx, args);
4836
- const ownerPrincipal = currentOwner.principalId?.trim() || currentOwner.controllerId;
4837
- const ownerInstanceId = currentOwner.controllerInstanceId?.trim() || '';
4838
- if (currentOwner.controllerId !== identity.controllerId || ownerPrincipal !== identity.principalId) {
4839
- throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${workId}`);
4840
- }
4841
- if (!ownerInstanceId || ownerInstanceId !== identity.controllerInstanceId) {
4842
- throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${workId}`);
4843
- }
4844
- if (typeof currentOwner.claimGeneration !== 'number' || currentOwner.claimGeneration < 1) {
4845
- throw new Error(`WORK_CONTROLLER_CLAIM_GENERATION_REQUIRED: ${workId}`);
4846
- }
4847
- terminalizationAuthority = {
4848
- controllerId: currentOwner.controllerId,
4849
- controllerType: currentOwner.controllerType,
4850
- principalId: ownerPrincipal,
4851
- controllerInstanceId: ownerInstanceId,
4852
- claimGeneration: currentOwner.claimGeneration,
4853
- };
4854
- } catch (error) {
4855
- const blocked = buildFacadeResult({
4856
- status: 'blocked',
4857
- summary: error instanceof Error ? error.message : `Work ${workId} terminalization authority check failed.`,
4858
- data: { workId, terminalizationApplied: false },
4859
- });
4860
- return result(blocked as unknown as Record<string, unknown>, true);
4861
- }
4862
- } else if (!['user', 'system'].includes(String(args.requested_by ?? ''))) {
4610
+ try {
4611
+ if (workId) assertFacadeControllerRoundAuthority(ctx, store, workId, args);
4612
+ } catch (error) {
4613
+ const blocked = buildFacadeResult({
4614
+ status: 'blocked',
4615
+ summary: error instanceof Error ? error.message : `Work ${workId} controller-round authority check failed.`,
4616
+ data: { workId, terminalizationApplied: false },
4617
+ });
4618
+ return result(blocked as unknown as Record<string, unknown>, true);
4619
+ }
4620
+ let terminalizationAuthority: ControllerTerminalizationAuthority;
4621
+ try {
4622
+ terminalizationAuthority = currentFacadeTerminalizationAuthority(ctx, store, workId, args);
4623
+ } catch (error) {
4863
4624
  const blocked = buildFacadeResult({
4864
4625
  status: 'blocked',
4865
- summary: `WORK_CONTROLLER_OWNER_REQUIRED: ${workId}; unclaimed stop requires explicit requested_by=user or requested_by=system authority.`,
4626
+ summary: error instanceof Error ? error.message : `Work ${workId} terminalization authority check failed.`,
4866
4627
  data: { workId, terminalizationApplied: false },
4867
4628
  });
4868
4629
  return result(blocked as unknown as Record<string, unknown>, true);
@@ -4934,13 +4695,41 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4934
4695
 
4935
4696
  if (operation === 'finalize') {
4936
4697
  const workId = String(args.work_id ?? '').trim();
4937
- if (workId) reconcileTerminalFacadeWorkVerifications(ctx, repository, workId);
4698
+ try {
4699
+ if (workId) assertFacadeControllerRoundAuthority(ctx, store, workId, args);
4700
+ } catch (error) {
4701
+ const blocked = buildFacadeResult({ status: 'blocked', summary: error instanceof Error ? error.message : `Work ${workId} controller-round authority check failed.`, data: { workId, lifecycleClosed: false } });
4702
+ return result(blocked as unknown as Record<string, unknown>, true);
4703
+ }
4704
+ const finalizeReconciliation = workId
4705
+ ? reconcileTerminalFacadeWorkVerifications(ctx, repository, workId)
4706
+ : undefined;
4707
+ const semanticFinalizeContext = {
4708
+ ...workloopCtx,
4709
+ sourceRevision: finalizeReconciliation?.sourceRevision ?? workloopCtx.sourceRevision ?? undefined,
4710
+ workspaceFingerprint: finalizeReconciliation?.workspaceFingerprint,
4711
+ workspaceChangedPaths: finalizeReconciliation?.workspaceChangedPaths,
4712
+ workBoundProcessEvidenceIds: finalizeReconciliation?.workBoundProcessEvidenceIds,
4713
+ };
4938
4714
  let before = workId ? getWorkContract(store, workId) : undefined;
4715
+ let terminalizationAuthority: ControllerTerminalizationAuthority | undefined;
4716
+ // Finalize may commit, merge, clean resources, and complete the Work.
4717
+ // It therefore shares the same exact-claim authority as stop. Transport
4718
+ // or Runtime recovery remains an explicit controller_claim operation;
4719
+ // terminalization itself must never rebind an unrelated controller scope.
4720
+ if (before && !['completed', 'failed', 'cancelled'].includes(before.status)) {
4721
+ try {
4722
+ terminalizationAuthority = currentFacadeTerminalizationAuthority(ctx, store, workId, args);
4723
+ } catch (error) {
4724
+ const blocked = buildFacadeResult({ status: 'blocked', summary: error instanceof Error ? error.message : `Work ${workId} terminalization authority check failed.`, data: { workId, terminalizationApplied: false, lifecycleClosed: false } });
4725
+ return result(blocked as unknown as Record<string, unknown>, true);
4726
+ }
4727
+ }
4939
4728
  if (before && !before.completionReceipt && args.reconcile_historical_delivery === true) {
4940
4729
  try {
4941
4730
  const identity = authenticatedFacadeControllerIdentity(ctx, args);
4942
4731
  const owner = getControllerSession(store, workId);
4943
- if (!owner || (owner.principalId?.trim() || owner.controllerId) !== identity.principalId) {
4732
+ if (!owner || (controllerSessionPrincipalId(owner)) !== identity.principalId) {
4944
4733
  throw new Error(`DIRECT_EDIT_WORK_RECONCILIATION_CONTROLLER_CLAIM_REQUIRED: ${workId}`);
4945
4734
  }
4946
4735
  const historicalHandle = readWorkHandle(ctx.controllerHome, repository.repoId, workId);
@@ -4973,31 +4762,60 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
4973
4762
  : transitionWorkHandle(ctx.controllerHome, historicalHandle, 'committed', { expectedHead: reconciliation.receipt.targetRevision, finalization, failureReason: undefined });
4974
4763
  transitionWorkHandle(ctx.controllerHome, delivered, 'cleaned', { expectedHead: reconciliation.receipt.targetRevision, finalization, failureReason: undefined });
4975
4764
  }
4976
- releaseControllerSession(store, workId, owner.controllerId);
4765
+ const released = releaseObservedControllerSession(store, {
4766
+ workId,
4767
+ actor: `direct-edit-reconciliation:${identity.principalId}`,
4768
+ owner,
4769
+ });
4770
+ if (!released.allowed) {
4771
+ throw new Error(`DIRECT_EDIT_WORK_RECONCILIATION_CONTROLLER_RELEASE_FENCED: ${workId}:${released.reason}`);
4772
+ }
4977
4773
  before = getWorkContract(store, workId);
4978
4774
  } catch (error) {
4979
4775
  const blocked = buildFacadeResult({ status: 'blocked', summary: error instanceof Error ? error.message : 'Historical Work delivery reconciliation failed.', data: { workId, lifecycleClosed: false } });
4980
4776
  return result(blocked as unknown as Record<string, unknown>, true);
4981
4777
  }
4982
4778
  }
4983
- // Legacy callers could classify repository mutation + Git remote delivery
4984
- // as remote_effect. A physical WorkHandle proves this is repository delivery,
4985
- // so keep it on work_finalize instead of demanding a plugin action receipt.
4986
- // Pure plugin remote effects have no repository WorkHandle and retain the
4987
- // action-receipt finalization contract.
4988
4779
  const repositoryDeliveryHandle = workId
4989
4780
  ? readWorkHandle(ctx.controllerHome, repository.repoId, workId)
4990
4781
  : undefined;
4991
- if (before?.workKind === 'remote_effect' && !before.completionReceipt && !repositoryDeliveryHandle) {
4782
+ const effectHasRepositoryDelta = Boolean(
4783
+ before
4784
+ && ['local_effect', 'remote_effect'].includes(before.workKind)
4785
+ && (
4786
+ (repositoryDeliveryHandle && repositoryWorkHandleHasSourceDelta(repository, repositoryDeliveryHandle))
4787
+ || (finalizeReconciliation?.workspaceChangedPaths?.length ?? 0) > 0
4788
+ )
4789
+ );
4790
+ if (effectHasRepositoryDelta && !repositoryDeliveryHandle) {
4791
+ const blocked = buildFacadeResult({
4792
+ status: 'blocked',
4793
+ summary: `WORK_EFFECT_REPOSITORY_DELIVERY_HANDLE_REQUIRED: ${workId} has repository source delta and cannot use effect-only terminalization without a physical WorkHandle.`,
4794
+ data: { workId, lifecycleClosed: false },
4795
+ });
4796
+ return result(blocked as unknown as Record<string, unknown>, true);
4797
+ }
4798
+ // Pure remote effects may complete from either the canonical plugin
4799
+ // action receipt or the deliberately narrow trusted repository
4800
+ // Process authority (`git push` + exact remote lease). A remote_effect
4801
+ // that acquired source delta is no longer pure and must use physical
4802
+ // repository delivery instead.
4803
+ if (before?.workKind === 'remote_effect' && !before.completionReceipt && !effectHasRepositoryDelta) {
4992
4804
  try {
4993
4805
  before = finalizeRemoteEffectWorkFromActionReceipt(ctx.controllerHome, repository.repoId, workId);
4994
- } catch (error) {
4995
- const blocked = buildFacadeResult({
4996
- status: 'blocked',
4997
- summary: error instanceof Error ? error.message : 'Remote-effect semantic finalization failed.',
4998
- data: { workId, lifecycleClosed: false },
4999
- });
5000
- return result(blocked as unknown as Record<string, unknown>, true);
4806
+ } catch (pluginError) {
4807
+ const checkoutId = before.checkoutId ?? repositoryDeliveryHandle?.checkoutId ?? repository.activeCheckoutId;
4808
+ const processCompleted = finalizeRemoteEffectWorkFromRepositoryProcessReceipt(ctx, repository, workId, checkoutId);
4809
+ if (processCompleted) {
4810
+ before = processCompleted;
4811
+ } else {
4812
+ const blocked = buildFacadeResult({
4813
+ status: 'blocked',
4814
+ summary: pluginError instanceof Error ? pluginError.message : 'Remote-effect semantic finalization failed.',
4815
+ data: { workId, lifecycleClosed: false },
4816
+ });
4817
+ return result(blocked as unknown as Record<string, unknown>, true);
4818
+ }
5001
4819
  }
5002
4820
  }
5003
4821
  const completedCleanupPending = Boolean(
@@ -5006,7 +4824,15 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
5006
4824
  && before.worktreeRef?.trim()
5007
4825
  && existsSync(before.worktreeRef),
5008
4826
  );
5009
- if (before && before.workKind !== 'local_effect' && (!before.completionReceipt || completedCleanupPending)) {
4827
+ const repositoryDeliveryRequired = Boolean(
4828
+ before
4829
+ && before.workKind !== 'read_only_review'
4830
+ && (
4831
+ !['local_effect', 'remote_effect'].includes(before.workKind)
4832
+ || effectHasRepositoryDelta
4833
+ )
4834
+ );
4835
+ if (before && ((repositoryDeliveryRequired && !before.completionReceipt) || completedCleanupPending)) {
5010
4836
  try {
5011
4837
  const physical = await finalizeFacadeWorkHandle(ctx, repository, args, 'finalize');
5012
4838
  if (physical?.isError === true) return physical;
@@ -5025,40 +4851,15 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
5025
4851
  let facade;
5026
4852
  if (semanticWork && !['completed', 'failed', 'cancelled'].includes(semanticWork.status)) {
5027
4853
  try {
5028
- const identity = authenticatedFacadeControllerIdentity(ctx, args);
5029
- let owner = getControllerSession(store, workId);
5030
- if (!owner) {
5031
- owner = claimControllerSession(store, {
5032
- workId,
5033
- controllerId: identity.controllerId,
5034
- controllerType: identity.controllerType,
5035
- sessionId: identity.sessionId,
5036
- principalId: identity.principalId,
5037
- controllerInstanceId: identity.controllerInstanceId,
5038
- expectedClaimGeneration: 0,
5039
- leaseMs: 3_600_000,
5040
- });
5041
- }
5042
- const ownerPrincipal = owner.principalId?.trim() || owner.controllerId;
5043
- const ownerInstanceId = owner.controllerInstanceId?.trim() || '';
5044
- if (owner.controllerId !== identity.controllerId) throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${workId}`);
5045
- if (ownerPrincipal !== identity.principalId) throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${workId}`);
5046
- if (!ownerInstanceId || ownerInstanceId !== identity.controllerInstanceId) throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${workId}`);
5047
- if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) throw new Error(`WORK_CONTROLLER_CLAIM_GENERATION_REQUIRED: ${workId}`);
4854
+ const authority = terminalizationAuthority ?? currentFacadeTerminalizationAuthority(ctx, store, workId, args);
5048
4855
  const fenced = withControllerSessionTerminalizationFence(
5049
4856
  store,
5050
4857
  {
5051
4858
  workId,
5052
- actor: `rh-work-finalize:${identity.controllerId}:${identity.controllerInstanceId}`,
5053
- authority: {
5054
- controllerId: owner.controllerId,
5055
- controllerType: owner.controllerType,
5056
- principalId: ownerPrincipal,
5057
- controllerInstanceId: ownerInstanceId,
5058
- claimGeneration: owner.claimGeneration,
5059
- },
4859
+ actor: `rh-work-finalize:${authority.controllerId}:${authority.controllerInstanceId}`,
4860
+ authority,
5060
4861
  },
5061
- () => runGoalWorkloop({ ...workloopCtx, sourceRevision: workloopCtx.sourceRevision ?? undefined }, 'finalize', args),
4862
+ () => runGoalWorkloop(semanticFinalizeContext, 'finalize', args),
5062
4863
  );
5063
4864
  if (!fenced.allowed) {
5064
4865
  throw new Error(`WORK_TERMINALIZATION_AUTHORITY_FENCED: ${workId}:${fenced.reason}`);
@@ -5073,9 +4874,29 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
5073
4874
  return result(blocked as unknown as Record<string, unknown>, true);
5074
4875
  }
5075
4876
  } else {
5076
- facade = runGoalWorkloop({ ...workloopCtx, sourceRevision: workloopCtx.sourceRevision ?? undefined }, 'finalize', args);
4877
+ facade = runGoalWorkloop(semanticFinalizeContext, 'finalize', args);
4878
+ }
4879
+ let completed = getWorkContract(store, workId);
4880
+ const postSemanticCleanupPending = Boolean(
4881
+ completed?.completionReceipt
4882
+ && args.cleanup !== false
4883
+ && completed.worktreeRef?.trim()
4884
+ && existsSync(completed.worktreeRef),
4885
+ );
4886
+ if (postSemanticCleanupPending) {
4887
+ try {
4888
+ const physical = await finalizeFacadeWorkHandle(ctx, repository, { ...args, commit: false, merge: false }, 'finalize');
4889
+ if (physical?.isError === true) return physical;
4890
+ completed = getWorkContract(store, workId);
4891
+ } catch (error) {
4892
+ const blocked = buildFacadeResult({
4893
+ status: 'blocked',
4894
+ summary: error instanceof Error ? error.message : `Work ${workId} terminal cleanup reconciliation failed.`,
4895
+ data: { workId, terminalizationApplied: true, lifecycleClosed: false },
4896
+ });
4897
+ return result(blocked as unknown as Record<string, unknown>, true);
4898
+ }
5077
4899
  }
5078
- const completed = getWorkContract(store, workId);
5079
4900
  // Finalizing a Work proves the Work lifecycle only. A Plan step may aggregate
5080
4901
  // acceptance criteria that are broader than this Work (for example, a canary
5081
4902
  // plus a later stabilization soak), so finalize must never synthesize semantic
@@ -5100,12 +4921,23 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
5100
4921
  let continuationSourceRevision = workloopCtx.sourceRevision;
5101
4922
  let continuationWorkspaceFingerprint: string | undefined;
5102
4923
  let continuationWorkspaceChangedPaths: string[] | undefined;
4924
+ let continuationWorkBoundProcessEvidenceIds: string[] | undefined;
5103
4925
  if (operation === 'continue') {
5104
4926
  try {
5105
4927
  const workId = String(args.work_id ?? '').trim();
4928
+ if (workId) assertFacadeControllerRoundAuthority(ctx, store, workId, args);
5106
4929
  let work = getWorkContract(store, workId);
5107
4930
  if (work?.status === 'cancelled') {
5108
- const resumed = reauthorizeCancelledFacadeWork(ctx, repository, workId, args);
4931
+ const identity = authenticatedFacadeControllerIdentity(ctx, args);
4932
+ const resumed = reauthorizeRetainedCancelledRepositoryWork({
4933
+ controllerHome: ctx.controllerHome,
4934
+ repository,
4935
+ workId,
4936
+ identity,
4937
+ requestedBy: typeof args.requested_by === 'string' ? args.requested_by : undefined,
4938
+ approvalConfirmed: args.approval_confirmed === true,
4939
+ prepareDependencies: args.needs_dependencies === true,
4940
+ });
5109
4941
  cancelledWorkReauthorized = true;
5110
4942
  reconstructedCancelledCheckout = resumed.reconstructedCheckout;
5111
4943
  work = getWorkContract(store, workId);
@@ -5121,14 +4953,12 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
5121
4953
  controllerInstanceId: identity.controllerInstanceId,
5122
4954
  leaseMs: 3_600_000,
5123
4955
  });
5124
- const existingHandle = readWorkHandle(ctx.controllerHome, repository.repoId, workId);
5125
- if (existingHandle && (existingHandle.principalId !== identity.principalId || existingHandle.sessionId !== identity.sessionId)) {
5126
- writeWorkHandle(ctx.controllerHome, {
5127
- ...existingHandle,
5128
- principalId: identity.principalId,
5129
- sessionId: identity.sessionId,
5130
- });
5131
- }
4956
+ rebindRepositoryWorkHandleControllerIdentity({
4957
+ controllerHome: ctx.controllerHome,
4958
+ repositoryId: repository.repoId,
4959
+ workId,
4960
+ identity: { sessionId: identity.sessionId, principalId: identity.principalId },
4961
+ });
5132
4962
  if (work.worktreePolicy.required === true && !work.worktreeRef) {
5133
4963
  materializeFacadeWorkPlacement(ctx, repository, workId, args);
5134
4964
  }
@@ -5138,6 +4968,7 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
5138
4968
  continuationSourceRevision = reconciled.sourceRevision ?? continuationSourceRevision;
5139
4969
  continuationWorkspaceFingerprint = reconciled.workspaceFingerprint ?? continuationWorkspaceFingerprint;
5140
4970
  continuationWorkspaceChangedPaths = reconciled.workspaceChangedPaths ?? continuationWorkspaceChangedPaths;
4971
+ continuationWorkBoundProcessEvidenceIds = reconciled.workBoundProcessEvidenceIds;
5141
4972
  }
5142
4973
  } catch (error) {
5143
4974
  const facade = buildFacadeResult({ status: 'blocked', summary: error instanceof Error ? error.message : 'Controller resume failed.', data: { operation, executionStarted: false, ownershipResumed: false } });
@@ -5157,6 +4988,7 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
5157
4988
  sourceRevision: continuationSourceRevision ?? undefined,
5158
4989
  workspaceFingerprint: continuationWorkspaceFingerprint,
5159
4990
  workspaceChangedPaths: continuationWorkspaceChangedPaths,
4991
+ workBoundProcessEvidenceIds: continuationWorkBoundProcessEvidenceIds,
5160
4992
  semanticAdmissionLocked: semanticAdmissionRequired,
5161
4993
  };
5162
4994
  const facade = semanticAdmissionRequired
@@ -6130,8 +5962,7 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
6130
5962
  ? selected(ctx, args)
6131
5963
  : (ctx.explicitRepository ?? (registered.length === 1 ? registered[0] : undefined));
6132
5964
  const readiness = await controllerReadiness(ctx, repository);
6133
- const toolset = await import('../../../cli/mcp/toolset');
6134
- const exposure = toolset.controllerExposureSnapshot(ctx);
5965
+ const exposure = controllerExposureSnapshot(ctx);
6135
5966
  const toolSurfaceReady = exposure.ready && exposure.missingToolNames.length === 0;
6136
5967
  const reasonCodes = new Set(readiness.reasonCodes);
6137
5968
  if (!toolSurfaceReady) reasonCodes.add('MCP_TOOL_SURFACE_INCOMPLETE');
@@ -6511,52 +6342,14 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
6511
6342
  return result(buildWorkflowWatchdogReport(ctx.controllerHome, repository, { staleMinutes: args.stale_minutes, includeProcesses: args.include_processes }) as unknown as Record<string, unknown>);
6512
6343
  }
6513
6344
  case 'ios_xcode_status':
6514
- return legacyIosPluginAction(ctx, name, args, 'xcode_status', {});
6515
6345
  case 'ios_simulators_list':
6516
- return legacyIosPluginAction(ctx, name, args, 'list_simulators', {
6517
- runtime: typeof args.runtime === 'string' ? args.runtime : undefined,
6518
- name: typeof args.name === 'string' ? args.name : undefined,
6519
- });
6520
6346
  case 'ios_project_discover':
6521
- return legacyIosPluginAction(ctx, name, args, 'discover_project', {});
6522
6347
  case 'ios_schemes_list':
6523
- return legacyIosPluginAction(ctx, name, args, 'list_schemes', {
6524
- workspace: typeof args.workspace === 'string' ? args.workspace : undefined,
6525
- project: typeof args.project === 'string' ? args.project : undefined,
6526
- });
6527
6348
  case 'ios_simulator_boot':
6528
- return legacyIosPluginAction(ctx, name, args, 'launch_simulator', {
6529
- udid: String(args.udid ?? '').trim(),
6530
- open_simulator: args.open_simulator !== false,
6531
- timeout_ms: typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined,
6532
- });
6533
6349
  case 'ios_app_build':
6534
- return legacyIosPluginAction(ctx, name, args, 'build', {
6535
- scheme: String(args.scheme ?? '').trim(),
6536
- udid: typeof args.udid === 'string' ? args.udid : undefined,
6537
- simulator_name: typeof args.simulator_name === 'string' ? args.simulator_name : undefined,
6538
- workspace: typeof args.workspace === 'string' ? args.workspace : undefined,
6539
- project: typeof args.project === 'string' ? args.project : undefined,
6540
- configuration: typeof args.configuration === 'string' ? args.configuration : undefined,
6541
- timeout_ms: typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined,
6542
- });
6543
6350
  case 'ios_simulator_screenshot':
6544
- return legacyIosPluginAction(ctx, name, args, 'capture_screenshot', {
6545
- udid: String(args.udid ?? '').trim(),
6546
- label: typeof args.label === 'string' ? args.label : undefined,
6547
- });
6548
6351
  case 'ios_ui_smoke_test':
6549
- return legacyIosPluginAction(ctx, name, args, 'smoke_review', {
6550
- scheme: typeof args.scheme === 'string' ? args.scheme : undefined,
6551
- bundle_id: typeof args.bundle_id === 'string' ? args.bundle_id : undefined,
6552
- udid: typeof args.udid === 'string' ? args.udid : undefined,
6553
- simulator_name: typeof args.simulator_name === 'string' ? args.simulator_name : undefined,
6554
- workspace: typeof args.workspace === 'string' ? args.workspace : undefined,
6555
- project: typeof args.project === 'string' ? args.project : undefined,
6556
- configuration: typeof args.configuration === 'string' ? args.configuration : undefined,
6557
- app_path: typeof args.app_path === 'string' ? args.app_path : undefined,
6558
- screenshot_label: typeof args.screenshot_label === 'string' ? args.screenshot_label : undefined,
6559
- });
6352
+ return legacyIosPluginAction(ctx, name, args);
6560
6353
  case 'ios_app_install':
6561
6354
  case 'ios_app_launch':
6562
6355
  case 'ios_simulator_log_tail':
@@ -6595,7 +6388,9 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
6595
6388
  }
6596
6389
  case 'plugin_action_execute': {
6597
6390
  const pluginId = String(args.plugin_id ?? '').trim();
6391
+ const workId = typeof args.work_id === 'string' && args.work_id.trim() ? args.work_id.trim() : undefined;
6598
6392
  const repository = pluginRepository(ctx, args, pluginId);
6393
+ const workRepository = workId ? selected(ctx, args) : undefined;
6599
6394
  const actionId = String(args.action_id ?? '').trim();
6600
6395
  const requestId = String(args.request_id ?? '').trim();
6601
6396
  const actionArguments = args.arguments && typeof args.arguments === 'object' && !Array.isArray(args.arguments)
@@ -6605,7 +6400,8 @@ export async function callRuntimeTool(ctx: MultiRepositoryMcpToolContext, name:
6605
6400
  pluginId,
6606
6401
  actionId,
6607
6402
  requestId,
6608
- workId: typeof args.work_id === 'string' && args.work_id.trim() ? args.work_id.trim() : undefined,
6403
+ workId,
6404
+ ...(workId && workRepository ? { workRepoId: workRepository.repoId } : {}),
6609
6405
  args: actionArguments,
6610
6406
  timeoutMs: typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined,
6611
6407
  signal: ctx.signal,