@moretea-labs/forge 1.5.0 → 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 (138) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/DESIGN.md +7 -2
  3. package/README.md +7 -7
  4. package/README.zh-CN.md +5 -3
  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/forge-chatgpt-mcp-setup.md +27 -3
  22. package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
  23. package/docs/operations/controller-browser-plugin.md +12 -16
  24. package/docs/operations/features.md +1 -1
  25. package/docs/operations/homebrew.md +2 -2
  26. package/docs/public-usage-guide.md +1 -1
  27. package/docs/public-usage-guide.zh-CN.md +1 -1
  28. package/docs/tutorials/01-install-and-start.md +6 -4
  29. package/docs/tutorials/01-install-and-start.zh-CN.md +3 -1
  30. package/docs/tutorials/02-connect-chatgpt.md +8 -2
  31. package/docs/tutorials/02-connect-chatgpt.zh-CN.md +8 -2
  32. package/docs/wiki/Installation.md +1 -1
  33. package/docs/wiki/Quick-Start.md +1 -1
  34. package/package.json +1 -1
  35. package/scripts/check-public-docs.sh +14 -1
  36. package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
  37. package/scripts/sprint-backlog.sh +1 -1
  38. package/src/cli/commands/chatgpt.ts +31 -2
  39. package/src/cli/commands/init-hook.ts +3 -1
  40. package/src/cli/commands/mcp.ts +2 -0
  41. package/src/cli/commands/plugin.ts +36 -9
  42. package/src/cli/commands/runtime.ts +1 -0
  43. package/src/cli/commands/setup-profile.ts +49 -18
  44. package/src/cli/controller/context-pack.ts +158 -6
  45. package/src/cli/hook/prompt-guard-decision.ts +17 -18
  46. package/src/cli/hook/prompt-intents.ts +1 -1
  47. package/src/cli/local-bridge/console-assets.ts +17 -0
  48. package/src/cli/local-bridge/console-automations.ts +205 -23
  49. package/src/cli/local-bridge/console-view-models.ts +2 -0
  50. package/src/cli/local-bridge/facade-api.ts +2 -0
  51. package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
  52. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
  53. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
  54. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
  55. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
  56. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
  57. package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
  58. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
  59. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
  60. package/src/cli/local-bridge/frontend/src/styles.css +27 -11
  61. package/src/cli/local-bridge/frontend/src/types.ts +6 -2
  62. package/src/cli/local-bridge/server.ts +31 -20
  63. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  64. package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
  65. package/src/cli/mcp/auth.ts +2 -0
  66. package/src/cli/mcp/paths.ts +8 -2
  67. package/src/cli/mcp/repository-tools.ts +124 -9
  68. package/src/cli/mcp/server.ts +164 -19
  69. package/src/cli/mcp/setup.ts +9 -1
  70. package/src/cli/mcp/transports/http.ts +52 -2
  71. package/src/cli/mcp/transports/session-registry.ts +24 -0
  72. package/src/cli/repositories/command-classifier.ts +113 -0
  73. package/src/cli/repositories/safe-patch.ts +6 -4
  74. package/src/cli/repositories/structured-git.ts +49 -7
  75. package/src/cli/repository/inspector.ts +16 -3
  76. package/src/runtime/assistant/intent.ts +3 -3
  77. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
  78. package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
  79. package/src/runtime/control-plane/execution/validation.ts +15 -2
  80. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
  81. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
  82. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
  83. package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
  84. package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
  85. package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
  86. package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
  87. package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
  88. package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
  89. package/src/runtime/control-plane/facade/types.ts +33 -6
  90. package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
  91. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
  92. package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
  93. package/src/runtime/diagnostics/mcp-timing.ts +6 -0
  94. package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
  95. package/src/runtime/execution/process-runtime/check-result.ts +10 -2
  96. package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
  97. package/src/runtime/execution/process-runtime/gc.ts +64 -10
  98. package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
  99. package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
  100. package/src/runtime/execution/process-runtime/runtime.ts +16 -1
  101. package/src/runtime/execution/process-runtime/store.ts +6 -0
  102. package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
  103. package/src/runtime/gateway/mcp/router.ts +1 -1
  104. package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
  105. package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
  106. package/src/runtime/plugins/browser-adapter.ts +19 -72
  107. package/src/runtime/plugins/browser-handoff-host.ts +6 -31
  108. package/src/runtime/plugins/browser-handoff.ts +0 -3
  109. package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
  110. package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
  111. package/src/runtime/plugins/external-registration.ts +84 -5
  112. package/src/runtime/plugins/first-party-registry.ts +2 -2
  113. package/src/runtime/plugins/gmail-adapter.ts +28 -2
  114. package/src/runtime/plugins/local-system-adapter.ts +2 -2
  115. package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
  116. package/src/runtime/plugins/store.ts +107 -5
  117. package/src/runtime/plugins/types.ts +4 -0
  118. package/src/runtime/recovery/actions.ts +0 -10
  119. package/src/runtime/recovery/classifier.ts +0 -9
  120. package/src/runtime/recovery/maintenance-executor.ts +43 -11
  121. package/src/runtime/recovery/probe.ts +0 -1
  122. package/src/runtime/recovery/types.ts +0 -1
  123. package/src/runtime/root/package-connector-service.ts +136 -0
  124. package/src/runtime/root/package-runtime-service.ts +25 -12
  125. package/src/runtime/root/release-materialize.ts +161 -18
  126. package/src/runtime/root/service.ts +6 -4
  127. package/src/runtime/safe-tooling/index.ts +0 -1
  128. package/src/runtime/safe-tooling/types.ts +0 -30
  129. package/src/runtime/standalone-recovery/core.ts +2 -2
  130. package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
  131. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
  132. package/src/runtime/workflow/schedules/engine.ts +357 -64
  133. package/src/runtime/workflow/schedules/types.ts +5 -0
  134. package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
  135. package/bin/forge-desktop-helper.mjs +0 -204
  136. package/docs/operations/controller-desktop-plugin.md +0 -112
  137. package/src/runtime/plugins/desktop-adapter.ts +0 -374
  138. package/src/runtime/safe-tooling/browser-safe.ts +0 -133
