@moretea-labs/forge 1.6.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 (469) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +39 -0
  3. package/CLAUDE.md +4 -0
  4. package/README.md +2 -2
  5. package/README.zh-CN.md +1 -1
  6. package/SKILL.md +2 -2
  7. package/THIRD_PARTY_NOTICES.md +1 -1
  8. package/agents/openai.yaml +1 -1
  9. package/assets/hooks/hook-input.sh +11 -8
  10. package/assets/hooks/lib/session-state.sh +10 -7
  11. package/assets/hooks/lib/workflow-state.sh +13 -177
  12. package/assets/hooks/post-tool-observer.sh +8 -2
  13. package/assets/hooks/prompt-guard.sh +5 -81
  14. package/assets/hooks/session-start-context.sh +56 -1
  15. package/assets/partials/05-workflow.partial.md +1 -1
  16. package/assets/partials-agents/02-operating-mode.partial.md +2 -0
  17. package/assets/partials-agents/03-orchestration.partial.md +1 -1
  18. package/assets/plugin-registry.v1.json +3 -3
  19. package/assets/reference-configs/document-generation.md +1 -1
  20. package/assets/reference-configs/harness-overview.md +1 -1
  21. package/assets/reference-configs/sprint-contracts.md +3 -3
  22. package/assets/skill-version.json +2 -2
  23. package/assets/templates/contract.template.md +2 -7
  24. package/assets/templates/helpers/capture-plan.sh +2 -2
  25. package/assets/templates/helpers/check-architecture-sync.sh +17 -0
  26. package/assets/templates/helpers/check-task-workflow.sh +4 -4
  27. package/assets/templates/helpers/contract-worktree.sh +1 -21
  28. package/assets/templates/helpers/ensure-task-workflow.sh +11 -36
  29. package/assets/templates/helpers/inspect-project-state.ts +8 -6
  30. package/assets/templates/helpers/migrate-project-template.sh +22 -45
  31. package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
  32. package/assets/templates/helpers/new-plan.sh +1 -1
  33. package/assets/templates/helpers/plan-to-todo.sh +12 -37
  34. package/assets/templates/helpers/ship-worktrees.sh +2 -29
  35. package/assets/templates/helpers/verify-contract.sh +1 -111
  36. package/assets/templates/helpers/verify-sprint.sh +3 -180
  37. package/assets/templates/helpers/workflow-contract.ts +5 -1
  38. package/assets/templates/plan.template.md +1 -1
  39. package/assets/templates/review.template.md +9 -34
  40. package/assets/workflow-contract.v1.json +10 -19
  41. package/docs/README.md +10 -10
  42. package/docs/architecture/CURRENT.md +178 -0
  43. package/docs/architecture/EVOLUTION.md +42 -0
  44. package/docs/architecture/current/README.md +4 -156
  45. package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +2 -2
  46. package/docs/architecture/decisions/20260823-immediate-controller-relay.md +53 -0
  47. package/docs/architecture/decisions/20260826-browser-runtime-v2.md +73 -0
  48. package/docs/architecture/history/README.md +7 -0
  49. package/docs/architecture/{chatgpt-handoff-facade.md → history/chatgpt-handoff-facade.md} +4 -0
  50. package/docs/architecture/{global-hook-runtime.md → history/global-hook-runtime.md} +7 -3
  51. package/docs/architecture/{ios-semantic-automation-provider-v2.md → history/ios-semantic-automation-provider-v2.md} +6 -2
  52. package/docs/architecture/history.md +3 -34
  53. package/docs/architecture/index.md +15 -98
  54. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +1 -1
  55. package/docs/architecture/reviews/2026-08-22-semantic-harness-convergence-acceptance.md +39 -0
  56. package/docs/architecture/versions/1.6.md +38 -0
  57. package/docs/architecture/versions/1.7.md +41 -0
  58. package/docs/forge-chatgpt-browser-engine.md +2 -2
  59. package/docs/forge-chatgpt-mcp-setup.md +4 -28
  60. package/docs/forge-chatgpt-supervised-automation.md +3 -3
  61. package/docs/forge-github-issue-launcher.md +1 -1
  62. package/docs/forge-plugin-management.md +1 -1
  63. package/docs/forge-runtime-self-healing-loop.md +1 -1
  64. package/docs/operations/controller-browser-plugin.md +26 -6
  65. package/docs/operations/controller-performance-and-502.md +143 -0
  66. package/docs/operations/controller-reliability-runbook.md +1 -1
  67. package/docs/operations/features.md +2 -2
  68. package/docs/operations/github-repository.md +1 -1
  69. package/docs/operations/homebrew.md +2 -2
  70. package/docs/operations/mcp-tool-exposure.md +5 -3
  71. package/docs/wiki/Architecture.md +6 -6
  72. package/docs/wiki/Implementation.md +2 -2
  73. package/docs/wiki/Integrations.md +2 -2
  74. package/docs/wiki/Work-Lifecycle.md +1 -1
  75. package/package.json +33 -11
  76. package/scripts/activate-source-baseline.ts +17 -13
  77. package/scripts/architecture-event.ts +2 -820
  78. package/scripts/architecture-queue.sh +2 -645
  79. package/scripts/archive-architecture-request.sh +2 -242
  80. package/scripts/archive-workflow.sh +1 -291
  81. package/scripts/benchmark-background-check-overlap.ts +228 -0
  82. package/scripts/benchmark-mcp-host.sh +117 -0
  83. package/scripts/benchmark-mcp-transport.ts +170 -0
  84. package/scripts/benchmark-quality-harness.ts +146 -0
  85. package/scripts/benchmark-route-session-concurrency.ts +207 -6
  86. package/scripts/benchmark-thin-harness-mcp-e2e.ts +7 -1
  87. package/scripts/benchmark-typescript-navigation.ts +186 -0
  88. package/scripts/benchmark-work-efficiency.ts +334 -0
  89. package/scripts/capability-config.ts +2 -392
  90. package/scripts/capability-resolver.ts +2 -503
  91. package/scripts/capture-plan.sh +1 -332
  92. package/scripts/check-agent-tooling.sh +2 -1558
  93. package/scripts/check-architecture-sync.sh +2 -371
  94. package/scripts/check-brain-manifest.sh +2 -253
  95. package/scripts/check-context-files.sh +2 -47
  96. package/scripts/check-deploy-sql-order.sh +2 -115
  97. package/scripts/check-mcp-compatibility.ts +14 -0
  98. package/scripts/check-runtime-architecture.mjs +349 -79
  99. package/scripts/check-skill-version.ts +3 -191
  100. package/scripts/check-source-duplication.mjs +61 -0
  101. package/scripts/check-stable-baseline.ts +92 -0
  102. package/scripts/check-task-sync.sh +2 -63
  103. package/scripts/check-task-workflow.sh +2 -1000
  104. package/scripts/codex-handoff-resume.sh +2 -267
  105. package/scripts/context-contract-sync.sh +2 -514
  106. package/scripts/contract-run.ts +2 -463
  107. package/scripts/contract-worktree.sh +1 -722
  108. package/scripts/ensure-task-workflow.sh +1 -1225
  109. package/scripts/host-temporary-cloud-mcp-e2e.sh +265 -0
  110. package/scripts/inspect-project-state.ts +4 -279
  111. package/scripts/install-standalone-recovery.ts +24 -0
  112. package/scripts/lib/project-init-lib.sh +22 -46
  113. package/scripts/lib/run-forge-helper.sh +19 -0
  114. package/scripts/lib/run-forge-helper.ts +26 -0
  115. package/scripts/migrate-project-template.sh +1 -1189
  116. package/scripts/migrate-workflow-docs.ts +4 -412
  117. package/scripts/new-plan.sh +1 -180
  118. package/scripts/new-spec.sh +1 -47
  119. package/scripts/new-sprint.sh +1 -46
  120. package/scripts/plan-to-todo.sh +1 -796
  121. package/scripts/prepare-codex-handoff.sh +2 -201
  122. package/scripts/prepare-handoff.sh +1 -78
  123. package/scripts/refresh-current-status.sh +2 -467
  124. package/scripts/run-governed-gate.ts +5 -1
  125. package/scripts/select-agent-context-blocks.sh +2 -64
  126. package/scripts/ship-worktrees.sh +1 -558
  127. package/scripts/smoke-ios-physical-device.ts +4 -2
  128. package/scripts/smoke-runtime-recovery.ts +1 -35
  129. package/scripts/smoke-schedule-engine.ts +1 -20
  130. package/scripts/sprint-backlog.sh +1 -834
  131. package/scripts/stage-runtime-release.ts +2 -1
  132. package/scripts/summarize-failures.sh +2 -123
  133. package/scripts/switch-plan.sh +1 -150
  134. package/scripts/sync-brain-docs.sh +2 -379
  135. package/scripts/sync-generated-authority.mjs +175 -0
  136. package/scripts/verify-contract.sh +2 -703
  137. package/scripts/verify-forge-runtime.sh +0 -1
  138. package/scripts/verify-sprint.sh +1 -677
  139. package/scripts/workflow-contract.ts +4 -205
  140. package/scripts/workstream-sync.sh +2 -261
  141. package/src/cli/agent-jobs/executor-health.ts +1 -1
  142. package/src/cli/commands/init-hook.ts +3 -3
  143. package/src/cli/commands/mcp.ts +9 -5
  144. package/src/cli/commands/plugin.ts +12 -3
  145. package/src/cli/commands/recovery.ts +25 -3
  146. package/src/cli/commands/runtime.ts +55 -3
  147. package/src/cli/commands/security.ts +12 -1
  148. package/src/cli/commands/setup-profile.ts +31 -21
  149. package/src/cli/controller/check-runner.ts +113 -5
  150. package/src/cli/controller/composite-result.ts +1 -1
  151. package/src/cli/controller/context/focus.ts +51 -0
  152. package/src/cli/controller/context/instruction-context.ts +114 -0
  153. package/src/cli/controller/context/known-paths.ts +76 -0
  154. package/src/cli/controller/context/query-planning.ts +110 -0
  155. package/src/cli/controller/context/rule-resolution.ts +532 -0
  156. package/src/cli/controller/context/source-materializer.ts +278 -0
  157. package/src/cli/controller/context/structural.ts +16 -0
  158. package/src/cli/controller/context/types.ts +286 -0
  159. package/src/cli/controller/context-pack.ts +828 -487
  160. package/src/cli/controller/launch-agents.ts +77 -2
  161. package/src/cli/controller/operational-plan.ts +2 -2
  162. package/src/cli/controller/work-mode.ts +6 -4
  163. package/src/cli/editing/edit-session.ts +60 -7
  164. package/src/cli/github/contracts.ts +16 -0
  165. package/src/cli/github/github.ts +135 -16
  166. package/src/cli/github/session.ts +2 -1
  167. package/src/cli/installer/managed-entries.ts +15 -1
  168. package/src/cli/local-bridge/console-automations.ts +54 -78
  169. package/src/cli/local-bridge/console-view-models.ts +1 -35
  170. package/src/cli/local-bridge/facade-api.ts +37 -264
  171. package/src/cli/local-bridge/frontend/src/App.tsx +3 -3
  172. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +23 -13
  173. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +37 -4
  174. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +6 -4
  175. package/src/cli/local-bridge/frontend/src/lib/api.ts +1 -6
  176. package/src/cli/local-bridge/frontend/src/lib/routes.ts +1 -1
  177. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -2
  178. package/src/cli/local-bridge/frontend/src/styles.css +1 -1
  179. package/src/cli/local-bridge/frontend/src/types.ts +5 -8
  180. package/src/cli/local-bridge/job-store.ts +1 -490
  181. package/src/cli/local-bridge/server.ts +2 -515
  182. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  183. package/src/cli/local-bridge/ui-dist/app.js +3703 -3757
  184. package/src/cli/mcp/auth.ts +5 -5
  185. package/src/cli/mcp/instructions.ts +3 -2
  186. package/src/cli/mcp/legacy-tool-service.ts +10 -222
  187. package/src/cli/mcp/multi-repository.ts +16 -5
  188. package/src/cli/mcp/oauth.ts +175 -26
  189. package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
  190. package/src/cli/mcp/repository-tools.ts +282 -146
  191. package/src/cli/mcp/server.ts +586 -116
  192. package/src/cli/mcp/tool-capability-cache.ts +1 -1
  193. package/src/cli/mcp/tool-contract.ts +14 -0
  194. package/src/cli/mcp/toolset-selection.ts +1 -1
  195. package/src/cli/mcp/toolset.ts +44 -7
  196. package/src/cli/mcp/transports/http.ts +124 -14
  197. package/src/cli/mcp/transports/session-registry.ts +43 -5
  198. package/src/cli/mcp/types.ts +3 -3
  199. package/src/cli/repositories/command-classifier.ts +103 -21
  200. package/src/cli/repositories/command-executor.ts +78 -526
  201. package/src/cli/repositories/command-process.ts +108 -0
  202. package/src/cli/repositories/command-scope.ts +99 -41
  203. package/src/cli/repositories/controller-home.ts +7 -8
  204. package/src/cli/repositories/git-command-executor.ts +2 -0
  205. package/src/cli/repositories/locks.ts +53 -16
  206. package/src/cli/repositories/repository-snapshot.ts +124 -0
  207. package/src/cli/repositories/selected-path-actions.ts +17 -9
  208. package/src/cli/repositories/structured-git.ts +118 -6
  209. package/src/cli/repository/inspector.ts +563 -65
  210. package/src/cli/repository/session-cache.ts +58 -17
  211. package/src/cli/v81-entry.ts +1 -4
  212. package/src/effects/fs-transaction.ts +85 -17
  213. package/src/runtime/context/codegraph-read-provider.ts +233 -2
  214. package/src/runtime/context/codegraph-sidecar.cjs +74 -40
  215. package/src/runtime/context/generic-lsp-provider.ts +355 -0
  216. package/src/runtime/context/lsp-client.ts +283 -0
  217. package/src/runtime/context/semantic-navigation-contract.ts +61 -0
  218. package/src/runtime/context/semantic-navigation.ts +223 -0
  219. package/src/runtime/context/swift-navigation.ts +565 -0
  220. package/src/runtime/context/typescript-navigation.ts +226 -0
  221. package/src/runtime/control-plane/dispatch-priority.ts +28 -0
  222. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +48 -16
  223. package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +86 -24
  224. package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
  225. package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
  226. package/src/runtime/control-plane/execution/session-store.ts +18 -0
  227. package/src/runtime/control-plane/execution/verification-evidence.ts +58 -3
  228. package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
  229. package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
  230. package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
  231. package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
  232. package/src/runtime/control-plane/execution/work-handle-authority.ts +96 -0
  233. package/src/runtime/control-plane/execution/work-handle-store.ts +27 -2
  234. package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +258 -0
  235. package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
  236. package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
  237. package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
  238. package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
  239. package/src/runtime/control-plane/execution/work-remote-delivery.ts +215 -0
  240. package/src/runtime/control-plane/execution/work-task-receipt.ts +22 -18
  241. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +156 -11
  242. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +22 -14
  243. package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
  244. package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
  245. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +90 -9
  246. package/src/runtime/control-plane/facade/capability-registry.ts +129 -25
  247. package/src/runtime/control-plane/facade/controller-round-relay.ts +1118 -0
  248. package/src/runtime/control-plane/facade/controller-session-store.ts +302 -12
  249. package/src/runtime/control-plane/facade/goal-workloop-access.ts +17 -1
  250. package/src/runtime/control-plane/facade/goal-workloop.ts +928 -190
  251. package/src/runtime/control-plane/facade/index.ts +3 -0
  252. package/src/runtime/control-plane/facade/plan-contract-store.ts +275 -42
  253. package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
  254. package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
  255. package/src/runtime/control-plane/facade/semantic-admission.ts +205 -0
  256. package/src/runtime/control-plane/facade/suggested-actions.ts +5 -1
  257. package/src/runtime/control-plane/facade/types.ts +90 -10
  258. package/src/runtime/control-plane/facade/work-contract-store.ts +157 -261
  259. package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
  260. package/src/runtime/control-plane/generated-cache-retention.ts +271 -0
  261. package/src/runtime/control-plane/global-scheduler/config.ts +44 -0
  262. package/src/runtime/control-plane/global-scheduler/dispatch-capacity.ts +80 -0
  263. package/src/runtime/control-plane/global-scheduler/durable-admission.ts +46 -0
  264. package/src/runtime/control-plane/global-scheduler/maintenance.ts +152 -0
  265. package/src/runtime/control-plane/global-scheduler/projection-refresh.ts +84 -0
  266. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +3 -3
  267. package/src/runtime/control-plane/global-scheduler/scheduler.ts +278 -543
  268. package/src/runtime/control-plane/global-scheduler/source-scan.ts +59 -0
  269. package/src/runtime/control-plane/global-scheduler/state.ts +88 -0
  270. package/src/runtime/control-plane/global-scheduler/worker-attachment.ts +57 -0
  271. package/src/runtime/control-plane/global-scheduler/worker-exit-decision.ts +38 -0
  272. package/src/runtime/control-plane/global-scheduler/worker-exit-reconciler.ts +175 -0
  273. package/src/runtime/control-plane/global-scheduler/worker-launch.ts +111 -0
  274. package/src/runtime/control-plane/global-scheduler/worker-lifecycle-store.ts +64 -0
  275. package/src/runtime/control-plane/global-scheduler/worker-lifecycle.ts +150 -0
  276. package/src/runtime/control-plane/global-scheduler/worker-process.ts +119 -0
  277. package/src/runtime/control-plane/global-scheduler/worker-stderr.ts +60 -0
  278. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +63 -0
  279. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +365 -117
  280. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +89 -0
  281. package/src/runtime/control-plane/launcher/thin-launcher.ts +244 -11
  282. package/src/runtime/control-plane/persistence/controller-home-migration.ts +487 -0
  283. package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
  284. package/src/runtime/control-plane/persistence/sqlite-store.ts +33 -2
  285. package/src/runtime/control-plane/release-retention.ts +62 -2
  286. package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
  287. package/src/runtime/control-plane/routing/route-policy.ts +24 -21
  288. package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
  289. package/src/runtime/control-plane/runtime-status-client.ts +0 -2
  290. package/src/runtime/diagnostics/mcp-timing.ts +13 -0
  291. package/src/runtime/diagnostics/mcp-transport-benchmark.ts +129 -0
  292. package/src/runtime/diagnostics/performance.ts +24 -1
  293. package/src/runtime/evidence/event-ledger.ts +1 -1
  294. package/src/runtime/evidence/process-check-execution.ts +18 -0
  295. package/src/runtime/execution/jobs/child-reference.ts +6 -2
  296. package/src/runtime/execution/jobs/store.ts +58 -4
  297. package/src/runtime/execution/jobs/types.ts +1 -1
  298. package/src/runtime/execution/managed-workspace.ts +203 -1
  299. package/src/runtime/execution/process-runtime/check-facade.ts +51 -4
  300. package/src/runtime/execution/process-runtime/check-result.ts +88 -0
  301. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
  302. package/src/runtime/execution/process-runtime/check-scheduling.ts +114 -0
  303. package/src/runtime/execution/process-runtime/command-facade.ts +367 -40
  304. package/src/runtime/execution/process-runtime/gc.ts +38 -6
  305. package/src/runtime/execution/process-runtime/lightweight-managed.ts +1201 -0
  306. package/src/runtime/execution/process-runtime/persisted-check.ts +51 -5
  307. package/src/runtime/execution/process-runtime/resource-claims.ts +10 -6
  308. package/src/runtime/execution/process-runtime/runtime.ts +53 -3
  309. package/src/runtime/execution/process-runtime/store.ts +1 -0
  310. package/src/runtime/execution/process-runtime/types.ts +4 -12
  311. package/src/runtime/execution/thin-harness/async-process.ts +12 -2
  312. package/src/runtime/execution/thin-harness/search-worker.ts +54 -5
  313. package/src/runtime/execution/work-activity.ts +21 -0
  314. package/src/runtime/execution/workers/executor.ts +18 -61
  315. package/src/runtime/gateway/mcp/execution-tools.ts +35 -1852
  316. package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
  317. package/src/runtime/gateway/mcp/process-tools.ts +83 -37
  318. package/src/runtime/gateway/mcp/router.ts +186 -84
  319. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +565 -0
  320. package/src/runtime/gateway/mcp/runtime-tools.ts +2378 -1947
  321. package/src/runtime/plugins/app-store-connect-adapter.ts +216 -47
  322. package/src/runtime/plugins/browser-adapter.ts +2662 -829
  323. package/src/runtime/plugins/browser-automation-service.ts +10 -0
  324. package/src/runtime/plugins/browser-handoff-host.ts +2 -25
  325. package/src/runtime/plugins/browser-handoff.ts +110 -5
  326. package/src/runtime/plugins/browser-macos-bridge.ts +701 -63
  327. package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
  328. package/src/runtime/plugins/browser-native-session-service.ts +222 -0
  329. package/src/runtime/plugins/browser-node-bridge.ts +6 -34
  330. package/src/runtime/plugins/browser-provider-registry.ts +109 -0
  331. package/src/runtime/plugins/browser-runtime-contract.ts +154 -0
  332. package/src/runtime/plugins/browser-runtime.ts +193 -0
  333. package/src/runtime/plugins/browser-session-authority.ts +332 -0
  334. package/src/runtime/plugins/browser-session-store.ts +121 -0
  335. package/src/runtime/plugins/browser-session-types.ts +98 -0
  336. package/src/runtime/plugins/capability-authorization-grants.ts +354 -0
  337. package/src/runtime/plugins/desktop-operator-registration.ts +10 -4
  338. package/src/runtime/plugins/external-adapter.ts +358 -3
  339. package/src/runtime/plugins/external-unix-socket.ts +7 -1
  340. package/src/runtime/plugins/first-party-registry.ts +2 -0
  341. package/src/runtime/plugins/github-adapter.ts +125 -0
  342. package/src/runtime/plugins/ios/agent-device-provider.ts +1 -1
  343. package/src/runtime/plugins/ios/agent-device-typed-provider.ts +52 -9
  344. package/src/runtime/plugins/ios/remote-xpc-hid.ts +1171 -0
  345. package/src/runtime/plugins/ios-adapter.ts +106 -16
  346. package/src/runtime/plugins/ios-agent-device.ts +234 -64
  347. package/src/runtime/plugins/ios-physical-device.ts +1143 -392
  348. package/src/runtime/plugins/lightweight-action.ts +153 -0
  349. package/src/runtime/plugins/local-system-adapter.ts +123 -6
  350. package/src/runtime/plugins/macos-capability-broker.ts +47 -9
  351. package/src/runtime/plugins/plugin-action-sidecar.ts +59 -0
  352. package/src/runtime/plugins/plugin-management-adapter.ts +49 -8
  353. package/src/runtime/plugins/resend-adapter.ts +1 -1
  354. package/src/runtime/plugins/store.ts +330 -33
  355. package/src/runtime/plugins/types.ts +38 -3
  356. package/src/runtime/plugins/xiaohongshu-publish.ts +515 -0
  357. package/src/runtime/projections/git-status-sampler.ts +16 -16
  358. package/src/runtime/projections/materialized-view.ts +28 -10
  359. package/src/runtime/recovery/maintenance-executor.ts +426 -25
  360. package/src/runtime/root/mcp-transport.ts +180 -10
  361. package/src/runtime/root/package-connector-service.ts +230 -10
  362. package/src/runtime/root/package-runtime-release.ts +143 -17
  363. package/src/runtime/root/package-runtime-service.ts +461 -22
  364. package/src/runtime/root/release-manifest.ts +144 -18
  365. package/src/runtime/root/release-materialize.ts +125 -32
  366. package/src/runtime/root/release-store.ts +48 -11
  367. package/src/runtime/root/runtime.ts +23 -5
  368. package/src/runtime/root/service.ts +100 -29
  369. package/src/runtime/root/types.ts +12 -0
  370. package/src/runtime/safe-tooling/ios-development.ts +321 -63
  371. package/src/runtime/shared/trusted-executable.ts +62 -0
  372. package/src/runtime/shared/trusted-node-executable.ts +48 -0
  373. package/src/runtime/standalone-recovery/core.ts +1079 -240
  374. package/src/runtime/standalone-recovery/entry.ts +100 -24
  375. package/src/runtime/standalone-recovery/installer.ts +20 -4
  376. package/src/runtime/standalone-recovery/service-labels.ts +2 -0
  377. package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +160 -0
  378. package/src/runtime/workflow/schedules/browser-probe.ts +81 -3
  379. package/src/runtime/workflow/schedules/engine.ts +364 -202
  380. package/src/runtime/workflow/schedules/github-issue-watch.ts +158 -0
  381. package/src/runtime/workflow/schedules/settlement.ts +64 -17
  382. package/src/runtime/workflow/schedules/store.ts +209 -19
  383. package/src/runtime/workflow/schedules/types.ts +2 -4
  384. package/src/runtime/workflow/schedules/work-continuation.ts +26 -13
  385. package/src/testing/test-governance.ts +8 -40
  386. package/assets/reference-configs/heartbeat-triage.md +0 -57
  387. package/assets/templates/helpers/harness-trace-grade.sh +0 -150
  388. package/assets/templates/helpers/heartbeat-triage.sh +0 -361
  389. package/assets/templates/helpers/maintenance-triage.sh +0 -167
  390. package/docs/architecture/current/approved-target-architecture.zh-CN.md +0 -1501
  391. package/docs/architecture/current/architecture-invariants.md +0 -331
  392. package/docs/architecture/current/automation-and-schedule-engine.md +0 -459
  393. package/docs/architecture/current/codegraph-context-provider.md +0 -238
  394. package/docs/architecture/current/control-plane-authority-inventory.md +0 -94
  395. package/docs/architecture/current/controller-repository-decoupling.md +0 -148
  396. package/docs/architecture/current/dispatch-and-agent-strategy.md +0 -373
  397. package/docs/architecture/current/entity-model.md +0 -612
  398. package/docs/architecture/current/external-plugin-governance.md +0 -396
  399. package/docs/architecture/current/failure-recovery.md +0 -447
  400. package/docs/architecture/current/governance.md +0 -213
  401. package/docs/architecture/current/human-interaction-plane.md +0 -87
  402. package/docs/architecture/current/implementation-status.md +0 -128
  403. package/docs/architecture/current/job-and-run-lifecycle.md +0 -413
  404. package/docs/architecture/current/legacy-governance.md +0 -58
  405. package/docs/architecture/current/local-workspace-target-grants.md +0 -70
  406. package/docs/architecture/current/migration-roadmap.md +0 -105
  407. package/docs/architecture/current/multi-repository-execution.md +0 -371
  408. package/docs/architecture/current/operations-runbook.md +0 -45
  409. package/docs/architecture/current/peer-supercontroller-runtime.md +0 -32
  410. package/docs/architecture/current/personal-assistant-plugin-baseline.md +0 -206
  411. package/docs/architecture/current/repository-access-modes.md +0 -102
  412. package/docs/architecture/current/route-policy.md +0 -15
  413. package/docs/architecture/current/route-session-concurrency.md +0 -85
  414. package/docs/architecture/current/runtime-architecture-simplification.md +0 -214
  415. package/docs/architecture/current/runtime-directory-map.md +0 -49
  416. package/docs/architecture/current/runtime-health-and-resource-lifecycle.md +0 -85
  417. package/docs/architecture/current/scheduler-and-resource-claims.md +0 -422
  418. package/docs/architecture/current/session-aware-execution-and-authorization.md +0 -77
  419. package/docs/architecture/current/system-overview.md +0 -169
  420. package/docs/architecture/current/target-requirements-traceability.md +0 -202
  421. package/docs/architecture/current/thin-harness-governance-policy.md +0 -32
  422. package/docs/architecture/current/thin-harness-v1.md +0 -321
  423. package/docs/architecture/current/verification-and-release-gates.md +0 -391
  424. package/docs/forge-autonomous-goal-loop.md +0 -9
  425. package/docs/forge-provider-configuration.md +0 -7
  426. package/docs/operations/chatgpt-personal-assistant-runtime.md +0 -93
  427. package/docs/operations/gmail-assistant-routines.md +0 -186
  428. package/docs/operations/personal-assistant-operator-guide.md +0 -61
  429. package/docs/operations/provider-configuration.md +0 -31
  430. package/scripts/harness-trace-grade.sh +0 -150
  431. package/scripts/heartbeat-triage.sh +0 -361
  432. package/scripts/maintenance-triage.sh +0 -167
  433. package/src/cli/local-bridge/frontend/src/features/settings/SettingsView.tsx +0 -2
  434. package/src/cli/repositories/goal-registry.ts +0 -239
  435. package/src/runtime/assistant/action-proposals.ts +0 -279
  436. package/src/runtime/assistant/intent.ts +0 -395
  437. package/src/runtime/assistant/model-provider.ts +0 -460
  438. package/src/runtime/assistant/openapi.ts +0 -223
  439. package/src/runtime/assistant/policy.ts +0 -89
  440. package/src/runtime/assistant/readiness.ts +0 -258
  441. package/src/runtime/assistant/routine-runtime.ts +0 -477
  442. package/src/runtime/assistant/schedule-binding.ts +0 -171
  443. package/src/runtime/assistant/standing-grants.ts +0 -375
  444. package/src/runtime/assistant/store.ts +0 -243
  445. package/src/runtime/assistant/types.ts +0 -135
  446. package/src/runtime/control-plane/goal-loop/config-facade.ts +0 -700
  447. package/src/runtime/control-plane/goal-loop/config-store.ts +0 -570
  448. package/src/runtime/control-plane/goal-loop/config-types.ts +0 -213
  449. package/src/runtime/control-plane/goal-loop/executor-router.ts +0 -109
  450. package/src/runtime/control-plane/goal-loop/failure-taxonomy.ts +0 -149
  451. package/src/runtime/control-plane/goal-loop/goal-contract-store.ts +0 -304
  452. package/src/runtime/control-plane/goal-loop/goal-loop-engine.ts +0 -1001
  453. package/src/runtime/control-plane/goal-loop/handoff-packet.ts +0 -160
  454. package/src/runtime/control-plane/goal-loop/index.ts +0 -13
  455. package/src/runtime/control-plane/goal-loop/local-tools.ts +0 -290
  456. package/src/runtime/control-plane/goal-loop/policy-gates.ts +0 -203
  457. package/src/runtime/control-plane/goal-loop/provider-dispatch.ts +0 -93
  458. package/src/runtime/control-plane/goal-loop/provider-registry.ts +0 -545
  459. package/src/runtime/control-plane/goal-loop/types.ts +0 -378
  460. package/src/runtime/control-plane/startup-recovery.ts +0 -198
  461. package/src/runtime/gateway/mcp/resource-policy.ts +0 -186
  462. package/src/runtime/personal-assistant/gmail-triage-manager.ts +0 -231
  463. package/src/runtime/personal-assistant/reporting-runtime.ts +0 -344
  464. package/src/runtime/personal-assistant/triage-runtime.ts +0 -748
  465. package/src/runtime/workflow/findings/store.ts +0 -204
  466. package/src/runtime/workflow/findings/types.ts +0 -58
  467. package/src/runtime/workflow/portfolio/engine.ts +0 -20
  468. package/src/runtime/workflow/portfolio/store.ts +0 -185
  469. package/src/runtime/workflow/portfolio/types.ts +0 -29
