From 3d9061805eba23c26d02461e7f82cc3f4ab1d6ce Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 23 Jun 2026 23:58:40 +0530 Subject: [PATCH 1/3] feat(goals): add workspace goal persistence --- package.json | 2 +- src/config.test.ts | 6 ++ src/config.ts | 8 ++- src/db/migrations.ts | 28 ++++++++ src/db/schema.ts | 20 ++++++ src/goal-store.test.ts | 75 ++++++++++++++++++++++ src/goal-store.ts | 137 ++++++++++++++++++++++++++++++++++++++++ src/oauth-store.test.ts | 1 + src/user-config.ts | 1 + 9 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 src/goal-store.test.ts create mode 100644 src/goal-store.ts diff --git a/package.json b/package.json index 65a3de0..9b77622 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/goal-store.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/config.test.ts b/src/config.test.ts index dc23aa8..b3db4d6 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -24,6 +24,10 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).toolMode, "f assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, "codex"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); +assert.equal(loadConfig(baseEnv).goalsEnabled, false); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).goalsEnabled, true); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex", DEVSPACE_GOALS: "0" }).goalsEnabled, false); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_GOALS: "1" }).goalsEnabled, true); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); @@ -150,6 +154,7 @@ writeFileSync( port: 8787, allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", + goalsEnabled: true, }), ); writeFileSync( @@ -161,6 +166,7 @@ writeFileSync( const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); assert.equal(fileConfig.port, 8787); +assert.equal(fileConfig.goalsEnabled, true); assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); assert.deepEqual(fileConfig.allowedHosts, [ diff --git a/src/config.ts b/src/config.ts index 5bf96f8..8e2662b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ export interface ServerConfig { widgets: WidgetMode; stateDir: string; worktreeRoot: string; + goalsEnabled: boolean; skillsEnabled: boolean; skillPaths: string[]; agentDir: string; @@ -210,6 +211,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { const files = loadDevspaceFiles(env); const host = env.HOST ?? files.config.host ?? "127.0.0.1"; const port = parsePort(env.PORT ?? files.config.port); + const toolMode = parseToolMode(env); const publicBaseUrl = parsePublicBaseUrl( env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port), ); @@ -229,11 +231,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, - toolMode: parseToolMode(env), + toolMode, toolNaming: parseToolNaming(env.DEVSPACE_TOOL_NAMING), widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), + goalsEnabled: + env.DEVSPACE_GOALS === undefined + ? (files.config.goalsEnabled ?? toolMode === "codex") + : parseBoolean(env.DEVSPACE_GOALS), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS), agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())), diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 1ce1e1c..2af0535 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -17,6 +17,11 @@ const migrations: Migration[] = [ name: "oauth-state", up: migrateOAuthState, }, + { + version: 3, + name: "workspace-goals", + up: migrateWorkspaceGoals, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -138,6 +143,29 @@ function migrateOAuthState(sqlite: Database.Database): void { `); } +function migrateWorkspaceGoals(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workspace_goals ( + workspace_session_id text primary key, + goal_id text not null, + objective text not null, + status text not null check(status in ('active', 'blocked', 'complete')), + created_at text not null, + updated_at text not null, + completed_at text, + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create index if not exists workspace_goals_goal_id_idx + on workspace_goals(goal_id); + + create index if not exists workspace_goals_status_idx + on workspace_goals(status, updated_at desc); + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 94b3862..3e53263 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -38,6 +38,25 @@ export const loadedAgentFiles = sqliteTable( ], ); +export const workspaceGoals = sqliteTable( + "workspace_goals", + { + workspaceSessionId: text("workspace_session_id") + .primaryKey() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + goalId: text("goal_id").notNull(), + objective: text("objective").notNull(), + status: text("status", { enum: ["active", "blocked", "complete"] }).notNull(), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + completedAt: text("completed_at"), + }, + (table) => [ + index("workspace_goals_goal_id_idx").on(table.goalId), + index("workspace_goals_status_idx").on(table.status, table.updatedAt), + ], +); + export const oauthClients = sqliteTable( "oauth_clients", { @@ -77,3 +96,4 @@ export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; +export type WorkspaceGoalRow = typeof workspaceGoals.$inferSelect; diff --git a/src/goal-store.test.ts b/src/goal-store.test.ts new file mode 100644 index 0000000..8c458ad --- /dev/null +++ b/src/goal-store.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SqliteGoalStore, validateObjective } from "./goal-store.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-goal-store-test-")); + +try { + testObjectiveValidation(); + testGoalLifecycle(join(root, "lifecycle")); + testGoalPersistence(join(root, "persistence")); +} finally { + await rm(root, { recursive: true, force: true }); +} + +function testObjectiveValidation(): void { + assert.equal(validateObjective(" ship goals "), "ship goals"); + assert.throws(() => validateObjective(" "), /Goal objective must not be empty/); + assert.throws(() => validateObjective("x".repeat(4001)), /must not exceed 4000/); +} + +function testGoalLifecycle(stateDir: string): void { + const workspaceStore = new SqliteWorkspaceStore(stateDir); + const goalStore = new SqliteGoalStore(stateDir); + try { + const workspace = workspaceStore.createSession({ id: "ws_1", root: process.cwd() }); + assert.equal(goalStore.getGoal(workspace.id), undefined); + + const goal = goalStore.createGoal({ workspaceId: workspace.id, objective: " implement workspace goals " }); + assert.equal(goal.workspaceId, workspace.id); + assert.equal(goal.objective, "implement workspace goals"); + assert.equal(goal.status, "active"); + assert.ok(goal.goalId); + + assert.throws( + () => goalStore.createGoal({ workspaceId: workspace.id, objective: "replace too early" }), + /unfinished goal already exists/, + ); + + const blocked = goalStore.updateGoal({ workspaceId: workspace.id, status: "blocked" }); + assert.equal(blocked?.status, "blocked"); + assert.equal(blocked?.completedAt, undefined); + + const complete = goalStore.updateGoal({ workspaceId: workspace.id, status: "complete" }); + assert.equal(complete?.status, "complete"); + assert.ok(complete?.completedAt); + + const replacement = goalStore.createGoal({ workspaceId: workspace.id, objective: "next goal" }); + assert.equal(replacement.status, "active"); + assert.equal(replacement.objective, "next goal"); + assert.notEqual(replacement.goalId, goal.goalId); + } finally { + goalStore.close(); + workspaceStore.close(); + } +} + +function testGoalPersistence(stateDir: string): void { + const workspaceStore = new SqliteWorkspaceStore(stateDir); + const firstGoalStore = new SqliteGoalStore(stateDir); + const workspace = workspaceStore.createSession({ id: "ws_2", root: process.cwd() }); + const created = firstGoalStore.createGoal({ workspaceId: workspace.id, objective: "persist me" }); + firstGoalStore.close(); + workspaceStore.close(); + + const secondGoalStore = new SqliteGoalStore(stateDir); + try { + assert.deepEqual(secondGoalStore.getGoal(workspace.id), created); + assert.equal(secondGoalStore.updateGoal({ workspaceId: "missing", status: "complete" }), undefined); + } finally { + secondGoalStore.close(); + } +} diff --git a/src/goal-store.ts b/src/goal-store.ts new file mode 100644 index 0000000..e718dab --- /dev/null +++ b/src/goal-store.ts @@ -0,0 +1,137 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { openDatabase, type DatabaseHandle } from "./db/client.js"; +import { workspaceGoals, type WorkspaceGoalRow } from "./db/schema.js"; + +export type GoalStatus = "active" | "blocked" | "complete"; +export type ModelSettableGoalStatus = "blocked" | "complete"; + +export interface WorkspaceGoal { + workspaceId: string; + goalId: string; + objective: string; + status: GoalStatus; + createdAt: string; + updatedAt: string; + completedAt?: string; +} + +export interface GoalStore { + getGoal(workspaceId: string): WorkspaceGoal | undefined; + createGoal(input: { workspaceId: string; objective: string }): WorkspaceGoal; + updateGoal(input: { workspaceId: string; status: ModelSettableGoalStatus }): WorkspaceGoal | undefined; + close?(): void; +} + +export class SqliteGoalStore implements GoalStore { + private readonly database: DatabaseHandle; + + constructor(stateDir: string) { + this.database = openDatabase(stateDir); + } + + getGoal(workspaceId: string): WorkspaceGoal | undefined { + const row = this.database.db + .select() + .from(workspaceGoals) + .where(eq(workspaceGoals.workspaceSessionId, workspaceId)) + .get(); + + return row ? rowToWorkspaceGoal(row) : undefined; + } + + createGoal(input: { workspaceId: string; objective: string }): WorkspaceGoal { + const objective = validateObjective(input.objective); + const existing = this.getGoal(input.workspaceId); + if (existing && existing.status !== "complete") { + throw new Error("An unfinished goal already exists for this workspace. Use update_goal to change its status."); + } + + const now = new Date().toISOString(); + const goal: WorkspaceGoal = { + workspaceId: input.workspaceId, + goalId: randomUUID(), + objective, + status: "active", + createdAt: now, + updatedAt: now, + }; + + this.database.db + .insert(workspaceGoals) + .values({ + workspaceSessionId: goal.workspaceId, + goalId: goal.goalId, + objective: goal.objective, + status: goal.status, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + completedAt: null, + }) + .onConflictDoUpdate({ + target: workspaceGoals.workspaceSessionId, + set: { + goalId: goal.goalId, + objective: goal.objective, + status: goal.status, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + completedAt: null, + }, + }) + .run(); + + return goal; + } + + updateGoal(input: { workspaceId: string; status: ModelSettableGoalStatus }): WorkspaceGoal | undefined { + const existing = this.getGoal(input.workspaceId); + if (!existing) return undefined; + if (existing.status === "complete") return existing; + + const now = new Date().toISOString(); + this.database.db + .update(workspaceGoals) + .set({ + status: input.status, + updatedAt: now, + completedAt: input.status === "complete" ? now : null, + }) + .where(eq(workspaceGoals.workspaceSessionId, input.workspaceId)) + .run(); + + return this.getGoal(input.workspaceId); + } + + close(): void { + this.database.close(); + } +} + +export function createGoalStore(stateDir: string): GoalStore { + return new SqliteGoalStore(stateDir); +} + +export function validateObjective(value: string): string { + const objective = value.trim(); + if (!objective) { + throw new Error("Goal objective must not be empty."); + } + if (objective.length > 4000) { + throw new Error("Goal objective must not exceed 4000 characters."); + } + return objective; +} + +function rowToWorkspaceGoal(row: WorkspaceGoalRow): WorkspaceGoal { + const goal: WorkspaceGoal = { + workspaceId: row.workspaceSessionId, + goalId: row.goalId, + objective: row.objective, + status: row.status, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + if (row.completedAt) goal.completedAt = row.completedAt; + return goal; +} diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 2f2a873..c15e701 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -43,6 +43,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { assert.deepEqual(migrations, [ { version: 1, name: "workspace-state" }, { version: 2, name: "oauth-state" }, + { version: 3, name: "workspace-goals" }, ]); } finally { database.close(); diff --git a/src/user-config.ts b/src/user-config.ts index 0b79c51..522c7a8 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -18,6 +18,7 @@ export interface DevspaceUserConfig { stateDir?: string; worktreeRoot?: string; agentDir?: string; + goalsEnabled?: boolean; } export interface DevspaceAuthConfig { From a83a016e46b74e5e7ca03d156b213c7dd8c5c671 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 24 Jun 2026 00:04:48 +0530 Subject: [PATCH 2/3] feat(goals): expose workspace goal tools --- src/server.ts | 204 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 198 insertions(+), 6 deletions(-) diff --git a/src/server.ts b/src/server.ts index 8661932..fa00758 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,7 @@ import type { Request, Response } from "express"; import * as z from "zod/v4"; import { applyPatch } from "./apply-patch.js"; import { loadConfig, type ServerConfig, type WidgetMode } from "./config.js"; +import { createGoalStore, type GoalStore, type WorkspaceGoal } from "./goal-store.js"; import { logEvent, requestIp, @@ -89,6 +90,7 @@ interface DiffStats { type ToolWidgetKind = | "workspace" + | "goal" | "read" | "write" | "edit" @@ -187,8 +189,12 @@ function toolNamesFor(config: ServerConfig): ToolNames { } function serverInstructions(config: ServerConfig, toolNames: ToolNames): string { + const goals = config.goalsEnabled + ? " Goals are durable workspace state: use get_goal after opening a workspace, after context compaction or resume, and before continuing long-running work; create or update goals only when the user or governing instructions call for it." + : ""; + if (config.toolMode === "codex") { - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree and reuse its workspaceId. Use ${toolNames.read} for direct file reads, apply_patch for all file modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.${goals}`; } const inspection = config.toolMode !== "full" @@ -206,7 +212,7 @@ function serverInstructions(config: ServerConfig, toolNames: ToolNames): string ? " After creating, editing, or overwriting files, call show_changes once after the related file changes are complete so the user can see the aggregate diff." : ""; - return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}`; + return `Use DevSpace as a local coding workspace. Call ${toolNames.openWorkspace} once per project folder or worktree to obtain a workspaceId. Reuse that same workspaceId for all later file, search, edit, write, show-changes, and shell tools in that folder; do not call ${toolNames.openWorkspace} again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. ${agentsMd}${skills}${inspection}Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell. Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.${showChanges}${goals}`; } function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { return { @@ -234,6 +240,16 @@ const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); +const goalOutputSchema = z.object({ + workspaceId: z.string(), + goalId: z.string(), + objective: z.string(), + status: z.enum(["active", "blocked", "complete"]), + createdAt: z.string(), + updatedAt: z.string(), + completedAt: z.string().optional(), +}); + const reviewFileOutputSchema = z.object({ path: z.string(), previousPath: z.string().optional(), @@ -509,6 +525,159 @@ function processToolResponse( }; } +function goalResultText(goal: WorkspaceGoal | undefined): string { + if (!goal) return "No goal exists for this workspace."; + + const completed = goal.completedAt ? `\nCompleted at: ${goal.completedAt}` : ""; + return [ + `Goal ${goal.goalId}`, + `Status: ${goal.status}`, + `Objective: ${goal.objective}`, + `Updated at: ${goal.updatedAt}${completed}`, + ].join("\n"); +} + +function goalToolResponse( + tool: "get_goal" | "create_goal" | "update_goal", + workspaceId: string, + goal: WorkspaceGoal | undefined, + action: string, +) { + const result = goalResultText(goal); + const content = [textBlock(result)]; + return { + content, + _meta: { + tool, + card: { + workspaceId, + summary: { + action, + hasGoal: Boolean(goal), + status: goal?.status, + }, + payload: { content }, + }, + }, + structuredContent: { + result, + goal: goal ?? null, + }, + }; +} + +function registerGoalTools( + server: McpServer, + config: ServerConfig, + workspaces: WorkspaceRegistry, + goalStore: GoalStore, +): void { + registerAppTool( + server, + "get_goal", + { + title: "Get goal", + description: + "Retrieve the durable goal for an open workspace. Call this after open_workspace, after context compaction or resume, and before continuing long-running work. Returns null when no goal exists.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: resultOutputSchema({ + goal: goalOutputSchema.nullable(), + }), + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = goalStore.getGoal(workspaceId); + logToolCall(config, { + tool: "get_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return goalToolResponse("get_goal", workspaceId, goal, "get"); + }, + ); + + registerAppTool( + server, + "create_goal", + { + title: "Create goal", + description: + "Create a durable active goal for an open workspace. Use only when explicitly requested by the user or governing instructions. Fails if an unfinished goal already exists; update that goal instead.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + objective: z.string().min(1).max(4000).describe("Concrete goal objective to persist for this workspace."), + }, + outputSchema: resultOutputSchema({ + goal: goalOutputSchema.nullable(), + }), + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ workspaceId, objective }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = goalStore.createGoal({ workspaceId, objective }); + logToolCall(config, { + tool: "create_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return goalToolResponse("create_goal", workspaceId, goal, "create"); + }, + ); + + registerAppTool( + server, + "update_goal", + { + title: "Update goal", + description: + "Update the current workspace goal lifecycle. Allowed statuses are complete and blocked. Use complete only when the objective is done. Use blocked only when the model cannot make meaningful progress without user input or an external state change.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + status: z.enum(["complete", "blocked"]).describe("New lifecycle status for the current goal."), + }, + outputSchema: resultOutputSchema({ + goal: goalOutputSchema.nullable(), + }), + ...toolWidgetDescriptorMeta(config, "goal"), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ workspaceId, status }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = goalStore.updateGoal({ workspaceId, status }); + logToolCall(config, { + tool: "update_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + + return goalToolResponse("update_goal", workspaceId, goal, "update"); + }, + ); +} + function registerCodexProcessTools( server: McpServer, config: ServerConfig, @@ -657,6 +826,7 @@ function createMcpServer( workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, processSessions: ProcessSessionManager, + goalStore: GoalStore, ): McpServer { const toolNames = toolNamesFor(config); const server = new McpServer( @@ -747,6 +917,7 @@ function createMcpServer( skills: z.array(workspaceSkillOutputSchema), skillDiagnostics: z.array(z.unknown()), instruction: z.string(), + goal: goalOutputSchema.nullable().optional(), }, ...toolWidgetDescriptorMeta(config, "workspace"), annotations: { readOnlyHint: true }, @@ -774,9 +945,17 @@ function createMcpServer( const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), })); - const instruction = config.skillsEnabled - ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." - : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + const goal = config.goalsEnabled ? goalStore.getGoal(workspace.id) : undefined; + const instructionParts = [ + "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file.", + config.skillsEnabled + ? "When a task matches an available skill in skills, read its path before proceeding." + : undefined, + config.goalsEnabled + ? "Goal state is workspace-scoped and durable. Call get_goal after context compaction or resume and before continuing goal-directed work." + : undefined, + ]; + const instruction = instructionParts.filter(Boolean).join(" "); const resultContent: ToolContent[] = [ { type: "text" as const, @@ -793,6 +972,11 @@ function createMcpServer( visibleSkills.length > 0 ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` : undefined, + config.goalsEnabled + ? goal + ? `Current goal: ${goal.objective} (status: ${goal.status})` + : "Current goal: none" + : undefined, instruction, ].filter(Boolean).join("\n"), }, @@ -818,6 +1002,7 @@ function createMcpServer( availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, skillDiagnostics: workspace.skillDiagnostics.length, + ...(config.goalsEnabled ? { goal: goal?.status ?? "none" } : {}), }, }, }, @@ -832,11 +1017,16 @@ function createMcpServer( skills: visibleSkills, skillDiagnostics: workspace.skillDiagnostics, instruction, + ...(config.goalsEnabled ? { goal: goal ?? null } : {}), }, }; }, ); + if (config.goalsEnabled) { + registerGoalTools(server, config, workspaces, goalStore); + } + registerAppTool( server, toolNames.read, @@ -1566,6 +1756,7 @@ export function createServer(config = loadConfig()): RunningServer { resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl), }); const workspaceStore = createWorkspaceStore(config.stateDir); + const goalStore = createGoalStore(config.stateDir); const workspaces = new WorkspaceRegistry(config, workspaceStore); const reviewCheckpoints = createReviewCheckpointManager(); const processSessions = new ProcessSessionManager(); @@ -1692,7 +1883,7 @@ export function createServer(config = loadConfig()): RunningServer { } }; - const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions); + const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, goalStore); await server.connect(transport); } else { sendJsonRpcError(res, 400, -32000, "No valid MCP session"); @@ -1720,6 +1911,7 @@ export function createServer(config = loadConfig()): RunningServer { closed = true; processSessions.shutdown(); oauthProvider.close(); + goalStore.close?.(); workspaceStore.close?.(); }, }; From e9aca59465c3947c4cb0c8e6f799400a21e177a1 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 24 Jun 2026 00:10:35 +0530 Subject: [PATCH 3/3] fix(goals): decouple default from codex mode --- src/config.test.ts | 2 +- src/config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index b3db4d6..4c99120 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -25,7 +25,7 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, " assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal"); assert.equal(loadConfig(baseEnv).goalsEnabled, false); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).goalsEnabled, true); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).goalsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex", DEVSPACE_GOALS: "0" }).goalsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_GOALS: "1" }).goalsEnabled, true); assert.equal(loadConfig(baseEnv).skillsEnabled, true); diff --git a/src/config.ts b/src/config.ts index 8e2662b..1ac5144 100644 --- a/src/config.ts +++ b/src/config.ts @@ -238,7 +238,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), goalsEnabled: env.DEVSPACE_GOALS === undefined - ? (files.config.goalsEnabled ?? toolMode === "codex") + ? (files.config.goalsEnabled ?? false) : parseBoolean(env.DEVSPACE_GOALS), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS),