diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index f395ec2b8..88c9f4910 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1906,13 +1906,14 @@ describe("adeRpcServer", () => { // 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).toContain("only normal reason to skip ADE CLI"); + expect(finalArg).toContain("ADE proof drawer"); expect(finalArg).toContain("clean up old, stale, or finished processes"); 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.startupCommand).toContain("only normal reason to skip ADE CLI"); expect(response.structuredContent.permissionMode).toBe("default"); expect(response.structuredContent.contextRef?.path).toBeNull(); }); @@ -2874,7 +2875,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"); + expect(response.structuredContent.startupCommand).toContain("only normal reason to skip ADE CLI"); const contextPath = response.structuredContent.contextRef?.path as string | null; expect(contextPath).toBeTruthy(); expect(contextPath?.includes("/.ade/cache/orchestrator/agent-context/run-123/")).toBe(true); diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 7e3094588..47e7ef96c 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -59,7 +59,6 @@ import { createMissionBudgetService } from "../../desktop/src/main/services/orch 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"; @@ -170,7 +169,6 @@ export type AdeRuntime = { syncHostService?: ReturnType | null; syncService?: ReturnType | null; automationIngressService?: ReturnType | null; - contextDocService?: ReturnType | null; feedbackReporterService?: ReturnType | null; usageTrackingService?: ReturnType | null; budgetCapService?: ReturnType | null; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 0f7e4da77..8a53a1ab4 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -20,7 +20,6 @@ import { createPortAllocationService } from "./services/lanes/portAllocationServ import { createLaneProxyService } from "./services/lanes/laneProxyService"; import { createOAuthRedirectService } from "./services/lanes/oauthRedirectService"; import { createRuntimeDiagnosticsService } from "./services/lanes/runtimeDiagnosticsService"; -import { createContextDocService } from "./services/context/contextDocService"; import { createSessionService } from "./services/sessions/sessionService"; import { createSessionDeltaService } from "./services/sessions/sessionDeltaService"; import { createPtyService } from "./services/pty/ptyService"; @@ -2115,19 +2114,6 @@ app.whenReady().then(async () => { proceduralLearningService, }); skillRegistryServiceRef = skillRegistryService; - const contextDocService = createContextDocService({ - db, - logger, - projectRoot, - projectId, - packsDir: adePaths.packsDir, - laneService, - projectConfigService, - aiIntegrationService, - onStatusChanged: (status) => - emitProjectEvent(projectRoot, IPC.contextStatusChanged, status), - }); - const ctoStateService = createCtoStateService({ db, projectId, @@ -2279,7 +2265,6 @@ app.whenReady().then(async () => { getAutomationService: () => automationService, getGitService: () => gitServiceRef, conflictService, - contextDocService, getWorkerBudgetService: () => workerBudgetService, getMissionBudgetService: () => missionBudgetServiceRef, episodicSummaryService, @@ -3346,7 +3331,6 @@ app.whenReady().then(async () => { syncHostService: syncService.getHostService(), syncService, automationIngressService, - contextDocService, feedbackReporterService, usageTrackingService, budgetCapService, @@ -3574,7 +3558,6 @@ app.whenReady().then(async () => { missionBudgetService, aiOrchestratorService, agentChatService, - contextDocService, projectConfigService, processService, sessionDeltaService, @@ -3682,7 +3665,6 @@ app.whenReady().then(async () => { orchestratorService: null, missionBudgetService: null, aiOrchestratorService: null, - contextDocService: null, projectConfigService: null, processService: null, sessionDeltaService: null, diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 23432f04d..f8b41f907 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -44,7 +44,7 @@ import type { createIssueInventoryService } from "../../prs/issueInventoryServic import { computeConvergenceStatus, detectSource, extractSeverity } from "../../prs/issueInventoryService"; import { launchPrIssueResolutionChat } from "../../prs/prIssueResolver"; import type { createPrService } from "../../prs/prService"; -import { isNoisyIssueComment, mapPermissionMode } from "../../prs/resolverUtils"; +import { isNoisyIssueComment } from "../../prs/resolverUtils"; import type { createProcessService } from "../../processes/processService"; import type { createSessionService } from "../../sessions/sessionService"; import type { createCtoStateService } from "../../cto/ctoStateService"; @@ -115,10 +115,6 @@ export interface CtoOperatorToolDeps { applyProposal: (args: any) => Promise; undoProposal: (args: any) => Promise; } | null; - contextDocService?: { - getStatus: () => any; - generateDocs: (args: any) => Promise; - } | null; steerChat?: (args: { sessionId: string; instruction: string }) => Promise<{ steerId: string; queued: boolean }>; cancelSteer?: (args: { sessionId: string }) => Promise; handoffChat?: (args: { sessionId: string; targetIdentityKey?: string; reason?: string }) => Promise; @@ -2779,39 +2775,6 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record conflictGuard(() => deps.conflictService!.undoProposal({ laneId, proposalId })), }); - // --------------------------------------------------------------------------- - // Context Pack Export - // --------------------------------------------------------------------------- - - tools.getContextStatus = tool({ - description: "Check what ADE context docs exist and whether they are stale.", - inputSchema: z.object({}), - execute: async () => { - if (!deps.contextDocService) return { success: false, error: "Context doc service is not available." }; - try { - return { success: true, ...deps.contextDocService.getStatus() }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - - tools.generateContextDocs = tool({ - description: "Generate bounded context packs for bootstrapping workers or exporting project state.", - inputSchema: z.object({ - scope: z.string().optional(), - categories: z.array(z.string()).optional(), - }), - execute: async ({ scope, categories }) => { - if (!deps.contextDocService) return { success: false, error: "Context doc service is not available." }; - try { - return { success: true, ...(await deps.contextDocService.generateDocs({ scope, categories })) }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; - } - }, - }); - // --------------------------------------------------------------------------- // Agent Chat Steering // --------------------------------------------------------------------------- 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 c55708ebd..bcb794c8c 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -230,7 +230,7 @@ describe("buildCodingAgentSystemPrompt", () => { 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("only normal reason to skip ADE CLI"); expect(result).toContain("## Editing Rules"); expect(result).toContain("## Verification Rules"); expect(result).toContain("## User-Facing Progress"); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 41fb806e9..94bda846c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -938,6 +938,7 @@ describe("buildComputerUseDirective", () => { const status = makeBackendStatus({ ghostOs: true }); const result = buildComputerUseDirective(status); expect(result).toContain("Proof Capture"); + expect(result).toContain("ade proof"); expect(result).toContain("ingest_computer_use_artifacts"); expect(result).toContain("capture visual proof first"); expect(result).toContain("Console logs and text files are supporting diagnostics only"); @@ -1052,9 +1053,10 @@ describe("createAgentChatService", () => { }); const opts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { systemPrompt?: { append?: string } } | undefined; - 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("default control plane"); + expect(opts?.systemPrompt?.append).toContain("only normal reason to skip ADE CLI"); expect(opts?.systemPrompt?.append).toContain("ade lanes list"); + expect(opts?.systemPrompt?.append).toContain("ADE proof drawer"); expect(opts?.systemPrompt?.append).toContain("clean up old, stale, or finished processes"); }); @@ -1665,10 +1667,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("only normal reason to skip ADE CLI"); expect(firstUserContent).toContain("ade actions list --text"); expect(secondUserContent).not.toContain("[ADE launch directive]"); - expect(secondUserContent).toContain("Before saying an ADE task is blocked"); + expect(secondUserContent).toContain("only normal reason to skip ADE CLI"); }); it("starts Codex sessions without ADE-owned tool server injection", async () => { @@ -1697,7 +1699,7 @@ describe("createAgentChatService", () => { 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("only normal reason to skip ADE CLI"); expect(textInput).toContain("ade actions list --text"); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ad2a7b7b7..8272d7e34 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -1774,6 +1774,7 @@ export function buildComputerUseDirective( "ADE will automatically capture screenshots and other visual artifacts from your computer-use tool calls into the proof drawer — you do not need to manually call ingest_computer_use_artifacts for normal captures.", "", "Call `get_computer_use_backend_status` to check available backends before attempting computer use.", + "When the user asks you to send proof, register the resulting artifact with ADE via `ade proof ...` or `ingest_computer_use_artifacts` so it appears in the active proof drawer.", ].join("\n"), ); @@ -2679,7 +2680,6 @@ export function createAgentChatService(args: { getAutomationService?: () => { list: () => any[]; triggerManually: (args: any) => Promise; listRuns: (args?: any) => any[] } | null; getGitService?: () => CtoOperatorToolDeps["gitService"]; conflictService?: CtoOperatorToolDeps["conflictService"]; - contextDocService?: CtoOperatorToolDeps["contextDocService"]; getWorkerBudgetService?: () => CtoOperatorToolDeps["workerBudgetService"]; getMissionBudgetService?: () => CtoOperatorToolDeps["missionBudgetService"]; computerUseArtifactBrokerService?: ComputerUseArtifactBrokerService | null; @@ -2719,7 +2719,6 @@ export function createAgentChatService(args: { getAutomationService, getGitService, conflictService, - contextDocService, getWorkerBudgetService, getMissionBudgetService, computerUseArtifactBrokerService, @@ -3691,7 +3690,6 @@ export function createAgentChatService(args: { automationService: getAutomationService?.() ?? null, gitService: getGitService?.() ?? null, conflictService: conflictService ?? null, - contextDocService: contextDocService ?? null, computerUseArtifactBrokerService: computerUseArtifactBrokerRef ?? null, workerBudgetService: getWorkerBudgetService?.() ?? null, missionBudgetService: getMissionBudgetService?.() ?? null, diff --git a/apps/desktop/src/main/services/context/contextDocBuilder.test.ts b/apps/desktop/src/main/services/context/contextDocBuilder.test.ts deleted file mode 100644 index f17b71267..000000000 --- a/apps/desktop/src/main/services/context/contextDocBuilder.test.ts +++ /dev/null @@ -1,561 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { openKvDb, type AdeDb } from "../state/kvDb"; -import { readContextStatus, runContextDocGeneration } from "./contextDocBuilder"; - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as const; -} - -function buildValidPrdDoc(summary = "ADE is a local-first desktop workspace for orchestrating coding agents."): string { - return [ - "# PRD.ade", - "", - "## What this is", - summary, - "", - "## Who it's for", - "- Solo AI-native developers coordinating multiple agents.", - "- Small teams working across parallel lanes and PRs.", - "", - "## Feature areas", - "- Lanes, missions, PR workflows, and proof capture.", - "- CTO and ADE CLI-backed operator flows.", - "", - "## Current state", - "- Desktop app is the main product surface.", - "- Context docs are bounded agent bootstrap cards.", - "", - "## Working norms", - "- Prefer service-first fixes over renderer-only workarounds.", - "- Keep IPC, preload, shared types, and renderer code aligned.", - "", - ].join("\n"); -} - -function buildValidPrdDocWithoutCanonicalTitle(summary = "ADE is a local-first desktop workspace for orchestrating coding agents."): string { - return [ - "## What this is", - summary, - "", - "## Who it's for", - "- Solo AI-native developers coordinating multiple agents.", - "- Small teams working across parallel lanes and PRs.", - "", - "## Feature areas", - "- Lanes, missions, PR workflows, and proof capture.", - "- CTO and ADE CLI-backed operator flows.", - "", - "## Current state", - "- Desktop app is the main product surface.", - "- Context docs are bounded agent bootstrap cards.", - "", - "## Working norms", - "- Prefer service-first fixes over renderer-only workarounds.", - "- Keep IPC, preload, shared types, and renderer code aligned.", - "", - ].join("\n"); -} - -function buildValidArchitectureDocWithoutCanonicalTitle( - summary = "ADE uses a trusted Electron main process, typed preload bridge, and untrusted renderer.", -): string { - return [ - "## System shape", - summary, - "", - "## Core services", - "- Main-process services own git, files, processes, missions, and context generation.", - "- The ADE CLI reuses shared ADE services.", - "", - "## Data and state", - "- Project state lives under `.ade/`.", - "- Runtime metadata primarily lives in `.ade/ade.db`.", - "", - "## Integration points", - "- Renderer talks to trusted services over typed IPC.", - "- External AI, GitHub, and Linear connect through service adapters.", - "", - "## Key patterns", - "- Enforce trust boundaries in code paths.", - "- Keep generated context distinct from canonical docs.", - "", - ].join("\n"); -} - -function buildValidArchitectureDoc(summary = "ADE uses a trusted Electron main process, typed preload bridge, and untrusted renderer."): string { - return [ - "# ARCHITECTURE.ade", - "", - "## System shape", - summary, - "", - "## Core services", - "- Main-process services own git, files, processes, missions, and context generation.", - "- The ADE CLI reuses shared ADE services.", - "", - "## Data and state", - "- Project state lives under `.ade/`.", - "- Runtime metadata primarily lives in `.ade/ade.db`.", - "", - "## Integration points", - "- Renderer talks to trusted services over typed IPC.", - "- External AI, GitHub, and Linear connect through service adapters.", - "", - "## Key patterns", - "- Enforce trust boundaries in code paths.", - "- Keep generated context distinct from canonical docs.", - "", - ].join("\n"); -} - -function createAiIntegrationService(text: string) { - return { - getMode: () => "subscription" as const, - generateInitialContext: vi.fn(async () => ({ - text, - structuredOutput: null, - provider: "claude", - model: "anthropic/claude-sonnet-4-6", - sessionId: null, - inputTokens: null, - outputTokens: null, - durationMs: 25, - })), - }; -} - -async function createFixture(): Promise<{ - projectRoot: string; - packsDir: string; - db: AdeDb; -}> { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-context-doc-builder-")); - const packsDir = path.join(projectRoot, ".ade", "packs"); - fs.mkdirSync(packsDir, { recursive: true }); - fs.mkdirSync(path.join(projectRoot, "docs", "features"), { recursive: true }); - fs.mkdirSync(path.join(projectRoot, "docs", "architecture"), { recursive: true }); - fs.writeFileSync(path.join(projectRoot, "README.md"), "# ADE\n\nLocal-first workspace for coding agents.\n", "utf8"); - fs.writeFileSync(path.join(projectRoot, "AGENTS.md"), [ - "# ADE Project Instructions", - "", - "## Working norms", - "- Preserve existing desktop app patterns before introducing new abstractions.", - "- Prefer fixing the underlying service or shared type rather than layering renderer-only workarounds on top.", - "- Keep IPC contracts, preload types, shared types, and renderer usage in sync whenever an interface changes.", - "", - ].join("\n"), "utf8"); - fs.writeFileSync(path.join(projectRoot, "docs", "PRD.md"), [ - "# ADE product requirements document", - "", - "ADE is a local-first desktop control plane for coding agents.", - "", - "## Product surfaces", - "- Lanes", - "- Missions", - "- PRs", - "", - ].join("\n"), "utf8"); - fs.writeFileSync(path.join(projectRoot, "docs", "features", "MISSIONS.md"), [ - "# Missions", - "", - "Missions plan and supervise multi-step work across agents.", - "", - ].join("\n"), "utf8"); - fs.writeFileSync(path.join(projectRoot, "docs", "architecture", "SYSTEM_OVERVIEW.md"), [ - "# ADE system overview", - "", - "ADE uses a trusted Electron main process with a typed preload bridge.", - "", - "## Trust boundaries", - "- Renderer remains untrusted.", - "", - ].join("\n"), "utf8"); - fs.writeFileSync(path.join(projectRoot, "docs", "architecture", "CONTEXT_CONTRACT.md"), [ - "# Context documentation contract", - "", - "Generated docs must stay distinct and compact.", - "", - ].join("\n"), "utf8"); - const db = await openKvDb(path.join(projectRoot, ".ade", "ade.db"), createLogger() as any); - return { projectRoot, packsDir, db }; -} - -describe("contextDocBuilder", () => { - const cleanupRoots: string[] = []; - const cleanupDbs: AdeDb[] = []; - - afterEach(() => { - while (cleanupDbs.length > 0) { - cleanupDbs.pop()?.close(); - } - while (cleanupRoots.length > 0) { - fs.rmSync(cleanupRoots.pop()!, { recursive: true, force: true }); - } - }); - - it("accepts narrated JSON output and writes ready docs", async () => { - const fixture = await createFixture(); - cleanupRoots.push(fixture.projectRoot); - cleanupDbs.push(fixture.db); - - const ai = createAiIntegrationService([ - "Here are the updated cards:", - "```json", - JSON.stringify({ - prd: buildValidPrdDoc(), - architecture: buildValidArchitectureDoc(), - }), - "```", - ].join("\n")); - - const result = await runContextDocGeneration({ - db: fixture.db, - logger: createLogger() as any, - projectRoot: fixture.projectRoot, - projectId: "project-1", - packsDir: fixture.packsDir, - laneService: {} as any, - projectConfigService: {} as any, - aiIntegrationService: ai as any, - }, { - provider: "opencode", - }); - - expect(result.degraded).toBe(false); - expect(result.docResults).toEqual([ - expect.objectContaining({ id: "prd_ade", health: "ready", source: "ai" }), - expect.objectContaining({ id: "architecture_ade", health: "ready", source: "ai" }), - ]); - - const prdBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "PRD.ade.md"), "utf8"); - const archBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "ARCHITECTURE.ade.md"), "utf8"); - expect(prdBody).toContain("## What this is"); - expect(archBody).toContain("## System shape"); - - const status = readContextStatus({ - db: fixture.db, - projectId: "project-1", - projectRoot: fixture.projectRoot, - packsDir: fixture.packsDir, - }); - expect(status.docs.map((doc) => ({ id: doc.id, health: doc.health, source: doc.source }))).toEqual([ - { id: "prd_ade", health: "ready", source: "ai" }, - { id: "architecture_ade", health: "ready", source: "ai" }, - ]); - }); - - it("canonicalizes model output that omits the leading # title and still writes ready ai docs", async () => { - const fixture = await createFixture(); - cleanupRoots.push(fixture.projectRoot); - cleanupDbs.push(fixture.db); - - const ai = createAiIntegrationService(JSON.stringify({ - prd: buildValidPrdDocWithoutCanonicalTitle(), - architecture: buildValidArchitectureDocWithoutCanonicalTitle(), - })); - - const result = await runContextDocGeneration({ - db: fixture.db, - logger: createLogger() as any, - projectRoot: fixture.projectRoot, - projectId: "project-1", - packsDir: fixture.packsDir, - laneService: {} as any, - projectConfigService: {} as any, - aiIntegrationService: ai as any, - }, { - provider: "opencode", - }); - - expect(result.degraded).toBe(false); - expect(result.warnings.filter((w) => w.code === "generator_invalid_prd" || w.code === "generator_invalid_architecture")).toEqual([]); - expect(result.docResults).toEqual([ - expect.objectContaining({ id: "prd_ade", health: "ready", source: "ai" }), - expect.objectContaining({ id: "architecture_ade", health: "ready", source: "ai" }), - ]); - - const prdBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "PRD.ade.md"), "utf8"); - const archBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "ARCHITECTURE.ade.md"), "utf8"); - expect(prdBody.startsWith("# PRD.ade\n")).toBe(true); - expect(archBody.startsWith("# ARCHITECTURE.ade\n")).toBe(true); - }); - - it("preserves previous good docs when replacement output is invalid", async () => { - const fixture = await createFixture(); - cleanupRoots.push(fixture.projectRoot); - cleanupDbs.push(fixture.db); - - const firstResult = await runContextDocGeneration({ - db: fixture.db, - logger: createLogger() as any, - projectRoot: fixture.projectRoot, - projectId: "project-1", - packsDir: fixture.packsDir, - laneService: {} as any, - projectConfigService: {} as any, - aiIntegrationService: createAiIntegrationService(JSON.stringify({ - prd: buildValidPrdDoc("ADE gives operators a local-first control plane."), - architecture: buildValidArchitectureDoc("ADE routes all repo mutation through trusted main-process services."), - })) as any, - }, { - provider: "opencode", - }); - - expect(firstResult.degraded).toBe(false); - const firstPrd = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "PRD.ade.md"), "utf8"); - const firstArch = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "ARCHITECTURE.ade.md"), "utf8"); - - const degradedResult = await runContextDocGeneration({ - db: fixture.db, - logger: createLogger() as any, - projectRoot: fixture.projectRoot, - projectId: "project-1", - packsDir: fixture.packsDir, - laneService: {} as any, - projectConfigService: {} as any, - aiIntegrationService: createAiIntegrationService("not valid json") as any, - }, { - provider: "opencode", - }); - - expect(degradedResult.degraded).toBe(true); - expect(degradedResult.docResults).toEqual([ - expect.objectContaining({ id: "prd_ade", health: "ready", source: "previous_good" }), - expect.objectContaining({ id: "architecture_ade", health: "ready", source: "previous_good" }), - ]); - expect(degradedResult.warnings.some((warning) => warning.code === "generator_invalid_prd")).toBe(true); - expect(degradedResult.warnings.some((warning) => warning.code === "generator_invalid_architecture")).toBe(true); - - expect(fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "PRD.ade.md"), "utf8")).toBe(firstPrd); - expect(fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "ARCHITECTURE.ade.md"), "utf8")).toBe(firstArch); - }); - - it("compacts oversized docs instead of falling back", async () => { - const fixture = await createFixture(); - cleanupRoots.push(fixture.projectRoot); - cleanupDbs.push(fixture.db); - - const oversizedArchitecture = [ - buildValidArchitectureDoc("ADE routes trusted repo mutation through Electron main-process services."), - "", - "## Extra detail", - "architecture ".repeat(1200), - ].join("\n"); - - const result = await runContextDocGeneration({ - db: fixture.db, - logger: createLogger() as any, - projectRoot: fixture.projectRoot, - projectId: "project-1", - packsDir: fixture.packsDir, - laneService: {} as any, - projectConfigService: {} as any, - aiIntegrationService: createAiIntegrationService(JSON.stringify({ - prd: buildValidPrdDoc("ADE gives operators a durable control plane for coding agents."), - architecture: oversizedArchitecture, - })) as any, - }, { - provider: "opencode", - }); - - expect(result.degraded).toBe(false); - expect(result.docResults).toEqual([ - expect.objectContaining({ id: "prd_ade", health: "ready", source: "ai" }), - expect.objectContaining({ id: "architecture_ade", health: "ready", source: "ai" }), - ]); - - const prdBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "PRD.ade.md"), "utf8"); - const archBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "ARCHITECTURE.ade.md"), "utf8"); - expect(prdBody).toContain("durable control plane for coding agents"); - expect(archBody).toContain("trusted repo mutation through Electron main-process services"); - expect(archBody.length).toBeLessThanOrEqual(8_000); - expect(result.warnings.some((warning) => warning.code === "generator_invalid_architecture")).toBe(false); - }); - - it("keeps a valid doc when only its sibling is structurally invalid", async () => { - const fixture = await createFixture(); - cleanupRoots.push(fixture.projectRoot); - cleanupDbs.push(fixture.db); - - const invalidArchitecture = [ - "# ARCHITECTURE.ade", - "", - "## System shape", - "ADE routes trusted repo mutation through Electron main-process services.", - "", - "## Core services", - "- Main process services own git, files, and process execution.", - "", - "## Data and state", - "- Project state lives under `.ade/`.", - "", - "## Integration points", - "- Renderer talks to trusted services over typed IPC.", - "", - ].join("\n"); - - const result = await runContextDocGeneration({ - db: fixture.db, - logger: createLogger() as any, - projectRoot: fixture.projectRoot, - projectId: "project-1", - packsDir: fixture.packsDir, - laneService: {} as any, - projectConfigService: {} as any, - aiIntegrationService: createAiIntegrationService(JSON.stringify({ - prd: buildValidPrdDoc("ADE gives operators a durable control plane for coding agents."), - architecture: invalidArchitecture, - })) as any, - }, { - provider: "opencode", - }); - - expect(result.degraded).toBe(true); - expect(result.docResults).toEqual([ - expect.objectContaining({ id: "prd_ade", health: "ready", source: "ai" }), - expect.objectContaining({ id: "architecture_ade", health: "fallback", source: "deterministic" }), - ]); - expect(result.warnings.some((warning) => warning.code === "generator_invalid_architecture")).toBe(true); - expect(result.warnings.some((warning) => warning.code === "generator_fallback_architecture")).toBe(true); - expect(result.warnings.some((warning) => warning.code === "generator_fallback_prd")).toBe(false); - - const prdBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "PRD.ade.md"), "utf8"); - const archBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "ARCHITECTURE.ade.md"), "utf8"); - expect(prdBody).toContain("durable control plane for coding agents"); - expect(archBody).toContain("Auto-generated from curated docs and code digests."); - }); - - it("records an explicit warning when the model returns narration instead of JSON", async () => { - const fixture = await createFixture(); - cleanupRoots.push(fixture.projectRoot); - cleanupDbs.push(fixture.db); - - const result = await runContextDocGeneration({ - db: fixture.db, - logger: createLogger() as any, - projectRoot: fixture.projectRoot, - projectId: "project-1", - packsDir: fixture.packsDir, - laneService: {} as any, - projectConfigService: {} as any, - aiIntegrationService: createAiIntegrationService("Reading the key source docs and recent code changes to produce accurate bootstrap cards.") as any, - }, { - provider: "opencode", - }); - - expect(result.degraded).toBe(true); - expect(result.warnings.some((warning) => warning.code === "generator_unstructured_output")).toBe(true); - expect(result.docResults).toEqual([ - expect.objectContaining({ id: "prd_ade", health: "fallback", source: "deterministic" }), - expect.objectContaining({ id: "architecture_ade", health: "fallback", source: "deterministic" }), - ]); - }); - - it("rejects overlapping docs and falls back to compact deterministic cards", async () => { - const fixture = await createFixture(); - cleanupRoots.push(fixture.projectRoot); - cleanupDbs.push(fixture.db); - - const sharedParagraph = "ADE coordinates lane mission operator branch worktree session review queue merge artifact transcript context bootstrap provider runtime preload renderer mainprocess sqlite storage secrets cache github linear automation routing policy validation proof retrieval sync checkpoint telemetry resilience isolation auditability discoverability onboarding settings workspace orchestration supervision conflictprediction reviewqueue providerselection contextdelivery intervention history exports ownership contracts services adapters."; - const duplicatedDoc = [ - "# PRD.ade", - "", - "## What this is", - sharedParagraph, - "", - "## Who it's for", - `- ${sharedParagraph}`, - "", - "## Feature areas", - `- ${sharedParagraph}`, - "", - "## Current state", - `- ${sharedParagraph}`, - "", - "## Working norms", - `- ${sharedParagraph}`, - "", - ].join("\n"); - const duplicatedArchitecture = duplicatedDoc - .replace("# PRD.ade", "# ARCHITECTURE.ade") - .replace("## What this is", "## System shape") - .replace("## Who it's for", "## Core services") - .replace("## Feature areas", "## Data and state") - .replace("## Current state", "## Integration points") - .replace("## Working norms", "## Key patterns"); - - const result = await runContextDocGeneration({ - db: fixture.db, - logger: createLogger() as any, - projectRoot: fixture.projectRoot, - projectId: "project-1", - packsDir: fixture.packsDir, - laneService: {} as any, - projectConfigService: {} as any, - aiIntegrationService: createAiIntegrationService(JSON.stringify({ - prd: duplicatedDoc, - architecture: duplicatedArchitecture, - })) as any, - }, { - provider: "opencode", - }); - - expect(result.degraded).toBe(true); - expect(result.warnings.some((warning) => warning.code === "generator_overlap_rejected")).toBe(true); - expect(result.docResults).toEqual([ - expect.objectContaining({ id: "prd_ade", health: "fallback", source: "deterministic" }), - expect.objectContaining({ id: "architecture_ade", health: "fallback", source: "deterministic" }), - ]); - - const prdBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "PRD.ade.md"), "utf8"); - const archBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "ARCHITECTURE.ade.md"), "utf8"); - expect(prdBody.length).toBeLessThanOrEqual(8_000); - expect(archBody.length).toBeLessThanOrEqual(8_000); - expect(prdBody).not.toContain("## Directory tree"); - expect(archBody).not.toContain("## Directory tree"); - }); - - it("marks generated docs as stale when canonical docs become newer", async () => { - const fixture = await createFixture(); - cleanupRoots.push(fixture.projectRoot); - cleanupDbs.push(fixture.db); - - await runContextDocGeneration({ - db: fixture.db, - logger: createLogger() as any, - projectRoot: fixture.projectRoot, - projectId: "project-1", - packsDir: fixture.packsDir, - laneService: {} as any, - projectConfigService: {} as any, - aiIntegrationService: createAiIntegrationService(JSON.stringify({ - prd: buildValidPrdDoc(), - architecture: buildValidArchitectureDoc(), - })) as any, - }, { - provider: "opencode", - }); - - const future = new Date(Date.now() + 60_000); - const canonicalPrd = path.join(fixture.projectRoot, "docs", "PRD.md"); - fs.utimesSync(canonicalPrd, future, future); - - const status = readContextStatus({ - db: fixture.db, - projectId: "project-1", - projectRoot: fixture.projectRoot, - packsDir: fixture.packsDir, - }); - - expect(status.docs.every((doc) => doc.health === "stale")).toBe(true); - expect(status.docs.every((doc) => doc.staleReason === "older_than_canonical_docs")).toBe(true); - }); -}); diff --git a/apps/desktop/src/main/services/context/contextDocBuilder.ts b/apps/desktop/src/main/services/context/contextDocBuilder.ts deleted file mode 100644 index 61eb0a3b2..000000000 --- a/apps/desktop/src/main/services/context/contextDocBuilder.ts +++ /dev/null @@ -1,1476 +0,0 @@ -/** - * Project pack builder — generates the project-level context pack and - * bootstrap scan. Also handles ADE context document generation and status reading. - */ - -import { createHash } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { runGit } from "../git/git"; -import type { Logger } from "../logging/logger"; -import type { AdeDb } from "../state/kvDb"; -import type { createLaneService } from "../lanes/laneService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createAiIntegrationService } from "../ai/aiIntegrationService"; -import { parseStructuredOutput } from "../ai/utils"; -import { readDocPaths } from "../orchestrator/stepPolicyResolver"; -import type { - ContextDocHealth, - ContextDocOutputSource, - ContextDocStatus, - ContextGenerateDocsArgs, - ContextGenerateDocsResult, - ContextStatus, - LaneSummary -} from "../../../shared/types"; -import { nowIso } from "../shared/utils"; -import { - ensureDirFor, - formatCommand, - isRecord, - asString, - readFileIfExists -} from "../shared/packLegacyUtils"; - -// ── Constants ──────────────────────────────────────────────────────────────── - -const CONTEXT_VERSION = 1; -const BOOTSTRAP_FINGERPRINT_RE = //i; -const ADE_DOC_PRD_REL = ".ade/context/PRD.ade.md"; -const ADE_DOC_ARCH_REL = ".ade/context/ARCHITECTURE.ade.md"; -const CONTEXT_DOC_LAST_RUN_KEY = "context:docs:lastRun.v1"; -const DOC_TEXT_EXT_RE = /\.(md|mdx|txt|rst)$/i; -const DOC_CONTEXT_EXT_RE = /\.(md|mdx|txt|rst|yaml|yml|json)$/i; -const DOC_PRD_HINT_RE = /(prd|product|roadmap|feature|requirement|spec|user-story|planning)/i; -const DOC_ARCH_HINT_RE = /(architecture|system|design|technical|infra|platform|lanes|conflict|pack)/i; -const DOC_GUIDE_HINT_RE = /(readme|guide|overview|context|contributing|claude|agents)/i; -const CONTEXT_DOC_MAX_CHARS = 8_000; - -type ContextDocId = ContextDocStatus["id"]; - -const CONTEXT_DOC_SPECS: Record = { - prd_ade: { - label: "PRD (ADE minimized)", - relPath: ADE_DOC_PRD_REL, - fallbackFileName: "PRD.ade.md", - title: "# PRD.ade", - requiredHeadings: [ - "## What this is", - "## Who it's for", - "## Feature areas", - "## Current state", - "## Working norms", - ], - }, - architecture_ade: { - label: "Architecture (ADE minimized)", - relPath: ADE_DOC_ARCH_REL, - fallbackFileName: "ARCHITECTURE.ade.md", - title: "# ARCHITECTURE.ade", - requiredHeadings: [ - "## System shape", - "## Core services", - "## Data and state", - "## Integration points", - "## Key patterns", - ], - }, -}; - -type ContextSourceDigest = { - relPath: string; - title: string; - blurb: string; - headings: string[]; -}; - -type HybridSourceBundle = { - productDigests: ContextSourceDigest[]; - technicalDigests: ContextSourceDigest[]; - codeAnchors: Array<{ relPath: string; excerpt: string }>; - gitHistory: string; - gitChanges: string; -}; - -type PersistedDocResult = ContextGenerateDocsResult["docResults"][number]; - -type PersistedContextDocRun = { - generatedAt?: string; - provider?: string; - trigger?: string; - modelId?: string | null; - reasoningEffort?: string | null; - prdPath?: string; - architecturePath?: string; - degraded?: boolean; - warnings?: Array<{ code?: string; message?: string; actionLabel?: string; actionPath?: string }>; - docResults?: Array<{ - id?: string; - health?: string; - source?: string; - sizeBytes?: number; - }>; -}; - -// ── Deps ───────────────────────────────────────────────────────────────────── - -export type ProjectPackBuilderDeps = { - db: AdeDb; - logger: Logger; - projectRoot: string; - projectId: string; - packsDir: string; - laneService: ReturnType; - projectConfigService: ReturnType; - aiIntegrationService?: ReturnType; -}; - -// ── Internal helpers ───────────────────────────────────────────────────────── - -const sha256 = (input: string): string => createHash("sha256").update(input).digest("hex"); - -const nowTimestampSegment = () => { - const iso = nowIso(); - return iso.replace(/[:]/g, "-").replace(/\..+$/, "Z"); -}; - -const safeReadDoc = (absPath: string, maxBytes: number): { text: string; truncated: boolean } => { - try { - const fd = fs.openSync(absPath, "r"); - try { - const buf = Buffer.alloc(maxBytes); - const bytesRead = fs.readSync(fd, buf, 0, maxBytes, 0); - const text = buf.slice(0, Math.max(0, bytesRead)).toString("utf8"); - const size = fs.statSync(absPath).size; - return { text, truncated: size > bytesRead }; - } finally { - fs.closeSync(fd); - } - } catch { - return { text: "", truncated: false }; - } -}; - -const clipText = (value: string, maxChars: number): string => { - const normalized = value.trim(); - if (normalized.length <= maxChars) return normalized; - return `${normalized.slice(0, Math.max(0, maxChars - 16)).trimEnd()}\n...(truncated)`; -}; - -function normalizeContextDocSource(value: unknown): ContextDocOutputSource { - const normalized = String(value ?? "").trim(); - if (normalized === "deterministic" || normalized === "previous_good") return normalized; - return "ai"; -} - -const VALID_CONTEXT_DOC_HEALTH = new Set(["missing", "incomplete", "fallback", "stale", "ready"]); - -function normalizeContextDocHealth(value: unknown): ContextDocHealth | null { - const normalized = String(value ?? "").trim(); - return VALID_CONTEXT_DOC_HEALTH.has(normalized as ContextDocHealth) ? normalized as ContextDocHealth : null; -} - -function stripMarkdown(text: string): string { - return text - .replace(/```[\s\S]*?```/g, " ") - .replace(/`[^`]*`/g, " ") - .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") - .replace(/\[[^\]]*\]\([^)]*\)/g, " ") - .replace(/^#{1,6}\s+/gm, " ") - .replace(/[*_>#-]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function computeDocOverlap(left: string, right: string): number { - const toTokens = (input: string) => - stripMarkdown(input) - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter((token) => token.length >= 4); - const leftSet = new Set(toTokens(left)); - const rightSet = new Set(toTokens(right)); - if (leftSet.size === 0 || rightSet.size === 0) return 0; - let intersection = 0; - for (const token of leftSet) { - if (rightSet.has(token)) intersection += 1; - } - const union = leftSet.size + rightSet.size - intersection; - return union > 0 ? intersection / union : 0; -} - -function extractMarkdownTitle(text: string, fallback: string): string { - const lines = text.split(/\r?\n/); - const heading = lines.find((line) => /^#\s+/.test(line.trim())); - return heading ? heading.trim().replace(/^#\s+/, "") : fallback; -} - -function extractParagraph(text: string, maxChars = 260): string { - const lines = text.split(/\r?\n/); - const parts: string[] = []; - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line) { - if (parts.length > 0) break; - continue; - } - if (line.startsWith("#")) continue; - if (line.startsWith(">")) continue; - if (line === "---") continue; - parts.push(line); - if (parts.join(" ").length >= maxChars) break; - } - return clipText(parts.join(" "), maxChars); -} - -function extractHeadings(text: string, maxHeadings = 5): string[] { - return text - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => /^##\s+/.test(line)) - .map((line) => line.replace(/^##\s+/, "")) - .slice(0, maxHeadings); -} - -function extractSectionBullets(text: string, heading: string, maxBullets = 5): string[] { - const lines = text.split(/\r?\n/); - const out: string[] = []; - let inside = false; - for (const rawLine of lines) { - const line = rawLine.trim(); - if (/^##\s+/.test(line)) { - if (inside) break; - inside = line.toLowerCase() === heading.toLowerCase(); - continue; - } - if (!inside) continue; - if (/^[-*]\s+/.test(line)) { - out.push(line.replace(/^[-*]\s+/, "").trim()); - if (out.length >= maxBullets) break; - } - } - return out; -} - -function collectSourceDigest(projectRoot: string, relPath: string): ContextSourceDigest | null { - const absPath = path.join(projectRoot, relPath); - const { text } = safeReadDoc(absPath, 24_000); - if (!text.trim()) return null; - return { - relPath, - title: extractMarkdownTitle(text, path.basename(relPath)), - blurb: extractParagraph(text), - headings: extractHeadings(text), - }; -} - -function formatSourceDigests(label: string, digests: ContextSourceDigest[], maxChars: number): string { - const lines: string[] = [`## ${label}`]; - for (const digest of digests) { - const entry = [ - `- ${digest.relPath}`, - ` title: ${digest.title}`, - digest.blurb ? ` summary: ${digest.blurb}` : "", - digest.headings.length > 0 ? ` sections: ${digest.headings.join(" | ")}` : "", - ].filter(Boolean).join("\n"); - const next = [...lines, entry].join("\n"); - if (next.length > maxChars) break; - lines.push(entry); - } - return lines.join("\n"); -} - -function readSectionExcerpt( - projectRoot: string, - relPath: string, - pattern: RegExp, - linesBefore = 2, - linesAfter = 26, -): { relPath: string; excerpt: string } | null { - const absPath = path.join(projectRoot, relPath); - const { text } = safeReadDoc(absPath, 18_000); - if (!text.trim()) return null; - const lines = text.split(/\r?\n/); - const matchIndex = lines.findIndex((line) => pattern.test(line)); - const start = Math.max(0, matchIndex >= 0 ? matchIndex - linesBefore : 0); - const end = Math.min(lines.length, matchIndex >= 0 ? matchIndex + linesAfter : Math.min(lines.length, 30)); - const excerpt = lines.slice(start, end).join("\n").trim(); - if (!excerpt) return null; - return { relPath, excerpt: clipText(excerpt, 1_600) }; -} - -async function collectGitHistory(projectRoot: string): Promise { - try { - const result = await runGit(["log", "--oneline", "-n", "8"], { - cwd: projectRoot, - timeoutMs: 8_000, - }); - if (result.exitCode === 0) return result.stdout.trim(); - } catch { - // ignore - } - return ""; -} - -async function collectGitChangesSince(projectRoot: string, lastDate: string | null): Promise { - if (!lastDate) return ""; - try { - const result = await runGit(["log", "--oneline", "--stat", `--since=${lastDate}`], { - cwd: projectRoot, - timeoutMs: 10_000, - }); - if (result.exitCode === 0) return result.stdout.trim(); - } catch { - // ignore - } - return ""; -} - -async function buildHybridSourceBundle(projectRoot: string, lastGeneratedAt: string | null): Promise { - const productDigests: ContextSourceDigest[] = []; - const technicalDigests: ContextSourceDigest[] = []; - const pushDigest = (target: ContextSourceDigest[], relPath: string) => { - const digest = collectSourceDigest(projectRoot, relPath); - if (digest) target.push(digest); - }; - - for (const relPath of ["README.md", "AGENTS.md", "docs/PRD.md"]) { - pushDigest(productDigests, relPath); - } - - const featuresDir = path.join(projectRoot, "docs", "features"); - if (fs.existsSync(featuresDir)) { - for (const entry of fs.readdirSync(featuresDir).sort()) { - if (!DOC_TEXT_EXT_RE.test(entry)) continue; - pushDigest(productDigests, path.join("docs", "features", entry).replace(/\\/g, "/")); - } - } - - const architectureDir = path.join(projectRoot, "docs", "architecture"); - if (fs.existsSync(architectureDir)) { - for (const entry of fs.readdirSync(architectureDir).sort()) { - if (!DOC_TEXT_EXT_RE.test(entry)) continue; - pushDigest(technicalDigests, path.join("docs", "architecture", entry).replace(/\\/g, "/")); - } - } - - const codeAnchors = [ - readSectionExcerpt(projectRoot, "apps/desktop/src/main/main.ts", /createContextDocService/), - readSectionExcerpt(projectRoot, "apps/desktop/src/main/services/ipc/registerIpc.ts", /IPC\.contextGetStatus/), - readSectionExcerpt(projectRoot, "apps/desktop/src/preload/preload.ts", /context:\s*\{/), - readSectionExcerpt(projectRoot, "apps/desktop/src/shared/types/packs.ts", /export type ContextDocStatus = \{/), - readSectionExcerpt(projectRoot, "apps/ade-cli/src/cli.ts", /^/), - ].filter((value): value is { relPath: string; excerpt: string } => value != null); - - return { - productDigests, - technicalDigests, - codeAnchors, - gitHistory: await collectGitHistory(projectRoot), - gitChanges: await collectGitChangesSince(projectRoot, lastGeneratedAt), - }; -} - -function formatCodeAnchors(anchors: HybridSourceBundle["codeAnchors"], maxChars: number): string { - const lines: string[] = ["## Code anchors"]; - for (const anchor of anchors) { - const entry = [`- ${anchor.relPath}`, "```ts", anchor.excerpt, "```"].join("\n"); - const next = [...lines, entry].join("\n"); - if (next.length > maxChars) break; - lines.push(entry); - } - return lines.join("\n"); -} - -function docSpecFor(id: ContextDocId) { - return CONTEXT_DOC_SPECS[id]; -} - -function inferContextDocSource(content: string, persistedSource: ContextDocOutputSource | null): ContextDocOutputSource { - if (persistedSource === "previous_good") return "previous_good"; - const looksDeterministic = /auto-generated from curated docs and code digests/i.test(content) - || /auto-generated from codebase snapshot/i.test(content); - return looksDeterministic ? "deterministic" : "ai"; -} - -/** Ensures generated markdown opens with the canonical doc title (models often skip the `# …` line). */ -function ensureCanonicalContextDocTitle(id: ContextDocId, content: string): string { - const spec = docSpecFor(id); - const trimmed = content.trim(); - if (!trimmed.length) return trimmed; - if (trimmed.startsWith(spec.title)) return trimmed; - return `${spec.title}\n\n${trimmed}`; -} - -function validateContextDoc(id: ContextDocId, content: string): { valid: boolean; reasons: string[] } { - const reasons: string[] = []; - const spec = docSpecFor(id); - const normalized = content.trim(); - if (!normalized) reasons.push("empty"); - if (normalized.length > CONTEXT_DOC_MAX_CHARS) reasons.push("too_long"); - const lowered = normalized.toLowerCase(); - const missingHeadings = spec.requiredHeadings.filter((heading) => !lowered.includes(heading.toLowerCase())); - if (missingHeadings.length > 0) reasons.push(`missing_headings:${missingHeadings.join("|")}`); - if (normalized.split(/\r?\n/).filter((line) => line.trim()).length < 10) reasons.push("too_short"); - return { valid: reasons.length === 0, reasons }; -} - -function compactGeneratedContextDoc(id: ContextDocId, content: string): string { - const normalized = ensureCanonicalContextDocTitle(id, content); - if (normalized.length <= CONTEXT_DOC_MAX_CHARS) return normalized; - - const spec = docSpecFor(id); - - const headingOffsets = spec.requiredHeadings.map((heading) => normalized.indexOf(heading)); - if (headingOffsets.some((offset) => offset < 0)) return normalized; - for (let index = 1; index < headingOffsets.length; index += 1) { - if (headingOffsets[index] <= headingOffsets[index - 1]) return normalized; - } - - const sectionBodies = spec.requiredHeadings.map((heading, index) => { - const bodyStart = headingOffsets[index] + heading.length; - const sectionEnd = index + 1 < headingOffsets.length - ? headingOffsets[index + 1] - : normalized.length; - return normalized.slice(bodyStart, sectionEnd).trim(); - }); - - const scaffoldLines = [ - spec.title, - "", - ...spec.requiredHeadings.flatMap((heading) => [heading, ""]), - ]; - const scaffoldLength = scaffoldLines.join("\n").length; - const reservedEllipsisBudget = spec.requiredHeadings.length * 20; - const bodyBudget = Math.max( - 120 * spec.requiredHeadings.length, - CONTEXT_DOC_MAX_CHARS - scaffoldLength - reservedEllipsisBudget, - ); - const perSectionBudget = Math.max(120, Math.floor(bodyBudget / spec.requiredHeadings.length)); - - const compactedLines: string[] = [spec.title, ""]; - for (let index = 0; index < spec.requiredHeadings.length; index += 1) { - compactedLines.push(spec.requiredHeadings[index]); - compactedLines.push(clipText(sectionBodies[index], perSectionBudget)); - compactedLines.push(""); - } - - return compactedLines.join("\n").trim(); -} - -function computeContextDocHealth(args: { - id: ContextDocId; - exists: boolean; - content: string; - staleReason: string | null; - source: ContextDocOutputSource; -}): ContextDocHealth { - if (!args.exists) return "missing"; - if (args.staleReason) return "stale"; - const validation = validateContextDoc(args.id, args.content); - if (!validation.valid) return "incomplete"; - if (args.source === "deterministic") return "fallback"; - return "ready"; -} - -function readPersistedDocResults(raw: PersistedContextDocRun | null | undefined): Partial> { - const out: Partial> = {}; - if (!Array.isArray(raw?.docResults)) return out; - for (const entry of raw.docResults) { - const id = entry?.id === "prd_ade" || entry?.id === "architecture_ade" ? entry.id : null; - if (!id) continue; - out[id] = { - id, - health: normalizeContextDocHealth(entry.health) ?? "incomplete", - source: normalizeContextDocSource(entry.source), - sizeBytes: Number.isFinite(Number(entry.sizeBytes)) ? Math.max(0, Math.floor(Number(entry.sizeBytes))) : 0, - }; - } - return out; -} - -function readContextDocFile(projectRoot: string, relPath: string): { - exists: boolean; - sizeBytes: number; - updatedAt: string | null; - fingerprint: string | null; - body: string; -} { - const absPath = path.join(projectRoot, relPath); - try { - const st = fs.statSync(absPath); - if (!st.isFile()) { - return { exists: false, sizeBytes: 0, updatedAt: null, fingerprint: null, body: "" }; - } - const body = fs.readFileSync(absPath, "utf8"); - return { - exists: true, - sizeBytes: st.size, - updatedAt: st.mtime.toISOString(), - fingerprint: sha256(body), - body, - }; - } catch { - return { exists: false, sizeBytes: 0, updatedAt: null, fingerprint: null, body: "" }; - } -} - -function buildDeterministicPrdDoc(args: { - productDigests: ContextSourceDigest[]; - featureDigests: ContextSourceDigest[]; - gitHistory: string; - workingNorms: string[]; -}): string { - const overview = args.productDigests.find((digest) => digest.relPath === "docs/PRD.md")?.blurb - || args.productDigests.find((digest) => digest.relPath === "README.md")?.blurb - || "ADE is a local-first desktop workspace for orchestrating coding agents, lanes, missions, PR workflows, and proof capture."; - const audience = "Developers and small teams coordinating multiple AI coding agents across parallel lanes and review workflows."; - const featureBullets = args.featureDigests.slice(0, 10).map((digest) => - `- ${digest.title}: ${digest.blurb || `See ${digest.relPath} for current behavior.`}` - ); - const currentState = args.gitHistory - ? `ADE is actively evolving. Recent work is concentrated on ${args.gitHistory.split(/\r?\n/).slice(0, 3).join("; ")}.` - : "ADE is actively evolving across desktop orchestration, iOS parity, and AI workflow hardening."; - const norms = args.workingNorms.length > 0 - ? args.workingNorms.slice(0, 5).map((line) => `- ${line}`) - : [ - "- Preserve existing desktop app patterns before introducing new abstractions.", - "- Keep IPC contracts, preload types, shared types, and renderer usage in sync.", - "- Validate the smallest relevant desktop/ADE CLI checks first, then broaden coverage.", - ]; - return clipText([ - CONTEXT_DOC_SPECS.prd_ade.title, - "", - "> Auto-generated from curated docs and code digests.", - "", - "## What this is", - overview, - "", - "## Who it's for", - audience, - "", - "## Feature areas", - ...featureBullets, - "", - "## Current state", - currentState, - "", - "## Working norms", - ...norms, - "", - ].join("\n"), CONTEXT_DOC_MAX_CHARS); -} - -function buildDeterministicArchitectureDoc(args: { - technicalDigests: ContextSourceDigest[]; - codeAnchors: Array<{ relPath: string; excerpt: string }>; - workingNorms: string[]; -}): string { - const overview = args.technicalDigests.find((digest) => digest.relPath === "docs/architecture/SYSTEM_OVERVIEW.md")?.blurb - || "ADE uses a trusted Electron main process, typed preload bridge, and untrusted renderer, with AI/runtime services operating through the main process."; - const serviceBullets = args.technicalDigests.slice(0, 8).map((digest) => - `- ${digest.title}: ${digest.blurb || `See ${digest.relPath}.`}` - ); - const dataBullets = [ - "- Project state lives under `.ade/`, with runtime metadata in `.ade/ade.db` and machine-local state in `.ade/secrets`, `.ade/cache`, and `.ade/artifacts`.", - "- Generated agent context lives in `.ade/context/PRD.ade.md` and `.ade/context/ARCHITECTURE.ade.md`.", - "- Shared types in `apps/desktop/src/shared` define IPC and renderer/main-process contracts.", - ]; - const integrationBullets = [ - "- Desktop UI talks to trusted services over typed IPC via the preload bridge.", - "- `apps/ade-cli` exposes ADE actions for terminal-capable agents in headless and desktop-backed modes.", - "- AI execution remains provider-flexible across CLI subscriptions, API/OpenRouter, and local endpoints.", - ]; - const patternBullets = (args.workingNorms.length > 0 ? args.workingNorms.slice(0, 4) : [ - "Renderer surfaces should not implement repo-mutation workarounds that belong in shared services.", - "Computer-use flows must enforce policy and artifact ownership in code paths, not prompts alone.", - ]).map((line) => `- ${line}`); - const anchorBullets = args.codeAnchors.slice(0, 4).map((anchor) => `- ${anchor.relPath}`); - return clipText([ - CONTEXT_DOC_SPECS.architecture_ade.title, - "", - "> Auto-generated from curated docs and code digests.", - "", - "## System shape", - overview, - "", - "## Core services", - ...serviceBullets, - "", - "## Data and state", - ...dataBullets, - "", - "## Integration points", - ...integrationBullets, - ...(anchorBullets.length > 0 ? ["- Key code anchors:", ...anchorBullets] : []), - "", - "## Key patterns", - ...patternBullets, - "", - ].join("\n"), CONTEXT_DOC_MAX_CHARS); -} - -function buildGenerationPrompt(args: { - bundle: HybridSourceBundle; - existingPrd: string; - existingArch: string; - lastGeneratedAt: string | null; -}): string { - const productSources = formatSourceDigests("Product sources", args.bundle.productDigests, 7_000); - const technicalSources = formatSourceDigests("Technical sources", args.bundle.technicalDigests, 7_000); - const codeAnchors = formatCodeAnchors(args.bundle.codeAnchors, 4_500); - const gitHistory = args.bundle.gitHistory ? `## Recent git history\n${args.bundle.gitHistory}` : "## Recent git history\nUnavailable"; - const gitChanges = args.bundle.gitChanges - ? `## Changes since last generation (${args.lastGeneratedAt ?? "unknown"})\n${clipText(args.bundle.gitChanges, 4_500)}` - : `## Changes since last generation (${args.lastGeneratedAt ?? "unknown"})\nUnavailable or unchanged`; - - const currentDocsSection = args.existingPrd.trim() && args.existingArch.trim() - ? [ - "## Current generated docs", - "", - clipText(args.existingPrd, 4_500), - "", - "", - clipText(args.existingArch, 4_500), - "", - ].join("\n") - : "## Current generated docs\nNone yet."; - - return [ - "You are producing two dense bootstrap cards that ADE agents read at session start.", - "", - "Ownership rules:", - "- `PRD.ade.md` owns product semantics: what ADE is, who it is for, feature areas, current shipped state, workflow expectations, and operator-facing norms.", - "- `ARCHITECTURE.ade.md` owns implementation shape: trust boundaries, process/service layout, data/state model, IPC boundaries, integration points, and extension patterns.", - "- Do not duplicate the same feature list or stack summary in both docs unless it is essential for orientation.", - "", - "Output rules:", - "- Return JSON only: {\"prd\":\"...\",\"architecture\":\"...\"}.", - "- The first character of your response must be `{` and the last character must be `}`.", - "- Do not include narration, thinking text, Markdown fences, or any text before/after the JSON object.", - `- Each doc must stay under ${CONTEXT_DOC_MAX_CHARS} characters.`, - "- Use these exact headings and no changelog language.", - "", - "PRD headings:", - ...CONTEXT_DOC_SPECS.prd_ade.requiredHeadings.map((heading) => `- ${heading}`), - "", - "Architecture headings:", - ...CONTEXT_DOC_SPECS.architecture_ade.requiredHeadings.map((heading) => `- ${heading}`), - "", - currentDocsSection, - "", - productSources, - "", - technicalSources, - "", - codeAnchors, - "", - gitHistory, - "", - gitChanges, - ].join("\n"); -} - -const writeDocWithFallback = (args: { - preferredAbsPath: string; - fallbackFileName: string; - content: string; - fallbackRoot: string; -}): { writtenPath: string; usedFallback: boolean; warning: string | null } => { - try { - ensureDirFor(args.preferredAbsPath); - fs.writeFileSync(args.preferredAbsPath, args.content, "utf8"); - return { writtenPath: args.preferredAbsPath, usedFallback: false, warning: null }; - } catch (error) { - const ts = nowTimestampSegment(); - const fallbackDir = path.join(args.fallbackRoot, ts); - fs.mkdirSync(fallbackDir, { recursive: true }); - const fallbackPath = path.join(fallbackDir, args.fallbackFileName); - fs.writeFileSync(fallbackPath, args.content, "utf8"); - const reason = error instanceof Error ? error.message : String(error); - return { - writtenPath: fallbackPath, - usedFallback: true, - warning: `write_failed_preferred_path:${args.preferredAbsPath}:${reason}` - }; - } -}; - -// ── Exported helpers used by packService.ts ────────────────────────────────── - -export function collectContextDocPaths(projectRoot: string): string[] { - const out = new Set([ADE_DOC_PRD_REL, ADE_DOC_ARCH_REL]); - for (const absPath of readDocPaths(projectRoot)) { - const rel = path.relative(projectRoot, absPath).replace(/\\/g, "/"); - if (!rel.length || rel.startsWith("..")) continue; - if (!DOC_CONTEXT_EXT_RE.test(rel)) continue; - out.add(rel); - } - return [...out] - .sort((a, b) => a.localeCompare(b)) - .sort((a, b) => { - const aAde = a.endsWith(".ade.md") ? 0 : 1; - const bAde = b.endsWith(".ade.md") ? 0 : 1; - return aAde - bAde; - }); -} - -function scoreDocPath(relPath: string): number { - const rel = relPath.replace(/\\/g, "/"); - const base = path.posix.basename(rel).toLowerCase(); - let score = 0; - if (rel.startsWith(".ade/context/")) score += 120; - if (base === "readme.md" || base === "readme.mdx") score += 80; - if (DOC_PRD_HINT_RE.test(rel)) score += 55; - if (DOC_ARCH_HINT_RE.test(rel)) score += 50; - if (DOC_GUIDE_HINT_RE.test(rel)) score += 25; - if (rel.toLowerCase().includes("/docs/")) score += 12; - score += Math.max(0, 35 - Math.floor(rel.length / 5)); - return score; -} - -function rankDocPathsByRelevance(paths: string[]): string[] { - return [...paths].sort((left, right) => { - const scoreDiff = scoreDocPath(right) - scoreDocPath(left); - if (scoreDiff !== 0) return scoreDiff; - return left.localeCompare(right); - }); -} - -export function readContextDocMeta(projectRoot: string): { - contextFingerprint: string; - contextVersion: number; - lastDocsRefreshAt: string | null; - docsStaleReason: string | null; -} { - const paths = collectContextDocPaths(projectRoot); - const entries: Array<{ path: string; size: number; mtimeMs: number }> = []; - for (const rel of paths) { - const abs = path.join(projectRoot, rel); - try { - const st = fs.statSync(abs); - if (!st.isFile()) continue; - entries.push({ path: rel, size: st.size, mtimeMs: st.mtimeMs }); - } catch { - // ignore missing files - } - } - - const contextFingerprint = sha256(JSON.stringify(entries)); - const latestMtime = entries.reduce((max, entry) => Math.max(max, entry.mtimeMs), 0); - return { - contextFingerprint, - contextVersion: CONTEXT_VERSION, - lastDocsRefreshAt: latestMtime > 0 ? new Date(latestMtime).toISOString() : null, - docsStaleReason: entries.length ? null : "docs_missing_or_unreadable" - }; -} - -export function readContextStatus(deps: { - db: AdeDb; - projectId: string; - projectRoot: string; - packsDir: string; -}): ContextStatus { - const FALLBACK_GENERATED_ROOT = path.join(path.dirname(deps.packsDir), "context", "generated"); - - const collectCanonicalContextDocPaths = (): string[] => - collectContextDocPaths(deps.projectRoot).filter((rel) => !rel.endsWith(".ade.md")); - - const readCanonicalDocMeta = (): { - scanned: number; - present: number; - fingerprint: string; - updatedAt: string | null; - } => { - const paths = collectCanonicalContextDocPaths(); - const present: Array<{ path: string; size: number; mtimeMs: number }> = []; - for (const rel of paths) { - try { - const st = fs.statSync(path.join(deps.projectRoot, rel)); - if (!st.isFile()) continue; - present.push({ path: rel, size: st.size, mtimeMs: st.mtimeMs }); - } catch { - // ignore - } - } - const latestMtime = present.reduce((max, entry) => Math.max(max, entry.mtimeMs), 0); - return { - scanned: paths.length, - present: present.length, - fingerprint: sha256(JSON.stringify(present)), - updatedAt: latestMtime > 0 ? new Date(latestMtime).toISOString() : null - }; - }; - - const countFallbackWrites = (): number => { - if (!fs.existsSync(FALLBACK_GENERATED_ROOT)) return 0; - const walk = (dir: string): number => { - let total = 0; - let entries: fs.Dirent[] = []; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return 0; - } - for (const entry of entries) { - const abs = path.join(dir, entry.name); - if (entry.isDirectory()) total += walk(abs); - if (entry.isFile() && entry.name.endsWith(".ade.md")) total += 1; - } - return total; - }; - return walk(FALLBACK_GENERATED_ROOT); - }; - - const canonical = readCanonicalDocMeta(); - const fallbackCount = countFallbackWrites(); - const latestRunRaw = deps.db.getJson(CONTEXT_DOC_LAST_RUN_KEY); - const persistedDocResults = readPersistedDocResults(latestRunRaw); - const latestWarnings = Array.isArray(latestRunRaw?.warnings) - ? latestRunRaw!.warnings!.map((warning) => ({ - code: String(warning?.code ?? "unknown"), - message: String(warning?.message ?? ""), - ...(warning?.actionLabel ? { actionLabel: String(warning.actionLabel) } : {}), - ...(warning?.actionPath ? { actionPath: String(warning.actionPath) } : {}) - })) - : []; - const readDocStatus = (id: ContextDocId): ContextDocStatus => { - const spec = docSpecFor(id); - const file = readContextDocFile(deps.projectRoot, spec.relPath); - const staleReason = (() => { - if (!file.exists) return "missing"; - if (!file.updatedAt || !canonical.updatedAt) return null; - const docTs = Date.parse(file.updatedAt); - const canonicalTs = Date.parse(canonical.updatedAt); - if (Number.isFinite(docTs) && Number.isFinite(canonicalTs) && docTs < canonicalTs) { - return "older_than_canonical_docs"; - } - return null; - })(); - const persisted = persistedDocResults[id]; - const source = inferContextDocSource(file.body, persisted?.source ?? null); - const health = computeContextDocHealth({ - id, - exists: file.exists, - content: file.body, - staleReason, - source, - }); - return { - id, - label: spec.label, - preferredPath: spec.relPath, - exists: file.exists, - sizeBytes: file.sizeBytes, - updatedAt: file.updatedAt, - fingerprint: file.fingerprint, - staleReason, - fallbackCount, - health, - source, - }; - }; - const docs = [ - readDocStatus("prd_ade"), - readDocStatus("architecture_ade"), - ]; - - const projectPackIndex = deps.db.get<{ metadata_json: string | null; deterministic_updated_at: string | null }>( - ` - select metadata_json, deterministic_updated_at - from packs_index - where project_id = ? - and pack_key = 'project' - limit 1 - `, - [deps.projectId] - ); - const projectPackMeta = (() => { - if (!projectPackIndex?.metadata_json) return {} as Record; - try { - const parsed = JSON.parse(projectPackIndex.metadata_json) as unknown; - return isRecord(parsed) ? parsed : {}; - } catch { - return {} as Record; - } - })(); - - const insufficientContextCount = Number( - deps.db.get<{ count: number }>( - ` - select count(1) as count - from conflict_proposals - where project_id = ? - and metadata_json like '%"insufficientContext":true%' - `, - [deps.projectId] - )?.count ?? 0 - ); - - return { - docs, - canonicalDocsPresent: canonical.present, - canonicalDocsScanned: canonical.scanned, - canonicalDocsFingerprint: canonical.fingerprint, - canonicalDocsUpdatedAt: canonical.updatedAt, - projectExportFingerprint: typeof projectPackMeta.contextFingerprint === "string" ? projectPackMeta.contextFingerprint : null, - projectExportUpdatedAt: projectPackIndex?.deterministic_updated_at ?? null, - contextManifestRefs: { - project: null, - packs: null, - transcripts: null - }, - fallbackWrites: fallbackCount, - insufficientContextCount, - warnings: latestWarnings, - generation: { - state: "idle", - requestedAt: null, - startedAt: null, - finishedAt: null, - error: null, - source: null, - event: null, - reason: null, - provider: null, - modelId: null, - reasoningEffort: null, - }, - }; -} - -export async function runContextDocGeneration( - deps: ProjectPackBuilderDeps, - args: ContextGenerateDocsArgs -): Promise { - const FALLBACK_GENERATED_ROOT = path.join(path.dirname(deps.packsDir), "context", "generated"); - const provider = args.provider ?? "opencode"; - const trigger = args.trigger ?? "manual"; - const modelId = typeof args.modelId === "string" && args.modelId.trim().length > 0 ? args.modelId.trim() : null; - const reasoningEffort = - typeof args.reasoningEffort === "string" && args.reasoningEffort.trim().length > 0 - ? args.reasoningEffort.trim() - : null; - const providerHint = provider === "codex" || provider === "claude" ? provider : undefined; - const warnings: ContextGenerateDocsResult["warnings"] = []; - const lastRunRaw = deps.db.getJson(CONTEXT_DOC_LAST_RUN_KEY); - const lastGeneratedAt = typeof lastRunRaw?.generatedAt === "string" ? lastRunRaw.generatedAt : null; - const persistedDocResults = readPersistedDocResults(lastRunRaw); - const generationStartedAt = nowIso(); - const existingPrdFile = readContextDocFile(deps.projectRoot, ADE_DOC_PRD_REL); - const existingArchFile = readContextDocFile(deps.projectRoot, ADE_DOC_ARCH_REL); - const bundle = await buildHybridSourceBundle(deps.projectRoot, lastGeneratedAt); - const prompt = buildGenerationPrompt({ - bundle, - existingPrd: existingPrdFile.body, - existingArch: existingArchFile.body, - lastGeneratedAt, - }); - - let generatedPrd = ""; - let generatedArch = ""; - let outputPreview = ""; - if (!deps.aiIntegrationService || deps.aiIntegrationService.getMode() === "guest") { - warnings.push({ - code: "generator_failed", - message: `provider=${provider} ai_unavailable` - }); - } else { - try { - const aiResult = await deps.aiIntegrationService.generateInitialContext({ - cwd: deps.projectRoot, - ...(providerHint ? { provider: providerHint } : {}), - prompt, - ...(modelId ? { model: modelId } : {}), - ...(reasoningEffort ? { reasoningEffort } : {}), - jsonSchema: { - type: "object", - additionalProperties: false, - properties: { - prd: { type: "string" }, - architecture: { type: "string" } - }, - required: ["prd", "architecture"] - } - }); - - outputPreview = aiResult.text.trim().slice(0, 1_500); - const structuredCandidate = isRecord(aiResult.structuredOutput) - ? aiResult.structuredOutput - : parseStructuredOutput(aiResult.text); - const structured = isRecord(structuredCandidate) ? structuredCandidate : null; - if (structured) { - generatedPrd = compactGeneratedContextDoc("prd_ade", asString(structured.prd)); - generatedArch = compactGeneratedContextDoc("architecture_ade", asString(structured.architecture)); - } else if (aiResult.text.trim()) { - warnings.push({ - code: "generator_unstructured_output", - message: "Model returned text instead of the required JSON object for context docs.", - }); - } else { - warnings.push({ - code: "generator_empty_output", - message: "Model returned empty output for context docs.", - }); - } - } catch (error) { - warnings.push({ - code: "generator_failed", - message: `provider=${provider}${modelId ? ` model=${modelId}` : ""} error=${error instanceof Error ? error.message : String(error)}` - }); - } - } - - const prdValidation = validateContextDoc("prd_ade", generatedPrd); - const archValidation = validateContextDoc("architecture_ade", generatedArch); - let overlapScore = 0; - if (generatedPrd.trim() && generatedArch.trim()) { - overlapScore = computeDocOverlap(generatedPrd, generatedArch); - if (overlapScore >= 0.72) { - warnings.push({ - code: "generator_overlap_rejected", - message: `Rejected generated docs because PRD/architecture overlap was too high (${overlapScore.toFixed(2)}).`, - }); - } - } - if (!prdValidation.valid) { - warnings.push({ - code: "generator_invalid_prd", - message: `Generated PRD failed validation: ${prdValidation.reasons.join(", ") || "unknown"}.`, - }); - } - if (!archValidation.valid) { - warnings.push({ - code: "generator_invalid_architecture", - message: `Generated architecture doc failed validation: ${archValidation.reasons.join(", ") || "unknown"}.`, - }); - } - - const agentsText = safeReadDoc(path.join(deps.projectRoot, "AGENTS.md"), 16_000).text; - const workingNorms = extractSectionBullets(agentsText, "## Working norms", 6); - const featureDigests = bundle.productDigests.filter((digest) => digest.relPath.startsWith("docs/features/")); - const deterministicPrd = buildDeterministicPrdDoc({ - productDigests: bundle.productDigests, - featureDigests, - gitHistory: bundle.gitHistory, - workingNorms, - }); - const deterministicArch = buildDeterministicArchitectureDoc({ - technicalDigests: bundle.technicalDigests, - codeAnchors: bundle.codeAnchors, - workingNorms, - }); - - const existingPrdBaseHealth = computeContextDocHealth({ - id: "prd_ade", - exists: existingPrdFile.exists, - content: existingPrdFile.body, - staleReason: null, - source: inferContextDocSource(existingPrdFile.body, persistedDocResults.prd_ade?.source ?? null), - }); - const existingArchBaseHealth = computeContextDocHealth({ - id: "architecture_ade", - exists: existingArchFile.exists, - content: existingArchFile.body, - staleReason: null, - source: inferContextDocSource(existingArchFile.body, persistedDocResults.architecture_ade?.source ?? null), - }); - - type ResolvedDoc = { - content: string; - source: ContextDocOutputSource; - preserveExisting: boolean; - health: ContextDocHealth; - }; - - const rejectBothForOverlap = overlapScore >= 0.72; - - function resolveDocStrategy( - generated: string, - existingFile: { body: string }, - existingHealth: ContextDocHealth, - deterministicContent: string, - allowAi: boolean, - ): ResolvedDoc { - if (allowAi) { - return { content: generated, source: "ai", preserveExisting: false, health: "ready" }; - } - if (existingHealth === "ready") { - return { content: existingFile.body, source: "previous_good", preserveExisting: true, health: "ready" }; - } - return { content: deterministicContent, source: "deterministic", preserveExisting: false, health: "fallback" }; - } - - const resolvedDocs: Record = { - prd_ade: resolveDocStrategy( - generatedPrd, - existingPrdFile, - existingPrdBaseHealth, - deterministicPrd, - prdValidation.valid && !rejectBothForOverlap, - ), - architecture_ade: resolveDocStrategy( - generatedArch, - existingArchFile, - existingArchBaseHealth, - deterministicArch, - archValidation.valid && !rejectBothForOverlap, - ), - }; - - const FALLBACK_WARNINGS: Record> = { - deterministic: { - prd_ade: { code: "generator_fallback_prd", message: "Used deterministic fallback PRD." }, - architecture_ade: { code: "generator_fallback_architecture", message: "Used deterministic fallback architecture." }, - }, - previous_good: { - prd_ade: { code: "generator_preserved_previous_prd", message: "Preserved the previous valid PRD because new output was degraded." }, - architecture_ade: { code: "generator_preserved_previous_architecture", message: "Preserved the previous valid architecture doc because new output was degraded." }, - }, - ai: { prd_ade: null, architecture_ade: null }, - }; - - for (const [id, doc] of Object.entries(resolvedDocs) as Array<[ContextDocId, ResolvedDoc]>) { - if (doc.source === "ai") continue; - const warning = FALLBACK_WARNINGS[doc.source]?.[id]; - if (warning) warnings.push(warning); - } - - const persistResolvedDoc = (id: ContextDocId) => { - const spec = docSpecFor(id); - const preferredAbsPath = path.join(deps.projectRoot, spec.relPath); - const resolved = resolvedDocs[id]; - if (resolved.preserveExisting && fs.existsSync(preferredAbsPath)) { - const stat = fs.statSync(preferredAbsPath); - return { - writtenPath: preferredAbsPath, - usedFallback: false, - warning: null, - sizeBytes: stat.size, - }; - } - const write = writeDocWithFallback({ - preferredAbsPath, - fallbackFileName: spec.fallbackFileName, - content: resolved.content, - fallbackRoot: FALLBACK_GENERATED_ROOT, - }); - return { - ...write, - sizeBytes: Buffer.byteLength(resolved.content, "utf8"), - }; - }; - - const prdWrite = persistResolvedDoc("prd_ade"); - const archWrite = persistResolvedDoc("architecture_ade"); - if (prdWrite.warning) { - warnings.push({ - code: "write_fallback_prd", - message: prdWrite.warning, - actionLabel: "Open fallback PRD", - actionPath: prdWrite.writtenPath - }); - } - if (archWrite.warning) { - warnings.push({ - code: "write_fallback_architecture", - message: archWrite.warning, - actionLabel: "Open fallback architecture", - actionPath: archWrite.writtenPath - }); - } - - deps.db.setJson(CONTEXT_DOC_LAST_RUN_KEY, { - generatedAt: generationStartedAt, - provider, - trigger, - modelId, - reasoningEffort, - prdPath: prdWrite.writtenPath, - architecturePath: archWrite.writtenPath, - degraded: Object.values(resolvedDocs).some((doc) => doc.source !== "ai"), - docResults: [ - { - id: "prd_ade", - health: resolvedDocs.prd_ade.health, - source: resolvedDocs.prd_ade.source, - sizeBytes: prdWrite.sizeBytes, - }, - { - id: "architecture_ade", - health: resolvedDocs.architecture_ade.health, - source: resolvedDocs.architecture_ade.source, - sizeBytes: archWrite.sizeBytes, - }, - ], - warnings - }); - - return { - provider, - generatedAt: generationStartedAt, - prdPath: prdWrite.writtenPath, - architecturePath: archWrite.writtenPath, - usedFallbackPath: prdWrite.usedFallback || archWrite.usedFallback, - degraded: Object.values(resolvedDocs).some((doc) => doc.source !== "ai"), - docResults: [ - { - id: "prd_ade", - health: resolvedDocs.prd_ade.health, - source: resolvedDocs.prd_ade.source, - sizeBytes: prdWrite.sizeBytes, - }, - { - id: "architecture_ade", - health: resolvedDocs.architecture_ade.health, - source: resolvedDocs.architecture_ade.source, - sizeBytes: archWrite.sizeBytes, - }, - ], - warnings, - outputPreview - }; -} - -export function resolveContextDocPath(projectRoot: string, docId: ContextDocStatus["id"]): string { - if (docId === "prd_ade") return path.join(projectRoot, ADE_DOC_PRD_REL); - return path.join(projectRoot, ADE_DOC_ARCH_REL); -} - -export async function buildProjectBootstrap(deps: ProjectPackBuilderDeps, args: { lanes: LaneSummary[] }): Promise { - const lanes = args.lanes; - const primary = lanes.find((lane) => lane.laneType === "primary") ?? null; - const historyRef = primary?.branchRef || primary?.baseRef || "HEAD"; - - const topLevelEntries = (() => { - try { - return fs - .readdirSync(deps.projectRoot, { withFileTypes: true }) - .filter((entry) => !entry.name.startsWith(".") && entry.name !== "node_modules") - .slice(0, 40) - .map((entry) => `${entry.isDirectory() ? "dir" : "file"}: ${entry.name}`); - } catch { - return []; - } - })(); - - const pickDocs = (): string[] => { - const candidates = collectContextDocPaths(deps.projectRoot) - .filter((rel) => DOC_TEXT_EXT_RE.test(rel)) - .filter((rel) => { - const abs = path.join(deps.projectRoot, rel); - try { - return fs.statSync(abs).isFile(); - } catch { - return false; - } - }); - return rankDocPathsByRelevance(candidates).slice(0, 14); - }; - - const excerptDoc = (rel: string): { rel: string; title: string; blurb: string } | null => { - const abs = path.join(deps.projectRoot, rel); - try { - const fd = fs.openSync(abs, "r"); - try { - const MAX = 48_000; - const buf = Buffer.alloc(MAX); - const read = fs.readSync(fd, buf, 0, MAX, 0); - const raw = buf.slice(0, Math.max(0, read)).toString("utf8"); - const lines = raw.split(/\r?\n/); - const titleLine = lines.find((line) => line.trim().startsWith("# ")); - const title = titleLine ? titleLine.replace(/^#\s+/, "").trim() : path.basename(rel); - const blurbLines: string[] = []; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - if (trimmed.startsWith("#")) continue; - if (/^table of contents/i.test(trimmed)) continue; - if (trimmed.startsWith("---")) continue; - blurbLines.push(trimmed); - if (blurbLines.join(" ").length > 220) break; - } - const blurb = blurbLines.slice(0, 2).join(" "); - return { rel, title, blurb }; - } finally { - fs.closeSync(fd); - } - } catch { - return null; - } - }; - - const historyLines = await (async (): Promise => { - const res = await runGit(["log", historyRef, "-n", "18", "--date=short", "--pretty=format:%h %ad %s"], { - cwd: deps.projectRoot, - timeoutMs: 12_000 - }); - if (res.exitCode !== 0) return []; - return res.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - })(); - - const lines: string[] = []; - lines.push("## Bootstrap context (codebase + docs)"); - lines.push(""); - lines.push("### Repo map (top level)"); - if (topLevelEntries.length) { - for (const entry of topLevelEntries) lines.push(`- ${entry}`); - } else { - lines.push("- (unavailable)"); - } - lines.push(""); - - lines.push("### Docs index"); - const docs = pickDocs().map(excerptDoc).filter(Boolean) as Array<{ rel: string; title: string; blurb: string }>; - if (docs.length) { - for (const doc of docs) { - lines.push(`- ${doc.rel}: ${doc.title}`); - if (doc.blurb) lines.push(` - ${doc.blurb}`); - } - } else { - lines.push("- no docs found"); - } - lines.push(""); - - lines.push(`### Git history seed (${historyRef})`); - if (historyLines.length) { - for (const entry of historyLines) lines.push(`- ${entry}`); - } else { - lines.push("- (no git history available)"); - } - lines.push(""); - - return `${lines.join("\n")}\n`; -} - -export async function buildProjectPackBody( - deps: ProjectPackBuilderDeps, - args: { - reason: string; - deterministicUpdatedAt: string; - sourceLaneId?: string; - } -): Promise { - const projectBootstrapPath = path.join(deps.packsDir, "_bootstrap", "project_bootstrap.md"); - const config = deps.projectConfigService.get().effective; - const lanes = await deps.laneService.list({ includeArchived: false }); - const docsMeta = readContextDocMeta(deps.projectRoot); - const existingBootstrapRaw = readFileIfExists(projectBootstrapPath); - const existingFingerprint = (() => { - const m = existingBootstrapRaw.match(BOOTSTRAP_FINGERPRINT_RE); - return m?.[1]?.toLowerCase() ?? null; - })(); - - const shouldBootstrap = - args.reason === "onboarding_init" || - !fs.existsSync(projectBootstrapPath) || - existingFingerprint !== docsMeta.contextFingerprint; - if (shouldBootstrap) { - try { - const bootstrap = await buildProjectBootstrap(deps, { lanes }); - ensureDirFor(projectBootstrapPath); - const withMeta = [ - ``, - ``, - ``, - bootstrap - ].join("\n"); - fs.writeFileSync(projectBootstrapPath, withMeta, "utf8"); - } catch (error) { - deps.logger.warn("packs.project_bootstrap_failed", { - error: error instanceof Error ? error.message : String(error) - }); - } - } - const bootstrapBody = readFileIfExists(projectBootstrapPath) - .replace(BOOTSTRAP_FINGERPRINT_RE, "") - .replace(//gi, "") - .replace(//gi, "") - .trim(); - - const lines: string[] = []; - lines.push("# Project Pack"); - lines.push(""); - lines.push(`Deterministic updated: ${args.deterministicUpdatedAt}`); - lines.push(`Trigger: ${args.reason}`); - if (args.sourceLaneId) lines.push(`Source lane: ${args.sourceLaneId}`); - lines.push(`Active lanes: ${lanes.length}`); - lines.push(`Context fingerprint: ${docsMeta.contextFingerprint}`); - lines.push(`Context version: ${docsMeta.contextVersion}`); - lines.push(`Last docs refresh at: ${docsMeta.lastDocsRefreshAt ?? "unknown"}`); - if (docsMeta.docsStaleReason) lines.push(`Docs stale reason: ${docsMeta.docsStaleReason}`); - lines.push(""); - - if (bootstrapBody) { - lines.push(bootstrapBody); - } else { - lines.push("## Bootstrap context"); - lines.push("- Bootstrap scan not generated yet."); - lines.push("- Run Onboarding -> Generate Initial Packs, or refresh the Project pack once after onboarding."); - lines.push(""); - } - - lines.push("## How To Run (Processes)"); - if (config.processes.length) { - for (const proc of config.processes) { - const cmd = formatCommand(proc.command); - const cwd = proc.cwd && proc.cwd !== "." ? ` (cwd=${proc.cwd})` : ""; - lines.push(`- ${proc.name}: ${cmd}${cwd}`); - } - } else { - lines.push("- no managed process definitions"); - } - lines.push(""); - - lines.push("## How To Test (Test Suites)"); - if (config.testSuites.length) { - for (const suite of config.testSuites) { - const cmd = formatCommand(suite.command); - const cwd = suite.cwd && suite.cwd !== "." ? ` (cwd=${suite.cwd})` : ""; - lines.push(`- ${suite.name}: ${cmd}${cwd}`); - } - } else { - lines.push("- no test suites configured"); - } - lines.push(""); - - lines.push("## Stack Buttons"); - if (config.stackButtons.length) { - for (const stack of config.stackButtons) { - lines.push(`- ${stack.name}: ${stack.processIds.join(", ")}`); - } - } else { - lines.push("- no stack buttons configured"); - } - lines.push(""); - - lines.push("## Lane Snapshot"); - if (lanes.length) { - for (const lane of lanes) { - const dirty = lane.status.dirty ? "dirty" : "clean"; - const stack = lane.parentLaneId ? "stacked" : lane.laneType === "primary" ? "primary" : "root"; - lines.push(`- ${lane.name}: ${dirty} · ahead ${lane.status.ahead} · behind ${lane.status.behind} · ${stack}`); - } - } else { - lines.push("- no active lanes"); - } - lines.push(""); - - lines.push("## Conventions And Constraints"); - lines.push("- Deterministic sections are rebuilt by ADE on session end and commit operations."); - if ((config.providerMode ?? "guest") === "guest") { - lines.push("- Guest Mode active: narrative sections use local templates only."); - } else { - lines.push("- Narrative sections are AI-assisted when subscription providers are configured and available."); - } - lines.push(""); - - return `${lines.join("\n")}\n`; -} diff --git a/apps/desktop/src/main/services/context/contextDocService.test.ts b/apps/desktop/src/main/services/context/contextDocService.test.ts deleted file mode 100644 index 57cd1d059..000000000 --- a/apps/desktop/src/main/services/context/contextDocService.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { openKvDb } from "../state/kvDb"; - -vi.mock("./contextDocBuilder", () => ({ - readContextDocMeta: vi.fn(() => ({ - contextFingerprint: "fingerprint", - contextVersion: 1, - lastDocsRefreshAt: null, - docsStaleReason: null, - })), - readContextStatus: vi.fn(() => ({ - docs: [], - canonicalDocsPresent: 0, - canonicalDocsScanned: 0, - canonicalDocsFingerprint: "fingerprint", - canonicalDocsUpdatedAt: null, - projectExportFingerprint: null, - projectExportUpdatedAt: null, - contextManifestRefs: { - project: null, - packs: null, - transcripts: null, - }, - fallbackWrites: 0, - insufficientContextCount: 0, - warnings: [], - })), - resolveContextDocPath: vi.fn((projectRoot: string, docId: string) => path.join(projectRoot, `${docId}.md`)), - runContextDocGeneration: vi.fn(async (_deps: unknown, args: Record) => ({ - provider: args.provider ?? "opencode", - generatedAt: "2026-03-05T12:00:00.000Z", - prdPath: "/tmp/PRD.ade.md", - architecturePath: "/tmp/ARCHITECTURE.ade.md", - usedFallbackPath: false, - degraded: false, - docResults: [ - { id: "prd_ade", health: "ready", source: "ai", sizeBytes: 512 }, - { id: "architecture_ade", health: "ready", source: "ai", sizeBytes: 640 }, - ], - warnings: [], - outputPreview: "generated", - })), -})); - -import { createContextDocService } from "./contextDocService"; -import { - resolveContextDocPath, - runContextDocGeneration, -} from "./contextDocBuilder"; - -function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function createLogger() { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as const; -} - -function createMockProjectConfigService(overrides?: { contextRefreshEvents?: Record }) { - return { - get: () => ({ - shared: { contextRefreshEvents: overrides?.contextRefreshEvents ?? undefined }, - local: {}, - effective: {}, - validation: { ok: true, issues: [] }, - trust: { sharedHash: "", localHash: "", approvedSharedHash: null, requiresSharedTrust: false }, - paths: { sharedPath: "", localPath: "" }, - }), - } as any; -} - -async function createFixture(opts?: { - contextRefreshEvents?: Record; - onStatusChanged?: (status: unknown) => void; -}) { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-context-doc-service-")); - const packsDir = path.join(projectRoot, ".ade", "packs"); - fs.mkdirSync(packsDir, { recursive: true }); - const db = await openKvDb(path.join(projectRoot, "ade.db"), createLogger() as any); - - const service = createContextDocService({ - db, - logger: createLogger() as any, - projectRoot, - projectId: "project-1", - packsDir, - laneService: {} as any, - projectConfigService: createMockProjectConfigService(opts), - onStatusChanged: opts?.onStatusChanged as ((status: any) => void) | undefined, - }); - - return { db, projectRoot, service }; -} - -describe("contextDocService", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-05T12:00:00.000Z")); - vi.mocked(runContextDocGeneration).mockClear(); - vi.mocked(resolveContextDocPath).mockClear(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("reuses persisted doc generation preferences for matching auto-refresh events", async () => { - const { db, service } = await createFixture(); - - await service.generateDocs({ - provider: "codex", - modelId: "gpt-5", - reasoningEffort: "medium", - events: { onPrCreate: true }, - }); - - expect(runContextDocGeneration).toHaveBeenCalledTimes(1); - - db.setJson("context:docs:lastRun.v1", { - generatedAt: "2026-03-05T11:30:00.000Z", - }); - - const refreshed = await service.maybeAutoRefreshDocs({ - event: "pr_create", - reason: "pr_closed", - }); - - expect(refreshed?.provider).toBe("codex"); - expect(runContextDocGeneration).toHaveBeenLastCalledWith( - expect.anything(), - expect.objectContaining({ - provider: "codex", - modelId: "gpt-5", - reasoningEffort: "medium", - events: expect.objectContaining({ onPrCreate: true }), - }) - ); - }); - - it("skips auto-refresh when the previous run is still within the minimum interval", async () => { - const { db, service } = await createFixture(); - - await service.generateDocs({ - provider: "claude", - modelId: "anthropic/claude-sonnet-4-6", - events: { onSessionEnd: true }, - }); - db.setJson("context:docs:lastRun.v1", { - generatedAt: "2026-03-05T11:40:00.000Z", - }); - - const refreshed = await service.maybeAutoRefreshDocs({ - event: "session_end", - reason: "lane_refresh", - }); - - expect(refreshed).toBeNull(); - expect(runContextDocGeneration).toHaveBeenCalledTimes(1); - }); - - it("skips auto-refresh when event is not enabled", async () => { - const { service } = await createFixture(); - - await service.generateDocs({ - provider: "opencode", - modelId: "openai/gpt-5.4-codex", - events: { onPrCreate: true }, - }); - - const refreshed = await service.maybeAutoRefreshDocs({ - event: "commit", - reason: "test_commit", - }); - - expect(refreshed).toBeNull(); - // Only the initial generateDocs call - expect(runContextDocGeneration).toHaveBeenCalledTimes(1); - }); - - it("backward compat: old trigger cadence maps to event flags", async () => { - const { db, service } = await createFixture(); - - // Simulate old-style prefs with cadence but no events - await service.generateDocs({ - provider: "codex", - modelId: "gpt-5", - trigger: "per_mission", - }); - - db.setJson("context:docs:lastRun.v1", { - generatedAt: "2026-03-05T11:30:00.000Z", - }); - - const refreshed = await service.maybeAutoRefreshDocs({ - event: "mission_start", - reason: "mission_launch", - }); - - expect(refreshed?.provider).toBe("codex"); - }); - - it("uses project config contextRefreshEvents when available", async () => { - const { db, service } = await createFixture({ - contextRefreshEvents: { onCommit: true }, - }); - - await service.generateDocs({ - provider: "opencode", - modelId: "openai/gpt-5.4-codex", - events: { onPrCreate: true }, - }); - - db.setJson("context:docs:lastRun.v1", { - generatedAt: "2026-03-05T11:30:00.000Z", - }); - - // commit event is enabled via project config, not stored prefs - const refreshed = await service.maybeAutoRefreshDocs({ - event: "commit", - reason: "config_override", - }); - - expect(refreshed?.provider).toBe("opencode"); - }); - - it("resolves canonical doc paths through the extracted service", async () => { - const { projectRoot, service } = await createFixture(); - - expect(service.getDocPath("prd_ade")).toBe(path.join(projectRoot, "prd_ade.md")); - expect(resolveContextDocPath).toHaveBeenCalledWith(projectRoot, "prd_ade"); - }); - - it("persists active auto-refresh status as pending/running with trigger metadata", async () => { - const { service } = await createFixture(); - const deferred = createDeferred>>(); - vi.mocked(runContextDocGeneration).mockReturnValueOnce(deferred.promise as ReturnType); - - await service.savePrefs({ - provider: "opencode", - modelId: "gpt-5", - reasoningEffort: "medium", - events: { onPrLand: true }, - }); - - const refreshPromise = service.maybeAutoRefreshDocs({ - event: "pr_land", - reason: "prs_land:123", - }); - - const duringRun = service.getStatus().generation; - expect(["pending", "running"]).toContain(duringRun.state); - expect(duringRun.source).toBe("auto"); - expect(duringRun.event).toBe("pr_land"); - expect(duringRun.reason).toBe("prs_land:123"); - expect(duringRun.provider).toBe("opencode"); - expect(duringRun.modelId).toBe("gpt-5"); - expect(duringRun.reasoningEffort).toBe("medium"); - - deferred.resolve({ - provider: "opencode", - generatedAt: "2026-03-05T12:01:00.000Z", - prdPath: "/tmp/PRD.ade.md", - architecturePath: "/tmp/ARCHITECTURE.ade.md", - usedFallbackPath: false, - degraded: false, - docResults: [ - { id: "prd_ade", health: "ready", source: "ai", sizeBytes: 512 }, - { id: "architecture_ade", health: "ready", source: "ai", sizeBytes: 640 }, - ], - warnings: [], - outputPreview: "generated", - }); - - await expect(refreshPromise).resolves.toMatchObject({ - provider: "opencode", - generatedAt: "2026-03-05T12:01:00.000Z", - }); - - expect(service.getStatus().generation).toMatchObject({ - state: "succeeded", - source: "auto", - event: "pr_land", - reason: "prs_land:123", - provider: "opencode", - modelId: "gpt-5", - reasoningEffort: "medium", - finishedAt: "2026-03-05T12:01:00.000Z", - }); - }); - - it("records manual generation metadata on completion", async () => { - const { service } = await createFixture(); - - await service.generateDocs({ - provider: "codex", - modelId: "gpt-5-codex", - reasoningEffort: "high", - events: { onPrCreate: true }, - }); - - expect(service.getStatus().generation).toMatchObject({ - state: "succeeded", - source: "manual", - event: null, - reason: "manual_generate", - provider: "codex", - modelId: "gpt-5-codex", - reasoningEffort: "high", - finishedAt: "2026-03-05T12:00:00.000Z", - }); - }); - - it("rejects manual generation when no model is selected", async () => { - const { service } = await createFixture(); - - await expect(service.generateDocs({ - provider: "opencode", - events: { onPrCreate: true }, - })).rejects.toThrow("Select a model before generating context docs."); - - expect(runContextDocGeneration).not.toHaveBeenCalled(); - }); - - it("skips auto-refresh when no model is configured", async () => { - const { service } = await createFixture(); - - await service.savePrefs({ - provider: "opencode", - modelId: null, - reasoningEffort: null, - events: { onPrCreate: true }, - }); - - const refreshed = await service.maybeAutoRefreshDocs({ - event: "pr_create", - reason: "pr_opened", - }); - - expect(refreshed).toBeNull(); - expect(runContextDocGeneration).not.toHaveBeenCalled(); - expect(service.getStatus().generation.state).toBe("idle"); - }); - - it("clears stale finished timestamps when a new generation starts", async () => { - const { db, service } = await createFixture(); - const deferred = createDeferred>>(); - - await service.generateDocs({ - provider: "opencode", - modelId: "openai/gpt-5.4-codex", - events: { onPrLand: true }, - }); - - vi.mocked(runContextDocGeneration).mockReturnValueOnce(deferred.promise as ReturnType); - db.setJson("context:docs:lastRun.v1", { - generatedAt: "2026-03-05T11:30:00.000Z", - }); - - const refreshPromise = service.maybeAutoRefreshDocs({ - event: "pr_land", - reason: "prs_land:456", - }); - - const duringRun = service.getStatus().generation; - expect(["pending", "running"]).toContain(duringRun.state); - expect(duringRun.finishedAt).toBeNull(); - - deferred.resolve({ - provider: "opencode", - generatedAt: "2026-03-05T12:01:00.000Z", - prdPath: "/tmp/PRD.ade.md", - architecturePath: "/tmp/ARCHITECTURE.ade.md", - usedFallbackPath: false, - degraded: false, - docResults: [ - { id: "prd_ade", health: "ready", source: "ai", sizeBytes: 512 }, - { id: "architecture_ade", health: "ready", source: "ai", sizeBytes: 640 }, - ], - warnings: [], - outputPreview: "generated", - }); - - await refreshPromise; - }); - - it("does not mark an active long-running generation as stale", async () => { - const { service } = await createFixture(); - const deferred = createDeferred>>(); - vi.mocked(runContextDocGeneration).mockReturnValueOnce(deferred.promise as ReturnType); - - const generatePromise = service.generateDocs({ - provider: "opencode", - modelId: "openai/gpt-5.4-codex", - }); - - vi.advanceTimersByTime(6 * 60_000); - - expect(service.getStatus().generation).toMatchObject({ - state: "running", - provider: "opencode", - modelId: "openai/gpt-5.4-codex", - }); - - deferred.resolve({ - provider: "opencode", - generatedAt: "2026-03-05T12:06:00.000Z", - prdPath: "/tmp/PRD.ade.md", - architecturePath: "/tmp/ARCHITECTURE.ade.md", - usedFallbackPath: false, - degraded: false, - docResults: [ - { id: "prd_ade", health: "ready", source: "ai", sizeBytes: 512 }, - { id: "architecture_ade", health: "ready", source: "ai", sizeBytes: 640 }, - ], - warnings: [], - outputPreview: "generated", - }); - - await expect(generatePromise).resolves.toMatchObject({ - generatedAt: "2026-03-05T12:06:00.000Z", - }); - expect(service.getStatus().generation).toMatchObject({ - state: "succeeded", - finishedAt: "2026-03-05T12:06:00.000Z", - }); - }); - - it("emits status updates when generation state changes", async () => { - const onStatusChanged = vi.fn(); - const { service } = await createFixture({ onStatusChanged }); - const deferred = createDeferred>>(); - vi.mocked(runContextDocGeneration).mockReturnValueOnce(deferred.promise as ReturnType); - - const generatePromise = service.generateDocs({ - provider: "opencode", - modelId: "gpt-5", - }); - - expect(onStatusChanged).toHaveBeenCalled(); - expect(onStatusChanged.mock.calls.at(-1)?.[0]?.generation?.state).toBe("running"); - - deferred.resolve({ - provider: "opencode", - generatedAt: "2026-03-05T12:02:00.000Z", - prdPath: "/tmp/PRD.ade.md", - architecturePath: "/tmp/ARCHITECTURE.ade.md", - usedFallbackPath: false, - degraded: false, - docResults: [ - { id: "prd_ade", health: "ready", source: "ai", sizeBytes: 512 }, - { id: "architecture_ade", health: "ready", source: "ai", sizeBytes: 640 }, - ], - warnings: [], - outputPreview: "generated", - }); - - await generatePromise; - - expect(onStatusChanged.mock.calls.at(-1)?.[0]?.generation?.state).toBe("succeeded"); - }); - - it("maps legacy idle generation records with a finish time to succeeded", async () => { - const { db, service } = await createFixture(); - - db.setJson("context:docs:generationStatus.v1", { - state: "idle", - finishedAt: "2026-03-05T09:30:00.000Z", - source: "auto", - event: "pr_create", - reason: "legacy_run", - }); - - expect(service.getStatus().generation).toMatchObject({ - state: "succeeded", - source: "auto", - event: "pr_create", - reason: "legacy_run", - finishedAt: "2026-03-05T09:30:00.000Z", - }); - }); - - it("repairs stale in-progress generation records", async () => { - const { db, service } = await createFixture(); - - db.setJson("context:docs:generationStatus.v1", { - state: "running", - requestedAt: "2026-03-05T11:40:00.000Z", - startedAt: "2026-03-05T11:40:00.000Z", - finishedAt: "2026-03-05T11:30:00.000Z", - error: null, - source: "auto", - event: "pr_create", - reason: "stale_run", - provider: "opencode", - modelId: null, - reasoningEffort: null, - }); - - expect(service.getStatus().generation).toMatchObject({ - state: "failed", - source: "auto", - event: "pr_create", - reason: "stale_run", - provider: "opencode", - }); - expect(service.getStatus().generation.error).toContain("did not finish"); - }); -}); diff --git a/apps/desktop/src/main/services/context/contextDocService.ts b/apps/desktop/src/main/services/context/contextDocService.ts deleted file mode 100644 index 963684a09..000000000 --- a/apps/desktop/src/main/services/context/contextDocService.ts +++ /dev/null @@ -1,657 +0,0 @@ -import type { createAiIntegrationService } from "../ai/aiIntegrationService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import type { createLaneService } from "../lanes/laneService"; -import type { Logger } from "../logging/logger"; -import { - type ProjectPackBuilderDeps, - readContextDocMeta as readContextDocMetaImpl, - readContextStatus as readContextStatusImpl, - resolveContextDocPath as resolveContextDocPathImpl, - runContextDocGeneration as runContextDocGenerationImpl, -} from "./contextDocBuilder"; -import { getErrorMessage, nowIso, toOptionalString } from "../shared/utils"; -import type { AdeDb } from "../state/kvDb"; -import type { - ContextDocGenerationEvent, - ContextDocGenerationSource, - ContextDocGenerationStatus, - ContextDocPrefs, - ContextDocStatus, - ContextGenerateDocsArgs, - ContextGenerateDocsResult, - ContextRefreshEvents, - ContextRefreshTrigger, - ContextStatus, -} from "../../../shared/types"; - -/** Event names that can trigger auto-refresh of context docs. */ -export type ContextRefreshEventName = ContextDocGenerationEvent; - -type ContextDocRefreshPrefs = { - cadence: ContextRefreshTrigger; - events: ContextRefreshEvents; - provider: "codex" | "claude" | "opencode"; - modelId: string | null; - reasoningEffort: string | null; - updatedAt: string; -}; - -type GenerationRunMeta = { - source: ContextDocGenerationStatus["source"]; - event?: ContextDocGenerationStatus["event"]; - reason?: string | null; - requestedAt?: string | null; - provider?: ContextDocGenerationStatus["provider"]; - modelId?: string | null; - reasoningEffort?: string | null; -}; - -const CONTEXT_DOC_LOG_MESSAGE_MAX = 2_000; - -function logMetaForContextGenerateResult( - result: ContextGenerateDocsResult, - meta: GenerationRunMeta, -): Record { - const clip = (msg: string) => - msg.length > CONTEXT_DOC_LOG_MESSAGE_MAX ? `${msg.slice(0, CONTEXT_DOC_LOG_MESSAGE_MAX)}…` : msg; - return { - source: meta.source ?? null, - event: meta.event ?? null, - reason: meta.reason ?? null, - provider: meta.provider ?? null, - modelId: meta.modelId ?? null, - reasoningEffort: meta.reasoningEffort ?? null, - degraded: result.degraded, - usedFallbackPath: result.usedFallbackPath, - generatedAt: result.generatedAt, - warningCount: result.warnings.length, - warnings: result.warnings.map((w) => ({ - code: w.code, - message: clip(String(w.message ?? "")), - ...(w.actionLabel ? { actionLabel: w.actionLabel } : {}), - ...(w.actionPath ? { actionPath: w.actionPath } : {}), - })), - docResults: result.docResults.map((d) => ({ - id: d.id, - health: d.health, - source: d.source, - sizeBytes: d.sizeBytes, - })), - }; -} - -const CONTEXT_DOC_PREFS_KEY = "context:docs:preferences.v1"; -const CONTEXT_DOC_LAST_RUN_KEY = "context:docs:lastRun.v1"; -const CONTEXT_DOC_GENERATION_STATUS_KEY = "context:docs:generationStatus.v1"; -const STALE_GENERATION_TIMEOUT_MS = 5 * 60_000; - -/** Minimum interval between auto-refresh runs (per event name). */ -const AUTO_REFRESH_MIN_INTERVAL_MS: Record = { - session_end: 45 * 60_000, - commit: 15 * 60_000, - pr_create: 15 * 60_000, - pr_land: 15 * 60_000, - mission_start: 15 * 60_000, - mission_end: 15 * 60_000, - lane_create: 45 * 60_000, -}; - -/** Default events when none are configured. */ -const DEFAULT_EVENTS: ContextRefreshEvents = { onPrCreate: true, onMissionStart: true }; - -/** Maps an event name to the corresponding key on ContextRefreshEvents. */ -const EVENT_NAME_TO_KEY: Record = { - session_end: "onSessionEnd", - commit: "onCommit", - pr_create: "onPrCreate", - pr_land: "onPrLand", - mission_start: "onMissionStart", - mission_end: "onMissionEnd", - lane_create: "onLaneCreate", -}; - -/** Maps old cadence trigger values to equivalent event flags for backward compat. */ -function cadenceToEvents(cadence: ContextRefreshTrigger): ContextRefreshEvents { - switch (cadence) { - case "per_mission": return { onMissionStart: true }; - case "per_pr": return { onPrCreate: true }; - case "per_lane_refresh": return { onSessionEnd: true }; - default: return {}; - } -} - -function normalizeRefreshTrigger(value: unknown): ContextRefreshTrigger { - const normalized = String(value ?? "").trim(); - if (normalized === "per_mission" || normalized === "per_pr" || normalized === "per_lane_refresh") return normalized; - return "manual"; -} - -function normalizeContextProvider(value: unknown): "codex" | "claude" | "opencode" { - const normalized = String(value ?? "").trim(); - if (normalized === "codex" || normalized === "claude") return normalized; - return "opencode"; -} - -function normalizeEvents(value: unknown): ContextRefreshEvents { - if (!value || typeof value !== "object") return {}; - const raw = value as Record; - const events: ContextRefreshEvents = {}; - for (const key of Object.keys(EVENT_NAME_TO_KEY) as ContextRefreshEventName[]) { - const fieldKey = EVENT_NAME_TO_KEY[key]; - if (typeof raw[fieldKey] === "boolean") { - events[fieldKey] = raw[fieldKey] as boolean; - } - } - return events; -} - -function normalizeGenerationEvent(value: unknown): ContextDocGenerationEvent | null { - const normalized = String(value ?? "").trim(); - return normalized in EVENT_NAME_TO_KEY ? normalized as ContextDocGenerationEvent : null; -} - -function normalizeGenerationSource(value: unknown): ContextDocGenerationSource | null { - const normalized = String(value ?? "").trim(); - if (normalized === "manual" || normalized === "auto") return normalized; - return null; -} - -function pickDefined(value: T | undefined, fallback: T): T { - return value !== undefined ? value : fallback; -} - -function parseIsoMs(value: string | null | undefined): number | null { - if (!value) return null; - const ts = Date.parse(value); - return Number.isFinite(ts) ? ts : null; -} - -export function createContextDocService(args: { - db: AdeDb; - logger: Logger; - projectRoot: string; - projectId: string; - packsDir: string; - laneService: ReturnType; - projectConfigService: ReturnType; - aiIntegrationService?: ReturnType; - onStatusChanged?: (status: ContextStatus) => void; -}) { - const { - db, - logger, - projectRoot, - projectId, - packsDir, - laneService, - projectConfigService, - aiIntegrationService, - onStatusChanged, - } = args; - - const projectPackBuilderDeps: ProjectPackBuilderDeps = { - db, - logger, - projectRoot, - projectId, - packsDir, - laneService, - projectConfigService, - aiIntegrationService, - }; - - let activeGeneration: Promise | null = null; - - const buildStatusSnapshot = (): ContextStatus => ({ - ...readContextStatusImpl({ db, projectId, projectRoot, packsDir }), - generation: reconcileGenerationStatus(), - }); - - const emitStatusChanged = (): void => { - if (!onStatusChanged) return; - try { - onStatusChanged(buildStatusSnapshot()); - } catch (error) { - logger.debug("context_docs.status_emit_failed", { - error: getErrorMessage(error), - }); - } - }; - - const readContextDocRefreshPrefs = (): ContextDocRefreshPrefs | null => { - const raw = db.getJson>(CONTEXT_DOC_PREFS_KEY); - if (!raw) return null; - const cadence = normalizeRefreshTrigger(raw.cadence); - const storedEvents = normalizeEvents(raw.events); - // Backward compat: if no events stored but cadence is set, derive events from cadence - const hasAnyEvent = Object.values(storedEvents).some(Boolean); - const events = hasAnyEvent ? storedEvents : cadenceToEvents(cadence); - return { - cadence, - events, - provider: normalizeContextProvider(raw.provider), - modelId: toOptionalString(raw.modelId), - reasoningEffort: toOptionalString(raw.reasoningEffort), - updatedAt: toOptionalString(raw.updatedAt) ?? nowIso(), - }; - }; - - const persistContextDocRefreshPrefs = (docArgs: ContextGenerateDocsArgs): ContextDocRefreshPrefs => { - const cadence = normalizeRefreshTrigger(docArgs.trigger); - const events = docArgs.events ? normalizeEvents(docArgs.events) : cadenceToEvents(cadence); - const prefs: ContextDocRefreshPrefs = { - cadence, - events, - provider: normalizeContextProvider(docArgs.provider), - modelId: toOptionalString(docArgs.modelId), - reasoningEffort: toOptionalString(docArgs.reasoningEffort), - updatedAt: nowIso(), - }; - db.setJson(CONTEXT_DOC_PREFS_KEY, prefs); - return prefs; - }; - - const readLastContextDocRunAt = (): number | null => { - const raw = db.getJson>(CONTEXT_DOC_LAST_RUN_KEY); - const generatedAt = toOptionalString(raw?.generatedAt); - if (!generatedAt) return null; - const ts = Date.parse(generatedAt); - return Number.isFinite(ts) ? ts : null; - }; - - const readGenerationStatus = (): ContextStatus["generation"] => { - const raw = db.getJson>(CONTEXT_DOC_GENERATION_STATUS_KEY); - const finishedAt = toOptionalString(raw?.finishedAt) ?? null; - const error = toOptionalString(raw?.error) ?? null; - const rawState = toOptionalString(raw?.state); - const state: ContextDocGenerationStatus["state"] = (() => { - if ( - rawState === "pending" - || rawState === "running" - || rawState === "succeeded" - || rawState === "failed" - ) { - return rawState; - } - if (rawState === "idle" && finishedAt && !error) return "succeeded"; - return "idle"; - })(); - const sourceValue = normalizeGenerationSource(raw?.source); - const eventValue = normalizeGenerationEvent(raw?.event); - const providerValue = toOptionalString(raw?.provider); - return { - state, - requestedAt: toOptionalString(raw?.requestedAt) ?? null, - startedAt: toOptionalString(raw?.startedAt) ?? null, - finishedAt, - error, - source: sourceValue, - event: eventValue, - reason: toOptionalString(raw?.reason) ?? null, - provider: providerValue === "codex" || providerValue === "claude" || providerValue === "opencode" ? providerValue : null, - modelId: toOptionalString(raw?.modelId) ?? null, - reasoningEffort: toOptionalString(raw?.reasoningEffort) ?? null, - }; - }; - - const normalizeStaleGenerationStatus = ( - status: ContextDocGenerationStatus, - ): { status: ContextDocGenerationStatus; changed: boolean } => { - if (status.state !== "pending" && status.state !== "running") { - return { status, changed: false }; - } - - const startedAtMs = parseIsoMs(status.startedAt); - const requestedAtMs = parseIsoMs(status.requestedAt); - const baselineMs = startedAtMs ?? requestedAtMs; - if (baselineMs == null) { - return { - status: { - ...status, - state: "failed", - finishedAt: nowIso(), - error: "Context doc generation state was left in progress without timestamps. ADE reset it.", - }, - changed: true, - }; - } - - if (Date.now() - baselineMs <= STALE_GENERATION_TIMEOUT_MS) { - return { status, changed: false }; - } - - return { - status: { - ...status, - state: "failed", - finishedAt: nowIso(), - error: "Previous context doc generation did not finish. ADE reset the stale in-progress state.", - }, - changed: true, - }; - }; - - const reconcileGenerationStatus = (): ContextDocGenerationStatus => { - const current = readGenerationStatus(); - if (activeGeneration && (current.state === "pending" || current.state === "running")) { - return current; - } - const normalized = normalizeStaleGenerationStatus(current); - if (normalized.changed) { - db.setJson(CONTEXT_DOC_GENERATION_STATUS_KEY, normalized.status); - } - return normalized.status; - }; - - const writeGenerationStatus = (next: ContextStatus["generation"]): void => { - db.setJson(CONTEXT_DOC_GENERATION_STATUS_KEY, next); - emitStatusChanged(); - }; - - const buildGenerationStatus = (args: { - state: ContextDocGenerationStatus["state"]; - requestedAt?: string | null; - startedAt?: string | null; - finishedAt?: string | null; - error?: string | null; - meta?: GenerationRunMeta | null; - previous?: ContextDocGenerationStatus | null; - }): ContextDocGenerationStatus => { - const previous = args.previous ?? readGenerationStatus(); - return { - state: args.state, - requestedAt: pickDefined(args.requestedAt, pickDefined(args.meta?.requestedAt, previous.requestedAt ?? null)), - startedAt: pickDefined(args.startedAt, previous.startedAt ?? null), - finishedAt: pickDefined(args.finishedAt, previous.finishedAt ?? null), - error: pickDefined(args.error, null), - source: pickDefined(args.meta?.source, previous.source ?? null), - event: pickDefined(args.meta?.event, previous.event ?? null), - reason: pickDefined(args.meta?.reason, previous.reason ?? null), - provider: pickDefined(args.meta?.provider, previous.provider ?? null), - modelId: pickDefined(args.meta?.modelId, previous.modelId ?? null), - reasoningEffort: pickDefined(args.meta?.reasoningEffort, previous.reasoningEffort ?? null), - }; - }; - - const generateDocsInternal = async ( - docArgs: ContextGenerateDocsArgs, - meta: GenerationRunMeta, - ): Promise => { - // If generation is already in-flight, wait for it instead of starting a second one - if (activeGeneration) { - logger.info("context_docs.generation_already_running", { - source: meta.source, - event: meta.event ?? null, - reason: meta.reason ?? null, - }); - return activeGeneration; - } - - const provider = normalizeContextProvider(docArgs.provider); - const modelId = toOptionalString(docArgs.modelId); - const reasoningEffort = toOptionalString(docArgs.reasoningEffort); - const requestedAt = meta.requestedAt ?? nowIso(); - const startedAt = nowIso(); - - persistContextDocRefreshPrefs(docArgs); - writeGenerationStatus( - buildGenerationStatus({ - state: "running", - requestedAt, - startedAt, - finishedAt: null, - error: null, - meta: { - ...meta, - requestedAt, - provider, - modelId, - reasoningEffort, - }, - }) - ); - - const run = async (): Promise => { - try { - const result = await runContextDocGenerationImpl(projectPackBuilderDeps, docArgs); - const completeMeta = logMetaForContextGenerateResult(result, meta); - if (result.warnings.length > 0 || result.degraded) { - logger.warn("context_docs.generate.complete", completeMeta); - } else { - logger.info("context_docs.generate.complete", completeMeta); - } - writeGenerationStatus( - buildGenerationStatus({ - state: "succeeded", - requestedAt, - startedAt, - finishedAt: result.generatedAt, - error: null, - meta: { - ...meta, - requestedAt, - provider, - modelId, - reasoningEffort, - }, - }) - ); - return result; - } catch (error) { - writeGenerationStatus( - buildGenerationStatus({ - state: "failed", - requestedAt, - startedAt, - finishedAt: nowIso(), - error: getErrorMessage(error), - meta: { - ...meta, - requestedAt, - provider, - modelId, - reasoningEffort, - }, - }) - ); - throw error; - } finally { - activeGeneration = null; - } - }; - - activeGeneration = run(); - return activeGeneration; - }; - - const generateDocs = async (docArgs: ContextGenerateDocsArgs): Promise => { - const modelId = toOptionalString(docArgs.modelId); - if (!modelId) { - throw new Error("Select a model before generating context docs."); - } - return generateDocsInternal(docArgs, { - source: "manual", - reason: "manual_generate", - provider: normalizeContextProvider(docArgs.provider), - modelId, - reasoningEffort: toOptionalString(docArgs.reasoningEffort), - }); - }; - - /** - * Resolves which events are enabled, merging project config with stored prefs. - * Priority: project config > stored prefs > defaults. - */ - const resolveEnabledEvents = (): ContextRefreshEvents => { - // 1. Check project config - const configSnapshot = projectConfigService.get(); - const configEvents = configSnapshot.shared?.contextRefreshEvents ?? configSnapshot.local?.contextRefreshEvents; - if (configEvents && Object.values(configEvents).some((v) => typeof v === "boolean")) { - return configEvents; - } - // 2. Check stored prefs - const prefs = readContextDocRefreshPrefs(); - if (prefs) { - const hasAnyEvent = Object.values(prefs.events).some(Boolean); - if (hasAnyEvent) return prefs.events; - } - // 3. Defaults - return DEFAULT_EVENTS; - }; - - const maybeAutoRefreshDocs = async (docArgs: { - event: ContextRefreshEventName; - reason?: string; - force?: boolean; - }): Promise => { - const { event } = docArgs; - const eventKey = EVENT_NAME_TO_KEY[event]; - if (!eventKey) return null; - - const generationBeforeAttempt = readGenerationStatus(); - const requestedAt = nowIso(); - const pendingMeta = (prefs: ContextDocRefreshPrefs | null): GenerationRunMeta => ({ - source: "auto", - event, - reason: docArgs.reason ?? null, - requestedAt, - provider: prefs?.provider ?? null, - modelId: prefs?.modelId ?? null, - reasoningEffort: prefs?.reasoningEffort ?? null, - }); - const settlePendingWithoutRun = (): void => { - if (activeGeneration) return; - const restored = - generationBeforeAttempt.state === "running" || generationBeforeAttempt.state === "pending" - ? { ...generationBeforeAttempt, state: "idle" as const, error: null } - : generationBeforeAttempt; - writeGenerationStatus(restored); - }; - - // Check if this event is enabled - const enabledEvents = resolveEnabledEvents(); - if (!enabledEvents[eventKey]) { - settlePendingWithoutRun(); - logger.debug("context_docs.auto_refresh_event_disabled", { - event, - reason: docArgs.reason ?? null, - }); - return null; - } - - // Need stored prefs for provider/model info - const prefs = readContextDocRefreshPrefs(); - if (!prefs) { - settlePendingWithoutRun(); - return null; - } - - if (!prefs.modelId) { - settlePendingWithoutRun(); - logger.debug("context_docs.auto_refresh_skipped_missing_model", { - event, - reason: docArgs.reason ?? null, - }); - return null; - } - - if (!activeGeneration) { - writeGenerationStatus( - buildGenerationStatus({ - state: "pending", - requestedAt, - startedAt: null, - finishedAt: null, - error: null, - meta: pendingMeta(prefs), - }) - ); - } - - // Throttle: check min interval - const minIntervalMs = AUTO_REFRESH_MIN_INTERVAL_MS[event]; - if (!docArgs.force) { - const lastRunAt = readLastContextDocRunAt(); - if (lastRunAt != null && Date.now() - lastRunAt < minIntervalMs) { - settlePendingWithoutRun(); - logger.debug("context_docs.auto_refresh_skipped_recent", { - event, - reason: docArgs.reason ?? null, - minIntervalMs, - }); - return null; - } - } - - try { - logger.info("context_docs.auto_refresh_start", { - event, - reason: docArgs.reason ?? null, - provider: prefs.provider, - modelId: prefs.modelId, - }); - return await generateDocsInternal({ - provider: prefs.provider, - ...(prefs.modelId ? { modelId: prefs.modelId } : {}), - ...(prefs.reasoningEffort ? { reasoningEffort: prefs.reasoningEffort } : {}), - events: enabledEvents, - }, { - source: "auto", - event, - reason: docArgs.reason ?? null, - provider: prefs.provider, - modelId: prefs.modelId, - reasoningEffort: prefs.reasoningEffort, - }); - } catch (error) { - logger.warn("context_docs.auto_refresh_failed", { - event, - reason: docArgs.reason ?? null, - error: getErrorMessage(error), - }); - return null; - } - }; - - reconcileGenerationStatus(); - - return { - getDocMeta() { - return readContextDocMetaImpl(projectRoot); - }, - getStatus(): ContextStatus { - return buildStatusSnapshot(); - }, - getPrefs(): ContextDocPrefs { - const stored = readContextDocRefreshPrefs(); - return { - provider: stored?.provider ?? "opencode", - modelId: stored?.modelId ?? null, - reasoningEffort: stored?.reasoningEffort ?? null, - events: stored?.events ?? DEFAULT_EVENTS, - }; - }, - savePrefs(prefs: ContextDocPrefs): ContextDocPrefs { - const args: ContextGenerateDocsArgs = { - provider: prefs.provider ?? "opencode", - modelId: prefs.modelId ?? undefined, - reasoningEffort: prefs.reasoningEffort, - events: prefs.events, - }; - const saved = persistContextDocRefreshPrefs(args); - return { - provider: saved.provider, - modelId: saved.modelId, - reasoningEffort: saved.reasoningEffort, - events: saved.events, - }; - }, - generateDocs, - maybeAutoRefreshDocs, - getDocPath(docId: ContextDocStatus["id"]): string { - return resolveContextDocPathImpl(projectRoot, docId); - }, - }; -} - -export type ContextDocService = ReturnType; diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index 0a6e4b5e2..d6af368ec 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -367,10 +367,6 @@ const CTO_CAPABILITY_MANIFEST = [ " searchWorkspaceText — Search for text patterns in workspace files. Params: query, laneId.", " searchCodebase — Search the ADE codebase itself for patterns (for self-debugging). Params: pattern, fileGlob.", "", - "## Context & Documentation", - " getContextStatus — Check what ADE context docs exist and staleness.", - " generateContextDocs — Generate context packs for workers or export.", - "", "## Processes (managed dev servers, builds, etc.)", " listManagedProcesses — List defined processes and their runtime status.", " startManagedProcess — Start a defined process. Params: processId, laneId.", @@ -758,7 +754,7 @@ export function createCtoStateService(args: CtoStateServiceArgs) { // The remaining files here are generated local/runtime state. const coreMemoryPath = path.join(ctoDir, "core-memory.json"); const memoryDocPath = path.join(ctoDir, "MEMORY.md"); - const currentContextDocPath = path.join(ctoDir, "CURRENT.md"); + const currentContextPath = path.join(ctoDir, "CURRENT.md"); const sessionsPath = path.join(ctoDir, "sessions.jsonl"); const subordinateActivityPath = path.join(ctoDir, "subordinate-activity.jsonl"); @@ -1036,17 +1032,6 @@ export function createCtoStateService(args: CtoStateServiceArgs) { }; }; - const listProjectContextDocPaths = (): string[] => { - const projectRoot = path.dirname(args.adeDir); - return [".ade/context/PRD.ade.md", ".ade/context/ARCHITECTURE.ade.md"].filter((rel) => { - try { - return fs.existsSync(path.join(projectRoot, rel)); - } catch { - return false; - } - }); - }; - const listDurableMemoryHighlights = (limit = 12): Memory[] => { if (!args.memoryService) return []; const promoted = args.memoryService.listMemories({ @@ -1158,13 +1143,6 @@ export function createCtoStateService(args: CtoStateServiceArgs) { } } - const contextDocs = listProjectContextDocPaths(); - if (contextDocs.length > 0) { - lines.push(""); - lines.push("## Project context docs"); - lines.push(...contextDocs.map((docPath) => `- ${docPath}`)); - } - const recentLogs = listRecentDailyLogSnippets(); if (recentLogs.length > 0) { lines.push(""); @@ -1200,13 +1178,13 @@ export function createCtoStateService(args: CtoStateServiceArgs) { "Internal ADE-generated long-term CTO memory. This mirrors the persistent continuity brief plus promoted durable project memory.", buildLongTermMemoryLines(snapshot), ); - const currentContextDoc = renderGeneratedMemoryDoc( + const currentContextBody = renderGeneratedMemoryDoc( "CTO Current Context", "Internal ADE-generated working context for continuity across compaction and session resumes.", buildCurrentContextLines(snapshot), ); writeTextAtomic(memoryDocPath, `${longTermDoc}\n`); - writeTextAtomic(currentContextDocPath, `${currentContextDoc}\n`); + writeTextAtomic(currentContextPath, `${currentContextBody}\n`); }; const updateCoreMemory = (patch: CoreMemoryPatch): CtoSnapshot => { diff --git a/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts b/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts index c17804152..88c6cc7db 100644 --- a/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts +++ b/apps/desktop/src/main/services/cto/ctoWorkerLifecycle.test.ts @@ -1087,7 +1087,7 @@ describe("workerAdapterRuntimeService (file group)", () => { }); const firstCall = runSessionTurn.mock.calls[0] as unknown as [{ text: string }] | undefined; expect(firstCall?.[0]?.text).toContain("## ADE CLI"); - expect(firstCall?.[0]?.text).toContain("Before saying an ADE task is blocked"); + expect(firstCall?.[0]?.text).toContain("only normal reason to skip ADE CLI"); expect(result.effectiveSurface).toBe("unified_chat"); expect(result.continuation).toMatchObject({ surface: "unified_chat", diff --git a/apps/desktop/src/main/services/files/fileService.test.ts b/apps/desktop/src/main/services/files/fileService.test.ts index ce8fc40ef..f00149ff3 100644 --- a/apps/desktop/src/main/services/files/fileService.test.ts +++ b/apps/desktop/src/main/services/files/fileService.test.ts @@ -58,19 +58,19 @@ describe("fileService", () => { const service = createFileService({ laneService }); try { - fs.mkdirSync(path.join(rootPath, ".ade", "context"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, ".ade", "notes"), { recursive: true }); fs.mkdirSync(path.join(rootPath, "src"), { recursive: true }); - fs.writeFileSync(path.join(rootPath, ".ade", "context", "PRD.ade.md"), "# PRD\nRenderer-safe content\n", "utf8"); + fs.writeFileSync(path.join(rootPath, ".ade", "notes", "project.md"), "# Project notes\nRenderer-safe content\n", "utf8"); fs.writeFileSync(path.join(rootPath, "src", "index.ts"), "export const visible = true;\n", "utf8"); const quickOpenDefault = await service.quickOpen({ workspaceId: "workspace-1", - query: "prd", + query: "project", includeIgnored: false, }); const quickOpenIgnored = await service.quickOpen({ workspaceId: "workspace-1", - query: "prd", + query: "project", includeIgnored: true, }); const searchDefault = await service.searchText({ @@ -85,9 +85,9 @@ describe("fileService", () => { }); expect(quickOpenDefault).toEqual([]); - expect(quickOpenIgnored.map((item) => item.path)).toContain(".ade/context/PRD.ade.md"); + expect(quickOpenIgnored.map((item) => item.path)).toContain(".ade/notes/project.md"); expect(searchDefault).toEqual([]); - expect(searchIgnored.map((item) => item.path)).toContain(".ade/context/PRD.ade.md"); + expect(searchIgnored.map((item) => item.path)).toContain(".ade/notes/project.md"); } finally { fs.rmSync(rootPath, { recursive: true, force: true }); } diff --git a/apps/desktop/src/main/services/files/fileWatcherService.test.ts b/apps/desktop/src/main/services/files/fileWatcherService.test.ts index 2ee357922..955f9d07f 100644 --- a/apps/desktop/src/main/services/files/fileWatcherService.test.ts +++ b/apps/desktop/src/main/services/files/fileWatcherService.test.ts @@ -72,7 +72,7 @@ describe("fileWatcherService", () => { const handlers = chokidarState.watchers[0]?.handlers; expect(handlers).toBeTruthy(); - handlers?.get("add")?.("/repo/.ade/context/PRD.ade.md"); + handlers?.get("add")?.("/repo/.ade/notes/project.md"); handlers?.get("change")?.("/repo/.git/config"); vi.runAllTimers(); @@ -80,7 +80,7 @@ describe("fileWatcherService", () => { expect(callback).toHaveBeenCalledWith({ workspaceId: "ws-1", type: "created", - path: ".ade/context/PRD.ade.md", + path: ".ade/notes/project.md", ts: expect.any(String), }); }); @@ -109,7 +109,7 @@ describe("fileWatcherService", () => { const handlers = chokidarState.watchers[0]?.handlers; expect(handlers).toBeTruthy(); - handlers?.get("add")?.("/repo/.ade/context/PRD.ade.md"); + handlers?.get("add")?.("/repo/.ade/notes/project.md"); vi.runAllTimers(); expect(callback).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 6f048bb98..9fb6a34d1 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -246,10 +246,6 @@ import type { MergeSimulationArgs, MergeSimulationResult, OperationRecord, - ContextGenerateDocsArgs, - ContextGenerateDocsResult, - ContextOpenDocArgs, - ContextStatus, ProcessActionArgs, ProcessDefinition, ProcessRuntime, @@ -525,7 +521,6 @@ import type { createOAuthRedirectService } from "../lanes/oauthRedirectService"; import type { createRuntimeDiagnosticsService } from "../lanes/runtimeDiagnosticsService"; import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; import type { createAutoRebaseService } from "../lanes/autoRebaseService"; -import type { ContextDocService, ContextRefreshEventName } from "../context/contextDocService"; import type { createSessionService } from "../sessions/sessionService"; import type { SessionDeltaService } from "../sessions/sessionDeltaService"; import type { createPtyService } from "../pty/ptyService"; @@ -654,7 +649,6 @@ export type AppContext = { orchestratorService: ReturnType; missionBudgetService: ReturnType; aiOrchestratorService: ReturnType; - contextDocService?: ContextDocService | null; projectConfigService: ReturnType; processService: ReturnType; testService: ReturnType; @@ -1683,7 +1677,7 @@ export function registerIpc({ })(), args: summarizeIpcValue(args), }); - const IPC_TIMEOUT_MS = channel === IPC.contextGenerateDocs ? null : 30_000; + const IPC_TIMEOUT_MS = 30_000; try { const result = await ( IPC_TIMEOUT_MS == null @@ -1720,25 +1714,6 @@ export function registerIpc({ tracedIpcMain.__adeTraceWrapped = true; } - const triggerAutoContextDocs = ( - ctx: AppContext, - args: { event: ContextRefreshEventName; reason: string } - ): void => { - if (!ctx.contextDocService) return; - void ctx.contextDocService - .maybeAutoRefreshDocs({ - event: args.event, - reason: args.reason - }) - .catch((error: unknown) => { - ctx.logger.debug("ipc.context_docs_auto_refresh_failed", { - event: args.event, - reason: args.reason, - error: error instanceof Error ? error.message : String(error) - }); - }); - }; - const ensureComputerUseBroker = (): AppContext => { const ctx = getCtx(); if (!ctx.computerUseArtifactBrokerService) { @@ -2975,10 +2950,6 @@ export function registerIpc({ void (async () => { try { - triggerAutoContextDocs(ctx, { - event: "mission_start", - reason: `missions_create_autostart:${created.id}` - }); await ctx.aiOrchestratorService.startMissionRun({ missionId: created.id, runMode, @@ -3107,10 +3078,6 @@ export function registerIpc({ IPC.orchestratorStartRunFromMission, async (_event, arg: StartOrchestratorRunFromMissionArgs): Promise<{ run: OrchestratorRun; steps: OrchestratorStep[] }> => { const ctx = getCtx(); - triggerAutoContextDocs(ctx, { - event: "mission_start", - reason: `orchestrator_start_run_from_mission:${arg.missionId}` - }); const started = await ctx.aiOrchestratorService.startMissionRun({ missionId: arg.missionId, runMode: arg.runMode, @@ -3174,7 +3141,6 @@ export function registerIpc({ } const run = ctx.orchestratorService.listRuns({ limit: 1_000 }).find((entry) => entry.id === arg.runId); if (!run) throw new Error(`Run not found after cancellation: ${arg.runId}`); - triggerAutoContextDocs(ctx, { event: "mission_end", reason: `orchestrator_cancel_run:${arg.runId}` }); return run; }); @@ -3238,10 +3204,6 @@ export function registerIpc({ IPC.orchestratorStartMissionRun, async (_event, arg: StartMissionRunWithAIArgs): Promise => { const ctx = getCtx(); - triggerAutoContextDocs(ctx, { - event: "mission_start", - reason: `orchestrator_start_mission_run:${arg.missionId}` - }); return ctx.aiOrchestratorService.startMissionRun(arg); } ); @@ -3282,9 +3244,7 @@ export function registerIpc({ IPC.orchestratorFinalizeRun, async (_event, arg: FinalizeRunArgs): Promise => { const ctx = getCtx(); - const result = ctx.orchestratorService.finalizeRun(arg); - triggerAutoContextDocs(ctx, { event: "mission_end", reason: `orchestrator_finalize_run:${arg.runId}` }); - return result; + return ctx.orchestratorService.finalizeRun(arg); } ); @@ -3636,10 +3596,6 @@ export function registerIpc({ }); await ensureLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); - triggerAutoContextDocs(ctx, { - event: "lane_create", - reason: `lanes_create:${lane.id}`, - }); return lane; }); @@ -3648,10 +3604,6 @@ export function registerIpc({ const lane = await ctx.laneService.createChild(arg); await ensureLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); - triggerAutoContextDocs(ctx, { - event: "lane_create", - reason: `lanes_create_child:${lane.id}`, - }); return lane; }); @@ -3660,10 +3612,6 @@ export function registerIpc({ const lane = await ctx.laneService.createFromUnstaged(arg); await ensureLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); - triggerAutoContextDocs(ctx, { - event: "lane_create", - reason: `lanes_create_from_unstaged:${lane.id}`, - }); return lane; }); @@ -3672,10 +3620,6 @@ export function registerIpc({ const lane = await ctx.laneService.importBranch(arg); await ensureLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); - triggerAutoContextDocs(ctx, { - event: "lane_create", - reason: `lanes_import_branch:${lane.id}`, - }); return lane; }); @@ -3694,10 +3638,6 @@ export function registerIpc({ const lane = await ctx.laneService.attach(arg); await ensureLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); - triggerAutoContextDocs(ctx, { - event: "lane_create", - reason: `lanes_attach:${lane.id}`, - }); return lane; }); @@ -3711,10 +3651,6 @@ export function registerIpc({ const lane = await ctx.laneService.adoptAttached(arg); await ensureLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); - triggerAutoContextDocs(ctx, { - event: "lane_create", - reason: `lanes_adopt_attached:${lane.id}`, - }); return lane; }); @@ -4765,12 +4701,6 @@ export function registerIpc({ ipcMain.handle(IPC.ptyDispose, async (_event, arg: { ptyId: string; sessionId?: string }): Promise => { const ctx = getCtx(); ctx.ptyService.dispose(arg); - if (arg.sessionId) { - triggerAutoContextDocs(ctx, { - event: "session_end", - reason: `pty_dispose:${arg.sessionId}`, - }); - } }); ipcMain.handle(IPC.diffGetChanges, async (_event, arg: GetDiffChangesArgs) => { @@ -4913,9 +4843,7 @@ export function registerIpc({ ipcMain.handle(IPC.gitCommit, async (_event, arg: GitCommitArgs): Promise => { const ctx = getCtx(); - const result = ctx.gitService.commit(arg); - triggerAutoContextDocs(ctx, { event: "commit", reason: "git_commit" }); - return result; + return ctx.gitService.commit(arg); }); ipcMain.handle( @@ -5144,44 +5072,6 @@ export function registerIpc({ ipcMain.handle(IPC.conflictsSuggestResolverTarget, async (_event, arg) => getCtx().conflictService.suggestResolverTarget(arg)); - ipcMain.handle(IPC.contextGetStatus, async (): Promise => { - const ctx = getCtx(); - if (!ctx.contextDocService) { - throw new Error("Context doc service is not available."); - } - return ctx.contextDocService.getStatus(); - }); - - ipcMain.handle(IPC.contextGenerateDocs, async (_event, arg: ContextGenerateDocsArgs): Promise => { - const ctx = getCtx(); - if (!ctx.contextDocService) { - throw new Error("Context doc service is not available."); - } - return ctx.contextDocService.generateDocs(arg); - }); - - ipcMain.handle(IPC.contextGetPrefs, async () => { - const ctx = getCtx(); - if (!ctx.contextDocService) throw new Error("Context doc service is not available."); - return ctx.contextDocService.getPrefs(); - }); - - ipcMain.handle(IPC.contextSavePrefs, async (_event, arg) => { - const ctx = getCtx(); - if (!ctx.contextDocService) throw new Error("Context doc service is not available."); - return ctx.contextDocService.savePrefs(arg); - }); - - ipcMain.handle(IPC.contextOpenDoc, async (_event, arg: ContextOpenDocArgs): Promise => { - const ctx = getCtx(); - const explicitPath = typeof arg.path === "string" ? arg.path.trim() : ""; - const target = explicitPath || (arg.docId ? ctx.contextDocService?.getDocPath(arg.docId) ?? "" : ""); - if (!target) { - throw new Error("contextOpenDoc requires docId or path"); - } - await shell.openPath(target); - }); - ipcMain.handle(IPC.githubGetStatus, async (): Promise => { const ctx = getCtx(); return await ctx.githubService.getStatus(); @@ -5255,22 +5145,12 @@ export function registerIpc({ ipcMain.handle(IPC.prsCreateFromLane, async (_event, arg: CreatePrFromLaneArgs): Promise => { const ctx = getCtx(); - const created = await ctx.prService.createFromLane(arg); - triggerAutoContextDocs(ctx, { - event: "pr_create", - reason: `prs_create_from_lane:${created.id}` - }); - return created; + return await ctx.prService.createFromLane(arg); }); ipcMain.handle(IPC.prsLinkToLane, async (_event, arg: LinkPrToLaneArgs): Promise => { const ctx = getCtx(); - const linked = await ctx.prService.linkToLane(arg); - triggerAutoContextDocs(ctx, { - event: "pr_create", - reason: `prs_link_to_lane:${linked.id}` - }); - return linked; + return await ctx.prService.linkToLane(arg); }); const ensurePrPolling = () => { @@ -5348,20 +5228,11 @@ export function registerIpc({ ipcMain.handle(IPC.prsUpdateDescription, async (_event, arg: UpdatePrDescriptionArgs): Promise => { const ctx = getCtx(); await ctx.prService.updateDescription(arg); - triggerAutoContextDocs(ctx, { - event: "pr_create", - reason: `prs_update_description:${arg.prId}` - }); }); ipcMain.handle(IPC.prsDelete, async (_event, arg: DeletePrArgs): Promise => { const ctx = getCtx(); - const deleted = await ctx.prService.delete(arg); - triggerAutoContextDocs(ctx, { - event: "pr_create", - reason: `prs_delete:${arg.prId}` - }); - return deleted; + return await ctx.prService.delete(arg); }); ipcMain.handle(IPC.prsDraftDescription, async (_event, arg: DraftPrDescriptionArgs): Promise<{ title: string; body: string }> => { @@ -5371,22 +5242,12 @@ export function registerIpc({ ipcMain.handle(IPC.prsLand, async (_event, arg: LandPrArgs): Promise => { const ctx = getCtx(); - const landed = await ctx.prService.land(arg); - triggerAutoContextDocs(ctx, { - event: "pr_land", - reason: `prs_land:${arg.prId}` - }); - return landed; + return await ctx.prService.land(arg); }); ipcMain.handle(IPC.prsLandStack, async (_event, arg: LandStackArgs): Promise => { const ctx = getCtx(); - const landed = await ctx.prService.landStack(arg); - triggerAutoContextDocs(ctx, { - event: "pr_land", - reason: `prs_land_stack:${arg.rootLaneId}` - }); - return landed; + return await ctx.prService.landStack(arg); }); ipcMain.handle(IPC.prsOpenInGitHub, async (_event, arg: { prId: string }): Promise => { @@ -5396,22 +5257,12 @@ export function registerIpc({ ipcMain.handle(IPC.prsCreateIntegration, async (_event, arg: CreateIntegrationPrArgs): Promise => { const ctx = getCtx(); - const created = await ctx.prService.createIntegrationPr(arg); - triggerAutoContextDocs(ctx, { - event: "pr_create", - reason: `prs_create_integration:${arg.integrationLaneName}:${arg.baseBranch}` - }); - return created; + return await ctx.prService.createIntegrationPr(arg); }); ipcMain.handle(IPC.prsLandStackEnhanced, async (_event, arg: LandStackEnhancedArgs): Promise => { const ctx = getCtx(); - const landed = await ctx.prService.landStackEnhanced(arg); - triggerAutoContextDocs(ctx, { - event: "pr_land", - reason: `prs_land_stack_enhanced:${arg.rootLaneId}` - }); - return landed; + return await ctx.prService.landStackEnhanced(arg); }); ipcMain.handle(IPC.prsGetConflictAnalysis, async (_event, arg: { prId: string }) => getCtx().prService.getConflictAnalysis(arg.prId)); @@ -5426,24 +5277,14 @@ export function registerIpc({ ipcMain.handle(IPC.prsCreateQueue, async (_event, arg: CreateQueuePrsArgs): Promise => { const ctx = getCtx(); - const created = await ctx.prService.createQueuePrs(arg); - triggerAutoContextDocs(ctx, { - event: "pr_create", - reason: `prs_create_queue:${arg.targetBranch ?? "queue"}` - }); - return created; + return await ctx.prService.createQueuePrs(arg); }); ipcMain.handle(IPC.prsSimulateIntegration, async (_event, arg: SimulateIntegrationArgs): Promise => getCtx().prService.simulateIntegration(arg)); ipcMain.handle(IPC.prsCommitIntegration, async (_event, arg: CommitIntegrationArgs): Promise => { const ctx = getCtx(); - const committed = await ctx.prService.commitIntegration(arg); - triggerAutoContextDocs(ctx, { - event: "pr_create", - reason: `prs_commit_integration:${arg.proposalId}:${arg.integrationLaneName}` - }); - return committed; + return await ctx.prService.commitIntegration(arg); }); ipcMain.handle(IPC.prsListProposals, async (): Promise => @@ -5472,34 +5313,19 @@ export function registerIpc({ ipcMain.handle(IPC.prsLandQueueNext, async (_event, arg: LandQueueNextArgs): Promise => { const ctx = getCtx(); - const landed = await ctx.prService.landQueueNext(arg); - triggerAutoContextDocs(ctx, { - event: "pr_land", - reason: `prs_land_queue_next:${arg.groupId}` - }); - return landed; + return await ctx.prService.landQueueNext(arg); }); ipcMain.handle(IPC.prsStartQueueAutomation, async (_event, arg) => { const ctx = getCtx(); - const state = await ctx.queueLandingService.startQueue(arg); - triggerAutoContextDocs(ctx, { - event: "pr_create", - reason: `prs_start_queue_automation:${arg.groupId}`, - }); - return state; + return await ctx.queueLandingService.startQueue(arg); }); ipcMain.handle(IPC.prsPauseQueueAutomation, async (_event, arg) => getCtx().queueLandingService.pauseQueue(arg.queueId)); ipcMain.handle(IPC.prsResumeQueueAutomation, async (_event, arg) => { const ctx = getCtx(); - const state = ctx.queueLandingService.resumeQueue(arg); - triggerAutoContextDocs(ctx, { - event: "pr_create", - reason: `prs_resume_queue_automation:${arg.queueId}`, - }); - return state; + return ctx.queueLandingService.resumeQueue(arg); }); ipcMain.handle(IPC.prsCancelQueueAutomation, async (_event, arg) => getCtx().queueLandingService.cancelQueue(arg.queueId)); diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index 742bed411..def36e16f 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -4252,7 +4252,7 @@ describe("aiOrchestratorService", () => { "You are an ADE orchestrator worker executing step \"implement-changes\".", "Mission goal: Ignore noisy worker runtime previews in chat.", "Mission Plan:", - "Referenced docs: .ade/context/PRD.ade.md (abc123), .ade/context/ARCHITECTURE.ade.md (def456)", + "Referenced docs: README.md (abc123), docs/architecture.md (def456)", "tool ade.report_result({\"workerId\":\"worker_1\"})", "\"text\": \"{\\n \\\"ok\\\": true }\"", "admin@Mac test1-30b1aa3d %", diff --git a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts index 257096996..7ac07c467 100644 --- a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts @@ -570,7 +570,12 @@ export function buildFullPrompt( } // ADE self-awareness - systemParts.push("You are working within ADE (Autonomous Development Environment), an Electron-based multi-agent development tool. ADE manages lanes (git worktrees), missions (task orchestration), PRs, and agent sessions. You have access to the project's full context including PRD and architecture docs when provided."); + systemParts.push( + [ + "You are working within ADE (Autonomous Development Environment), an Electron-based multi-agent development tool. ADE manages lanes (git worktrees), missions (task orchestration), PRs, and agent sessions.", + "Project orientation: do not assume generated PRD or architecture summaries exist. When you need product or architecture context, inspect the repo directly: start with AGENTS.md, README.md, docs/, package manifests, and the relevant source files in your lane worktree." + ].join("\n") + ); // ADE collaboration tools if (hasMissionTooling) { diff --git a/apps/desktop/src/main/services/orchestrator/missionLifecycle.ts b/apps/desktop/src/main/services/orchestrator/missionLifecycle.ts index 817ef404e..8acdaa343 100644 --- a/apps/desktop/src/main/services/orchestrator/missionLifecycle.ts +++ b/apps/desktop/src/main/services/orchestrator/missionLifecycle.ts @@ -307,8 +307,6 @@ export function discoverProjectDocs(ctx: OrchestratorContext): { const candidatePaths = [ ...new Set( [ - ".ade/context/PRD.ade.md", - ".ade/context/ARCHITECTURE.ade.md", "README.md", "CLAUDE.md", "AGENTS.md", diff --git a/apps/desktop/src/main/services/orchestrator/promptInspector.ts b/apps/desktop/src/main/services/orchestrator/promptInspector.ts index 8145fe9c7..e5bd1004e 100644 --- a/apps/desktop/src/main/services/orchestrator/promptInspector.ts +++ b/apps/desktop/src/main/services/orchestrator/promptInspector.ts @@ -208,7 +208,8 @@ function buildWorkerBaseGuidance(step: OrchestratorStep, graph: OrchestratorRunG ].join("\n"), ); sections.push( - "You are working within ADE (Autonomous Development Environment), an Electron-based multi-agent development tool. ADE manages lanes (git worktrees), missions (task orchestration), PRs, and agent sessions. You have access to the project's full context including PRD and architecture docs when provided.", + "You are working within ADE (Autonomous Development Environment), an Electron-based multi-agent development tool. ADE manages lanes (git worktrees), missions (task orchestration), PRs, and agent sessions.", + "Project orientation: do not assume generated PRD or architecture summaries exist. When you need product or architecture context, inspect the repo directly: start with AGENTS.md, README.md, docs/, package manifests, and the relevant source files in your lane worktree.", ); sections.push( [ diff --git a/apps/desktop/src/main/services/orchestrator/stepPolicyResolver.ts b/apps/desktop/src/main/services/orchestrator/stepPolicyResolver.ts index 7d54d21a8..3b59ee657 100644 --- a/apps/desktop/src/main/services/orchestrator/stepPolicyResolver.ts +++ b/apps/desktop/src/main/services/orchestrator/stepPolicyResolver.ts @@ -288,8 +288,6 @@ import fs from "node:fs"; const docPathsCache = new Map(); const DOC_PATHS_CACHE_TTL_MS = 60_000; const DOC_PRIORITY_REL_PATHS = [ - ".ade/context/PRD.ade.md", - ".ade/context/ARCHITECTURE.ade.md", "README.md", "CLAUDE.md", "AGENTS.md", @@ -360,8 +358,7 @@ export function readDocPaths(projectRoot: string): string[] { const inDocsDir = /(^|\/)docs\//i.test(rel); const hinted = DOC_FILE_NAME_HINT_RE.test(entry.name) - || inDocsDir - || rel.startsWith(".ade/context/"); + || inDocsDir; if (!hinted) continue; const normalized = path.normalize(abs); if (!prioritySet.has(normalized)) scannedSet.add(normalized); diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index cf4964a66..6f87bcc9c 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { randomBytes } from "node:crypto"; import net from "node:net"; import os from "node:os"; import path from "node:path"; @@ -785,6 +786,141 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { await client.close(); }); + it("chunks oversized mobile project catalog responses", async () => { + const brainDb = await openKvDb(makeDbPath("ade-sync-project-catalog-large-"), createLogger() as any); + const projectRoot = makeProjectRoot("ade-sync-project-catalog-large-project-"); + const workspaceRoot = path.join(projectRoot, "workspace"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + + const projects = Array.from({ length: 2_000 }, (_, index) => { + const entropy = randomBytes(256).toString("hex"); + return { + id: `project-${index}-${entropy.slice(0, 16)}`, + displayName: `Project ${index} ${entropy.slice(16, 80)}`, + rootPath: path.join(projectRoot, entropy), + defaultBaseRef: "main", + lastOpenedAt: "2026-04-22T12:00:00.000Z", + laneCount: index % 7, + isAvailable: true, + isCached: false, + isOpen: false, + }; + }); + + const host = createSyncHostService({ + db: brainDb, + logger: createLogger() as any, + projectRoot, + port: 0, + pinStore: createStubPinStore(), + fileService: createStubFileService(workspaceRoot) as any, + laneService: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn(), + archive: vi.fn(), + } as any, + prService: { + listAll: vi.fn().mockResolvedValue([]), + getDetail: vi.fn(), + getStatus: vi.fn(), + getChecks: vi.fn(), + getReviews: vi.fn(), + getComments: vi.fn(), + getFiles: vi.fn(), + createFromLane: vi.fn(), + land: vi.fn(), + closePr: vi.fn(), + requestReviewers: vi.fn(), + } as any, + sessionService: { + list: () => [], + get: () => null, + readTranscriptTail: async () => "", + } as any, + ptyService: { + create: vi.fn(), + enrichSessions: (rows: any[]) => rows, + } as any, + computerUseArtifactBrokerService: { + listArtifacts: () => [], + } as any, + projectCatalogProvider: { + listProjects: vi.fn(async () => ({ projects })), + prepareProjectConnection: vi.fn(), + }, + }); + activeDisposers.push(async () => { + await host.dispose(); + brainDb.close(); + }); + + const port = await host.waitUntilListening(); + const client = await connectClient({ + port, + token: host.getBootstrapToken(), + deviceId: "ios-phone-large-catalog", + deviceName: "Arul iPhone", + siteId: "ios-site-large-catalog", + dbVersion: 0, + platform: "iOS", + deviceType: "phone", + }); + + const helloPayload = client.helloOk.payload as { projects?: unknown[] }; + expect(helloPayload.projects).toEqual([]); + + client.ws.send(encodeSyncEnvelope({ + type: "project_catalog_request", + requestId: "catalog-large", + payload: {}, + })); + + const receivedProjects: unknown[] = []; + let chunkCount = 0; + let done = false; + while (!done) { + const chunk = await client.queue.next("project_catalog_chunk"); + expect(chunk.requestId).toBe("catalog-large"); + const payload = chunk.payload as { + index: number; + total: number; + done: boolean; + projects: unknown[]; + }; + chunkCount += 1; + done = payload.done; + expect(payload.index).toBe(chunkCount - 1); + expect(payload.total).toBeGreaterThan(1); + receivedProjects.push(...payload.projects); + } + expect(chunkCount).toBeGreaterThan(1); + expect(receivedProjects).toHaveLength(projects.length); + + await host.broadcastProjectCatalog(); + const broadcastProjects: unknown[] = []; + chunkCount = 0; + done = false; + while (!done) { + const chunk = await client.queue.next("project_catalog_chunk"); + expect(chunk.requestId).toBeNull(); + const payload = chunk.payload as { + index: number; + total: number; + done: boolean; + projects: unknown[]; + }; + chunkCount += 1; + done = payload.done; + expect(payload.index).toBe(chunkCount - 1); + expect(payload.total).toBeGreaterThan(1); + broadcastProjects.push(...payload.projects); + } + expect(chunkCount).toBeGreaterThan(1); + expect(broadcastProjects).toHaveLength(projects.length); + + await client.close(); + }, 30_000); + it("authenticates peers, relays CRDT changes, and rebroadcasts to other peers", async () => { const brainDb = await openKvDb(makeDbPath("ade-sync-brain-"), createLogger() as any); const dbA = await openKvDb(makeDbPath("ade-sync-peer-a-"), createLogger() as any); diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index ba29978e3..8f0771805 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -33,9 +33,11 @@ import type { SyncFileRequest, SyncFileResponsePayload, SyncHelloPayload, + SyncMobileProjectSummary, SyncPairingRequestPayload, SyncPeerConnectionState, SyncPeerMetadata, + SyncProjectCatalogChunkPayload, SyncProjectCatalogPayload, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload, @@ -392,6 +394,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const pollIntervalMs = Math.max(100, Math.floor(args.pollIntervalMs ?? DEFAULT_SYNC_POLL_INTERVAL_MS)); const brainStatusIntervalMs = Math.max(1_000, Math.floor(args.brainStatusIntervalMs ?? DEFAULT_BRAIN_STATUS_INTERVAL_MS)); const compressionThresholdBytes = Math.max(256, Math.floor(args.compressionThresholdBytes ?? DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES)); + const maxChangesetBatchBytes = 256 * 1024; + const maxChangesetBatchRows = 250; + const maxProjectCatalogEnvelopeBytes = 768 * 1024; + const maxProjectCatalogChunkBytes = 192 * 1024; const localPresenceCommandDescriptors: SyncRemoteCommandDescriptor[] = [ { action: "lanes.presence.announce", @@ -1113,6 +1119,80 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); } + function encodedEnvelopeBytes( + type: SyncEnvelope["type"], + payload: TPayload, + requestId?: string | null, + ): number { + return Buffer.byteLength(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes }), "utf8"); + } + + function closeExistingPeersForDevice(deviceId: string, currentPeer: PeerState): void { + const normalized = toOptionalString(deviceId); + if (!normalized) return; + for (const peer of peers) { + if (peer === currentPeer) continue; + if (peer.metadata?.deviceId !== normalized && peer.pairedDeviceId !== normalized) continue; + peer.authenticated = false; + peer.metadata = null; + peer.authKind = null; + peer.pairedDeviceId = null; + try { + peer.ws.close(4000, "Superseded by a newer connection for this device"); + } catch { + // ignore close failures + } + } + } + + function sendChangesetBatch( + peer: PeerState, + reason: SyncChangesetBatchPayload["reason"], + fromDbVersion: number, + toDbVersion: number, + changes: CrsqlChangeRow[], + ): void { + let chunk: CrsqlChangeRow[] = []; + let chunkFromDbVersion = fromDbVersion; + let chunkBytes = 0; + + const flush = (): void => { + if (chunk.length === 0) return; + const chunkToDbVersion = Math.max(...chunk.map((change) => Number(change.db_version ?? chunkFromDbVersion))); + send(peer.ws, "changeset_batch", { + reason, + fromDbVersion: chunkFromDbVersion, + toDbVersion: chunkToDbVersion, + changes: chunk, + }); + chunkFromDbVersion = chunkToDbVersion; + chunk = []; + chunkBytes = 0; + }; + + for (const change of changes) { + const changeBytes = Buffer.byteLength(JSON.stringify(change), "utf8"); + if ( + chunk.length > 0 + && (chunk.length >= maxChangesetBatchRows || chunkBytes + changeBytes > maxChangesetBatchBytes) + ) { + flush(); + } + chunk.push(change); + chunkBytes += changeBytes; + } + flush(); + + if (changes.length === 0 && toDbVersion > fromDbVersion) { + send(peer.ws, "changeset_batch", { + reason, + fromDbVersion, + toDbVersion, + changes: [], + }); + } + } + async function buildProjectCatalogPayload(): Promise { if (!args.projectCatalogProvider) { return { projects: [] }; @@ -1127,6 +1207,80 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } } + function splitProjectCatalog(projects: SyncMobileProjectSummary[]): SyncMobileProjectSummary[][] { + const chunks: SyncMobileProjectSummary[][] = []; + let chunk: SyncMobileProjectSummary[] = []; + let chunkBytes = 0; + + const flush = (): void => { + if (chunk.length === 0) return; + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + }; + + for (const project of projects) { + const projectBytes = Buffer.byteLength(JSON.stringify(project), "utf8"); + if (chunk.length > 0 && chunkBytes + projectBytes > maxProjectCatalogChunkBytes) { + flush(); + } + chunk.push(project); + chunkBytes += projectBytes; + } + flush(); + return chunks; + } + + function projectsForHello(projectCatalog: SyncProjectCatalogPayload): SyncMobileProjectSummary[] { + const payload = { + peer: readBrainMetadata(), + brain: readBrainMetadata(), + serverDbVersion: args.db.sync.getDbVersion(), + heartbeatIntervalMs, + pollIntervalMs, + projects: projectCatalog.projects, + features: {}, + }; + return encodedEnvelopeBytes("hello_ok", payload) <= maxProjectCatalogEnvelopeBytes + ? projectCatalog.projects + : []; + } + + function sendProjectCatalog( + peer: PeerState, + projectCatalog: SyncProjectCatalogPayload, + requestId?: string | null, + ): void { + if (encodedEnvelopeBytes("project_catalog", projectCatalog, requestId) <= maxProjectCatalogEnvelopeBytes) { + send(peer.ws, "project_catalog", projectCatalog, requestId); + return; + } + + const chunks = splitProjectCatalog(projectCatalog.projects); + const total = Math.max(1, chunks.length); + const catalogId = randomBytes(8).toString("hex"); + if (chunks.length === 0) { + send(peer.ws, "project_catalog_chunk", { + catalogId, + index: 0, + total, + done: true, + projects: [], + } satisfies SyncProjectCatalogChunkPayload, requestId); + return; + } + + chunks.forEach((projects, index) => { + send(peer.ws, "project_catalog_chunk", { + catalogId, + index, + total, + done: index === total - 1, + projects, + } satisfies SyncProjectCatalogChunkPayload, requestId); + }); + } + async function handleProjectSwitchRequest( peer: PeerState, requestId: string | null | undefined, @@ -1303,13 +1457,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { .exportChangesSince(peer.lastKnownServerDbVersion) .filter((change: CrsqlChangeRow) => change.site_id !== peer.metadata?.siteId); if (changes.length > 0) { - const payload: SyncChangesetBatchPayload = { - reason: "broadcast", - fromDbVersion: peer.lastKnownServerDbVersion, - toDbVersion: currentDbVersion, - changes, - }; - send(peer.ws, "changeset_batch", payload); + sendChangesetBatch(peer, "broadcast", peer.lastKnownServerDbVersion, currentDbVersion, changes); lastBroadcastAt = nowIso(); } peer.lastKnownServerDbVersion = currentDbVersion; @@ -1704,6 +1852,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return; } + closeExistingPeersForDevice(hello.peer.deviceId, peer); peer.authenticated = true; peer.metadata = hello.peer; const auth = hello.auth ?? { kind: "bootstrap", token: "" }; @@ -1722,7 +1871,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { serverDbVersion: args.db.sync.getDbVersion(), heartbeatIntervalMs, pollIntervalMs, - projects: projectCatalog.projects, + projects: projectsForHello(projectCatalog), features: { fileAccess: true, terminalStreaming: true, @@ -1758,7 +1907,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { switch (envelope.type) { case "project_catalog_request": { - send(peer.ws, "project_catalog", await buildProjectCatalogPayload(), envelope.requestId); + sendProjectCatalog(peer, await buildProjectCatalogPayload(), envelope.requestId); break; } case "project_switch_request": { @@ -2171,9 +2320,16 @@ export function createSyncHostService(args: SyncHostServiceArgs) { getPeerStates(): SyncPeerConnectionState[] { const dbVersion = args.db.sync.getDbVersion(); - return [...peers] + const latestByDevice = new Map(); + for (const peer of [...peers] .map((peer) => toSyncPeerConnectionState(peer, dbVersion)) - .filter((peer): peer is SyncPeerConnectionState => peer != null); + .filter((peer): peer is SyncPeerConnectionState => peer != null)) { + const existing = latestByDevice.get(peer.deviceId); + if (!existing || peer.connectedAt > existing.connectedAt) { + latestByDevice.set(peer.deviceId, peer); + } + } + return [...latestByDevice.values()]; }, getTailnetDiscoveryStatus(): SyncTailnetDiscoveryStatus { @@ -2204,7 +2360,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const payload = await buildProjectCatalogPayload(); for (const peer of peers) { if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; - send(peer.ws, "project_catalog", payload); + sendProjectCatalog(peer, payload); } }, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index e1f219c00..7c90ffd07 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -21,11 +21,6 @@ import type { ConflictProposal, ConflictExternalResolverRunSummary, ConflictProposalPreview, - ContextDocPrefs, - ContextGenerateDocsArgs, - ContextGenerateDocsResult, - ContextOpenDocArgs, - ContextStatus, ConflictEventPayload, ConflictOverlap, ConflictStatus, @@ -1355,16 +1350,6 @@ declare global { ) => Promise; onEvent: (cb: (ev: ConflictEventPayload) => void) => () => void; }; - context: { - getStatus: () => Promise; - generateDocs: ( - args: ContextGenerateDocsArgs, - ) => Promise; - openDoc: (args: ContextOpenDocArgs) => Promise; - getPrefs: () => Promise; - savePrefs: (prefs: ContextDocPrefs) => Promise; - onStatusChanged: (cb: (status: ContextStatus) => void) => () => void; - }; feedback: { prepareDraft: (args: FeedbackPrepareDraftArgs) => Promise; submitDraft: (args: FeedbackSubmitDraftArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e186ba956..246cdc2ea 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -132,11 +132,6 @@ import type { ConflictExternalResolverRunSummary, ConflictProposal, ConflictProposalPreview, - ContextDocPrefs, - ContextGenerateDocsArgs, - ContextGenerateDocsResult, - ContextOpenDocArgs, - ContextStatus, ConflictEventPayload, ConflictOverlap, ConflictStatus, @@ -1971,29 +1966,6 @@ contextBridge.exposeInMainWorld("ade", { return () => ipcRenderer.removeListener(IPC.conflictsEvent, listener); }, }, - context: { - getStatus: async (): Promise => - ipcRenderer.invoke(IPC.contextGetStatus), - generateDocs: async ( - args: ContextGenerateDocsArgs, - ): Promise => - ipcRenderer.invoke(IPC.contextGenerateDocs, args), - openDoc: async (args: ContextOpenDocArgs): Promise => - ipcRenderer.invoke(IPC.contextOpenDoc, args), - getPrefs: async (): Promise => - ipcRenderer.invoke(IPC.contextGetPrefs), - savePrefs: async (prefs: ContextDocPrefs): Promise => - ipcRenderer.invoke(IPC.contextSavePrefs, prefs), - onStatusChanged: (cb: (status: ContextStatus) => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: ContextStatus, - ) => cb(payload); - ipcRenderer.on(IPC.contextStatusChanged, listener); - return () => - ipcRenderer.removeListener(IPC.contextStatusChanged, listener); - }, - }, feedback: { prepareDraft: async (args: FeedbackPrepareDraftArgs): Promise => ipcRenderer.invoke(IPC.feedbackPrepareDraft, args), diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 4674bb0ab..955a0f741 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -26,7 +26,6 @@ import { useOnboardingStore } from "../../state/onboardingStore"; import { Button } from "../ui/Button"; import type { AiSettingsStatus, - ContextStatus, GitHubStatus, LinearWorkflowEventPayload, OnboardingStatus, @@ -45,11 +44,6 @@ import { getStoredZoomLevel, displayZoomToLevel } from "../../lib/zoom"; import { ONBOARDING_STATUS_UPDATED_EVENT } from "../../lib/onboardingStatusEvents"; import { logRendererDebugEvent } from "../../lib/debugLog"; import { cn } from "../ui/cn"; -import { - describeContextDocHealth, - listActionableContextDocs, - listContextDocsByHealth, -} from "../context/contextShared"; import { disposeTerminalRuntimesForProjectChange } from "../terminals/TerminalView"; import { buildPrsRouteSearch, type PrDetailRouteTab } from "../prs/prsRouteState"; @@ -238,17 +232,12 @@ export function AppShell({ children }: { children: React.ReactNode }) { const [onboardingStatus, setOnboardingStatus] = useState(null); const [onboardingStatusLoading, setOnboardingStatusLoading] = useState(false); - const [contextStatus, setContextStatus] = useState( - null, - ); // Banner dismissals live in the store so they can be pruned when projects close/switch // — AppShell used to own these as local state, which leaked entries across a long session. - const dismissedContextBannerRoots = useAppStore((s) => s.dismissedContextBannerRoots); const dismissedMissingAiBannerRoots = useAppStore((s) => s.dismissedMissingAiBannerRoots); const dismissedGithubBannerRoots = useAppStore((s) => s.dismissedGithubBannerRoots); const dismissMissingAiBanner = useAppStore((s) => s.dismissMissingAiBanner); const dismissGithubBanner = useAppStore((s) => s.dismissGithubBanner); - const dismissContextBanner = useAppStore((s) => s.dismissContextBanner); const [projectMissing, setProjectMissing] = useState(false); const [feedbackGenerating, setFeedbackGenerating] = useState(false); const previousProjectRootRef = useRef(undefined); @@ -653,7 +642,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { useEffect(() => { let cancelled = false; if (!project?.rootPath) { - setContextStatus(null); setAiStatus(null); setAiStatusLoaded(false); setGithubStatus(null); @@ -662,15 +650,11 @@ export function AppShell({ children }: { children: React.ReactNode }) { setAiStatusLoaded(false); const timer = window.setTimeout(() => { void Promise.allSettled([ - window.ade.context.getStatus(), window.ade.ai.getStatus(), window.ade.github.getStatus(), ]).then((results) => { if (cancelled) return; - const [contextResult, aiResult, githubResult] = results; - setContextStatus( - contextResult.status === "fulfilled" ? contextResult.value : null, - ); + const [aiResult, githubResult] = results; setAiStatus(aiResult.status === "fulfilled" ? aiResult.value : null); setAiStatusLoaded(true); setGithubStatus( @@ -684,15 +668,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { }; }, [project?.rootPath]); - useEffect(() => { - if (!project?.rootPath) return; - return ( - window.ade.context?.onStatusChanged?.((status) => { - setContextStatus(status); - }) ?? (() => {}) - ); - }, [project?.rootPath]); - useEffect(() => { if (!window.ade.feedback?.onUpdate) return; const dispose = window.ade.feedback.onUpdate((event) => { @@ -738,28 +713,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { return Boolean(runtimeOrLocal || (aiStatus.detectedAuth?.length ?? 0) > 0); }, [aiStatus]); - const missingContextDocs = useMemo( - () => listContextDocsByHealth(contextStatus, "missing"), - [contextStatus], - ); - - const actionableContextDocs = useMemo( - () => listActionableContextDocs(contextStatus), - [contextStatus], - ); - - const actionableContextSummary = useMemo( - () => - actionableContextDocs - .map((doc) => `${doc.label} (${describeContextDocHealth(doc)})`) - .join(", "), - [actionableContextDocs], - ); - - const missingContextSummary = useMemo( - () => missingContextDocs.map((doc) => doc.label).join(", "), - [missingContextDocs], - ); const currentProjectRoot = project?.rootPath ?? null; const missingAiBannerDismissed = Boolean( currentProjectRoot && dismissedMissingAiBannerRoots[currentProjectRoot], @@ -767,11 +720,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { const githubBannerDismissed = Boolean( currentProjectRoot && dismissedGithubBannerRoots[currentProjectRoot], ); - const contextBannerDismissed = Boolean( - currentProjectRoot && dismissedContextBannerRoots[currentProjectRoot], - ); - const generationState = contextStatus?.generation.state; - const commandPaletteBinding = useMemo( () => getEffectiveBinding(keybindings, "commandPalette.open", "Mod+K"), [keybindings], @@ -893,16 +841,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { staleCliNotice && currentProjectRoot ? `${currentProjectRoot}:${staleCliNotice.count}:${staleCliNotice.oldestStartedAt}` : null; - const showContextBanner = - !hideSidebar && - Boolean(project?.rootPath) && - !showWelcome && - !tourActive && - generationState !== "pending" && - generationState !== "running" && - actionableContextDocs.length > 0 && - !contextBannerDismissed; - return (
@@ -1066,66 +1004,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
) : null} - {!hideSidebar && - project?.rootPath && - !showWelcome && - !tourActive && - (contextStatus?.generation.state === "pending" || - contextStatus?.generation.state === "running") ? ( -
- Generating context docs...{" "} - - Open context settings - -
- ) : null} - {!hideSidebar && feedbackGenerating ? (
Generating feedback report...
) : null} - {!hideSidebar && - project?.rootPath && - !showWelcome && - !tourActive && - contextStatus?.generation.state === "failed" ? ( -
- Context doc generation failed - {contextStatus.generation.error - ? `: ${contextStatus.generation.error}` - : "."}{" "} - - Retry generation - -
- ) : null} - - {showContextBanner ? ( -
- - {missingContextDocs.length > 0 - ? `Missing ADE context docs: ${missingContextSummary}.` - : `ADE context docs need regeneration: ${actionableContextSummary}.`} - - Generate docs - - - -
- ) : null} -
{hideSidebar ? null : (
- - - {/* ── Generation Config ── */} -
-
Generate Context Docs
-
-
- Choose a model and configure which events trigger automatic regeneration. -
- - {/* Model selector */} -
-
Model
- navigate("/settings?tab=ai#ai-providers")} - className="w-full" - /> - {loadingModels ? ( -
Detecting configured models...
- ) : null} - {!loadingModels && !modelId.trim() ? ( -
- Auto-refresh stays idle until you select a model. -
- ) : null} -
- - {/* Auto refresh events */} -
-
Auto Refresh Events
-
- Toggle which events trigger automatic context doc regeneration. Changes save automatically. -
-
- {EVENT_TOGGLES.map((toggle) => { - const checked = !!events[toggle.key]; - return ( - - ); - })} -
-
- Higher frequency can increase token usage and cost. Use lightweight models for aggressive cadences. -
-
- - {/* Generate button + status */} -
-
- - - {generating ? ( -
- Context docs are being generated. This can take a while depending on your model. -
- ) : null} -
- - {genResult ? (() => { - const color = genResultColor(genResult); - return ( -
- {genResult} -
- ); - })() : null} - {genWarnings.length > 0 ? ( -
- {genWarnings.map((w, i) =>
{w}
)} -
- ) : null} - {genError ? ( -
- {genError} -
- ) : null} -
-
-
- - - - ); -} - -/* ═══════════════════════════════════════════════════════════════════════════ - Skill Files Section - ═══════════════════════════════════════════════════════════════════════════ */ - -function SkillFilesSection() { - const [skills, setSkills] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [reindexing, setReindexing] = React.useState(false); - - const loadSkills = React.useCallback(async () => { - setLoading(true); - try { - const entries = await window.ade.memory?.listIndexedSkills?.() ?? []; - setSkills(entries); - } catch { - setSkills([]); - } finally { - setLoading(false); - } - }, []); - - React.useEffect(() => { - void loadSkills(); - }, [loadSkills]); - - const handleReindex = React.useCallback(async () => { - setReindexing(true); - try { - const entries = await window.ade.memory?.reindexSkills?.({}) ?? []; - setSkills(entries); - } catch { - /* ignore — list stays as-is */ - } finally { - setReindexing(false); - } - }, []); - - return ( -
-
Skill Files
-
-
-
- Reusable instruction files and legacy command files that AI agents can reference. ADE indexes them for retrieval and dedupe, but - manages the files here instead of showing them as standalone entries in the generic Memory browser. Scanned from .ade/skills/, - .claude/skills/, .claude/commands/, CLAUDE.md, and agents.md. -
- -
- - {loading ? ( -
Loading skill files...
- ) : skills.length === 0 ? ( - - ) : ( - skills.map((skill) => ( -
-
-
- {skill.path} -
-
- {skill.kind} · {skill.source} · {relativeTime(skill.lastModifiedAt ?? skill.updatedAt)} -
-
- -
- )) - )} -
-
- ); -} diff --git a/apps/desktop/src/renderer/components/settings/GeneralSection.tsx b/apps/desktop/src/renderer/components/settings/GeneralSection.tsx index a01ef5080..2e92aa46e 100644 --- a/apps/desktop/src/renderer/components/settings/GeneralSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GeneralSection.tsx @@ -70,7 +70,7 @@ export function GeneralSection() { {setupComplete ? "Project setup completed" : onboardingStatus?.freshProject ? "Fresh project setup available" : "Project setup can be reopened"}
- The guided setup flow covers AI, GitHub, Linear, and context docs for fresh projects. You can reopen it any time if you want to walk through those steps again. + The guided setup flow covers AI, GitHub, Linear, and local helpers for fresh projects. You can reopen it any time if you want to walk through those steps again.
- ); } diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index a3d625855..d21f633ea 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -67,7 +67,6 @@ function resetStore() { laneWorkViewByScope: {}, dismissedMissingAiBannerRoots: {}, dismissedGithubBannerRoots: {}, - dismissedContextBannerRoots: {}, }); } @@ -550,7 +549,6 @@ describe("appStore", () => { useAppStore.setState({ dismissedMissingAiBannerRoots: { "/p/a": true, "/p/b": true, "/p/c": true }, dismissedGithubBannerRoots: { "/p/a": true, "/p/b": true }, - dismissedContextBannerRoots: { "/p/c": true }, } as any); const nextProject = { rootPath: "/p/a", displayName: "A", baseRef: "main" } as any; @@ -562,7 +560,7 @@ describe("appStore", () => { await useAppStore.getState().switchProjectToPath("/p/a"); - // `/p/c` was neither active nor in recents → pruned from all three maps. + // `/p/c` was neither active nor in recents → pruned from all banner maps. expect(useAppStore.getState().dismissedMissingAiBannerRoots).toEqual({ "/p/a": true, "/p/b": true, @@ -571,7 +569,6 @@ describe("appStore", () => { "/p/a": true, "/p/b": true, }); - expect(useAppStore.getState().dismissedContextBannerRoots).toEqual({}); }); it("clears all banner-dismiss maps when the project is closed", async () => { @@ -579,14 +576,12 @@ describe("appStore", () => { project: { rootPath: "/p/x" } as any, dismissedMissingAiBannerRoots: { "/p/x": true, "/p/y": true }, dismissedGithubBannerRoots: { "/p/x": true }, - dismissedContextBannerRoots: { "/p/y": true }, } as any); await useAppStore.getState().closeProject(); expect(useAppStore.getState().dismissedMissingAiBannerRoots).toEqual({}); expect(useAppStore.getState().dismissedGithubBannerRoots).toEqual({}); - expect(useAppStore.getState().dismissedContextBannerRoots).toEqual({}); }); it("dismiss setters append to the session-scoped map without touching other keys", () => { diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 4716bf20e..66eb336ee 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -472,7 +472,6 @@ type AppState = { /** Session-scoped banner dismissals. Pruned when a project is closed/switched so the maps don't leak. */ dismissedMissingAiBannerRoots: SessionDismissMap; dismissedGithubBannerRoots: SessionDismissMap; - dismissedContextBannerRoots: SessionDismissMap; setProject: (project: ProjectInfo | null) => void; setProjectHydrated: (hydrated: boolean) => void; @@ -518,7 +517,6 @@ type AppState = { refreshKeybindings: () => Promise; dismissMissingAiBanner: (projectRoot: string) => void; dismissGithubBanner: (projectRoot: string) => void; - dismissContextBanner: (projectRoot: string) => void; openNewTab: () => void; cancelNewTab: () => void; @@ -616,7 +614,6 @@ export const useAppStore = create((set, get) => ({ laneWorkViewByScope: initialPersistedWorkViews.laneWorkViewByScope, dismissedMissingAiBannerRoots: {}, dismissedGithubBannerRoots: {}, - dismissedContextBannerRoots: {}, setProject: (project) => set((prev) => { @@ -912,14 +909,6 @@ export const useAppStore = create((set, get) => ({ dismissedGithubBannerRoots: { ...prev.dismissedGithubBannerRoots, [key]: true }, })); }, - dismissContextBanner: (projectRoot) => { - const key = normalizeProjectKey(projectRoot); - if (!key) return; - set((prev) => ({ - dismissedContextBannerRoots: { ...prev.dismissedContextBannerRoots, [key]: true }, - })); - }, - openRepo: async () => { // Invalidate in-flight lane refreshes before the async open so stale // responses from the previous project are discarded immediately. @@ -955,7 +944,6 @@ export const useAppStore = create((set, get) => ({ terminalAttention: EMPTY_TERMINAL_ATTENTION, dismissedMissingAiBannerRoots: pickDismissMapForRoots(prev.dismissedMissingAiBannerRoots, [project.rootPath]), dismissedGithubBannerRoots: pickDismissMapForRoots(prev.dismissedGithubBannerRoots, [project.rootPath]), - dismissedContextBannerRoots: pickDismissMapForRoots(prev.dismissedContextBannerRoots, [project.rootPath]), })); invalidateAiDiscoveryCache(project.rootPath); invalidateProjectConfigCache(project.rootPath); @@ -1040,7 +1028,6 @@ export const useAppStore = create((set, get) => ({ laneWorkViewByScope: nextLaneWorkViews, dismissedMissingAiBannerRoots: pickDismissMapForRoots(prev.dismissedMissingAiBannerRoots, retainedRoots), dismissedGithubBannerRoots: pickDismissMapForRoots(prev.dismissedGithubBannerRoots, retainedRoots), - dismissedContextBannerRoots: pickDismissMapForRoots(prev.dismissedContextBannerRoots, retainedRoots), }; }); } catch (error) { @@ -1084,7 +1071,6 @@ export const useAppStore = create((set, get) => ({ // No active project: drop every dismiss entry so reopening the same project later starts with a clean slate. dismissedMissingAiBannerRoots: {}, dismissedGithubBannerRoots: {}, - dismissedContextBannerRoots: {}, }); } catch (error) { set({ diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 616ecd99d..1ca5a0aec 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -1,10 +1,12 @@ export const ADE_CLI_AGENT_GUIDANCE = [ "## ADE CLI", - "`ade` should be available in this ADE-managed session for internal ADE work: lanes, missions, PRs, chats/sessions, memory, proof, config, and process state.", + "`ade` is the default control plane for ADE-managed sessions: lanes, missions, PRs, chats/sessions, memory, proof, config, and process state.", "If `command -v ade` fails, try `${ADE_CLI_PATH:-}` when set, then `${ADE_CLI_BIN_DIR:-}/ade`, and in an ADE source checkout fall back to `node apps/ade-cli/dist/cli.cjs ...` after confirming the file exists.", - "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 ...`.", + "The only normal reason to skip ADE CLI for an ADE action is that the user truly does not have it installed or reachable after those fallbacks.", + "For ADE work beyond the immediate local edit, shell command, or repository inspection in front of you, check ADE CLI 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 ...`.", + "When the user asks you to capture, send, attach, or provide proof, use whatever computer-use or browser tool is appropriate to produce the evidence, then register it with ADE via `ade proof ...` so it appears in the ADE proof drawer for the active chat, mission, or lane.", "When you run processes of any kind, track what you started and clean up old, stale, or finished processes before leaving the task.", ].join("\n"); export const ADE_CLI_INLINE_GUIDANCE = - "`ade` should be available for ADE tasks. If `command -v ade` fails, try `${ADE_CLI_PATH:-}`, then `${ADE_CLI_BIN_DIR:-}/ade`, and in an ADE source checkout `node apps/ade-cli/dist/cli.cjs ...` after confirming it exists. 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 ...`. When you run processes of any kind, track what you started and clean up old, stale, or finished processes before leaving the task."; + "`ade` is the default control plane for ADE-managed sessions: lanes, missions, PRs, chats/sessions, memory, proof, config, and process state. If `command -v ade` fails, try `${ADE_CLI_PATH:-}`, then `${ADE_CLI_BIN_DIR:-}/ade`, and in an ADE source checkout `node apps/ade-cli/dist/cli.cjs ...` after confirming it exists. The only normal reason to skip ADE CLI for an ADE action is that the user truly does not have it installed or reachable after those fallbacks. For ADE work beyond the immediate local edit, shell command, or repository inspection in front of you, check ADE CLI first: try `ade doctor`, typed `ade ... --text` commands, or `ade actions list --text` / `ade actions run ...`. When the user asks you to capture, send, attach, or provide proof, use whatever computer-use or browser tool is appropriate to produce the evidence, then register it with ADE via `ade proof ...` so it appears in the ADE proof drawer for the active chat, mission, or lane. When you run processes of any kind, track what you started and clean up old, stale, or finished processes before leaving the task."; diff --git a/apps/desktop/src/shared/contextContract.ts b/apps/desktop/src/shared/contextContract.ts index 047096f0e..1189e8e5e 100644 --- a/apps/desktop/src/shared/contextContract.ts +++ b/apps/desktop/src/shared/contextContract.ts @@ -12,8 +12,6 @@ export const ADE_TASK_SPEC_END = ""; // Machine-readable header schema embedded in packs/exports as a JSON fence. export const CONTEXT_HEADER_SCHEMA_V1 = "ade.context.v1" as const; -export const ADE_CONTEXT_DOC_STATUS_SCHEMA_V1 = "ade.contextDocStatus.v1" as const; -export const ADE_CONTEXT_DOC_RUN_SCHEMA_V1 = "ade.contextDocRun.v1" as const; export const ADE_CONFLICT_EXTERNAL_RUN_SCHEMA_V1 = "ade.conflictExternalRun.v1" as const; // Contract version is an advisory monotonic counter for backward-compatible additions. diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 4a8ae53ab..4f4f84515 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -235,12 +235,6 @@ export const IPC = { conflictsCancelResolverSession: "ade.conflicts.cancelResolverSession", conflictsSuggestResolverTarget: "ade.conflicts.suggestResolverTarget", conflictsEvent: "ade.conflicts.event", - contextGetStatus: "ade.context.getStatus", - contextStatusChanged: "ade.context.statusChanged", - contextGenerateDocs: "ade.context.generateDocs", - contextOpenDoc: "ade.context.openDoc", - contextGetPrefs: "ade.context.getPrefs", - contextSavePrefs: "ade.context.savePrefs", automationsList: "ade.automations.list", automationsToggle: "ade.automations.toggle", automationsDeleteRule: "ade.automations.deleteRule", diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 86ad84a35..6abadbcbe 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -1306,8 +1306,6 @@ export type ProjectConfigFile = { laneCleanup?: LaneCleanupConfig; providers?: Record; linearSync?: LinearSyncConfig; - /** Event-based checklist for context doc auto-regeneration */ - contextRefreshEvents?: import("./packs").ContextRefreshEvents; /** Mobile push notification configuration (APNs). */ notifications?: NotificationsConfig; }; diff --git a/apps/desktop/src/shared/types/packs.ts b/apps/desktop/src/shared/types/packs.ts index 0508bfb80..ef6615f92 100644 --- a/apps/desktop/src/shared/types/packs.ts +++ b/apps/desktop/src/shared/types/packs.ts @@ -13,7 +13,6 @@ export type ContextExportLevel = "lite" | "standard" | "deep"; export type OrchestratorContextProfileId = string; -export type OrchestratorContextDocsMode = string; // Event metadata (standardized keys embedded into PackEvent.payload for selection/digests). export type PackEventImportance = "low" | "medium" | "high"; @@ -366,132 +365,3 @@ export type Checkpoint = { packEventIds: string[]; createdAt: string; }; - -// -------------------------------- -// Context Status & Inventory -// -------------------------------- - -export type ContextDocStatus = { - id: "prd_ade" | "architecture_ade"; - label: string; - preferredPath: string; - exists: boolean; - sizeBytes: number; - updatedAt: string | null; - fingerprint: string | null; - staleReason: string | null; - fallbackCount: number; - health: ContextDocHealth; - source: ContextDocOutputSource; -}; - -export type ContextDocHealth = "missing" | "incomplete" | "fallback" | "stale" | "ready"; - -export type ContextDocOutputSource = "ai" | "deterministic" | "previous_good"; - -export type ContextDocGenerationWarning = { - code: string; - message: string; - actionLabel?: string; - actionPath?: string; -}; - -export type ContextDocGenerationSource = "manual" | "auto"; - -export type ContextDocGenerationEvent = - | "session_end" - | "commit" - | "pr_create" - | "pr_land" - | "mission_start" - | "mission_end" - | "lane_create"; - -export type ContextDocGenerationStatus = { - state: "idle" | "pending" | "running" | "succeeded" | "failed"; - requestedAt: string | null; - startedAt: string | null; - finishedAt: string | null; - error: string | null; - source: ContextDocGenerationSource | null; - event: ContextDocGenerationEvent | null; - reason: string | null; - provider: ContextDocProvider | null; - modelId: string | null; - reasoningEffort: string | null; -}; - -export type ContextStatus = { - docs: ContextDocStatus[]; - canonicalDocsPresent: number; - canonicalDocsScanned: number; - canonicalDocsFingerprint: string; - canonicalDocsUpdatedAt: string | null; - projectExportFingerprint: string | null; - projectExportUpdatedAt: string | null; - contextManifestRefs: { - project: string | null; - packs: string | null; - transcripts: string | null; - }; - fallbackWrites: number; - insufficientContextCount: number; - warnings: ContextDocGenerationWarning[]; - generation: ContextDocGenerationStatus; -}; - -export type ContextDocProvider = "codex" | "claude" | "opencode"; - -/** @deprecated Use ContextRefreshEvents instead */ -export type ContextRefreshTrigger = "manual" | "per_mission" | "per_pr" | "per_lane_refresh"; - -/** Event-based checklist for context doc regeneration. Users toggle which events trigger regen. */ -export type ContextRefreshEvents = { - onSessionEnd?: boolean; - onCommit?: boolean; - onPrCreate?: boolean; - onPrLand?: boolean; - onMissionStart?: boolean; - onMissionEnd?: boolean; - onLaneCreate?: boolean; -}; - -/** Saved preferences for context doc generation (model, effort, events). */ -export type ContextDocPrefs = { - provider: ContextDocProvider; - modelId: string | null; - reasoningEffort: string | null; - events: ContextRefreshEvents; -}; - -export type ContextGenerateDocsArgs = { - provider?: ContextDocProvider; - modelId?: string; - reasoningEffort?: string | null; - /** @deprecated Use events instead */ - trigger?: ContextRefreshTrigger; - events?: ContextRefreshEvents; - force?: boolean; -}; - -export type ContextGenerateDocsResult = { - provider: ContextDocProvider; - generatedAt: string; - prdPath: string; - architecturePath: string; - usedFallbackPath: boolean; - degraded: boolean; - docResults: Array<{ - id: ContextDocStatus["id"]; - health: ContextDocHealth; - source: ContextDocOutputSource; - sizeBytes: number; - }>; - warnings: ContextDocGenerationWarning[]; - outputPreview: string; -}; - -export type ContextOpenDocArgs = { - docId?: ContextDocStatus["id"]; - path?: string; -}; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 108a68fe3..89c61296c 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -234,6 +234,14 @@ export type SyncProjectCatalogPayload = { projects: SyncMobileProjectSummary[]; }; +export type SyncProjectCatalogChunkPayload = { + catalogId: string; + index: number; + total: number; + done: boolean; + projects: SyncMobileProjectSummary[]; +}; + export type SyncProjectSwitchRequestPayload = { projectId?: string | null; rootPath?: string | null; @@ -853,6 +861,7 @@ export type SyncHelloOkEnvelope = SyncEnvelopeWithPayload<"hello_ok", SyncHelloO export type SyncHelloErrorEnvelope = SyncEnvelopeWithPayload<"hello_error", SyncHelloErrorPayload>; export type SyncProjectCatalogRequestEnvelope = SyncEnvelopeWithPayload<"project_catalog_request", Record>; export type SyncProjectCatalogEnvelope = SyncEnvelopeWithPayload<"project_catalog", SyncProjectCatalogPayload>; +export type SyncProjectCatalogChunkEnvelope = SyncEnvelopeWithPayload<"project_catalog_chunk", SyncProjectCatalogChunkPayload>; export type SyncProjectSwitchRequestEnvelope = SyncEnvelopeWithPayload<"project_switch_request", SyncProjectSwitchRequestPayload>; export type SyncProjectSwitchResultEnvelope = SyncEnvelopeWithPayload<"project_switch_result", SyncProjectSwitchResultPayload>; export type SyncPairingRequestEnvelope = SyncEnvelopeWithPayload<"pairing_request", SyncPairingRequestPayload>; @@ -886,6 +895,7 @@ export type SyncEnvelope = | SyncHelloErrorEnvelope | SyncProjectCatalogRequestEnvelope | SyncProjectCatalogEnvelope + | SyncProjectCatalogChunkEnvelope | SyncProjectSwitchRequestEnvelope | SyncProjectSwitchResultEnvelope | SyncPairingRequestEnvelope diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 4cfaf52c6..9279d1f8a 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -105,6 +105,14 @@ struct MobileProjectCatalogPayload: Codable, Equatable { var projects: [MobileProjectSummary] } +struct MobileProjectCatalogChunkPayload: Codable, Equatable { + var catalogId: String + var index: Int + var total: Int + var done: Bool + var projects: [MobileProjectSummary] +} + struct MobileProjectConnectionPayload: Codable, Equatable { var authKind: String var token: String? diff --git a/apps/ios/ADE/Services/KeychainService.swift b/apps/ios/ADE/Services/KeychainService.swift index d48317d26..1cbd6eb1a 100644 --- a/apps/ios/ADE/Services/KeychainService.swift +++ b/apps/ios/ADE/Services/KeychainService.swift @@ -6,8 +6,18 @@ final class KeychainService { private let tokenAccount = "connection-token" private let deviceIdAccount = "device-id" - private func tokenAccount(forHostKey hostKey: String) -> String { - "connection-token.\(hostKey)" + private func tokenAccount(for hostKey: String?) -> String { + guard let hostKey, !hostKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return tokenAccount + } + return "\(tokenAccount):\(hostKey.trimmingCharacters(in: .whitespacesAndNewlines))" + } + + private func legacyTokenAccount(for hostKey: String?) -> String? { + guard let hostKey else { return nil } + let trimmed = hostKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return "\(tokenAccount).\(trimmed)" } private func saveString(_ value: String, account: String) { @@ -54,24 +64,34 @@ final class KeychainService { saveString(token, account: tokenAccount) } - func saveToken(_ token: String, forHostKey hostKey: String) { - saveString(token, account: tokenAccount(forHostKey: hostKey)) + func saveToken(_ token: String, hostKey: String?) { + saveString(token, account: tokenAccount(for: hostKey)) } func loadToken() -> String? { loadString(account: tokenAccount) } - func loadToken(forHostKey hostKey: String) -> String? { - loadString(account: tokenAccount(forHostKey: hostKey)) + func loadToken(hostKey: String?) -> String? { + let newAccount = tokenAccount(for: hostKey) + if let token = loadString(account: newAccount) { + return token + } + if let legacyAccount = legacyTokenAccount(for: hostKey), + let migrated = loadString(account: legacyAccount) { + saveString(migrated, account: newAccount) + clearString(account: legacyAccount) + return migrated + } + return nil } func clearToken() { clearString(account: tokenAccount) } - func clearToken(forHostKey hostKey: String) { - clearString(account: tokenAccount(forHostKey: hostKey)) + func clearToken(hostKey: String?) { + clearString(account: tokenAccount(for: hostKey)) } func saveDeviceId(_ deviceId: String) { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index ce7c4fb32..6f35aaa20 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -396,6 +396,11 @@ private func syncLogProfileSummary(_ profile: HostConnectionProfile) -> String { ].joined(separator: " ") } +private func syncIsMessageTooLongError(_ error: Error) -> Bool { + let message = (error as NSError).localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return message.contains("message too long") +} + func syncShouldRoamToTailnet( currentAddress: String?, hasTailnetRoute: Bool, @@ -483,6 +488,9 @@ enum SyncUserFacingError { if lowered.contains("unable to start gzip decoder") || lowered.contains("unable to decode compressed sync payload") { return "The host sent unreadable sync data. Reconnect and try again." } + if lowered.contains("message too long") { + return "The desktop sent too much sync data in one message. Update ADE on the desktop, then reconnect." + } return rawMessage } @@ -670,6 +678,7 @@ final class SyncService: ObservableObject { private(set) var deviceId: String private var remoteCommandDescriptors: [SyncRemoteCommandDescriptor] = [] private var remoteProjectCatalog: [MobileProjectSummary] = [] + private var pendingProjectCatalogChunks: [String: [Int: [MobileProjectSummary]]] = [:] private var supportsProjectCatalog = false private var supportsChatStreaming = false private var projectSelectionTask: Task? @@ -949,6 +958,25 @@ final class SyncService: ObservableObject { refreshProjectCatalog(preferRemoteSelection: true) } + private func applyRemoteProjectCatalogChunk( + _ chunk: MobileProjectCatalogChunkPayload, + requestId: String? + ) { + guard chunk.index >= 0, chunk.total > 0, chunk.index < chunk.total else { return } + var chunks = pendingProjectCatalogChunks[chunk.catalogId] ?? [:] + chunks[chunk.index] = chunk.projects + pendingProjectCatalogChunks[chunk.catalogId] = chunks + + guard chunks.count == chunk.total else { return } + let projects = (0.. HostConnectionProfile? { if let data = UserDefaults.standard.data(forKey: profileKey), let profile = try? decoder.decode(HostConnectionProfile.self, from: data) { - upsertKnownProfile(profile) + migrateTokenIfNeeded(for: profile) return profile } guard let data = UserDefaults.standard.data(forKey: legacyDraftKey), @@ -1384,95 +1413,70 @@ final class SyncService: ObservableObject { } private func profileStorageKey(_ profile: HostConnectionProfile) -> String? { - if let identity = profile.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines), !identity.isEmpty { - return "device:\(identity.lowercased())" + [ + profile.hostIdentity, + profile.lastHostDeviceId, + profile.hostName.map { "\($0):\(profile.port)" }, + profile.lastSuccessfulAddress.map { "\($0):\(profile.port)" }, + ] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } + } + + private func loadSavedProfilesRaw() -> [String: HostConnectionProfile] { + guard let data = UserDefaults.standard.data(forKey: profilesKey) else { + return [:] } - if let lastSuccessfulAddress = profile.lastSuccessfulAddress, - let host = syncEndpointHost(lastSuccessfulAddress)?.lowercased(), - !host.isEmpty { - return "route:\(host):\(profile.port)" + if let decoded = try? decoder.decode([String: HostConnectionProfile].self, from: data) { + return decoded } - if let hostName = profile.hostName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !hostName.isEmpty { - return "name:\(hostName):\(profile.port)" + if let legacyArray = try? decoder.decode([HostConnectionProfile].self, from: data) { + var migrated: [String: HostConnectionProfile] = [:] + for profile in legacyArray { + guard let key = profileStorageKey(profile) else { continue } + migrated[key] = profile + } + syncConnectLog.warning("Migrated \(legacyArray.count, privacy: .public) legacy array-format host profiles to dict format (\(migrated.count, privacy: .public) keyed)") + saveSavedProfiles(migrated) + return migrated } - return nil + return [:] } - private func loadKnownProfiles() -> [HostConnectionProfile] { - guard let data = UserDefaults.standard.data(forKey: profilesKey), - let profiles = try? decoder.decode([HostConnectionProfile].self, from: data) else { - return [] + private func loadSavedProfiles() -> [String: HostConnectionProfile] { + var profiles = loadSavedProfilesRaw() + if let active = loadProfile(), let key = profileStorageKey(active), profiles[key] == nil { + profiles[key] = active + saveSavedProfiles(profiles) } - return deduplicatedProfiles(profiles) + return profiles } - private func saveKnownProfiles(_ profiles: [HostConnectionProfile]) { - let normalized = deduplicatedProfiles(profiles) - if normalized.isEmpty { + private func saveSavedProfiles(_ profiles: [String: HostConnectionProfile]) { + if profiles.isEmpty { UserDefaults.standard.removeObject(forKey: profilesKey) return } - if let data = try? encoder.encode(normalized) { + if let data = try? encoder.encode(profiles) { UserDefaults.standard.set(data, forKey: profilesKey) } } - private func deduplicatedProfiles(_ profiles: [HostConnectionProfile]) -> [HostConnectionProfile] { - var byKey: [String: HostConnectionProfile] = [:] - var anonymous: [HostConnectionProfile] = [] - for profile in profiles { - guard let key = profileStorageKey(profile) else { - anonymous.append(profile) - continue - } - if let existing = byKey[key] { - byKey[key] = shouldPreferProfile(profile, over: existing) ? profile : existing - } else { - byKey[key] = profile - } - } - return (Array(byKey.values) + anonymous).sorted { left, right in - left.updatedAt > right.updatedAt - } - } - - private func shouldPreferProfile(_ candidate: HostConnectionProfile, over existing: HostConnectionProfile) -> Bool { - if candidate.updatedAt != existing.updatedAt { - return candidate.updatedAt > existing.updatedAt - } - if candidate.tailscaleAddress != nil && existing.tailscaleAddress == nil { - return true - } - return candidate.lastSuccessfulAddress != nil && existing.lastSuccessfulAddress == nil - } - - private func upsertKnownProfile(_ profile: HostConnectionProfile) { - guard let key = profileStorageKey(profile) else { return } - let existing = loadKnownProfiles().filter { candidate in - if profileStorageKey(candidate) == key { return false } - if let newIdentity = profile.hostIdentity, candidate.hostIdentity == newIdentity { - return false - } - return true - } - saveKnownProfiles([profile] + existing) - if let token = keychain.loadToken() { - keychain.saveToken(token, forHostKey: key) + private func migrateTokenIfNeeded(for profile: HostConnectionProfile) { + guard let key = profileStorageKey(profile), + keychain.loadToken(hostKey: key) == nil, + let legacyToken = keychain.loadToken() else { + return } + keychain.saveToken(legacyToken, hostKey: key) } - private func removeKnownProfile(_ profile: HostConnectionProfile) { - guard let key = profileStorageKey(profile) else { return } - saveKnownProfiles(loadKnownProfiles().filter { profileStorageKey($0) != key }) - keychain.clearToken(forHostKey: key) - } - - private func token(for profile: HostConnectionProfile) -> String? { - if let key = profileStorageKey(profile), - let token = keychain.loadToken(forHostKey: key) { + private func tokenForProfile(_ profile: HostConnectionProfile?) -> String? { + guard let profile else { return nil } + if let key = profileStorageKey(profile), let token = keychain.loadToken(hostKey: key) { return token } - if let activeHostProfile, profile == activeHostProfile { + if activeHostProfile == profile { return keychain.loadToken() } return nil @@ -1483,21 +1487,25 @@ final class SyncService: ObservableObject { } var canReconnectToSavedHost: Bool { - guard let profile = activeHostProfile else { return false } - return token(for: profile) != nil + tokenForProfile(activeHostProfile) != nil } var savedReconnectHost: DiscoveredSyncHost? { - savedReconnectHosts.first + guard let profile = activeHostProfile ?? loadProfile(), + tokenForProfile(profile) != nil else { + return nil + } + return discoveredHost(fromSavedProfile: profile) } var savedReconnectHosts: [DiscoveredSyncHost] { - loadKnownProfiles() - .filter { token(for: $0) != nil } - .compactMap { savedReconnectHost(for: $0) } + let profiles = loadSavedProfiles().values + .filter { tokenForProfile($0) != nil } + .sorted { lhs, rhs in lhs.updatedAt > rhs.updatedAt } + return profiles.compactMap(discoveredHost(fromSavedProfile:)) } - private func savedReconnectHost(for profile: HostConnectionProfile) -> DiscoveredSyncHost? { + private func discoveredHost(fromSavedProfile profile: HostConnectionProfile) -> DiscoveredSyncHost? { let tailscaleAddress = profile.tailscaleAddress ?? profile.savedAddressCandidates.first(where: syncIsTailscaleRoute) @@ -1525,6 +1533,27 @@ final class SyncService: ObservableObject { ) } + func reconnect(toSavedHost host: DiscoveredSyncHost, preferTailnet: Bool = false) async { + let profiles = loadSavedProfiles() + let candidates = profiles.values.filter { profile in + if let identity = host.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines), !identity.isEmpty { + return profile.hostIdentity == identity || profile.lastHostDeviceId == identity + } + if host.id.hasPrefix("saved-"), let key = profileStorageKey(profile) { + return host.id == "saved-\(key)" + } + return matchesDiscoveredHost(host, profile: profile) + } + guard let profile = candidates.sorted(by: { $0.updatedAt > $1.updatedAt }).first, + tokenForProfile(profile) != nil else { + lastError = "This saved computer no longer has pairing credentials. Pair again from Settings." + connectionState = .error + return + } + saveProfile(profile) + await reconnectIfPossible(userInitiated: true, preferTailnet: preferTailnet) + } + func reconnectIfPossible(userInitiated: Bool = false, preferTailnet: Bool = false) async { do { try ensureDatabaseReady() @@ -1555,8 +1584,7 @@ final class SyncService: ObservableObject { return } allowAutoReconnect = true - guard let profile = loadProfile(), let token = token(for: profile) else { return } - keychain.saveToken(token) + guard let profile = loadProfile(), let token = tokenForProfile(profile) else { return } if preferTailnet || (userInitiated && shouldPreferTailnetForUserReconnect(profile)) { preferTailnetForUpcomingReconnect() } @@ -1612,7 +1640,7 @@ final class SyncService: ObservableObject { } func reconnectToSavedHost(_ host: DiscoveredSyncHost, preferTailnet: Bool = false) async { - guard let profile = profile(forSavedHost: host), let token = token(for: profile) else { + guard let profile = profile(forSavedHost: host), let token = tokenForProfile(profile) else { lastError = "That saved host is missing pairing credentials. Pair it again from Settings." connectionState = .error return @@ -1625,7 +1653,7 @@ final class SyncService: ObservableObject { private func profile(forSavedHost host: DiscoveredSyncHost) -> HostConnectionProfile? { let normalizedHostId = host.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let hostAddresses = Set((host.addresses + (host.tailscaleAddress.map { [$0] } ?? [])).map(syncNormalizedRouteHost)) - return loadKnownProfiles().first { profile in + return loadSavedProfiles().values.first { profile in if let normalizedHostId, let profileIdentity = profile.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), profileIdentity == normalizedHostId { @@ -1849,7 +1877,26 @@ final class SyncService: ObservableObject { throw NSError(domain: "ADE", code: 3, userInfo: [NSLocalizedDescriptionKey: "Pairing secret missing from response."]) } let pairedDeviceId = payload["deviceId"] as? String ?? deviceId + let profile = HostConnectionProfile( + hostIdentity: hostIdentity, + hostName: hostName, + port: preferredPort, + authKind: "paired", + pairedDeviceId: pairedDeviceId, + lastRemoteDbVersion: 0, + lastHostDeviceId: nil, + lastSuccessfulAddress: preferredAddress, + savedAddressCandidates: addressCandidates, + discoveredLanAddresses: addressCandidates.filter { host in + guard !host.contains(":") else { return false } + if host == "127.0.0.1" { return false } + return !syncIsTailscaleRoute(host) + }, + tailscaleAddress: normalizedTailscaleAddress ?? addressCandidates.first(where: syncIsTailscaleRoute) + ) keychain.saveToken(secret) + keychain.saveToken(secret, hostKey: profileStorageKey(profile)) + saveProfile(profile) currentAddress = preferredAddress try await hello( host: preferredAddress, @@ -1929,8 +1976,12 @@ final class SyncService: ObservableObject { outboundLocalDbVersion = database.currentDbVersion() setDomainStatus(SyncDomain.allCases, phase: .disconnected) if clearCredentials { - if let profile = activeHostProfile { - removeKnownProfile(profile) + let profileToClear = activeHostProfile ?? loadProfile() + if let key = profileToClear.flatMap({ profileStorageKey($0) }) { + keychain.clearToken(hostKey: key) + var profiles = loadSavedProfilesRaw() + profiles.removeValue(forKey: key) + saveSavedProfiles(profiles) } keychain.clearToken() saveProfile(nil) @@ -3614,9 +3665,14 @@ final class SyncService: ObservableObject { private func saveProfile(_ profile: HostConnectionProfile?) { if let profile, let data = try? encoder.encode(profile) { UserDefaults.standard.set(data, forKey: profileKey) + if let key = profileStorageKey(profile) { + var profiles = loadSavedProfilesRaw() + profiles[key] = profile + saveSavedProfiles(profiles) + migrateTokenIfNeeded(for: profile) + } activeHostProfile = profile hostName = profile.hostName - upsertKnownProfile(profile) } else { UserDefaults.standard.removeObject(forKey: profileKey) UserDefaults.standard.removeObject(forKey: legacyDraftKey) @@ -4137,10 +4193,8 @@ final class SyncService: ObservableObject { } private func scheduleReconnectIfNeeded(after delayNanoseconds: UInt64) { - guard allowAutoReconnect, - !autoReconnectPausedByUser, - let profile = loadProfile(), - token(for: profile) != nil else { return } + let profile = loadProfile() + guard allowAutoReconnect, !autoReconnectPausedByUser, profile != nil, tokenForProfile(profile) != nil else { return } reconnectTask?.cancel() reconnectTask = Task { @MainActor in try? await Task.sleep(nanoseconds: delayNanoseconds) @@ -4227,7 +4281,7 @@ final class SyncService: ObservableObject { !reconnectConnectInFlight, !canSendLiveRequests(), let refreshedProfile = activeHostProfile, - token(for: refreshedProfile) != nil, + tokenForProfile(refreshedProfile) != nil, !automaticReconnectAddresses(for: refreshedProfile).isEmpty else { return } @@ -4445,6 +4499,7 @@ final class SyncService: ObservableObject { return false }() remoteProjectCatalog = [] + pendingProjectCatalogChunks.removeAll() let commandDescriptors: [SyncRemoteCommandDescriptor] = { guard let commandRouting = features?["commandRouting"], @@ -4522,6 +4577,7 @@ final class SyncService: ObservableObject { let friendlyError = SyncUserFacingError.error(from: error) let completions = pending pending.removeAll() + pendingProjectCatalogChunks.removeAll() for request in completions.values { request.timeoutTask.cancel() request.completion(.failure(friendlyError)) @@ -4618,6 +4674,9 @@ final class SyncService: ObservableObject { let catalog = try decode(payload, as: MobileProjectCatalogPayload.self) applyRemoteProjectCatalog(catalog) resolve(requestId: requestId, result: .success(payload)) + case "project_catalog_chunk": + let chunk = try decode(payload, as: MobileProjectCatalogChunkPayload.self) + applyRemoteProjectCatalogChunk(chunk, requestId: requestId) case "project_switch_result": resolve(requestId: requestId, result: .success(payload)) case "hello_error": @@ -4882,6 +4941,7 @@ final class SyncService: ObservableObject { socket?.cancel(with: closeCode, reason: reason?.data(using: .utf8)) socket = nil currentAddress = nil + pendingProjectCatalogChunks.removeAll() connectionGeneration &+= 1 } @@ -4901,6 +4961,12 @@ final class SyncService: ObservableObject { setDomainStatus(SyncDomain.allCases, phase: .disconnected) } failPendingRequests(with: friendlyError) + if syncIsMessageTooLongError(error) { + allowAutoReconnect = false + setAutoReconnectPausedByUser(true) + cancelReconnectLoop() + return + } scheduleReconnectIfNeeded(after: reconnectDelayNanoseconds ?? reconnectDelay()) } diff --git a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift index 45eae65cc..3f0add102 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift @@ -224,16 +224,12 @@ struct DiscoverHostsSheet: View { LazyVStack(spacing: 10) { let savedHosts = syncService.savedReconnectHosts let liveHosts = syncService.discoveredHosts.filter { host in - for savedHost in savedHosts { - if let hostIdentity = host.hostIdentity, let savedIdentity = savedHost.hostIdentity, - hostIdentity == savedIdentity { - return false - } - if host.id == savedHost.id { - return false + !savedHosts.contains { savedHost in + if let hostIdentity = host.hostIdentity, let savedIdentity = savedHost.hostIdentity { + return hostIdentity == savedIdentity } + return host.id == savedHost.id } - return true } if savedHosts.isEmpty && liveHosts.isEmpty { @@ -251,8 +247,8 @@ struct DiscoverHostsSheet: View { Button { dismiss() Task { - await syncService.reconnectToSavedHost( - savedHost, + await syncService.reconnect( + toSavedHost: savedHost, preferTailnet: savedHost.tailscaleAddress != nil ) } diff --git a/apps/web/src/components/editorial/IndexPage.tsx b/apps/web/src/components/editorial/IndexPage.tsx index f4916288d..32548cac5 100644 --- a/apps/web/src/components/editorial/IndexPage.tsx +++ b/apps/web/src/components/editorial/IndexPage.tsx @@ -8,7 +8,6 @@ const ENTRIES: Entry[] = [ { name: "Automations", page: "28", href: "/automations/overview" }, { name: "Byok (Bring your own keys)", page: "22", href: "/configuration/settings" }, { name: "Computer Use", page: "30", href: "/computer-use/overview" }, - { name: "Context packs", page: "26", href: "/context-packs/overview" }, { name: "CTO (chief technical officer)", page: "15", href: "/cto/overview" }, { name: "CLI · ade", page: "12", href: "/tools/project-home" }, { name: "Dispatch", page: "17", href: "/missions/creating" }, diff --git a/chat/capabilities.mdx b/chat/capabilities.mdx index 7d9468400..d79bd0987 100644 --- a/chat/capabilities.mdx +++ b/chat/capabilities.mdx @@ -1,6 +1,6 @@ --- title: "Chat Capabilities" -description: "What the chat agent can do — file operations, terminal commands, git, PRs, Linear integration, mission spawning, context pack queries, and ADE action tools." +description: "What the chat agent can do — file operations, terminal commands, git, PRs, Linear integration, mission spawning, repo inspection, and ADE action tools." icon: "toolbox" --- @@ -27,8 +27,8 @@ The chat agent has access to a broad tool palette. The exact tools available dep Escalate the current task into a new Mission, carrying over conversation context as the planner's seed. - - Query the lane's pack for architectural context, file maps, open PRs, recent activity, and human work digests. + + Inspect repository docs, source files, file maps, open PRs, recent activity, and human work digests. Attach images (JPEG, PNG, GIF, WebP) to any message. Images are sent as inline base64 content blocks so the agent can see screenshots, diagrams, or UI mockups natively. @@ -58,7 +58,7 @@ The most common workflow is: make changes in chat → have the agent create a PR Say "create a PR" or "open a pull request." The agent will: - Read the current branch name and recent commits - - Query the lane pack for architectural context + - Inspect relevant repo docs and source files for architectural context - Generate a PR title and description based on the changes - Pre-fill reviewer suggestions if you have GitHub team integration enabled - Apply relevant labels based on the branch name and changed files @@ -69,7 +69,7 @@ The most common workflow is: make changes in chat → have the agent create a PR - If your project has a PR description template, put it in the lane's context pack. The agent will use it automatically when generating PR descriptions. + If your project has a PR description template, keep it in the repository. The agent can inspect it when generating PR descriptions. --- diff --git a/chat/context.mdx b/chat/context.mdx index 7f03ea720..c4ba63adf 100644 --- a/chat/context.mdx +++ b/chat/context.mdx @@ -31,15 +31,9 @@ Every chat session has a **context window** — the amount of conversation histo --- -## Context Doc Preferences +## Project orientation -ADE generates context docs (PRD, architecture overview) from your codebase to seed agent sessions with project-level knowledge. You can configure how these docs are generated in **Settings > Context**: - -- **Model** — choose which AI model generates the docs -- **Reasoning effort** — control the depth of analysis -- **Auto-refresh events** — select which project events trigger a doc regeneration (e.g., on commit, on PR create, on mission start) - -Preferences are saved automatically and persist across sessions. Changes take effect the next time context docs are regenerated, either manually or via a configured event trigger. +ADE does not generate PRD or architecture summary files for chat. Agents are instructed to inspect the repository directly when they need project context: start with `AGENTS.md`, `README.md`, `docs/`, package manifests, and the source files relevant to the lane. --- diff --git a/configuration/overview.mdx b/configuration/overview.mdx index be97425d9..34e7242b2 100644 --- a/configuration/overview.mdx +++ b/configuration/overview.mdx @@ -14,7 +14,7 @@ ADE initializes a `.ade/` directory at the project root on first open. It is the local.secret.yaml # API keys and tokens — ALWAYS gitignored worktrees/ # Git worktrees for each lane (auto-managed by ADE) artifacts/ - packs/ # Context pack snapshots + packs/ # Compatibility snapshots and audit artifacts checkpoints/ # Session boundary snapshots sessions/ # Agent session transcripts audit.log # Append-only audit trail diff --git a/configuration/settings.mdx b/configuration/settings.mdx index 28aa19249..6c1930617 100644 --- a/configuration/settings.mdx +++ b/configuration/settings.mdx @@ -17,7 +17,7 @@ Settings are organized into nine sections in the left navigation panel. Changes App-wide preferences, keybindings, onboarding, help. Theme, density, and visual options. - Project identity, paths, and context docs. + Project identity, paths, and skill files. Providers, per-role models, context management. iPhone pairing, tailnet discovery, and push notifications. GitHub, Linear, and proof backends. @@ -94,7 +94,7 @@ Theme changes take effect instantly and are saved per-device. Density and accent ## Workspace -Project identity, file paths, and how ADE generates and refreshes context docs. This section combines what older builds split across a separate **Project** tab and **Context** tab. +Project identity, file paths, and workspace-level skill files. | Setting | Description | Default | |---------|-------------|---------| @@ -102,23 +102,6 @@ Project identity, file paths, and how ADE generates and refreshes context docs. | Project description | Short description shown on Project Home | Empty | | Default working directory | Root directory for terminal sessions | Repository root | | baseRef | Primary integration branch for PRs | `main` | -| Context doc model | AI model used to regenerate project context docs | `claude-sonnet-4-6` | -| Context doc reasoning effort | Depth of analysis during generation | Default (model decides) | -| Auto-refresh events | Which project events trigger automatic doc regeneration | On PR create, On mission start | - -### Auto-Refresh Events - -Toggle individual events that trigger a context doc regeneration: - -- **On session end** — regenerate when a terminal/agent session ends -- **On commit** — regenerate when a commit is created -- **On PR create** — regenerate when a pull request is created or updated -- **On PR land** — regenerate when a pull request is merged -- **On mission start** — regenerate when a mission launches -- **On mission end** — regenerate when a mission completes -- **On lane create** — regenerate when a new lane is created - -Preferences are saved automatically and persist across sessions. --- @@ -383,4 +366,4 @@ The monthly cap is a hard stop. Once reached, all agent activity (chat, missions - \ No newline at end of file + diff --git a/context-packs/freshness.mdx b/context-packs/freshness.mdx deleted file mode 100644 index e37ebf273..000000000 --- a/context-packs/freshness.mdx +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: "Freshness & Integration" -description: "Understand auto-refresh triggers, freshness indicators, and how packs integrate with missions, pull requests, and the memory system." -icon: "arrows-rotate" ---- - -## Pack Freshness and Refresh - -### Auto-Refresh Triggers - -ADE's Job Engine automatically refreshes packs when: - -| Event | Packs Refreshed | -|---|---| -| A commit is made in a lane | That lane's pack | -| A PR is merged | Project Pack + originating lane's pack | -| More than 10 files change | Project Pack | -| A Linear issue is updated | Corresponding Feature Pack (if any) | -| A new conflict is detected | Conflict Pack for that pair of lanes | -| A mission completes | Mission Pack (final phase snapshot) | - -### Manual Refresh - -You can force a refresh at any time: - -- **From the Context view:** Right-click any pack entry → **Refresh Pack** -- **From the Lane panel:** Right-click a lane → **Refresh Lane Pack** -- **Keyboard shortcut:** Focus a lane in the Lanes panel and press `⌘⇧R` -- **From the CTO:** Ask the CTO agent to "refresh the pack for lane X" - -### Freshness Indicators - -Each pack entry in the Context view shows a freshness badge: - -| Badge | Meaning | -|---|---| -| Green "Updated Xs ago" | Pack is current | -| Amber "Stale" | Pack is older than the configured threshold (default: 30 minutes after a change event) | -| Red "Very Stale" | Pack has not been updated in over 2 hours despite activity in the lane | -| Gray "Generating..." | Pack refresh is currently in progress | - ---- - -## Packs in Missions - -Missions consume and produce packs as first-class artifacts throughout their lifecycle. - -**Planning Phase:** -- The Mission planner reads the Project Pack and all relevant Lane Pack(s) to understand scope. -- The resulting Plan Pack is created at the end of planning and must be approved before execution starts. - -**Execution Phase:** -- The executor operates with the Plan Pack as its primary guidance document. -- Each phase boundary creates a new Mission Pack snapshot. - -**Validation Phase:** -- The validator reads the Mission Pack's execution snapshot to understand what was done. -- Validation results are appended to the Mission Pack. - -**After Completion:** -- The full Mission Pack is archived in `.ade/artifacts/packs/missions//`. -- It can be viewed in **History → Missions → [Mission] → Context Packs**. - ---- - -## Packs in Pull Requests - -When a PR is created from a lane, ADE automatically: - -1. Includes the Lane Pack's `intent` and `criteria` sections as the PR description template. -2. Links any active Conflict Pack to the PR as a comment if conflicts were detected. -3. Attaches the Lane Pack ID to the PR metadata so reviewers can access the full pack from the PR detail view. - -Reviewers can click **View Lane Pack** in the PR panel to see the full context that guided the agent that wrote the code they are reviewing. - ---- - -## Memory System Integration - -Packs work together with ADE's unified memory system. Understanding the relationship helps you predict what context agents will have in any session. - -| Memory Tier | Storage | Contents | Loaded When | -|---|---|---|---| -| Core | SQLite (always in RAM) | Project identity, active lanes, current mission state | Always | -| Hot | SQLite (recent rows) | Last 10 sessions, recent commits, open PRs | Every session start | -| Cold | Pack files (`.ade/artifacts/`) | Historical lane packs, archived mission packs, feature packs | On demand | - -**How retrieval works:** - -1. At session start, ADE loads Core + Hot memory automatically. -2. The agent's context stack is assembled from the relevant packs (Cold tier). -3. During a session, ADE CLI tool calls can fetch additional Cold-tier context on demand. -4. At session end, significant facts from the session are written back into Hot memory (SQLite), and the relevant pack is queued for refresh. - ---- - -## Troubleshooting - - - - The Job Engine processes pack refreshes asynchronously. In normal operation, a pack refresh completes within 10–30 seconds of the triggering event. If a pack is still stale after a minute: - - 1. Check that the Job Engine is running: **Settings → Debug → Job Engine Status**. If it is paused or erroring, restart it. - 2. Force a manual refresh from the Context view or with `⌘⇧R` in the lane panel. - 3. If refresh fails repeatedly, check **View → Developer Tools → Console** for error output. - - - - If an agent is acting on stale information, the most likely cause is that it received a stale pack at session start. - - 1. Check the pack's freshness badge — if it was stale when the session started, the agent had stale context. - 2. Force-refresh the relevant pack, then start a **new** chat session. The new session will pick up the refreshed pack. - 3. If the pack seems correct but the agent is still confused, the issue may be Hot-tier SQLite state. Try **Settings → Debug → Clear Hot Memory** (does not delete packs or commits, only clears the recent-sessions index). - - - - The Project Pack is generated on first project open and after each significant event. If it is missing: - - 1. Navigate to the **Context** view and click **Generate Project Pack**. - 2. If generation fails, ADE needs read access to your full repository — confirm there are no permission issues with `.ade/artifacts/packs/project/`. - 3. For monorepos with very large file trees, initial generation may take up to 2 minutes. Check **Settings → Debug → Job Engine** for the running job. - - - - Pack history files are pruned based on the retention setting. Default is 30 days. - - 1. Check the current setting in **Settings → Context → Pack History Retention**. - 2. Increase the retention period if you need longer history. - 3. Note that individual mission packs are retained independently of the standard retention policy — mission packs are kept as long as the mission record exists in the database. - - diff --git a/context-packs/overview.mdx b/context-packs/overview.mdx deleted file mode 100644 index 5e1e2dce6..000000000 --- a/context-packs/overview.mdx +++ /dev/null @@ -1,171 +0,0 @@ ---- -title: "Context Packs Overview" -description: "Packs are durable context bundles that give AI agents instant, structured knowledge of your codebase. ADE auto-generates and auto-refreshes them so agents are never starting cold." -icon: "cubes" ---- - -## Overview - -A **Pack** is a durable, structured context bundle capturing project or lane knowledge in machine-readable format. Packs give agents instant orientation — architecture conventions, intent, touched files, recent checkpoints, and risk signals — without the agent re-deriving context from scratch each session. - -Packs are **synthesized exports**, not primary storage. The source of truth is ADE's SQLite database and the live filesystem. Force-refresh derives a fresh pack from live state. - - - - ADE generates and refreshes packs automatically on commits, PR merges, and significant file changes. No manual maintenance required. - - - Packs use stable section markers that agents can rely on across regenerations. The structure never changes unexpectedly. - - - Agents receive context in priority order: Lane Pack first, then Feature Pack, then Project Pack. More specific context always wins. - - - Packs are automatically embedded in mission planning, conflict resolution, and PR descriptions. Reviewers see context without asking for it. - - - ---- - -## Pack Types - - - - The Project Pack is generated for the entire repository and serves as the foundational context layer included in every agent session. - - **Contents:** - - Architecture overview (system components, their responsibilities, and how they interact) - - Technology stack and dependency summary - - Project-wide coding conventions and style guidelines - - Key architectural decisions and their rationale - - Risk signals (known unstable areas, files with high churn, deprecated patterns) - - Top-level file structure with purpose annotations - - **Used by:** CTO agent, Mission planning phase, new Lane initializations, all automations - - **Location:** `.ade/artifacts/packs/project/current.md` - - - - A Lane Pack captures everything relevant to a specific lane's current work. It is the most specific and highest-priority context an agent receives. - - **Contents:** - - Lane intent (what is this lane trying to accomplish?) - - Coding criteria (definition of done for this lane) - - Touched files since lane creation - - Recent checkpoint summaries - - Current diff summary (what has changed since last commit) - - Active conflicts with other lanes - - **Used by:** All agents running inside the lane, Lane chat, Lane-scoped automations - - **Location:** `.ade/artifacts/packs/lanes//current.md` - - - - A Feature Pack aggregates context for a specific Linear issue or feature that may span multiple lanes. It combines relevant lane packs with the issue description and related code paths. - - **Contents:** - - Linear issue description, acceptance criteria, and comments - - All lane packs for lanes linked to this issue - - Relevant code paths identified by the CTO - - Cross-lane dependency summary - - **Used by:** Multi-lane feature coordination, CTO feature planning, mission scope determination - - **Location:** `.ade/artifacts/packs/features//current.md` - - - - A Conflict Pack is generated when ADE detects a merge conflict between two lanes. It provides a resolution agent with everything it needs to understand and resolve the conflict without starting from scratch. - - **Contents:** - - Both sides of the conflict (lane A intent + lane B intent) - - The conflicting diffs, annotated with intent context - - Previous resolution attempts (if any) - - Suggested resolution strategy generated by the CTO - - File ownership context (which lane "owns" each conflicting file) - - **Used by:** Conflict resolution agents, the Conflicts view panel, PR merge rehearsals - - **Location:** `.ade/artifacts/packs/conflicts//current.md` - - - - A Plan Pack is a versioned implementation plan created during a Mission's planning phase. It contains the full step-by-step plan that the mission executor will follow. - - **Contents:** - - Detailed implementation steps in order - - Acceptance criteria for each step - - Dependency map (which steps must complete before others can start) - - Risk assessment for each major change - - Estimated token budget per phase - - **Used by:** Mission executor, Mission validation phase, post-mission review - - **Location:** `.ade/artifacts/packs/missions//plan.md` - - - - A Mission Pack is a deterministic snapshot of a mission's full context recorded at each phase boundary (planning complete, execution complete, validation complete). It enables mission resume after a crash, full audit, and replay. - - **Contents:** - - Complete mission state at each phase boundary - - All inputs consumed and all outputs produced at each phase - - Executor decisions and their reasoning - - Cost and token usage per phase - - **Used by:** Mission resume, mission audit trail, History view replay - - **Location:** `.ade/artifacts/packs/missions//phase-.md` - - - ---- - -## Pack Filesystem Layout - -All packs live inside `.ade/artifacts/packs/` at your project root. The layout is deterministic and stable. - -``` -.ade/ - artifacts/ - packs/ - project/ - current.md ← always the latest project pack - history/ - .md ← previous versions - lanes/ - / - current.md - history/ - features/ - / - current.md - conflicts/ - / - current.md - missions/ - / - plan.md - phase-1.md - phase-2.md - ... -``` - - -Pack history files are retained for 30 days by default. You can adjust retention in **Settings → Context → Pack History Retention**. History files are useful for auditing what context an agent had during a specific session. - - ---- - -## Next Steps - - - - Learn about the pack content contract, section markers, export levels, and how ADE assembles the context priority stack. - - - Understand auto-refresh triggers, freshness indicators, and how packs integrate with missions, PRs, and the memory system. - - diff --git a/context-packs/structure.mdx b/context-packs/structure.mdx deleted file mode 100644 index db5eac641..000000000 --- a/context-packs/structure.mdx +++ /dev/null @@ -1,114 +0,0 @@ ---- -title: "Pack Structure & Priority" -description: "Learn about the pack content contract, section markers, export levels, and how ADE assembles the context priority stack for agent sessions." -icon: "code" ---- - -## Pack Content Contract - -Packs use a stable, machine-readable format that agents can parse reliably across regenerations. - -### Header - -Every pack begins with a metadata header: - -```markdown - -``` - -### Section Markers - -Content is divided into named sections with stable markers: - -```markdown - -This lane is implementing the user authentication refresh flow... - - -- All existing auth tests must pass -- New refresh token endpoint must be covered by integration tests -- No changes to the public API surface - - -- src/auth/refreshToken.ts (modified) -- src/auth/refreshToken.test.ts (created) -- src/api/routes/auth.ts (modified) - - -Added `refreshToken()` function. Extended `/auth/refresh` route handler... -``` - -Agents can rely on these section names being stable. ADE never renames or removes a section marker without a major version bump. - -### Export Levels - -When exporting or sharing a pack, three verbosity levels are available: - -| Level | Contents | Use Case | -|---|---|---| -| `Lite` | Header + intent section only | Quick orientation, small context windows | -| `Standard` | All primary sections (intent, criteria, touched files, diff summary) | Normal agent sessions | -| `Deep` | Full pack including history entries and extended annotations | Deep debugging, mission planning | - ---- - -## Context Priority and Injection - -When an agent session begins, ADE assembles the context stack in priority order. More specific context always takes precedence over general context. - - - - The lane's own pack is always included first. It defines the specific work context the agent is operating in. - - - If the lane is linked to a Linear issue, the Feature Pack for that issue is included. This brings in cross-lane awareness and the original issue requirements. - - - The Project Pack is always included as the foundational context layer. Every agent gets the project-wide architecture and conventions. - - - If the lane has active merge conflicts, the relevant Conflict Pack is included. The agent sees both sides of the conflict and the suggested resolution. - - - Dynamic context fetched from ADE tools during the session (e.g., a Linear issue's current state, a GitHub PR's latest review comments). This is fetched live, not from pack files. - - - - -The context stack is assembled at session start and does not update mid-session (except for live tool results, which are fetched on demand). If the lane pack was updated while a session was running, the agent will use the version from session start. Start a new chat session to pick up a refreshed pack. - - ---- - -## Accessing Packs Manually - -Packs are plain Markdown files. You can read them directly from the filesystem, open them in your editor, or copy their contents for use outside ADE. - - - - Open the **Context** tab in ADE's left sidebar. The pack tree shows all packs with their type, associated lane/feature/mission, freshness status, and a preview of the intent section. - - Click any pack entry to open the full pack in ADE's built-in viewer. Use the **Export** button to save a copy to your clipboard or a file. - - - Packs are stored as `.md` files in `.ade/artifacts/packs/`. Open them directly in your editor: - - ```bash - # View the current project pack - cat .ade/artifacts/packs/project/current.md - - # View a specific lane's pack - cat ".ade/artifacts/packs/lanes//current.md" - ``` - - - Agents running inside an ADE session can call the `packs.get` action through the local `ade` CLI: - - ```bash - ade actions run packs.get \ - --arg type=lane \ - --arg id=lane-abc123 \ - --arg exportLevel=standard - ``` - - diff --git a/docs.json b/docs.json index bbc282642..5b030d0a9 100644 --- a/docs.json +++ b/docs.json @@ -190,15 +190,6 @@ "computer-use/config" ] }, - { - "group": "Context Packs", - "icon": "cubes", - "pages": [ - "context-packs/overview", - "context-packs/structure", - "context-packs/freshness" - ] - }, { "group": "AI Tool Integrations", "icon": "wand-magic-sparkles", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c961abd86..df285be9d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -200,9 +200,6 @@ Types for these tables are split into domain modules under `apps/desktop/src/sha │ ├── transcripts/ # PTY transcripts (ignored) │ ├── cache/ # Runtime scratch (ignored) │ ├── artifacts/ # Pack exports, history artifacts (ignored) -│ ├── context/ # Generated agent bootstrap docs (ignored) -│ │ ├── PRD.ade.md -│ │ └── ARCHITECTURE.ade.md │ ├── memory/ # Promoted-memory markdown mirror (ignored) │ ├── cto/ │ │ ├── identity.yaml # Shared CTO identity (tracked) @@ -347,7 +344,6 @@ ade.git.* # stage/commit/push/sync/revert/cherry-pick/stash ade.github.* # PR list, review, merge, checks ade.prs.* # stacked PR queue, integration, issue inventory ade.conflicts.* # risk matrix, simulation, proposals -ade.context.* # context doc generation, status events ade.memory.* # memory CRUD, search, health, embeddings ade.missions.* / ade.orchestrator.* ade.cto.* # identity, core memory, agent roster, Linear @@ -390,7 +386,6 @@ High-frequency events flow from main → renderer via `webContents.send(channel, | `ade.lanes.rebaseSuggestions.event` / `ade.lanes.autoRebase.event` / `ade.lanes.rebase.event` | rebase services | Lanes + Graph | | `ade.project.missing` | projectService | Shell banner | | `ade.project.state.event` | projectState | Startup flow | -| `ade.context.statusChanged` | contextDocService | Settings → Context | | `ade.memory.*` events | memory services | Settings → Memory | | `ade.sync.*` events | syncService | Settings → Sync | @@ -413,7 +408,6 @@ Every service lives under `apps/desktop/src/main/services//`. Summary: | `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `agentBrowserArtifactAdapter.ts`, `syntheticToolResult.ts` | Proof-artifact broker (ingests, owner links, review state, routing), control-plane snapshot helpers, macOS capture capability descriptor, agent-browser payload parser, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. | | `config/` | `projectConfigService.ts`, `laneOverlayMatcher.ts` | Load/save `.ade/ade.yaml` + `local.yaml`; trust enforcement; lane overlays. | | `conflicts/` | `conflictService.ts` | Pairwise dry-merge simulation, risk matrix, proposal generation. | -| `context/` | `contextDocService.ts`, `contextDocBuilder.ts` | Generate `.ade/context/PRD.ade.md` + `ARCHITECTURE.ade.md` with budgets and quality gates. | | `cto/` | `ctoStateService.ts`, `workerAgentService.ts`, `workerBudgetService.ts`, `workerHeartbeatService.ts`, `linearSyncService.ts`, `linearIngressService.ts`, `linearOAuthService.ts`, `linearRoutingService.ts`, `linearDispatcherService.ts`, `linearCloseoutService.ts`, `openclawBridgeService.ts`, `flowPolicyService.ts` | CTO identity + core memory; worker agents; Linear sync/ingress/OAuth/routing/dispatcher/closeout; OpenClaw bridge. | | `devTools/` | `devToolsService.ts` | Probe for git + `gh` CLI availability. | | `diffs/` | `diffService.ts` | Diff computation for file panes. | @@ -502,7 +496,6 @@ lanes/ # list/detail/inspector, stacks, laneDesignTokens.ts files/ # tree, editor, diffs terminals/ # TerminalView, WorkViewArea (PaneTilingLayout-backed grid), workSessionTiling, LaneCombobox conflicts/ # risk matrix, simulation, resolution -context/ # shared helpers (contextShared.ts) graph/ # WorkspaceGraphPage (decomposed into nodes/edges/dialogs) prs/ # PR list/detail, stacked queue, shared/ history/ # operation timeline @@ -740,50 +733,21 @@ Full surface: [`docs/architecture/MEMORY.md`](../docs/architecture/MEMORY.md). --- -## 11. Context Contract +## 11. Runtime context -### 11.1 Two layers +ADE does not generate PRD or architecture bootstrap documents. Agent prompts tell models to inspect the repository directly when they need product or architecture context, starting with `AGENTS.md`, `README.md`, `docs/`, package manifests, and relevant source files. -- **Canonical docs** (`docs/`) — human-owned, broad-coverage. `docs/PRD.md` owns product; `docs/architecture/*` owns technical design. -- **Generated bootstrap cards** (`.ade/context/`) — agent-facing summaries, bounded token budget. - -### 11.2 Generated docs - -| File | Required headings | Default budget | -|------|-------------------|----------------| -| `.ade/context/PRD.ade.md` | `## What this is`, `## Who it's for`, `## Feature areas`, `## Current state`, `## Working norms` | 8,000 chars | -| `.ade/context/ARCHITECTURE.ade.md` | `## System shape`, `## Core services`, `## Data and state`, `## Integration points`, `## Key patterns` | 8,000 chars | - -Generation inputs (hybrid source-digest model): - -- Product sources: `docs/PRD.md`, `docs/features/*`, `README.md`, `AGENTS.md`. -- Technical sources: `docs/architecture/*`, selected shared contracts + IPC/preload surfaces, selected main-process anchors, recent git history. -- Each source is summarized into a `ContextSourceDigest` (title, blurb, headings) before bundling — no raw doc is shipped to the AI. - -### 11.3 Quality gates - -- Fit inside per-doc char budget (overflow → proportional per-section trimming, not outright rejection). -- Required heading scaffold present. -- PRD ↔ architecture token-level Jaccard < 0.72. -- Validation is **per-doc independent** — PRD can succeed while architecture falls back. - -Fallback order when AI path fails: `previous_good` → `deterministic`. Status model: health ∈ `{missing, incomplete, fallback, stale, ready}`; source ∈ `{ai, deterministic, previous_good}`. Helpers in `apps/desktop/src/renderer/components/context/contextShared.ts` (`isContextDocReady`, `describeContextDocHealth`, etc.) keep shell banners + Settings + onboarding consistent. - -### 11.4 What gets shipped to each AI call +### 11.1 What gets shipped to each AI call | Call type | Payload | |-----------|---------| | Narrative generation | `LaneExportStandard` (lane, bounded) | | Conflict proposal | `LaneExportLite` (lane) + `LaneExportLite` (peer, optional) + `ConflictExportStandard` | | PR description | `LaneExportStandard` with commit history | -| Mission planning | Generated `.ade/context/*` bootstrap cards + memory briefing + mission-scoped data | +| Mission planning | Memory briefing + mission-scoped data + direct repo inspection guidance | | Memory briefing (worker turn) | `MemoryBriefing` (l0/l1/l2/mission sections + shared facts + direct-source injections) | | Initial context (repo scan) | Targeted file/commit digests | -Runtime health is **pushed**, not polled — `contextStatusChanged` IPC event fires whenever generation status or doc health changes. Stale generations (>5 min in `pending`/`running` without an active promise) auto-reset to `failed`. - -Full spec: [`docs/architecture/CONTEXT_CONTRACT.md`](../docs/architecture/CONTEXT_CONTRACT.md). - --- ## 12. Proof (Computer-Use Artifacts) @@ -977,7 +941,7 @@ Post-packaging hardening (`apps/desktop/scripts/`): ### 14.5 Documentation - **Internal docs** (this directory + `docs/`) — for engineers and agents. Not published. -- **Public docs site** — Mintlify, configured in `docs.json` at repo root. Content lives alongside the repo (`introduction.mdx`, `quickstart.mdx`, `welcome.mdx`, `key-concepts.mdx`, plus subdirs `getting-started/`, `guides/`, `lanes/`, `chat/`, `missions/`, `cto/`, `pull-requests/`, `configuration/`, `tools/`, `computer-use/`, `automations/`, `context-packs/`, `ai-tools/`). Theme `maple`, brand primary `#7C3AED`. +- **Public docs site** — Mintlify, configured in `docs.json` at repo root. Content lives alongside the repo (`introduction.mdx`, `quickstart.mdx`, `welcome.mdx`, `key-concepts.mdx`, plus subdirs `getting-started/`, `guides/`, `lanes/`, `chat/`, `missions/`, `cto/`, `pull-requests/`, `configuration/`, `tools/`, `computer-use/`, `automations/`, `ai-tools/`). Theme `maple`, brand primary `#7C3AED`. - **Doc validation**: `scripts/validate-docs.mjs` runs in CI to catch broken links / structure drift. --- @@ -1011,7 +975,6 @@ Post-packaging hardening (`apps/desktop/scripts/`): - **Dev tools probe** — `devToolsService.ts` checks for `git` and `gh` CLI availability at startup, surfacing warnings in UI. - **Port allocation** — `portAllocationService.ts` manages per-lane port leases with orphan recovery. - **Runtime diagnostics** — `runtimeDiagnosticsService.ts` surfaces lane launch context and runtime state. -- **Context status stream** — push-based (`contextStatusChanged`) replaces earlier poll loop. - **Embedding health** — polled at 10s intervals in Settings → Memory (raised from 1.5s to reduce renderer churn). - **Sync telemetry** — `sync_cluster_state` + device registry surfaced in Settings → Sync. - **Operation timeline** — `operationService.ts` + History page provide full audit trail for debugging and undo. diff --git a/docs/PRD.md b/docs/PRD.md index ca90d875b..1bbd4674c 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -33,7 +33,6 @@ ADE is the control plane. It does not execute browser automation or computer-use | Worktree | Git clone dir under `.ade/worktrees//`, one per lane. | [lanes/worktree-isolation.md](./features/lanes/worktree-isolation.md) | | Runtime | Per-lane process pool + env + ports + proxy + diagnostics. | [lanes/runtime.md](./features/lanes/runtime.md) | | Session | PTY-backed terminal session pinned to a lane. | [terminals-and-sessions/README.md](./features/terminals-and-sessions/README.md) | -| Context pack | Canonical `.ade/context/*.ade.md` docs generated from repo state. | [context-packs/README.md](./features/context-packs/README.md) | | Memory | Structured, searchable, compaction-aware knowledge entries. | [memory/README.md](./features/memory/README.md) | | Proof | Normalized computer-use artifact (screenshot, recording, network log). | [computer-use/artifact-broker.md](./features/computer-use/artifact-broker.md) | @@ -72,7 +71,6 @@ ADE is the control plane. It does not execute browser automation or computer-use - [**Linear Integration**](./features/linear-integration/README.md) — Webhook + relay + reconciliation. Workflow presets, target types (mission/session/worker/PR), bidirectional sync. - [**Computer Use**](./features/computer-use/README.md) — Control plane for Ghost OS, agent-browser, ADE local backends. Canonical artifact model, ownership-linked storage. -- [**Context Packs**](./features/context-packs/README.md) — Three notions: canonical docs, live exports, persisted packs. Event-driven regeneration with seven refresh events. - [**Sync and Multi-Device**](./features/sync-and-multi-device/README.md) — cr-sqlite CRDT (desktop native ext, iOS pure-SQL emulation). Host/controller model. WebSocket envelope. Remote commands. --- diff --git a/docs/README.md b/docs/README.md index c069e5f86..d70bfd10e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,7 +25,6 @@ new-docs/ ├── chat/ # multi-provider agent chat ├── computer-use/ # proof control plane, backends, broker ├── conflicts/ # detection + simulation + resolution - ├── context-packs/ # context docs + live exports + packs ├── cto/ # CTO agent: identity, pipeline, workers, Linear ├── files-and-editor/ # watcher, editor, Monaco, search ├── history/ # operations timeline, transcripts, export diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 1ad090bd6..024c3cdd4 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -98,8 +98,8 @@ and a footer that contains the composer. handoff. - **Reasoning effort.** Dropdown for models that support reasoning tiers. -- **Context pack injection.** Allows the user to attach a context pack - (PRD, ARCHITECTURE, mission pack) to the next turn. +- **Attachments.** Allows the user to attach files and artifacts to + the next turn. - **Permission controls.** Inline with the composer: - Interaction mode selector (`default` / `plan`). - Claude permission mode — a trigger button that opens a popover diff --git a/docs/features/context-packs/README.md b/docs/features/context-packs/README.md deleted file mode 100644 index e88cade62..000000000 --- a/docs/features/context-packs/README.md +++ /dev/null @@ -1,247 +0,0 @@ -# Context Packs - -ADE has three distinct notions that sometimes get conflated: - -- **Context docs** — the canonical `.ade/context/PRD.ade.md` and - `ARCHITECTURE.ade.md` files that summarize the project for the AI. - Regenerated on demand and on event triggers. -- **Live exports** — synthesized at request time from current local - state (project, lane, feature, plan, mission, conflict). They do not - require any persisted file. -- **Packs** — persisted compatibility artifacts under - `.ade/artifacts/packs/`. Historical file-shaped snapshots for - audit, export compatibility, and legacy consumers. - -The runtime contract is: **live exports + unified memory are the -source of truth** for AI calls. Packs remain as file-shaped -compatibility artifacts but are not the runtime source. - -## Source file map - -Main process: - -- `apps/desktop/src/main/services/context/contextDocService.ts` — - orchestrates generation runs, stores prefs, reconciles stale - in-flight state, emits push events to the renderer. ~660 lines. -- `apps/desktop/src/main/services/context/contextDocBuilder.ts` — - builds the canonical docs from repo scan + git history + doc - discovery + AI narration. Resolves doc paths, writes to preferred - path or fallback. ~1,480 lines. -- `apps/desktop/src/main/services/context/contextDocService.test.ts` - and `contextDocBuilder.test.ts` — unit coverage. -- `apps/desktop/src/main/services/conflicts/conflictService.ts` — - uses `laneExportLite` at ~line 2300; consumes live exports for - conflict proposals. -- `apps/desktop/src/main/services/orchestrator/orchestratorQueries.ts` - — mission/planning queries pass `laneExportLevel`, `projectExportLevel`. - -Packs filesystem layout (still managed, but not the runtime source): - -- `.ade/artifacts/packs/project_pack.md` — project pack -- `.ade/artifacts/packs/lanes//lane_pack.md` -- `.ade/artifacts/packs/features//feature_pack.md` -- `.ade/artifacts/packs/plans//plan_pack.md` -- `.ade/artifacts/packs/missions//mission_pack.md` -- `.ade/artifacts/packs/conflicts/v2/__.md` -- `.ade/artifacts/packs/external-resolver-runs//` -- `.ade/history/` — pack history with SQLite-backed index -- `.ade/artifacts/packs/versions/` — versioned pack files - -Path resolution: `apps/desktop/src/shared/adeLayout.ts` -(`resolveAdeLayout(projectRoot).packsDir`). Migrations: -`apps/desktop/src/main/services/projects/adeProjectService.ts` -handles moving `.ade/packs` → `.ade/artifacts/packs`. - -Canonical context docs: - -- `.ade/context/PRD.ade.md` -- `.ade/context/ARCHITECTURE.ade.md` - -Fallback paths when writing canonical files fails: - -- `.ade/context/generated//PRD.ade.md` -- `.ade/context/generated//ARCHITECTURE.ade.md` - -Shared types and contract: - -- `apps/desktop/src/shared/types/packs.ts` — `ContextStatus`, - `ContextDocStatus`, `ContextDocHealth` (`missing` | `incomplete` | - `fallback` | `stale` | `ready`), `ContextDocOutputSource` - (`ai` | `deterministic` | `previous_good`), - `ContextDocGenerationStatus`, `ContextGenerateDocsArgs`, - `ContextGenerateDocsResult`, `ContextDocPrefs`, - `ContextRefreshEvents`, `ContextRefreshTrigger`. -- `apps/desktop/src/shared/contextContract.ts` — public-contract - marker strings used in pack/export text (intent markers, narrative - markers, task-spec markers), `CONTEXT_HEADER_SCHEMA_V1`, - `CONTEXT_CONTRACT_VERSION = 4`, `PackRelation`, `PackGraphEnvelopeV1`, - `ExportOmissionV1`. - -Renderer: - -- `apps/desktop/src/renderer/components/settings/ContextSection.tsx` - — Settings > Workspace > Context tab. Doc list, health indicators, - inline generation controls (provider, model, reasoning effort, - event triggers), generation status card. ~550 lines. -- `apps/desktop/src/renderer/components/context/contextShared.ts` - — `describeContextDocHealth`, `relativeTime`, - `listActionableContextDocs`. Used by both Settings and onboarding. - -IPC: - -- `ade.context.getStatus` — `ContextStatus` -- `ade.context.statusChanged` — push event replacing the old poll -- `ade.context.generateDocs` — manual generation with - `ContextGenerateDocsArgs` -- `ade.context.openDoc` — open a doc in the system editor -- `ade.context.getPrefs` / `savePrefs` — `ContextDocPrefs` - -## Detail doc - -- [freshness-and-delivery.md](./freshness-and-delivery.md) — when - context docs regenerate, how packs get delivered, and the runtime - truth path. - -## Runtime truth - -Runtime exports are synthesized from current local state when -requested. Functions: - -- `getProjectExport({ level })` -- `getLaneExport({ laneId, level })` -- `getConflictExport({...})` -- `getFeatureExport({...})` -- `getPlanExport({ laneId, level })` -- `getMissionExport({ missionId, level })` - -Level is `"lite"`, `"standard"`, or `"deep"`. They do not require a -pre-refreshed on-disk pack file to exist first. Consumers like -`conflictService` call them in-line when they need a compact -representation of a lane's state. - -Conflict external-resolver runs now consume generated per-run context -files plus optional `.ade/context/*.ade.md` docs. They no longer -assume `.ade/artifacts/packs/...` files are present. - -## Context doc generation - -`ContextDocService.generateDocs(args)` is the entry point for -manual runs; `maybeAutoRefreshDocs({ event, reason, force? })` is the -entry point for event-driven refreshes. - -Inputs (`ContextGenerateDocsArgs`): - -- `provider`: `"codex" | "claude" | "opencode"` -- `modelId`: string | undefined -- `reasoningEffort`: string | null -- `trigger`: legacy `ContextRefreshTrigger` -- `events`: `ContextRefreshEvents` - -Outputs (`ContextGenerateDocsResult`): - -- `degraded`: boolean — true when AI narration failed but - deterministic fallback produced content -- `usedFallbackPath`: boolean — true when canonical write failed and - the run wrote to `.ade/context/generated//` -- `generatedAt`: ISO timestamp -- `warnings`: list with `code`, `message`, optional `actionLabel`, - `actionPath` -- `docResults`: per-doc `{ id, health, source, sizeBytes }` - -### Discovery - -Doc discovery prioritizes (from `contextDocBuilder`): - -1. `.ade/context/PRD.ade.md` (canonical) -2. `.ade/context/ARCHITECTURE.ade.md` (canonical) -3. root-level docs: `README.md`, `CLAUDE.md`, `AGENTS.md` -4. bounded repo scan for PRD-ish / architecture-ish / guide-ish docs - using `DOC_PRD_HINT_RE`, `DOC_ARCH_HINT_RE`, `DOC_GUIDE_HINT_RE` - -Docs are truncated to `CONTEXT_DOC_MAX_CHARS = 8_000` per source. - -### Output - -Two canonical docs with required heading sets: - -- `PRD.ade.md`: "What this is", "Who it's for", "Feature areas", - "Current state", "Working norms" -- `ARCHITECTURE.ade.md`: "System shape", "Core services", "Data and - state", "Integration points", "Key patterns" - -A doc is `ready` when it exists, has all required headings, and -matches the current fingerprint. Other health states: -`missing`, `incomplete`, `fallback`, `stale`. - -## Context doc status - -`ContextStatus` shape: - -```ts -type ContextStatus = { - docs: ContextDocStatus[]; // PRD, architecture - canonicalDocsPresent: string[]; - canonicalDocsScanned: string[]; - canonicalDocsFingerprint: string | null; - canonicalDocsUpdatedAt: string | null; - projectExportFingerprint: string | null; - projectExportUpdatedAt: string | null; - contextManifestRefs: { project, packs, transcripts }; - fallbackWrites: number; - insufficientContextCount: number; - warnings: ContextDocWarning[]; - generation: ContextDocGenerationStatus; // current in-flight state -}; -``` - -The service pushes updates via `onStatusChanged` callback — the -renderer subscribes through `ade.context.statusChanged` rather than -polling. - -## Unified memory (separate subsystem) - -Unified memory (project/agent/mission scopes, Tier 1-3 lifecycle) is a -separate subsystem managed through Settings > Memory. It is not part -of packs. Memory-backed indexed skill files are managed from the -Workspace skill-file surface and hidden from the generic memory -browser so they cannot be orphaned. - -## Guest mode - -In guest mode (`ai.mode === "guest"`), deterministic compatibility -packs remain usable — narrative generation is simply skipped. The -"degraded" flag in the generation result is set true and the -`fallback` health state surfaces in the UI so users know content is -deterministic. - -## Gotchas - -- Packs are not the runtime source of truth. Fresh AI calls go - through live exports and unified memory. Relying on a specific - pack file existing will produce stale data. -- The canonical docs are the ones services consume — if you need to - surface project knowledge to the AI, update `PRD.ade.md` / - `ARCHITECTURE.ade.md`, not a pack file. -- Fallback writes land under `.ade/context/generated//`. These - are not cleaned up automatically; the `fallbackWrites` counter in - `ContextStatus` is the signal to show a banner in the UI. -- Generation status can get stuck in `pending` / `running` if the - process crashes mid-run. `reconcileGenerationStatus` normalizes - this on service construction using - `STALE_GENERATION_TIMEOUT_MS = 5 minutes`. -- `activeGeneration` is a service-scoped promise. Concurrent - generation requests await it instead of kicking off a duplicate. -- The migration from `.ade/packs` to `.ade/artifacts/packs` is - handled in `adeProjectService.ts` — projects opened under older - ADE versions may briefly show both paths during migration. -- Don't add new pack consumers. Use live exports or unified memory - instead; packs are frozen as compatibility artifacts. - -## Cross-links - -- Freshness and delivery: [freshness-and-delivery.md](./freshness-and-delivery.md) -- Config triggers for auto-refresh: - [../onboarding-and-settings/configuration-schema.md](../onboarding-and-settings/configuration-schema.md) -- Settings UI for context docs: - `apps/desktop/src/renderer/components/settings/ContextSection.tsx` -- Memory (separate): Settings > Memory tab diff --git a/docs/features/context-packs/freshness-and-delivery.md b/docs/features/context-packs/freshness-and-delivery.md deleted file mode 100644 index 41490b316..000000000 --- a/docs/features/context-packs/freshness-and-delivery.md +++ /dev/null @@ -1,293 +0,0 @@ -# Context Freshness and Delivery - -How context flows from the codebase to AI calls: event-driven -refresh of `.ade/context/*.ade.md` canonical docs, throttling, -prefs resolution, and how live exports reach the consuming service. - -Canonical backend: `apps/desktop/src/main/services/context/contextDocService.ts`. -Builder implementation: `contextDocBuilder.ts`. - -## Context doc prefs - -Stored in `AdeDb` under `context:docs:preferences.v1`: - -```ts -type ContextDocRefreshPrefs = { - cadence: ContextRefreshTrigger; // legacy, still stored for backcompat - events: ContextRefreshEvents; // new event-based model - provider: "codex" | "claude" | "opencode"; - modelId: string | null; - reasoningEffort: string | null; - updatedAt: string; -}; -``` - -`ContextDocPrefs` (IPC shape, narrower) has: - -- `provider` -- `modelId` -- `reasoningEffort` -- `events` (the 7-key boolean map) - -The renderer reads via `window.ade.context.getPrefs()` and writes via -`savePrefs(prefs)`. Persistence is immediate — no confirm dialog. - -## Event triggers - -Seven events can trigger auto-refresh: - -- `session_end` -- `commit` -- `pr_create` -- `pr_land` -- `mission_start` -- `mission_end` -- `lane_create` - -Mapped to `ContextRefreshEvents` keys: - -```ts -const EVENT_NAME_TO_KEY = { - session_end: "onSessionEnd", - commit: "onCommit", - pr_create: "onPrCreate", - pr_land: "onPrLand", - mission_start: "onMissionStart", - mission_end: "onMissionEnd", - lane_create: "onLaneCreate", -}; -``` - -Each event has a minimum interval enforced per event type: - -```ts -const AUTO_REFRESH_MIN_INTERVAL_MS = { - session_end: 45 * 60_000, // 45 min - commit: 15 * 60_000, // 15 min - pr_create: 15 * 60_000, - pr_land: 15 * 60_000, - mission_start: 15 * 60_000, - mission_end: 15 * 60_000, - lane_create: 45 * 60_000, // 45 min -}; -``` - -Defaults when no prefs are stored and no config override is set: - -```ts -const DEFAULT_EVENTS = { onPrCreate: true, onMissionStart: true }; -``` - -## Trigger resolution - -`resolveEnabledEvents()` priority (highest wins): - -1. `ProjectConfigFile.contextRefreshEvents` in shared or local config - (whichever has booleans set first) -2. Stored `ContextDocRefreshPrefs.events` if any event is enabled -3. `DEFAULT_EVENTS` - -This lets teams lock event triggers via committed `ade.yaml` while -still allowing per-user tweaks via stored prefs. - -## Auto-refresh flow - -`maybeAutoRefreshDocs({ event, reason, force? })`: - -1. Look up the event key; return null if unknown. -2. Check `enabledEvents[eventKey]` — if disabled, settle the pending - state without running and log `auto_refresh_event_disabled`. -3. Read stored prefs; require a `modelId`. If missing, log - `auto_refresh_skipped_missing_model` and return null. -4. Write `generation` state as `pending` (so the UI can show queued - progress). -5. Check throttle: if the last run was within - `minIntervalMs` and `force !== true`, settle pending and log - `auto_refresh_skipped_recent`. -6. Call `generateDocsInternal` with source=`"auto"` and the event - metadata. - -The service prevents concurrent runs by awaiting `activeGeneration` -when it exists. Duplicate event fires within a run collapse into -the first one. - -## Manual generation - -`generateDocs(docArgs)`: - -1. Require a `modelId` (throws if missing). -2. Delegate to `generateDocsInternal` with source=`"manual"` and - reason=`"manual_generate"`. - -Called from: - -- Settings > Workspace > Context — `ContextSection.tsx` -- Onboarding > Context step — `ProjectSetupPage.tsx` -- CLI-like developer hooks (future) - -## Generation internal flow - -`generateDocsInternal(docArgs, meta)`: - -1. If another generation is already in-flight, return the same - promise. -2. Normalize provider, model, reasoning effort from args. -3. Persist prefs (so auto-refresh later uses the same provider). -4. Write `generation.state = "running"` and emit push event. -5. Call `runContextDocGeneration(deps, args)` from - `contextDocBuilder`. -6. On success: - - log `context_docs.generate.complete` - - write `generation.state = "succeeded"` with `finishedAt` - - update `context:docs:lastRun.v1` for throttle calculations -7. On warning/degraded: - - log at `warn` level - - write `state = "succeeded"` with warnings attached -8. On failure: - - log `context_docs.generate.failed` - - write `state = "failed"` with `error` - -## Stale generation recovery - -If the process crashes mid-generation, the DB state can be stuck in -`pending` / `running`. On service construction, -`reconcileGenerationStatus()` runs: - -1. If `activeGeneration` is live, leave state alone. -2. Otherwise, check the baseline timestamp - (`startedAt ?? requestedAt`). If the timestamp is missing, set - state to `failed` with a "state left in progress" error. -3. If the baseline is older than - `STALE_GENERATION_TIMEOUT_MS = 5 minutes`, set state to `failed` - with a "previous generation did not finish" error. -4. Otherwise leave the pending state in place (a live generation - elsewhere might still be running). - -## Push-based status updates - -The service accepts an `onStatusChanged` callback at construction. -`buildStatusSnapshot()` reads the current `ContextStatus` and -`emitStatusChanged()` invokes the callback. The main-process IPC -layer forwards the callback to the renderer via the -`ade.context.statusChanged` event channel. - -This replaces the previous polling path where the renderer called -`getStatus()` on a timer. In the current code the renderer still -reads `getStatus()` once on mount and relies on push events after. - -## Doc paths - -`resolveContextDocPath(projectRoot, docId)` returns: - -- `prd_ade`: `/.ade/context/PRD.ade.md` -- `architecture_ade`: `/.ade/context/ARCHITECTURE.ade.md` - -`openDoc({ docId })` IPC resolves the path and opens it via -`shell.openPath`. If the canonical file does not exist, it falls back -to the most recent `.ade/context/generated//` version. - -## Live exports - -Exports are not touched by the event-driven doc regeneration path. -They synthesize from local state on demand. Typical delivery: - -- `conflictService` constructs `laneExportLite` inline when building - a conflict proposal (`laneExportLevel = "lite"`). -- `orchestratorQueries` pass `projectExportLevel = "lite"` and - `laneExportLevel = "standard"` when gathering mission context. -- External resolver runs use per-run context files written at - invocation time rather than any persisted pack. - -Exports include the context contract markers from -`contextContract.ts`: - -- `ADE_INTENT_START` / `ADE_INTENT_END` -- `ADE_TODOS_START` / `ADE_TODOS_END` -- `ADE_NARRATIVE_START` / `ADE_NARRATIVE_END` -- `ADE_TASK_SPEC_START` / `ADE_TASK_SPEC_END` -- JSON header fence with `schema: "ade.context.v1"` - -Contract version (`CONTEXT_CONTRACT_VERSION = 4`) is advisory; consumers -should not gate hard on the value. - -## Packs as audit/history - -Persisted pack files and pack versions under `.ade/history/` and -`.ade/artifacts/packs/versions/` still exist for: - -- audit trails -- compatibility with external consumers that accept - `ade://pack/` resource URIs -- optional persisted summaries for offline viewing - -Writing to packs is still done by some code paths (project pack -index in `packs_index` table, lane pack directories) but reading from -them at AI-call time is discouraged. - -## Fallback writes - -When `contextDocBuilder.runContextDocGeneration` fails to write to -the canonical path (permissions, symlink issues, read-only FS), it -writes to `.ade/context/generated//` instead and marks -the result with `usedFallbackPath: true`. The `ContextStatus` exposes -a `fallbackWrites` counter the UI uses to surface "generation wrote -to fallback path" warnings. - -## Delivery flow summary - -``` -Event (PR create, mission start, session end...) - └─→ emit to contextDocService.maybeAutoRefreshDocs - ├─ resolveEnabledEvents (config > prefs > defaults) - ├─ throttle check (AUTO_REFRESH_MIN_INTERVAL_MS) - ├─ generateDocsInternal (single-flight via activeGeneration) - │ ├─ persistContextDocRefreshPrefs - │ ├─ writeGenerationStatus("running") - │ └─ runContextDocGeneration (contextDocBuilder) - │ ├─ discover docs (.ade/context + root docs + bounded scan) - │ ├─ hybrid summarize (AI + deterministic) - │ ├─ write canonical file, fallback to generated// on failure - │ └─ return ContextGenerateDocsResult - └─ writeGenerationStatus("succeeded" | "failed") - -AI call (chat turn, mission step, conflict proposal) - └─→ service-local: - ├─ read .ade/context/PRD.ade.md / ARCHITECTURE.ade.md - ├─ build live export at required level - └─ attach unified-memory retrievals -``` - -## Gotchas - -- Event triggers fire regardless of whether anything has actually - changed. The throttle is the only cheap guard; content - deduplication happens inside the builder via fingerprints. -- `session_end` is expensive to hook into because every terminal - close fires one. The 45-minute throttle is deliberate. -- Without a configured `modelId`, auto-refresh is a no-op. Users on - guest mode will never see auto-refresh success unless they also - configure a local model through OpenCode (LM Studio / Ollama). -- `lane_create` fires during lane init, which is already an expensive - flow. Consider disabling it for short-lived lanes to avoid compounding - the first-create latency. -- Docs are considered `stale` when their fingerprint differs from the - source fingerprint. Fingerprints are sha256 of canonical source - bundles. If a user manually edits a doc and leaves it - unfingerprint-matched, it may flip to `stale` after the next run. -- Canonical docs are preferred over packs by every discovery path. - If you need a doc to show up in AI context, put it at - `.ade/context/*.ade.md` or reference it from there. - -## Cross-links - -- Event sources that call `maybeAutoRefreshDocs`: - `apps/desktop/src/main/services/history/` (session end), - `apps/desktop/src/main/services/git/` (commit), - `apps/desktop/src/main/services/prs/` (PR create/land), - mission services, lane services. -- Settings UI for prefs: - `apps/desktop/src/renderer/components/settings/ContextSection.tsx`. -- Onboarding step that prompts initial generation: - [../onboarding-and-settings/first-run.md](../onboarding-and-settings/first-run.md) -- Config `contextRefreshEvents` field: - [../onboarding-and-settings/configuration-schema.md](../onboarding-and-settings/configuration-schema.md) diff --git a/docs/features/files-and-editor/README.md b/docs/features/files-and-editor/README.md index e8fa175d4..12076e533 100644 --- a/docs/features/files-and-editor/README.md +++ b/docs/features/files-and-editor/README.md @@ -216,8 +216,6 @@ For deeper detail on the watcher + trust boundary, see ## Cross-links - Lane worktrees feed the workspace list: [../lanes/](../lanes/) -- Files drive context doc freshness (the context doc generator scans - the workspace): [../context-packs/](../context-packs/) - Processes and tests can monitor the workspace for changes via the watcher — see [../terminals-and-sessions/](../terminals-and-sessions/) for the transcript and log story. diff --git a/docs/features/files-and-editor/file-watcher-and-trust.md b/docs/features/files-and-editor/file-watcher-and-trust.md index b56ea8d66..dc0bb9c66 100644 --- a/docs/features/files-and-editor/file-watcher-and-trust.md +++ b/docs/features/files-and-editor/file-watcher-and-trust.md @@ -212,7 +212,7 @@ All registered in `registerIpc.ts`: `onLaneWorktreeMutation` is an optional callback passed to `createFileService`. It fires when the user mutates a lane worktree -so other services (lane manager, context doc generator) can invalidate +so other services (lane manager, search index, and editor surfaces) can invalidate their caches. ## Gotchas diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 0b2f8710d..c849b327b 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -12,7 +12,7 @@ Two related but distinct flows: The runtime no longer assumes first-run setup must hydrate every service. Project open favors a cheap first pass; secondary hydration -(full lane status, provider modes, context generation) happens after +(full lane status, provider modes, semantic indexing) happens after the app is interactive. ## Source file map @@ -153,8 +153,6 @@ Renderer — settings: target isn't on the user's `$PATH`. - `apps/desktop/src/renderer/components/settings/WorkspaceSettingsSection.tsx` + `ProjectSection.tsx` — project identity, base ref, paths. -- `apps/desktop/src/renderer/components/settings/ContextSection.tsx` - — context docs management and generation preferences. - `apps/desktop/src/renderer/components/settings/AiSettingsSection.tsx` / `AiFeaturesSection.tsx` — AI provider preferences. - `apps/desktop/src/renderer/components/settings/ProvidersSection.tsx` @@ -202,7 +200,7 @@ Repository onboarding covers five things: 2. detect stack signals (node, rust, go, python, docker, make) 3. suggest config defaults for processes, tests, stacks 4. optionally import existing git branches as lanes -5. prepare initial deterministic context state +5. prepare initial deterministic workspace state Timing: project open runs a cheap first pass and defers heavy work. Current behavior: @@ -210,8 +208,8 @@ Current behavior: - lanes load without expensive per-lane status first - keybindings load immediately (they are tiny) - provider mode and full lane status warm later -- context doc generation is no longer gated on "must finish before the - app feels usable" +- expensive background work is no longer gated on "must finish before + the app feels usable" ### CTO first-run setup @@ -236,7 +234,7 @@ is changing rather than which service backs it: | Tab | Section file | What lives here | |---|---|---| | General | `GeneralSection.tsx` (embeds `AdeCliSection` in compact form) | Theme, AI mode, task routing, terminal preferences (font size, line height, scrollback), keybindings link, and the `ade` CLI install / status surface | -| Workspace | `WorkspaceSettingsSection.tsx`, `ProjectSection.tsx`, `ContextSection.tsx` | Project identity, context docs, skill files | +| Workspace | `WorkspaceSettingsSection.tsx`, `ProjectSection.tsx` | Project identity, paths, skill files | | AI | `AiSettingsSection.tsx`, `AiFeaturesSection.tsx`, `ProvidersSection.tsx` | Provider CLIs, models, AI feature flags | | Sync | `SyncDevicesSection.tsx` | Multi-device sync, host-role transfer, peer status, pairing PIN, Tailscale tailnet discovery | | Integrations | `IntegrationsSettingsSection.tsx`, `GitHubSection.tsx`, `LinearSection.tsx` | GitHub, Linear, and computer-use backend readiness | @@ -313,8 +311,6 @@ Onboarding and settings follow a simple rule: ## Cross-links - Run/Project home: [../project-home/README.md](../project-home/README.md) -- Context docs (consumed by Settings > Workspace > Context): - [../context-packs/](../context-packs/) - Lane templates used during lane creation: Lanes feature - Terminal preferences applied at runtime: [../terminals-and-sessions/ui-surfaces.md](../terminals-and-sessions/ui-surfaces.md) diff --git a/docs/features/onboarding-and-settings/configuration-schema.md b/docs/features/onboarding-and-settings/configuration-schema.md index 2c6947124..8722a1c37 100644 --- a/docs/features/onboarding-and-settings/configuration-schema.md +++ b/docs/features/onboarding-and-settings/configuration-schema.md @@ -43,7 +43,6 @@ type ProjectConfigFile = { laneCleanup?: LaneCleanupConfig; providers?: Record; linearSync?: LinearSyncConfig; - contextRefreshEvents?: ContextRefreshEvents; }; ``` @@ -312,22 +311,6 @@ Resolved through `projectConfigService.linearSync` and surfaced in ## Context refresh events -```ts -type ContextRefreshEvents = { - onSessionEnd?: boolean; - onCommit?: boolean; - onPrCreate?: boolean; - onPrLand?: boolean; - onMissionStart?: boolean; - onMissionEnd?: boolean; - onLaneCreate?: boolean; -}; -``` - -Stored under the `contextRefreshEvents` key. Consumed by -`contextDocService` to decide when to auto-regenerate PRD/architecture -docs. See [../context-packs/freshness-and-delivery.md](../context-packs/freshness-and-delivery.md). - ## Merge rules The service does a shallow-first, deep-on-known-fields merge: diff --git a/docs/features/onboarding-and-settings/first-run.md b/docs/features/onboarding-and-settings/first-run.md index 98ff6e0aa..fe0fd172e 100644 --- a/docs/features/onboarding-and-settings/first-run.md +++ b/docs/features/onboarding-and-settings/first-run.md @@ -2,7 +2,7 @@ The wizard that turns a freshly-opened project into something usable. Covers stack detection, AI provider setup, optional integrations, and -initial context doc generation. +semantic search setup. The canonical backend is `apps/desktop/src/main/services/onboarding/onboardingService.ts`. The @@ -12,7 +12,7 @@ wizard UI is ## Wizard steps -`STEP_ORDER = ["tools", "ai", "helpers", "github", "embeddings", "linear", "context"]`. +`STEP_ORDER = ["tools", "ai", "helpers", "github", "embeddings", "linear"]`. | Step | Heading | Subtitle | Purpose | |---|---|---|---| @@ -22,7 +22,6 @@ wizard UI is | `github` | GitHub Integration | "A personal access token lets ADE create PRs, request reviews, and monitor CI on your behalf." | GitHub PAT setup. | | `embeddings` | Semantic Search | "A small local model that enables meaning-based memory search instead of just keyword matching." | Local embeddings opt-in. | | `linear` | Linear Integration | "Connect your Linear workspace to route issues, sync statuses, and enable CTO workflows." | Optional Linear connection. | -| `context` | Context Documents | "Generate a PRD and architecture overview from your codebase. These help ADE understand your project deeply." | PRD/architecture doc generation. | All steps are visited in order but none *block* completion — the user can Skip on any step and come back via Settings. @@ -105,11 +104,6 @@ The page is stateful and reacts to: - `window.ade.onboarding.getStatus()` on mount - `window.ade.ai.getStatus()` for `availableModelIds` -- `window.ade.context.getPrefs()` for context doc preferences -- `window.ade.context.getStatus()` when the `context` step is active, - and whenever a generation event fires -- `window.ade.context.onStatusChanged` push events (replacing the - previous polling path) — new in the current branch Step-to-section embedding: @@ -121,21 +115,6 @@ Step-to-section embedding: | `github` | `GitHubSection` | | `embeddings` | `EmbeddingsSection` | | `linear` | `LinearSection` | -| `context` | inline generation controls driven by - `ContextSection` helpers and `listActionableContextDocs` | - -### Context step specifics - -- `ProviderModelSelector` and `deriveConfiguredModelIds(aiStatus)` let - the user pick the generation model. -- `EVENT_TOGGLES` renders the seven `ContextRefreshEvents` toggles; - saving writes through `window.ade.context.savePrefs`. -- `describeContextStatusLine` composes a user-friendly status string - ("generating...", "last generation failed: ...", "both docs - present, regenerate if needed", etc.). -- Generation is not required to finish onboarding — the user can - queue it and move on. - ### Completion Clicking "Finish" calls `window.ade.onboarding.complete()` and @@ -150,8 +129,8 @@ leaving the onboarding banner available via a re-entry from Settings. Onboarding follows a small rule set: -- Do not block on optional integrations. GitHub, Linear, embeddings, - and context generation are all skippable. +- Do not block on optional integrations. GitHub, Linear, and embeddings + are all skippable. - Keep setup responsive. Model detection, CLI probes, and lane detection run concurrently where possible. - Show the fastest path first. For Linear that means personal API @@ -184,7 +163,5 @@ Onboarding follows a small rule set: - Configuration schema (where suggested configs land): [configuration-schema.md](./configuration-schema.md) -- Context docs flow (what the `context` step triggers): - [../context-packs/freshness-and-delivery.md](../context-packs/freshness-and-delivery.md) - Project home (the screen users arrive at after onboarding): [../project-home/README.md](../project-home/README.md) diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 0695ba920..efab7f80b 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -350,5 +350,3 @@ Processes (managed): [../files-and-editor/](../files-and-editor/) (the file watcher is scoped per workspace, not per session). - Configuration-driven processes: [../onboarding-and-settings/configuration-schema.md](../onboarding-and-settings/configuration-schema.md) -- Context packs / exports that reference session deltas: - [../context-packs/](../context-packs/) diff --git a/getting-started/open-project.mdx b/getting-started/open-project.mdx index ef8f0f39e..54a9b60e8 100644 --- a/getting-started/open-project.mdx +++ b/getting-started/open-project.mdx @@ -31,12 +31,10 @@ When ADE opens a repository for the first time, it creates a `.ade/` directory a .ade/ artifacts/ ← packs, proofs, and mission outputs packs/ - project/ ← project-wide context pack - lanes/ ← per-lane context packs - features/ ← per-feature context packs (Linear-linked) + lanes/ ← lane artifacts and compatibility snapshots + features/ ← feature artifacts (Linear-linked) conflicts/ ← conflict resolution context missions/ ← mission plans and phase snapshots - context/ ← generated PRD and architecture docs cto/ ← CTO identity, memory, and configuration worktrees/ ← git worktrees for lanes (one per lane) db/ ← SQLite database for lanes, sessions, history @@ -50,7 +48,7 @@ Add `.ade/` to your `.gitignore` if it is not already there. The `.ade/` directo ## First-Run Onboarding -When ADE opens a new repository, it launches the **project setup wizard** — a 7-step flow that configures your development tools, AI providers, GitHub integration, and context documents. +When ADE opens a new repository, it launches the **project setup wizard** — a 6-step flow that configures your development tools, AI providers, GitHub integration, semantic search, and Linear. @@ -66,8 +64,6 @@ When ADE opens a new repository, it launches the **project setup wizard** — a Enable optional background services: - **Auto-titling** — generates descriptive names for lanes and sessions based on their content - - **Context doc generation** — creates baseline PRD and architecture docs from your codebase (see step 7) - - **Auto-refresh** — keeps lane packs and context docs up to date automatically after commits Authenticate with GitHub for PR management, CI sync, and stacked PR workflows. ADE uses the GitHub CLI (`gh`) for authentication — if you are already logged in via `gh auth login`, ADE detects this automatically. @@ -78,13 +74,6 @@ When ADE opens a new repository, it launches the **project setup wizard** — a Connect Linear for bidirectional issue tracking with the CTO agent. This step is optional — skip it if you do not use Linear. See [Linear Integration](/cto/linear) for details. - - Generate baseline PRD and architecture documents from your codebase. ADE scans your repository's documentation files (README, docs/, etc.), code structure, and dependency graph to produce two structured documents: - - **PRD.ade.md** — what the project is, who it is for, and its feature areas - - **ARCHITECTURE.ade.md** — system shape, core services, data model, and key patterns - - These documents become part of the Project Pack and are injected into every agent session as foundational context. - You can skip any step and configure it later in **Settings**. For a detailed walkthrough of each step with screenshots and troubleshooting, see [Project Setup & Onboarding](/getting-started/project-setup). diff --git a/getting-started/project-setup.mdx b/getting-started/project-setup.mdx index 239172507..230042cbb 100644 --- a/getting-started/project-setup.mdx +++ b/getting-started/project-setup.mdx @@ -1,6 +1,6 @@ --- title: "Project setup & onboarding" -description: "Walk through ADE's onboarding wizard step by step — detect tools, connect providers, configure GitHub, enable context docs, and get your project ready for agents." +description: "Walk through ADE's onboarding wizard step by step — detect tools, connect providers, configure GitHub, and get your project ready for agents." icon: "wand-magic-sparkles" --- @@ -54,8 +54,6 @@ Enable optional AI-powered background features: | Feature | What it does | Default | |---------|-------------|---------| | **Auto-title sessions** | AI generates descriptive names for terminal and chat sessions | On | -| **Context doc generation** | AI synthesizes a PRD and architecture overview from your codebase | On | -| **Auto-refresh context** | Regenerate context docs on key events (PR create, mission start, etc.) | On | These features use your configured AI provider and count toward your budget. Disable any you do not want. @@ -115,28 +113,6 @@ Linear integration is most valuable when paired with the CTO agent. The CTO can --- -## Step 7: Context docs - -The final step generates your project's baseline context documents. These are the knowledge packs that every agent session starts with. - - - - Select which AI model generates the context docs (defaults to your configured default model). - - - ADE analyzes your codebase and produces two baseline documents: - - **PRD.ade.md** — Product requirements derived from your code, README, and docs - - **ARCHITECTURE.ade.md** — Technical architecture, key modules, patterns, and conventions - - - Choose which events trigger automatic regeneration. Recommended: on PR create, on mission start, on lane create. - - - -Context docs are stored in `.ade/context/` and injected into agent system prompts. They give agents meaningful project awareness before they write a single line of code. - ---- - ## After setup Once you finish (or skip) the wizard, ADE takes you to the **Run** tab. From here you can: diff --git a/key-concepts.mdx b/key-concepts.mdx index 3a2c550a4..6a99553d6 100644 --- a/key-concepts.mdx +++ b/key-concepts.mdx @@ -68,36 +68,13 @@ ADE tracks rebase suggestions per-lane. When an upstream branch changes, a compa --- -## Pack +## Runtime context -A **Pack** is a durable context bundle injected into agent system prompts, stored in `.ade/artifacts/packs/`. Packs give agents project awareness before they write any code. +ADE keeps runtime context local to the work being done. Agents receive lane/session state, selected artifacts, mission details, memory retrievals, and explicit user instructions. When they need product or architecture knowledge, they inspect the repo directly instead of relying on generated PRD or architecture summaries. -ADE defines several pack types: +ADE also stores some compatibility artifacts under `.ade/artifacts/packs/` for audit and replay: - - A project-wide context document generated from the repository's source code, documentation, and git history. Contains: - - High-level architecture description - - Key modules and their responsibilities - - Coding conventions and patterns - - Risk signals (files that frequently cause conflicts, complex areas) - - Recent significant changes - - The Project Pack is regenerated periodically by the Job Engine and whenever you explicitly trigger a context refresh. - - - A lane-specific context document. Contains: - - The lane's **intent** — what this lane is supposed to accomplish - - **Acceptance criteria** — how success is defined - - **Checkpoints** — immutable snapshots of progress so far - - **Touched files** — which files the lane's agent has modified - - **Outstanding risks** — known issues the agent flagged - - Lane Packs are updated at session boundaries and after commits. - - - An issue-scoped aggregate pack that combines the Project Pack, relevant Lane Packs, and Linear issue context into a single context bundle for a specific feature. Used when a feature spans multiple lanes. - A resolution-context pack created when a conflict is detected between two lanes. Contains the conflicting file regions, each lane's intent, and suggested resolution strategies. Injected into the resolver agent's context. diff --git a/lanes/packs.mdx b/lanes/packs.mdx index bab4c68d7..3e9d0bea2 100644 --- a/lanes/packs.mdx +++ b/lanes/packs.mdx @@ -6,7 +6,7 @@ icon: "cube" ## What is a Lane Pack? -A Lane Pack is a structured context document auto-generated by ADE, versioned on each commit, and automatically injected into every agent session in the lane. It eliminates the cold-start problem by handing agents a pre-built briefing before they read your first message. +A Lane Pack is a structured lane briefing, versioned on each commit, that captures the lane's intent, touched files, and recent checkpoints. --- @@ -158,10 +158,10 @@ This means an agent working in a child lane knows: ## Next Steps - - Learn about all pack types — Project, Lane, Feature, Conflict, Plan, and Mission — and how they work together. + + Learn how lanes isolate work, state, branches, and agent sessions. - - Understand the pack content contract, section markers, export levels, and context priority injection order. + + Understand how agents manage context windows and inspect repo context.