@@ -0,0 +1,1118 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+ import { withControllerLock } from '../../../cli/repositories/locks';
3
+ import { readRequirement } from '../persistence/requirement-store';
4
+ import {
5
+ listControlPlaneRecords,
6
+ readControlPlaneRecord,
7
+ writeControlPlaneRecord,
8
+ type ControlPlaneRecord,
9
+ } from '../persistence/sqlite-store';
10
+ import { workHasActiveExecution } from '../../execution/work-activity';
11
+ import { controllerSessionBlocksRecovery, getControllerSession } from './controller-session-store';
12
+ import { getHandoffItem, listHandoffItems } from './handoff-inbox-store';
13
+ import { getWorkContract, readWorkContractStore } from './work-contract-store';
14
+ import {
15
+ isTerminalHandoffStatus,
16
+ isTerminalWorkContractStatus,
17
+ type ControllerSession,
18
+ type WorkContract,
19
+ } from './types';
20
+
21
+ export const CONTROLLER_ROUND_DISPOSITIONS = [
22
+ 'continue_immediately',
23
+ 'wait',
24
+ 'wait_for_user',
25
+ 'goal_complete',
26
+ ] as const;
27
+ export type ControllerRoundDisposition = (typeof CONTROLLER_ROUND_DISPOSITIONS)[number];
28
+
29
+ export const CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR = 'CONTROLLER_RELAY_CLAIM_RELEASED_WITHOUT_DISPOSITION';
30
+ const CONTROLLER_DISPOSITION_COMPATIBILITY_PREFIX = 'controller.disposition:';
31
+
32
+ export function parseControllerDispositionCompatibilityCapability(
33
+ operation: string,
34
+ capabilityId: unknown,
35
+ ): { disposition: ControllerRoundDisposition; relayScopeId: string } | undefined {
36
+ if (operation !== 'repair' || typeof capabilityId !== 'string') return undefined;
37
+ const normalized = capabilityId.trim();
38
+ if (!normalized.startsWith(CONTROLLER_DISPOSITION_COMPATIBILITY_PREFIX)) return undefined;
39
+ const payload = normalized.slice(CONTROLLER_DISPOSITION_COMPATIBILITY_PREFIX.length);
40
+ const separator = payload.indexOf(':');
41
+ if (separator <= 0) throw new Error('CONTROLLER_RELAY_DISPOSITION_COMPATIBILITY_INVALID');
42
+ const disposition = payload.slice(0, separator) as ControllerRoundDisposition;
43
+ const relayScopeId = payload.slice(separator + 1).trim();
44
+ if (!CONTROLLER_ROUND_DISPOSITIONS.includes(disposition) || !relayScopeId) {
45
+ throw new Error('CONTROLLER_RELAY_DISPOSITION_COMPATIBILITY_INVALID');
46
+ }
47
+ return { disposition, relayScopeId };
48
+ }
49
+
50
+ export type ControllerRoundRelayStatus =
51
+ | 'pending_release'
52
+ | 'dispatching'
53
+ | 'dispatched'
54
+ | 'claimed'
55
+ | 'waiting'
56
+ | 'waiting_for_user'
57
+ | 'goal_complete'
58
+ | 'blocked'
59
+ | 'failed';
60
+
61
+ export type ControllerRoundTabSettlementStatus =
62
+ | 'round_open'
63
+ | 'retained_for_immediate_continuation'
64
+ | 'closed'
65
+ | 'preserved_user_owned'
66
+ | 'session_closed'
67
+ | 'failed';
68
+
69
+ export interface ControllerRoundRelayRecord {
70
+ schemaVersion: 1;
71
+ repoId: string;
72
+ relayScopeId: string;
73
+ originWorkId: string;
74
+ requirementId?: string;
75
+ disposition: ControllerRoundDisposition;
76
+ status: ControllerRoundRelayStatus;
77
+ controllerId: string;
78
+ principalId: string;
79
+ controllerInstanceId: string;
80
+ sessionId: string;
81
+ claimGeneration: number;
82
+ /** Opaque per-round capability. Rotates when Forge dispatches a new ChatGPT round. */
83
+ authorityId?: string;
84
+ stateFingerprint: string;
85
+ roundCount: number;
86
+ repeatedStateCount: number;
87
+ consecutiveFailures: number;
88
+ maxRounds: number;
89
+ maxRepeatedState: number;
90
+ maxFailures: number;
91
+ handoffId?: string;
92
+ reason?: string;
93
+ browserSessionId?: string;
94
+ conversationUrl?: string;
95
+ blockedReason?: string;
96
+ lastError?: string;
97
+ nextRecoveryAt?: string;
98
+ submittedAt: string;
99
+ updatedAt: string;
100
+ dispatchedAt?: string;
101
+ claimedAt?: string;
102
+ tabSettlementStatus?: ControllerRoundTabSettlementStatus;
103
+ tabSettlementAt?: string;
104
+ tabSettlementError?: string;
105
+ }
106
+
107
+ export interface ControllerRoundRelayStoreOptions {
108
+ controllerHome: string;
109
+ repoId: string;
110
+ now?: () => string;
111
+ }
112
+
113
+ export interface ControllerRoundRelayIdentity {
114
+ controllerId: string;
115
+ principalId: string;
116
+ controllerInstanceId: string;
117
+ sessionId: string;
118
+ }
119
+
120
+ export interface SubmitControllerRoundDispositionInput {
121
+ workId: string;
122
+ identity: ControllerRoundRelayIdentity;
123
+ disposition: ControllerRoundDisposition;
124
+ relayScopeId?: string;
125
+ requirementId?: string;
126
+ handoffId?: string;
127
+ stateFingerprint?: string;
128
+ reason?: string;
129
+ browserSessionId?: string;
130
+ conversationUrl?: string;
131
+ maxRounds?: number;
132
+ maxRepeatedState?: number;
133
+ maxFailures?: number;
134
+ }
135
+
136
+ export interface BeginInitialControllerRoundDispatchInput {
137
+ workId: string;
138
+ identity: ControllerRoundRelayIdentity;
139
+ relayScopeId?: string;
140
+ requirementId?: string;
141
+ browserSessionId?: string;
142
+ conversationUrl?: string;
143
+ maxRounds?: number;
144
+ maxRepeatedState?: number;
145
+ maxFailures?: number;
146
+ }
147
+
148
+ const NAMESPACE = 'controller_round_relay';
149
+ const SCHEMA_VERSION = 1;
150
+ const DEFAULT_MAX_ROUNDS = 8;
151
+ const DEFAULT_MAX_REPEATED_STATE = 2;
152
+ const DEFAULT_MAX_FAILURES = 3;
153
+ const DEFAULT_UNCLOSED_ROUND_GRACE_MS = 10 * 60_000;
154
+ const MAX_UNCLOSED_ROUND_GRACE_MS = 60 * 60_000;
155
+ const DEFAULT_STALLED_RECOVERY_BACKOFF_MS = 60_000;
156
+ const MAX_STALLED_RECOVERY_BACKOFF_MS = 15 * 60_000;
157
+
158
+ function nowIso(options: ControllerRoundRelayStoreOptions): string {
159
+ return options.now?.() ?? new Date().toISOString();
160
+ }
161
+
162
+ function newControllerRoundAuthorityId(): string {
163
+ return `cra_${randomUUID().replace(/-/g, '')}`;
164
+ }
165
+
166
+ function bounded(value: string | undefined, max: number): string | undefined {
167
+ const normalized = value?.trim();
168
+ return normalized ? normalized.slice(0, max) : undefined;
169
+ }
170
+
171
+ function normalizeScopeId(value: string): string {
172
+ const normalized = value.trim();
173
+ if (!normalized || /[\r\n]/.test(normalized)) throw new Error('CONTROLLER_RELAY_SCOPE_INVALID');
174
+ return normalized.slice(0, 240);
175
+ }
176
+
177
+ function boundedInteger(value: number | undefined, fallback: number, min: number, max: number): number {
178
+ if (value === undefined || !Number.isFinite(value)) return fallback;
179
+ return Math.max(min, Math.min(Math.trunc(value), max));
180
+ }
181
+
182
+ function relayLock<T>(
183
+ options: ControllerRoundRelayStoreOptions,
184
+ relayScopeId: string,
185
+ actor: string,
186
+ operation: () => T,
187
+ ): T {
188
+ const lockId = createHash('sha256').update(relayScopeId).digest('hex').slice(0, 20);
189
+ return withControllerLock(
190
+ options.controllerHome,
191
+ { scope: 'task', repoId: options.repoId, taskId: `controller-round-relay-${lockId}` },
192
+ actor,
193
+ operation,
194
+ );
195
+ }
196
+
197
+ function readRelayRecord(
198
+ options: ControllerRoundRelayStoreOptions,
199
+ workId: string,
200
+ ): ControlPlaneRecord<ControllerRoundRelayRecord> | undefined {
201
+ return readControlPlaneRecord<ControllerRoundRelayRecord>(options.controllerHome, NAMESPACE, options.repoId, workId);
202
+ }
203
+
204
+ export function getControllerRoundRelay(
205
+ options: ControllerRoundRelayStoreOptions,
206
+ workId: string,
207
+ ): ControllerRoundRelayRecord | undefined {
208
+ return readRelayRecord(options, workId)?.value;
209
+ }
210
+
211
+ export function recordControllerRoundTabSettlement(
212
+ options: ControllerRoundRelayStoreOptions,
213
+ input: {
214
+ workId: string;
215
+ status: ControllerRoundTabSettlementStatus;
216
+ error?: string;
217
+ },
218
+ ): ControllerRoundRelayRecord | undefined {
219
+ const initial = readRelayRecord(options, input.workId);
220
+ if (!initial) return undefined;
221
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-tab-settlement:${input.workId}`, () => {
222
+ const current = readRelayRecord(options, input.workId);
223
+ if (!current) return undefined;
224
+ const at = nowIso(options);
225
+ const next: ControllerRoundRelayRecord = {
226
+ ...current.value,
227
+ tabSettlementStatus: input.status,
228
+ tabSettlementAt: at,
229
+ tabSettlementError: bounded(input.error, 2_000),
230
+ updatedAt: at,
231
+ };
232
+ writeControlPlaneRecord(options.controllerHome, {
233
+ namespace: NAMESPACE,
234
+ scope: options.repoId,
235
+ key: input.workId,
236
+ schemaVersion: SCHEMA_VERSION,
237
+ value: next,
238
+ action: 'controller_round_tab_settled',
239
+ expectedRevision: current.revision,
240
+ });
241
+ return next;
242
+ });
243
+ }
244
+
245
+ function relayHistory(options: ControllerRoundRelayStoreOptions, relayScopeId: string): ControllerRoundRelayRecord[] {
246
+ return listControlPlaneRecords<ControllerRoundRelayRecord>(options.controllerHome, {
247
+ namespace: NAMESPACE,
248
+ scope: options.repoId,
249
+ limit: 5_000,
250
+ })
251
+ .map((entry) => entry.value)
252
+ .filter((entry) => entry.relayScopeId === relayScopeId)
253
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
254
+ }
255
+
256
+ function latestRelayRecordsByScope(options: ControllerRoundRelayStoreOptions): ControllerRoundRelayRecord[] {
257
+ const latest = new Map<string, ControllerRoundRelayRecord>();
258
+ for (const entry of listControlPlaneRecords<ControllerRoundRelayRecord>(options.controllerHome, {
259
+ namespace: NAMESPACE,
260
+ scope: options.repoId,
261
+ limit: 5_000,
262
+ }).map((record) => record.value)) {
263
+ const current = latest.get(entry.relayScopeId);
264
+ if (!current || entry.updatedAt > current.updatedAt) latest.set(entry.relayScopeId, entry);
265
+ }
266
+ return [...latest.values()].sort((left, right) => left.updatedAt.localeCompare(right.updatedAt));
267
+ }
268
+
269
+ function requirementForRelay(options: ControllerRoundRelayStoreOptions, requirementId: string | undefined) {
270
+ return requirementId ? readRequirement({ controllerHome: options.controllerHome }, requirementId)?.value : undefined;
271
+ }
272
+
273
+ function relevantWork(options: ControllerRoundRelayStoreOptions, record: Pick<ControllerRoundRelayRecord, 'relayScopeId' | 'originWorkId' | 'requirementId'>): WorkContract[] {
274
+ const all = readWorkContractStore({ controllerHome: options.controllerHome, repoId: options.repoId }).contracts;
275
+ const linkedWorkIds = new Set([
276
+ record.originWorkId,
277
+ ...relayHistory(options, record.relayScopeId).map((entry) => entry.originWorkId),
278
+ ]);
279
+ if (record.requirementId) {
280
+ for (const work of all) {
281
+ if (work.requirementId === record.requirementId) linkedWorkIds.add(work.workId);
282
+ }
283
+ }
284
+ let expanded = true;
285
+ while (expanded) {
286
+ expanded = false;
287
+ for (const work of all) {
288
+ if (linkedWorkIds.has(work.workId)) {
289
+ if (work.parentWorkId && !linkedWorkIds.has(work.parentWorkId)) {
290
+ linkedWorkIds.add(work.parentWorkId);
291
+ expanded = true;
292
+ }
293
+ continue;
294
+ }
295
+ if (work.parentWorkId && linkedWorkIds.has(work.parentWorkId)) {
296
+ linkedWorkIds.add(work.workId);
297
+ expanded = true;
298
+ }
299
+ }
300
+ }
301
+ return all
302
+ .filter((work) => linkedWorkIds.has(work.workId))
303
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
304
+ }
305
+
306
+ function mechanicalStateFingerprint(
307
+ options: ControllerRoundRelayStoreOptions,
308
+ work: WorkContract,
309
+ requirementId: string | undefined,
310
+ relayScopeId: string,
311
+ ): string {
312
+ const requirement = requirementForRelay(options, requirementId);
313
+ const works = relevantWork(options, { relayScopeId, originWorkId: work.workId, requirementId })
314
+ .map((entry) => ({
315
+ workId: entry.workId,
316
+ parentWorkId: entry.parentWorkId,
317
+ status: entry.status,
318
+ phase: entry.phase,
319
+ dispatchState: entry.dispatchState,
320
+ evidenceState: entry.evidenceState,
321
+ completionOutcome: entry.completionOutcome,
322
+ updatedAt: entry.updatedAt,
323
+ }))
324
+ .sort((left, right) => left.workId.localeCompare(right.workId));
325
+ const linkedWorkIds = new Set(works.map((entry) => entry.workId));
326
+ const handoffs = listHandoffItems({ controllerHome: options.controllerHome, repoId: options.repoId, status: 'active', limit: 100 })
327
+ .filter((handoff) => !handoff.workId || linkedWorkIds.has(handoff.workId))
328
+ .map((handoff) => ({ id: handoff.id, status: handoff.status, updatedAt: handoff.updatedAt }));
329
+ return createHash('sha256').update(JSON.stringify({
330
+ requirement: requirement ? {
331
+ requirementId: requirement.requirementId,
332
+ state: requirement.state,
333
+ revision: requirement.revision,
334
+ updatedAt: requirement.updatedAt,
335
+ } : undefined,
336
+ works,
337
+ handoffs,
338
+ })).digest('hex');
339
+ }
340
+
341
+ function resolveRequirementId(
342
+ options: ControllerRoundRelayStoreOptions,
343
+ work: WorkContract,
344
+ requestedRequirementId: string | undefined,
345
+ ): string | undefined {
346
+ const requested = bounded(requestedRequirementId, 160);
347
+ if (work.requirementId && requested && work.requirementId !== requested) {
348
+ throw new Error(`CONTROLLER_RELAY_REQUIREMENT_MISMATCH: Work ${work.workId} belongs to ${work.requirementId}`);
349
+ }
350
+ const requirementId = work.requirementId ?? requested;
351
+ if (requirementId && !requirementForRelay(options, requirementId)) {
352
+ throw new Error(`REQUIREMENT_NOT_FOUND: ${requirementId}`);
353
+ }
354
+ return requirementId;
355
+ }
356
+
357
+ function resolveRelayScope(
358
+ work: WorkContract,
359
+ requirementId: string | undefined,
360
+ requestedScopeId: string | undefined,
361
+ ): string {
362
+ const canonicalRequirementScope = requirementId ? `requirement:${requirementId}` : undefined;
363
+ if (canonicalRequirementScope) {
364
+ if (requestedScopeId && normalizeScopeId(requestedScopeId) !== canonicalRequirementScope) {
365
+ throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: expected ${canonicalRequirementScope}`);
366
+ }
367
+ return canonicalRequirementScope;
368
+ }
369
+ return requestedScopeId ? normalizeScopeId(requestedScopeId) : `goal:${work.workId}`;
370
+ }
371
+
372
+ function assertChatgptOwner(
373
+ options: ControllerRoundRelayStoreOptions,
374
+ workId: string,
375
+ identity: ControllerRoundRelayIdentity,
376
+ ): ControllerSession & { claimGeneration: number } {
377
+ const owner = getControllerSession(options, workId);
378
+ if (!owner) throw new Error(`WORK_CONTROLLER_OWNER_REQUIRED: ${workId}`);
379
+ if (owner.controllerType !== 'chatgpt') throw new Error(`CONTROLLER_RELAY_CHATGPT_ONLY: ${workId}`);
380
+ if (owner.controllerId !== identity.controllerId) throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${workId}`);
381
+ const ownerPrincipal = owner.principalId?.trim() || owner.controllerId;
382
+ if (ownerPrincipal !== identity.principalId.trim()) throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${workId}`);
383
+ if (owner.sessionId !== identity.sessionId.trim()) throw new Error(`WORK_CONTROLLER_SESSION_MISMATCH: ${workId}`);
384
+ const ownerInstanceId = owner.controllerInstanceId?.trim();
385
+ if (ownerInstanceId && ownerInstanceId !== identity.controllerInstanceId.trim()) {
386
+ throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${workId}`);
387
+ }
388
+ if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${workId}`);
389
+ return owner as ControllerSession & { claimGeneration: number };
390
+ }
391
+
392
+ export function submitControllerRoundDisposition(
393
+ options: ControllerRoundRelayStoreOptions,
394
+ input: SubmitControllerRoundDispositionInput,
395
+ ): ControllerRoundRelayRecord {
396
+ if (!CONTROLLER_ROUND_DISPOSITIONS.includes(input.disposition)) throw new Error('CONTROLLER_RELAY_DISPOSITION_INVALID');
397
+ const work = getWorkContract(options, input.workId);
398
+ if (!work) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
399
+ const requirementId = resolveRequirementId(options, work, input.requirementId);
400
+ const relayScopeId = resolveRelayScope(work, requirementId, input.relayScopeId);
401
+ const terminal = isTerminalWorkContractStatus(work.status);
402
+
403
+ return relayLock(options, relayScopeId, `controller-relay-submit:${input.identity.controllerId}`, () => {
404
+ const existing = readRelayRecord(options, work.workId);
405
+ if (!existing) throw new Error(`CONTROLLER_RELAY_ROUND_NOT_OPEN: ${work.workId}`);
406
+ if (existing.value.relayScopeId !== relayScopeId) {
407
+ throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: Work ${work.workId} is already bound to ${existing.value.relayScopeId}`);
408
+ }
409
+ if (existing.value.status !== 'claimed') {
410
+ throw new Error(`CONTROLLER_RELAY_ROUND_NOT_CLAIMED: ${existing.value.status}`);
411
+ }
412
+ if (terminal && (work.status !== 'completed' || input.disposition !== 'goal_complete')) {
413
+ throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${work.status}`);
414
+ }
415
+
416
+ const liveOwner = getControllerSession(options, work.workId);
417
+ const authority = terminal
418
+ ? (() => {
419
+ // The physical Work may be completed while its old live lease still exists.
420
+ // goal_complete is semantic round closure, not a terminal Work reclaim: use
421
+ // the authenticated controller/principal plus the already-claimed relay as
422
+ // authority and allow MCP session / Runtime instance rotation. If a live
423
+ // owner remains, it must still agree on controller type and principal.
424
+ const principalId = input.identity.principalId.trim();
425
+ const controllerInstanceId = input.identity.controllerInstanceId.trim();
426
+ const sessionId = input.identity.sessionId.trim();
427
+ if (!principalId || !controllerInstanceId || !sessionId) throw new Error(`CONTROLLER_RELAY_TERMINAL_AUTHORITY_REQUIRED: ${work.workId}`);
428
+ if (liveOwner) {
429
+ if (liveOwner.controllerType !== 'chatgpt') throw new Error(`CONTROLLER_RELAY_CHATGPT_ONLY: ${work.workId}`);
430
+ if (liveOwner.controllerId !== input.identity.controllerId) throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${work.workId}`);
431
+ if ((liveOwner.principalId?.trim() || liveOwner.controllerId) !== principalId) {
432
+ throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${work.workId}`);
433
+ }
434
+ }
435
+ if (existing.value.controllerId !== input.identity.controllerId) throw new Error(`CONTROLLER_RELAY_CLAIM_CONTROLLER_MISMATCH: ${work.workId}`);
436
+ if (existing.value.principalId !== principalId) throw new Error(`CONTROLLER_RELAY_CLAIM_PRINCIPAL_MISMATCH: ${work.workId}`);
437
+ if (existing.value.claimGeneration < 1) throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${work.workId}`);
438
+ return {
439
+ controllerId: existing.value.controllerId,
440
+ principalId: existing.value.principalId,
441
+ controllerInstanceId,
442
+ sessionId,
443
+ claimGeneration: existing.value.claimGeneration,
444
+ };
445
+ })()
446
+ : (() => {
447
+ const owner = assertChatgptOwner(options, work.workId, input.identity);
448
+ return {
449
+ controllerId: owner.controllerId,
450
+ principalId: owner.principalId?.trim() || owner.controllerId,
451
+ controllerInstanceId: owner.controllerInstanceId?.trim() || '',
452
+ sessionId: owner.sessionId,
453
+ claimGeneration: owner.claimGeneration,
454
+ };
455
+ })();
456
+ if (existing.value.controllerId !== authority.controllerId) {
457
+ throw new Error(`CONTROLLER_RELAY_CLAIM_CONTROLLER_MISMATCH: ${work.workId}`);
458
+ }
459
+ if (existing.value.principalId !== authority.principalId) {
460
+ throw new Error(`CONTROLLER_RELAY_CLAIM_PRINCIPAL_MISMATCH: ${work.workId}`);
461
+ }
462
+ // A completed Work may have already released its live controller session before
463
+ // the prompt-required terminal disposition arrives. At that point the exact
464
+ // authenticated controller/principal + Work + relay scope remain the lineage
465
+ // fence; MCP session and canonical Runtime instance are allowed to rotate.
466
+ const terminalLineageMigration = terminal;
467
+ if (!terminalLineageMigration && existing.value.controllerInstanceId !== authority.controllerInstanceId) {
468
+ throw new Error(`CONTROLLER_RELAY_CLAIM_INSTANCE_MISMATCH: ${work.workId}`);
469
+ }
470
+ if (!terminalLineageMigration && existing.value.claimGeneration !== authority.claimGeneration) {
471
+ throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_MISMATCH: ${work.workId}`);
472
+ }
473
+ const previous = relayHistory(options, relayScopeId)[0];
474
+ const requestedMaxRounds = boundedInteger(input.maxRounds, DEFAULT_MAX_ROUNDS, 1, 32);
475
+ const requestedMaxRepeatedState = boundedInteger(input.maxRepeatedState, DEFAULT_MAX_REPEATED_STATE, 1, 8);
476
+ const requestedMaxFailures = boundedInteger(input.maxFailures, DEFAULT_MAX_FAILURES, 1, 8);
477
+ const maxRounds = previous ? Math.min(previous.maxRounds, requestedMaxRounds) : requestedMaxRounds;
478
+ const maxRepeatedState = previous ? Math.min(previous.maxRepeatedState, requestedMaxRepeatedState) : requestedMaxRepeatedState;
479
+ const maxFailures = previous ? Math.min(previous.maxFailures, requestedMaxFailures) : requestedMaxFailures;
480
+ const stateFingerprint = bounded(input.stateFingerprint, 256) ?? mechanicalStateFingerprint(options, work, requirementId, relayScopeId);
481
+ const continuing = input.disposition === 'continue_immediately';
482
+ const roundCount = (previous?.roundCount ?? 0) + (continuing ? 1 : 0);
483
+ const repeatedStateCount = continuing
484
+ ? (previous?.stateFingerprint === stateFingerprint ? (previous.repeatedStateCount + 1) : 0)
485
+ : (previous?.repeatedStateCount ?? 0);
486
+ const consecutiveFailures = previous?.consecutiveFailures ?? 0;
487
+ const browserSessionId = bounded(input.browserSessionId, 500) ?? previous?.browserSessionId;
488
+ const conversationUrl = bounded(input.conversationUrl, 2_000) ?? previous?.conversationUrl;
489
+
490
+ let status: ControllerRoundRelayStatus = input.disposition === 'continue_immediately'
491
+ ? 'pending_release'
492
+ : input.disposition === 'wait'
493
+ ? 'waiting'
494
+ : input.disposition === 'wait_for_user'
495
+ ? 'waiting_for_user'
496
+ : 'goal_complete';
497
+ let blockedReason: string | undefined;
498
+
499
+ const handoffId = bounded(input.handoffId, 200);
500
+ if (input.disposition === 'wait_for_user') {
501
+ if (!handoffId) throw new Error('CONTROLLER_RELAY_WAIT_FOR_USER_HANDOFF_REQUIRED');
502
+ const handoff = getHandoffItem(options, handoffId);
503
+ if (!handoff) throw new Error(`HANDOFF_NOT_FOUND: ${handoffId}`);
504
+ if (isTerminalHandoffStatus(handoff.status)) throw new Error(`CONTROLLER_RELAY_HANDOFF_TERMINAL: ${handoff.status}`);
505
+ if (handoff.workId && handoff.workId !== work.workId) throw new Error(`CONTROLLER_RELAY_HANDOFF_WORK_MISMATCH: ${handoffId}`);
506
+ }
507
+
508
+ if (continuing) {
509
+ if (roundCount > maxRounds) blockedReason = `round_budget_exhausted:${roundCount}>${maxRounds}`;
510
+ else if (repeatedStateCount >= maxRepeatedState) blockedReason = `repeated_state:${repeatedStateCount}>=${maxRepeatedState}`;
511
+ else if (consecutiveFailures >= maxFailures) blockedReason = `consecutive_failures:${consecutiveFailures}>=${maxFailures}`;
512
+ if (blockedReason) status = 'blocked';
513
+ }
514
+
515
+ const at = nowIso(options);
516
+ const record: ControllerRoundRelayRecord = {
517
+ schemaVersion: 1,
518
+ repoId: options.repoId,
519
+ relayScopeId,
520
+ originWorkId: work.workId,
521
+ ...(requirementId ? { requirementId } : {}),
522
+ disposition: input.disposition,
523
+ status,
524
+ controllerId: authority.controllerId,
525
+ principalId: authority.principalId,
526
+ controllerInstanceId: authority.controllerInstanceId,
527
+ sessionId: authority.sessionId,
528
+ claimGeneration: authority.claimGeneration,
529
+ authorityId: existing.value.authorityId,
530
+ stateFingerprint,
531
+ roundCount,
532
+ repeatedStateCount,
533
+ consecutiveFailures,
534
+ maxRounds,
535
+ maxRepeatedState,
536
+ maxFailures,
537
+ ...(handoffId ? { handoffId } : {}),
538
+ ...(bounded(input.reason, 1_000) ? { reason: bounded(input.reason, 1_000) } : {}),
539
+ ...(browserSessionId ? { browserSessionId } : {}),
540
+ ...(conversationUrl ? { conversationUrl } : {}),
541
+ ...(blockedReason ? { blockedReason } : {}),
542
+ tabSettlementStatus: existing.value.tabSettlementStatus ?? 'round_open',
543
+ submittedAt: at,
544
+ updatedAt: at,
545
+ };
546
+ writeControlPlaneRecord(options.controllerHome, {
547
+ namespace: NAMESPACE,
548
+ scope: options.repoId,
549
+ key: work.workId,
550
+ schemaVersion: SCHEMA_VERSION,
551
+ value: record,
552
+ action: 'controller_round_disposition_submitted',
553
+ expectedRevision: existing?.revision ?? null,
554
+ });
555
+ return record;
556
+ });
557
+ }
558
+
559
+ export function beginInitialControllerRoundDispatch(
560
+ options: ControllerRoundRelayStoreOptions,
561
+ input: BeginInitialControllerRoundDispatchInput,
562
+ ): ControllerRoundRelayRecord {
563
+ const work = getWorkContract(options, input.workId);
564
+ if (!work) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
565
+ if (isTerminalWorkContractStatus(work.status)) throw new Error(`CONTROLLER_RELAY_WORK_TERMINAL: ${work.status}`);
566
+ const requirementId = resolveRequirementId(options, work, input.requirementId);
567
+ const requirement = requirementForRelay(options, requirementId);
568
+ if (requirement && !['planned', 'active'].includes(requirement.state)) {
569
+ throw new Error(`CONTROLLER_RELAY_REQUIREMENT_TERMINAL: ${requirement.state}`);
570
+ }
571
+ const relayScopeId = resolveRelayScope(work, requirementId, input.relayScopeId);
572
+
573
+ return relayLock(options, relayScopeId, `controller-relay-launch:${input.identity.controllerId}`, () => {
574
+ const existing = readRelayRecord(options, work.workId);
575
+ if (existing && existing.value.relayScopeId !== relayScopeId) {
576
+ throw new Error(`CONTROLLER_RELAY_SCOPE_MISMATCH: Work ${work.workId} is already bound to ${existing.value.relayScopeId}`);
577
+ }
578
+ const previous = relayHistory(options, relayScopeId)[0];
579
+ if (previous && ['pending_release', 'dispatching', 'dispatched', 'claimed'].includes(previous.status)) {
580
+ throw new Error(`CONTROLLER_RELAY_ROUND_ALREADY_OPEN: ${relayScopeId}`);
581
+ }
582
+ const abandonedReleasedRound = previous?.status === 'failed'
583
+ && previous.lastError === CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR;
584
+ const requestedMaxRounds = boundedInteger(input.maxRounds, DEFAULT_MAX_ROUNDS, 1, 32);
585
+ const requestedMaxRepeatedState = boundedInteger(input.maxRepeatedState, DEFAULT_MAX_REPEATED_STATE, 1, 8);
586
+ const requestedMaxFailures = boundedInteger(input.maxFailures, DEFAULT_MAX_FAILURES, 1, 8);
587
+ const maxRounds = previous ? Math.min(previous.maxRounds, requestedMaxRounds) : requestedMaxRounds;
588
+ const maxRepeatedState = previous ? Math.min(previous.maxRepeatedState, requestedMaxRepeatedState) : requestedMaxRepeatedState;
589
+ const maxFailures = previous ? Math.min(previous.maxFailures, requestedMaxFailures) : requestedMaxFailures;
590
+ const stateFingerprint = mechanicalStateFingerprint(options, work, requirementId, relayScopeId);
591
+ // Ordinary later schedule/manual occurrences intentionally receive a fresh
592
+ // mechanical budget. A claimed round explicitly released without any semantic
593
+ // disposition is different: launcher_start is recovering the same abandoned
594
+ // relay chain, so it must consume round/repeated-state budget rather than reset
595
+ // it and thereby weaken repeated-state fencing.
596
+ const roundCount = abandonedReleasedRound ? previous!.roundCount + 1 : 1;
597
+ const repeatedStateCount = abandonedReleasedRound
598
+ ? (previous!.stateFingerprint === stateFingerprint ? previous!.repeatedStateCount + 1 : 0)
599
+ : 0;
600
+ const consecutiveFailures = abandonedReleasedRound ? previous!.consecutiveFailures : 0;
601
+ let blockedReason: string | undefined;
602
+ if (roundCount > maxRounds) blockedReason = `round_budget_exhausted:${roundCount}>${maxRounds}`;
603
+ else if (repeatedStateCount >= maxRepeatedState) blockedReason = `repeated_state:${repeatedStateCount}>=${maxRepeatedState}`;
604
+ else if (consecutiveFailures >= maxFailures) blockedReason = `consecutive_failures:${consecutiveFailures}>=${maxFailures}`;
605
+
606
+ const at = nowIso(options);
607
+ const record: ControllerRoundRelayRecord = {
608
+ schemaVersion: 1,
609
+ repoId: options.repoId,
610
+ relayScopeId,
611
+ originWorkId: work.workId,
612
+ ...(requirementId ? { requirementId } : {}),
613
+ // launcher_start is itself an explicit request to continue the Work. The
614
+ // launched controller must still submit its own end-of-round disposition.
615
+ disposition: 'continue_immediately',
616
+ status: blockedReason ? 'blocked' : 'dispatching',
617
+ controllerId: input.identity.controllerId.trim().slice(0, 240) || 'chatgpt-launcher',
618
+ principalId: input.identity.principalId.trim().slice(0, 240) || input.identity.controllerId.trim().slice(0, 240),
619
+ controllerInstanceId: input.identity.controllerInstanceId.trim().slice(0, 240),
620
+ sessionId: input.identity.sessionId.trim().slice(0, 240),
621
+ claimGeneration: 0,
622
+ authorityId: newControllerRoundAuthorityId(),
623
+ stateFingerprint,
624
+ roundCount,
625
+ repeatedStateCount,
626
+ consecutiveFailures,
627
+ maxRounds,
628
+ maxRepeatedState,
629
+ maxFailures,
630
+ reason: abandonedReleasedRound ? 'launcher_start_recovered_abandoned_claim' : 'launcher_start_requested_continuation',
631
+ ...(bounded(input.browserSessionId, 500) ? { browserSessionId: bounded(input.browserSessionId, 500) } : previous?.browserSessionId ? { browserSessionId: previous.browserSessionId } : {}),
632
+ ...(bounded(input.conversationUrl, 2_000) ? { conversationUrl: bounded(input.conversationUrl, 2_000) } : previous?.conversationUrl ? { conversationUrl: previous.conversationUrl } : {}),
633
+ ...(blockedReason ? { blockedReason } : {}),
634
+ submittedAt: at,
635
+ updatedAt: at,
636
+ };
637
+ writeControlPlaneRecord(options.controllerHome, {
638
+ namespace: NAMESPACE,
639
+ scope: options.repoId,
640
+ key: work.workId,
641
+ schemaVersion: SCHEMA_VERSION,
642
+ value: record,
643
+ action: blockedReason ? 'controller_round_initial_launch_blocked' : 'controller_round_initial_launch_begin',
644
+ expectedRevision: existing?.revision ?? null,
645
+ });
646
+ return record;
647
+ });
648
+ }
649
+
650
+ export function beginControllerRoundRelayAfterRelease(
651
+ options: ControllerRoundRelayStoreOptions,
652
+ input: { workId: string; releasedSession: ControllerSession },
653
+ ): ControllerRoundRelayRecord | undefined {
654
+ const initial = readRelayRecord(options, input.workId);
655
+ if (!initial || initial.value.status !== 'pending_release') return undefined;
656
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-begin:${input.releasedSession.controllerId}`, () => {
657
+ const current = readRelayRecord(options, input.workId);
658
+ if (!current || current.value.status !== 'pending_release') return undefined;
659
+ const record = current.value;
660
+ const releasedPrincipal = input.releasedSession.principalId?.trim() || input.releasedSession.controllerId;
661
+ if (
662
+ record.claimGeneration !== input.releasedSession.claimGeneration
663
+ || record.controllerId !== input.releasedSession.controllerId
664
+ || record.principalId !== releasedPrincipal
665
+ ) {
666
+ throw new Error(`CONTROLLER_RELAY_RELEASE_FENCE_MISMATCH: ${input.workId}`);
667
+ }
668
+ const at = nowIso(options);
669
+ // A released round is semantically closed. The successor dispatch receives a
670
+ // fresh capability so a stale tab/conversation cannot mutate the next round.
671
+ const dispatching: ControllerRoundRelayRecord = {
672
+ ...record,
673
+ authorityId: newControllerRoundAuthorityId(),
674
+ status: 'dispatching',
675
+ updatedAt: at,
676
+ };
677
+ writeControlPlaneRecord(options.controllerHome, {
678
+ namespace: NAMESPACE,
679
+ scope: options.repoId,
680
+ key: input.workId,
681
+ schemaVersion: SCHEMA_VERSION,
682
+ value: dispatching,
683
+ action: 'controller_round_relay_dispatch_begin',
684
+ expectedRevision: current.revision,
685
+ });
686
+ return dispatching;
687
+ });
688
+ }
689
+
690
+ /**
691
+ * Mechanically close a claimed ChatGPT round only after its exact controller
692
+ * epoch has been released and durable state proves no controller lease remains.
693
+ * This never invents a semantic disposition; launcher_start must open a new round.
694
+ */
695
+ export function reconcileControllerRoundAfterAbandonedRelease(
696
+ options: ControllerRoundRelayStoreOptions,
697
+ input: { workId: string; releasedSession: ControllerSession },
698
+ ): ControllerRoundRelayRecord | undefined {
699
+ const initial = readRelayRecord(options, input.workId);
700
+ if (!initial || initial.value.status !== 'claimed') return undefined;
701
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-abandoned-release:${input.releasedSession.controllerId}`, () => {
702
+ const current = readRelayRecord(options, input.workId);
703
+ if (!current || current.value.status !== 'claimed') return undefined;
704
+ const record = current.value;
705
+ const releasedPrincipal = input.releasedSession.principalId?.trim() || input.releasedSession.controllerId;
706
+ const releasedInstanceId = input.releasedSession.controllerInstanceId?.trim() || '';
707
+ if (
708
+ record.claimGeneration !== input.releasedSession.claimGeneration
709
+ || record.controllerId !== input.releasedSession.controllerId
710
+ || record.principalId !== releasedPrincipal
711
+ || record.controllerInstanceId !== releasedInstanceId
712
+ || record.sessionId !== input.releasedSession.sessionId
713
+ ) {
714
+ throw new Error(`CONTROLLER_RELAY_RELEASE_FENCE_MISMATCH: ${input.workId}`);
715
+ }
716
+ if (getControllerSession(options, input.workId)) {
717
+ throw new Error(`CONTROLLER_RELAY_ABANDONED_RELEASE_ACTIVE_CLAIM: ${input.workId}`);
718
+ }
719
+ const work = getWorkContract(options, input.workId);
720
+ if (!work || isTerminalWorkContractStatus(work.status)) return undefined;
721
+
722
+ const at = nowIso(options);
723
+ const abandoned: ControllerRoundRelayRecord = {
724
+ ...record,
725
+ status: 'failed',
726
+ lastError: CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR,
727
+ claimedAt: undefined,
728
+ updatedAt: at,
729
+ };
730
+ writeControlPlaneRecord(options.controllerHome, {
731
+ namespace: NAMESPACE,
732
+ scope: options.repoId,
733
+ key: input.workId,
734
+ schemaVersion: SCHEMA_VERSION,
735
+ value: abandoned,
736
+ action: 'controller_round_relay_claim_abandoned_after_release',
737
+ expectedRevision: current.revision,
738
+ });
739
+ return abandoned;
740
+ });
741
+ }
742
+
743
+ export function finishControllerRoundRelayDispatch(
744
+ options: ControllerRoundRelayStoreOptions,
745
+ input: { workId: string; ok: boolean; browserSessionId?: string; conversationUrl?: string; error?: string; recovery?: boolean; nowMs?: number },
746
+ ): ControllerRoundRelayRecord | undefined {
747
+ const initial = readRelayRecord(options, input.workId);
748
+ if (!initial || initial.value.status !== 'dispatching') return initial?.value;
749
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-finish:${input.workId}`, () => {
750
+ const current = readRelayRecord(options, input.workId);
751
+ if (!current || current.value.status !== 'dispatching') return current?.value;
752
+ const at = typeof input.nowMs === 'number' ? new Date(input.nowMs).toISOString() : nowIso(options);
753
+ const nextFailureCount = current.value.consecutiveFailures + 1;
754
+ const recoveryBlocked = input.recovery === true && nextFailureCount >= current.value.maxFailures;
755
+ const recoveryDelayMs = Math.min(
756
+ MAX_STALLED_RECOVERY_BACKOFF_MS,
757
+ DEFAULT_STALLED_RECOVERY_BACKOFF_MS * 2 ** Math.max(0, nextFailureCount - 1),
758
+ );
759
+ const next: ControllerRoundRelayRecord = input.ok
760
+ ? {
761
+ ...current.value,
762
+ status: 'dispatched',
763
+ consecutiveFailures: 0,
764
+ lastError: undefined,
765
+ nextRecoveryAt: undefined,
766
+ blockedReason: undefined,
767
+ tabSettlementStatus: 'round_open',
768
+ tabSettlementAt: undefined,
769
+ tabSettlementError: undefined,
770
+ ...(bounded(input.browserSessionId, 500) ? { browserSessionId: bounded(input.browserSessionId, 500) } : {}),
771
+ ...(bounded(input.conversationUrl, 2_000) ? { conversationUrl: bounded(input.conversationUrl, 2_000) } : {}),
772
+ dispatchedAt: at,
773
+ updatedAt: at,
774
+ }
775
+ : input.recovery
776
+ ? {
777
+ ...current.value,
778
+ status: recoveryBlocked ? 'blocked' : 'dispatching',
779
+ consecutiveFailures: nextFailureCount,
780
+ lastError: bounded(input.error, 2_000) ?? 'CHATGPT_CONTROLLER_RELAY_RECOVERY_FAILED',
781
+ blockedReason: recoveryBlocked
782
+ ? `consecutive_failures:${nextFailureCount}>=${current.value.maxFailures}`
783
+ : undefined,
784
+ nextRecoveryAt: recoveryBlocked
785
+ ? undefined
786
+ : new Date(Date.parse(at) + recoveryDelayMs).toISOString(),
787
+ updatedAt: at,
788
+ }
789
+ : {
790
+ ...current.value,
791
+ status: 'failed',
792
+ consecutiveFailures: nextFailureCount,
793
+ nextRecoveryAt: undefined,
794
+ lastError: bounded(input.error, 2_000) ?? 'CONTROLLER_RELAY_DISPATCH_FAILED',
795
+ updatedAt: at,
796
+ };
797
+ writeControlPlaneRecord(options.controllerHome, {
798
+ namespace: NAMESPACE,
799
+ scope: options.repoId,
800
+ key: input.workId,
801
+ schemaVersion: SCHEMA_VERSION,
802
+ value: next,
803
+ action: input.ok
804
+ ? 'controller_round_relay_dispatched'
805
+ : input.recovery
806
+ ? recoveryBlocked ? 'controller_round_relay_recovery_blocked' : 'controller_round_relay_recovery_retry_scheduled'
807
+ : 'controller_round_relay_failed',
808
+ expectedRevision: current.revision,
809
+ });
810
+ return next;
811
+ });
812
+ }
813
+
814
+ export function acknowledgeControllerRoundClaim(
815
+ options: ControllerRoundRelayStoreOptions,
816
+ input: { workId: string; session: ControllerSession },
817
+ ): ControllerRoundRelayRecord | undefined {
818
+ const initial = readRelayRecord(options, input.workId);
819
+ if (!initial) return undefined;
820
+ if (initial.value.status === 'claimed') {
821
+ if (
822
+ initial.value.controllerId === input.session.controllerId
823
+ && initial.value.sessionId === input.session.sessionId
824
+ && initial.value.claimGeneration === input.session.claimGeneration
825
+ ) return initial.value;
826
+ } else if (initial.value.status === 'blocked') {
827
+ if (!initial.value.blockedReason?.startsWith('repeated_state:')) return initial.value;
828
+ } else if (!['dispatching', 'dispatched'].includes(initial.value.status)) return initial.value;
829
+ if (input.session.controllerType !== 'chatgpt') throw new Error(`CONTROLLER_RELAY_CHATGPT_CLAIM_REQUIRED: ${input.workId}`);
830
+
831
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-claim:${input.session.controllerId}`, () => {
832
+ const current = readRelayRecord(options, input.workId);
833
+ if (!current) return undefined;
834
+ if (current.value.status === 'claimed') {
835
+ if (
836
+ current.value.controllerId === input.session.controllerId
837
+ && current.value.sessionId === input.session.sessionId
838
+ && current.value.claimGeneration === input.session.claimGeneration
839
+ ) return current.value;
840
+
841
+ const owner = getControllerSession(options, input.workId);
842
+ const ownerPrincipal = owner?.principalId?.trim() || owner?.controllerId;
843
+ const sessionPrincipal = input.session.principalId?.trim() || input.session.controllerId;
844
+ if (
845
+ !owner
846
+ || owner.controllerType !== 'chatgpt'
847
+ || owner.controllerId !== input.session.controllerId
848
+ || owner.sessionId !== input.session.sessionId
849
+ || owner.claimGeneration !== input.session.claimGeneration
850
+ || ownerPrincipal !== sessionPrincipal
851
+ || (owner.controllerInstanceId?.trim() || '') !== (input.session.controllerInstanceId?.trim() || '')
852
+ ) throw new Error(`CONTROLLER_RELAY_CLAIM_IDENTITY_MISMATCH: ${input.workId}`);
853
+ if (current.value.controllerId !== owner.controllerId || current.value.principalId !== ownerPrincipal) {
854
+ throw new Error(`CONTROLLER_RELAY_CLAIM_CONFLICT: ${input.workId}`);
855
+ }
856
+ if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) {
857
+ throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${input.workId}`);
858
+ }
859
+ const controllerInstanceId = owner.controllerInstanceId?.trim();
860
+ if (!controllerInstanceId) throw new Error(`CONTROLLER_RELAY_CLAIM_INSTANCE_REQUIRED: ${input.workId}`);
861
+ const at = nowIso(options);
862
+ const migrated: ControllerRoundRelayRecord = {
863
+ ...current.value,
864
+ controllerInstanceId,
865
+ sessionId: owner.sessionId,
866
+ claimGeneration: owner.claimGeneration,
867
+ claimedAt: at,
868
+ updatedAt: at,
869
+ lastError: undefined,
870
+ };
871
+ writeControlPlaneRecord(options.controllerHome, {
872
+ namespace: NAMESPACE,
873
+ scope: options.repoId,
874
+ key: input.workId,
875
+ schemaVersion: SCHEMA_VERSION,
876
+ value: migrated,
877
+ action: 'controller_round_relay_claim_migrated',
878
+ expectedRevision: current.revision,
879
+ });
880
+ return migrated;
881
+ }
882
+ if (current.value.status === 'blocked') {
883
+ if (!current.value.blockedReason?.startsWith('repeated_state:')) return current.value;
884
+ if (current.value.roundCount > current.value.maxRounds || current.value.consecutiveFailures >= current.value.maxFailures) {
885
+ return current.value;
886
+ }
887
+ const work = getWorkContract(options, input.workId);
888
+ if (!work || isTerminalWorkContractStatus(work.status)) return current.value;
889
+ const owner = getControllerSession(options, input.workId);
890
+ const ownerPrincipal = owner?.principalId?.trim() || owner?.controllerId;
891
+ const sessionPrincipal = input.session.principalId?.trim() || input.session.controllerId;
892
+ if (
893
+ !owner
894
+ || owner.controllerType !== 'chatgpt'
895
+ || owner.controllerId !== input.session.controllerId
896
+ || owner.sessionId !== input.session.sessionId
897
+ || owner.claimGeneration !== input.session.claimGeneration
898
+ || ownerPrincipal !== sessionPrincipal
899
+ || (owner.controllerInstanceId?.trim() || '') !== (input.session.controllerInstanceId?.trim() || '')
900
+ ) throw new Error(`CONTROLLER_RELAY_CLAIM_IDENTITY_MISMATCH: ${input.workId}`);
901
+ if (current.value.controllerId !== owner.controllerId || current.value.principalId !== ownerPrincipal) {
902
+ throw new Error(`CONTROLLER_RELAY_CLAIM_CONFLICT: ${input.workId}`);
903
+ }
904
+ if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) {
905
+ throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${input.workId}`);
906
+ }
907
+ const controllerInstanceId = owner.controllerInstanceId?.trim();
908
+ if (!controllerInstanceId) throw new Error(`CONTROLLER_RELAY_CLAIM_INSTANCE_REQUIRED: ${input.workId}`);
909
+ const stateFingerprint = mechanicalStateFingerprint(options, work, current.value.requirementId, current.value.relayScopeId);
910
+ if (stateFingerprint === current.value.stateFingerprint) return current.value;
911
+ const at = nowIso(options);
912
+ const rearmed: ControllerRoundRelayRecord = {
913
+ ...current.value,
914
+ status: 'claimed',
915
+ controllerId: owner.controllerId,
916
+ principalId: ownerPrincipal,
917
+ controllerInstanceId,
918
+ sessionId: owner.sessionId,
919
+ claimGeneration: owner.claimGeneration,
920
+ stateFingerprint,
921
+ repeatedStateCount: 0,
922
+ blockedReason: undefined,
923
+ lastError: undefined,
924
+ claimedAt: at,
925
+ updatedAt: at,
926
+ };
927
+ writeControlPlaneRecord(options.controllerHome, {
928
+ namespace: NAMESPACE,
929
+ scope: options.repoId,
930
+ key: input.workId,
931
+ schemaVersion: SCHEMA_VERSION,
932
+ value: rearmed,
933
+ action: 'controller_round_relay_claim_rearmed_after_state_change',
934
+ expectedRevision: current.revision,
935
+ });
936
+ return rearmed;
937
+ }
938
+ if (!['dispatching', 'dispatched'].includes(current.value.status)) return current.value;
939
+ const owner = getControllerSession(options, input.workId);
940
+ if (!owner) throw new Error(`CONTROLLER_RELAY_ACTIVE_CLAIM_REQUIRED: ${input.workId}`);
941
+ if (
942
+ owner.controllerType !== 'chatgpt'
943
+ || owner.controllerId !== input.session.controllerId
944
+ || owner.sessionId !== input.session.sessionId
945
+ || owner.claimGeneration !== input.session.claimGeneration
946
+ || (owner.principalId?.trim() || owner.controllerId) !== (input.session.principalId?.trim() || input.session.controllerId)
947
+ ) throw new Error(`CONTROLLER_RELAY_CLAIM_IDENTITY_MISMATCH: ${input.workId}`);
948
+ if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) {
949
+ throw new Error(`CONTROLLER_RELAY_CLAIM_GENERATION_REQUIRED: ${input.workId}`);
950
+ }
951
+ const claimGeneration = owner.claimGeneration;
952
+ const at = nowIso(options);
953
+ const next: ControllerRoundRelayRecord = {
954
+ ...current.value,
955
+ status: 'claimed',
956
+ controllerId: owner.controllerId,
957
+ principalId: owner.principalId?.trim() || owner.controllerId,
958
+ controllerInstanceId: owner.controllerInstanceId?.trim() || current.value.controllerInstanceId,
959
+ sessionId: owner.sessionId,
960
+ claimGeneration,
961
+ claimedAt: at,
962
+ updatedAt: at,
963
+ lastError: undefined,
964
+ };
965
+ writeControlPlaneRecord(options.controllerHome, {
966
+ namespace: NAMESPACE,
967
+ scope: options.repoId,
968
+ key: input.workId,
969
+ schemaVersion: SCHEMA_VERSION,
970
+ value: next,
971
+ action: 'controller_round_relay_claim_acknowledged',
972
+ expectedRevision: current.revision,
973
+ });
974
+ return next;
975
+ });
976
+ }
977
+
978
+ export function claimStalledControllerRoundRelays(
979
+ options: ControllerRoundRelayStoreOptions,
980
+ input: { nowMs?: number; graceMs?: number; limit?: number } = {},
981
+ ): ControllerRoundRelayRecord[] {
982
+ const nowMs = input.nowMs ?? Date.now();
983
+ const graceMs = Math.max(60_000, Math.min(input.graceMs ?? DEFAULT_UNCLOSED_ROUND_GRACE_MS, MAX_UNCLOSED_ROUND_GRACE_MS));
984
+ const limit = Math.max(1, Math.min(Math.trunc(input.limit ?? 2), 16));
985
+ const claimed: ControllerRoundRelayRecord[] = [];
986
+
987
+ for (const candidate of latestRelayRecordsByScope(options)) {
988
+ if (claimed.length >= limit) break;
989
+ if (!['pending_release', 'dispatching', 'dispatched', 'claimed'].includes(candidate.status)) continue;
990
+ const scheduledRecoveryAtMs = candidate.nextRecoveryAt ? Date.parse(candidate.nextRecoveryAt) : Number.NaN;
991
+ if (Number.isFinite(scheduledRecoveryAtMs)) {
992
+ if (nowMs < scheduledRecoveryAtMs) continue;
993
+ } else {
994
+ const roundOpenedAtMs = Date.parse(candidate.claimedAt ?? candidate.dispatchedAt ?? candidate.updatedAt);
995
+ if (!Number.isFinite(roundOpenedAtMs) || nowMs - roundOpenedAtMs < graceMs) continue;
996
+ }
997
+ const requirement = requirementForRelay(options, candidate.requirementId);
998
+ if (requirement && !['planned', 'active'].includes(requirement.state)) continue;
999
+ const candidateWorks = relevantWork(options, candidate);
1000
+ const activeCandidateWorks = candidateWorks.filter((work) => !isTerminalWorkContractStatus(work.status));
1001
+ if (activeCandidateWorks.length === 0) continue;
1002
+ if (activeCandidateWorks.some((work) => workHasActiveExecution(options.controllerHome, options.repoId, work.workId) || controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) continue;
1003
+
1004
+ const next = relayLock(options, candidate.relayScopeId, `controller-relay-recover:${candidate.originWorkId}`, () => {
1005
+ const latest = relayHistory(options, candidate.relayScopeId)[0];
1006
+ if (!latest || latest.originWorkId !== candidate.originWorkId || latest.updatedAt !== candidate.updatedAt || latest.status !== candidate.status) return undefined;
1007
+ const latestRecoveryAtMs = latest.nextRecoveryAt ? Date.parse(latest.nextRecoveryAt) : Number.NaN;
1008
+ if (Number.isFinite(latestRecoveryAtMs)) {
1009
+ if (nowMs < latestRecoveryAtMs) return undefined;
1010
+ } else {
1011
+ const latestRoundOpenedAtMs = Date.parse(latest.claimedAt ?? latest.dispatchedAt ?? latest.updatedAt);
1012
+ if (!Number.isFinite(latestRoundOpenedAtMs) || nowMs - latestRoundOpenedAtMs < graceMs) return undefined;
1013
+ }
1014
+ const latestRequirement = requirementForRelay(options, latest.requirementId);
1015
+ if (latestRequirement && !['planned', 'active'].includes(latestRequirement.state)) return undefined;
1016
+ const works = relevantWork(options, latest);
1017
+ const activeWorks = works.filter((work) => !isTerminalWorkContractStatus(work.status));
1018
+ if (activeWorks.length === 0) return undefined;
1019
+ if (activeWorks.some((work) => workHasActiveExecution(options.controllerHome, options.repoId, work.workId) || controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) return undefined;
1020
+
1021
+ const currentRecord = readRelayRecord(options, latest.originWorkId);
1022
+ if (!currentRecord || currentRecord.value.updatedAt !== latest.updatedAt || currentRecord.value.status !== latest.status) return undefined;
1023
+ const fingerprintWork = activeWorks[0] ?? getWorkContract(options, latest.originWorkId);
1024
+ const stateFingerprint = fingerprintWork
1025
+ ? mechanicalStateFingerprint(options, fingerprintWork, latest.requirementId, latest.relayScopeId)
1026
+ : latest.stateFingerprint;
1027
+ const resumesUndispatchedRound = latest.status === 'pending_release' || latest.status === 'dispatching';
1028
+ const roundCount = latest.roundCount + (resumesUndispatchedRound ? 0 : 1);
1029
+ const repeatedStateCount = resumesUndispatchedRound
1030
+ ? latest.repeatedStateCount
1031
+ : latest.stateFingerprint === stateFingerprint ? latest.repeatedStateCount + 1 : 0;
1032
+ let blockedReason: string | undefined;
1033
+ if (roundCount > latest.maxRounds) blockedReason = `round_budget_exhausted:${roundCount}>${latest.maxRounds}`;
1034
+ else if (repeatedStateCount >= latest.maxRepeatedState) blockedReason = `repeated_state:${repeatedStateCount}>=${latest.maxRepeatedState}`;
1035
+
1036
+ const at = new Date(nowMs).toISOString();
1037
+ const recovered: ControllerRoundRelayRecord = {
1038
+ ...latest,
1039
+ // Re-dispatching an already dispatched/claimed round creates a new
1040
+ // controller epoch. Retrying an in-flight dispatch keeps its capability.
1041
+ authorityId: blockedReason
1042
+ ? latest.authorityId
1043
+ : latest.status === 'dispatching' && latest.authorityId
1044
+ ? latest.authorityId
1045
+ : newControllerRoundAuthorityId(),
1046
+ status: blockedReason ? 'blocked' : 'dispatching',
1047
+ stateFingerprint,
1048
+ roundCount,
1049
+ repeatedStateCount,
1050
+ lastError: latest.nextRecoveryAt
1051
+ ? latest.lastError
1052
+ : latest.status === 'pending_release'
1053
+ ? 'CONTROLLER_RELAY_RELEASE_TRANSITION_INCOMPLETE'
1054
+ : latest.status === 'dispatching'
1055
+ ? 'CONTROLLER_RELAY_DISPATCH_TRANSITION_INCOMPLETE'
1056
+ : latest.status === 'claimed' ? 'CONTROLLER_RELAY_CLAIMED_ROUND_UNCLOSED' : 'CONTROLLER_RELAY_ROUND_UNCLOSED',
1057
+ nextRecoveryAt: undefined,
1058
+ claimedAt: undefined,
1059
+ ...(blockedReason ? { blockedReason } : { blockedReason: undefined }),
1060
+ updatedAt: at,
1061
+ };
1062
+ writeControlPlaneRecord(options.controllerHome, {
1063
+ namespace: NAMESPACE,
1064
+ scope: options.repoId,
1065
+ key: latest.originWorkId,
1066
+ schemaVersion: SCHEMA_VERSION,
1067
+ value: recovered,
1068
+ action: blockedReason ? 'controller_round_relay_stalled_blocked' : 'controller_round_relay_stalled_recovery_begin',
1069
+ expectedRevision: currentRecord.revision,
1070
+ });
1071
+ return blockedReason ? undefined : recovered;
1072
+ });
1073
+ if (next) claimed.push(next);
1074
+ }
1075
+ return claimed;
1076
+ }
1077
+
1078
+ export function buildControllerRoundRelayPrompt(
1079
+ options: ControllerRoundRelayStoreOptions,
1080
+ record: ControllerRoundRelayRecord,
1081
+ promptOptions: { exactOriginWork?: boolean } = {},
1082
+ ): string {
1083
+ const requirement = requirementForRelay(options, record.requirementId);
1084
+ const relevantWorks = relevantWork(options, record);
1085
+ const works = relevantWorks.slice(0, 8);
1086
+ const workIds = new Set(relevantWorks.map((work) => work.workId));
1087
+ const handoffs = listHandoffItems({ controllerHome: options.controllerHome, repoId: options.repoId, status: 'active', limit: 100 })
1088
+ .filter((handoff) => !handoff.workId || workIds.has(handoff.workId))
1089
+ .slice(0, 8);
1090
+ const requirementLine = requirement
1091
+ ? `Requirement ${requirement.requirementId}: state=${requirement.state}; outcome=${requirement.outcomeStatement.slice(0, 800)}`
1092
+ : `No durable Requirement is bound; semantic relay scope is ${record.relayScopeId}.`;
1093
+ const workLines = works.length > 0
1094
+ ? works.map((work) => `- ${work.workId}: status=${work.status}; phase=${work.phase}; updated=${work.updatedAt}; objective=${work.objective.slice(0, 500)}`).join('\n')
1095
+ : '- no linked Work snapshot found';
1096
+ const handoffLines = handoffs.length > 0
1097
+ ? handoffs.map((handoff) => `- ${handoff.id}: status=${handoff.status}; work=${handoff.workId ?? 'repo'}; ${handoff.title}; reason=${handoff.reason.slice(0, 300)}`).join('\n')
1098
+ : '- no active linked Handoff';
1099
+ return [
1100
+ `Continue Forge Requirement/Goal relay ${record.relayScopeId} in repo ${record.repoId}.`,
1101
+ 'This is a new ChatGPT controller round. First reread the latest Forge Requirement/Work/Handoff state; the snapshot below is only a launch hint.',
1102
+ requirementLine,
1103
+ `Linked Work snapshot:\n${workLines}`,
1104
+ `Active Handoff snapshot:\n${handoffLines}`,
1105
+ promptOptions.exactOriginWork
1106
+ ? `This is a Work-bound scheduled round. Claim and advance only origin Work ${record.originWorkId}. Do not select, start, delegate, or resume a sibling Work, create another schedule, or widen scope. If this Work cannot safely advance after one bounded diagnostic or repair attempt, record the exact evidence, submit wait or wait_for_user, release ownership, and end the round.`
1107
+ : `Previous relay origin Work: ${record.originWorkId}. Do not assume the next action must continue that Work; select, start, or claim the appropriate Work from the latest semantic state.`,
1108
+ `Mechanical relay budget: round=${record.roundCount}/${record.maxRounds}; repeated_state=${record.repeatedStateCount}/${record.maxRepeatedState}; consecutive_failures=${record.consecutiveFailures}/${record.maxFailures}.`,
1109
+ record.authorityId
1110
+ ? `This round's durable controller authority is controller_authority_id=${record.authorityId}. Pass this exact controller_authority_id together with relay_scope_id=${record.relayScopeId} on controller_claim, continue, finalize, stop, and controller_release. The opaque authority survives MCP transport/Runtime rotation within this round and rotates before a successor round; never substitute another Work/conversation's authority.`
1111
+ : `This is a legacy relay record without a controller authority capability. Forge permits only the exact already-claimed MCP/controller epoch to finish it; a replacement transport must reopen/recover the round before mutating Work lifecycle.`,
1112
+ ...(['CONTROLLER_RELAY_ROUND_UNCLOSED', 'CONTROLLER_RELAY_CLAIMED_ROUND_UNCLOSED'].includes(record.lastError ?? '')
1113
+ ? ['The previous ChatGPT round did not submit an explicit disposition before its liveness grace elapsed. Forge is only recovering liveness: reread durable state, make the semantic decision in ChatGPT, and close this round explicitly.']
1114
+ : []),
1115
+ `If the overall Requirement/Goal still needs another controller round, explicitly submit rh_work controller_disposition=continue_immediately with relay_scope_id=${record.relayScopeId} before releasing the Work you own. Otherwise use wait, wait_for_user with an active Handoff, or goal_complete. If a frozen client schema does not expose controller_disposition, use rh_work operation=repair with capability_id=controller.disposition:<disposition>:${record.relayScopeId}; Forge maps that compatibility call to the same canonical disposition path and fences it identically.`,
1116
+ 'Forge must not infer the semantic next step. Existing Work ownership, Handoff authority, and external-effect authorization remain authoritative; claim the exact Work before any mutation.',
1117
+ ].join('\n');
1118
+ }