@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
@@ -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
  });
@@ -1,4 +1,6 @@
1
1
  import { randomUUID, timingSafeEqual } from 'crypto';
2
+ import { existsSync, watch } from 'fs';
3
+ import { dirname } from 'path';
2
4
  import express, { type Request, type Response, type NextFunction } from 'express';
3
5
  import { tokenHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/token.js';
4
6
  import { revocationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/revoke.js';
@@ -37,7 +39,7 @@ import { readForgeRuntimeStatus } from '../../../runtime/control-plane/runtime-s
37
39
  import { runtimeIdentitySnapshot } from '../../../runtime/gateway/mcp/runtime-tools';
38
40
  import { projectionBlocksReadiness, readRepositoryProjectionSnapshot } from '../../../runtime/projections/materialized-view';
39
41
  import { readRuntimeGeneration } from '../../../runtime/control-plane/runtime-generation';
40
- import { readRuntimeStatusSnapshot } from '../../../runtime/root/status';
42
+ import { readRuntimeStatusSnapshot, runtimeStatusPath } from '../../../runtime/root/status';
41
43
  import { getRepository, listRepositories } from '../../repositories/registry';
42
44
  import { buildControllerTaskLedgerProjection } from '../../controller/task-ledger';
43
45
  import { legacyIssueAuthorityRetired } from '../../controller/legacy-issue-cutover';
@@ -740,6 +742,7 @@ async function handleMcpPost(
740
742
  principalId,
741
743
  });
742
744
  const runtimeSchema = await resolveRuntimeSchema?.(sessionContext);
745
+ let server: ReturnType<typeof createForgeMcpServerFromContext> | undefined;
743
746
  transport = new StreamableHTTPServerTransport({
744
747
  sessionIdGenerator: () => randomUUID(),
745
748
  onsessioninitialized: (newSessionId: string): void => {
@@ -752,6 +755,7 @@ async function handleMcpPost(
752
755
  clientIdentity,
753
756
  toolSurfaceFingerprint: runtimeSchema?.fingerprint ?? currentToolSurfaceFingerprint(),
754
757
  toolNames: runtimeSchema?.toolNames,
758
+ notifyToolListChanged: async () => { await server?.sendToolListChanged(); },
755
759
  });
756
760
  initializedSessionId = newSessionId;
757
761
  },
@@ -759,7 +763,7 @@ async function handleMcpPost(
759
763
  transport.onclose = () => {
760
764
  if (transport?.sessionId) registry.detach(transport.sessionId);
761
765
  };
762
- const server = createForgeMcpServerFromContext(sessionContext, runtimeSchema);
766
+ server = createForgeMcpServerFromContext(sessionContext, runtimeSchema);
763
767
  await server.connect(transport);
764
768
  await transport.handleRequest(req, res, body);
765
769
  } finally {
@@ -917,6 +921,23 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
917
921
  const currentRuntimeToolSurfaceFingerprint = () => runtimeControllerHome
918
922
  ? readRuntimeStatusSnapshot(runtimeControllerHome)?.toolSurfaceFingerprint
919
923
  : undefined;
924
+ let observedRuntimeToolSurfaceFingerprint = currentRuntimeToolSurfaceFingerprint();
925
+ let toolSurfaceNotificationTimer: ReturnType<typeof setTimeout> | undefined;
926
+ const runtimeStatusDirectory = runtimeControllerHome ? dirname(runtimeStatusPath(runtimeControllerHome)) : undefined;
927
+ const runtimeStatusWatcher = runtimeStatusDirectory && existsSync(runtimeStatusDirectory)
928
+ ? watch(runtimeStatusDirectory, { persistent: false }, (_event, filename) => {
929
+ if (String(filename ?? '') !== 'status.json') return;
930
+ if (toolSurfaceNotificationTimer) clearTimeout(toolSurfaceNotificationTimer);
931
+ toolSurfaceNotificationTimer = setTimeout(() => {
932
+ const currentFingerprint = currentRuntimeToolSurfaceFingerprint();
933
+ if (!currentFingerprint || currentFingerprint === observedRuntimeToolSurfaceFingerprint) return;
934
+ observedRuntimeToolSurfaceFingerprint = currentFingerprint;
935
+ void sessionRegistry.notifyToolListChanged(currentFingerprint);
936
+ }, 25);
937
+ toolSurfaceNotificationTimer.unref();
938
+ })
939
+ : undefined;
940
+ runtimeStatusWatcher?.on('error', () => undefined);
920
941
  const resolveRuntimeSchema = runtimeControllerHome
921
942
  ? async (context: McpToolContext): Promise<CanonicalRuntimeToolSchema | undefined> => {
922
943
  if (!('controllerHome' in context)) return undefined;
@@ -1260,6 +1281,8 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
1260
1281
 
1261
1282
  httpServer.on('close', () => {
1262
1283
  clearInterval(cleanupTimer);
1284
+ if (toolSurfaceNotificationTimer) clearTimeout(toolSurfaceNotificationTimer);
1285
+ runtimeStatusWatcher?.close();
1263
1286
  void sessionRegistry.closeAll('shutdown');
1264
1287
  });
1265
1288
 
@@ -31,6 +31,9 @@ export interface ManagedMcpSession<
31
31
  toolSurfaceFingerprint?: string;
32
32
  /** Exact tool names returned by the Canonical Runtime at initialize time. */
33
33
  toolNames?: string[];
34
+ /** Standard MCP tools/list_changed notification bound to this session's connected Server. */
35
+ notifyToolListChanged?: () => Promise<void> | void;
36
+ lastNotifiedToolSurfaceFingerprint?: string;
34
37
  createdAt: number;
35
38
  lastActivityAt: number;
36
39
  streamOpenedAt?: number;
@@ -88,6 +91,7 @@ interface RegisterMcpSession<TTransport extends ClosableMcpTransport, TContext>
88
91
  clientIdentity: string;
89
92
  toolSurfaceFingerprint?: string;
90
93
  toolNames?: string[];
94
+ notifyToolListChanged?: () => Promise<void> | void;
91
95
  initialPost?: boolean;
92
96
  }
93
97
 
@@ -206,6 +210,26 @@ export class McpSessionRegistry<
206
210
  if (session) session.lastActivityAt = this.now();
207
211
  }
208
212
 
213
+ async notifyToolListChanged(currentFingerprint: string | undefined): Promise<number> {
214
+ if (!currentFingerprint) return 0;
215
+ let notified = 0;
216
+ await Promise.all(Array.from(this.sessions.values()).map(async (session) => {
217
+ if (!session.toolSurfaceFingerprint
218
+ || session.toolSurfaceFingerprint === currentFingerprint
219
+ || session.lastNotifiedToolSurfaceFingerprint === currentFingerprint
220
+ || !session.notifyToolListChanged) return;
221
+ try {
222
+ await session.notifyToolListChanged();
223
+ session.lastNotifiedToolSurfaceFingerprint = currentFingerprint;
224
+ notified += 1;
225
+ } catch {
226
+ // Fingerprint fencing remains authoritative. A broken notification
227
+ // stream must not mark a stale session as refreshed or close active work.
228
+ }
229
+ }));
230
+ return notified;
231
+ }
232
+
209
233
  beginPost(sessionId: string): ManagedMcpSession<TTransport, TContext> | undefined {
210
234
  const session = this.sessions.get(sessionId);
211
235
  if (!session) return undefined;