@moretea-labs/forge 1.5.1 → 1.6.0

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 (124) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/DESIGN.md +7 -2
  3. package/README.md +4 -4
  4. package/README.zh-CN.md +2 -2
  5. package/THIRD_PARTY_NOTICES.md +1 -1
  6. package/assets/hooks/pre-edit-guard.sh +8 -39
  7. package/assets/hooks/prompt-guard.sh +2 -2
  8. package/assets/plugin-registry.v1.json +54 -3
  9. package/assets/reference-configs/hook-operations.md +1 -1
  10. package/assets/reference-configs/sprint-contracts.md +2 -2
  11. package/assets/skill-version.json +2 -2
  12. package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
  13. package/assets/templates/helpers/sprint-backlog.sh +6 -6
  14. package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
  15. package/docs/architecture/current/codegraph-context-provider.md +15 -3
  16. package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
  17. package/docs/architecture/current/entity-model.md +20 -0
  18. package/docs/architecture/current/external-plugin-governance.md +3 -3
  19. package/docs/architecture/current/system-overview.md +26 -1
  20. package/docs/architecture/current/thin-harness-v1.md +10 -0
  21. package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
  22. package/docs/operations/controller-browser-plugin.md +12 -16
  23. package/docs/operations/features.md +1 -1
  24. package/docs/operations/homebrew.md +2 -2
  25. package/docs/tutorials/01-install-and-start.md +1 -1
  26. package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
  27. package/docs/wiki/Installation.md +1 -1
  28. package/docs/wiki/Quick-Start.md +1 -1
  29. package/package.json +1 -1
  30. package/scripts/check-public-docs.sh +14 -1
  31. package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
  32. package/scripts/sprint-backlog.sh +1 -1
  33. package/src/cli/commands/chatgpt.ts +31 -2
  34. package/src/cli/commands/plugin.ts +36 -9
  35. package/src/cli/controller/context-pack.ts +158 -6
  36. package/src/cli/hook/prompt-guard-decision.ts +17 -18
  37. package/src/cli/hook/prompt-intents.ts +1 -1
  38. package/src/cli/local-bridge/console-automations.ts +205 -23
  39. package/src/cli/local-bridge/console-view-models.ts +2 -0
  40. package/src/cli/local-bridge/facade-api.ts +2 -0
  41. package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
  42. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
  43. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
  44. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
  45. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
  46. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
  47. package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
  48. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
  49. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
  50. package/src/cli/local-bridge/frontend/src/styles.css +27 -11
  51. package/src/cli/local-bridge/frontend/src/types.ts +6 -2
  52. package/src/cli/local-bridge/server.ts +29 -19
  53. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  54. package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
  55. package/src/cli/mcp/paths.ts +8 -2
  56. package/src/cli/mcp/repository-tools.ts +124 -9
  57. package/src/cli/mcp/server.ts +164 -19
  58. package/src/cli/mcp/transports/http.ts +25 -2
  59. package/src/cli/mcp/transports/session-registry.ts +24 -0
  60. package/src/cli/repositories/command-classifier.ts +113 -0
  61. package/src/cli/repositories/safe-patch.ts +6 -4
  62. package/src/cli/repositories/structured-git.ts +49 -7
  63. package/src/cli/repository/inspector.ts +16 -3
  64. package/src/runtime/assistant/intent.ts +3 -3
  65. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
  66. package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
  67. package/src/runtime/control-plane/execution/validation.ts +15 -2
  68. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
  69. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
  70. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
  71. package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
  72. package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
  73. package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
  74. package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
  75. package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
  76. package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
  77. package/src/runtime/control-plane/facade/types.ts +33 -6
  78. package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
  79. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
  80. package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
  81. package/src/runtime/diagnostics/mcp-timing.ts +6 -0
  82. package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
  83. package/src/runtime/execution/process-runtime/check-result.ts +10 -2
  84. package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
  85. package/src/runtime/execution/process-runtime/gc.ts +64 -10
  86. package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
  87. package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
  88. package/src/runtime/execution/process-runtime/runtime.ts +16 -1
  89. package/src/runtime/execution/process-runtime/store.ts +6 -0
  90. package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
  91. package/src/runtime/gateway/mcp/router.ts +1 -1
  92. package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
  93. package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
  94. package/src/runtime/plugins/browser-adapter.ts +19 -72
  95. package/src/runtime/plugins/browser-handoff-host.ts +6 -31
  96. package/src/runtime/plugins/browser-handoff.ts +0 -3
  97. package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
  98. package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
  99. package/src/runtime/plugins/external-registration.ts +84 -5
  100. package/src/runtime/plugins/first-party-registry.ts +2 -2
  101. package/src/runtime/plugins/gmail-adapter.ts +28 -2
  102. package/src/runtime/plugins/local-system-adapter.ts +2 -2
  103. package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
  104. package/src/runtime/plugins/store.ts +107 -5
  105. package/src/runtime/plugins/types.ts +4 -0
  106. package/src/runtime/recovery/actions.ts +0 -10
  107. package/src/runtime/recovery/classifier.ts +0 -9
  108. package/src/runtime/recovery/maintenance-executor.ts +43 -11
  109. package/src/runtime/recovery/probe.ts +0 -1
  110. package/src/runtime/recovery/types.ts +0 -1
  111. package/src/runtime/root/release-materialize.ts +143 -19
  112. package/src/runtime/root/service.ts +6 -4
  113. package/src/runtime/safe-tooling/index.ts +0 -1
  114. package/src/runtime/safe-tooling/types.ts +0 -30
  115. package/src/runtime/standalone-recovery/core.ts +2 -2
  116. package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
  117. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
  118. package/src/runtime/workflow/schedules/engine.ts +357 -64
  119. package/src/runtime/workflow/schedules/types.ts +5 -0
  120. package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
  121. package/bin/forge-desktop-helper.mjs +0 -204
  122. package/docs/operations/controller-desktop-plugin.md +0 -112
  123. package/src/runtime/plugins/desktop-adapter.ts +0 -374
  124. package/src/runtime/safe-tooling/browser-safe.ts +0 -133
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'crypto';
1
+ import { createHash, randomUUID } from 'crypto';
2
2
  import { executeBrowserPluginAction, readBrowserPluginConfiguration } from '../../plugins/browser-adapter';
