@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
@@ -13,7 +13,11 @@ import {
13
13
  } from '../chatgpt-browser/engine';
14
14
  import type { BrowserProviderName, BrowserSessionStatus, NativeBrowserChannel, ThinkingLevel } from '../chatgpt-browser/types';
15
15
  import { durableControllerHome } from '../repositories/controller-home';
16
- import { runWorkChatgptContinuation } from '../../runtime/control-plane/launcher/chatgpt-work-continuation';
16
+ import {
17
+ runWorkChatgptContinuation,
18
+ type ChatgptAutomationReasoning,
19
+ type ChatgptAutomationTabPolicy,
20
+ } from '../../runtime/control-plane/launcher/chatgpt-work-continuation';
17
21
 
18
22
  interface BrowserCommonOptions {
19
23
  repo?: string;
@@ -84,6 +88,9 @@ interface WorkChatgptContinueOptions extends BrowserCommonOptions {
84
88
  title?: string;
85
89
  session?: string;
86
90
  conversationUrl?: string;
91
+ model?: string;
92
+ reasoning?: string;
93
+ tabPolicy?: string;
87
94
  timeoutMs?: string;
88
95
  }
89
96
 
@@ -109,6 +116,18 @@ interface BrowserFollowupOptions extends BrowserCommonOptions {
109
116
  oracleBin?: string;
110
117
  }
111
118
 
119
+ function parseAutomationReasoning(value?: string): ChatgptAutomationReasoning | undefined {
120
+ if (value === undefined) return undefined;
121
+ if (value === 'medium' || value === 'high' || value === 'xhigh') return value;
122
+ throw new Error(`invalid --reasoning "${value}" (expected: medium, high, xhigh)`);
123
+ }
124
+
125
+ function parseAutomationTabPolicy(value?: string): ChatgptAutomationTabPolicy | undefined {
126
+ if (value === undefined) return undefined;
127
+ if (value === 'auto' || value === 'reuse' || value === 'new') return value;
128
+ throw new Error(`invalid --tab-policy "${value}" (expected: auto, reuse, new)`);
129
+ }
130
+
112
131
  function parseProvider(value?: string): BrowserProviderName {
113
132
  if (value === undefined || value === 'oracle') return 'oracle';
114
133
  if (value === 'native') return 'native';
@@ -384,8 +403,11 @@ export function buildChatgptCommand(): Command {
384
403
  .requiredOption('--work-id <work-id>', 'Stable Forge Work id')
385
404
  .requiredOption('--prompt <text>', 'Bounded continuation prompt')
386
405
  .option('--title <title>', 'Human-readable local conversation alias')
387
- .option('--session <session-id>', 'Legacy saved browser session to seed the binding')
406
+ .option('--session <session-id>', 'Saved browser session to seed or resume the Work binding')
388
407
  .option('--conversation-url <url>', 'Explicit ChatGPT conversation URL to seed the binding')
408
+ .option('--model <label>', 'Requested ChatGPT automation model', 'gpt-5.6')
409
+ .option('--reasoning <level>', 'Reasoning level: medium|high|xhigh', 'high')
410
+ .option('--tab-policy <policy>', 'Browser tab policy: auto|reuse|new', 'auto')
389
411
  .option('--timeout-ms <ms>', 'Assistant timeout in milliseconds')
390
412
  .action((rawOpts: WorkChatgptContinueOptions) => {
391
413
  void runChatgptAction(async () => {
@@ -398,6 +420,9 @@ export function buildChatgptCommand(): Command {
398
420
  title: rawOpts.title,
399
421
  browserSessionId: rawOpts.session,
400
422
  conversationUrl: rawOpts.conversationUrl,
423
+ model: rawOpts.model,
424
+ reasoning: parseAutomationReasoning(rawOpts.reasoning),
425
+ tabPolicy: parseAutomationTabPolicy(rawOpts.tabPolicy),
401
426
  timeoutMs: parsePositiveInteger('timeout-ms', rawOpts.timeoutMs),
402
427
  });
403
428
  console.log(JSON.stringify({
@@ -408,6 +433,10 @@ export function buildChatgptCommand(): Command {
408
433
  conversationId: result.conversationId,
409
434
  localAlias: result.localAlias,
410
435
  resumedFromBinding: result.resumedFromBinding,
436
+ model: result.model,
437
+ reasoning: result.reasoning,
438
+ tabPolicy: result.tabPolicy,
439
+ executionPreferenceVerified: result.executionPreferenceVerified,
411
440
  error: result.error,
412
441
  }, null, 2));
413
442
  });
@@ -17,6 +17,7 @@ import { Command } from 'commander';
17
17
  import { runDoctor, type CheckStatus, type DoctorReport } from './doctor';
18
18
  import { runStatus, type StatusReport } from './status';
19
19
  import { runMcpSetupChatgpt } from '../mcp/setup';
20
+ import { resolveRepoPreferredControllerHome } from '../repositories/controller-home';
20
21
  import {
21
22
  configureSetupProfile,
22
23
  detectSetupPlatform,
@@ -790,7 +791,8 @@ export function openSetupSession(options: SetupSessionOptions = {}): SetupSessio
790
791
  const platform = options.platform ?? detectSetupPlatform({ env: options.env });
791
792
  const target = options.target ?? setupHostTarget(profile);
792
793
  const report = options.report ?? runInitHook({ ...options, target });
793
- const nextAction = profileNextAction(profile, platform, { controllerHome: options.controllerHome, accountHome: options.accountHome }) ?? report.agent_actions[0];
794
+ const controllerHome = resolveRepoPreferredControllerHome(options.cwd ?? process.cwd(), options.controllerHome);
795
+ const nextAction = profileNextAction(profile, platform, { controllerHome, accountHome: options.accountHome }) ?? report.agent_actions[0];
794
796
  const now = (options.now ?? (() => new Date()))().toISOString();
795
797
  const reuse = previous && previous.status !== 'closed';
796
798
  return writeSetupSessionAt(path, {
@@ -50,6 +50,7 @@ interface McpSetupChatgptOptions {
50
50
  endpoint?: string;
51
51
  serverName?: string;
52
52
  localControllerPort?: string;
53
+ connectorPort?: string;
53
54
  }
54
55
 
55
56
  interface McpSetupLocalControllerOptions {
@@ -237,6 +238,7 @@ export function buildMcpCommand(): Command {
237
238
  .option('--host <host>', 'Local MCP HTTP bind host', '127.0.0.1')
238
239
  .option('--port <port>', 'Local MCP HTTP bind port', '8765')
239
240
  .option('--local-controller-port <port>', 'Local-only Utility Console port; defaults to MCP port + 1')
241
+ .option('--connector-port <port>', 'Loopback OAuth connector port for ChatGPT tunnels; defaults to MCP port + 2')
240
242
  .option('--endpoint <url>', 'Stable public HTTPS /mcp endpoint to store in ignored local config')
241
243
  .option('--server-name <name>', 'ChatGPT Connector/MCP server name to record in ignored local config')
242
244
  .action((rawOpts: McpSetupChatgptOptions) => {
@@ -5,11 +5,11 @@ import { dirname, isAbsolute, join, resolve, sep } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { Command } from 'commander';
7
7
  import { resolveControllerHome, controllerSystemRoot } from '../repositories/controller-home';
8
- import { installExternalPluginRegistration, listExternalPluginRegistrations, type ExternalPluginRegistrationInput } from '../../runtime/plugins/external-registration';
8
+ import { installExternalPluginRegistration, listExternalPluginRegistrations, type ExternalPluginRegistration, type ExternalPluginRegistrationInput } from '../../runtime/plugins/external-registration';
9
9
  import { createDesktopOperatorRegistrationInput } from '../../runtime/plugins/desktop-operator-registration';
10
10
  import { controllerPluginRepository, getAssistantPluginManifest, syncAssistantPluginRegistry } from '../../runtime/plugins/store';
11
11
 
12
- interface RegistryEntry { id:string; name:string; version:string; description:string; repository:string; ref:string; installer:string; platforms:NodeJS.Platform[]; }
12
+ export interface RegistryEntry { id:string; name:string; version:string; description:string; repository:string; ref:string; installer:string; providerVersion?:string; protocolVersion?:string; platforms:NodeJS.Platform[]; }
13
13
  interface Registry { schemaVersion:1; plugins:RegistryEntry[]; }
14
14
  interface CliOptions { json?:boolean; controllerHome?:string; refresh?:boolean; }
15
15
  const packageRoot=resolve(dirname(fileURLToPath(import.meta.url)),'../../..');
@@ -24,11 +24,26 @@ function registry():Registry{
24
24
  if(!plugin.repository.startsWith('https://github.com/moretea-labs/')||!plugin.repository.endsWith('.git'))throw new Error(`PLUGIN_CATALOG_UNTRUSTED_REPOSITORY: ${plugin.id}`);
25
25
  if(!/^v\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(plugin.ref))throw new Error(`PLUGIN_CATALOG_UNPINNED_REF: ${plugin.id}`);
26
26
  if(plugin.installer!=='forge-plugin-install.mjs')throw new Error(`PLUGIN_CATALOG_INSTALLER_INVALID: ${plugin.id}`);
27
+ if(plugin.providerVersion!==undefined&&!/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(plugin.providerVersion))throw new Error(`PLUGIN_CATALOG_PROVIDER_VERSION_INVALID: ${plugin.id}`);
28
+ if(plugin.protocolVersion!==undefined&&!/^\d+\.\d+$/.test(plugin.protocolVersion))throw new Error(`PLUGIN_CATALOG_PROTOCOL_VERSION_INVALID: ${plugin.id}`);
27
29
  ids.add(plugin.id);
28
30
  }
29
31
  return value;
30
32
  }
31
33
  function within(root:string,relative:string):string{const target=resolve(root,relative);const base=`${resolve(root)}${sep}`;if(target!==resolve(root)&&!target.startsWith(base))throw new Error('PLUGIN_INSTALL_PATH_ESCAPE');return target;}
34
+ export function pluginCatalogCompatibility(entry:RegistryEntry,platform:NodeJS.Platform=process.platform):{compatible:boolean;reason?:string}{
35
+ if(!entry.platforms.includes(platform))return{compatible:false,reason:`unsupported platform: ${platform}`};
36
+ if(entry.providerVersion&&entry.providerVersion!==entry.version)return{compatible:false,reason:`catalog version ${entry.version} does not match pinned provider version ${entry.providerVersion}`};
37
+ return{compatible:true};
38
+ }
39
+ export function officialPluginCatalogItems(platform:NodeJS.Platform=process.platform){return registry().plugins.map(entry=>({...entry,...pluginCatalogCompatibility(entry,platform)}));}
40
+ export function externalPluginListItem(controllerHome:string,registration:ExternalPluginRegistration){
41
+ const base={pluginId:registration.pluginId,version:registration.pluginVersion,provider:registration.provider,enabled:registration.enabled,scope:registration.scope,transport:registration.transport.kind};
42
+ if(registration.scope==='repository')return{...base,healthScope:'repository_context_required' as const};
43
+ const repository=controllerPluginRepository(controllerHome);
44
+ try{return{...base,health:getAssistantPluginManifest(controllerHome,repository,registration.pluginId).health};}
45
+ catch(error){return{...base,health:{state:'error',message:error instanceof Error?error.message:String(error)}};}
46
+ }
32
47
  function run(command:string,args:string[],cwd:string,timeoutMs=120000):string{
33
48
  const result=spawnSync(command,args,{cwd,encoding:'utf8',timeout:timeoutMs,maxBuffer:32*1024*1024,shell:false,windowsHide:true});
34
49
  if(result.error)throw new Error(`PLUGIN_INSTALL_COMMAND_FAILED: ${result.error.message}`);
@@ -40,17 +55,29 @@ function installerJson(stdout:string):Record<string,unknown>{
40
55
  for(let i=lines.length-1;i>=0;i--){try{const x=JSON.parse(lines[i]);if(x&&typeof x==='object'&&!Array.isArray(x)&&x.schemaVersion===1)return x;}catch{}}
41
56
  throw new Error('PLUGIN_INSTALLER_OUTPUT_INVALID');
42
57
  }
58
+ export function installerNextSteps(result:Record<string,unknown>):string[]{
59
+ if(!Array.isArray(result.nextSteps))return[];
60
+ return result.nextSteps
61
+ .filter((value):value is string=>typeof value==='string')
62
+ .map(value=>value.replace(/[\u0000-\u001F\u007F]/g,' ').trim().slice(0,1000))
63
+ .filter(Boolean)
64
+ .slice(0,10);
65
+ }
43
66
  function registrationFrom(result:Record<string,unknown>,entry:RegistryEntry):ExternalPluginRegistrationInput{
44
67
  if(result.registration){const input=result.registration as ExternalPluginRegistrationInput;if(input.pluginId!==entry.id)throw new Error('PLUGIN_INSTALLER_ID_MISMATCH');if(input.pluginVersion!==entry.version)throw new Error('PLUGIN_INSTALLER_VERSION_MISMATCH');return input;}
45
68
  const facts=result.providerInstall as Record<string,unknown>|undefined;
46
69
  if(!facts||facts.kind!=='desktop_operator'||entry.id!=='desktop_operator')throw new Error('PLUGIN_INSTALLER_REGISTRATION_MISSING');
47
- if(facts.pluginVersion!==entry.version||facts.protocolVersion!=='1.0')throw new Error('PLUGIN_INSTALLER_PROVIDER_VERSION_MISMATCH');
70
+ const expectedProviderVersion=entry.providerVersion??entry.version;
71
+ const expectedProtocolVersion=entry.protocolVersion??'1.0';
72
+ if(facts.pluginVersion!==expectedProviderVersion)throw new Error(`PLUGIN_INSTALLER_PROVIDER_VERSION_MISMATCH: expected ${expectedProviderVersion} for ${entry.id}@${entry.version}, actual ${String(facts.pluginVersion??'missing')}`);
73
+ if(facts.protocolVersion!==expectedProtocolVersion)throw new Error(`PLUGIN_INSTALLER_PROTOCOL_VERSION_MISMATCH: expected ${expectedProtocolVersion} for ${entry.id}@${entry.version}, actual ${String(facts.protocolVersion??'missing')}`);
48
74
  const socketPath=typeof facts.socketPath==='string'?facts.socketPath:'';const launchAgentLabel=typeof facts.launchAgentLabel==='string'?facts.launchAgentLabel:'';const expectedProgramContains=typeof facts.expectedProgramContains==='string'?facts.expectedProgramContains:'';
49
75
  if(!isAbsolute(socketPath)||!launchAgentLabel||!expectedProgramContains)throw new Error('PLUGIN_INSTALLER_PROVIDER_FACTS_INVALID');
50
- return createDesktopOperatorRegistrationInput({socketPath,launchAgentLabel,expectedProgramContains,pluginVersion:entry.version,protocolVersion:'1.0'});
76
+ return createDesktopOperatorRegistrationInput({socketPath,launchAgentLabel,expectedProgramContains,pluginVersion:expectedProviderVersion,protocolVersion:expectedProtocolVersion});
51
77
  }
52
78
  function install(entry:RegistryEntry,controllerHome:string):Record<string,unknown>{
53
- if(!entry.platforms.includes(process.platform))throw new Error(`PLUGIN_PLATFORM_UNSUPPORTED: ${entry.id}/${process.platform}`);
79
+ const compatibility=pluginCatalogCompatibility(entry,process.platform);
80
+ if(!compatibility.compatible)throw new Error(`PLUGIN_CATALOG_INCOMPATIBLE: ${entry.id}@${entry.version}: ${compatibility.reason}`);
54
81
  const packagesRoot=join(controllerSystemRoot(controllerHome),'plugins','packages');mkdirSync(packagesRoot,{recursive:true,mode:0o700});
55
82
  const finalRoot=join(packagesRoot,entry.id),stage=join(packagesRoot,`.${entry.id}.${randomUUID()}.staging`),backup=join(packagesRoot,`.${entry.id}.${randomUUID()}.backup`);let backed=false;
56
83
  try{
@@ -62,13 +89,13 @@ function install(entry:RegistryEntry,controllerHome:string):Record<string,unknow
62
89
  const installed=installExternalPluginRegistration(controllerHome,registrationFrom(result,entry));
63
90
  const repository=controllerPluginRepository(controllerHome);syncAssistantPluginRegistry(controllerHome,repository);const plugin=getAssistantPluginManifest(controllerHome,repository,entry.id);
64
91
  if(backed)rmSync(backup,{recursive:true,force:true});
65
- return{pluginId:entry.id,version:entry.version,packageRoot:finalRoot,registrationRevision:installed.revision,enabled:plugin.enabled,health:plugin.health};
92
+ return{pluginId:entry.id,version:entry.version,packageRoot:finalRoot,registrationRevision:installed.revision,enabled:plugin.enabled,health:plugin.health,nextSteps:installerNextSteps(result)};
66
93
  }catch(error){rmSync(stage,{recursive:true,force:true});if(backed&&existsSync(finalRoot))rmSync(finalRoot,{recursive:true,force:true});if(backed&&existsSync(backup))renameSync(backup,finalRoot);throw error;}
67
94
  }
68
95
  export function buildPluginCommand():Command{
69
96
  const root=new Command('plugin').description('Discover and install trusted Forge plugins');
70
- root.command('catalog').description('List official plugins').option('--json','Output JSON').action((o:CliOptions)=>{const items=registry().plugins.map(x=>({...x,compatible:x.platforms.includes(process.platform)}));if(o.json)console.log(JSON.stringify({schemaVersion:1,platform:process.platform,plugins:items},null,2));else items.forEach(x=>console.log(`${x.id}\t${x.version}\t${x.compatible?'compatible':'unsupported'}\t${x.name}`));});
71
- root.command('list').description('List installed external plugins').option('--json','Output JSON').option('--controller-home <path>','Override Controller Home').option('--refresh','Probe providers').action((o:CliOptions)=>{const home=resolveControllerHome(o.controllerHome),repo=controllerPluginRepository(home);if(o.refresh)syncAssistantPluginRegistry(home,repo);const items=listExternalPluginRegistrations(home).map(r=>{let health:unknown;try{health=getAssistantPluginManifest(home,repo,r.pluginId).health;}catch(e){health={state:'error',message:e instanceof Error?e.message:String(e)};}return{pluginId:r.pluginId,version:r.pluginVersion,provider:r.provider,enabled:r.enabled,transport:r.transport.kind,health};});if(o.json)console.log(JSON.stringify({schemaVersion:1,plugins:items},null,2));else if(!items.length)console.log('No external Forge plugins installed.');else items.forEach(x=>console.log(`${x.pluginId}\t${x.version}\t${x.enabled?'enabled':'disabled'}\t${(x.health as {state?:string})?.state??'unknown'}`));});
72
- root.command('install <plugin-id>').description('Install/update an official plugin from a pinned public release').option('--json','Output JSON').option('--controller-home <path>','Override Controller Home').action((id:string,o:CliOptions)=>{const entry=registry().plugins.find(x=>x.id===id);if(!entry)throw new Error(`PLUGIN_NOT_IN_OFFICIAL_CATALOG: ${id}`);const result=install(entry,resolveControllerHome(o.controllerHome));if(o.json)console.log(JSON.stringify(result,null,2));else console.log(`Installed ${entry.name} ${entry.version} (${(result.health as {state?:string})?.state??'unknown'}).`);});
97
+ root.command('catalog').description('List official plugins').option('--json','Output JSON').action((o:CliOptions)=>{const items=officialPluginCatalogItems();if(o.json)console.log(JSON.stringify({schemaVersion:1,platform:process.platform,plugins:items},null,2));else items.forEach(x=>console.log(`${x.id}\t${x.version}\t${x.compatible?'compatible':`incompatible (${x.reason})`}\t${x.name}`));});
98
+ root.command('list').description('List installed external plugins').option('--json','Output JSON').option('--controller-home <path>','Override Controller Home').option('--refresh','Probe controller-scoped providers').action((o:CliOptions)=>{const home=resolveControllerHome(o.controllerHome),repo=controllerPluginRepository(home);if(o.refresh)syncAssistantPluginRegistry(home,repo);const items=listExternalPluginRegistrations(home).map(r=>externalPluginListItem(home,r));if(o.json)console.log(JSON.stringify({schemaVersion:1,plugins:items},null,2));else if(!items.length)console.log('No external Forge plugins installed.');else items.forEach(x=>console.log(`${x.pluginId}\t${x.version}\t${x.enabled?'enabled':'disabled'}\t${'healthScope' in x?'repository-context-required':((x.health as {state?:string})?.state??'unknown')}`));});
99
+ root.command('install <plugin-id>').description('Install/update an official plugin from a pinned public release').option('--json','Output JSON').option('--controller-home <path>','Override Controller Home').action((id:string,o:CliOptions)=>{const entry=registry().plugins.find(x=>x.id===id);if(!entry)throw new Error(`PLUGIN_NOT_IN_OFFICIAL_CATALOG: ${id}`);const result=install(entry,resolveControllerHome(o.controllerHome));if(o.json)console.log(JSON.stringify(result,null,2));else{console.log(`Installed ${entry.name} ${entry.version} (${(result.health as {state?:string})?.state??'unknown'}).`);for(const step of (result.nextSteps as string[]|undefined)??[])console.log(`- ${step}`);}});
73
100
  return root;
74
101
  }
@@ -72,6 +72,7 @@ export function buildRuntimeCommand(): Command {
72
72
  },
73
73
  servicePath: result.servicePath,
74
74
  pid: result.pid,
75
+ connector: result.connector,
75
76
  warnings: result.warnings,
76
77
  next: `forge runtime status --controller-home ${home}`,
77
78
  });
@@ -206,6 +206,20 @@ export function detectSetupPlatform(options: { platform?: NodeJS.Platform; arch?
206
206
  }
207
207
 
208
208
 
209
+ function isLoopbackMcpEndpoint(value: string | undefined): boolean {
210
+ if (!value) return false;
211
+ try {
212
+ const parsed = new URL(value);
213
+ return parsed.protocol === 'http:'
214
+ && (parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost' || parsed.hostname === '::1')
215
+ && parsed.pathname === '/mcp'
216
+ && !parsed.search
217
+ && !parsed.hash;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
209
223
  export interface ControllerGuidance {
210
224
  controller: SetupControllerKind;
211
225
  ready: boolean;
@@ -244,8 +258,9 @@ export function resolveControllerGuidance(
244
258
  const localReady = localConfig?.profile === 'controller'
245
259
  && localConfig.server?.host === '127.0.0.1'
246
260
  && Number.isInteger(localConfig.server?.port);
261
+ const connectorReady = controller !== 'chatgpt' || isLoopbackMcpEndpoint(localConfig?.chatgpt?.localEndpoint);
247
262
  const endpointReady = !profile.tunnel.endpoint || localConfig?.chatgpt?.endpoint === profile.tunnel.endpoint;
248
- if (!localReady || !endpointReady) {
263
+ if (!localReady || !connectorReady || !endpointReady) {
249
264
  const endpoint = profile.tunnel.endpoint ? ` --endpoint ${profile.tunnel.endpoint}` : '';
250
265
  return {
251
266
  controller,
@@ -319,7 +334,7 @@ export function resolveRuntimeGuidance(
319
334
  export function resolveTunnelGuidance(
320
335
  profile: SetupProfile | undefined,
321
336
  platform = detectSetupPlatform(),
322
- options: { controllerHome?: string } = {},
337
+ options: { controllerHome?: string; env?: NodeJS.ProcessEnv } = {},
323
338
  ): TunnelGuidance {
324
339
  if (!profile || !setupNeedsRemoteAccess(profile)) {
325
340
  return { provider: 'none', ready: true, title: 'Remote controller connection', detail: 'Not required by the configured local controller set.' };
@@ -329,15 +344,11 @@ export function resolveTunnelGuidance(
329
344
  }
330
345
  let provider = profile.tunnel.provider;
331
346
  if (provider === 'auto') {
332
- if (platform.commands.tunnelClient) provider = 'openai';
333
- else if (platform.commands.cloudflared) provider = 'cloudflare';
334
- else if (platform.commands.tailscale) provider = 'tailscale';
335
- else {
336
- return {
337
- provider: 'auto', ready: false, title: 'Choose secure remote access',
338
- detail: 'For ChatGPT, prefer OpenAI Secure MCP Tunnel when your workspace/Platform organization supports it; otherwise choose Cloudflare Tunnel, Tailscale Funnel, an existing HTTPS endpoint, or defer remote access. Forge never requires public ingress when a private tunnel is available.',
339
- };
340
- }
347
+ // ChatGPT's private outbound path is the default. Do not silently choose a
348
+ // public ingress provider merely because cloudflared/tailscale happens to
349
+ // be installed; users without Secure Tunnel access can explicitly select a
350
+ // fallback provider.
351
+ provider = 'openai';
341
352
  }
342
353
  if (provider === 'openai') {
343
354
  if (!profile.tunnel.tunnelId) {
@@ -353,9 +364,22 @@ export function resolveTunnelGuidance(
353
364
  detail: 'Install the supported tunnel-client binary from OpenAI Platform Tunnels or the official openai/tunnel-client release. The runtime API key remains an environment/file reference owned by tunnel-client; Forge does not store it.',
354
365
  };
355
366
  }
356
- const alias = 'forge';
357
- const status = spawnSync('tunnel-client', ['runtimes', 'status', alias, '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000 });
358
- if (status.status === 0) {
367
+ const preferredAlias = 'forge';
368
+ const aliases = [preferredAlias];
369
+ const listed = spawnSync('tunnel-client', ['runtimes', 'list', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000, env: options.env ?? process.env });
370
+ if (listed.status === 0) {
371
+ try {
372
+ const value = JSON.parse(listed.stdout || '{}') as { aliases?: Array<{ alias?: string; tunnel_id?: string }> };
373
+ for (const entry of value.aliases ?? []) {
374
+ if (entry.tunnel_id === profile.tunnel.tunnelId && entry.alias) aliases.push(entry.alias);
375
+ }
376
+ } catch {
377
+ // Older clients may not expose local aliases as JSON. Keep the preferred alias fallback.
378
+ }
379
+ }
380
+ for (const alias of Array.from(new Set(aliases))) {
381
+ const status = spawnSync('tunnel-client', ['runtimes', 'status', alias, '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000, env: options.env ?? process.env });
382
+ if (status.status !== 0) continue;
359
383
  try {
360
384
  const value = JSON.parse(status.stdout || '{}') as { process_running?: boolean; healthy?: boolean; ready?: boolean; tunnel_id?: string };
361
385
  if (value.process_running === true && value.healthy === true && value.ready === true
@@ -369,13 +393,20 @@ export function resolveTunnelGuidance(
369
393
  // Treat malformed or older status output as not-ready instead of claiming success.
370
394
  }
371
395
  }
396
+ const alias = preferredAlias;
372
397
  const localConfig = loadMcpServiceLocalConfig(resolveControllerHome(options.controllerHome));
373
- const configuredPort = localConfig?.server?.port;
374
- const mcpPort = typeof configuredPort === 'number' && Number.isInteger(configuredPort) ? configuredPort : 8765;
398
+ const localEndpoint = localConfig?.chatgpt?.localEndpoint;
399
+ if (!isLoopbackMcpEndpoint(localEndpoint)) {
400
+ return {
401
+ provider, ready: false, title: 'Prepare the local ChatGPT OAuth endpoint',
402
+ detail: 'Secure Tunnel must terminate at Forge’s loopback OAuth Gateway, not the bearer-only Canonical Runtime. Refresh the user-level ChatGPT MCP configuration first.',
403
+ command: 'forge mcp setup chatgpt --user-level',
404
+ };
405
+ }
375
406
  return {
376
407
  provider, ready: false, title: 'Connect OpenAI Secure MCP Tunnel',
377
- detail: 'Create a supervised tunnel-client runtime and verify it with runtimes status. Set CONTROL_PLANE_API_KEY in the invoking environment; Forge passes only an env reference and never reads or stores the key.',
378
- command: `tunnel-client runtimes connect --alias ${alias} --tunnel-id ${profile.tunnel.tunnelId} --runtime-api-key env:CONTROL_PLANE_API_KEY --mcp-server-url http://127.0.0.1:${mcpPort}/mcp`,
408
+ detail: 'Create a supervised tunnel-client runtime and verify it with runtimes status. Keep the runtime API key in an environment or file reference owned by tunnel-client; Forge never reads or stores the key.',
409
+ command: `tunnel-client runtimes connect --alias ${alias} --tunnel-id ${profile.tunnel.tunnelId} --runtime-api-key env:CONTROL_PLANE_API_KEY --mcp-server-url ${localEndpoint}`,
379
410
  };
380
411
  }
381
412
  if (provider === 'none') {
@@ -67,6 +67,8 @@ export interface ControllerContextPackOptions {
67
67
  maxSnippets?: number;
68
68
  maxCharsPerSnippet?: number;
69
69
  structuralContext?: StructuralContextMode;
70
+ /** Optional repository-level CodeGraph root used as structural baseline when the selected checkout has no local index. */
71
+ structuralIndexRoot?: string;
70
72
  retrievalMode?: ControllerContextRetrievalMode;
71
73
  /** GPT-selected cross-cutting evidence dimensions. Forge expands mechanically and never treats them as semantic completeness proof. */
72
74
  impactDomains?: ControllerContextImpactDomain[];
@@ -138,6 +140,9 @@ export interface ControllerContextPackProjection {
138
140
  requestedMode: StructuralContextMode;
139
141
  status: "disabled" | "ready" | "stale" | "unavailable" | "degraded";
140
142
  requiredSatisfied: boolean;
143
+ indexSource: "current_checkout" | "repository_baseline";
144
+ overlayChangedFiles: string[];
145
+ baselineRevisionMatches: boolean;
141
146
  query?: string;
142
147
  entryPoints: Array<Pick<CodeGraphNodeSummary, "id" | "kind" | "name" | "filePath" | "startLine" | "endLine">>;
143
148
  relatedFiles: string[];
@@ -146,6 +151,26 @@ export interface ControllerContextPackProjection {
146
151
  timingsMs?: CodeGraphReadProviderResponse["timingsMs"];
147
152
  truncated: boolean;
148
153
  };
154
+ impactContext: {
155
+ status: "not_requested" | "ready" | "degraded";
156
+ confidence: "low" | "medium" | "high";
157
+ primaryTargets: string[];
158
+ mustInspect: string[];
159
+ likelyAffected: string[];
160
+ relevantTests: string[];
161
+ relevantChecks: string[];
162
+ architectureContracts: string[];
163
+ coverageGaps: string[];
164
+ relationSources: Array<"codegraph" | "lexical" | "forge_checks">;
165
+ freshness: {
166
+ structuralStatus: "disabled" | "ready" | "stale" | "unavailable" | "degraded";
167
+ lastIndexedAt?: number | null;
168
+ changedFileCount: number;
169
+ indexSource: "current_checkout" | "repository_baseline";
170
+ overlayChangedFileCount: number;
171
+ baselineRevisionMatches: boolean;
172
+ };
173
+ };
149
174
  files: ControllerContextPackFile[];
150
175
  deniedPaths: Array<{ path: string; reason: string }>;
151
176
  omitted: Array<{ path: string; reason: string }>;
@@ -273,6 +298,18 @@ function coveredByGlob(path: string, globs: string[]): boolean {
273
298
  return globs.length === 0 || globs.some((glob) => globMatches(glob, path));
274
299
  }
275
300
 
301
+ function gitStatusChangedPaths(status: string): string[] {
302
+ const paths: string[] = [];
303
+ for (const line of status.split(/\r?\n/)) {
304
+ if (!line.trim() || line.startsWith("##") || line.length < 4) continue;
305
+ let path = line.slice(3).trim();
306
+ if (path.includes(" -> ")) path = path.slice(path.lastIndexOf(" -> ") + 4).trim();
307
+ if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1);
308
+ if (path && !paths.includes(path)) paths.push(path);
309
+ }
310
+ return paths;
311
+ }
312
+
276
313
  function addReason(map: Map<string, { reasons: Set<string>; lines: Set<number> }>, path: string, reason: string, line?: number): void {
277
314
  const entry = map.get(path) ?? { reasons: new Set<string>(), lines: new Set<number>() };
278
315
  entry.reasons.add(reason);
@@ -453,11 +490,19 @@ export function buildControllerContextPack(
453
490
  let skippedBinaryFiles = 0;
454
491
  let searchTruncated = false;
455
492
  const structuralMode = options.structuralContext ?? "off";
493
+ const structuralIndexRoot = options.structuralIndexRoot?.trim() || repoRoot;
494
+ const indexSource = structuralIndexRoot === repoRoot ? "current_checkout" : "repository_baseline";
495
+ const overlayChangedFiles = indexSource === "repository_baseline" ? gitStatusChangedPaths(git.status) : [];
496
+ const baselineRevisionMatches = indexSource === "current_checkout" || gitSnapshot(structuralIndexRoot).head === git.head;
497
+ const graphImpactFiles = new Set<string>();
456
498
  let structuralContext: ControllerContextPackProjection["structuralContext"] = {
457
499
  provider: "codegraph",
458
500
  requestedMode: structuralMode,
459
501
  status: "disabled",
460
502
  requiredSatisfied: structuralMode !== "required",
503
+ indexSource,
504
+ overlayChangedFiles,
505
+ baselineRevisionMatches,
461
506
  entryPoints: [],
462
507
  relatedFiles: [],
463
508
  truncated: false,
@@ -474,7 +519,7 @@ export function buildControllerContextPack(
474
519
  };
475
520
  } else {
476
521
  const queryCodeGraph = dependencies.queryCodeGraph ?? queryCodeGraphReadProvider;
477
- const structural = queryCodeGraph(repoRoot, {
522
+ const structural = queryCodeGraph(structuralIndexRoot, {
478
523
  operation: "context",
479
524
  query: structuralQuery,
480
525
  limit: Math.min(Math.max(maxFiles, 4), 12),
@@ -496,8 +541,11 @@ export function buildControllerContextPack(
496
541
  structuralContext = {
497
542
  provider: "codegraph",
498
543
  requestedMode: structuralMode,
499
- status: structural.status,
500
- requiredSatisfied: structuralMode !== "required" || structural.status === "ready",
544
+ status: indexSource === "repository_baseline" && (!baselineRevisionMatches || overlayChangedFiles.length > 0) && structural.status === "ready" ? "stale" : structural.status,
545
+ requiredSatisfied: structuralMode !== "required" || (structural.status === "ready" && baselineRevisionMatches && overlayChangedFiles.length === 0),
546
+ indexSource,
547
+ overlayChangedFiles,
548
+ baselineRevisionMatches,
501
549
  query: structuralQuery,
502
550
  entryPoints: graph.entryPoints.slice(0, 12).map((node) => ({
503
551
  id: node.id,
@@ -530,17 +578,50 @@ export function buildControllerContextPack(
530
578
  addStructuralPath(node.filePath, `codegraph:entry:${node.name}`, node.startLine);
531
579
  }
532
580
  for (const path of graph.relatedFiles.slice(0, maxFiles * 6)) addStructuralPath(path, "codegraph:related");
581
+ // Context search identifies the structural entry points. For a bounded
582
+ // number of those files, enrich the same Context Plane call with direct
583
+ // file dependencies/dependents. This is impact evidence, not a second
584
+ // routing/tool surface, and remains completely off for ordinary Direct.
585
+ if (structural.status === "ready") {
586
+ const primaryFiles = Array.from(new Set(graph.entryPoints.map((node) => node.filePath).filter(Boolean))).slice(0, 4);
587
+ for (const filePath of primaryFiles) {
588
+ const adjacency = queryCodeGraph(structuralIndexRoot, { operation: "file_dependencies", filePath, limit: 40 });
589
+ if (!adjacency.ok || adjacency.status !== "ready") continue;
590
+ const dependencies = Array.isArray(adjacency.result?.dependencies)
591
+ ? adjacency.result.dependencies.filter((value): value is string => typeof value === "string")
592
+ : [];
593
+ const dependents = Array.isArray(adjacency.result?.dependents)
594
+ ? adjacency.result.dependents.filter((value): value is string => typeof value === "string")
595
+ : [];
596
+ for (const path of dependencies.slice(0, 40)) {
597
+ graphImpactFiles.add(path);
598
+ addStructuralPath(path, `codegraph:dependency:${filePath}`);
599
+ }
600
+ for (const path of dependents.slice(0, 40)) {
601
+ graphImpactFiles.add(path);
602
+ addStructuralPath(path, `codegraph:dependent:${filePath}`);
603
+ }
604
+ }
605
+ }
533
606
  for (const path of metadata?.changedFiles.added ?? []) addStructuralPath(path, "codegraph:changed-file", 1);
534
607
  for (const path of metadata?.changedFiles.modified ?? []) addStructuralPath(path, "codegraph:changed-file", 1);
535
608
  for (const path of metadata?.changedFiles.removed ?? []) omitted.push({ path, reason: "codegraph reports file removed since index" });
609
+ for (const path of overlayChangedFiles) addStructuralPath(path, "worktree:changed-file", 1);
536
610
  }
537
611
  }
538
612
 
613
+ const exactKnownFiles: string[] = [];
614
+ let exactKnownFileScope = explicitKnownPaths.length > 0;
539
615
  for (const path of explicitKnownPaths) {
540
616
  const expanded = expandKnownPath(repoRoot, policy, path, Math.max(maxFiles * 4, 40));
541
617
  for (const file of expanded.files) {
542
618
  addReason(candidates, file, expanded.directory ? `explicit-known-directory:${expanded.directory}` : "explicit-known-path", 1);
543
619
  }
620
+ if (!expanded.directory && expanded.files.length === 1 && expanded.denied.length === 0 && !expanded.truncated) {
621
+ exactKnownFiles.push(expanded.files[0]!);
622
+ } else {
623
+ exactKnownFileScope = false;
624
+ }
544
625
  deniedPaths.push(...expanded.denied);
545
626
  scannedFiles += expanded.files.length;
546
627
  searchTruncated = searchTruncated || expanded.truncated;
@@ -550,16 +631,27 @@ export function buildControllerContextPack(
550
631
  if (!includeGlobs.includes(glob)) includeGlobs.push(glob);
551
632
  }
552
633
 
634
+ // Exact known files are already a caller-selected implementation scope. When
635
+ // no broader impact/structural/include scope was requested, search those files
636
+ // for useful hit lines instead of rereading the whole repository. Planning,
637
+ // debugging, directories and explicit broader scopes retain full discovery.
638
+ const scopedExactKnownFileSearch = exactKnownFileScope
639
+ && retrievalMode === "implementation"
640
+ && structuralMode === "off"
641
+ && impactDomains.length === 0
642
+ && searchIncludeGlobs.length === 0;
643
+
553
644
  // Lexical retrieval is one bounded pass across candidate files for all terms.
554
645
  // The old per-term loop reread the same source files up to 14 times even
555
646
  // though inventory itself was cached.
556
647
  if (searchQueries.length > 0 && candidates.size < maxFiles * 3) {
557
648
  const search = searchRepositoryMany(repoRoot, policy, {
558
649
  queries: searchQueries,
559
- includeGlobs: searchIncludeGlobs,
650
+ files: scopedExactKnownFileSearch ? exactKnownFiles : undefined,
651
+ includeGlobs: scopedExactKnownFileSearch ? undefined : searchIncludeGlobs,
560
652
  excludeGlobs,
561
653
  maxResultsPerQuery: Math.max(maxFiles * 4, 12),
562
- maxFiles: MAX_TOTAL_SEARCHED_FILES,
654
+ maxFiles: scopedExactKnownFileSearch ? exactKnownFiles.length : MAX_TOTAL_SEARCHED_FILES,
563
655
  caseSensitive: false,
564
656
  cacheKey: JSON.stringify({ head: git.head, status: git.status, diffStat: git.diffStat }),
565
657
  });
@@ -685,6 +777,65 @@ export function buildControllerContextPack(
685
777
  const rawCodeRequiredForImplementation = retrievalMode !== "implementation"
686
778
  || files.length === 0
687
779
  || rawSnippetTruncated;
780
+ const primaryTargets = Array.from(new Set(structuralContext.entryPoints.map((entry) => entry.filePath).filter(Boolean))).slice(0, 20);
781
+ const structuralUniverse = Array.from(new Set([
782
+ ...primaryTargets,
783
+ ...graphImpactFiles,
784
+ ...structuralContext.relatedFiles,
785
+ ...selected.map((entry) => entry.path),
786
+ ])).slice(0, 160);
787
+ const relevantTests = structuralUniverse.filter((path) => /(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[^.]+$/i.test(path)).slice(0, 40);
788
+ const architectureContracts = structuralUniverse.filter((path) =>
789
+ path === "AGENTS.md"
790
+ || path === "CLAUDE.md"
791
+ || path.startsWith("docs/architecture/")
792
+ || path.startsWith(".ai/context/"),
793
+ ).slice(0, 40);
794
+ const mustInspect = Array.from(new Set([...primaryTargets, ...graphImpactFiles, ...relevantTests, ...architectureContracts])).slice(0, 80);
795
+ const mustInspectSet = new Set(mustInspect);
796
+ const likelyAffected = structuralUniverse.filter((path) => !mustInspectSet.has(path)).slice(0, 80);
797
+ const changedFileCount = structuralContext.metadata
798
+ ? structuralContext.metadata.changedFiles.added.length
799
+ + structuralContext.metadata.changedFiles.modified.length
800
+ + structuralContext.metadata.changedFiles.removed.length
801
+ : 0;
802
+ const coverageGaps = [
803
+ ...(structuralContext.status === "stale" ? ["structural_index_stale"] : []),
804
+ ...(structuralContext.indexSource === "repository_baseline" ? ["structural_repository_baseline_overlay"] : []),
805
+ ...(!structuralContext.baselineRevisionMatches ? ["structural_baseline_revision_mismatch"] : []),
806
+ ...(structuralContext.status === "unavailable" || structuralContext.status === "degraded" ? ["structural_provider_unavailable"] : []),
807
+ ...(structuralContext.truncated ? ["structural_result_truncated"] : []),
808
+ ...(changedFileCount > 0 ? ["structural_index_has_unindexed_changes"] : []),
809
+ ...impactCoverage.filter((coverage) => coverage.status !== "selected").map((coverage) => `impact_domain_${coverage.status}:${coverage.domain}`),
810
+ ];
811
+ const impactContext: ControllerContextPackProjection["impactContext"] = {
812
+ status: structuralMode === "off" ? "not_requested" : structuralContext.status === "ready" ? "ready" : "degraded",
813
+ confidence: structuralContext.status === "ready" && primaryTargets.length > 0 && !structuralContext.truncated
814
+ ? "high"
815
+ : structuralContext.status === "ready" || files.length > 0
816
+ ? "medium"
817
+ : "low",
818
+ primaryTargets,
819
+ mustInspect,
820
+ likelyAffected,
821
+ relevantTests,
822
+ relevantChecks: taskChecks,
823
+ architectureContracts,
824
+ coverageGaps: Array.from(new Set(coverageGaps)).slice(0, 40),
825
+ relationSources: [
826
+ ...(structuralMode !== "off" ? ["codegraph" as const] : []),
827
+ ...(files.length > 0 ? ["lexical" as const] : []),
828
+ ...(taskChecks.length > 0 ? ["forge_checks" as const] : []),
829
+ ],
830
+ freshness: {
831
+ structuralStatus: structuralContext.status,
832
+ lastIndexedAt: structuralContext.metadata?.lastIndexedAt,
833
+ changedFileCount,
834
+ indexSource: structuralContext.indexSource,
835
+ overlayChangedFileCount: structuralContext.overlayChangedFiles.length,
836
+ baselineRevisionMatches: structuralContext.baselineRevisionMatches,
837
+ },
838
+ };
688
839
 
689
840
  return {
690
841
  schemaVersion: CONTEXT_PACK_SCHEMA_VERSION,
@@ -712,6 +863,7 @@ export function buildControllerContextPack(
712
863
  truncated: searchTruncated,
713
864
  },
714
865
  structuralContext,
866
+ impactContext,
715
867
  files,
716
868
  deniedPaths: deniedPaths.slice(0, 20),
717
869
  omitted: omitted.slice(0, 30),
@@ -732,7 +884,7 @@ export function buildControllerContextPack(
732
884
  retrievalMode === "implementation"
733
885
  ? "The pack already contains policy-approved current raw source snippets with file SHA identities. Request wider/exact ranges only when ChatGPT judges the impact surface ambiguous or an expansion signal makes the raw evidence mechanically incomplete."
734
886
  : "Investigation modes may intentionally expand exact ranges, structural relationships, tests, and neighboring modules before any implementation decision.",
735
- "Search/CodeGraph ranking is discovery evidence, not a business-semantics authority. Forge never decides that the returned files are the complete semantic impact surface.",
887
+ "Search/CodeGraph ranking is discovery evidence, not a business-semantics authority. impactContext makes the bounded evidence surface and coverage gaps explicit; ChatGPT still decides semantic sufficiency.",
736
888
  impactDomains.length > 0 ? `Impact domains were selected by ChatGPT and expanded mechanically in this same retrieval call: ${impactDomains.join(", ")}. Missing or omitted domain evidence is an ambiguity signal, not proof that the domain is irrelevant.` : "No explicit cross-cutting impact domains were requested; ChatGPT may add them when state, scheduling, notifications, events, caching, API, or concurrency could materially change the implementation.",
737
889
  "CodeGraph structural evidence is discovery evidence. Raw source access still passes through Forge repository policy and current-file reads.",
738
890
  structuralContext.status === "stale" ? "CodeGraph reports stale structural evidence. Treat graph relationships as hints and prefer the returned current raw source for changed files." : structuralContext.status === "unavailable" || structuralContext.status === "degraded" ? "Structural context was unavailable or degraded; bounded text search remains the fallback." : structuralContext.status === "ready" ? "CodeGraph structural context was queried read-only with index sync disabled." : "Structural context was not requested.",