From 7597729a8b2da3ab4fd0dc98a601d68653632d4b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:37:47 -0400 Subject: [PATCH 1/7] fix: ADE CLI/PR workflow and integration lane ownership - Expand ADE CLI RPC, bootstrap, and command surface; add shared adeCliGuidance for agent prompts. - Wire desktop orchestrator, chat, CTO, and main for PR/CLI behavior. - Derive integration scratch lanes from missing merge-into id; avoid misclassifying adopted lanes in cleanup. Made-with: Cursor --- apps/ade-cli/src/adeRpcServer.test.ts | 117 +++++- apps/ade-cli/src/adeRpcServer.ts | 108 +++++- apps/ade-cli/src/bootstrap.ts | 73 +++- apps/ade-cli/src/cli.test.ts | 113 +++++- apps/ade-cli/src/cli.ts | 338 +++++++++++++++--- apps/desktop/src/main/main.ts | 36 ++ .../services/ai/tools/systemPrompt.test.ts | 2 + .../main/services/ai/tools/systemPrompt.ts | 5 +- .../services/chat/agentChatService.test.ts | 12 +- .../main/services/chat/agentChatService.ts | 7 +- .../src/main/services/cto/ctoStateService.ts | 7 +- .../cto/workerAdapterRuntimeService.test.ts | 3 +- .../cto/workerAdapterRuntimeService.ts | 3 +- .../orchestrator/baseOrchestratorAdapter.ts | 8 +- .../services/orchestrator/coordinatorAgent.ts | 5 +- .../orchestrator/orchestratorService.ts | 3 +- .../services/orchestrator/promptInspector.ts | 7 +- .../src/main/services/prs/prService.ts | 148 ++++++-- apps/desktop/src/shared/adeCliGuidance.ts | 8 + 19 files changed, 878 insertions(+), 125 deletions(-) create mode 100644 apps/desktop/src/shared/adeCliGuidance.ts diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index f2763e853..adcb70682 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -15,6 +15,7 @@ function createRuntime() { const threadRows: Array> = []; const threadMessages = new Map>>(); let messageCounter = 0; + const kv = new Map(); const ensureThread = (input: { missionId: string; attemptId: string; runId?: string | null }): Record => { const existing = threadRows.find( @@ -84,6 +85,14 @@ function createRuntime() { }, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, db: { + getJson: vi.fn((key: string) => (kv.has(key) ? kv.get(key) : null)), + setJson: vi.fn((key: string, value: unknown) => { + if (value == null) { + kv.delete(key); + return; + } + kv.set(key, value); + }), get: vi.fn((sql: string) => { if (sql.includes("orchestrator_evaluations") && sql.includes("SELECT")) { return { @@ -129,6 +138,36 @@ function createRuntime() { } }) }, + keybindingsService: { + get: vi.fn(() => [{ command: "ade.openCommandPalette", binding: "mod+k" }]), + set: vi.fn((overrides: unknown) => overrides), + } as any, + onboardingService: { + getStatus: vi.fn(() => ({ completedAt: null, dismissedAt: null, freshProject: false })), + detectDefaults: vi.fn(async () => ({ indicators: [] })), + } as any, + automationPlannerService: { + validateDraft: vi.fn((draft: unknown) => ({ ok: true, draft })), + } as any, + githubService: { + getStatus: vi.fn(async () => ({ tokenStored: false, repo: "owner/repo" })), + getRepoOrThrow: vi.fn(() => ({ owner: "owner", repo: "repo" })), + setToken: vi.fn(async () => ({ tokenStored: true })), + clearToken: vi.fn(async () => ({ tokenStored: false })), + } as any, + usageTrackingService: { + getUsageSnapshot: vi.fn(() => ({ available: true, entries: [] })), + forceRefresh: vi.fn(async () => ({ available: true, entries: [] })), + poll: vi.fn(async () => ({ available: true, entries: [] })), + start: vi.fn(() => {}), + stop: vi.fn(() => {}), + } as any, + autoUpdateService: { + getSnapshot: vi.fn(() => ({ status: "idle", version: null })), + checkForUpdates: vi.fn(() => {}), + dismissInstalledNotice: vi.fn(() => {}), + quitAndInstall: vi.fn(() => false), + } as any, laneService: { list: vi.fn(async () => laneRows), listUnregisteredWorktrees: vi.fn(async () => [{ path: "/tmp/untracked-worktree", branch: "feature/untracked" }]), @@ -178,7 +217,12 @@ function createRuntime() { }, gitService: { getConflictState: vi.fn(async () => ({ laneId: "lane-1", kind: null, inProgress: false, conflictedFiles: [], canContinue: false, canAbort: false })), + stageFile: vi.fn(async () => ({ success: true })), stageAll: vi.fn(async () => ({ success: true })), + unstageFile: vi.fn(async () => ({ success: true })), + unstageAll: vi.fn(async () => ({ success: true })), + discardFile: vi.fn(async () => ({ success: true })), + restoreStagedFile: vi.fn(async () => ({ success: true })), commit: vi.fn(async () => ({ success: true })), generateCommitMessage: vi.fn(async () => ({ message: "generated commit message", model: "gpt-5-mini" })), listRecentCommits: vi.fn(async () => [{ sha: "abc123", subject: "test" }]), @@ -293,6 +337,7 @@ function createRuntime() { })), getNewItems: vi.fn((_prId: string) => []), markSentToAgent: vi.fn(), + privateMaintenanceTask: vi.fn(), resetInventory: vi.fn(), saveConvergenceRuntime: vi.fn((prId: string, state: Record) => { const existing = runtimeByPr.get(prId) ?? {}; @@ -311,6 +356,7 @@ function createRuntime() { simulateIntegration: vi.fn(async () => ({ steps: [], conflicts: [], clean: true })), createQueuePrs: vi.fn(async () => ({ groupId: "group-1", prs: [] })), createIntegrationPr: vi.fn(async () => ({ prId: "pr-int-1", url: "https://github.com/pr/1" })), + draftDescription: vi.fn(async () => ({ title: "Drafted PR", body: "Drafted body" })), createFromLane: vi.fn(async () => ({ id: "pr-new", laneId: "lane-1", title: "New PR", status: "open" })), getPrHealth: vi.fn(async (prId: string) => ({ prId, healthy: true, checks: "pass", reviews: "approved" })), landQueueNext: vi.fn(async () => ({ landed: true, prId: "pr-1", sha: "def456" })), @@ -1829,6 +1875,7 @@ describe("adeRpcServer", () => { expect(response.structuredContent.startupCommand).toContain("claude"); expect(response.structuredContent.startupCommand).toContain("--model"); expect(response.structuredContent.startupCommand).toContain("--permission-mode"); + expect(response.structuredContent.startupCommand).toContain("Before reporting an ADE lane"); expect(response.structuredContent.permissionMode).toBe("default"); expect(response.structuredContent.contextRef?.path).toBeNull(); }); @@ -2718,6 +2765,7 @@ describe("adeRpcServer", () => { expect(response.structuredContent.permissionMode).toBe("plan"); expect(response.structuredContent.startupCommand).toContain("--sandbox"); expect(response.structuredContent.startupCommand).toContain("read-only"); + expect(response.structuredContent.startupCommand).toContain("Before reporting an ADE lane"); const contextPath = response.structuredContent.contextRef?.path as string | null; expect(contextPath).toBeTruthy(); expect(contextPath?.includes("/.ade/cache/orchestrator/agent-context/run-123/")).toBe(true); @@ -3112,6 +3160,20 @@ describe("adeRpcServer", () => { draft: true, }); + const drafted = await callTool(handler, "create_pr_from_lane", { + laneId: "lane-1", + baseBranch: "main", + }); + expect(drafted?.isError).toBeUndefined(); + expect(fixture.runtime.prService.draftDescription).toHaveBeenCalledWith({ laneId: "lane-1" }); + expect(fixture.runtime.prService.createFromLane).toHaveBeenLastCalledWith({ + laneId: "lane-1", + baseBranch: "main", + title: "Drafted PR", + body: "Drafted body", + draft: false, + }); + const updateTitle = await callTool(handler, "pr_update_title", { prId: "pr-1", title: "Renamed" }); expect(updateTitle?.isError).toBeUndefined(); expect(fixture.runtime.prService.updateTitle).toHaveBeenCalledWith({ prId: "pr-1", title: "Renamed" }); @@ -3130,12 +3192,28 @@ describe("adeRpcServer", () => { expect(response?.isError).toBeUndefined(); expect(response.structuredContent.actions.some((entry: { action: string }) => entry.action === "push")).toBe(true); expect(response.structuredContent.actions.some((entry: { action: string }) => entry.action === "commit")).toBe(true); + expect(response.structuredContent.actions.some((entry: { action: string }) => entry.action === "stageFile")).toBe(true); + expect(response.structuredContent.actions.every((entry: { name?: string; usage?: string }) => entry.name && entry.usage)).toBe(true); const allDomains = await callTool(handler, "list_ade_actions", { domain: "all" }); expect(allDomains?.isError).toBeUndefined(); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "memory")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "mission")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "orchestrator")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "orchestrator_core")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "cto_state")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "worker_agent")).toBe(true); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "computer_use_artifacts")).toBe(true); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "operation")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "keybindings")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "onboarding")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "automation_planner")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "github")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "usage")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "update")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "layout")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "tiling_tree")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "graph_state")).toBe(true); }); it("invokes ADE actions dynamically and returns status hints", async () => { @@ -3160,9 +3238,33 @@ describe("adeRpcServer", () => { }); expect(variadic?.isError).toBeUndefined(); expect(fixture.runtime.operationService.list).toHaveBeenCalledWith({ limit: 10 }); + + const keybindings = await callTool(handler, "run_ade_action", { + domain: "keybindings", + action: "get", + args: {}, + }); + expect(keybindings?.isError).toBeUndefined(); + expect(fixture.runtime.keybindingsService.get).toHaveBeenCalled(); + + const layoutSet = await callTool(handler, "run_ade_action", { + domain: "layout", + action: "set", + args: { layoutId: "main", layout: { left: 120, right: -5, ignored: "wide" } }, + }); + expect(layoutSet?.isError).toBeUndefined(); + expect(fixture.runtime.db.setJson).toHaveBeenCalledWith("dock_layout:main", { left: 100, right: 0 }); + + const layoutGet = await callTool(handler, "run_ade_action", { + domain: "layout", + action: "get", + args: { layoutId: "main" }, + }); + expect(layoutGet?.isError).toBeUndefined(); + expect(layoutGet.structuredContent.result).toEqual({ left: 100, right: 0 }); }); - it("does not expose internal service mutators through dynamic ADE actions", async () => { + it("does not expose unlisted service methods through dynamic ADE actions", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); await initialize(handler, { callerId: "agent-1", role: "agent" }); @@ -3171,21 +3273,22 @@ describe("adeRpcServer", () => { expect(listed?.isError).toBeUndefined(); const actions = listed.structuredContent.actions.map((entry: { action: string }) => entry.action); expect(actions).toContain("getPipelineSettings"); - expect(actions).not.toContain("resetInventory"); - expect(actions).not.toContain("saveConvergenceRuntime"); - expect(actions).not.toContain("deletePipelineSettings"); + expect(actions).toContain("resetInventory"); + expect(actions).toContain("saveConvergenceRuntime"); + expect(actions).toContain("deletePipelineSettings"); + expect(actions).not.toContain("privateMaintenanceTask"); const response = await callTool(handler, "run_ade_action", { domain: "issue_inventory", - action: "resetInventory", + action: "privateMaintenanceTask", argsList: ["pr-1"], }); expect(response.isError).toBe(true); expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain( - "Action 'issue_inventory.resetInventory' is not exposed through ADE actions.", + "Action 'issue_inventory.privateMaintenanceTask' is not exposed through ADE actions.", ); - expect(fixture.runtime.issueInventoryService.resetInventory).not.toHaveBeenCalled(); + expect(fixture.runtime.issueInventoryService.privateMaintenanceTask).not.toHaveBeenCalled(); }); it("rejects run_ade_action when the action is not a callable on the domain service", async () => { diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index af22482dc..a8fb162e2 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -27,10 +27,13 @@ import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../ import { runGit } from "../../desktop/src/main/services/git/git"; import { resolvePathWithinRoot } from "../../desktop/src/main/services/shared/utils"; import { getDefaultModelDescriptor } from "../../desktop/src/shared/modelRegistry"; +import { ADE_CLI_INLINE_GUIDANCE } from "../../desktop/src/shared/adeCliGuidance"; import { getPrIssueResolutionAvailability } from "../../desktop/src/shared/prIssueResolution"; import { type LinearWorkflowConfig, type ComputerUseArtifactOwner, + type DockLayout, + type GraphPersistedState, type MergeMethod, } from "../../desktop/src/shared/types"; import type { PrActionRun, PrCheck, PrComment, PrReviewThread } from "../../desktop/src/shared/types/prs"; @@ -159,7 +162,7 @@ const TOOL_SPECS: ToolSpec[] = [ }, { name: "list_ade_actions", - description: "List callable ADE action methods across core runtime services (lane/git/pr/tests/chat/mission/orchestrator).", + description: "List callable ADE service methods exposed to the CLI. Actions are returned as domain.action names with CLI usage hints.", inputSchema: { type: "object", additionalProperties: false, @@ -174,25 +177,63 @@ const TOOL_SPECS: ToolSpec[] = [ "pr", "tests", "chat", + "keybindings", + "agent_tools", + "ade_cli", + "dev_tools", + "ai", + "sync", + "onboarding", + "automation", + "automation_planner", + "automation_ingress", + "context", "mission", + "mission_preflight", "orchestrator", "orchestrator_core", + "mission_budget", "memory", "cto_state", "worker_agent", + "worker_budget", + "worker_revision", + "worker_heartbeat", + "worker_task_session", + "openclaw", "session", + "session_delta", "operation", "project_config", "issue_inventory", + "queue_landing", + "pr_summary", "flow_policy", + "linear_credentials", "linear_dispatcher", "linear_issue_tracker", "linear_sync", "linear_ingress", "linear_routing", + "github", + "feedback", + "usage", + "budget", + "update", "file", "process", "pty", + "lane_env", + "lane_template", + "port_allocation", + "lane_proxy", + "oauth_redirect", + "runtime_diagnostics", + "rebase_suggestion", + "auto_rebase", + "layout", + "tiling_tree", + "graph_state", "computer_use_artifacts", "all" ], @@ -203,7 +244,7 @@ const TOOL_SPECS: ToolSpec[] = [ }, { name: "run_ade_action", - description: "Invoke any ADE action by domain and action name. Use args for object-style calls, or arg for scalar-style calls.", + description: "Invoke an exposed ADE service method by domain and action. Use args for one object parameter, argsList for multiple positional parameters, or arg for one scalar parameter.", inputSchema: { type: "object", required: ["domain", "action"], @@ -219,25 +260,63 @@ const TOOL_SPECS: ToolSpec[] = [ "pr", "tests", "chat", + "keybindings", + "agent_tools", + "ade_cli", + "dev_tools", + "ai", + "sync", + "onboarding", + "automation", + "automation_planner", + "automation_ingress", + "context", "mission", + "mission_preflight", "orchestrator", "orchestrator_core", + "mission_budget", "memory", "cto_state", "worker_agent", + "worker_budget", + "worker_revision", + "worker_heartbeat", + "worker_task_session", + "openclaw", "session", + "session_delta", "operation", "project_config", "issue_inventory", + "queue_landing", + "pr_summary", "flow_policy", + "linear_credentials", "linear_dispatcher", "linear_issue_tracker", "linear_sync", "linear_ingress", "linear_routing", + "github", + "feedback", + "usage", + "budget", + "update", "file", "process", "pty", + "lane_env", + "lane_template", + "port_allocation", + "lane_proxy", + "oauth_redirect", + "runtime_diagnostics", + "rebase_suggestion", + "auto_rebase", + "layout", + "tiling_tree", + "graph_state", "computer_use_artifacts", "automations", "issue", @@ -935,10 +1014,10 @@ const TOOL_SPECS: ToolSpec[] = [ }, { name: "create_pr_from_lane", - description: "Create a PR from a lane branch.", + description: "Create a PR from a lane branch. Drafts a title/body from ADE context when omitted.", inputSchema: { type: "object", - required: ["laneId", "baseBranch", "title"], + required: ["laneId"], additionalProperties: false, properties: { laneId: { type: "string", minLength: 1 }, @@ -4099,6 +4178,8 @@ async function runTool(args: { return listAllowedAdeActionNames(entry, service).map((action) => ({ domain: entry, action, + name: `${entry}.${action}`, + usage: `ade actions run ${entry}.${action} --input-json '{"key":"value"}' (or --scalar value / --args-list-json '[...]' for scalar or positional service methods)`, })); }); return { @@ -5402,16 +5483,22 @@ async function runTool(args: { if (name === "create_pr_from_lane") { const laneId = assertNonEmptyString(toolArgs.laneId, "laneId"); - const baseBranch = assertNonEmptyString(toolArgs.baseBranch, "baseBranch"); - const title = assertNonEmptyString(toolArgs.title, "title"); - const body = asOptionalTrimmedString(toolArgs.body); + const baseBranch = asOptionalTrimmedString(toolArgs.baseBranch); + const prSvc = requirePrService(runtime); + let title = asOptionalTrimmedString(toolArgs.title); + let body = typeof toolArgs.body === "string" ? toolArgs.body : null; + if (!title || body == null) { + const draft = await prSvc.draftDescription({ laneId }); + title = title || asOptionalTrimmedString(draft.title) || `PR for ${laneId}`; + body = body ?? asOptionalTrimmedString(draft.body) ?? ""; + } const draft = asBoolean(toolArgs.draft, false); - const pr = await requirePrService(runtime).createFromLane({ + const pr = await prSvc.createFromLane({ laneId, - baseBranch, title, - body: body ?? "", + body, draft, + ...(baseBranch ? { baseBranch } : {}), }); return { pr }; } @@ -5668,6 +5755,7 @@ async function runTool(args: { }); const promptSegments: string[] = []; + promptSegments.push(ADE_CLI_INLINE_GUIDANCE); if (promptRunId || promptStepId || promptAttemptId) { promptSegments.push( `Mission context: run=${promptRunId ?? "n/a"} step=${promptStepId ?? "n/a"} attempt=${promptAttemptId ?? "n/a"}.` diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index f68bb393c..cb1eadeef 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -13,15 +13,36 @@ import { createConflictService } from "../../desktop/src/main/services/conflicts import { createGitOperationsService } from "../../desktop/src/main/services/git/gitOperationsService"; import { createDiffService } from "../../desktop/src/main/services/diffs/diffService"; import { createMissionService } from "../../desktop/src/main/services/missions/missionService"; +import type { createMissionPreflightService } from "../../desktop/src/main/services/missions/missionPreflightService"; import { createPtyService } from "../../desktop/src/main/services/pty/ptyService"; import { createTestService } from "../../desktop/src/main/services/tests/testService"; +import type { createKeybindingsService } from "../../desktop/src/main/services/keybindings/keybindingsService"; +import type { createAgentToolsService } from "../../desktop/src/main/services/agentTools/agentToolsService"; +import type { createAdeCliService } from "../../desktop/src/main/services/cli/adeCliService"; +import type { createDevToolsService } from "../../desktop/src/main/services/devTools/devToolsService"; +import type { createOnboardingService } from "../../desktop/src/main/services/onboarding/onboardingService"; +import type { createLaneEnvironmentService } from "../../desktop/src/main/services/lanes/laneEnvironmentService"; +import type { createLaneTemplateService } from "../../desktop/src/main/services/lanes/laneTemplateService"; +import type { createPortAllocationService } from "../../desktop/src/main/services/lanes/portAllocationService"; +import type { createLaneProxyService } from "../../desktop/src/main/services/lanes/laneProxyService"; +import type { createOAuthRedirectService } from "../../desktop/src/main/services/lanes/oauthRedirectService"; +import type { createRuntimeDiagnosticsService } from "../../desktop/src/main/services/lanes/runtimeDiagnosticsService"; +import type { createRebaseSuggestionService } from "../../desktop/src/main/services/lanes/rebaseSuggestionService"; +import type { createAutoRebaseService } from "../../desktop/src/main/services/lanes/autoRebaseService"; import type { createAgentChatService } from "../../desktop/src/main/services/chat/agentChatService"; import type { createPrService } from "../../desktop/src/main/services/prs/prService"; +import type { createPrSummaryService } from "../../desktop/src/main/services/prs/prSummaryService"; +import type { createQueueLandingService } from "../../desktop/src/main/services/prs/queueLandingService"; import { createIssueInventoryService } from "../../desktop/src/main/services/prs/issueInventoryService"; import { createMemoryService } from "../../desktop/src/main/services/memory/memoryService"; import { createCtoStateService } from "../../desktop/src/main/services/cto/ctoStateService"; import { createWorkerAgentService } from "../../desktop/src/main/services/cto/workerAgentService"; import { createWorkerBudgetService } from "../../desktop/src/main/services/cto/workerBudgetService"; +import type { createWorkerRevisionService } from "../../desktop/src/main/services/cto/workerRevisionService"; +import type { createWorkerHeartbeatService } from "../../desktop/src/main/services/cto/workerHeartbeatService"; +import type { createWorkerTaskSessionService } from "../../desktop/src/main/services/cto/workerTaskSessionService"; +import type { createLinearCredentialService } from "../../desktop/src/main/services/cto/linearCredentialService"; +import type { createOpenclawBridgeService } from "../../desktop/src/main/services/cto/openclawBridgeService"; import type { createFlowPolicyService } from "../../desktop/src/main/services/cto/flowPolicyService"; import type { createLinearDispatcherService } from "../../desktop/src/main/services/cto/linearDispatcherService"; import type { createLinearIssueTracker } from "../../desktop/src/main/services/cto/linearIssueTracker"; @@ -32,13 +53,24 @@ import { createOrchestratorService } from "../../desktop/src/main/services/orche import { createAiOrchestratorService } from "../../desktop/src/main/services/orchestrator/aiOrchestratorService"; import { createAiIntegrationService } from "../../desktop/src/main/services/ai/aiIntegrationService"; import { createMissionBudgetService } from "../../desktop/src/main/services/orchestrator/missionBudgetService"; +import type { createSyncService } from "../../desktop/src/main/services/sync/syncService"; +import type { createSyncHostService } from "../../desktop/src/main/services/sync/syncHostService"; +import type { createAutomationService } from "../../desktop/src/main/services/automations/automationService"; +import type { createAutomationPlannerService } from "../../desktop/src/main/services/automations/automationPlannerService"; +import type { createAutomationIngressService } from "../../desktop/src/main/services/automations/automationIngressService"; +import type { createContextDocService } from "../../desktop/src/main/services/context/contextDocService"; +import type { createGithubService } from "../../desktop/src/main/services/github/githubService"; +import type { createFeedbackReporterService } from "../../desktop/src/main/services/feedback/feedbackReporterService"; +import type { createUsageTrackingService } from "../../desktop/src/main/services/usage/usageTrackingService"; +import type { createBudgetCapService } from "../../desktop/src/main/services/usage/budgetCapService"; +import type { createSessionDeltaService } from "../../desktop/src/main/services/sessions/sessionDeltaService"; +import type { createAutoUpdateService } from "../../desktop/src/main/services/updates/autoUpdateService"; import { createComputerUseArtifactBrokerService, type ComputerUseArtifactBrokerService, } from "../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService"; import type { createFileService } from "../../desktop/src/main/services/files/fileService"; import type { createProcessService } from "../../desktop/src/main/services/processes/processService"; -import type { createGithubService } from "../../desktop/src/main/services/github/githubService"; import { createAutomationService, type AutomationAdeActionRegistry, @@ -81,7 +113,20 @@ export type AdeRuntime = { paths: AdeRuntimePaths; logger: Logger; db: AdeDb; + keybindingsService?: ReturnType | null; + agentToolsService?: ReturnType | null; + adeCliService?: ReturnType | null; + devToolsService?: ReturnType | null; + onboardingService?: ReturnType | null; laneService: ReturnType; + laneEnvironmentService?: ReturnType | null; + laneTemplateService?: ReturnType | null; + portAllocationService?: ReturnType | null; + laneProxyService?: ReturnType | null; + oauthRedirectService?: ReturnType | null; + runtimeDiagnosticsService?: ReturnType | null; + rebaseSuggestionService?: ReturnType | null; + autoRebaseService?: ReturnType | null; sessionService: ReturnType; operationService: ReturnType; projectConfigService: ReturnType; @@ -89,15 +134,25 @@ export type AdeRuntime = { gitService: ReturnType; diffService: ReturnType; missionService: ReturnType; + missionPreflightService?: ReturnType | null; ptyService: ReturnType; testService: ReturnType; + aiIntegrationService?: ReturnType | null; agentChatService?: ReturnType | null; prService?: ReturnType; + prSummaryService?: ReturnType | null; + queueLandingService?: ReturnType | null; issueInventoryService: ReturnType; fileService?: ReturnType | null; memoryService: ReturnType; ctoStateService: ReturnType; workerAgentService: ReturnType; + workerBudgetService?: ReturnType | null; + workerRevisionService?: ReturnType | null; + workerHeartbeatService?: ReturnType | null; + workerTaskSessionService?: ReturnType | null; + linearCredentialService?: ReturnType | null; + openclawBridgeService?: ReturnType | null; flowPolicyService?: ReturnType | null; linearDispatcherService?: ReturnType | null; linearIssueTracker?: ReturnType | null; @@ -111,6 +166,16 @@ export type AdeRuntime = { computerUseArtifactBrokerService: ComputerUseArtifactBrokerService; orchestratorService: ReturnType; aiOrchestratorService: ReturnType; + missionBudgetService?: ReturnType | null; + syncHostService?: ReturnType | null; + syncService?: ReturnType | null; + automationIngressService?: ReturnType | null; + contextDocService?: ReturnType | null; + feedbackReporterService?: ReturnType | null; + usageTrackingService?: ReturnType | null; + budgetCapService?: ReturnType | null; + sessionDeltaService?: ReturnType | null; + autoUpdateService?: ReturnType | null; eventBuffer: EventBuffer; dispose: () => void; }; @@ -415,14 +480,20 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo gitService, diffService, missionService, + missionBudgetService, ptyService, testService, + aiIntegrationService, agentChatService, issueInventoryService, memoryService, ctoStateService, workerAgentService, + workerBudgetService, githubService: headlessLinearServices.githubService as never, + workerTaskSessionService: headlessLinearServices.workerTaskSessionService, + workerHeartbeatService: headlessLinearServices.workerHeartbeatService, + linearCredentialService: headlessLinearServices.linearCredentialService, prService: headlessLinearServices.prService, fileService: headlessLinearServices.fileService, flowPolicyService: headlessLinearServices.flowPolicyService, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index f8aab1005..b7b8907d3 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1,5 +1,8 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; -import { buildCliPlan, formatOutput, parseCliArgs, renderLaneGraph, summarizeExecution, unwrapToolResult } from "./cli"; +import { buildCliPlan, findProjectRoots, formatOutput, parseCliArgs, renderLaneGraph, summarizeExecution, unwrapToolResult } from "./cli"; describe("ADE CLI", () => { it("parses global options without stealing command flags", () => { @@ -97,6 +100,68 @@ describe("ADE CLI", () => { }); }); + it("builds documented generic ADE action JSON shapes", () => { + const objectCall = buildCliPlan([ + "actions", + "run", + "git.push", + "--input-json", + "{\"laneId\":\"lane-1\",\"setUpstream\":true}", + ]); + expect(objectCall.kind).toBe("execute"); + if (objectCall.kind !== "execute") return; + expect(objectCall.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "git", + action: "push", + args: { + laneId: "lane-1", + setUpstream: true, + }, + }, + }); + + const argsListCall = buildCliPlan([ + "actions", + "run", + "issue_inventory.savePipelineSettings", + "--args-list-json", + "[\"pr-1\",{\"maxRounds\":3}]", + ]); + expect(argsListCall.kind).toBe("execute"); + if (argsListCall.kind !== "execute") return; + expect(argsListCall.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "issue_inventory", + action: "savePipelineSettings", + argsList: ["pr-1", { maxRounds: 3 }], + }, + }); + + const scalarCall = buildCliPlan(["actions", "run", "mission.get", "--scalar", "mission-1"]); + expect(scalarCall.kind).toBe("execute"); + if (scalarCall.kind !== "execute") return; + expect(scalarCall.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "mission", + action: "get", + arg: "mission-1", + }, + }); + }); + + it("rejects invalid JSON action shapes before execution", () => { + expect(() => buildCliPlan(["actions", "run", "git.push", "--input-json", "[1,2]"])).toThrow( + /--input-json must be a JSON object/, + ); + expect(() => buildCliPlan(["actions", "run", "git.push", "--args-list-json", "{\"laneId\":\"lane-1\"}"])).toThrow( + /--args-list-json must be a JSON array/, + ); + }); + it("rejects prototype-sensitive generic ADE action arg paths", () => { expect(({} as Record).polluted).toBeUndefined(); @@ -291,6 +356,52 @@ describe("ADE CLI", () => { }); }); + it("uses the parent ADE project when invoked inside an ADE-managed lane worktree", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-roots-")); + const worktree = path.join(root, ".ade", "worktrees", "feature-lane"); + const nested = path.join(worktree, "apps", "ade-cli"); + fs.mkdirSync(path.join(root, ".ade"), { recursive: true }); + fs.mkdirSync(path.join(worktree, ".ade"), { recursive: true }); + fs.mkdirSync(nested, { recursive: true }); + + expect(findProjectRoots(nested)).toEqual({ + projectRoot: root, + workspaceRoot: worktree, + }); + }); + + it("maps PR link arguments to the service contract", () => { + const plan = buildCliPlan(["prs", "link", "--lane", "lane-1", "--url", "https://github.com/acme/ade/pull/123"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "pr", + action: "linkToLane", + args: { + laneId: "lane-1", + prUrlOrNumber: "https://github.com/acme/ade/pull/123", + }, + }, + }); + }); + + it("shows command help from subcommand help flags", () => { + const prsHelp = buildCliPlan(["prs", "create", "--help"]); + expect(prsHelp.kind).toBe("help"); + if (prsHelp.kind !== "help") return; + expect(prsHelp.text).toContain("PR identifiers may be ADE PR ids"); + expect(prsHelp.text).toContain("prs link"); + + const actionsHelp = buildCliPlan(["actions", "run", "--help"]); + expect(actionsHelp.kind).toBe("help"); + if (actionsHelp.kind !== "help") return; + expect(actionsHelp.text).toContain("Argument shapes"); + expect(actionsHelp.text).toContain("--args-list-json"); + }); + it("shell-escapes argv tokens after -- when building shell start commands", () => { const plan = buildCliPlan(["shell", "start", "--lane", "lane-1", "--", "cat", "file with spaces.txt", "literal&name"]); expect(plan.kind).toBe("execute"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 00adae71a..665d3aa2c 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -240,7 +240,11 @@ const ADE_BANNER = String.raw` `; const TOP_LEVEL_HELP = `${ADE_BANNER} - Agent-focused command-line interface for ADE + Agent-focused command-line interface for ADE. + + ADE CLI commands operate on the same project database and live desktop socket + used by the ADE app. By default the CLI connects to the app socket when it is + running; otherwise it falls back to a headless runtime for local-safe actions. $ ade help Display help for a command $ ade auth status Check local ADE CLI readiness @@ -265,87 +269,252 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade actions list | run | status Escape hatch for every ADE service action Global options: - --project-root --workspace-root --headless --socket --json --text --timeout-ms + --project-root ADE project root. Inside .ade/worktrees/, this resolves to the parent project. + --workspace-root Lane/worktree to treat as the active workspace. + --headless Skip the desktop socket and run an in-process ADE runtime. + --socket Require the desktop socket; fail instead of falling back to headless. + --json Print machine-readable JSON. This is the default output mode. + --text Print a compact human-readable summary when a formatter exists. + --timeout-ms Per-request timeout. Long agent/PR workflows may need several minutes. Common agent flows: - $ ade lanes create --name fix-login - $ ade git commit --lane - $ ade prs create --lane --base main --draft - $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge + $ ade doctor --text + $ ade lanes list --text + $ ade lanes create --name fix-login --description "Repair login redirect" + $ ade git status --lane --text + $ ade git stage --lane src/index.ts + $ ade git commit --lane -m "Fix login redirect" + $ ade prs create --lane --base main --draft + $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade proof record --seconds 20 - Escape hatch: + Generic ADE action JSON contract: + Object-shaped call: + $ ade actions run git.push --input-json '{"laneId":"lane-1","setUpstream":true}' + $ ade actions run git.push --arg laneId=lane-1 --arg setUpstream=true + JSON value fields: + $ ade actions run pr.setLabels --arg prId=123 --arg-json 'labels=["ready","ship"]' + Multi-parameter service call: + $ ade actions run issue_inventory.savePipelineSettings --args-list-json '["pr-1",{"maxRounds":3}]' + Single scalar parameter: + $ ade actions run mission.get --scalar mission-1 + $ ade actions list --text - $ ade actions run --arg key=value + $ ade actions list --domain pr --text + $ ade actions run --input-json '{"key":"value"}' - try: ade lanes list --text + Start with: ade doctor --text `; const HELP_BY_COMMAND: Record = { lanes: `${ADE_BANNER} Lanes - $ ade lanes list --text Show the lane stack graph - $ ade lanes show --text Inspect one lane - $ ade lanes create --name Create a lane from the current context - $ ade lanes child --lane --name Create a child lane - $ ade lanes import --branch Bring an existing worktree/branch into ADE - $ ade lanes actions List lane service actions + Lanes are ADE-managed worktrees and branches. Most commands accept either + --lane or a positional lane id. + + $ ade lanes list --text Show lane stack graph and branch names + $ ade lanes show --text Inspect one lane status + $ ade lanes create --name Create a lane from the current project context + $ ade lanes child --lane --name Create a child lane under a parent + $ ade lanes import --branch Register an existing branch/worktree + $ ade lanes archive Archive a lane in ADE + $ ade lanes unarchive Restore an archived lane + $ ade lanes attach --path --name Attach an external worktree + $ ade lanes actions --text List callable lane service methods `, git: `${ADE_BANNER} Git - $ ade git status --lane --text Show ADE-aware sync status - $ ade git commit --lane [-m ] Commit, generating a message when omitted - $ ade git push --lane --set-upstream Push through ADE + Git commands run in the lane worktree and record ADE operations so the app can + refresh lane state. Use --lane for anything other than the active workspace. + + $ ade git status --lane --text Show ADE-aware sync status + $ ade git stage --lane src/file.ts Stage one file + $ ade git stage-all --lane Stage all current changes + $ ade git unstage --lane src/file.ts Unstage one file + $ ade git commit --lane [-m ] Commit, generating a message when omitted + $ ade git push --lane --set-upstream Push through ADE $ ade git stash push|list|apply|pop Use ADE lane stash actions - $ ade git rebase --lane --ai Rebase with ADE conflict support - $ ade diff changes --lane --text Inspect changed files + $ ade git rebase --lane --ai Rebase with ADE conflict support + $ ade diff changes --lane --text Inspect changed files +`, + diff: `${ADE_BANNER} + Diffs + + $ ade diff changes --lane --text Summarize staged/unstaged file changes + $ ade diff file --lane --text Show one file diff + $ ade diff file --mode staged Inspect staged diff for one file + $ ade diff actions --text List diff service actions `, prs: `${ADE_BANNER} Pull requests + PR identifiers may be ADE PR ids, GitHub PR numbers, #numbers, or full PR URLs. + Creating or linking a PR persists the lane mapping in ADE so the PR tab tracks it. + $ ade prs list --text List PRs known to ADE - $ ade prs create --lane --base main Open a PR from a lane + $ ade prs create --lane --base main Open and map a GitHub PR from a lane + $ ade prs link --lane --url Map an existing GitHub PR to a lane $ ade prs checks --text Show check status $ ade prs comments --text Show unresolved review work $ ade prs inventory Refresh ADE issue inventory $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge $ ade prs resolve-thread --thread Resolve a review thread + $ ade prs labels set ready-to-merge Replace labels + $ ade prs reviewers request alice bob Request reviewers `, run: `${ADE_BANNER} Run tab + Run tab commands mirror ADE desktop process definitions and runtime state. + They require the desktop socket when live process state is needed. + $ ade run defs --text List configured run commands - $ ade run ps --lane --text List process runtime state - $ ade run start --lane Start a process in a lane + $ ade run ps --lane --text List process runtime state + $ ade run start --lane Start a process in a lane + $ ade run stop --lane Stop a process in a lane $ ade run logs --run --text Tail process logs - $ ade run stack start --stack --lane Start a process stack + $ ade run stack start --stack --lane Start a process stack + $ ade run start-all --lane Start all configured processes +`, + shell: `${ADE_BANNER} + Shell sessions + + Shell commands create tracked PTY sessions that ADE can display and audit. + + $ ade shell start --lane -- npm test Start a tracked shell session + $ ade shell start --lane -c "npm test" Start with a command string + $ ade shell write --data "q" Write data to a PTY + $ ade shell resize --cols 120 --rows 36 + $ ade shell close Dispose a PTY `, files: `${ADE_BANNER} Files + File commands operate inside an ADE workspace id, usually a lane id. + $ ade files workspaces --text List workspace roots - $ ade files tree --workspace --path src Show a workspace tree - $ ade files read --workspace --text Read a file - $ ade files write --workspace --stdin - $ ade files search --workspace -q Search text in a workspace + $ ade files tree --workspace --path src Show a workspace tree + $ ade files read --workspace --text Read a file + $ ade files write --workspace --stdin + $ ade files write --workspace --text "new content" + $ ade files create --workspace --text "content" + $ ade files mkdir --workspace src/new + $ ade files search --workspace -q Search text in a workspace + $ ade files quick-open --workspace -q app +`, + chat: `${ADE_BANNER} + Work chats + + Chat commands use ADE agent chat sessions. Live provider-backed chat normally + requires the desktop socket because the app owns provider/session state. + + $ ade chat list --text List chat sessions + $ ade chat create --lane --provider codex --model + $ ade chat send --text "next step" Send a message + $ ade chat interrupt Stop an active turn + $ ade chat resume Resume a session + $ ade agent spawn --lane --prompt "fix" Start a new agent work session +`, + agent: `${ADE_BANNER} + Agent sessions + + $ ade agent spawn --lane --prompt "Fix the failing test" + $ ade agent spawn --lane --provider codex --model --permissions workspace-write + $ ade agent spawn --lane --context-file docs/context.md --prompt "continue" + $ ade agent spawn --lane --tool=git --tool=files --prompt "review changes" `, proof: `${ADE_BANNER} Proof and computer use - $ ade proof status --text Show local proof backend capabilities + Proof commands capture or ingest artifacts that ADE can attach to work. + Local screenshot/video fallback is macOS-only; desktop socket mode has the + best parity with the app. + + $ ade proof status --text Show proof backend capabilities $ ade proof list --text List captured artifacts $ ade proof screenshot Capture a screenshot artifact $ ade proof record --seconds 20 Capture a short video proof - $ ade proof ingest --input-json '{...}' Ingest external proof artifacts + $ ade proof launch --app "ADE" Launch an app for proof capture + $ ade proof ingest --input-json '{"artifacts":[]}' Ingest external proof artifacts +`, + tests: `${ADE_BANNER} + Tests + + $ ade tests list --text List configured test suites + $ ade tests run --lane --suite unit Run a configured suite + $ ade tests run --lane --command "npm test" --wait + $ ade tests runs --lane --text List recent test runs + $ ade tests logs --text Tail a test run log + $ ade tests stop Stop an active test run +`, + memory: `${ADE_BANNER} + Memory + + $ ade memory add --category fact --content "User prefers concise summaries" + $ ade memory search -q "release process" --text + $ ade memory pin + $ ade memory core --arg projectSummary="Current focus" +`, + cto: `${ADE_BANNER} + CTO and Work state + + $ ade cto state --text Read CTO identity, core memory, and recent sessions + $ ade cto chats list --text List CTO work chats + $ ade cto chats spawn --lane --prompt "plan this" + $ ade cto chats send --text "continue" + $ ade actions run cto_state.updateCoreMemory --input-json '{"projectSummary":"..."}' + $ ade actions run worker_agent.listAgents --input-json '{"includeDeleted":false}' +`, + linear: `${ADE_BANNER} + Linear workflows + + $ ade linear workflows --text List configured workflows + $ ade linear sync dashboard --text Show sync dashboard + $ ade linear sync run Trigger a sync run + $ ade linear sync queue --text List sync queue items + $ ade linear sync resolve --queue-item --action approve + $ ade linear route worker --input-json '{"issueId":"LIN-123","workerId":"worker-1"}' +`, + flow: `${ADE_BANNER} + Flow policy + + $ ade flow policy get --text Read current workflow policy + $ ade flow policy validate --input-json '{...}' Validate policy JSON + $ ade flow policy save --input-json '{...}' Save policy JSON + $ ade flow policy revisions --text List saved revisions + $ ade flow policy rollback Restore a prior revision +`, + coordinator: `${ADE_BANNER} + Coordinator runtime tools + + Coordinator tools expose orchestration operations used by mission agents. + List tool names with: + $ ade actions call list_ade_actions --input-json '{"domain":"orchestrator_core"}' + + $ ade coordinator --input-json '{"key":"value"}' `, actions: `${ADE_BANNER} ADE actions + Escape hatch for any exposed ADE service method. Use typed commands first + when they exist; use actions when an agent needs exact service coverage. + + Argument shapes: + Object args become one object parameter: + $ ade actions run git.push --input-json '{"laneId":"lane-1","setUpstream":true}' + $ ade actions run git.push --arg laneId=lane-1 --arg setUpstream=true + --arg parses true/false/null/numbers; --arg-json parses a JSON value: + $ ade actions run pr.setLabels --arg prId=123 --arg-json 'labels=["ready","ship"]' + argsList is for service methods with multiple positional parameters: + $ ade actions run issue_inventory.savePipelineSettings --args-list-json '["pr-1",{"maxRounds":3}]' + scalar is for one non-object parameter: + $ ade actions run mission.get --scalar mission-1 + $ ade actions list --text Domain-grouped action catalog - $ ade actions list --domain git Narrow the catalog - $ ade actions run git.stageFile --arg laneId= --arg path=src/index.ts + $ ade actions list --domain git --text Narrow the catalog + $ ade actions run --input-json '{"key":"value"}' $ ade actions run --input-json '{"key":"value"}' $ ade actions status --text Runtime action availability `, @@ -996,7 +1165,22 @@ function buildPrPlan(args: string[]): CliPlan { if (sub === "resolve-thread") return { kind: "execute", label: "PR resolve thread", steps: [actionCallStep("result", "pr_resolve_review_thread", withPr({ prId: requireValue(prId ?? firstPositional(args), "prId"), threadId: requireValue(readValue(args, ["--thread", "--thread-id"]), "threadId") }))] }; if (sub === "title" || sub === "update-title") return { kind: "execute", label: "PR update title", steps: [actionCallStep("result", "pr_update_title", withPr({ prId: prId ?? firstPositional(args), title: readValue(args, ["--title"]) }))] }; if (sub === "body" || sub === "update-body") return { kind: "execute", label: "PR update body", steps: [actionCallStep("result", "pr_update_body", withPr({ prId: prId ?? firstPositional(args), body: readValue(args, ["--body"]) ?? "" }))] }; - if (sub === "link") return { kind: "execute", label: "PR link", steps: [actionStep("result", "pr", "linkToLane", collectGenericObjectArgs(args, { laneId: readLaneId(args) ?? firstPositional(args), url: readValue(args, ["--url"]) }))] }; + if (sub === "link") { + const laneId = readLaneId(args) ?? firstPositional(args); + const prUrlOrNumber = + readValue(args, ["--url", "--pr-url", "--number", "--pr-number"]) + ?? firstPositional(args); + return { + kind: "execute", + label: "PR link", + steps: [ + actionStep("result", "pr", "linkToLane", collectGenericObjectArgs(args, { + laneId: requireValue(laneId, "laneId"), + prUrlOrNumber: requireValue(prUrlOrNumber, "prUrlOrNumber"), + })), + ], + }; + } const scalarPrActions: Record = { status: "getStatus", @@ -1672,25 +1856,47 @@ function buildCoordinatorPlan(args: string[]): CliPlan { return { kind: "execute", label: `coordinator ${toolName}`, steps: [actionCallStep("result", toolName, collectGenericObjectArgs(args))] }; } +function hasHelpFlag(args: string[]): boolean { + const terminatorIndex = args.indexOf("--"); + const searchable = terminatorIndex >= 0 ? args.slice(0, terminatorIndex) : args; + return searchable.includes("--help") || searchable.includes("-h"); +} + function buildCliPlan(command: string[]): CliPlan { const args = [...command]; const primary = firstPositional(args); if (!primary || primary === "-h" || primary === "--help") { return { kind: "help", text: TOP_LEVEL_HELP }; } + const aliases: Record = { + lane: "lanes", + diff: "diff", + diffs: "diff", + file: "files", + pr: "prs", + process: "run", + processes: "run", + pty: "shell", + chats: "chat", + work: "chat", + agents: "agent", + test: "tests", + computer: "proof", + "computer-use": "proof", + artifact: "proof", + artifacts: "proof", + setting: "settings", + config: "settings", + action: "actions", + coord: "coordinator", + automation: "automations", + }; + const primaryHelpKey = aliases[primary] ?? primary; + if (hasHelpFlag(args)) { + return { kind: "help", text: HELP_BY_COMMAND[primaryHelpKey] ?? TOP_LEVEL_HELP }; + } if (primary === "help") { const topic = (firstPositional(args) ?? "").toLowerCase(); - const aliases: Record = { - lane: "lanes", - pr: "prs", - process: "run", - processes: "run", - file: "files", - computer: "proof", - "computer-use": "proof", - action: "actions", - automation: "automations", - }; const key = aliases[topic] ?? topic; return { kind: "help", text: key && HELP_BY_COMMAND[key] ? HELP_BY_COMMAND[key] : TOP_LEVEL_HELP }; } @@ -1749,10 +1955,30 @@ function buildCliPlan(command: string[]): CliPlan { throw new CliUsageError(`Unknown command '${primary}'. Run 'ade help'.`); } -function findProjectRoot(startDir: string): string { +function findAdeManagedWorktreeRoot(startDir: string): { projectRoot: string; workspaceRoot: string } | null { + const resolved = path.resolve(startDir); + const segments = resolved.split(path.sep); + for (let index = segments.length - 2; index >= 0; index -= 1) { + if (segments[index] !== ".ade" || segments[index + 1] !== "worktrees") continue; + const projectRoot = segments.slice(0, index).join(path.sep) || path.sep; + const worktreeName = segments[index + 2]; + if (!worktreeName) continue; + const workspaceRoot = segments.slice(0, index + 3).join(path.sep) || path.sep; + if (!fs.existsSync(path.join(projectRoot, ".ade"))) continue; + return { projectRoot: path.resolve(projectRoot), workspaceRoot: path.resolve(workspaceRoot) }; + } + return null; +} + +function findProjectRoots(startDir: string): { projectRoot: string; workspaceRoot: string } { + const managedWorktree = findAdeManagedWorktreeRoot(startDir); + if (managedWorktree) return managedWorktree; + let cursor = path.resolve(startDir); while (true) { - if (fs.existsSync(path.join(cursor, ".ade"))) return cursor; + if (fs.existsSync(path.join(cursor, ".ade"))) { + return { projectRoot: cursor, workspaceRoot: cursor }; + } const parent = path.dirname(cursor); if (parent === cursor) break; cursor = parent; @@ -1764,14 +1990,16 @@ function findProjectRoot(startDir: string): string { stdio: ["ignore", "pipe", "ignore"], }); const gitRoot = git.status === 0 ? git.stdout.trim() : ""; - return gitRoot ? path.resolve(gitRoot) : path.resolve(startDir); + const fallback = gitRoot ? path.resolve(gitRoot) : path.resolve(startDir); + return { projectRoot: fallback, workspaceRoot: fallback }; } function resolveRoots(options: GlobalOptions): { projectRoot: string; workspaceRoot: string } { + const discovered = findProjectRoots(process.cwd()); const projectRoot = options.projectRoot - ?? (process.env.ADE_PROJECT_ROOT?.trim() ? path.resolve(process.env.ADE_PROJECT_ROOT.trim()) : findProjectRoot(process.cwd())); + ?? (process.env.ADE_PROJECT_ROOT?.trim() ? path.resolve(process.env.ADE_PROJECT_ROOT.trim()) : discovered.projectRoot); const workspaceRoot = options.workspaceRoot - ?? (process.env.ADE_WORKSPACE_ROOT?.trim() ? path.resolve(process.env.ADE_WORKSPACE_ROOT.trim()) : projectRoot); + ?? (process.env.ADE_WORKSPACE_ROOT?.trim() ? path.resolve(process.env.ADE_WORKSPACE_ROOT.trim()) : discovered.workspaceRoot); return { projectRoot, workspaceRoot }; } @@ -2431,7 +2659,11 @@ function formatActionsList(value: unknown): string { list.push(action); byDomain.set(domain, list); } - const lines = ["ADE actions"]; + const lines = [ + "ADE actions", + "Use: ade actions run --input-json '{\"key\":\"value\"}'", + "For multi-parameter methods: --args-list-json '[\"first\",{\"second\":true}]'", + ]; for (const [domain, list] of [...byDomain.entries()].sort(([left], [right]) => left.localeCompare(right))) { lines.push("", `${domain}:`); for (const action of list.sort((left, right) => cell(left.action ?? left.name).localeCompare(cell(right.action ?? right.name)))) { @@ -2461,10 +2693,10 @@ function formatPrList(value: unknown): string { return renderTable( ["PR", "state", "lane", "branch", "title"], prs.map((pr) => [ - pr.number ?? pr.prNumber ?? pr.id, + pr.githubPrNumber ?? pr.number ?? pr.prNumber ?? pr.id, pr.state ?? pr.status, pr.laneId ?? pr.laneName, - pr.headRefName ?? pr.branchRef ?? pr.branch, + pr.headBranch ?? pr.headRefName ?? pr.branchRef ?? pr.branch, pr.title, ]), "ADE pull requests\n(no PRs)", @@ -2931,9 +3163,11 @@ if (/(^|[/\\])cli\.(?:ts|js|cjs)$/.test(process.argv[1] ?? "")) { export { buildCliPlan, + findProjectRoots, formatOutput, parseCliArgs, renderLaneGraph, + resolveRoots, runCli, summarizeExecution, unwrapToolResult, diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 0d752427e..806c86bdd 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3165,7 +3165,20 @@ app.whenReady().then(async () => { paths: adePaths as unknown as AdeRuntimePaths, logger, db, + keybindingsService, + agentToolsService, + adeCliService, + devToolsService, + onboardingService, laneService, + laneEnvironmentService, + laneTemplateService, + portAllocationService, + laneProxyService, + oauthRedirectService, + runtimeDiagnosticsService, + rebaseSuggestionService, + autoRebaseService, sessionService, operationService, projectConfigService, @@ -3173,14 +3186,24 @@ app.whenReady().then(async () => { gitService, diffService, missionService, + missionPreflightService, ptyService, testService, + aiIntegrationService, agentChatService, prService, + prSummaryService, + queueLandingService, fileService, memoryService, ctoStateService, workerAgentService, + workerBudgetService, + workerRevisionService, + workerHeartbeatService, + workerTaskSessionService, + linearCredentialService, + openclawBridgeService, flowPolicyService, linearDispatcherService, linearIssueTracker, @@ -3194,6 +3217,19 @@ app.whenReady().then(async () => { computerUseArtifactBrokerService, orchestratorService, aiOrchestratorService, + missionBudgetService, + syncHostService: syncService.getHostService(), + syncService, + automationService, + automationPlannerService, + automationIngressService, + contextDocService, + githubService, + feedbackReporterService, + usageTrackingService, + budgetCapService, + sessionDeltaService, + autoUpdateService, issueInventoryService, eventBuffer: rpcEventBuffer, dispose: () => {}, // desktop manages service lifecycle diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index 34a0dd179..c55708ebd 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -229,6 +229,8 @@ describe("buildCodingAgentSystemPrompt", () => { it("always includes operating loop, editing rules, and verification rules", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x" }); expect(result).toContain("## Operating Loop"); + expect(result).toContain("## ADE CLI"); + expect(result).toContain("Before saying an ADE task is blocked"); expect(result).toContain("## Editing Rules"); expect(result).toContain("## Verification Rules"); expect(result).toContain("## User-Facing Progress"); diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index e7a9f637a..eb9cc04c4 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -1,3 +1,5 @@ +import { ADE_CLI_AGENT_GUIDANCE } from "../../../../shared/adeCliGuidance"; + type HarnessMode = "chat" | "coding" | "planning"; type HarnessPermissionMode = "plan" | "edit" | "full-auto"; @@ -113,8 +115,7 @@ export function buildCodingAgentSystemPrompt(args: { : "If requirements are unclear, make the safest reasonable assumption and continue. State the assumption in the final answer.", "If tool results fail or contradict the current plan, synthesize the finding and adapt rather than repeating the same failing action.", "", - "## ADE CLI", - "In terminal-capable sessions, use the bundled `ade` command for internal ADE actions. Run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text` or `ade prs checks --text` first, and `ade actions run ...` as the escape hatch. Use `--json` for structured output and `--text` for readable output.", + ADE_CLI_AGENT_GUIDANCE, ...(hasMemoryTools ? [ "", diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index b7d6608a5..548d79143 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1039,7 +1039,8 @@ describe("createAgentChatService", () => { }); const opts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { systemPrompt?: { append?: string } } | undefined; - expect(opts?.systemPrompt?.append).toContain("ADE actions are available through the `ade` CLI"); + expect(opts?.systemPrompt?.append).toContain("internal ADE work"); + expect(opts?.systemPrompt?.append).toContain("Before saying an ADE task is blocked"); expect(opts?.systemPrompt?.append).toContain("ade lanes list"); }); @@ -1577,7 +1578,10 @@ describe("createAgentChatService", () => { expect(firstUserContent).toContain("[ADE launch directive]"); expect(firstUserContent).toContain(tmpRoot); expect(firstUserContent).toContain("only inside that worktree"); + expect(firstUserContent).toContain("Before saying an ADE task is blocked"); + expect(firstUserContent).toContain("ade actions list --text"); expect(secondUserContent).not.toContain("[ADE launch directive]"); + expect(secondUserContent).not.toContain("Before saying an ADE task is blocked"); }); it("starts Codex sessions without ADE-owned tool server injection", async () => { @@ -1602,6 +1606,12 @@ describe("createAgentChatService", () => { const startPayload = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); expect(startPayload?.params).toMatchObject({ cwd: expect.stringContaining("lane-2") }); + + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const turnParams = turnStartRequest?.params as { input?: Array<{ text?: unknown }> } | undefined; + const textInput = turnParams?.input?.map((entry) => String(entry.text ?? "")).join("\n") ?? ""; + expect(textInput).toContain("Before saying an ADE task is blocked"); + expect(textInput).toContain("ade actions list --text"); }); it("spawns Codex with ADE CLI agent env injected", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 8b3a9caef..7244ca3c4 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -147,6 +147,7 @@ import { reportProviderRuntimeReady, } from "../ai/providerRuntimeHealth"; import { resolveAdeLayout } from "../../../shared/adeLayout"; +import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { extractLeadingSlashCommand, isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; import type { createMemoryService, Memory } from "../memory/memoryService"; @@ -9776,10 +9777,7 @@ export function createAgentChatService(args: { "GOOD memories: \"Convention: always use snake_case for DB columns\", \"Decision: chose Postgres over Mongo for ACID transactions\", \"Pitfall: CI silently skips tests if file doesn't match *.test.ts\"", "DO NOT save: file paths, raw error messages without lessons, task progress updates, information derivable from git log or the code itself, obvious patterns already visible in the codebase.", "", - "## ADE Tooling", - "ADE actions are available through the `ade` CLI in terminal-capable sessions.", - "Run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text`, `ade prs checks --text`, or `ade proof list --text` first, and `ade actions run ...` as the escape hatch.", - "Use `--json` for structured output and `--text` for readable output.", + ADE_CLI_AGENT_GUIDANCE, ].join("\n"), }; opts.settingSources = ["user", "project", "local"]; @@ -10915,6 +10913,7 @@ export function createAgentChatService(args: { : null, buildExecutionModeDirective(executionMode, managed.session.provider), buildClaudeInteractionModeDirective(managed.session.interactionMode, managed.session.provider), + shouldInjectLaneDirective ? ADE_CLI_AGENT_GUIDANCE : null, buildComputerUseDirective( computerUseArtifactBrokerRef?.getBackendStatus() ?? null, ), diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index 6395563ca..894531bc8 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -12,6 +12,7 @@ import type { CtoSnapshot, CtoSystemPromptPreview, } from "../../../shared/types"; +import { ADE_CLI_INLINE_GUIDANCE } from "../../../shared/adeCliGuidance"; import { getCtoPersonalityPreset } from "../../../shared/ctoPersonalityPresets"; import type { createMemoryService, Memory, MemoryCategory } from "../memory/memoryService"; import type { AdeDb } from "../state/kvDb"; @@ -85,7 +86,7 @@ const IMMUTABLE_CTO_DOCTRINE = [ "- All ADE internals are fair game. The user can request any action: launching chats, opening terminals, running CLI tools, spawning agents, managing lanes, etc. Never refuse an action that ADE supports.", "- When the user asks about something you can look up (lane status, PR checks, test results), call the tool first and report facts. Do not guess.", "- When you are unsure which tool to use, consult the capability manifest in your system prompt before asking the user.", - "- Terminal-capable sessions can use the bundled `ade` CLI for internal ADE actions: `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands first, `ade actions run ...` as the escape hatch, `--json` for structured output, and `--text` for readable output.", + `- ${ADE_CLI_INLINE_GUIDANCE}`, ].join("\n"); const CTO_MEMORY_OPERATING_MODEL = [ @@ -177,9 +178,7 @@ const CTO_ENVIRONMENT_KNOWLEDGE = [ " - Example: 'Launch a chat with opus' → spawnChat({ modelId: 'anthropic/claude-opus-4-7', ... }). 'Open a terminal' → createTerminal. 'Run npm test' → createTerminal({ startupCommand: 'npm test' }).", "", "Tool calling convention:", - " - ADE actions are available through ADE's native action surface and the `ade` CLI in terminal-capable sessions.", - " - In terminal-capable sessions, run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text` or `ade prs checks --text` first, and `ade actions run ...` as the escape hatch.", - " - Use `--json` when another agent or script needs stable fields; use `--text` when a human-readable summary is enough.", + ` - ${ADE_CLI_INLINE_GUIDANCE}`, " - If a tool from the manifest below is not in your immediate tool list, use the closest ADE CLI command or report the missing capability clearly.", "", "## PR Lifecycle in ADE", diff --git a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts index ab672d378..b4c07a23f 100644 --- a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts +++ b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.test.ts @@ -221,7 +221,8 @@ describe("workerAdapterRuntimeService", () => { timeoutMs: 300000, }); const firstCall = runSessionTurn.mock.calls[0] as unknown as [{ text: string }] | undefined; - expect(firstCall?.[0]?.text).toContain("ADE CLI:"); + expect(firstCall?.[0]?.text).toContain("## ADE CLI"); + expect(firstCall?.[0]?.text).toContain("Before saying an ADE task is blocked"); expect(result.effectiveSurface).toBe("unified_chat"); expect(result.continuation).toMatchObject({ surface: "unified_chat", diff --git a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts index 89d87418e..e8424eebe 100644 --- a/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts +++ b/apps/desktop/src/main/services/cto/workerAdapterRuntimeService.ts @@ -5,10 +5,11 @@ import type { WorkerContinuationHandle, WorkerRuntimeSurface, } from "../../../shared/types"; +import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; import { resolveCodexExecutable } from "../ai/codexExecutable"; import type { createAgentChatService } from "../chat/agentChatService"; -const ADE_CLI_WORKER_GUIDANCE = "ADE CLI: In terminal-capable sessions, use the bundled `ade` command for internal ADE actions. Run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text` or `ade prs checks --text` first, and `ade actions run ...` as the escape hatch. Use `--json` for structured output and `--text` for readable output."; +const ADE_CLI_WORKER_GUIDANCE = ADE_CLI_AGENT_GUIDANCE; type WorkerAdapterRuntimeServiceArgs = { fetchImpl?: typeof fetch; diff --git a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts index 105487cb3..7726e214d 100644 --- a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts @@ -4,6 +4,7 @@ import type { OrchestratorExecutorStartResult } from "./orchestratorService"; import type { OrchestratorWorkerRole, OrchestratorStep, OrchestratorExecutorKind, TerminalToolType, TeamRuntimeConfig } from "../../../shared/types"; +import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; import type { createMemoryService } from "../memory/memoryService"; import { DEFAULT_CONTEXT_VIEW_POLICIES, SLASH_COMMAND_TRANSLATIONS } from "./orchestratorConstants"; @@ -545,7 +546,7 @@ export function buildFullPrompt( if (hasMissionTooling) { systemParts.push( [ - "ADE TOOLING: In terminal-capable sessions, use the bundled `ade` CLI for internal ADE actions. Run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text` or `ade prs checks --text` first, and `ade actions run ...` as the escape hatch. Use `--json` for structured output and `--text` for readable output.", + ADE_CLI_AGENT_GUIDANCE, "Your worker identity (mission, run, step, attempt IDs) is automatically resolved — you don't need to pass IDs to observation tools.", "Key actions available:", "- get_worker_states: See all peer workers in your run and their current status", @@ -748,12 +749,13 @@ export function createBaseOrchestratorAdapter(config: BaseAdapterConfig): Orches : null; if (startupCommandOverride) { + const overridePrompt = [ADE_CLI_AGENT_GUIDANCE, startupCommandOverride].join("\n\n"); // Use the startup command directly as the prompt const session = await args.createTrackedSession({ laneId: step.laneId, toolType: sessionType, title: `[Orchestrator] ${step.title}`, - startupCommand: buildOverrideCommand({ prompt: startupCommandOverride }), + startupCommand: buildOverrideCommand({ prompt: overridePrompt }), cols: 120, rows: 40 }); @@ -764,7 +766,7 @@ export function createBaseOrchestratorAdapter(config: BaseAdapterConfig): Orches metadata: { adapterKind: executorKind, startupCommandOverride: true, - promptLength: startupCommandOverride.length, + promptLength: overridePrompt.length, startupCommandPreview: startupCommandOverride.slice(0, 320) } }; diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts index 26af30344..24c03e70e 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts @@ -33,6 +33,7 @@ import { import { asRecord, filterExecutionSteps } from "./orchestratorContext"; import { readMissionStateDocument, writeCoordinatorCheckpoint } from "./missionStateDoc"; import { getLocalProviderDefaultEndpoint, resolveModelDescriptor, type LocalProviderFamily } from "../../../shared/modelRegistry"; +import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; import { inspectLocalProvider } from "../ai/localModelDiscovery"; import type { DiscoveredLocalModelEntry } from "../opencode/openCodeRuntime"; import type { createOrchestratorService } from "./orchestratorService"; @@ -2075,9 +2076,7 @@ Your conversation persists across the entire mission — you accumulate context, You are NOT a repo-editing worker. You are the mission lead who owns phase state, worker spawning, runtime judgment, and final completion. In normal operation, workers inspect the repo, edit code, and run commands. You keep the mission aligned and delegated. The difference between you and a dumb orchestrator is that you THINK before you act and EVALUATE after each step. -## ADE CLI - -Terminal-capable workers can use the bundled \`ade\` command for internal ADE actions. Instruct them to run \`ade doctor\` for readiness, \`ade actions list --text\` for discovery, typed commands such as \`ade lanes list --text\` or \`ade prs checks --text\` first, and \`ade actions run ...\` as the escape hatch. Tell them to use \`--json\` for structured output and \`--text\` for readable output. +${ADE_CLI_AGENT_GUIDANCE} ## Your Mission ${this.deps.missionGoal} diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index 09ec034dd..1731a1a63 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -62,6 +62,7 @@ import type { AgentChatExecutionMode, AgentChatPermissionMode, } from "../../../shared/types"; +import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; import { DEFAULT_RECOVERY_LOOP_POLICY, DEFAULT_CONTEXT_VIEW_POLICIES, @@ -3987,7 +3988,7 @@ export function createOrchestratorService({ commandParts.push("--permission-mode", readOnlyExecution || cliMode === "read-only" ? "plan" : "acceptEdits"); } } - commandParts.push(shellInlineDecodedArg(prompt)); + commandParts.push(shellInlineDecodedArg([ADE_CLI_AGENT_GUIDANCE, prompt].join("\n\n"))); const startupCommand = commandParts.join(" "); const session = await args.createTrackedSession({ diff --git a/apps/desktop/src/main/services/orchestrator/promptInspector.ts b/apps/desktop/src/main/services/orchestrator/promptInspector.ts index dc080d901..fb0c04dfa 100644 --- a/apps/desktop/src/main/services/orchestrator/promptInspector.ts +++ b/apps/desktop/src/main/services/orchestrator/promptInspector.ts @@ -25,6 +25,7 @@ import type { PhaseCard, TeamRuntimeConfig, } from "../../../shared/types"; +import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; import { isRecord, toOptionalString } from "../shared/utils"; function pushLayer( @@ -176,7 +177,7 @@ function buildWorkerBaseGuidance(step: OrchestratorStep, graph: OrchestratorRunG if (planView) sections.push(planView); sections.push( [ - "ADE CLI: In terminal-capable sessions, use the bundled `ade` command for internal ADE actions. Run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text` or `ade prs checks --text` first, and `ade actions run ...` as the escape hatch. Use `--json` for structured output and `--text` for readable output.", + ADE_CLI_AGENT_GUIDANCE, "", "Work style:", "- If you discover information relevant to other steps (API changes, schema updates, config requirements), include it in your output summary.", @@ -211,7 +212,7 @@ function buildWorkerBaseGuidance(step: OrchestratorStep, graph: OrchestratorRunG ); sections.push( [ - "ADE TOOLING: Use ADE's action surface or the `ade` CLI for team collaboration commands when available.", + ADE_CLI_AGENT_GUIDANCE, "Your worker identity (mission, run, step, attempt IDs) is automatically resolved — you don't need to pass IDs to observation tools.", "Key actions available:", "- get_worker_states", @@ -613,7 +614,7 @@ export function buildCoordinatorPromptInspector(args: { text: [ providersSection, "", - "ADE CLI: Terminal-capable workers can use the bundled `ade` command for internal ADE actions. Instruct them to run `ade doctor` for readiness, `ade actions list --text` for discovery, typed commands such as `ade lanes list --text` or `ade prs checks --text` first, and `ade actions run ...` as the escape hatch. Use `--json` for structured output and `--text` for readable output.", + ADE_CLI_AGENT_GUIDANCE, ].join("\n"), description: "Runtime availability context for worker spawning.", }); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 22841aa4e..b0065e8a7 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -230,7 +230,12 @@ function getIntegrationLaneOrigin(row: { const integrationLaneId = asString(row.integration_lane_id).trim() || null; if (!integrationLaneId) return null; const preferredIntegrationLaneId = asString(row.preferred_integration_lane_id).trim() || null; - return preferredIntegrationLaneId === integrationLaneId ? "adopted" : "ade-created"; + // Scratch integration lanes use `laneService.createChild` only when simulation had no merge-into + // lane, so `preferred_integration_lane_id` stays unset. When merging into an existing lane, that + // lane id is stored here (and normally matches `integration_lane_id`). Inferring "adopted" from + // `preferred === integration` alone is unsafe because merge-into and integration ids can differ. + if (!preferredIntegrationLaneId) return "ade-created"; + return "adopted"; } function isAdeOwnedIntegrationLane(row: { @@ -502,8 +507,9 @@ function hasMaterialSummaryChange(row: PullRequestRow, summary: PrSummary): bool function parsePrLocator(raw: string): { owner?: string; repo?: string; number: number } { const trimmed = raw.trim(); if (!trimmed) throw new Error("PR URL or number is required"); - if (/^[0-9]+$/.test(trimmed)) { - return { number: Number(trimmed) }; + const numeric = trimmed.match(/^#?([0-9]+)$/); + if (numeric) { + return { number: Number(numeric[1]) }; } try { const url = new URL(trimmed); @@ -515,6 +521,10 @@ function parsePrLocator(raw: string): { owner?: string; repo?: string; number: n } } +function repoPrKey(owner: string, repo: string, number: number): string { + return `${owner.trim().toLowerCase()}/${repo.trim().toLowerCase()}#${Number(number)}`; +} + function readPrTemplate(projectRoot: string): string | null { const templatePath = path.join(projectRoot, ".github", "PULL_REQUEST_TEMPLATE.md"); if (!fs.existsSync(templatePath)) return null; @@ -698,12 +708,53 @@ export function createPrService({ checks_status, review_status, additions, deletions, last_synced_at, created_at, updated_at, creation_strategy`; - const getRow = (prId: string): PullRequestRow | null => + const getRowById = (prId: string): PullRequestRow | null => db.get( `select ${PR_COLUMNS} from pull_requests where id = ? and project_id = ? limit 1`, [prId, projectId] ); + const getRowForRepoPr = (repoOwner: string, repoName: string, prNumber: number): PullRequestRow | null => + db.get( + `select ${PR_COLUMNS} + from pull_requests + where project_id = ? + and lower(repo_owner) = lower(?) + and lower(repo_name) = lower(?) + and github_pr_number = ? + order by updated_at desc + limit 1`, + [projectId, repoOwner, repoName, prNumber] + ); + + const getRowByNumber = (prNumber: number): PullRequestRow | null => + db.get( + `select ${PR_COLUMNS} + from pull_requests + where project_id = ? + and github_pr_number = ? + order by updated_at desc + limit 1`, + [projectId, prNumber] + ); + + const getRowByLocator = (locator: string): PullRequestRow | null => { + const trimmed = String(locator ?? "").trim(); + if (!trimmed) return null; + try { + const parsed = parsePrLocator(trimmed); + if (parsed.owner && parsed.repo) { + return getRowForRepoPr(parsed.owner, parsed.repo, parsed.number); + } + return getRowByNumber(parsed.number); + } catch { + return null; + } + }; + + const getRow = (prIdOrLocator: string): PullRequestRow | null => + getRowById(prIdOrLocator) ?? getRowByLocator(prIdOrLocator); + const requireRow = (prId: string): PullRequestRow => { const row = getRow(prId); if (!row) throw new Error(`PR not found: ${prId}`); @@ -1101,14 +1152,16 @@ export function createPrService({ })); }; - const upsertRow = (summary: Omit & { projectId?: string }): void => { + const upsertRow = (summary: Omit & { projectId?: string }): string => { const now = nowIso(); - const existing = getRowForLane(summary.laneId); + const existing = getRowForLane(summary.laneId) + ?? getRowForRepoPr(summary.repoOwner, summary.repoName, summary.githubPrNumber); if (existing) { db.run( ` update pull_requests - set repo_owner = ?, + set lane_id = ?, + repo_owner = ?, repo_name = ?, github_pr_number = ?, github_url = ?, @@ -1127,6 +1180,7 @@ export function createPrService({ where id = ? and project_id = ? `, [ + summary.laneId, summary.repoOwner, summary.repoName, summary.githubPrNumber, @@ -1147,7 +1201,7 @@ export function createPrService({ projectId, ] ); - return; + return existing.id; } db.run( @@ -1198,6 +1252,7 @@ export function createPrService({ summary.creationStrategy ?? null ] ); + return summary.id; }; const assertDirtyWorktreesAllowed = (args: { @@ -1265,6 +1320,29 @@ export function createPrService({ return out; }; + const findExistingPrForBranch = async ( + repo: GitHubRepoRef, + headBranch: string, + baseBranch?: string | null, + ): Promise => { + const candidates = await fetchAllPages({ + path: `/repos/${repo.owner}/${repo.name}/pulls`, + query: { + state: "all", + head: `${repo.owner}:${headBranch}`, + ...(baseBranch ? { base: baseBranch } : {}), + sort: "updated", + direction: "desc", + }, + }); + if (candidates.length === 0) return null; + const open = candidates.find((candidate) => { + const state = asString(candidate?.state).toLowerCase(); + return state === "open" || Boolean(candidate?.draft); + }); + return open ?? candidates[0] ?? null; + }; + const listIntegrationProposalRows = (args: { where?: string; params?: Array } = {}): IntegrationProposalRow[] => db.all( `select * from integration_proposals where project_id = ?${args.where ? ` and ${args.where}` : ""} order by created_at desc`, @@ -2248,9 +2326,24 @@ export function createPrService({ }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - throw new Error( - `Failed to create pull request for "${headBranch}" → "${baseBranch}": ${msg}` - ); + const existingPr = /pull request already exists|already exists|validation failed/i.test(msg) + ? await findExistingPrForBranch(repo, headBranch, baseBranch).catch((lookupError) => { + logger.warn("prs.create_existing_lookup_failed", { + headBranch, + baseBranch, + error: lookupError instanceof Error ? lookupError.message : String(lookupError), + }); + return null; + }) + : null; + if (existingPr) { + logger.info("prs.create_existing_mapped", { headBranch, baseBranch, prNumber: Number(existingPr?.number) || null }); + created = { data: existingPr, response: null }; + } else { + throw new Error( + `Failed to create pull request for "${headBranch}" → "${baseBranch}": ${msg}` + ); + } } const pr = created.data; @@ -2304,9 +2397,10 @@ export function createPrService({ creationStrategy: strategy }; - upsertRow(summary); + const prId = upsertRow(summary); + markHotRefresh([prId]); - return await refreshOne(summary.id); + return await refreshOne(prId); }; const linkToLane = async (args: LinkPrToLaneArgs): Promise => { @@ -2327,18 +2421,7 @@ export function createPrService({ // that default here so linked PRs participate in strategy-aware rebase // behavior (follow-up 3) instead of being treated as "unset". The // upsertRow path uses COALESCE so we never clobber an existing value. - const existingRow = db.get<{ id: string; creation_strategy: string | null }>( - ` - select id, creation_strategy - from pull_requests - where project_id = ? - and repo_owner = ? - and repo_name = ? - and github_pr_number = ? - limit 1 - `, - [projectId, repo.owner, repo.name, locator.number], - ); + const existingRow = getRowForRepoPr(repo.owner, repo.name, locator.number); const creationStrategy: PrCreationStrategy = normalizePrCreationStrategy(existingRow?.creation_strategy) ?? "pr_target"; @@ -2365,8 +2448,9 @@ export function createPrService({ creationStrategy }; - upsertRow(summary); - return await refreshOne(summary.id); + const prId = upsertRow(summary); + markHotRefresh([prId]); + return await refreshOne(prId); }; const land = async (args: LandPrArgs): Promise => { @@ -3868,7 +3952,7 @@ export function createPrService({ const laneById = new Map(lanes.map((lane) => [lane.id, lane])); const pullRequestRows = listRows(); const linkedPrByRepoKey = new Map( - pullRequestRows.map((row) => [`${row.repo_owner}/${row.repo_name}#${row.github_pr_number}`, row] as const) + pullRequestRows.map((row) => [repoPrKey(row.repo_owner, row.repo_name, Number(row.github_pr_number)), row] as const) ); const groupRows = db.all<{ pr_id: string; group_id: string; group_type: "queue" | "integration" }>( `select gm.pr_id, gm.group_id, g.group_type @@ -3913,7 +3997,7 @@ export function createPrService({ const repoOwner = asString(rawRepo?.owner?.login) || repositoryParts[0] || repo.owner; const repoName = asString(rawRepo?.name) || repositoryParts[1] || repo.name; const githubPrNumber = Number(rawPr?.number) || 0; - const linkedPrRow = linkedPrByRepoKey.get(`${repoOwner}/${repoName}#${githubPrNumber}`) ?? null; + const linkedPrRow = linkedPrByRepoKey.get(repoPrKey(repoOwner, repoName, githubPrNumber)) ?? null; const workflowRow = linkedPrRow ? workflowByPrId.get(linkedPrRow.id) ?? null : null; const groupRow = linkedPrRow ? groupByPrId.get(linkedPrRow.id) ?? null : null; @@ -4755,8 +4839,10 @@ export function createPrService({ return row ? rowToSummary(row) : null; }, - listAll(): PrSummary[] { - return listRows().map(rowToSummary); + listAll(args: { laneId?: string } = {}): PrSummary[] { + const laneId = String(args.laneId ?? "").trim(); + const summaries = listRows().map(rowToSummary); + return laneId ? summaries.filter((pr) => pr.laneId === laneId) : summaries; }, async refresh(args: { prId?: string; prIds?: string[] } = {}): Promise { diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts new file mode 100644 index 000000000..121cc15be --- /dev/null +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -0,0 +1,8 @@ +export const ADE_CLI_AGENT_GUIDANCE = [ + "## ADE CLI", + "`ade` is available in this ADE-managed session for internal ADE work: lanes, missions, PRs, chats/sessions, memory, proof, config, and process state.", + "Before saying an ADE task is blocked or unsupported, try `ade` first: run `ade doctor` if needed, use typed commands like `ade lanes list --text` / `ade prs checks --text`, or discover with `ade actions list --text` and `ade actions run ...`.", +].join("\n"); + +export const ADE_CLI_INLINE_GUIDANCE = + "`ade` is available for ADE tasks. Before reporting an ADE lane, mission, PR, session, memory, proof, config, or process-state task as blocked, try `ade doctor`, typed `ade ... --text` commands, or `ade actions list --text` / `ade actions run ...`."; From 29765a3b3ae1c24d737ec8e870b7fa7c2701cd43 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:01:10 -0400 Subject: [PATCH 2/7] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20align?= =?UTF-8?q?=20CLI=20workspaceRoot=20with=20explicit=20project=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When --project-root or ADE_PROJECT_ROOT fixes the project root, default workspaceRoot to that path unless ADE_WORKSPACE_ROOT/--workspace-root is set (fixes capy-ai review thread). Made-with: Cursor --- apps/ade-cli/src/cli.test.ts | 67 +++++++++++++++++++++++++++++++++++- apps/ade-cli/src/cli.ts | 19 +++++++--- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index b7b8907d3..4b4718dda 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2,7 +2,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { buildCliPlan, findProjectRoots, formatOutput, parseCliArgs, renderLaneGraph, summarizeExecution, unwrapToolResult } from "./cli"; +import { buildCliPlan, findProjectRoots, formatOutput, parseCliArgs, renderLaneGraph, resolveRoots, summarizeExecution, unwrapToolResult } from "./cli"; + +type ResolveRootsOptions = Parameters[0]; + +function baseResolveOpts(): Omit { + return { + role: "external", + headless: true, + requireSocket: false, + pretty: false, + text: false, + timeoutMs: 15_000, + }; +} describe("ADE CLI", () => { it("parses global options without stealing command flags", () => { @@ -370,6 +383,58 @@ describe("ADE CLI", () => { }); }); + it("defaults workspaceRoot to projectRoot when ADE_PROJECT_ROOT overrides discovery", () => { + const prevProject = process.env.ADE_PROJECT_ROOT; + const prevWorkspace = process.env.ADE_WORKSPACE_ROOT; + try { + delete process.env.ADE_WORKSPACE_ROOT; + process.env.ADE_PROJECT_ROOT = "/explicit/project-root"; + const roots = resolveRoots({ + ...baseResolveOpts(), + projectRoot: null, + workspaceRoot: null, + }); + expect(roots.projectRoot).toBe("/explicit/project-root"); + expect(roots.workspaceRoot).toBe("/explicit/project-root"); + } finally { + if (prevProject === undefined) delete process.env.ADE_PROJECT_ROOT; + else process.env.ADE_PROJECT_ROOT = prevProject; + if (prevWorkspace === undefined) delete process.env.ADE_WORKSPACE_ROOT; + else process.env.ADE_WORKSPACE_ROOT = prevWorkspace; + } + }); + + it("defaults workspaceRoot to CLI projectRoot when only --project-root is set", () => { + const roots = resolveRoots({ + ...baseResolveOpts(), + projectRoot: "/cli/project-root", + workspaceRoot: null, + }); + expect(roots.projectRoot).toBe("/cli/project-root"); + expect(roots.workspaceRoot).toBe("/cli/project-root"); + }); + + it("still honors ADE_WORKSPACE_ROOT when both project and workspace overrides exist", () => { + const prevProject = process.env.ADE_PROJECT_ROOT; + const prevWorkspace = process.env.ADE_WORKSPACE_ROOT; + try { + process.env.ADE_PROJECT_ROOT = "/explicit/project-root"; + process.env.ADE_WORKSPACE_ROOT = "/explicit/workspace-root"; + const roots = resolveRoots({ + ...baseResolveOpts(), + projectRoot: null, + workspaceRoot: null, + }); + expect(roots.projectRoot).toBe("/explicit/project-root"); + expect(roots.workspaceRoot).toBe("/explicit/workspace-root"); + } finally { + if (prevProject === undefined) delete process.env.ADE_PROJECT_ROOT; + else process.env.ADE_PROJECT_ROOT = prevProject; + if (prevWorkspace === undefined) delete process.env.ADE_WORKSPACE_ROOT; + else process.env.ADE_WORKSPACE_ROOT = prevWorkspace; + } + }); + it("maps PR link arguments to the service contract", () => { const plan = buildCliPlan(["prs", "link", "--lane", "lane-1", "--url", "https://github.com/acme/ade/pull/123"]); expect(plan.kind).toBe("execute"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 665d3aa2c..e8360e22e 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1996,10 +1996,21 @@ function findProjectRoots(startDir: string): { projectRoot: string; workspaceRoo function resolveRoots(options: GlobalOptions): { projectRoot: string; workspaceRoot: string } { const discovered = findProjectRoots(process.cwd()); - const projectRoot = options.projectRoot - ?? (process.env.ADE_PROJECT_ROOT?.trim() ? path.resolve(process.env.ADE_PROJECT_ROOT.trim()) : discovered.projectRoot); - const workspaceRoot = options.workspaceRoot - ?? (process.env.ADE_WORKSPACE_ROOT?.trim() ? path.resolve(process.env.ADE_WORKSPACE_ROOT.trim()) : discovered.workspaceRoot); + const projectFromEnv = process.env.ADE_PROJECT_ROOT?.trim() + ? path.resolve(process.env.ADE_PROJECT_ROOT.trim()) + : null; + const workspaceFromEnv = process.env.ADE_WORKSPACE_ROOT?.trim() + ? path.resolve(process.env.ADE_WORKSPACE_ROOT.trim()) + : null; + + const projectRoot = options.projectRoot ?? projectFromEnv ?? discovered.projectRoot; + const projectExplicitlyOverridden = options.projectRoot != null || projectFromEnv != null; + + const workspaceRoot = + options.workspaceRoot + ?? workspaceFromEnv + ?? (projectExplicitlyOverridden ? projectRoot : discovered.workspaceRoot); + return { projectRoot, workspaceRoot }; } From 1a650f0376e1efe2e0332f7f7848c804349709d1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:21:29 -0400 Subject: [PATCH 3/7] fix: rebase onto main, review follow-ups, CI terminal test - Resolve adeRpcServer/bootstrap/cli conflicts with adeActions registry + headless services. - prService: clear pr_group_members when PR row moves lanes; tighter duplicate-PR message match. - CLI: route computer/artifact/setting aliases; safer hasHelpFlag after value-taking flags. - Orchestrator: log augmented startup preview when ADE CLI guidance is prepended. - main.ts: drop duplicate AdeRuntime passthrough keys. - TerminalView test: real timers + waitFor for webgl fallback flake. Made-with: Cursor --- apps/ade-cli/src/bootstrap.ts | 4 +- apps/ade-cli/src/cli.ts | 38 ++++++++++++++-- apps/desktop/src/main/main.ts | 3 -- .../orchestrator/baseOrchestratorAdapter.ts | 2 +- .../src/main/services/prs/prService.ts | 10 ++++- .../terminals/TerminalView.test.tsx | 43 ++++++++++--------- 6 files changed, 68 insertions(+), 32 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index cb1eadeef..514b9af06 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -55,8 +55,6 @@ import { createAiIntegrationService } from "../../desktop/src/main/services/ai/a import { createMissionBudgetService } from "../../desktop/src/main/services/orchestrator/missionBudgetService"; import type { createSyncService } from "../../desktop/src/main/services/sync/syncService"; import type { createSyncHostService } from "../../desktop/src/main/services/sync/syncHostService"; -import type { createAutomationService } from "../../desktop/src/main/services/automations/automationService"; -import type { createAutomationPlannerService } from "../../desktop/src/main/services/automations/automationPlannerService"; import type { createAutomationIngressService } from "../../desktop/src/main/services/automations/automationIngressService"; import type { createContextDocService } from "../../desktop/src/main/services/context/contextDocService"; import type { createGithubService } from "../../desktop/src/main/services/github/githubService"; @@ -493,7 +491,7 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo githubService: headlessLinearServices.githubService as never, workerTaskSessionService: headlessLinearServices.workerTaskSessionService, workerHeartbeatService: headlessLinearServices.workerHeartbeatService, - linearCredentialService: headlessLinearServices.linearCredentialService, + linearCredentialService: headlessLinearServices.linearCredentialService as never, prService: headlessLinearServices.prService, fileService: headlessLinearServices.fileService, flowPolicyService: headlessLinearServices.flowPolicyService, diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index e8360e22e..ca55f8c66 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1859,7 +1859,37 @@ function buildCoordinatorPlan(args: string[]): CliPlan { function hasHelpFlag(args: string[]): boolean { const terminatorIndex = args.indexOf("--"); const searchable = terminatorIndex >= 0 ? args.slice(0, terminatorIndex) : args; - return searchable.includes("--help") || searchable.includes("-h"); + const valueCarrierFlags = new Set([ + "--text", + "--body", + "--title", + "--question", + "--input-json", + "--json-input", + "--input", + "--arg", + "--set", + "--arg-json", + "--set-json", + "-t", + "-b", + "--lane", + "--session", + "--path", + "--url", + ]); + for (let i = 0; i < searchable.length; i++) { + const token = searchable[i]!; + if (token === "--help") { + if (valueCarrierFlags.has(searchable[i - 1] ?? "")) continue; + return true; + } + if (token === "-h") { + if (valueCarrierFlags.has(searchable[i - 1] ?? "")) continue; + return true; + } + } + return false; } function buildCliPlan(command: string[]): CliPlan { @@ -1948,9 +1978,11 @@ function buildCliPlan(command: string[]): CliPlan { if (primary === "coordinator" || primary === "coord") return buildCoordinatorPlan(args); if (primary === "ask") return { kind: "execute", label: "ask user", steps: [actionCallStep("result", "ask_user", collectGenericObjectArgs(args, { title: readValue(args, ["--title"]) ?? "ADE question", body: readValue(args, ["--body", "--question"]) ?? args.join(" ") }))] }; if (primary === "tests" || primary === "test") return buildTestsPlan(args); - if (primary === "proof" || primary === "computer-use" || primary === "artifacts") return buildProofPlan(args); + if (primary === "proof" || primary === "computer-use" || primary === "artifacts" || primary === "computer" || primary === "artifact") { + return buildProofPlan(args); + } if (primary === "memory") return buildMemoryPlan(args); - if (primary === "settings" || primary === "config") return buildSettingsPlan(args); + if (primary === "settings" || primary === "config" || primary === "setting") return buildSettingsPlan(args); if (primary === "actions" || primary === "action") return buildActionsPlan(args); throw new CliUsageError(`Unknown command '${primary}'. Run 'ade help'.`); } diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 806c86bdd..5a387577e 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3220,11 +3220,8 @@ app.whenReady().then(async () => { missionBudgetService, syncHostService: syncService.getHostService(), syncService, - automationService, - automationPlannerService, automationIngressService, contextDocService, - githubService, feedbackReporterService, usageTrackingService, budgetCapService, diff --git a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts index 7726e214d..d9837d76c 100644 --- a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts @@ -767,7 +767,7 @@ export function createBaseOrchestratorAdapter(config: BaseAdapterConfig): Orches adapterKind: executorKind, startupCommandOverride: true, promptLength: overridePrompt.length, - startupCommandPreview: startupCommandOverride.slice(0, 320) + startupCommandPreview: overridePrompt.slice(0, 320) } }; } diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index b0065e8a7..c239ff3d8 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -1157,6 +1157,9 @@ export function createPrService({ const existing = getRowForLane(summary.laneId) ?? getRowForRepoPr(summary.repoOwner, summary.repoName, summary.githubPrNumber); if (existing) { + if (existing.lane_id !== summary.laneId) { + db.run(`delete from pr_group_members where pr_id = ?`, [existing.id]); + } db.run( ` update pull_requests @@ -2326,7 +2329,12 @@ export function createPrService({ }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - const existingPr = /pull request already exists|already exists|validation failed/i.test(msg) + const msgLower = msg.toLowerCase(); + const duplicatePrMessage = + msgLower.includes("pull request already exists") + || msgLower.includes("a pull request already exists") + || /\bhead\b.*\balready exists\b/i.test(msg); + const existingPr = duplicatePrMessage ? await findExistingPrForBranch(repo, headBranch, baseBranch).catch((lookupError) => { logger.warn("prs.create_existing_lookup_failed", { headBranch, diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index 061f361ed..cb4147893 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import React from "react"; -import { act, render, cleanup } from "@testing-library/react"; +import { act, render, cleanup, waitFor } from "@testing-library/react"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const MOCK_TERMINAL_FONT_FAMILY = vi.hoisted(() => "monospace"); @@ -407,28 +407,29 @@ describe("TerminalView", () => { }); it("falls back to the DOM renderer when webgl initialization fails", async () => { - mockState.shouldThrowWebglAddon = true; - const previousFallbacks = getTerminalRuntimeSnapshot("session-dom")?.health.rendererFallbacks ?? 0; - - render(); - // initRendererChain is fire-and-forget with a dynamic import inside. - // Multiple flush cycles are needed for the microtask chain to fully settle: - // 1) timer flush kicks off the render + initRendererChain - // 2) microtask flush lets the dynamic import resolve - // 3) second timer flush lets the post-import code run - for (let i = 0; i < 100; i++) { + // `await import("@xterm/addon-webgl")` may not settle under Vi's fake timers on CI shards. + vi.useRealTimers(); + try { + mockState.shouldThrowWebglAddon = true; + const previousFallbacks = getTerminalRuntimeSnapshot("session-dom")?.health.rendererFallbacks ?? 0; + + render(); + + await waitFor( + () => { + const runtime = getTerminalRuntimeSnapshot("session-dom"); + expect(runtime?.renderer).toBe("dom"); + expect(runtime?.health.rendererFallbacks).toBeGreaterThan(previousFallbacks); + }, + { timeout: 10_000 }, + ); + + cleanup(); await act(async () => {}); - await (vi as any).dynamicImportSettled?.(); - await flushAllTimers(); - const runtime = getTerminalRuntimeSnapshot("session-dom"); - if (runtime?.renderer === "dom" && runtime.health.rendererFallbacks > previousFallbacks) { - break; - } + await Promise.resolve(); + } finally { + vi.useFakeTimers(); } - - const runtime = getTerminalRuntimeSnapshot("session-dom"); - expect(runtime?.renderer).toBe("dom"); - expect(runtime?.health.rendererFallbacks).toBeGreaterThan(previousFallbacks); }); it("applies updated terminal preferences to an existing runtime", async () => { From 24b48ae0d069181e83693a1ec00501f3c58720a0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:40:24 -0400 Subject: [PATCH 4/7] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20review?= =?UTF-8?q?=20follow-ups=20+=20CLI=20audit=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adeRpcServer / adeActions registry: role-gate CTO-only methods (linear_credentials set/clear, github set/clear token, update.quitAndInstall, flow_policy.save/rollback, linear_sync.runSyncNow/resolveQueueItem, linear_ingress.ensureRelayWebhook, budget.updateConfig, feedback.submitPreparedDraft, usage.start/stop/forceRefresh) so agent-role callers cannot invoke them via run_ade_action. - layout.set / tiling_tree.set / graph_state.set reject calls with missing payload key instead of persisting default {}/null, preventing accidental UI state wipe. - create_pr_from_lane threads baseBranch through prService.draftDescription so auto-drafted title/body describe the actual target branch. - Integration lane provenance: only flag as "adopted" when preferred_integration_lane_id === integration_lane_id; mismatched scratch lanes stay "ade-created" so cleanup/delete works. - promptInspector: dedup ADE_CLI_AGENT_GUIDANCE block. - agentChatService: skip first-user-message guidance injection for Claude sessions (system prompt already carries it). - cli hasHelpFlag: expand valueCarrierFlags to cover all ~120 value-taking long-flags so --help after a pending value flag is treated as the value. Addresses review comments 3134159150, 3134159153, 3134159164, 3134196314, 3134196316, 3134196319, 3134196322, 3134196340. Fixes 3 failing adeRpcServer tests around dynamic ADE actions listing. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ade-cli/src/adeRpcServer.test.ts | 2 +- apps/ade-cli/src/adeRpcServer.ts | 25 +- apps/ade-cli/src/cli.ts | 48 +-- .../src/main/services/adeActions/registry.ts | 273 +++++++++++++++++- .../main/services/chat/agentChatService.ts | 5 +- .../services/orchestrator/promptInspector.ts | 1 - .../src/main/services/prs/prService.ts | 22 +- .../services/sync/syncRemoteCommandService.ts | 1 + apps/desktop/src/shared/types/prs.ts | 1 + 9 files changed, 339 insertions(+), 39 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index adcb70682..c532a3915 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -3165,7 +3165,7 @@ describe("adeRpcServer", () => { baseBranch: "main", }); expect(drafted?.isError).toBeUndefined(); - expect(fixture.runtime.prService.draftDescription).toHaveBeenCalledWith({ laneId: "lane-1" }); + expect(fixture.runtime.prService.draftDescription).toHaveBeenCalledWith({ laneId: "lane-1", baseBranch: "main" }); expect(fixture.runtime.prService.createFromLane).toHaveBeenLastCalledWith({ laneId: "lane-1", baseBranch: "main", diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index a8fb162e2..663927003 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -17,8 +17,10 @@ import { resolveAgentMemoryWritePolicy } from "../../desktop/src/main/services/m import { ADE_ACTION_ALLOWLIST, type AdeActionDomain, + callerHasRoleAtLeast, getAdeActionDomainServices, isAllowedAdeAction, + isCtoOnlyAdeAction, listAllowedAdeActionNames, } from "../../desktop/src/main/services/adeActions/registry"; import { ReflectionValidationError } from "../../desktop/src/main/services/orchestrator/orchestratorService"; @@ -4172,15 +4174,18 @@ async function runTool(args: { const domains = domain === "all" ? (Object.keys(services) as AdeActionDomain[]) : [domain as AdeActionDomain]; + const callerIsCto = callerHasRoleAtLeast(callerCtx.role, "cto"); const actions = domains.flatMap((entry) => { const service = services[entry]; if (!service) return []; - return listAllowedAdeActionNames(entry, service).map((action) => ({ - domain: entry, - action, - name: `${entry}.${action}`, - usage: `ade actions run ${entry}.${action} --input-json '{"key":"value"}' (or --scalar value / --args-list-json '[...]' for scalar or positional service methods)`, - })); + return listAllowedAdeActionNames(entry, service) + .filter((action) => callerIsCto || !isCtoOnlyAdeAction(entry, action)) + .map((action) => ({ + domain: entry, + action, + name: `${entry}.${action}`, + usage: `ade actions run ${entry}.${action} --input-json '{"key":"value"}' (or --scalar value / --args-list-json '[...]' for scalar or positional service methods)`, + })); }); return { count: actions.length, @@ -4203,6 +4208,9 @@ async function runTool(args: { if (!isAllowedAdeAction(domain, action)) { throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Action '${domain}.${action}' is not exposed through ADE actions.`); } + if (isCtoOnlyAdeAction(domain, action) && !callerHasRoleAtLeast(callerCtx.role, "cto")) { + throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Action '${domain}.${action}' requires elevated role.`); + } const argsList = Array.isArray(toolArgs.argsList) ? toolArgs.argsList : null; const hasScalarArg = Object.prototype.hasOwnProperty.call(toolArgs, "arg"); const rawObjectArgs = safeObject(toolArgs.args); @@ -5488,7 +5496,10 @@ async function runTool(args: { let title = asOptionalTrimmedString(toolArgs.title); let body = typeof toolArgs.body === "string" ? toolArgs.body : null; if (!title || body == null) { - const draft = await prSvc.draftDescription({ laneId }); + const draft = await prSvc.draftDescription({ + laneId, + ...(baseBranch ? { baseBranch } : {}), + }); title = title || asOptionalTrimmedString(draft.title) || `PR for ${laneId}`; body = body ?? asOptionalTrimmedString(draft.body) ?? ""; } diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index ca55f8c66..92c2b967c 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1856,28 +1856,38 @@ function buildCoordinatorPlan(args: string[]): CliPlan { return { kind: "execute", label: `coordinator ${toolName}`, steps: [actionCallStep("result", toolName, collectGenericObjectArgs(args))] }; } +const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ + "-b", "-t", + "--additional-instructions", "--app", "--arg", "--arg-json", "--arg-value", + "--arg-value-json", "--args-list-json", "--attempt", "--attempt-id", + "--automation", "--base", "--base-branch", "--body", "--branch", + "--branch-name", "--branch-ref", "--category", "--color", "--cols", + "--command", "--comment", "--comment-id", "--commit", "--compare-ref", + "--compare-to", "--content", "--context-file", "--cwd", "--data", + "--delete-source", "--delete-source-lane", "--depth", "--desc", + "--description", "--domain", "--duration-sec", "--enabled", "--event", + "--from-file", "--group", "--group-id", "--head", "--icon", "--id", + "--include-ignored", "--input", "--input-json", "--instructions", + "--json-input", "--lane", "--lane-id", "--limit", "--max-bytes", "--memory", + "--memory-id", "--merge-method", "--message", "--method", "--mode", "--model", + "--model-id", "--name", "--new", "--new-path", "--number", "--old", + "--old-path", "--params-json", "--parent", "--parent-lane", "--parent-lane-id", + "--path", "--permission-mode", "--permissions", "--pr", "--pr-id", + "--pr-number", "--pr-url", "--process", "--process-id", "--project-root", + "--prompt", "--provider", "--pty", "--pty-id", "--query", "--question", + "--reason", "--reasoning", "--ref", "--role", "--root", "--root-lane", + "--round", "--rows", "--rule", "--run", "--run-id", "--scalar", + "--scalar-json", "--scope", "--seconds", "--session", "--session-id", "--set", + "--set-json", "--sha", "--source", "--source-lane", "--stack", "--stack-id", + "--stash-ref", "--step", "--step-id", "--suite", "--suite-id", "--surface", + "--text", "--thread", "--thread-id", "--timeout-ms", "--title", "--tool-type", + "--unresolved", "--url", "--workspace", "--workspace-id", "--workspace-root", +]); + function hasHelpFlag(args: string[]): boolean { const terminatorIndex = args.indexOf("--"); const searchable = terminatorIndex >= 0 ? args.slice(0, terminatorIndex) : args; - const valueCarrierFlags = new Set([ - "--text", - "--body", - "--title", - "--question", - "--input-json", - "--json-input", - "--input", - "--arg", - "--set", - "--arg-json", - "--set-json", - "-t", - "-b", - "--lane", - "--session", - "--path", - "--url", - ]); + const valueCarrierFlags = VALUE_CARRIER_FLAGS; for (let i = 0; i < searchable.length; i++) { const token = searchable[i]!; if (token === "--help") { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index c1004c892..a649139c0 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -18,6 +18,9 @@ export type AdeActionDomain = | "pr" | "tests" | "chat" + | "keybindings" + | "onboarding" + | "automation_planner" | "mission" | "orchestrator" | "orchestrator_core" @@ -29,18 +32,70 @@ export type AdeActionDomain = | "project_config" | "issue_inventory" | "flow_policy" + | "linear_credentials" | "linear_dispatcher" | "linear_issue_tracker" | "linear_sync" | "linear_ingress" | "linear_routing" + | "github" + | "feedback" + | "usage" + | "budget" + | "update" | "file" | "process" | "pty" + | "layout" + | "tiling_tree" + | "graph_state" | "computer_use_artifacts" | "automations" | "issue"; +export type AdeActionRole = "cto" | "orchestrator" | "agent" | "external" | "evaluator"; + +/** + * Methods that require at least `cto` role when invoked via `run_ade_action`. + * The generic bridge has no built-in role check, so anything that mutates + * account-level credentials, persisted policy, or drives privileged polling + * must be listed here. + */ +export const ADE_ACTION_CTO_ONLY: Partial> = { + linear_credentials: [ + "setToken", + "setOAuthToken", + "setOAuthClientCredentials", + "clearToken", + "clearOAuthClientCredentials", + ], + github: ["setToken", "clearToken"], + update: ["quitAndInstall"], + flow_policy: ["savePolicy", "rollbackRevision"], + linear_sync: ["runSyncNow", "resolveQueueItem"], + linear_ingress: ["ensureRelayWebhook"], + budget: ["updateConfig"], + feedback: ["submitPreparedDraft"], + usage: ["start", "stop", "forceRefresh"], +}; + +const ROLE_ORDER: Record = { + external: 0, + evaluator: 1, + agent: 2, + orchestrator: 3, + cto: 4, +}; + +export function isCtoOnlyAdeAction(domain: AdeActionDomain, action: string): boolean { + return (ADE_ACTION_CTO_ONLY[domain] ?? []).includes(action); +} + +export function callerHasRoleAtLeast(role: AdeActionRole | undefined | null, minRole: AdeActionRole): boolean { + if (!role) return false; + return ROLE_ORDER[role] >= ROLE_ORDER[minRole]; +} + export const ADE_ACTION_ALLOWLIST: Partial> = { lane: [ "adoptAttached", @@ -60,24 +115,40 @@ export const ADE_ACTION_ALLOWLIST: Partial): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(layout)) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + out[key] = Math.max(0, Math.min(100, value)); + } + return out; +} + +type LayoutService = { + get(args: { layoutId?: unknown }): unknown; + set(args: { layoutId?: unknown; layout?: unknown }): { layoutId: string; layout: Record }; +}; + +function buildLayoutDomainService(runtime: AdeRuntime): LayoutService | null { + if (!runtime.db) return null; + return { + get(args) { + const layoutId = requireNonEmptyString(args?.layoutId, "layoutId"); + return runtime.db.getJson(`dock_layout:${layoutId}`); + }, + set(args) { + const layoutId = requireNonEmptyString(args?.layoutId, "layoutId"); + if (!args || !Object.prototype.hasOwnProperty.call(args, "layout")) { + throw new Error("Missing required 'layout' object. Pass an explicit null to clear."); + } + const rawLayout = args.layout; + const layout = rawLayout && typeof rawLayout === "object" && !Array.isArray(rawLayout) + ? clampDockLayout(rawLayout as Record) + : {}; + runtime.db.setJson(`dock_layout:${layoutId}`, layout); + return { layoutId, layout }; + }, + }; +} + +type TilingTreeService = { + get(args: { layoutId?: unknown }): unknown; + set(args: { layoutId?: unknown; tree?: unknown }): { layoutId: string; tree: unknown }; +}; + +function buildTilingTreeDomainService(runtime: AdeRuntime): TilingTreeService | null { + if (!runtime.db) return null; + return { + get(args) { + const layoutId = requireNonEmptyString(args?.layoutId, "layoutId"); + return runtime.db.getJson(`tiling_tree:${layoutId}`); + }, + set(args) { + const layoutId = requireNonEmptyString(args?.layoutId, "layoutId"); + if (!args || !Object.prototype.hasOwnProperty.call(args, "tree")) { + throw new Error("Missing required 'tree'. Pass an explicit null to clear."); + } + const tree = args.tree; + runtime.db.setJson(`tiling_tree:${layoutId}`, tree); + return { layoutId, tree }; + }, + }; +} + +type GraphStateService = { + get(args?: { projectId?: unknown }): unknown; + set(args: { projectId?: unknown; state?: unknown }): { projectId: string; state: unknown }; +}; + +function buildGraphStateDomainService(runtime: AdeRuntime): GraphStateService | null { + if (!runtime.db) return null; + const resolveProjectId = (value: unknown): string => { + if (typeof value === "string" && value.trim().length) return value.trim(); + return runtime.projectId; + }; + return { + get(args) { + const projectId = resolveProjectId(args?.projectId); + return runtime.db.getJson(`graph_state:${projectId}`); + }, + set(args) { + const projectId = resolveProjectId(args?.projectId); + if (!args || !Object.prototype.hasOwnProperty.call(args, "state")) { + throw new Error("Missing required 'state'. Pass an explicit null to clear."); + } + const state = args.state; + runtime.db.setJson(`graph_state:${projectId}`, state); + return { projectId, state }; + }, + }; +} + export function getAdeActionDomainServices( runtime: AdeRuntime, ): Partial> { @@ -295,6 +552,9 @@ export function getAdeActionDomainServices( pr: toService(runtime.prService), tests: toService(runtime.testService), chat: toService(runtime.agentChatService), + keybindings: toService(runtime.keybindingsService), + onboarding: toService(runtime.onboardingService), + automation_planner: toService(runtime.automationPlannerService), mission: toService(runtime.missionService), orchestrator: toService(runtime.aiOrchestratorService), orchestrator_core: toService(runtime.orchestratorService), @@ -306,14 +566,23 @@ export function getAdeActionDomainServices( project_config: toService(runtime.projectConfigService), issue_inventory: toService(runtime.issueInventoryService), flow_policy: toService(runtime.flowPolicyService), + linear_credentials: toService(runtime.linearCredentialService), linear_dispatcher: toService(runtime.linearDispatcherService), linear_issue_tracker: toService(runtime.linearIssueTracker), linear_sync: toService(runtime.linearSyncService), linear_ingress: toService(runtime.linearIngressService), linear_routing: toService(runtime.linearRoutingService), + github: toService(runtime.githubService), + feedback: toService(runtime.feedbackReporterService), + usage: toService(runtime.usageTrackingService), + budget: toService(runtime.budgetCapService), + update: toService(runtime.autoUpdateService), file: toService(runtime.fileService), process: toService(runtime.processService), pty: toService(runtime.ptyService), + layout: toService(buildLayoutDomainService(runtime)), + tiling_tree: toService(buildTilingTreeDomainService(runtime)), + graph_state: toService(buildGraphStateDomainService(runtime)), computer_use_artifacts: toService(runtime.computerUseArtifactBrokerService), automations: toService(buildAutomationsDomainService(runtime)), issue: toService(buildIssueDomainService(runtime)), diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 7244ca3c4..2c4707b8f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -10902,6 +10902,9 @@ export function createAgentChatService(args: { } const laneDirectiveKey = executionContext.laneDirectiveKey; const shouldInjectLaneDirective = laneDirectiveKey != null && managed.lastLaneDirectiveKey !== laneDirectiveKey; + // Claude sessions already receive ADE_CLI_AGENT_GUIDANCE in their persistent system prompt + // (see buildClaudeV2SessionOpts). Skip the first-user-message copy to avoid duplicate guidance. + const providerHasPersistentGuidance = managed.session.provider === "claude"; const promptText = providerSlashCommand ? trimmed : composeLaunchDirectives(trimmed, [ @@ -10913,7 +10916,7 @@ export function createAgentChatService(args: { : null, buildExecutionModeDirective(executionMode, managed.session.provider), buildClaudeInteractionModeDirective(managed.session.interactionMode, managed.session.provider), - shouldInjectLaneDirective ? ADE_CLI_AGENT_GUIDANCE : null, + shouldInjectLaneDirective && !providerHasPersistentGuidance ? ADE_CLI_AGENT_GUIDANCE : null, buildComputerUseDirective( computerUseArtifactBrokerRef?.getBackendStatus() ?? null, ), diff --git a/apps/desktop/src/main/services/orchestrator/promptInspector.ts b/apps/desktop/src/main/services/orchestrator/promptInspector.ts index fb0c04dfa..8145fe9c7 100644 --- a/apps/desktop/src/main/services/orchestrator/promptInspector.ts +++ b/apps/desktop/src/main/services/orchestrator/promptInspector.ts @@ -212,7 +212,6 @@ function buildWorkerBaseGuidance(step: OrchestratorStep, graph: OrchestratorRunG ); sections.push( [ - ADE_CLI_AGENT_GUIDANCE, "Your worker identity (mission, run, step, attempt IDs) is automatically resolved — you don't need to pass IDs to observation tools.", "Key actions available:", "- get_worker_states", diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index c239ff3d8..bff6e1112 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -230,12 +230,14 @@ function getIntegrationLaneOrigin(row: { const integrationLaneId = asString(row.integration_lane_id).trim() || null; if (!integrationLaneId) return null; const preferredIntegrationLaneId = asString(row.preferred_integration_lane_id).trim() || null; - // Scratch integration lanes use `laneService.createChild` only when simulation had no merge-into - // lane, so `preferred_integration_lane_id` stays unset. When merging into an existing lane, that - // lane id is stored here (and normally matches `integration_lane_id`). Inferring "adopted" from - // `preferred === integration` alone is unsafe because merge-into and integration ids can differ. - if (!preferredIntegrationLaneId) return "ade-created"; - return "adopted"; + // A lane is only "adopted" when the caller's chosen merge-into lane (preferred) actually became + // the integration lane. `commitIntegration` can persist a new preferred lane alongside an + // existing scratch integration lane; in that case the two ids disagree and the scratch lane is + // still ade-created, so we must not claim it as adopted. + if (preferredIntegrationLaneId && preferredIntegrationLaneId === integrationLaneId) { + return "adopted"; + } + return "ade-created"; } function isAdeOwnedIntegrationLane(row: { @@ -2174,11 +2176,15 @@ export function createPrService({ const lane = (await laneService.list({ includeArchived: true })).find((entry) => entry.id === laneId); if (!lane) throw new Error(`Lane not found: ${laneId}`); + const baseRefForDiff = (args.baseBranch && args.baseBranch.trim().length > 0) + ? args.baseBranch.trim() + : lane.baseRef; + const template = readPrTemplate(projectRoot); const packBody = await (async () => { // W6: pack-based context removed. Provide a bounded git-native lane change summary instead. const diff = await runGit( - ["diff", "--name-status", `${lane.baseRef}...HEAD`], + ["diff", "--name-status", `${baseRefForDiff}...HEAD`], { cwd: lane.worktreePath, timeoutMs: 15_000 } ); if (diff.exitCode === 0) { @@ -2197,7 +2203,7 @@ export function createPrService({ laneId, laneName: lane.name, branchRef: lane.branchRef, - baseRef: lane.baseRef, + baseRef: baseRefForDiff, parentLaneId: lane.parentLaneId, commits, packBody, diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index 704e0c817..b958de1bc 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -868,6 +868,7 @@ function parseDraftPrDescriptionArgs(value: Record): DraftPrDes ...("reasoningEffort" in value ? { reasoningEffort: value.reasoningEffort == null ? null : asTrimmedString(value.reasoningEffort) ?? null } : {}), + ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), }; } diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index 3b7284fe4..41f4da95b 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -253,6 +253,7 @@ export type DraftPrDescriptionArgs = { laneId: string; model?: string; reasoningEffort?: string | null; + baseBranch?: string; }; export type UpdatePrDescriptionArgs = { From fda7bc83c73d175fbc045529804962d541a287df Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:49:31 -0400 Subject: [PATCH 5/7] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20sync=20?= =?UTF-8?q?ade=20action=20schema=20to=20registry,=20plug=20PR-row=20handof?= =?UTF-8?q?f=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adeActions registry: export ADE_ACTION_DOMAIN_NAMES as source of truth; AdeActionDomain derived from it. - adeRpcServer: list_ade_actions/run_ade_action schema enums now pull from ADE_ACTION_DOMAIN_NAMES, removing ~25 domains that were advertised but not backed by the registry (agent_tools, ade_cli, dev_tools, sync, etc.) and adding the missing automations/issue entries. - prService.upsertRow: when reusing a PR row across lanes, null out integration_proposals.linked_pr_id alongside the pr_group_members cleanup so old integration workflows don't silently track the new lane's PR status. - cli.ts hasHelpFlag: add "-q" to valueCarrierFlags (used by files search / memory search). Addresses review comments 3134268111, 3134268114, 3134268121, 3134268125. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ade-cli/src/adeRpcServer.ts | 140 +----------------- apps/ade-cli/src/cli.ts | 2 +- .../src/main/services/adeActions/registry.ts | 87 +++++------ .../src/main/services/prs/prService.ts | 1 + 4 files changed, 50 insertions(+), 180 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 663927003..39e1c5161 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -16,6 +16,7 @@ import { loadAgentBrowserArtifactPayloadFromFile, parseAgentBrowserArtifactPaylo import { resolveAgentMemoryWritePolicy } from "../../desktop/src/main/services/memory/memoryService"; import { ADE_ACTION_ALLOWLIST, + ADE_ACTION_DOMAIN_NAMES, type AdeActionDomain, callerHasRoleAtLeast, getAdeActionDomainServices, @@ -171,74 +172,7 @@ const TOOL_SPECS: ToolSpec[] = [ properties: { domain: { type: "string", - enum: [ - "lane", - "git", - "diff", - "conflicts", - "pr", - "tests", - "chat", - "keybindings", - "agent_tools", - "ade_cli", - "dev_tools", - "ai", - "sync", - "onboarding", - "automation", - "automation_planner", - "automation_ingress", - "context", - "mission", - "mission_preflight", - "orchestrator", - "orchestrator_core", - "mission_budget", - "memory", - "cto_state", - "worker_agent", - "worker_budget", - "worker_revision", - "worker_heartbeat", - "worker_task_session", - "openclaw", - "session", - "session_delta", - "operation", - "project_config", - "issue_inventory", - "queue_landing", - "pr_summary", - "flow_policy", - "linear_credentials", - "linear_dispatcher", - "linear_issue_tracker", - "linear_sync", - "linear_ingress", - "linear_routing", - "github", - "feedback", - "usage", - "budget", - "update", - "file", - "process", - "pty", - "lane_env", - "lane_template", - "port_allocation", - "lane_proxy", - "oauth_redirect", - "runtime_diagnostics", - "rebase_suggestion", - "auto_rebase", - "layout", - "tiling_tree", - "graph_state", - "computer_use_artifacts", - "all" - ], + enum: [...ADE_ACTION_DOMAIN_NAMES, "all"], default: "all", }, } @@ -254,75 +188,7 @@ const TOOL_SPECS: ToolSpec[] = [ properties: { domain: { type: "string", - enum: [ - "lane", - "git", - "diff", - "conflicts", - "pr", - "tests", - "chat", - "keybindings", - "agent_tools", - "ade_cli", - "dev_tools", - "ai", - "sync", - "onboarding", - "automation", - "automation_planner", - "automation_ingress", - "context", - "mission", - "mission_preflight", - "orchestrator", - "orchestrator_core", - "mission_budget", - "memory", - "cto_state", - "worker_agent", - "worker_budget", - "worker_revision", - "worker_heartbeat", - "worker_task_session", - "openclaw", - "session", - "session_delta", - "operation", - "project_config", - "issue_inventory", - "queue_landing", - "pr_summary", - "flow_policy", - "linear_credentials", - "linear_dispatcher", - "linear_issue_tracker", - "linear_sync", - "linear_ingress", - "linear_routing", - "github", - "feedback", - "usage", - "budget", - "update", - "file", - "process", - "pty", - "lane_env", - "lane_template", - "port_allocation", - "lane_proxy", - "oauth_redirect", - "runtime_diagnostics", - "rebase_suggestion", - "auto_rebase", - "layout", - "tiling_tree", - "graph_state", - "computer_use_artifacts", - "automations", - "issue", - ], + enum: [...ADE_ACTION_DOMAIN_NAMES], }, action: { type: "string", minLength: 1 }, args: { type: "object" }, diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 92c2b967c..2af47e441 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1857,7 +1857,7 @@ function buildCoordinatorPlan(args: string[]): CliPlan { } const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ - "-b", "-t", + "-b", "-q", "-t", "--additional-instructions", "--app", "--arg", "--arg-json", "--arg-value", "--arg-value-json", "--args-list-json", "--attempt", "--attempt-id", "--automation", "--base", "--base-branch", "--body", "--branch", diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index a649139c0..c7e855397 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -10,48 +10,51 @@ import type { } from "../../../shared/types/automations"; import type { AutomationRule } from "../../../shared/types/config"; -export type AdeActionDomain = - | "lane" - | "git" - | "diff" - | "conflicts" - | "pr" - | "tests" - | "chat" - | "keybindings" - | "onboarding" - | "automation_planner" - | "mission" - | "orchestrator" - | "orchestrator_core" - | "memory" - | "cto_state" - | "worker_agent" - | "session" - | "operation" - | "project_config" - | "issue_inventory" - | "flow_policy" - | "linear_credentials" - | "linear_dispatcher" - | "linear_issue_tracker" - | "linear_sync" - | "linear_ingress" - | "linear_routing" - | "github" - | "feedback" - | "usage" - | "budget" - | "update" - | "file" - | "process" - | "pty" - | "layout" - | "tiling_tree" - | "graph_state" - | "computer_use_artifacts" - | "automations" - | "issue"; +export const ADE_ACTION_DOMAIN_NAMES = [ + "lane", + "git", + "diff", + "conflicts", + "pr", + "tests", + "chat", + "keybindings", + "onboarding", + "automation_planner", + "mission", + "orchestrator", + "orchestrator_core", + "memory", + "cto_state", + "worker_agent", + "session", + "operation", + "project_config", + "issue_inventory", + "flow_policy", + "linear_credentials", + "linear_dispatcher", + "linear_issue_tracker", + "linear_sync", + "linear_ingress", + "linear_routing", + "github", + "feedback", + "usage", + "budget", + "update", + "file", + "process", + "pty", + "layout", + "tiling_tree", + "graph_state", + "computer_use_artifacts", + "automations", + "issue", +] as const; + +export type AdeActionDomain = (typeof ADE_ACTION_DOMAIN_NAMES)[number]; export type AdeActionRole = "cto" | "orchestrator" | "agent" | "external" | "evaluator"; diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index bff6e1112..7892aaa6a 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -1161,6 +1161,7 @@ export function createPrService({ if (existing) { if (existing.lane_id !== summary.laneId) { db.run(`delete from pr_group_members where pr_id = ?`, [existing.id]); + db.run(`update integration_proposals set linked_pr_id = null where linked_pr_id = ?`, [existing.id]); } db.run( ` From d9e33d5115b0efcd21a5e54d66c1a133f44f9eff Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:59:04 -0400 Subject: [PATCH 6/7] =?UTF-8?q?ship:=20iteration=204=20=E2=80=94=20review?= =?UTF-8?q?=20follow-ups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prService.findExistingPrForBranch: drop `|| Boolean(candidate?.draft)`. GitHub keeps draft=true on closed drafts, so the OR could prefer a closed draft over a later open PR. state==="open" is sufficient. - cli.ts VALUE_CARRIER_FLAGS: add -m, --max-rounds / --rounds, --max-log-bytes, --max-prompt-chars, --recent-limit; drop --text (global output flag, broke `lanes list --text --help`). - cli.test.ts: regression for `--text --help` ordering. - cli.ts findAdeManagedWorktreeRoot / findProjectRoots: canonicalize with fs.realpathSync.native so symlinked worktree paths still match. - coordinatorAgent.ts: drop ADE_CLI_AGENT_GUIDANCE injection — coordinator delegates to workers and has no terminal tool, so the guidance misled it. - main.ts buildAdeActionRuntimeForAutomations: forward keybindingsService, onboardingService, feedbackReporterService, usageTrackingService, budgetCapService, autoUpdateService so desktop automations can dispatch the new action domains. Addresses review comments 3134349765, 3134365534, 3134365536, 3134365659, 3134365660, 3134365668. 3134365535 already addressed in iter 3. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ade-cli/src/cli.test.ts | 8 +++++- apps/ade-cli/src/cli.ts | 28 +++++++++++++------ apps/desktop/src/main/main.ts | 6 ++++ .../services/orchestrator/coordinatorAgent.ts | 3 -- .../src/main/services/prs/prService.ts | 2 +- 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 4b4718dda..a010ced60 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -370,7 +370,9 @@ describe("ADE CLI", () => { }); it("uses the parent ADE project when invoked inside an ADE-managed lane worktree", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-roots-")); + const rawRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-roots-")); + // findProjectRoots canonicalizes symlinks (e.g. /var -> /private/var on macOS). + const root = fs.realpathSync.native(rawRoot); const worktree = path.join(root, ".ade", "worktrees", "feature-lane"); const nested = path.join(worktree, "apps", "ade-cli"); fs.mkdirSync(path.join(root, ".ade"), { recursive: true }); @@ -465,6 +467,10 @@ describe("ADE CLI", () => { if (actionsHelp.kind !== "help") return; expect(actionsHelp.text).toContain("Argument shapes"); expect(actionsHelp.text).toContain("--args-list-json"); + + // Regression: --text as output flag must not swallow --help. + const lanesHelp = buildCliPlan(["lanes", "list", "--text", "--help"]); + expect(lanesHelp.kind).toBe("help"); }); it("shell-escapes argv tokens after -- when building shell start commands", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 2af47e441..d0a6ec010 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1857,7 +1857,7 @@ function buildCoordinatorPlan(args: string[]): CliPlan { } const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ - "-b", "-q", "-t", + "-b", "-m", "-q", "-t", "--additional-instructions", "--app", "--arg", "--arg-json", "--arg-value", "--arg-value-json", "--args-list-json", "--attempt", "--attempt-id", "--automation", "--base", "--base-branch", "--body", "--branch", @@ -1868,19 +1868,20 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--description", "--domain", "--duration-sec", "--enabled", "--event", "--from-file", "--group", "--group-id", "--head", "--icon", "--id", "--include-ignored", "--input", "--input-json", "--instructions", - "--json-input", "--lane", "--lane-id", "--limit", "--max-bytes", "--memory", + "--json-input", "--lane", "--lane-id", "--limit", "--max-bytes", + "--max-log-bytes", "--max-prompt-chars", "--max-rounds", "--memory", "--memory-id", "--merge-method", "--message", "--method", "--mode", "--model", "--model-id", "--name", "--new", "--new-path", "--number", "--old", "--old-path", "--params-json", "--parent", "--parent-lane", "--parent-lane-id", "--path", "--permission-mode", "--permissions", "--pr", "--pr-id", "--pr-number", "--pr-url", "--process", "--process-id", "--project-root", "--prompt", "--provider", "--pty", "--pty-id", "--query", "--question", - "--reason", "--reasoning", "--ref", "--role", "--root", "--root-lane", - "--round", "--rows", "--rule", "--run", "--run-id", "--scalar", + "--reason", "--reasoning", "--recent-limit", "--ref", "--role", "--root", + "--root-lane", "--round", "--rounds", "--rows", "--rule", "--run", "--run-id", "--scalar", "--scalar-json", "--scope", "--seconds", "--session", "--session-id", "--set", "--set-json", "--sha", "--source", "--source-lane", "--stack", "--stack-id", "--stash-ref", "--step", "--step-id", "--suite", "--suite-id", "--surface", - "--text", "--thread", "--thread-id", "--timeout-ms", "--title", "--tool-type", + "--thread", "--thread-id", "--timeout-ms", "--title", "--tool-type", "--unresolved", "--url", "--workspace", "--workspace-id", "--workspace-root", ]); @@ -1998,7 +1999,12 @@ function buildCliPlan(command: string[]): CliPlan { } function findAdeManagedWorktreeRoot(startDir: string): { projectRoot: string; workspaceRoot: string } | null { - const resolved = path.resolve(startDir); + let resolved = path.resolve(startDir); + try { + resolved = fs.realpathSync.native(resolved); + } catch { + // path may not yet exist on disk; use the lexical resolution. + } const segments = resolved.split(path.sep); for (let index = segments.length - 2; index >= 0; index -= 1) { if (segments[index] !== ".ade" || segments[index + 1] !== "worktrees") continue; @@ -2013,10 +2019,16 @@ function findAdeManagedWorktreeRoot(startDir: string): { projectRoot: string; wo } function findProjectRoots(startDir: string): { projectRoot: string; workspaceRoot: string } { - const managedWorktree = findAdeManagedWorktreeRoot(startDir); + let canonicalStart = path.resolve(startDir); + try { + canonicalStart = fs.realpathSync.native(canonicalStart); + } catch { + // path may not yet exist on disk; use the lexical resolution. + } + const managedWorktree = findAdeManagedWorktreeRoot(canonicalStart); if (managedWorktree) return managedWorktree; - let cursor = path.resolve(startDir); + let cursor = canonicalStart; while (true) { if (fs.existsSync(path.join(cursor, ".ade"))) { return { projectRoot: cursor, workspaceRoot: cursor }; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 5a387577e..66d0cfca1 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3381,6 +3381,12 @@ app.whenReady().then(async () => { automationService, automationPlannerService, githubService, + keybindingsService, + onboardingService, + feedbackReporterService, + usageTrackingService, + budgetCapService, + autoUpdateService, } as unknown as AdeRuntime; } diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts index 24c03e70e..4eb244dac 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts @@ -33,7 +33,6 @@ import { import { asRecord, filterExecutionSteps } from "./orchestratorContext"; import { readMissionStateDocument, writeCoordinatorCheckpoint } from "./missionStateDoc"; import { getLocalProviderDefaultEndpoint, resolveModelDescriptor, type LocalProviderFamily } from "../../../shared/modelRegistry"; -import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; import { inspectLocalProvider } from "../ai/localModelDiscovery"; import type { DiscoveredLocalModelEntry } from "../opencode/openCodeRuntime"; import type { createOrchestratorService } from "./orchestratorService"; @@ -2076,8 +2075,6 @@ Your conversation persists across the entire mission — you accumulate context, You are NOT a repo-editing worker. You are the mission lead who owns phase state, worker spawning, runtime judgment, and final completion. In normal operation, workers inspect the repo, edit code, and run commands. You keep the mission aligned and delegated. The difference between you and a dumb orchestrator is that you THINK before you act and EVALUATE after each step. -${ADE_CLI_AGENT_GUIDANCE} - ## Your Mission ${this.deps.missionGoal} diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 7892aaa6a..f238ecb87 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -1344,7 +1344,7 @@ export function createPrService({ if (candidates.length === 0) return null; const open = candidates.find((candidate) => { const state = asString(candidate?.state).toLowerCase(); - return state === "open" || Boolean(candidate?.draft); + return state === "open"; }); return open ?? candidates[0] ?? null; }; From e4d44e3d50c1d9cee0c0f2fe320e24f7a7e673ef Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:41:39 -0400 Subject: [PATCH 7/7] =?UTF-8?q?ship:=20iteration=204=20(post-rebate)=20?= =?UTF-8?q?=E2=80=94=20post-merge=20review=20follow-ups=20+=20CI=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - orchestratorService: drop redundant ADE_CLI_AGENT_GUIDANCE prepend in augmentedPrompt; buildFullPrompt already injects it into the system prompt for mission-tooling workers, so the double-injection wasted context. - adeActions registry.graph_state: ignore caller-supplied projectId and always key by runtime.projectId — closes the cross-project read/write escalation. - adeActions registry.layout/tiling_tree/graph_state set: reject non-plain- object payloads in addition to missing keys (allow explicit null to clear). - adeActions registry CTO-only: gate usage.poll alongside start/stop/forceRefresh. - prService.upsertRow: no longer adopts rows matching by repo/PR number across lanes unless caller passes allowRepoPrAdoption. Only duplicate-PR recovery in createFromLane opts in; linkToLane + refresh stay lane-scoped. - prService.getRowByNumber: accepts optional repo owner/name; throws on cross-repo ambiguity so bare "#123" locators surface conflicts instead of silently picking the most-recently-updated row. - main.ts: autoUpdateService is initialized before rpcRuntime construction so the embedded socket RPC captures a live instance instead of null. - cli.ts VALUE_CARRIER_FLAGS: prune boolean-only flags (--delete-source, --delete-source-lane, --include-ignored, --unresolved). - agentChatService: decouple ADE_CLI_AGENT_GUIDANCE from shouldInjectLaneDirective. Providers without persistent system-prompt guidance (Codex/OpenCode/Cursor) now get the block on every launch, even resumed sessions. - providerOrchestratorAdapter.test: accept the now-always-augmented worker prompt via expect.stringContaining("diagnose the failing check") instead of an exact-equality assertion. Addresses 3134403033, 3134403054, 3134403060, 3134403076, 3134443870, 3134443879, 3134503369, 3134503423, 3134504179, 3134504183, 3134504190, 3134504196. Fixes the providerOrchestratorAdapter test that failed on CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ade-cli/src/cli.ts | 9 ++- apps/desktop/src/main/main.ts | 32 +++++---- .../src/main/services/adeActions/registry.ts | 36 ++++++---- .../main/services/chat/agentChatService.ts | 12 +++- .../orchestrator/orchestratorService.ts | 8 ++- .../providerOrchestratorAdapter.test.ts | 2 +- .../src/main/services/prs/prService.ts | 68 +++++++++++++++---- 7 files changed, 117 insertions(+), 50 deletions(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 900ba293d..e6a230075 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1858,6 +1858,9 @@ function buildCoordinatorPlan(args: string[]): CliPlan { } const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ + // Only flags that actually take a following value (readValue / readIntOption + // callers) belong here. Boolean-only flags consumed via readFlag must be + // excluded, otherwise the next positional would be swallowed as their value. "-b", "-m", "-q", "-t", "--additional-instructions", "--app", "--arg", "--arg-json", "--arg-value", "--arg-value-json", "--args-list-json", "--attempt", "--attempt-id", @@ -1865,10 +1868,10 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--branch-name", "--branch-ref", "--category", "--color", "--cols", "--command", "--comment", "--comment-id", "--commit", "--compare-ref", "--compare-to", "--content", "--context-file", "--cwd", "--data", - "--delete-source", "--delete-source-lane", "--depth", "--desc", + "--depth", "--desc", "--description", "--domain", "--duration-sec", "--enabled", "--event", "--from-file", "--group", "--group-id", "--head", "--icon", "--id", - "--include-ignored", "--input", "--input-json", "--instructions", + "--input", "--input-json", "--instructions", "--json-input", "--lane", "--lane-id", "--limit", "--max-bytes", "--max-log-bytes", "--max-prompt-chars", "--max-rounds", "--memory", "--memory-id", "--merge-method", "--message", "--method", "--mode", "--model", @@ -1883,7 +1886,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--set-json", "--sha", "--source", "--source-lane", "--stack", "--stack-id", "--stash-ref", "--step", "--step-id", "--suite", "--suite-id", "--surface", "--thread", "--thread-id", "--timeout-ms", "--title", "--tool-type", - "--unresolved", "--url", "--workspace", "--workspace-id", "--workspace-root", + "--url", "--workspace", "--workspace-id", "--workspace-root", ]); function hasHelpFlag(args: string[]): boolean { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 95f1b406c..d37e80e54 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1130,6 +1130,24 @@ app.whenReady().then(async () => { }); }; + // --- Auto-update service (global, not per-project) --- + // Created early so every `rpcRuntime` built inside `initContextForProjectRoot` + // captures a live reference. Previously this was assigned after all init + // paths were registered, which meant RPC-visible `runtime.autoUpdateService` + // could be null if a project context was built before the late assignment. + const updateLogger = createFileLogger( + path.join(app.getPath("userData"), "ade-update.jsonl"), + ); + cleanupStaleTempArtifacts({ + tempRoot: app.getPath("temp"), + logger: updateLogger, + }); + const autoUpdateService = createAutoUpdateService({ + logger: updateLogger, + currentVersion: app.getVersion(), + globalStatePath, + }); + const initContextForProjectRoot = async ({ projectRoot, baseRef, @@ -4170,7 +4188,6 @@ app.whenReady().then(async () => { dormantContext = createDormantProjectContext(); - let autoUpdateService: ReturnType | null = null; let shutdownPromise: Promise | null = null; let shutdownRequested = false; let shutdownFinalized = false; @@ -4417,19 +4434,6 @@ app.whenReady().then(async () => { runImmediateProcessCleanup("will_quit"); }); - // --- Auto-update service (global, not per-project) --- - const updateLogger = createFileLogger( - path.join(app.getPath("userData"), "ade-update.jsonl"), - ); - cleanupStaleTempArtifacts({ - tempRoot: app.getPath("temp"), - logger: updateLogger, - }); - autoUpdateService = createAutoUpdateService({ - logger: updateLogger, - currentVersion: app.getVersion(), - globalStatePath, - }); try { const { recoverManagedOpenCodeOrphans } = require("./services/opencode/openCodeServerManager"); await recoverManagedOpenCodeOrphans({ force: true, logger: getActiveContext().logger }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index c7e855397..6fa970d1e 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -79,7 +79,7 @@ export const ADE_ACTION_CTO_ONLY: Partial = { @@ -483,9 +483,14 @@ function buildLayoutDomainService(runtime: AdeRuntime): LayoutService | null { throw new Error("Missing required 'layout' object. Pass an explicit null to clear."); } const rawLayout = args.layout; - const layout = rawLayout && typeof rawLayout === "object" && !Array.isArray(rawLayout) - ? clampDockLayout(rawLayout as Record) - : {}; + let layout: Record; + if (rawLayout === null) { + layout = {}; + } else if (rawLayout && typeof rawLayout === "object" && !Array.isArray(rawLayout)) { + layout = clampDockLayout(rawLayout as Record); + } else { + throw new Error("Expected 'layout' to be a plain object or null."); + } runtime.db.setJson(`dock_layout:${layoutId}`, layout); return { layoutId, layout }; }, @@ -510,6 +515,9 @@ function buildTilingTreeDomainService(runtime: AdeRuntime): TilingTreeService | throw new Error("Missing required 'tree'. Pass an explicit null to clear."); } const tree = args.tree; + if (tree !== null && (typeof tree !== "object" || Array.isArray(tree))) { + throw new Error("Expected 'tree' to be a plain object or null."); + } runtime.db.setJson(`tiling_tree:${layoutId}`, tree); return { layoutId, tree }; }, @@ -517,27 +525,29 @@ function buildTilingTreeDomainService(runtime: AdeRuntime): TilingTreeService | } type GraphStateService = { - get(args?: { projectId?: unknown }): unknown; - set(args: { projectId?: unknown; state?: unknown }): { projectId: string; state: unknown }; + get(): unknown; + set(args: { state?: unknown }): { projectId: string; state: unknown }; }; function buildGraphStateDomainService(runtime: AdeRuntime): GraphStateService | null { if (!runtime.db) return null; - const resolveProjectId = (value: unknown): string => { - if (typeof value === "string" && value.trim().length) return value.trim(); - return runtime.projectId; - }; return { - get(args) { - const projectId = resolveProjectId(args?.projectId); + // graph_state is strictly scoped to the current runtime project. The caller + // cannot override `projectId`; the field is intentionally absent from the + // args surface to prevent cross-project reads/writes via `run_ade_action`. + get() { + const projectId = runtime.projectId; return runtime.db.getJson(`graph_state:${projectId}`); }, set(args) { - const projectId = resolveProjectId(args?.projectId); + const projectId = runtime.projectId; if (!args || !Object.prototype.hasOwnProperty.call(args, "state")) { throw new Error("Missing required 'state'. Pass an explicit null to clear."); } const state = args.state; + if (state !== null && (typeof state !== "object" || Array.isArray(state))) { + throw new Error("Expected 'state' to be a plain object or null."); + } runtime.db.setJson(`graph_state:${projectId}`, state); return { projectId, state }; }, diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 69b61643d..1bbb3f134 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -10912,9 +10912,15 @@ export function createAgentChatService(args: { } const laneDirectiveKey = executionContext.laneDirectiveKey; const shouldInjectLaneDirective = laneDirectiveKey != null && managed.lastLaneDirectiveKey !== laneDirectiveKey; - // Claude sessions already receive ADE_CLI_AGENT_GUIDANCE in their persistent system prompt - // (see buildClaudeV2SessionOpts). Skip the first-user-message copy to avoid duplicate guidance. + // Guidance injection is capability-based, not session-state-based: + // Claude sessions already receive ADE_CLI_AGENT_GUIDANCE in their + // persistent system prompt (see buildClaudeV2SessionOpts), so we skip the + // first-user-message copy there. Every other provider (Codex, OpenCode, + // Cursor…) has no persistent system prompt, so the guidance must be + // prepended even on resumed sessions where `shouldInjectLaneDirective` is + // false (review 3134504183 / 3134403060). const providerHasPersistentGuidance = managed.session.provider === "claude"; + const shouldInjectGuidance = !providerHasPersistentGuidance; const promptText = providerSlashCommand ? trimmed : composeLaunchDirectives(trimmed, [ @@ -10926,7 +10932,7 @@ export function createAgentChatService(args: { : null, buildExecutionModeDirective(executionMode, managed.session.provider), buildClaudeInteractionModeDirective(managed.session.interactionMode, managed.session.provider), - shouldInjectLaneDirective && !providerHasPersistentGuidance ? ADE_CLI_AGENT_GUIDANCE : null, + shouldInjectGuidance ? ADE_CLI_AGENT_GUIDANCE : null, buildComputerUseDirective( computerUseArtifactBrokerRef?.getBackendStatus() ?? null, ), diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index fb41c0ce4..5917636e7 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -62,7 +62,6 @@ import type { AgentChatExecutionMode, AgentChatPermissionMode, } from "../../../shared/types"; -import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; import { DEFAULT_RECOVERY_LOOP_POLICY, DEFAULT_CONTEXT_VIEW_POLICIES, @@ -4006,11 +4005,14 @@ export function createOrchestratorService({ commandPreviewParts.push("--permission-mode", shellEscapeArg(claudePermissionMode)); } } - const augmentedPrompt = [ADE_CLI_AGENT_GUIDANCE, prompt].join("\n\n"); + // ADE_CLI_AGENT_GUIDANCE is injected into the worker's system prompt + // via buildFullPrompt in baseOrchestratorAdapter when hasMissionTooling + // is true. Do not prepend it again here — that would duplicate the + // "## ADE CLI" block for Claude workers. const promptFilePath = writeWorkerPromptFile({ projectRoot, attemptId: args.attempt.id, - prompt: augmentedPrompt, + prompt, }); let launchCommand; diff --git a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts index d83d3dcc4..cbb5cddd1 100644 --- a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts +++ b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts @@ -141,7 +141,7 @@ describe("providerOrchestratorAdapter", () => { expect(mockState.resolveClaudeCodeExecutable).toHaveBeenCalledTimes(1); expect(createTrackedSession).toHaveBeenCalledWith(expect.objectContaining({ command: "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd", - args: ["-p", "diagnose the failing check"], + args: ["-p", expect.stringContaining("diagnose the failing check")], startupCommand: expect.stringContaining("exec claude -p"), })); }); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index f238ecb87..452258ac5 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -729,29 +729,54 @@ export function createPrService({ [projectId, repoOwner, repoName, prNumber] ); - const getRowByNumber = (prNumber: number): PullRequestRow | null => - db.get( + const getRowByNumber = ( + prNumber: number, + repoOwner?: string, + repoName?: string, + ): PullRequestRow | null => { + if (repoOwner && repoName) { + return getRowForRepoPr(repoOwner, repoName, prNumber); + } + // No repo context: check for ambiguity across repos in this project. If + // multiple rows match `github_pr_number`, refuse to guess — the caller + // must disambiguate with a full URL. If exactly one row matches, accept it. + const matches = db.all( `select ${PR_COLUMNS} from pull_requests where project_id = ? and github_pr_number = ? - order by updated_at desc - limit 1`, + order by updated_at desc`, [projectId, prNumber] ); + if (matches.length === 0) return null; + if (matches.length > 1) { + const repos = Array.from( + new Set(matches.map((row) => `${row.repo_owner}/${row.repo_name}`)) + ); + throw new Error( + `Ambiguous PR locator '#${prNumber}': multiple PRs with this number exist across repos in this project (${repos.join(", ")}). Specify a URL or owner/name.` + ); + } + return matches[0] ?? null; + }; const getRowByLocator = (locator: string): PullRequestRow | null => { const trimmed = String(locator ?? "").trim(); if (!trimmed) return null; + let parsed: ReturnType; try { - const parsed = parsePrLocator(trimmed); - if (parsed.owner && parsed.repo) { - return getRowForRepoPr(parsed.owner, parsed.repo, parsed.number); - } - return getRowByNumber(parsed.number); + parsed = parsePrLocator(trimmed); } catch { return null; } + if (parsed.owner && parsed.repo) { + return getRowForRepoPr(parsed.owner, parsed.repo, parsed.number); + } + // Bare numeric locators (e.g. "#123") must not silently pick the most + // recently-updated match when multiple repos share a PR number. Let the + // ambiguity error from getRowByNumber surface to the caller so they can + // supply a full URL. + return getRowByNumber(parsed.number); }; const getRow = (prIdOrLocator: string): PullRequestRow | null => @@ -1154,10 +1179,24 @@ export function createPrService({ })); }; - const upsertRow = (summary: Omit & { projectId?: string }): string => { + const upsertRow = ( + summary: Omit & { projectId?: string }, + options?: { allowRepoPrAdoption?: boolean }, + ): string => { const now = nowIso(); - const existing = getRowForLane(summary.laneId) - ?? getRowForRepoPr(summary.repoOwner, summary.repoName, summary.githubPrNumber); + // By default we only adopt an existing row that is already associated with + // this lane. Callers like `linkToLane`/`refreshOne` must not silently + // reassign an existing PR row from another lane just because the repo/PR + // number match — that was a data-loss bug when the same PR number was + // reused across lanes or when users manually linked an in-flight PR. + // The duplicate-PR recovery path in `createFromLane` (where GitHub rejects + // creation because a PR already exists for the head branch) is the only + // legitimate use of the repo/PR-number fallback; it opts in via + // `allowRepoPrAdoption: true`. + const existing = options?.allowRepoPrAdoption + ? getRowForLane(summary.laneId) + ?? getRowForRepoPr(summary.repoOwner, summary.repoName, summary.githubPrNumber) + : getRowForLane(summary.laneId); if (existing) { if (existing.lane_id !== summary.laneId) { db.run(`delete from pr_group_members where pr_id = ?`, [existing.id]); @@ -2412,7 +2451,10 @@ export function createPrService({ creationStrategy: strategy }; - const prId = upsertRow(summary); + // Allow repo/PR-number fallback here: when the GitHub create call collides + // with an already-existing PR for this branch, we need to adopt the row + // that represents that PR (regardless of prior lane attribution). + const prId = upsertRow(summary, { allowRepoPrAdoption: true }); markHotRefresh([prId]); return await refreshOne(prId);