3
3
  import { controllerPluginRepository } from '../../plugins/store';
4
4
  import { getWorkContract } from '../facade/work-contract-store';
@@ -8,10 +8,19 @@ import {
8
8
  type ChatgptWorkConversationBinding,
9
9
  } from './chatgpt-work-binding-store';
10
10
 
11
- const CONTROLLER_CHATGPT_SESSION_ID = 'forge-chatgpt-supercontroller';
12
- const CHATGPT_PROMPT_SELECTOR = '[name="prompt-textarea"]';
13
- const CHATGPT_SEND_KEY = 'Enter';
14
- const CHATGPT_ALLOWED_DOMAINS = ['chatgpt.com', 'www.chatgpt.com'];
11
+ const LEGACY_CONTROLLER_CHATGPT_SESSION_ID = 'forge-chatgpt-supercontroller';
12
+ export const DEFAULT_CHATGPT_AUTOMATION_MODEL = 'gpt-5.6';
13
+ export const DEFAULT_CHATGPT_AUTOMATION_REASONING = 'high';
14
+ export const DEFAULT_CHATGPT_AUTOMATION_TAB_POLICY = 'auto';
15
+ export const DEFAULT_CHATGPT_AUTOMATION_PLUGIN_MENTION = '@forge';
16
+ const CHATGPT_PROMPT_SELECTOR = 'div#prompt-textarea[contenteditable="true"]';
17
+ const CHATGPT_SEND_SELECTOR = '[data-testid="send-button"]';
18
+ const CHATGPT_INTELLIGENCE_CONTROL_SELECTOR = 'main button';
19
+ const CHATGPT_CAPABILITY_SLIDER_SELECTOR = '[role="slider"]';
20
+ const CHATGPT_CAPABILITY_MENUITEM_SELECTOR = '[role="menuitem"][aria-keyshortcuts~="ArrowLeft"][aria-keyshortcuts~="ArrowRight"]';
21
+
22
+ export type ChatgptAutomationReasoning = 'medium' | 'high' | 'xhigh';
23
+ export type ChatgptAutomationTabPolicy = 'auto' | 'reuse' | 'new';
15
24
 
