diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock index 309d60a9e..bc1d26542 100644 --- a/.claude/scheduled_tasks.lock +++ b/.claude/scheduled_tasks.lock @@ -1 +1 @@ -{"sessionId":"5364eda2-5696-4227-b94c-5f2678de1f2e","pid":64448,"procStart":"Thu Apr 23 18:51:52 2026","acquiredAt":1776973376438} \ No newline at end of file +{"sessionId":"bab81aa2-1bdb-495e-9bf6-3d87ede93f1f","pid":85962,"procStart":"Thu Apr 23 05:29:47 2026","acquiredAt":1776922287064} \ No newline at end of file diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 6c404c620..f3a92df56 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -27,6 +27,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( @@ -96,6 +97,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 { @@ -141,6 +150,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" }]), @@ -190,7 +229,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" }]), @@ -305,6 +349,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) ?? {}; @@ -323,6 +368,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" })), @@ -1849,15 +1895,22 @@ describe("adeRpcServer", () => { tracked: true, toolType: "claude-orchestrated", command: claudePath, - args: expect.arrayContaining(["--model", "claude-sonnet-4-6", "--permission-mode", "default", "Implement API wiring"]), + args: expect.arrayContaining(["--model", "claude-sonnet-4-6", "--permission-mode", "default"]), env: expect.objectContaining({ ADE_DEFAULT_ROLE: "agent", }), }) ); + // The final arg concatenates ADE_CLI_INLINE_GUIDANCE with the user prompt; assert + // it ends with the user prompt and carries the inline guidance preamble. + const createCall = (fixture.runtime.ptyService.create as ReturnType).mock.calls[0]?.[0] as { args: string[] }; + const finalArg = createCall.args[createCall.args.length - 1]; + expect(finalArg).toContain("Before reporting an ADE lane"); + expect(finalArg.endsWith("Implement API wiring")).toBe(true); 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(); }); @@ -2819,6 +2872,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); @@ -3213,6 +3267,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", baseBranch: "main" }); + 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" }); @@ -3231,12 +3299,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 () => { @@ -3261,9 +3345,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" }); @@ -3272,21 +3380,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 9fb52a87b..99c11136a 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -16,9 +16,12 @@ import { loadAgentBrowserArtifactPayloadFromFile, parseAgentBrowserArtifactPaylo import { resolveAgentMemoryWritePolicy } from "../../desktop/src/main/services/memory/memoryService"; import { ADE_ACTION_ALLOWLIST, + ADE_ACTION_DOMAIN_NAMES, type AdeActionDomain, + callerHasRoleAtLeast, getAdeActionDomainServices, isAllowedAdeAction, + isCtoOnlyAdeAction, listAllowedAdeActionNames, } from "../../desktop/src/main/services/adeActions/registry"; import { ReflectionValidationError } from "../../desktop/src/main/services/orchestrator/orchestratorService"; @@ -27,10 +30,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"; @@ -179,43 +185,14 @@ 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, properties: { domain: { type: "string", - enum: [ - "lane", - "git", - "diff", - "conflicts", - "pr", - "tests", - "chat", - "mission", - "orchestrator", - "orchestrator_core", - "memory", - "cto_state", - "worker_agent", - "session", - "operation", - "project_config", - "issue_inventory", - "flow_policy", - "linear_dispatcher", - "linear_issue_tracker", - "linear_sync", - "linear_ingress", - "linear_routing", - "file", - "process", - "pty", - "computer_use_artifacts", - "all" - ], + enum: [...ADE_ACTION_DOMAIN_NAMES, "all"], default: "all", }, } @@ -223,7 +200,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"], @@ -231,37 +208,7 @@ const TOOL_SPECS: ToolSpec[] = [ properties: { domain: { type: "string", - enum: [ - "lane", - "git", - "diff", - "conflicts", - "pr", - "tests", - "chat", - "mission", - "orchestrator", - "orchestrator_core", - "memory", - "cto_state", - "worker_agent", - "session", - "operation", - "project_config", - "issue_inventory", - "flow_policy", - "linear_dispatcher", - "linear_issue_tracker", - "linear_sync", - "linear_ingress", - "linear_routing", - "file", - "process", - "pty", - "computer_use_artifacts", - "automations", - "issue", - ], + enum: [...ADE_ACTION_DOMAIN_NAMES], }, action: { type: "string", minLength: 1 }, args: { type: "object" }, @@ -955,10 +902,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 }, @@ -4142,13 +4089,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, - })); + 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, @@ -4171,6 +4123,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); @@ -5451,16 +5406,25 @@ 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, + ...(baseBranch ? { baseBranch } : {}), + }); + 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 }; } @@ -5717,6 +5681,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 8c1bc32d7..7e3094588 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -14,17 +14,38 @@ 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 { createProcessService } from "../../desktop/src/main/services/processes/processService"; import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "../../desktop/src/main/services/ai/cliExecutableResolver"; 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"; @@ -35,12 +56,21 @@ 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 { 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 { createGithubService } from "../../desktop/src/main/services/github/githubService"; import { createAutomationService, type AutomationAdeActionRegistry, @@ -83,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; @@ -91,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; @@ -113,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; }; @@ -476,14 +539,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 as never, 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 66b9b53f3..3a674e4cc 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1,5 +1,31 @@ +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, shouldAttemptDesktopSocketConnection, summarizeExecution, unwrapToolResult } from "./cli"; +import { + buildCliPlan, + findProjectRoots, + formatOutput, + parseCliArgs, + renderLaneGraph, + resolveRoots, + shouldAttemptDesktopSocketConnection, + 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", () => { @@ -97,6 +123,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(); @@ -296,6 +384,110 @@ describe("ADE CLI", () => { }); }); + it("uses the parent ADE project when invoked inside an ADE-managed lane worktree", () => { + 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 }); + fs.mkdirSync(path.join(worktree, ".ade"), { recursive: true }); + fs.mkdirSync(nested, { recursive: true }); + + expect(findProjectRoots(nested)).toEqual({ + projectRoot: root, + workspaceRoot: worktree, + }); + }); + + 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"); + 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"); + + // 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", () => { 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 2bbb1b1c7..e6a230075 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -241,7 +241,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 @@ -266,87 +270,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 `, @@ -997,7 +1166,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", @@ -1673,25 +1857,91 @@ function buildCoordinatorPlan(args: string[]): CliPlan { return { kind: "execute", label: `coordinator ${toolName}`, steps: [actionCallStep("result", toolName, collectGenericObjectArgs(args))] }; } +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", + "--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", + "--depth", "--desc", + "--description", "--domain", "--duration-sec", "--enabled", "--event", + "--from-file", "--group", "--group-id", "--head", "--icon", "--id", + "--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", + "--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", "--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", + "--thread", "--thread-id", "--timeout-ms", "--title", "--tool-type", + "--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 = VALUE_CARRIER_FLAGS; + 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 { 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 }; } @@ -1743,17 +1993,50 @@ 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'.`); } -function findProjectRoot(startDir: string): string { - let cursor = path.resolve(startDir); +function findAdeManagedWorktreeRoot(startDir: string): { projectRoot: string; workspaceRoot: string } | null { + 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; + 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 } { + 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 = canonicalStart; 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; @@ -1765,14 +2048,27 @@ 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 projectRoot = options.projectRoot - ?? (process.env.ADE_PROJECT_ROOT?.trim() ? path.resolve(process.env.ADE_PROJECT_ROOT.trim()) : findProjectRoot(process.cwd())); - const workspaceRoot = options.workspaceRoot - ?? (process.env.ADE_WORKSPACE_ROOT?.trim() ? path.resolve(process.env.ADE_WORKSPACE_ROOT.trim()) : projectRoot); + const discovered = findProjectRoots(process.cwd()); + 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 }; } @@ -2442,7 +2738,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)))) { @@ -2472,10 +2772,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)", @@ -2948,9 +3248,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 1f6ac69d1..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, @@ -3181,7 +3199,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, @@ -3189,14 +3220,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, @@ -3210,6 +3251,16 @@ app.whenReady().then(async () => { computerUseArtifactBrokerService, orchestratorService, aiOrchestratorService, + missionBudgetService, + syncHostService: syncService.getHostService(), + syncService, + automationIngressService, + contextDocService, + feedbackReporterService, + usageTrackingService, + budgetCapService, + sessionDeltaService, + autoUpdateService, issueInventoryService, eventBuffer: rpcEventBuffer, dispose: () => {}, // desktop manages service lifecycle @@ -3365,6 +3416,12 @@ app.whenReady().then(async () => { automationService, automationPlannerService, githubService, + keybindingsService, + onboardingService, + feedbackReporterService, + usageTrackingService, + budgetCapService, + autoUpdateService, } as unknown as AdeRuntime; } @@ -4131,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; @@ -4378,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 c1004c892..6fa970d1e 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -10,36 +10,94 @@ import type { } from "../../../shared/types/automations"; import type { AutomationRule } from "../../../shared/types/config"; -export type AdeActionDomain = - | "lane" - | "git" - | "diff" - | "conflicts" - | "pr" - | "tests" - | "chat" - | "mission" - | "orchestrator" - | "orchestrator_core" - | "memory" - | "cto_state" - | "worker_agent" - | "session" - | "operation" - | "project_config" - | "issue_inventory" - | "flow_policy" - | "linear_dispatcher" - | "linear_issue_tracker" - | "linear_sync" - | "linear_ingress" - | "linear_routing" - | "file" - | "process" - | "pty" - | "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"; + +/** + * 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: ["forceRefresh", "poll", "start", "stop"], +}; + +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: [ @@ -60,24 +118,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; + 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 }; + }, + }; +} + +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; + 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 }; + }, + }; +} + +type GraphStateService = { + get(): unknown; + set(args: { state?: unknown }): { projectId: string; state: unknown }; +}; + +function buildGraphStateDomainService(runtime: AdeRuntime): GraphStateService | null { + if (!runtime.db) return null; + return { + // 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 = 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 }; + }, + }; +} + export function getAdeActionDomainServices( runtime: AdeRuntime, ): Partial> { @@ -295,6 +565,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 +579,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/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 e8317b3e6..1bbb3f134 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -151,6 +151,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"; @@ -9786,10 +9787,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"]; @@ -10914,6 +10912,15 @@ export function createAgentChatService(args: { } const laneDirectiveKey = executionContext.laneDirectiveKey; const shouldInjectLaneDirective = laneDirectiveKey != null && managed.lastLaneDirectiveKey !== laneDirectiveKey; + // 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, [ @@ -10925,6 +10932,7 @@ export function createAgentChatService(args: { : null, buildExecutionModeDirective(executionMode, managed.session.provider), buildClaudeInteractionModeDirective(managed.session.interactionMode, managed.session.provider), + shouldInjectGuidance ? 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 12c714205..b67fce0a8 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"; @@ -573,7 +574,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", @@ -776,7 +777,8 @@ export function createBaseOrchestratorAdapter(config: BaseAdapterConfig): Orches : null; if (startupCommandOverride) { - const launch = normalizeAdapterLaunch(buildOverrideCommand({ prompt: startupCommandOverride })); + const overridePrompt = [ADE_CLI_AGENT_GUIDANCE, startupCommandOverride].join("\n\n"); + const launch = normalizeAdapterLaunch(buildOverrideCommand({ prompt: overridePrompt })); // Use the startup command directly as the prompt const session = await args.createTrackedSession({ laneId: step.laneId, @@ -793,8 +795,8 @@ export function createBaseOrchestratorAdapter(config: BaseAdapterConfig): Orches metadata: { adapterKind: executorKind, startupCommandOverride: true, - promptLength: startupCommandOverride.length, - startupCommandPreview: launch.startupCommand.slice(0, 320) + promptLength: overridePrompt.length, + startupCommandPreview: overridePrompt.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..4eb244dac 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts @@ -2075,10 +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 - -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. - ## 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 a347c8a0a..5917636e7 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -4005,6 +4005,10 @@ export function createOrchestratorService({ commandPreviewParts.push("--permission-mode", shellEscapeArg(claudePermissionMode)); } } + // 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, diff --git a/apps/desktop/src/main/services/orchestrator/promptInspector.ts b/apps/desktop/src/main/services/orchestrator/promptInspector.ts index dc080d901..8145fe9c7 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,6 @@ 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.", "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 +613,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/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 22841aa4e..452258ac5 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -230,7 +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; - return preferredIntegrationLaneId === integrationLaneId ? "adopted" : "ade-created"; + // 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: { @@ -502,8 +509,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 +523,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 +710,78 @@ 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, + 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`, + [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 { + 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 => + getRowById(prIdOrLocator) ?? getRowByLocator(prIdOrLocator); + const requireRow = (prId: string): PullRequestRow => { const row = getRow(prId); if (!row) throw new Error(`PR not found: ${prId}`); @@ -1101,14 +1179,34 @@ export function createPrService({ })); }; - const upsertRow = (summary: Omit & { projectId?: string }): void => { + const upsertRow = ( + summary: Omit & { projectId?: string }, + options?: { allowRepoPrAdoption?: boolean }, + ): string => { const now = nowIso(); - const existing = getRowForLane(summary.laneId); + // 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]); + db.run(`update integration_proposals set linked_pr_id = null where linked_pr_id = ?`, [existing.id]); + } db.run( ` update pull_requests - set repo_owner = ?, + set lane_id = ?, + repo_owner = ?, repo_name = ?, github_pr_number = ?, github_url = ?, @@ -1127,6 +1225,7 @@ export function createPrService({ where id = ? and project_id = ? `, [ + summary.laneId, summary.repoOwner, summary.repoName, summary.githubPrNumber, @@ -1147,7 +1246,7 @@ export function createPrService({ projectId, ] ); - return; + return existing.id; } db.run( @@ -1198,6 +1297,7 @@ export function createPrService({ summary.creationStrategy ?? null ] ); + return summary.id; }; const assertDirtyWorktreesAllowed = (args: { @@ -1265,6 +1365,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"; + }); + 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`, @@ -2093,11 +2216,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) { @@ -2116,7 +2243,7 @@ export function createPrService({ laneId, laneName: lane.name, branchRef: lane.branchRef, - baseRef: lane.baseRef, + baseRef: baseRefForDiff, parentLaneId: lane.parentLaneId, commits, packBody, @@ -2248,9 +2375,29 @@ 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 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, + 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 +2451,13 @@ export function createPrService({ creationStrategy: strategy }; - 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(summary.id); + return await refreshOne(prId); }; const linkToLane = async (args: LinkPrToLaneArgs): Promise => { @@ -2327,18 +2478,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 +2505,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 +4009,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 +4054,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 +4896,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/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/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index ade546d4b..791931dab 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 < 200; i++) { - 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 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(); + + // initRendererChain is fire-and-forget with a dynamic import inside; real + // timers + waitFor let the microtask chain settle reliably across shards. + await waitFor( + () => { + const runtime = getTerminalRuntimeSnapshot("session-dom"); + expect(runtime?.renderer).toBe("dom"); + expect(runtime?.health.rendererFallbacks).toBeGreaterThan(previousFallbacks); + }, + { timeout: 10_000 }, + ); - const runtime = getTerminalRuntimeSnapshot("session-dom"); - expect(runtime?.renderer).toBe("dom"); - expect(runtime?.health.rendererFallbacks).toBeGreaterThan(previousFallbacks); + cleanup(); + } finally { + vi.useFakeTimers(); + } }); it("applies updated terminal preferences to an existing runtime", async () => { 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 ...`."; 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 = {