@@ -21,6 +21,8 @@ export interface McpLocalConfig {
21
21
  chatgpt?: {
22
22
  serverName?: string;
23
23
  endpoint?: string;
24
+ /** Loopback OAuth MCP endpoint used by ChatGPT tunnels/connectors. */
25
+ localEndpoint?: string;
24
26
  };
25
27
  profile?: string;
26
28
  toolset?: 'core' | 'advanced' | 'full';
@@ -93,7 +93,13 @@ function nearestExistingPath(path: string): string | undefined {
93
93
  return current;
94
94
  }
95
95
 
96
- export function resolveMcpPath(repoRoot: string, inputPath: string, policy: McpPolicy, intent: McpPathIntent): McpPathDecision {
96
+ export function resolveMcpPath(
97
+ repoRoot: string,
98
+ inputPath: string,
99
+ policy: McpPolicy,
100
+ intent: McpPathIntent,
101
+ canonicalRepoRoot?: string,
102
+ ): McpPathDecision {
97
103
  const normalized = normalizeMcpRelativePath(inputPath);
98
104
  if (!normalized.ok || !normalized.relativePath) return normalized;
99
105
 
@@ -107,7 +113,7 @@ export function resolveMcpPath(repoRoot: string, inputPath: string, policy: McpP
107
113
  return { ok: false, relativePath, reason: `path is not allowed for ${intent}: ${relativePath}` };
108
114
  }
109
115
 
110
- const repoRealpath = realpathSync(repoRoot);
116
+ const repoRealpath = canonicalRepoRoot ?? realpathSync(repoRoot);
111
117
  const absolutePath = resolve(repoRealpath, relativePath);
112
118
  if (intent === 'read' && !existsSync(absolutePath)) {
113
119
  return { ok: false, relativePath, reason: `path does not exist: ${relativePath}` };
@@ -2,6 +2,10 @@ import { bindRepositoryEntities } from '../repositories/entity-migration';
2
2
  import { bootstrapLocalProject, diagnoseLatestLocalProjectSource } from '../repositories/local-project-onboarding';
3
3
  import { resolveEphemeralWorkspaceTarget } from '../repositories/ephemeral-workspace';
4
4
  import type { ResolvedExecutionIdentity } from '../../runtime/control-plane/execution/execution-identity';
5
+ import { readExecutionSession } from '../../runtime/control-plane/execution/session-store';
6
+ import { getWorkContract } from '../../runtime/control-plane/facade/work-contract-store';
7
+ import { getControllerSession, listControllerSessions } from '../../runtime/control-plane/facade/controller-session-store';
8
+ import { isTerminalWorkContractStatus } from '../../runtime/control-plane/facade/types';
5
9
  import { executeRepositoryCommand, previewRepositoryCommandExecution } from '../repositories/command-executor';
6
10
  import { withControllerLock } from '../repositories/locks';
7
11
  import {
@@ -9,6 +13,7 @@ import {
9
13
  findIdenticalRepositoryRegistration,
10
14
  getRepository,
11
15
  listRepositories,
16
+ reconcileRepositoryCheckouts,
12
17
  refreshRepository,
13
18
  registerRepository,
14
19
  removeRepository,
@@ -19,7 +24,7 @@ import {
19
24
  } from '../repositories/registry';
20
25
  import { buildControllerWorkbench } from '../repositories/workbench';
21
26
  import { applySafePatch, buildSafePatchPlan } from '../repositories/safe-patch';
22
- import { getEditSessionDiff } from '../editing/edit-session';
27
+ import { getEditSessionDiff, type EditSessionBinding } from '../editing/edit-session';
23
28
  import { buildSyncOperationDigest, classifyUserFacingError } from '../../runtime/control-plane/facade/operation-digest';
24
29
  import { diagnoseRepositoryStuckState, listRepositoryGoalRuns, readRepositoryGoalRegistry, runRepositoryGoal, upsertRepositoryGoal } from '../repositories/goal-registry';
25
30
  import {
@@ -49,6 +54,7 @@ import {
49
54
  executeRepositoryCommandViaProcessRuntime,
50
55
  } from '../../runtime/execution/process-runtime/command-facade';
51
56
  import { assessWorkMode, parseExplicitTaskMode } from '../controller/work-mode';
57
+ import { normalizeRepositoryCommand } from '../repositories/command-normalization';
52
58
  import type { CallToolResult, McpToolDefinition } from './tools';
53
59
  import {
54
60
  boundUtf8,
@@ -240,6 +246,7 @@ export const repositoryToolDefinitions: McpToolDefinition[] = [
240
246
  definition('repository_safe_patch_apply', 'Apply one coherent deterministic edit batch and return bounded review evidence. Checks are opt-in: pass check_ids only when the batch is stable. Long checks return managed Process handles instead of blocking the MCP call; use validation_only with the returned session/request ids to join later without replaying the patch.', {
241
247
  repo_id: repoId,
242
248
  checkout_id: { type: 'string', description: 'Optional checkout identity for repositories with multiple local clones.' },
249
+ work_id: { type: 'string', description: 'Optional durable Work identity. Workflow controllers should pass the exact claimed Work id so attribution survives transient MCP transport sessions.' },
243
250
  session_id: { type: 'string', description: 'Existing edit session id. Required for validation_only; otherwise omit to create one.' },
244
251
  purpose: { type: 'string', description: 'Purpose for a newly created edit session.' },
245
252
  operations: { type: 'array', items: { type: 'object' }, description: 'Edit operations using the same shape as apply_patch. Required unless validation_only=true.' },
@@ -266,6 +273,7 @@ export const repositoryToolDefinitions: McpToolDefinition[] = [
266
273
  definition('repository_command_execute', 'Execute one repository-scoped local command through Full Access, Goal delegation, or a resumable approval request. Legacy preview-token callers remain compatible.', {
267
274
  repo_id: repoId,
268
275
  checkout_id: { type: 'string', description: 'Optional checkout identity for repositories with multiple local clones.' },
276
+ work_id: { type: 'string', description: 'Optional durable Work identity. Workflow controllers should pass the exact claimed Work id so attribution survives transient MCP transport sessions.' },
269
277
  command: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Legacy shell string or typed argv array. Typed argv executes without a shell.' },
270
278
  cwd: { type: 'string', description: 'Optional root-relative working directory.' },
271
279
  workspace_root: { type: 'string', description: 'Absolute existing local directory used as an ephemeral execution root. Mutually exclusive with repo_id/checkout_id; does not register or initialize the directory.' },
@@ -302,10 +310,89 @@ function withResponseMeta(payload: Record<string, unknown>, startedAt: number):
302
310
  return response;
303
311
  }
304
312
 
313
+ export interface RepositoryToolCallerContext {
314
+ sessionId?: string;
315
+ principalId?: string;
316
+ controllerInstanceId?: string;
317
+ }
318
+
319
+ function claimedSessionWorkId(
320
+ controllerHome: string,
321
+ repository: ReturnType<typeof resolveRepositorySelection>,
322
+ caller?: RepositoryToolCallerContext,
323
+ explicitWorkId?: unknown,
324
+ ): string | undefined {
325
+ if (!caller?.principalId?.trim()) return undefined;
326
+ const requestedWorkId = typeof explicitWorkId === 'string' ? explicitWorkId.trim() : '';
327
+ if (requestedWorkId) {
328
+ const work = getWorkContract({ controllerHome, repoId: repository.repoId }, requestedWorkId);
329
+ if (!work || isTerminalWorkContractStatus(work.status)) throw new Error(`WORK_ATTRIBUTION_INVALID: ${requestedWorkId}`);
330
+ if (work.checkoutId && work.checkoutId !== repository.activeCheckoutId) throw new Error(`WORK_CHECKOUT_MISMATCH: ${requestedWorkId}:${repository.activeCheckoutId}`);
331
+ const owner = getControllerSession({ controllerHome, repoId: repository.repoId }, requestedWorkId);
332
+ if (!owner) throw new Error(`WORK_CONTROLLER_CLAIM_REQUIRED: ${requestedWorkId}`);
333
+ if ((owner.principalId?.trim() || owner.controllerId) !== caller.principalId.trim()) throw new Error(`WORK_CONTROLLER_OWNERSHIP_MISMATCH: ${requestedWorkId}`);
334
+ return requestedWorkId;
335
+ }
336
+ if (caller.sessionId?.trim()) {
337
+ const executionSession = readExecutionSession(controllerHome, {
338
+ sessionId: caller.sessionId,
339
+ principalId: caller.principalId,
340
+ controllerInstanceId: caller.controllerInstanceId,
341
+ });
342
+ const workId = executionSession?.activeWorkId?.trim();
343
+ if (workId && executionSession?.activeRepositoryId === repository.repoId && (!executionSession.activeCheckoutId || executionSession.activeCheckoutId === repository.activeCheckoutId)) {
344
+ const work = getWorkContract({ controllerHome, repoId: repository.repoId }, workId);
345
+ const owner = getControllerSession({ controllerHome, repoId: repository.repoId }, workId);
346
+ if (work && !isTerminalWorkContractStatus(work.status) && owner?.sessionId === caller.sessionId && (owner.principalId?.trim() || owner.controllerId) === caller.principalId.trim()) return workId;
347
+ }
348
+ }
349
+ const principal = caller.principalId.trim();
350
+ const candidates = listControllerSessions({ controllerHome, repoId: repository.repoId })
351
+ .filter((owner) => (owner.principalId?.trim() || owner.controllerId) === principal)
352
+ .map((owner) => ({ owner, work: getWorkContract({ controllerHome, repoId: repository.repoId }, owner.workId) }))
353
+ .filter((entry): entry is { owner: ReturnType<typeof listControllerSessions>[number]; work: NonNullable<ReturnType<typeof getWorkContract>> } => Boolean(entry.work && !isTerminalWorkContractStatus(entry.work.status) && (!entry.work.checkoutId || entry.work.checkoutId === repository.activeCheckoutId)));
354
+ if (candidates.length === 1) return candidates[0].work.workId;
355
+ if (candidates.length > 1) throw new Error(`WORK_ATTRIBUTION_AMBIGUOUS: principal ${principal} owns ${candidates.length} active Works on checkout ${repository.activeCheckoutId}`);
356
+ return undefined;
357
+ }
358
+
359
+ function claimedSessionEditBinding(
360
+ controllerHome: string,
361
+ repository: ReturnType<typeof resolveRepositorySelection>,
362
+ caller?: RepositoryToolCallerContext,
363
+ explicitWorkId?: unknown,
364
+ ): EditSessionBinding | undefined {
365
+ const workId = claimedSessionWorkId(controllerHome, repository, caller, explicitWorkId);
366
+ if (!workId || !caller?.principalId?.trim()) return undefined;
367
+ const work = getWorkContract({ controllerHome, repoId: repository.repoId }, workId);
368
+ if (!work) return undefined;
369
+ return {
370
+ workId,
371
+ repoId: repository.repoId,
372
+ checkoutId: repository.activeCheckoutId,
373
+ principalId: caller.principalId.trim(),
374
+ controllerInstanceId: caller.controllerInstanceId,
375
+ routeDecisionFingerprint: work.routeDecisionFingerprint,
376
+ };
377
+ }
378
+
379
+ function activeWorkflowRawDefaultMergeBlocked(repository: ReturnType<typeof resolveRepositorySelection>, workId: string | undefined, command: unknown): boolean {
380
+ if (!workId || !repository.defaultBranch) return false;
381
+ const checkout = repository.checkouts.find((entry) => entry.checkoutId === repository.activeCheckoutId);
382
+ if (checkout?.branch !== repository.defaultBranch) return false;
383
+ const normalized = normalizeRepositoryCommand(command);
384
+ if (normalized.kind === 'argv') {
385
+ const executable = normalized.executable?.split('/').pop();
386
+ return executable === 'git' && normalized.args?.[0] === 'merge';
387
+ }
388
+ return /(?:^|[;&|]\s*)git\s+merge\b/.test(normalized.shellCommand ?? '');
389
+ }
390
+
305
391
  function resolveRepositoryCommandTarget(
306
392
  controllerHome: string,
307
393
  args: Record<string, unknown>,
308
394
  repoIdValue: string,
395
+ caller?: RepositoryToolCallerContext,
309
396
  ): {
310
397
  repository: ReturnType<typeof resolveRepositorySelection>;
311
398
  executionIdentity: ResolvedExecutionIdentity;
@@ -336,6 +423,7 @@ function resolveRepositoryCommandTarget(
336
423
  controllerHome,
337
424
  allowSoleRepository: true,
338
425
  });
426
+ const workId = claimedSessionWorkId(controllerHome, repository, caller, args.work_id);
339
427
  return {
340
428
  repository,
341
429
  executionIdentity: {
@@ -344,6 +432,7 @@ function resolveRepositoryCommandTarget(
344
432
  repositoryId: repository.repoId,
345
433
  checkoutId: repository.activeCheckoutId,
346
434
  canonicalRoot: repository.canonicalRoot,
435
+ ...(workId ? { workId } : {}),
347
436
  },
348
437
  };
349
438
  }
@@ -551,6 +640,18 @@ function emptyRepositoryMigration(repository: ReturnType<typeof registerReposito
551
640
  };
552
641
  }
553
642
 
643
+ function registrationOnlyMetadataRefresh(
644
+ match: ReturnType<typeof findIdenticalRepositoryRegistration>,
645
+ ): boolean {
646
+ if (!match || match.identical || match.reasons.length === 0) return false;
647
+ const metadataReasons = new Set([
648
+ 'remote_identity_changed',
649
+ 'default_branch_changed',
650
+ 'display_name_changed',
651
+ ]);
652
+ return match.reasons.every((reason) => metadataReasons.has(reason));
653
+ }
654
+
554
655
  function registrationOnlyAttachedExistingWorktree(repository: ReturnType<typeof registerRepository>): boolean {
555
656
  const selected = repository.checkouts.find((checkout) => checkout.checkoutId === repository.activeCheckoutId);
556
657
  return selected?.worktree === true
@@ -561,6 +662,7 @@ export async function callRepositoryTool(
561
662
  controllerHome: string,
562
663
  name: string,
563
664
  args: Record<string, unknown>,
665
+ caller?: RepositoryToolCallerContext,
564
666
  ): Promise<RepositoryToolResult | undefined> {
565
667
  if (!name.startsWith('repository_')) return undefined;
566
668
  try {
@@ -587,16 +689,17 @@ export async function callRepositoryTool(
587
689
  startedAt,
588
690
  ));
589
691
  }
692
+ const metadataRefresh = registrationOnlyMetadataRefresh(identical);
590
693
  const repository = registerRepository(registerInput);
591
- // Adding a worktree checkout to an already registered repository changes
592
- // checkout inventory only. Existing Issue/Task entities are already
593
- // bound to this repoId, so re-running historical migration is wasted work
594
- // and can produce misleading cross-repository rebinding diagnostics.
595
- const migration = registrationOnlyAttachedExistingWorktree(repository)
694
+ // Adding a worktree checkout or changing only display/remote/default-branch
695
+ // metadata preserves repository + checkout authority. Existing entities
696
+ // are already bound to the stable repoId, so a historical migration scan
697
+ // would only add latency and stale unresolved diagnostics.
698
+ const migration = registrationOnlyAttachedExistingWorktree(repository) || metadataRefresh
596
699
  ? emptyRepositoryMigration(repository)
597
700
  : bindRepositoryEntities(repository);
598
701
  return result(withResponseMeta(
599
- summarizeRepositoryRegistration(repository, migration, args.detail_level === 'detail'),
702
+ summarizeRepositoryRegistration(repository, migration, args.detail_level === 'detail', metadataRefresh),
600
703
  startedAt,
601
704
  ));
602
705
  }
@@ -623,6 +726,11 @@ export async function callRepositoryTool(
623
726
  case 'repository_list':
624
727
  return result({ repositories: listRepositories(controllerHome, { includeRemoved: args.include_removed === true }).map(repositorySummary) });
625
728
  case 'repository_get': {
729
+ // Repository inspection is also the bounded reconciliation point for
730
+ // checkout lifecycle drift caused by worktrees being removed outside
731
+ // Forge. Reuse the existing registry reconciler so stale worktrees are
732
+ // not exposed as lifecycle=active to automation callers.
733
+ reconcileRepositoryCheckouts(repoIdValue, controllerHome);
626
734
  const repository = getRepository(repoIdValue, controllerHome, { includeRemoved: args.include_removed === true });
627
735
  return result(summarizeRepositoryInspection(repository, args.detail_level === 'detail'));
628
736
  }
@@ -815,6 +923,7 @@ export async function callRepositoryTool(
815
923
  controllerHome,
816
924
  allowSoleRepository: true,
817
925
  });
926
+ const binding = claimedSessionEditBinding(controllerHome, repository, caller, args.work_id);
818
927
  const applied = withControllerLock(
819
928
  controllerHome,
820
929
  { scope: 'repository', repoId: repository.repoId },
@@ -829,6 +938,7 @@ export async function callRepositoryTool(
829
938
  continueOnError: args.continue_on_error,
830
939
  refreshFingerprints: args.refresh_fingerprints,
831
940
  recoverStaleSession: args.recover_stale_session,
941
+ binding,
832
942
  }),
833
943
  60_000,
834
944
  );
@@ -892,7 +1002,7 @@ export async function callRepositoryTool(
892
1002
  return ok ? result(payload) : { ...result(payload), isError: true };
893
1003
  }
894
1004
  case 'repository_command_preview': {
895
- const target = resolveRepositoryCommandTarget(controllerHome, args, repoIdValue);
1005
+ const target = resolveRepositoryCommandTarget(controllerHome, args, repoIdValue, caller);
896
1006
  const { repository } = target;
897
1007
  const execution = withControllerLock(
898
1008
  controllerHome,
@@ -912,8 +1022,11 @@ export async function callRepositoryTool(
912
1022
  });
913
1023
  }
914
1024
  case 'repository_command_execute': {
915
- const target = resolveRepositoryCommandTarget(controllerHome, args, repoIdValue);
1025
+ const target = resolveRepositoryCommandTarget(controllerHome, args, repoIdValue, caller);
916
1026
  const { repository, executionIdentity } = target;
1027
+ if (activeWorkflowRawDefaultMergeBlocked(repository, executionIdentity.workId, args.command)) {
1028
+ throw new Error(`WORK_DELIVERY_REQUIRES_FINALIZE: ${executionIdentity.workId} must pass Work verification and use rh_work finalize before default-branch integration`);
1029
+ }
917
1030
  const timeoutMs = typeof args.timeout_ms === 'number'
918
1031
  ? args.timeout_ms
919
1032
  : typeof args.timeout_ms === 'string'
@@ -972,6 +1085,7 @@ export async function callRepositoryTool(
972
1085
  maxOutputBytes,
973
1086
  returnHandleImmediately,
974
1087
  requestId: typeof args.request_id === 'string' ? args.request_id : undefined,
1088
+ workId: executionIdentity.workId,
975
1089
  executionIdentity,
976
1090
  allowNonGitWorkspace: target.workspace !== undefined,
977
1091
  });
@@ -1111,6 +1225,7 @@ export async function callRepositoryTool(
1111
1225
  timeoutMs,
1112
1226
  maxOutputBytes,
1113
1227
  requestId: typeof args.request_id === 'string' ? args.request_id : undefined,
1228
+ workId: executionIdentity.workId,
1114
1229
  executionIdentity,
1115
1230
  });
1116
1231
  if (processResult.route === 'process_direct') {
@@ -48,6 +48,38 @@ function recordRequestId(args: Record<string, unknown>, rpcId: string | number |
48
48
  return explicit || `mcp-rpc:${rpcId === undefined ? randomUUID() : String(rpcId)}`;
49
49
  }
50
50
 
51
+ function firstTimingString(...values: unknown[]): string | undefined {
52
+ for (const value of values) {
53
+ if (typeof value === 'string' && value.trim()) return value.trim();
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function timingIdentity(value: CallToolResult | undefined): {
59
+ repoId?: string;
60
+ workId?: string;
61
+ processId?: string;
62
+ route?: string;
63
+ } {
64
+ if (!value?.structuredContent || typeof value.structuredContent !== 'object' || Array.isArray(value.structuredContent)) return {};
65
+ const structured = value.structuredContent as Record<string, unknown>;
66
+ const work = structured.work && typeof structured.work === 'object' && !Array.isArray(structured.work)
67
+ ? structured.work as Record<string, unknown>
68
+ : undefined;
69
+ const process = structured.process && typeof structured.process === 'object' && !Array.isArray(structured.process)
70
+ ? structured.process as Record<string, unknown>
71
+ : undefined;
72
+ const resultRef = structured.resultRef && typeof structured.resultRef === 'object' && !Array.isArray(structured.resultRef)
73
+ ? structured.resultRef as Record<string, unknown>
74
+ : undefined;
75
+ return {
76
+ repoId: firstTimingString(structured.repoId, structured.repo_id, work?.repositoryId, work?.repoId),
77
+ workId: firstTimingString(structured.workId, structured.work_id, work?.workId),
78
+ processId: firstTimingString(structured.processId, structured.process_id, process?.processId, resultRef?.processId),
79
+ route: firstTimingString(structured.route, structured.path, structured.mode, process?.route),
80
+ };
81
+ }
82
+
51
83
  function tracedResult(
52
84
  value: CallToolResult | undefined,
53
85
  traceId: string,
@@ -79,15 +111,18 @@ async function traceControllerMcpRequest(
79
111
  name: string,
80
112
  args: Record<string, unknown>,
81
113
  rpcId: string | number | undefined,
82
- handler: () => Promise<CallToolResult>,
114
+ handler: (requestId: string) => Promise<CallToolResult>,
83
115
  ): Promise<CallToolResult> {
116
+ const startedAtWall = new Date().toISOString();
84
117
  const startedAt = performance.now();
85
118
  const traceId = randomUUID();
86
119
  const requestId = recordRequestId(args, rpcId);
87
120
  let outcome: 'ok' | 'error' | 'exception' = 'ok';
88
121
  let errorCode: string | undefined;
122
+ let executionIdentity: ReturnType<typeof timingIdentity> = {};
89
123
  try {
90
- const value = await handler();
124
+ const value = await handler(requestId);
125
+ executionIdentity = timingIdentity(value);
91
126
  if (value?.isError) {
92
127
  outcome = 'error';
93
128
  const error = value.structuredContent && typeof value.structuredContent === 'object'
@@ -133,8 +168,11 @@ async function traceControllerMcpRequest(
133
168
  traceId,
134
169
  requestId,
135
170
  ...(rpcId === undefined ? {} : { rpcId }),
171
+ layer: ctx.runtimeSourceRoot ? 'canonical_runtime' : 'public_gateway',
172
+ startedAt: startedAtWall,
136
173
  outcome,
137
174
  ...(errorCode ? { errorCode } : {}),
175
+ ...executionIdentity,
138
176
  totalToolDurationMs: Math.round((performance.now() - startedAt) * 100) / 100,
139
177
  });
140
178
  }
@@ -215,19 +253,117 @@ export async function readCanonicalRuntimeToolSchema(
215
253
  };
216
254
  }
217
255
 
218
- async function proxyRuntimeToolCall(
219
- ctx: MultiRepositoryMcpToolContext,
220
- name: string,
221
- args: Record<string, unknown>,
222
- ): Promise<CallToolResult> {
223
- return withCanonicalRuntimeClient(ctx, async (client) => {
224
- // The outer Connector session is already bound to the Canonical Runtime
225
- // schema and fenced by the Runtime-published fingerprint before dispatch.
226
- // Re-listing tools here creates a second discovery round trip for every
227
- // tool call without strengthening that fence.
228
- const response = await client.callTool({ name, arguments: args });
229
- return response as unknown as CallToolResult;
230
- });
256
+ interface CanonicalRuntimeProxyIdentity {
257
+ endpoint: URL;
258
+ token: string;
259
+ principalId: string;
260
+ sessionId: string;
261
+ }
262
+
263
+ function canonicalRuntimeProxyIdentity(ctx: MultiRepositoryMcpToolContext): CanonicalRuntimeProxyIdentity {
264
+ return {
265
+ endpoint: canonicalRuntimeEndpoint(ctx),
266
+ token: canonicalRuntimeToken(ctx),
267
+ principalId: ctx.principalId?.trim() ?? '',
268
+ sessionId: ctx.sessionId?.trim() ?? '',
269
+ };
270
+ }
271
+
272
+ function sameCanonicalRuntimeProxyIdentity(
273
+ left: CanonicalRuntimeProxyIdentity,
274
+ right: CanonicalRuntimeProxyIdentity,
275
+ ): boolean {
276
+ return left.endpoint.href === right.endpoint.href
277
+ && left.token === right.token
278
+ && left.principalId === right.principalId
279
+ && left.sessionId === right.sessionId;
280
+ }
281
+
282
+ function createCanonicalRuntimeProxy(ctx: MultiRepositoryMcpToolContext): {
283
+ callTool: (name: string, args: Record<string, unknown>) => Promise<CallToolResult>;
284
+ close: () => Promise<void>;
285
+ } {
286
+ let current: { identity: CanonicalRuntimeProxyIdentity; client: Client } | undefined;
287
+ let connecting: Promise<{ identity: CanonicalRuntimeProxyIdentity; client: Client }> | undefined;
288
+
289
+ const closeCurrent = async (expectedClient?: Client): Promise<void> => {
290
+ if (!current || (expectedClient && current.client !== expectedClient)) return;
291
+ const closing = current.client;
292
+ current = undefined;
293
+ await closing.close().catch(() => undefined);
294
+ };
295
+
296
+ const connect = async (identity: CanonicalRuntimeProxyIdentity): Promise<{ identity: CanonicalRuntimeProxyIdentity; client: Client }> => {
297
+ const abort = new AbortController();
298
+ const timeout = setTimeout(() => abort.abort(new Error('CANONICAL_RUNTIME_TIMEOUT')), CANONICAL_RUNTIME_MCP_TIMEOUT_MS);
299
+ const headers: Record<string, string> = {
300
+ Authorization: `Bearer ${identity.token}`,
301
+ };
302
+ if (identity.principalId) headers['x-forge-forwarded-principal-id'] = identity.principalId;
303
+ if (identity.sessionId) headers['x-forge-forwarded-session-id'] = identity.sessionId;
304
+ const transport = new StreamableHTTPClientTransport(identity.endpoint, {
305
+ requestInit: { headers, signal: abort.signal },
306
+ });
307
+ const client = new Client({ name: 'forge-public-gateway-proxy', version: '1.0.0' });
308
+ try {
309
+ await client.connect(transport);
310
+ return { identity, client };
311
+ } catch (error) {
312
+ await client.close().catch(() => undefined);
313
+ throw error;
314
+ } finally {
315
+ clearTimeout(timeout);
316
+ }
317
+ };
318
+
319
+ const clientForCurrentRuntime = async (): Promise<Client> => {
320
+ const identity = canonicalRuntimeProxyIdentity(ctx);
321
+ if (current && sameCanonicalRuntimeProxyIdentity(current.identity, identity)) return current.client;
322
+
323
+ if (connecting) {
324
+ const pending = await connecting;
325
+ if (sameCanonicalRuntimeProxyIdentity(pending.identity, identity)) return pending.client;
326
+ }
327
+
328
+ await closeCurrent();
329
+ connecting = connect(identity);
330
+ try {
331
+ current = await connecting;
332
+ return current.client;
333
+ } finally {
334
+ connecting = undefined;
335
+ }
336
+ };
337
+
338
+ return {
339
+ async callTool(name, args) {
340
+ const client = await clientForCurrentRuntime();
341
+ try {
342
+ // The outer Connector session is already bound to the Canonical Runtime
343
+ // schema and fenced by the Runtime-published fingerprint before dispatch.
344
+ // Re-listing tools here creates a second discovery round trip for every
345
+ // tool call without strengthening that fence.
346
+ const response = await client.callTool(
347
+ { name, arguments: args },
348
+ undefined,
349
+ { timeout: CANONICAL_RUNTIME_MCP_TIMEOUT_MS },
350
+ );
351
+ return response as unknown as CallToolResult;
352
+ } catch (error) {
353
+ // A Runtime restart, token rotation, or broken HTTP session must never
354
+ // poison later calls. The next request reconnects against fresh identity.
355
+ await closeCurrent(client);
356
+ throw error;
357
+ }
358
+ },
359
+ async close() {
360
+ if (connecting) {
361
+ await connecting.catch(() => undefined);
362
+ connecting = undefined;
363
+ }
364
+ await closeCurrent();
365
+ },
366
+ };
231
367
  }
232
368
 
233
369
  function canonicalRuntimeSchemaMatchesGateway(ctx: MultiRepositoryMcpToolContext): boolean {
@@ -257,6 +393,12 @@ export function createForgeMcpServerFromContext(
257
393
  { name: 'forge-mcp', version: FORGE_VERSION },
258
394
  { capabilities: { tools: { listChanged: true } }, instructions: mcpServerInstructions(baseContext.policy.profile) },
259
395
  );
396
+ const runtimeProxy = isMultiRepositoryContext(baseContext) && !getRuntimeWriteClaim()
397
+ ? createCanonicalRuntimeProxy(baseContext)
398
+ : undefined;
399
+ server.onclose = () => {
400
+ void runtimeProxy?.close();
401
+ };
260
402
  // The connector process can outlive the canonical Runtime release it proxies.
261
403
  // Refresh tools after every MCP session initialization so clients do not keep
262
404
  // a stale schema when the bounded Runtime surface changes behind the gateway.
@@ -279,7 +421,7 @@ export function createForgeMcpServerFromContext(
279
421
  const ctx: ServerToolContext = { ...baseContext, signal: extra.signal };
280
422
  if (isMultiRepositoryContext(ctx)) {
281
423
  const rpcId = (request as unknown as { id?: unknown }).id;
282
- return traceControllerMcpRequest(ctx, name, args, typeof rpcId === 'string' || typeof rpcId === 'number' ? rpcId : undefined, async () => {
424
+ return traceControllerMcpRequest(ctx, name, args, typeof rpcId === 'string' || typeof rpcId === 'number' ? rpcId : undefined, async (requestId) => {
283
425
  // The public Gateway only exposes its stable facade schema. It may
284
426
  // proxy execution, but it never discovers one Runtime schema and
285
427
  // validates calls against another source checkout.
@@ -304,8 +446,11 @@ export function createForgeMcpServerFromContext(
304
446
  // Gateway processes from acquiring Process Runtime leases or evaluating
305
447
  // Runtime source coherence against their own checkout.
306
448
  if (!getRuntimeWriteClaim()) {
307
- if (runtimeSchema) return proxyRuntimeToolCall(ctx, name, args);
308
- if (observeRuntimeStatus(ctx.controllerHome).ready) return proxyRuntimeToolCall(ctx, name, args);
449
+ const forwardedArgs = typeof args.request_id === 'string' && args.request_id.trim()
450
+ ? args
451
+ : { ...args, request_id: requestId };
452
+ if (runtimeProxy && runtimeSchema) return runtimeProxy.callTool(name, forwardedArgs);
453
+ if (runtimeProxy && observeRuntimeStatus(ctx.controllerHome).ready) return runtimeProxy.callTool(name, forwardedArgs);
309
454
  }
310
455
  const accessResult = callAccessTool(ctx, name, args);
311
456
  if (accessResult) return accessResult;
@@ -321,7 +466,7 @@ export function createForgeMcpServerFromContext(
321
466
  if (runtimeResult) return runtimeResult;
322
467
  const durableResult = await routeDurableMcpCall(ctx, name, args);
323
468
  if (durableResult) return durableResult;
324
- const repositoryResult = await callRepositoryTool(ctx.controllerHome, name, args);
469
+ const repositoryResult = await callRepositoryTool(ctx.controllerHome, name, args, ctx);
325
470
  if (repositoryResult) return repositoryResult;
326
471
  return callMultiRepositoryTool(ctx, name, args);
327
472
  });
@@ -303,6 +303,7 @@ export function runMcpSetupChatgpt(opts: {
303
303
  endpoint?: string;
304
304
  serverName?: string;
305
305
  localControllerPort?: string;
306
+ connectorPort?: string;
306
307
  }): McpSetupResult {
307
308
  const repoRoot = resolveMcpRepoRoot(opts.repo ?? ".");
308
309
  const controllerHome = opts.userLevel
@@ -319,6 +320,11 @@ export function runMcpSetupChatgpt(opts: {
319
320
  : existingConfig?.localController?.port ?? (parsedPort < 65_535 ? parsedPort + 1 : 8766);
320
321
  if (!Number.isInteger(localControllerPort) || localControllerPort < 1 || localControllerPort > 65_535) throw new Error(`forge mcp setup chatgpt: invalid Local Controller port ${opts.localControllerPort}`);
321
322
  if (localControllerPort === parsedPort) throw new Error('forge mcp setup chatgpt: Local Controller port must differ from MCP port');
323
+ const connectorPort = opts.connectorPort !== undefined
324
+ ? Number(opts.connectorPort)
325
+ : parsedPort <= 65_533 ? parsedPort + 2 : 8767;
326
+ if (!Number.isInteger(connectorPort) || connectorPort < 1 || connectorPort > 65_535) throw new Error(`forge mcp setup chatgpt: invalid connector port ${opts.connectorPort}`);
327
+ if (connectorPort === parsedPort || connectorPort === localControllerPort) throw new Error('forge mcp setup chatgpt: connector port must differ from Runtime and Local Controller ports');
322
328
  const existingServerName = existingConfig?.chatgpt?.serverName;
323
329
  const migratedServerName =
324
330
  existingServerName && !LEGACY_DEFAULT_SERVER_NAMES.has(existingServerName)
@@ -356,6 +362,7 @@ export function runMcpSetupChatgpt(opts: {
356
362
  ...existingConfig?.chatgpt,
357
363
  serverName,
358
364
  ...(endpoint ? { endpoint } : {}),
365
+ localEndpoint: `http://127.0.0.1:${connectorPort}/mcp`,
359
366
  },
360
367
  profile: existingConfig?.profile ?? "controller",
361
368
  toolset,
@@ -421,7 +428,8 @@ export function runMcpSetupChatgpt(opts: {
421
428
  "[forge mcp] Profile: controller",
422
429
  `[forge mcp] Toolset: ${config.toolset}`,
423
430
  `[forge mcp] ChatGPT MCP server name: ${serverName}`,
424
- `[forge mcp] Local endpoint: http://${host}:${port}/mcp`,
431
+ `[forge mcp] Internal Runtime endpoint: http://${host}:${port}/mcp`,
432
+ `[forge mcp] ChatGPT local OAuth endpoint: http://127.0.0.1:${connectorPort}/mcp`,
425
433
  `[forge mcp] Local Controller: http://${config.localController.host}:${config.localController.port}/`,
426
434
  `[forge mcp] Optional external delegation: ${config.devMode.agentRunner ? `enabled (${config.devMode.allowedAgents.join(',') || 'none'})` : 'disabled'}`,
427
435
  endpoint