@moretea-labs/forge 1.7.0 → 1.7.2

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 +26 -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 +125 -101
  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 +69 -29
  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 +150 -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 +815 -1015
  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 +3 -2
  166. package/src/runtime/root/package-runtime-service.ts +48 -3
  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
@@ -18,6 +18,12 @@ export interface ControllerSessionClaimInput {
18
18
  controllerInstanceId?: string;
19
19
  /** Optional compare-and-swap fence supplied by a recovering caller. */
20
20
  expectedClaimGeneration?: number;
21
+ /**
22
+ * Permit a different authenticated controller epoch to recover this Work only
23
+ * after the canonical execution/activity grace has elapsed. Callers must pair
24
+ * this with expectedClaimGeneration from the owner they observed.
25
+ */
26
+ allowStaleRecovery?: boolean;
21
27
  leaseMs?: number;
22
28
  }
23
29
 
@@ -49,12 +55,39 @@ function write(options: ControllerSessionStoreOptions, store: ControllerSessionS
49
55
  });
50
56
  }
51
57
 
52
- const DEFAULT_CONTROLLER_RECOVERY_GRACE_MS = 10 * 60_000;
58
+ const DEFAULT_CONTROLLER_RECOVERY_GRACE_MS = 5 * 60_000;
53
59
  const MAX_CONTROLLER_RECOVERY_GRACE_MS = 60 * 60_000;
60
+ const DEFAULT_CONTROLLER_LEASE_MS = 60 * 60_000;
61
+ const MAX_CONTROLLER_LEASE_MS = 60 * 60_000;
54
62
 