16
25
  export interface WorkChatgptContinuationInput {
17
26
  controllerHome: string;
@@ -22,9 +31,39 @@ export interface WorkChatgptContinuationInput {
22
31
  title?: string;
23
32
  browserSessionId?: string;
24
33
  conversationUrl?: string;
34
+ model?: string;
35
+ reasoning?: ChatgptAutomationReasoning;
36
+ tabPolicy?: ChatgptAutomationTabPolicy;
25
37
  timeoutMs?: number;
26
38
  }
27
39
 
40
+ export interface ScheduledChatgptPromptInput {
41
+ controllerHome: string;
42
+ repoId: string;
43
+ automationId: string;
44
+ prompt: string;
45
+ title?: string;
46
+ browserSessionId?: string;
47
+ conversationUrl?: string;
48
+ model?: string;
49
+ reasoning?: ChatgptAutomationReasoning;
50
+ tabPolicy?: ChatgptAutomationTabPolicy;
51
+ timeoutMs?: number;
52
+ }
53
+
54
+ export interface ScheduledChatgptPromptResult {
55
+ status: 'dispatched' | 'failed';
56
+ provider: 'controller-browser';
57
+ browserSessionId: string;
58
+ conversationUrl?: string;
59
+ model: string;
60
+ reasoning: ChatgptAutomationReasoning;
61
+ tabPolicy: ChatgptAutomationTabPolicy;
62
+ executionPreferenceVerified: boolean;
63
+ reusedSession: boolean;
64
+ error?: { code: string; message: string };
65
+ }
66
+
28
67
  export interface WorkChatgptContinuationResult {
29
68
  status: 'dispatched' | 'failed';
30
69
  provider: 'controller-browser';
@@ -33,9 +72,15 @@ export interface WorkChatgptContinuationResult {
33
72
  conversationId?: string;
34
73
  localAlias?: string;
35
74
  resumedFromBinding: boolean;
75
+ model: string;
76
+ reasoning: ChatgptAutomationReasoning;
77
+ tabPolicy: ChatgptAutomationTabPolicy;
78
+ executionPreferenceVerified: boolean;
36
79
  error?: { code: string; message: string };
37
80
  }
38
81
 
82
+ const workflowToolAttributionInstruction = (workId: string): string => `Forge Workflow execution contract: first claim exact Work ${workId}. For every repository_command_execute and repository_safe_patch_apply call in this turn, pass work_id=${workId} explicitly. MCP transport sessions may change between tool calls; never omit this Work id.`;
83
+
39
84
  function requestId(workId: string, actionId: string): string {
40
85
  return `chatgpt-work:${workId}:${actionId}:${randomUUID()}`;
41
86
  }
@@ -68,7 +113,6 @@ async function ensureControllerChatgptBrowser(controllerHome: string, workId: st
68
113
  const repoRoot = repository.canonicalRoot ?? repository.localRoot;
69
114
  if (!repoRoot) throw new Error('CHATGPT_CONTROLLER_BROWSER_ROOT_UNAVAILABLE');
70
115
  const existing = readBrowserPluginConfiguration(repoRoot);
71
- const allowedDomains = [...new Set([...existing.allowedDomains, ...CHATGPT_ALLOWED_DOMAINS])];
72
116
  await controllerBrowserAction(controllerHome, workId, 'configure', {
73
117
  enabled: true,
74
118
  ...(!existing.enabled ? {
@@ -79,7 +123,6 @@ async function ensureControllerChatgptBrowser(controllerHome: string, workId: st
79
123
  native_attach_mode: 'auto',
80
124
  native_browser_candidates: ['chrome'],
81
125
  } : {}),
82
- allowed_domains: allowedDomains,
83
126
  });
84
127
  }
85
128
 
@@ -87,11 +130,309 @@ function stringField(value: unknown): string | undefined {
87
130
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
88
131
  }
89
132
 
133
+ function normalizeModel(value?: string): string {
134
+ const model = value?.trim().toLowerCase() || DEFAULT_CHATGPT_AUTOMATION_MODEL;
135
+ if (model === 'gpt-5.6' || model === 'gpt-5.6-sol' || model === '5.6' || model === '5.6s') return DEFAULT_CHATGPT_AUTOMATION_MODEL;
136
+ throw new Error(`CHATGPT_AUTOMATION_MODEL_UNSUPPORTED:${value}`);
137
+ }
138
+
139
+ function normalizeReasoning(value?: ChatgptAutomationReasoning): ChatgptAutomationReasoning {
140
+ return value ?? DEFAULT_CHATGPT_AUTOMATION_REASONING;
141
+ }
142
+
143
+ function normalizeTabPolicy(value?: ChatgptAutomationTabPolicy): ChatgptAutomationTabPolicy {
144
+ return value ?? DEFAULT_CHATGPT_AUTOMATION_TAB_POLICY;
145
+ }
146
+
147
+ export function stableChatgptWorkBrowserSessionId(repoId: string, workId: string): string {
148
+ const digest = createHash('sha256').update(`${repoId}\n${workId}`).digest('hex').slice(0, 20);
149
+ return `forge-chatgpt-work-${digest}`;
150
+ }
151
+
152
+ export function stableChatgptAutomationBrowserSessionId(repoId: string, automationId: string): string {
153
+ const digest = createHash('sha256').update(`${repoId}\nautomation\n${automationId}`).digest('hex').slice(0, 20);
154
+ return `forge-chatgpt-automation-${digest}`;
155
+ }
156
+
157
+ export function resolveChatgptWorkBrowserSessionId(input: {
158
+ repoId: string;
159
+ workId: string;
160
+ tabPolicy?: ChatgptAutomationTabPolicy;
161
+ explicitSessionId?: string;
162
+ boundSessionId?: string;
163
+ }): string {
164
+ const policy = normalizeTabPolicy(input.tabPolicy);
165
+ const stable = stableChatgptWorkBrowserSessionId(input.repoId, input.workId);
166
+ if (policy === 'new') return `${stable}-${randomUUID().slice(0, 8)}`;
167
+ const explicit = input.explicitSessionId?.trim();
168
+ if (explicit && explicit !== LEGACY_CONTROLLER_CHATGPT_SESSION_ID) return explicit;
169
+ const bound = input.boundSessionId?.trim();
170
+ if (bound && bound !== LEGACY_CONTROLLER_CHATGPT_SESSION_ID) return bound;
171
+ return stable;
172
+ }
173
+
174
+ interface BrowserQueryMatch {
175
+ text?: string;
176
+ selectorHint?: string;
177
+ }
178
+
179
+ function queryMatches(result: Record<string, unknown> | undefined): BrowserQueryMatch[] {
180
+ if (!result || !Array.isArray(result.matches)) return [];
181
+ return result.matches.filter((value): value is BrowserQueryMatch => Boolean(value) && typeof value === 'object');
182
+ }
183
+
184
+ function matchText(value: BrowserQueryMatch): string {
185
+ return typeof value.text === 'string' ? value.text.trim() : '';
186
+ }
187
+
188
+ function matchSelector(value: BrowserQueryMatch | undefined): string | undefined {
189
+ return typeof value?.selectorHint === 'string' && value.selectorHint.trim() ? value.selectorHint.trim() : undefined;
190
+ }
191
+
192
+ function modelLabelMatches(label: string | undefined, model: string): boolean {
193
+ if (model !== DEFAULT_CHATGPT_AUTOMATION_MODEL || !label) return false;
194
+ const normalized = label.toLowerCase().replace(/\s+/g, '');
195
+ return normalized.includes('5.6sol') || normalized.includes('gpt-5.6sol');
196
+ }
197
+
198
+ function reasoningSliderValue(reasoning: ChatgptAutomationReasoning): number {
199
+ if (reasoning === 'medium') return 2;
200
+ if (reasoning === 'high') return 3;
201
+ return 4;
202
+ }
203
+
204
+ async function findChatgptIntelligenceControl(
205
+ controllerHome: string,
206
+ workId: string,
207
+ browserSessionId: string,
208
+ timeoutMs?: number,
209
+ ): Promise<BrowserQueryMatch | undefined> {
210
+ const result = await controllerBrowserAction(controllerHome, workId, 'query_all', {
211
+ session_id: browserSessionId,
212
+ selector: CHATGPT_INTELLIGENCE_CONTROL_SELECTOR,
213
+ limit: 50,
214
+ timeout_ms: timeoutMs ?? 60_000,
215
+ }, timeoutMs);
216
+ return queryMatches(result).find((match) => /(?:gpt-)?5\.6\s*sol/i.test(matchText(match)));
217
+ }
218
+
219
+ async function waitForChatgptIntelligenceControl(
220
+ controllerHome: string,
221
+ workId: string,
222
+ browserSessionId: string,
223
+ timeoutMs?: number,
224
+ ): Promise<BrowserQueryMatch | undefined> {
225
+ const waitBudgetMs = Math.min(Math.max(timeoutMs ?? 12_000, 1_000), 12_000);
226
+ const deadline = Date.now() + waitBudgetMs;
227
+ do {
228
+ const control = await findChatgptIntelligenceControl(controllerHome, workId, browserSessionId, Math.min(waitBudgetMs, 5_000));
229
+ if (control) return control;
230
+ if (Date.now() >= deadline) break;
231
+ await new Promise((resolveWait) => setTimeout(resolveWait, 500));
232
+ } while (Date.now() < deadline);
233
+ return undefined;
234
+ }
235
+
236
+ async function ensureChatgptExecutionPreference(
237
+ controllerHome: string,
238
+ workId: string,
239
+ browserSessionId: string,
240
+ model: string,
241
+ reasoning: ChatgptAutomationReasoning,
242
+ timeoutMs?: number,
243
+ ): Promise<boolean> {
244
+ if (model !== DEFAULT_CHATGPT_AUTOMATION_MODEL) throw new Error(`CHATGPT_AUTOMATION_MODEL_UNSUPPORTED:${model}`);
245
+ let control = await waitForChatgptIntelligenceControl(controllerHome, workId, browserSessionId, timeoutMs);
246
+ if (!control) throw new Error('CHATGPT_AUTOMATION_INTELLIGENCE_CONTROL_UNAVAILABLE');
247
+ if (!modelLabelMatches(matchText(control), model)) throw new Error(`CHATGPT_AUTOMATION_MODEL_NOT_VERIFIED:${model}`);
248
+ let controlSelector = matchSelector(control);
249
+ if (!controlSelector) throw new Error('CHATGPT_AUTOMATION_INTELLIGENCE_CONTROL_UNAVAILABLE');
250
+
251
+ const expanded = await controllerBrowserAction(controllerHome, workId, 'get_attribute', {
252
+ session_id: browserSessionId,
253
+ selector: controlSelector,
254
+ attribute: 'aria-expanded',
255
+ timeout_ms: timeoutMs ?? 60_000,
256
+ }, timeoutMs).catch(() => undefined);
257
+ if (stringField(expanded?.value) !== 'true') {
258
+ await controllerBrowserAction(controllerHome, workId, 'press', {
259
+ session_id: browserSessionId,
260
+ selector: controlSelector,
261
+ key: 'ArrowDown',
262
+ timeout_ms: timeoutMs ?? 60_000,
263
+ post_action_wait_ms: 150,
264
+ }, timeoutMs);
265
+ }
266
+
267
+ const currentValueResult = await controllerBrowserAction(controllerHome, workId, 'get_attribute', {
268
+ session_id: browserSessionId,
269
+ selector: CHATGPT_CAPABILITY_SLIDER_SELECTOR,
270
+ attribute: 'aria-valuenow',
271
+ timeout_ms: timeoutMs ?? 60_000,
272
+ }, timeoutMs).catch(() => undefined);
273
+ const currentValue = Number(stringField(currentValueResult?.value));
274
+ const targetValue = reasoningSliderValue(reasoning);
275
+ if (!Number.isInteger(currentValue) || currentValue < 0 || currentValue > 4) {
276
+ throw new Error(`CHATGPT_AUTOMATION_REASONING_STATE_UNAVAILABLE:${reasoning}`);
277
+ }
278
+ const direction = targetValue > currentValue ? 'ArrowRight' : 'ArrowLeft';
279
+ for (let index = 0; index < Math.abs(targetValue - currentValue); index += 1) {
280
+ await controllerBrowserAction(controllerHome, workId, 'press', {
281
+ session_id: browserSessionId,
282
+ selector: CHATGPT_CAPABILITY_MENUITEM_SELECTOR,
283
+ key: direction,
284
+ timeout_ms: timeoutMs ?? 60_000,
285
+ post_action_wait_ms: 120,
286
+ }, timeoutMs);
287
+ }
288
+
289
+ const verifiedValueResult = await controllerBrowserAction(controllerHome, workId, 'get_attribute', {
290
+ session_id: browserSessionId,
291
+ selector: CHATGPT_CAPABILITY_SLIDER_SELECTOR,
292
+ attribute: 'aria-valuenow',
293
+ timeout_ms: timeoutMs ?? 60_000,
294
+ }, timeoutMs).catch(() => undefined);
295
+ if (Number(stringField(verifiedValueResult?.value)) !== targetValue) {
296
+ throw new Error(`CHATGPT_AUTOMATION_REASONING_NOT_VERIFIED:${reasoning}`);
297
+ }
298
+ control = await findChatgptIntelligenceControl(controllerHome, workId, browserSessionId, timeoutMs) ?? control;
299
+ if (!modelLabelMatches(matchText(control), model)) throw new Error(`CHATGPT_AUTOMATION_MODEL_NOT_VERIFIED:${model}`);
300
+ controlSelector = matchSelector(control) ?? controlSelector;
301
+ await controllerBrowserAction(controllerHome, workId, 'press', {
302
+ session_id: browserSessionId,
303
+ selector: controlSelector,
304
+ key: 'Escape',
305
+ timeout_ms: timeoutMs ?? 60_000,
306
+ post_action_wait_ms: 100,
307
+ }, timeoutMs).catch(() => undefined);
308
+ return true;
309
+ }
310
+
90
311
  function resultUrl(result: Record<string, unknown>): string | undefined {
91
312
  return stringField(result.url)
92
313
  ?? stringField((result.session as Record<string, unknown> | undefined)?.url);
93
314
  }
94
315
 
316
+ async function submitChatgptPrompt(
317
+ controllerHome: string,
318
+ workId: string,
319
+ browserSessionId: string,
320
+ prompt: string,
321
+ targetUrl: string,
322
+ timeoutMs?: number,
323
+ ): Promise<string> {
324
+ await controllerBrowserAction(controllerHome, workId, 'fill', {
325
+ session_id: browserSessionId,
326
+ selector: CHATGPT_PROMPT_SELECTOR,
327
+ text: withForgePluginMention(prompt),
328
+ timeout_ms: timeoutMs ?? 60_000,
329
+ post_action_wait_ms: 100,
330
+ }, timeoutMs);
331
+ const sent = await controllerBrowserAction(controllerHome, workId, 'click', {
332
+ session_id: browserSessionId,
333
+ selector: CHATGPT_SEND_SELECTOR,
334
+ timeout_ms: timeoutMs ?? 60_000,
335
+ post_action_wait_ms: 1_000,
336
+ }, timeoutMs);
337
+ let observedUrl = resultUrl(sent) ?? targetUrl;
338
+ if (/\/c\/[^/?#]+/.test(observedUrl)) return observedUrl;
339
+ const deadline = Date.now() + Math.min(Math.max(timeoutMs ?? 8_000, 2_000), 8_000);
340
+ do {
341
+ const observed = await controllerBrowserAction(controllerHome, workId, 'query_all', {
342
+ session_id: browserSessionId,
343
+ selector: 'main',
344
+ limit: 1,
345
+ timeout_ms: Math.min(timeoutMs ?? 3_000, 3_000),
346
+ }, timeoutMs).catch(() => undefined);
347
+ observedUrl = observed ? resultUrl(observed) ?? observedUrl : observedUrl;
348
+ if (/\/c\/[^/?#]+/.test(observedUrl)) return observedUrl;
349
+ await new Promise((resolveWait) => setTimeout(resolveWait, 250));
350
+ } while (Date.now() < deadline);
351
+ throw new Error(`CHATGPT_AUTOMATION_SUBMISSION_NOT_CONFIRMED:${observedUrl}`);
352
+ }
353
+
354
+ export function withForgePluginMention(prompt: string): string {
355
+ const value = prompt.trim();
356
+ if (!value) throw new Error('CHATGPT_AUTOMATION_PROMPT_REQUIRED');
357
+ if (/^@forge(?:\s|$)/i.test(value)) return value;
358
+ return `${DEFAULT_CHATGPT_AUTOMATION_PLUGIN_MENTION} ${value}`;
359
+ }
360
+
361
+ export async function runScheduledChatgptPrompt(input: ScheduledChatgptPromptInput): Promise<ScheduledChatgptPromptResult> {
362
+ const model = normalizeModel(input.model);
363
+ const reasoning = normalizeReasoning(input.reasoning);
364
+ const tabPolicy = normalizeTabPolicy(input.tabPolicy);
365
+ const stableSessionId = stableChatgptAutomationBrowserSessionId(input.repoId, input.automationId);
366
+ const explicitSessionId = input.browserSessionId?.trim();
367
+ const sessionId = tabPolicy === 'new'
368
+ ? `${stableSessionId}-${randomUUID().slice(0, 8)}`
369
+ : explicitSessionId || stableSessionId;
370
+ const targetUrl = tabPolicy === 'new'
371
+ ? 'https://chatgpt.com/'
372
+ : input.conversationUrl?.trim() || 'https://chatgpt.com/';
373
+ let reusedSession = false;
374
+ try {
375
+ await ensureControllerChatgptBrowser(input.controllerHome, input.automationId);
376
+ if (tabPolicy !== 'new') {
377
+ try {
378
+ await controllerBrowserAction(input.controllerHome, input.automationId, 'get_text', {
379
+ session_id: sessionId,
380
+ selector: 'body',
381
+ max_chars: 1,
382
+ timeout_ms: Math.min(input.timeoutMs ?? 60_000, 3_000),
383
+ }, input.timeoutMs);
384
+ reusedSession = true;
385
+ } catch {
386
+ reusedSession = false;
387
+ }
388
+ }
389
+ if (!reusedSession) {
390
+ await controllerBrowserAction(input.controllerHome, input.automationId, 'open_page', {
391
+ session_id: sessionId,
392
+ url: targetUrl,
393
+ wait_until: 'domcontentloaded',
394
+ timeout_ms: input.timeoutMs ?? 60_000,
395
+ retries: 1,
396
+ }, input.timeoutMs);
397
+ }
398
+ const executionPreferenceVerified = await ensureChatgptExecutionPreference(
399
+ input.controllerHome,
400
+ input.automationId,
401
+ sessionId,
402
+ model,
403
+ reasoning,
404
+ input.timeoutMs,
405
+ );
406
+ const observedUrl = await submitChatgptPrompt(input.controllerHome, input.automationId, sessionId, input.prompt, targetUrl, input.timeoutMs);
407
+ return {
408
+ status: 'dispatched',
409
+ provider: 'controller-browser',
410
+ browserSessionId: sessionId,
411
+ conversationUrl: observedUrl,
412
+ model,
413
+ reasoning,
414
+ tabPolicy,
415
+ executionPreferenceVerified,
416
+ reusedSession,
417
+ };
418
+ } catch (error) {
419
+ return {
420
+ status: 'failed',
421
+ provider: 'controller-browser',
422
+ browserSessionId: sessionId,
423
+ model,
424
+ reasoning,
425
+ tabPolicy,
426
+ executionPreferenceVerified: false,
427
+ reusedSession,
428
+ error: {
429
+ code: error instanceof Error && error.message.includes(':') ? error.message.split(':', 1)[0] : 'CHATGPT_SCHEDULED_PROMPT_FAILED',
430
+ message: error instanceof Error ? error.message : String(error),
431
+ },
432
+ };
433
+ }
434
+ }
435
+
95
436
  /**
96
437
  * Dispatches a bounded resume prompt to one controller-owned ChatGPT Web tab.
97
438
  * Chat history is transport context only. Forge Work/Plan/evidence remain authoritative.
@@ -100,7 +441,16 @@ export async function runWorkChatgptContinuation(input: WorkChatgptContinuationI
100
441
  const store = { controllerHome: input.controllerHome, repoId: input.repoId };
101
442
  const existing = getChatgptWorkConversationBinding(store, input.workId);
102
443
  const seedUrl = input.conversationUrl?.trim() || existing?.conversationUrl;
103
- const sessionId = input.browserSessionId?.trim() || CONTROLLER_CHATGPT_SESSION_ID;
444
+ const model = normalizeModel(input.model);
445
+ const reasoning = normalizeReasoning(input.reasoning);
446
+ const tabPolicy = normalizeTabPolicy(input.tabPolicy);
447
+ const sessionId = resolveChatgptWorkBrowserSessionId({
448
+ repoId: input.repoId,
449
+ workId: input.workId,
450
+ tabPolicy,
451
+ explicitSessionId: input.browserSessionId,
452
+ boundSessionId: existing?.latestBrowserSessionId,
453
+ });
104
454
  let binding: ChatgptWorkConversationBinding | undefined = existing;
105
455
 
106
456
  try {
@@ -125,21 +475,15 @@ export async function runWorkChatgptContinuation(input: WorkChatgptContinuationI
125
475
  timeout_ms: input.timeoutMs ?? 60_000,
126
476
  retries: 1,
127
477
  }, input.timeoutMs);
128
- await controllerBrowserAction(input.controllerHome, input.workId, 'fill', {
129
- session_id: sessionId,
130
- selector: CHATGPT_PROMPT_SELECTOR,
131
- text: input.prompt,
132
- timeout_ms: input.timeoutMs ?? 60_000,
133
- post_action_wait_ms: 100,
134
- }, input.timeoutMs);
135
- const sent = await controllerBrowserAction(input.controllerHome, input.workId, 'press', {
136
- session_id: sessionId,
137
- selector: CHATGPT_PROMPT_SELECTOR,
138
- key: CHATGPT_SEND_KEY,
139
- timeout_ms: input.timeoutMs ?? 60_000,
140
- post_action_wait_ms: 1_500,
141
- }, input.timeoutMs);
142
- const observedUrl = resultUrl(sent) ?? targetUrl;
478
+ const executionPreferenceVerified = await ensureChatgptExecutionPreference(
479
+ input.controllerHome,
480
+ input.workId,
481
+ sessionId,
482
+ model,
483
+ reasoning,
484
+ input.timeoutMs,
485
+ );
486
+ const observedUrl = await submitChatgptPrompt(input.controllerHome, input.workId, sessionId, `${workflowToolAttributionInstruction(input.workId)}\n\n${input.prompt}`, targetUrl, input.timeoutMs);
143
487
  if (/\/c\/[^/?#]+/.test(observedUrl)) {
144
488
  binding = bindChatgptWorkConversation(store, {
145
489
  workId: input.workId,
@@ -156,6 +500,10 @@ export async function runWorkChatgptContinuation(input: WorkChatgptContinuationI
156
500
  conversationId: binding?.conversationId,
157
501
  localAlias: binding?.localAlias,
158
502
  resumedFromBinding: Boolean(existing),
503
+ model,
504
+ reasoning,
505
+ tabPolicy,
506
+ executionPreferenceVerified,
159
507
  };
160
508
  } catch (error) {
161
509
  return {
@@ -166,6 +514,10 @@ export async function runWorkChatgptContinuation(input: WorkChatgptContinuationI
166
514
  conversationId: binding?.conversationId,
167
515
  localAlias: binding?.localAlias,
168
516
  resumedFromBinding: Boolean(existing),
517
+ model,
518
+ reasoning,
519
+ tabPolicy,
520
+ executionPreferenceVerified: false,
169
521
  error: {
170
522
  code: error instanceof Error && error.message.includes(':') ? error.message.split(':', 1)[0] : 'CHATGPT_CONTROLLER_BROWSER_FAILED',
171
523
  message: error instanceof Error ? error.message : String(error),
@@ -20,6 +20,7 @@ export interface Requirement {
20
20
  outcomeStatement: string;
21
21
  acceptanceCriteria: string[];
22
22
  requiredDeliveryReferences: string[];
23
+ /** @deprecated Migration compatibility pointer only. Current Plan.requirementId is the relationship authority. */
23
24
  activePlanId?: string;
24
25
  state: RequirementState;
25
26
  needsAttention: boolean;
@@ -124,7 +125,7 @@ export function createRequirement(
124
125
  const ALLOWED_TRANSITIONS: Readonly<Record<RequirementState, readonly RequirementState[]>> = {
125
126
  planned: ['planned', 'active', 'waiting_for_user', 'cancelled'],
126
127
  active: ['active', 'waiting_for_user', 'done', 'cancelled'],
127
- waiting_for_user: ['waiting_for_user', 'planned', 'active', 'cancelled'],
128
+ waiting_for_user: ['waiting_for_user', 'planned', 'active', 'done', 'cancelled'],
128
129
  done: ['done', 'cancelled'],
129
130
  cancelled: ['cancelled'],
130
131
  };
@@ -164,6 +165,7 @@ export function updateRequirement(
164
165
  });
165
166
  }
166
167
 
168
+ /** @deprecated Migration/legacy compatibility only. New Plan creation must bind Plan.requirementId and derive active Plan slices by query. */
167
169
  export function setRequirementPlan(
168
170
  options: RequirementStoreOptions,
169
171
  input: { requirementId: string; planId: string; action?: string },
@@ -189,12 +191,13 @@ export interface RequirementCompletionInput {
189
191
  }
190
192
 
191
193
  /**
192
- * Complete a Requirement only from the Work-owned completion record.
194
+ * Project machine-complete Work delivery into a Requirement without asserting
195
+ * semantic acceptance. The Requirement waits for an explicit ChatGPT/user
196
+ * decision; a previously audited `done` Requirement is never reopened by
197
+ * historical execution evidence.
193
198
  *
194
- * This intentionally short-circuits an already audited `done` Requirement
195
- * before inspecting historical execution facts. A cancelled Run, missing
196
- * legacy Task receipt, or stale Work projection therefore cannot reopen or
197
- * rewrite a completed user outcome.
199
+ * The legacy function name is retained for compatibility with completion
200
+ * callers. Its authority is evidence projection only, not semantic completion.
198
201
  */
199
202
  export function completeRequirementFromWork(
200
203
  options: RequirementStoreOptions,
@@ -221,19 +224,20 @@ export function completeRequirementFromWork(
221
224
  if (receipt.delivery.kind === 'superseded' || receipt.delivery.status !== 'integrated' || !receipt.delivery.reachable) throw new Error('REQUIREMENT_WORK_DELIVERY_NOT_PROVEN');
222
225
  if (!['complete', 'maintenance_warning'].includes(receipt.cleanup.status) || receipt.cleanup.blockers.length > 0) throw new Error('REQUIREMENT_WORK_CLEANUP_NOT_PROVEN');
223
226
 
227
+ if (current.value.state === 'waiting_for_user' && current.value.auditRefs.includes(receipt.receiptId)) return current.value;
224
228
  return updateRequirement(options, {
225
229
  requirementId,
226
- action: 'requirement_completed_from_work_receipt',
230
+ action: 'requirement_delivery_evidence_recorded',
227
231
  mutate: (latest) => {
228
- // Preserve a concurrent audited completion; stale execution evidence is
229
- // never allowed to move a terminal Requirement backwards.
232
+ // Preserve a concurrent explicit semantic completion; stale execution
233
+ // evidence is never allowed to move a terminal Requirement backwards.
230
234
  if (latest.state === 'done') return latest;
231
235
  if (latest.state === 'cancelled') throw new Error('REQUIREMENT_CANCELLED');
232
236
  return {
233
237
  ...latest,
234
- state: 'done',
238
+ state: 'waiting_for_user',
235
239
  needsAttention: false,
236
- attentionSummary: undefined,
240
+ attentionSummary: `Work ${work.workId} delivered machine-valid evidence (${receipt.receiptId}); ChatGPT/user semantic acceptance is still required.`,
237
241
  auditRefs: Array.from(new Set([...latest.auditRefs, receipt.receiptId])).slice(-50),
238
242
  };
239
243
  },
@@ -7,6 +7,10 @@ export interface McpTimingTrace {
7
7
  traceId?: string;
8
8
  requestId?: string;
9
9
  rpcId?: string | number;
10
+ /** Public Connector ingress versus the authoritative loopback Runtime span. */
11
+ layer?: 'public_gateway' | 'canonical_runtime';
12
+ /** Wall-clock start for joining nested spans and later client-side timing when available. */
13
+ startedAt?: string;
10
14
  outcome?: 'ok' | 'error' | 'exception';
11
15
  errorCode?: string;
12
16
  sessionResolutionMs?: number;
@@ -21,6 +25,8 @@ export interface McpTimingTrace {
21
25
  sessionId?: string;
22
26
  repoId?: string;
23
27
  workId?: string;
28
+ processId?: string;
29
+ route?: string;
24
30
  }
25
31
 
26
32
  export interface McpIncident {
@@ -64,16 +64,7 @@ export function processCheckSemanticScopeKey(
64
64
  input: Pick<RunCheckFacadeInput, 'workId' | 'verificationBinding' | 'checkoutId'>,
65
65
  reuseScope: 'repository' | 'checkout',
66
66
  ): string {
67
- const base = reuseScope === 'repository' ? 'repository' : `checkout:${input.checkoutId ?? 'unknown'}`;
68
- const binding = input.verificationBinding;
69
- const owner = [
70
- input.workId ? `work:${input.workId}` : undefined,
71
- binding?.executionSessionId ? `execution:${binding.executionSessionId}` : undefined,
72
- binding?.editSessionId ? `edit:${binding.editSessionId}:r${binding.editRevision ?? 'unknown'}` : undefined,
73
- binding?.issueId ? `issue:${binding.issueId}` : undefined,
74
- binding?.taskId ? `task:${binding.taskId}` : undefined,
75
- ].filter(Boolean).join('|') || 'unbound';
76
- return `${base}|owner:${owner}`;
67
+ return reuseScope === 'repository' ? 'repository' : `checkout:${input.checkoutId ?? 'unknown'}`;
77
68
  }
78
69
 
79
70
  export interface RunCheckFacadeResult {
@@ -18,10 +18,18 @@ export interface PersistedCheckResultReceipt {
18
18
  cacheHit?: boolean;
19
19
  }
20
20
 
21
- export function allocatePersistedCheckResultReceiptPath(controllerHome: string, repoId: string): string {
21
+ export function allocatePersistedCheckResultReceiptPath(
22
+ controllerHome: string,
23
+ repoId: string,
24
+ requestId?: string,
25
+ ): string {
22
26
  const root = join(processLogDir(controllerHome, repoId), 'check-results');
23
27
  mkdirSync(root, { recursive: true });
24
- return join(root, `${randomUUID()}.json`);
28
+ const stableRequestId = requestId?.trim();
29
+ const receiptId = stableRequestId
30
+ ? `request-${createHash('sha256').update(stableRequestId).digest('hex').slice(0, 32)}`
31
+ : randomUUID();
32
+ return join(root, `${receiptId}.json`);
25
33
  }
26
34
 
27
35
  export function writePersistedCheckResultReceipt(path: string, input: Omit<PersistedCheckResultReceipt, 'schemaVersion' | 'receiptId'>): PersistedCheckResultReceipt {
@@ -273,6 +273,7 @@ export async function executeRepositoryCommandViaProcessRuntime(
273
273
  executionIdentity.repositoryId,
274
274
  executionIdentity.checkoutId,
275
275
  input.repository.defaultBranch,
276
+ input.repository.canonicalRoot,
276
277
  ),
277
278
  executionIdentity.repositoryId,
278
279
  executionIdentity.checkoutId,