@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.
- package/CHANGELOG.md +28 -1
- package/DESIGN.md +7 -2
- package/README.md +7 -7
- package/README.zh-CN.md +5 -3
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/assets/hooks/pre-edit-guard.sh +8 -39
- package/assets/hooks/prompt-guard.sh +2 -2
- package/assets/plugin-registry.v1.json +54 -3
- package/assets/reference-configs/hook-operations.md +1 -1
- package/assets/reference-configs/sprint-contracts.md +2 -2
- package/assets/skill-version.json +2 -2
- package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
- package/assets/templates/helpers/sprint-backlog.sh +6 -6
- package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
- package/docs/architecture/current/codegraph-context-provider.md +15 -3
- package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
- package/docs/architecture/current/entity-model.md +20 -0
- package/docs/architecture/current/external-plugin-governance.md +3 -3
- package/docs/architecture/current/system-overview.md +26 -1
- package/docs/architecture/current/thin-harness-v1.md +10 -0
- package/docs/forge-chatgpt-mcp-setup.md +27 -3
- package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
- package/docs/operations/controller-browser-plugin.md +12 -16
- package/docs/operations/features.md +1 -1
- package/docs/operations/homebrew.md +2 -2
- package/docs/public-usage-guide.md +1 -1
- package/docs/public-usage-guide.zh-CN.md +1 -1
- package/docs/tutorials/01-install-and-start.md +6 -4
- package/docs/tutorials/01-install-and-start.zh-CN.md +3 -1
- package/docs/tutorials/02-connect-chatgpt.md +8 -2
- package/docs/tutorials/02-connect-chatgpt.zh-CN.md +8 -2
- package/docs/wiki/Installation.md +1 -1
- package/docs/wiki/Quick-Start.md +1 -1
- package/package.json +1 -1
- package/scripts/check-public-docs.sh +14 -1
- package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
- package/scripts/sprint-backlog.sh +1 -1
- package/src/cli/commands/chatgpt.ts +31 -2
- package/src/cli/commands/init-hook.ts +3 -1
- package/src/cli/commands/mcp.ts +2 -0
- package/src/cli/commands/plugin.ts +36 -9
- package/src/cli/commands/runtime.ts +1 -0
- package/src/cli/commands/setup-profile.ts +49 -18
- package/src/cli/controller/context-pack.ts +158 -6
- package/src/cli/hook/prompt-guard-decision.ts +17 -18
- package/src/cli/hook/prompt-intents.ts +1 -1
- package/src/cli/local-bridge/console-assets.ts +17 -0
- package/src/cli/local-bridge/console-automations.ts +205 -23
- package/src/cli/local-bridge/console-view-models.ts +2 -0
- package/src/cli/local-bridge/facade-api.ts +2 -0
- package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
- package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
- package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
- package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
- package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
- package/src/cli/local-bridge/frontend/src/styles.css +27 -11
- package/src/cli/local-bridge/frontend/src/types.ts +6 -2
- package/src/cli/local-bridge/server.ts +31 -20
- package/src/cli/local-bridge/ui-dist/app.css +1 -1
- package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
- package/src/cli/mcp/auth.ts +2 -0
- package/src/cli/mcp/paths.ts +8 -2
- package/src/cli/mcp/repository-tools.ts +124 -9
- package/src/cli/mcp/server.ts +164 -19
- package/src/cli/mcp/setup.ts +9 -1
- package/src/cli/mcp/transports/http.ts +52 -2
- package/src/cli/mcp/transports/session-registry.ts +24 -0
- package/src/cli/repositories/command-classifier.ts +113 -0
- package/src/cli/repositories/safe-patch.ts +6 -4
- package/src/cli/repositories/structured-git.ts +49 -7
- package/src/cli/repository/inspector.ts +16 -3
- package/src/runtime/assistant/intent.ts +3 -3
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
- package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
- package/src/runtime/control-plane/execution/validation.ts +15 -2
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
- package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
- package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
- package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
- package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
- package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
- package/src/runtime/control-plane/facade/types.ts +33 -6
- package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
- package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
- package/src/runtime/diagnostics/mcp-timing.ts +6 -0
- package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
- package/src/runtime/execution/process-runtime/check-result.ts +10 -2
- package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
- package/src/runtime/execution/process-runtime/gc.ts +64 -10
- package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
- package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
- package/src/runtime/execution/process-runtime/runtime.ts +16 -1
- package/src/runtime/execution/process-runtime/store.ts +6 -0
- package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
- package/src/runtime/gateway/mcp/router.ts +1 -1
- package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
- package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
- package/src/runtime/plugins/browser-adapter.ts +19 -72
- package/src/runtime/plugins/browser-handoff-host.ts +6 -31
- package/src/runtime/plugins/browser-handoff.ts +0 -3
- package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
- package/src/runtime/plugins/external-registration.ts +84 -5
- package/src/runtime/plugins/first-party-registry.ts +2 -2
- package/src/runtime/plugins/gmail-adapter.ts +28 -2
- package/src/runtime/plugins/local-system-adapter.ts +2 -2
- package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
- package/src/runtime/plugins/store.ts +107 -5
- package/src/runtime/plugins/types.ts +4 -0
- package/src/runtime/recovery/actions.ts +0 -10
- package/src/runtime/recovery/classifier.ts +0 -9
- package/src/runtime/recovery/maintenance-executor.ts +43 -11
- package/src/runtime/recovery/probe.ts +0 -1
- package/src/runtime/recovery/types.ts +0 -1
- package/src/runtime/root/package-connector-service.ts +136 -0
- package/src/runtime/root/package-runtime-service.ts +25 -12
- package/src/runtime/root/release-materialize.ts +161 -18
- package/src/runtime/root/service.ts +6 -4
- package/src/runtime/safe-tooling/index.ts +0 -1
- package/src/runtime/safe-tooling/types.ts +0 -30
- package/src/runtime/standalone-recovery/core.ts +2 -2
- package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
- package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
- package/src/runtime/workflow/schedules/engine.ts +357 -64
- package/src/runtime/workflow/schedules/types.ts +5 -0
- package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
- package/bin/forge-desktop-helper.mjs +0 -204
- package/docs/operations/controller-desktop-plugin.md +0 -112
- package/src/runtime/plugins/desktop-adapter.ts +0 -374
- 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 {
|
|
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>', '
|
|
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
|
|
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, {
|
package/src/cli/commands/mcp.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
76
|
+
return createDesktopOperatorRegistrationInput({socketPath,launchAgentLabel,expectedProgramContains,pluginVersion:expectedProviderVersion,protocolVersion:expectedProtocolVersion});
|
|
51
77
|
}
|
|
52
78
|
function install(entry:RegistryEntry,controllerHome:string):Record<string,unknown>{
|
|
53
|
-
|
|
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=
|
|
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=>
|
|
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
|
|
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
|
}
|
|
@@ -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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
357
|
-
const
|
|
358
|
-
|
|
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
|
|
374
|
-
|
|
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.
|
|
378
|
-
command: `tunnel-client runtimes connect --alias ${alias} --tunnel-id ${profile.tunnel.tunnelId} --runtime-api-key env:CONTROL_PLANE_API_KEY --mcp-server-url
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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.",
|