55
- function activeSession(store: ControllerSessionStore, workId: string): ControllerSession | undefined {
56
- const at = Date.now();
57
- return store.sessions.find((session) => session.workId === workId && Date.parse(session.leaseExpiresAt) > at);
63
+ function optionsNowMs(options: ControllerSessionStoreOptions): number {
64
+ const parsed = Date.parse(now(options));
65
+ return Number.isFinite(parsed) ? parsed : Date.now();
66
+ }
67
+
68
+ function activeSession(store: ControllerSessionStore, workId: string, atMs: number = Date.now()): ControllerSession | undefined {
69
+ return store.sessions.find((session) => session.workId === workId && Date.parse(session.leaseExpiresAt) > atMs);
70
+ }
71
+
72
+ function sessionBlocksRecovery(
73
+ options: ControllerSessionStoreOptions,
74
+ owner: ControllerSession,
75
+ input: { nowMs: number; graceMs?: number },
76
+ ): boolean {
77
+ const execution = peekExecutionSession(options.controllerHome, owner.sessionId);
78
+ const graceMs = Math.max(60_000, Math.min(input.graceMs ?? DEFAULT_CONTROLLER_RECOVERY_GRACE_MS, MAX_CONTROLLER_RECOVERY_GRACE_MS));
79
+ if (execution?.invalidatedAt) {
80
+ const invalidatedAtMs = Date.parse(execution.invalidatedAt);
81
+ if (!Number.isFinite(invalidatedAtMs)) return true;
82
+ return input.nowMs - invalidatedAtMs < graceMs;
83
+ }
84
+ if (execution?.activeWorkId && execution.activeWorkId !== owner.workId) return false;
85
+ const activityMs = [execution?.lastValidatedAt, execution?.updatedAt, owner.claimedAt]
86
+ .map((value) => value ? Date.parse(value) : Number.NaN)
87
+ .filter(Number.isFinite)
88
+ .reduce((latest, value) => Math.max(latest, value), Number.NEGATIVE_INFINITY);
89
+ if (!Number.isFinite(activityMs)) return false;
90
+ return input.nowMs - activityMs < graceMs;
58
91
  }
59
92
 
60
93
  export function controllerSessionBlocksRecovery(
@@ -62,15 +95,10 @@ export function controllerSessionBlocksRecovery(
62
95
  workId: string,
63
96
  input: { nowMs?: number; graceMs?: number } = {},
64
97
  ): boolean {
65
- const owner = activeSession(read(options), workId);
66
- if (!owner) return false;
67
- const execution = peekExecutionSession(options.controllerHome, owner.sessionId);
68
- if (!execution?.invalidatedAt) return true;
69
- const invalidatedAtMs = Date.parse(execution.invalidatedAt);
70
- if (!Number.isFinite(invalidatedAtMs)) return true;
71
98
  const nowMs = input.nowMs ?? Date.now();
72
- const graceMs = Math.max(60_000, Math.min(input.graceMs ?? DEFAULT_CONTROLLER_RECOVERY_GRACE_MS, MAX_CONTROLLER_RECOVERY_GRACE_MS));
73
- return nowMs - invalidatedAtMs < graceMs;
99
+ const owner = activeSession(read(options), workId, nowMs);
100
+ if (!owner) return false;
101
+ return sessionBlocksRecovery(options, owner, { nowMs, graceMs: input.graceMs });
74
102
  }
75
103
 
76
104
  function assertIdentity(input: ControllerSessionClaimInput): void {
@@ -93,7 +121,7 @@ function claimedSession(
93
121
  previous?: ControllerSession,
94
122
  ): ControllerSession {
95
123
  const leaseExpiresAt = new Date(
96
- Date.parse(claimedAt) + Math.max(1_000, Math.min(input.leaseMs ?? 300_000, 3_600_000)),
124
+ Date.parse(claimedAt) + Math.max(1_000, Math.min(input.leaseMs ?? DEFAULT_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)),
97
125
  ).toISOString();
98
126
  const previousPrincipal = previous?.principalId?.trim() || previous?.controllerId;
99
127
  const inputPrincipal = input.principalId?.trim() || input.controllerId;
@@ -132,9 +160,10 @@ function persistClaim(
132
160
  previous?: ControllerSession,
133
161
  ): ControllerSession {
134
162
  const claimedAt = now(options);
163
+ const claimedAtMs = Date.parse(claimedAt);
135
164
  const session = claimedSession(options, input, claimedAt, previous);
136
165
  const sessions = [
137
- ...store.sessions.filter((entry) => entry.workId !== input.workId && Date.parse(entry.leaseExpiresAt) > Date.now()),
166
+ ...store.sessions.filter((entry) => entry.workId !== input.workId && Date.parse(entry.leaseExpiresAt) > claimedAtMs),
138
167
  session,
139
168
  ];
140
169
  write(options, { schemaVersion: 1, updatedAt: claimedAt, sessions });
@@ -142,15 +171,15 @@ function persistClaim(
142
171
  }
143
172
 
144
173
  export function getControllerSession(options: ControllerSessionStoreOptions, workId: string): ControllerSession | undefined {
145
- return activeSession(read(options), workId);
174
+ return activeSession(read(options), workId, optionsNowMs(options));
146
175
  }
147
176
 
148
177
  export function listControllerSessions(options: ControllerSessionStoreOptions): ControllerSession[] {
149
- const at = Date.now();
178
+ const at = optionsNowMs(options);
150
179
  return read(options).sessions.filter((session) => Date.parse(session.leaseExpiresAt) > at);
151
180
  }
152
181
 
153
- export interface ControllerTerminalizationAuthority {
182
+ export interface ControllerOwnershipAuthority {
154
183
  controllerId: string;
155
184
  controllerType: ControllerType;
156
185
  principalId: string;
@@ -158,6 +187,61 @@ export interface ControllerTerminalizationAuthority {
158
187
  claimGeneration: number;
159
188
  }
160
189
 
190
+ export type ControllerTerminalizationAuthority = ControllerOwnershipAuthority;
191
+
192
+ export function controllerSessionPrincipalId(
193
+ owner: Pick<ControllerSession, 'principalId' | 'controllerId'>,
194
+ ): string {
195
+ return owner.principalId?.trim() || owner.controllerId;
196
+ }
197
+
198
+ export function requireControllerOwnershipAuthority(
199
+ owner: ControllerSession,
200
+ workId: string = owner.workId,
201
+ ): ControllerOwnershipAuthority {
202
+ const principalId = controllerSessionPrincipalId(owner);
203
+ const controllerInstanceId = owner.controllerInstanceId?.trim() || '';
204
+ if (!controllerInstanceId) throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${workId}`);
205
+ if (typeof owner.claimGeneration !== 'number' || owner.claimGeneration < 1) {
206
+ throw new Error(`WORK_CONTROLLER_CLAIM_GENERATION_REQUIRED: ${workId}`);
207
+ }
208
+ return {
209
+ controllerId: owner.controllerId,
210
+ controllerType: owner.controllerType,
211
+ principalId,
212
+ controllerInstanceId,
213
+ claimGeneration: owner.claimGeneration,
214
+ };
215
+ }
216
+
217
+ export function assertControllerOwnershipAuthority(
218
+ owner: ControllerSession,
219
+ expected: {
220
+ workId?: string;
221
+ controllerId: string;
222
+ principalId: string;
223
+ controllerInstanceId?: string;
224
+ controllerType?: ControllerType;
225
+ },
226
+ ): ControllerOwnershipAuthority {
227
+ const workId = expected.workId ?? owner.workId;
228
+ const authority = requireControllerOwnershipAuthority(owner, workId);
229
+ if (authority.controllerId !== expected.controllerId.trim()) {
230
+ throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${workId} is owned by ${owner.controllerId}`);
231
+ }
232
+ if (expected.controllerType && authority.controllerType !== expected.controllerType) {
233
+ throw new Error(`WORK_CONTROLLER_TYPE_MISMATCH: ${workId} is owned by ${owner.controllerType}`);
234
+ }
235
+ if (authority.principalId !== expected.principalId.trim()) {
236
+ throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${workId}`);
237
+ }
238
+ if (expected.controllerInstanceId !== undefined
239
+ && authority.controllerInstanceId !== expected.controllerInstanceId.trim()) {
240
+ throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${workId}`);
241
+ }
242
+ return authority;
243
+ }
244
+
161
245
  export type ControllerTerminalizationFenceReason =
162
246
  | 'active_controller_claim'
163
247
  | 'stale_controller_authority'
@@ -167,11 +251,21 @@ export type ControllerTerminalizationFenceResult<T> =
167
251
  | { allowed: true; value: T; owner?: ControllerSession }
168
252
  | { allowed: false; reason: ControllerTerminalizationFenceReason; owner?: ControllerSession };
169
253
 
254
+ export function controllerTerminalizationAuthorityFromSession(
255
+ owner: ControllerSession,
256
+ ): ControllerTerminalizationAuthority | undefined {
257
+ try {
258
+ return requireControllerOwnershipAuthority(owner);
259
+ } catch {
260
+ return undefined;
261
+ }
262
+ }
263
+
170
264
  function controllerTerminalizationAuthorityMatches(
171
265
  owner: ControllerSession,
172
266
  authority: ControllerTerminalizationAuthority,
173
267
  ): boolean {
174
- const ownerPrincipal = owner.principalId?.trim() || owner.controllerId;
268
+ const ownerPrincipal = controllerSessionPrincipalId(owner);
175
269
  const ownerInstanceId = owner.controllerInstanceId?.trim() || '';
176
270
  return owner.controllerId === authority.controllerId
177
271
  && owner.controllerType === authority.controllerType
@@ -206,7 +300,7 @@ export function withControllerSessionTerminalizationFence<T>(
206
300
  { scope: 'task', repoId: options.repoId, taskId: `controller-session-${input.workId}` },
207
301
  input.actor,
208
302
  () => {
209
- const owner = activeSession(read(options), input.workId);
303
+ const owner = activeSession(read(options), input.workId, optionsNowMs(options));
210
304
  if (!owner) {
211
305
  if (input.authority) return { allowed: false, reason: 'controller_claim_missing' };
212
306
  return { allowed: true, value: operation() };
@@ -240,7 +334,7 @@ export function releaseControllerSessionWithAuthority(
240
334
  input.actor,
241
335
  () => {
242
336
  const store = read(options);
243
- const owner = activeSession(store, input.workId);
337
+ const owner = activeSession(store, input.workId, optionsNowMs(options));
244
338
  if (!owner) return { allowed: false, reason: 'controller_claim_missing' };
245
339
  if (!controllerTerminalizationAuthorityMatches(owner, input.authority)) {
246
340
  return { allowed: false, reason: 'stale_controller_authority', owner };
@@ -252,6 +346,19 @@ export function releaseControllerSessionWithAuthority(
252
346
  );
253
347
  }
254
348
 
349
+ export function releaseObservedControllerSession(
350
+ options: ControllerSessionStoreOptions,
351
+ input: { workId: string; actor: string; owner: ControllerSession },
352
+ ): ControllerTerminalizationFenceResult<void> {
353
+ const authority = controllerTerminalizationAuthorityFromSession(input.owner);
354
+ if (!authority) return { allowed: false, reason: 'stale_controller_authority', owner: input.owner };
355
+ return releaseControllerSessionWithAuthority(options, {
356
+ workId: input.workId,
357
+ actor: input.actor,
358
+ authority,
359
+ });
360
+ }
361
+
255
362
  /**
256
363
  * Strict claim used for first ownership acquisition and explicit release/reclaim flows.
257
364
  * A live claim with any different controller or transport session remains fenced.
@@ -268,7 +375,7 @@ export function claimControllerSession(
268
375
  () => {
269
376
  assertWorkClaimable(options, input.workId);
270
377
  const store = read(options);
271
- const current = activeSession(store, input.workId);
378
+ const current = activeSession(store, input.workId, optionsNowMs(options));
272
379
  assertExpectedGeneration(input, current);
273
380
  if (current && (current.controllerId !== input.controllerId || current.sessionId !== input.sessionId)) {
274
381
  throw new Error(`WORK_ALREADY_CLAIMED: ${input.workId} is owned by ${current.controllerId}`);
@@ -299,15 +406,19 @@ export function resumeControllerSession(
299
406
  () => {
300
407
  assertWorkClaimable(options, input.workId);
301
408
  const store = read(options);
302
- const current = activeSession(store, input.workId);
409
+ const currentNowMs = optionsNowMs(options);
410
+ const current = activeSession(store, input.workId, currentNowMs);
303
411
  assertExpectedGeneration(input, current);
304
412
  if (!current) return persistClaim(options, store, input);
305
413
  const priorExecution = peekExecutionSession(options.controllerHome, current.sessionId);
306
414
  const currentPrincipal = current.principalId?.trim() || priorExecution?.principalId?.trim() || current.controllerId;
307
- if (currentPrincipal !== input.principalId.trim()) {
415
+ const staleRecoveryAllowed = input.allowStaleRecovery === true
416
+ && input.expectedClaimGeneration !== undefined
417
+ && !sessionBlocksRecovery(options, current, { nowMs: currentNowMs });
418
+ if (currentPrincipal !== input.principalId.trim() && !staleRecoveryAllowed) {
308
419
  throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${input.workId} is owned by another authenticated principal`);
309
420
  }
310
- if (current.controllerId !== input.controllerId) {
421
+ if (current.controllerId !== input.controllerId && !staleRecoveryAllowed) {
311
422
  throw new Error(`WORK_ALREADY_CLAIMED: ${input.workId} is owned by ${current.controllerId}`);
312
423
  }
313
424
  return persistClaim(options, store, input, current);
@@ -315,6 +426,54 @@ export function resumeControllerSession(
315
426
  );
316
427
  }
317
428
 
429
+ /**
430
+ * Rebind one active Work to the authenticated Controller transport/Runtime that
431
+ * is serving the current request. Same-instance transport rollover needs only
432
+ * principal/controller proof. A Runtime-instance change additionally requires
433
+ * positive proof that the requested instance is the live canonical Runtime;
434
+ * this prevents a stale old Runtime request from migrating ownership backwards.
435
+ * The observed claim generation is always supplied as a CAS fence.
436
+ */
437
+ export function bindControllerSessionToCurrentRuntime(
438
+ options: ControllerSessionStoreOptions,
439
+ input: ControllerSessionClaimInput & {
440
+ principalId: string;
441
+ controllerInstanceId: string;
442
+ currentRuntimeInstanceId?: string;
443
+ allowClaimIfMissing?: boolean;
444
+ },
445
+ ): ControllerSession {
446
+ assertIdentity(input);
447
+ const current = getControllerSession(options, input.workId);
448
+ if (!current) {
449
+ if (input.allowClaimIfMissing !== true) throw new Error(`WORK_CONTROLLER_OWNER_REQUIRED: ${input.workId}`);
450
+ return claimControllerSession(options, { ...input, expectedClaimGeneration: 0 });
451
+ }
452
+ const currentPrincipal = controllerSessionPrincipalId(current);
453
+ if (current.controllerId !== input.controllerId) {
454
+ throw new Error(`WORK_CONTROLLER_OWNER_MISMATCH: ${input.workId} is owned by ${current.controllerId}`);
455
+ }
456
+ if (current.controllerType !== input.controllerType) {
457
+ throw new Error(`WORK_CONTROLLER_TYPE_MISMATCH: ${input.workId} is owned by ${current.controllerType}`);
458
+ }
459
+ if (currentPrincipal !== input.principalId.trim()) {
460
+ throw new Error(`WORK_CONTROLLER_PRINCIPAL_MISMATCH: ${input.workId}`);
461
+ }
462
+ const ownerInstanceId = current.controllerInstanceId?.trim() || '';
463
+ const requestedInstanceId = input.controllerInstanceId.trim();
464
+ if (ownerInstanceId !== requestedInstanceId) {
465
+ const currentRuntimeInstanceId = input.currentRuntimeInstanceId?.trim() || '';
466
+ if (!currentRuntimeInstanceId || currentRuntimeInstanceId !== requestedInstanceId) {
467
+ throw new Error(`WORK_CONTROLLER_INSTANCE_MISMATCH: ${input.workId}`);
468
+ }
469
+ }
470
+ if (ownerInstanceId === requestedInstanceId && current.sessionId === input.sessionId) return current;
471
+ return resumeControllerSession(options, {
472
+ ...input,
473
+ expectedClaimGeneration: current.claimGeneration,
474
+ });
475
+ }
476
+
318
477
  export function releaseControllerSession(options: ControllerSessionStoreOptions, workId: string, controllerId: string): void {
319
478
  withControllerLock(
320
479
  options.controllerHome,
@@ -13,7 +13,7 @@ import {
13
13
  type GoalWorkloopStartInput,
14
14
  } from './goal-workloop';
15
15
  import { getWorkContract } from './work-contract-store';
16
- import type { CapabilityRisk, FacadeResult, WorkContractConstraints } from './types';
16
+ import type { CapabilityRisk, FacadeResult, WorkContractConstraints, WorkKind } from './types';
17
17
 
18
18
  export {
19
19
  continueGoalWorkloop,
@@ -47,6 +47,18 @@ function accessModeValue(value: unknown): AccessMode | undefined {
47
47
  return isAccessMode(candidate) ? candidate : undefined;
48
48
  }
49
49
 
50
+ function workKindValue(value: unknown): Extract<WorkKind, 'repository_change' | 'completed_no_change' | 'read_only_review' | 'investigation' | 'local_effect' | 'remote_effect' | 'reconciliation'> | undefined {
51
+ return value === 'repository_change'
52
+ || value === 'completed_no_change'
53
+ || value === 'read_only_review'
54
+ || value === 'investigation'
55
+ || value === 'local_effect'
56
+ || value === 'remote_effect'
57
+ || value === 'reconciliation'
58
+ ? value
59
+ : undefined;
60
+ }
61
+
50
62
  function constraintsValue(value: unknown): WorkContractConstraints | undefined {
51
63
  if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
52
64
  const record = value as Record<string, unknown>;
@@ -65,6 +77,7 @@ function constraintsValue(value: unknown): WorkContractConstraints | undefined {
65
77
  ? 'isolated'
66
78
  : record.workspaceMode === 'auto' || record.workspace_mode === 'auto' ? 'auto' : undefined,
67
79
  requireWorktree: booleanValue(record, 'requireWorktree', 'require_worktree'),
80
+ directMainProhibited: booleanValue(record, 'directMainProhibited', 'direct_main_prohibited'),
68
81
  };
69
82
  return Object.fromEntries(
70
83
  Object.entries(constraints).filter(([, entry]) => entry !== undefined),
@@ -143,6 +156,7 @@ export function runGoalWorkloop(
143
156
  forbiddenPaths: Array.isArray(args.forbidden_paths) ? args.forbidden_paths.map(String) : undefined,
144
157
  checks: Array.isArray(args.check_ids) ? args.check_ids.map(String) : undefined,
145
158
  constraints,
159
+ workKind: workKindValue(args.work_kind),
146
160
  modeInput: {
147
161
  objective: typeof args.objective === 'string' ? args.objective : undefined,
148
162
  expectedFiles: typeof args.expected_files === 'number' ? args.expected_files : undefined,