From 0e0f493becb7f7da50a994b72e1e4819ae08898c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 3 Jul 2026 20:01:00 +0530 Subject: [PATCH 1/4] Add workspace goal persistence --- package.json | 2 +- src/config.test.ts | 3 + src/config.ts | 2 + src/db/migrations.ts | 27 +++++++ src/db/schema.ts | 22 ++++++ src/goal-store.test.ts | 74 +++++++++++++++++++ src/goal-store.ts | 163 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 src/goal-store.test.ts create mode 100644 src/goal-store.ts diff --git a/package.json b/package.json index e16711b3..0b513344 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/ui/patch-display.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/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.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/goal-store.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/config.test.ts b/src/config.test.ts index 75e007f3..859f93d7 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -24,6 +24,9 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, " assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true); +assert.equal(loadConfig(baseEnv).goalsEnabled, false); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_GOALS: "0" }).goalsEnabled, false); +assert.equal(loadConfig({ ...baseEnv, DEVSPACE_GOALS: "1" }).goalsEnabled, true); assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "invalid" }), diff --git a/src/config.ts b/src/config.ts index a5f84bac..03d1fa84 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ export interface ServerConfig { widgets: WidgetMode; stateDir: string; worktreeRoot: string; + goalsEnabled: boolean; skillsEnabled: boolean; skillPaths: string[]; agentDir: string; @@ -223,6 +224,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { 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: 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 1ce1e1c7..19dc1413 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 { @@ -95,6 +100,28 @@ function migrateWorkspaceState(sqlite: Database.Database): void { addColumnIfMissing(sqlite, "workspace_sessions", "managed", "text not null default 'false'"); } +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 default 'active', + progress_summary text not null default '', + next_step text not null default '', + 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_status_idx + on workspace_goals(status, updated_at desc); + `); +} + function migrateOAuthState(sqlite: Database.Database): void { sqlite.exec(` create table if not exists oauth_clients ( diff --git a/src/db/schema.ts b/src/db/schema.ts index 94b3862b..958b9835 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -38,6 +38,26 @@ 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").notNull().default("active"), + progressSummary: text("progress_summary").notNull().default(""), + nextStep: text("next_step").notNull().default(""), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + completedAt: text("completed_at"), + }, + (table) => [ + index("workspace_goals_status_idx").on(table.status, table.updatedAt), + ], +); + export const oauthClients = sqliteTable( "oauth_clients", { @@ -77,3 +97,5 @@ 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; +export type NewWorkspaceGoalRow = typeof workspaceGoals.$inferInsert; diff --git a/src/goal-store.test.ts b/src/goal-store.test.ts new file mode 100644 index 00000000..5df5d856 --- /dev/null +++ b/src/goal-store.test.ts @@ -0,0 +1,74 @@ +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 } from "./goal-store.js"; +import { SqliteWorkspaceStore, type WorkspaceStore } from "./workspace-store.js"; + +const root = await mkdtemp(join(tmpdir(), "devspace-goal-store-test-")); + +try { + const stateDir = join(root, ".state"); + const workspaceStore: WorkspaceStore = new SqliteWorkspaceStore(stateDir); + const goalStore = new SqliteGoalStore(stateDir); + const workspace = workspaceStore.createSession({ + id: "ws_goal_store_test", + root: join(root, "project"), + }); + + assert.equal(goalStore.getGoal(workspace.id), undefined); + + const created = goalStore.setGoal({ + workspaceSessionId: workspace.id, + objective: "Ship workspace goals", + progressSummary: "Schema exists", + nextStep: "Wire tools", + }); + assert.equal(created.workspaceSessionId, workspace.id); + assert.equal(created.objective, "Ship workspace goals"); + assert.equal(created.status, "active"); + assert.equal(created.completedAt, undefined); + + assert.deepEqual(goalStore.getGoal(workspace.id), created); + + const updated = goalStore.updateGoal({ + workspaceSessionId: workspace.id, + progressSummary: "Tools wired", + nextStep: "Document behavior", + }); + assert.equal(updated?.status, "active"); + assert.equal(updated?.progressSummary, "Tools wired"); + assert.equal(updated?.nextStep, "Document behavior"); + assert.notEqual(updated?.updatedAt, created.updatedAt); + + const completed = goalStore.updateGoal({ + workspaceSessionId: workspace.id, + status: "complete", + }); + assert.equal(completed?.status, "complete"); + assert.ok(completed?.completedAt); + + const replacement = goalStore.setGoal({ + workspaceSessionId: workspace.id, + objective: "Replacement goal", + }); + assert.equal(replacement.status, "active"); + assert.equal(replacement.progressSummary, ""); + assert.equal(replacement.nextStep, ""); + assert.notEqual(replacement.goalId, created.goalId); + + assert.equal(goalStore.updateGoal({ workspaceSessionId: "missing", status: "blocked" }), undefined); + assert.equal(goalStore.clearGoal(workspace.id), true); + assert.equal(goalStore.clearGoal(workspace.id), false); + assert.equal(goalStore.getGoal(workspace.id), undefined); + + assert.throws( + () => goalStore.setGoal({ workspaceSessionId: workspace.id, objective: " " }), + /Goal objective must not be empty/, + ); + + goalStore.close(); + workspaceStore.close?.(); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/goal-store.ts b/src/goal-store.ts new file mode 100644 index 00000000..e9c70fab --- /dev/null +++ b/src/goal-store.ts @@ -0,0 +1,163 @@ +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 const goalStatuses = ["active", "paused", "blocked", "complete", "cancelled"] as const; +export type GoalStatus = (typeof goalStatuses)[number]; + +export interface WorkspaceGoal { + workspaceSessionId: string; + goalId: string; + objective: string; + status: GoalStatus; + progressSummary: string; + nextStep: string; + createdAt: string; + updatedAt: string; + completedAt?: string; +} + +export interface SetGoalInput { + workspaceSessionId: string; + objective: string; + progressSummary?: string; + nextStep?: string; +} + +export interface UpdateGoalInput { + workspaceSessionId: string; + status?: GoalStatus; + progressSummary?: string; + nextStep?: string; +} + +export interface GoalStore { + getGoal(workspaceSessionId: string): WorkspaceGoal | undefined; + setGoal(input: SetGoalInput): WorkspaceGoal; + updateGoal(input: UpdateGoalInput): WorkspaceGoal | undefined; + clearGoal(workspaceSessionId: string): boolean; + close?(): void; +} + +export class SqliteGoalStore implements GoalStore { + private readonly database: DatabaseHandle; + + constructor(stateDir: string) { + this.database = openDatabase(stateDir); + } + + getGoal(workspaceSessionId: string): WorkspaceGoal | undefined { + const row = this.database.db + .select() + .from(workspaceGoals) + .where(eq(workspaceGoals.workspaceSessionId, workspaceSessionId)) + .get(); + + return row ? rowToWorkspaceGoal(row) : undefined; + } + + setGoal(input: SetGoalInput): WorkspaceGoal { + const now = new Date().toISOString(); + const goal: WorkspaceGoal = { + workspaceSessionId: input.workspaceSessionId, + goalId: `goal_${randomUUID()}`, + objective: input.objective.trim(), + status: "active", + progressSummary: input.progressSummary?.trim() ?? "", + nextStep: input.nextStep?.trim() ?? "", + createdAt: now, + updatedAt: now, + completedAt: undefined, + }; + + if (!goal.objective) { + throw new Error("Goal objective must not be empty."); + } + + const replaceGoal = this.database.sqlite.transaction(() => { + this.database.db + .delete(workspaceGoals) + .where(eq(workspaceGoals.workspaceSessionId, input.workspaceSessionId)) + .run(); + this.database.db + .insert(workspaceGoals) + .values({ + workspaceSessionId: goal.workspaceSessionId, + goalId: goal.goalId, + objective: goal.objective, + status: goal.status, + progressSummary: goal.progressSummary, + nextStep: goal.nextStep, + createdAt: goal.createdAt, + updatedAt: goal.updatedAt, + completedAt: null, + }) + .run(); + }); + replaceGoal.immediate(); + + return goal; + } + + updateGoal(input: UpdateGoalInput): WorkspaceGoal | undefined { + const existing = this.getGoal(input.workspaceSessionId); + if (!existing) return undefined; + + const now = new Date().toISOString(); + const status = input.status ?? existing.status; + const completedAt = status === "complete" + ? existing.completedAt ?? now + : undefined; + + this.database.db + .update(workspaceGoals) + .set({ + status, + progressSummary: input.progressSummary?.trim() ?? existing.progressSummary, + nextStep: input.nextStep?.trim() ?? existing.nextStep, + updatedAt: now, + completedAt: completedAt ?? null, + }) + .where(eq(workspaceGoals.workspaceSessionId, input.workspaceSessionId)) + .run(); + + return this.getGoal(input.workspaceSessionId); + } + + clearGoal(workspaceSessionId: string): boolean { + const result = this.database.db + .delete(workspaceGoals) + .where(eq(workspaceGoals.workspaceSessionId, workspaceSessionId)) + .run(); + + return result.changes > 0; + } + + close(): void { + this.database.close(); + } +} + +export function createGoalStore(stateDir: string): GoalStore { + return new SqliteGoalStore(stateDir); +} + +function rowToWorkspaceGoal(row: WorkspaceGoalRow): WorkspaceGoal { + return { + workspaceSessionId: row.workspaceSessionId, + goalId: row.goalId, + objective: row.objective, + status: parseGoalStatus(row.status), + progressSummary: row.progressSummary, + nextStep: row.nextStep, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + completedAt: row.completedAt ?? undefined, + }; +} + +function parseGoalStatus(status: string): GoalStatus { + if (goalStatuses.includes(status as GoalStatus)) return status as GoalStatus; + return "active"; +} From c4350ffbca2f7fb7c6feb5ec62cd37ce43e257bb Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 3 Jul 2026 20:06:51 +0530 Subject: [PATCH 2/4] Expose workspace goal tools --- src/server.ts | 277 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 273 insertions(+), 4 deletions(-) diff --git a/src/server.ts b/src/server.ts index f13a1b8e..0b467c1f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,6 +19,13 @@ 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, + goalStatuses, + type GoalStatus, + type GoalStore, + type WorkspaceGoal, +} from "./goal-store.js"; import { logEvent, requestIp, @@ -63,6 +70,7 @@ const SHELL_TOOL_ANNOTATIONS = { idempotentHint: false, openWorldHint: true, }; +const GOAL_WORKFLOW_INSTRUCTION = " If goal tools are available, treat goals as workspace-scoped persistent task state. When opening a workspace, resuming after a long gap, seeing a compaction or summary message, losing task context, or before deciding a multi-step task is complete, call get_goal to reload any active goal. If an active goal exists, preserve its full objective, continue from the stored progress and next step, update progress after meaningful work, and mark it complete only when current evidence proves the full objective is satisfied."; interface RunningServer { app: ReturnType; @@ -163,13 +171,14 @@ interface ToolLogFields { } function serverInstructions(config: ServerConfig): string { + const goalInstruction = config.goalsEnabled ? GOAL_WORKFLOW_INSTRUCTION : ""; const showChangesInstruction = config.widgets === "changes" ? " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs." : ""; 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.${showChangesInstruction}`; + 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.${goalInstruction}${showChangesInstruction}`; } const inspection = config.toolMode !== "full" @@ -182,7 +191,7 @@ function serverInstructions(config: ServerConfig): string { const agentsMd = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it. `; - 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.${showChangesInstruction}`; + 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.${goalInstruction}${showChangesInstruction}`; } function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { return { @@ -210,6 +219,18 @@ const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); +const workspaceGoalOutputSchema = z.object({ + workspaceSessionId: z.string(), + goalId: z.string(), + objective: z.string(), + status: z.enum(goalStatuses), + progressSummary: z.string(), + nextStep: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + completedAt: z.string().optional(), +}); + const reviewFileOutputSchema = z.object({ path: z.string(), previousPath: z.string().optional(), @@ -485,6 +506,239 @@ function processToolResponse( }; } +function goalInstruction(goal: WorkspaceGoal | undefined): string { + if (!goal) { + return "No workspace goal is set. Continue with the user's current request."; + } + + switch (goal.status) { + case "active": + return "Continue pursuing the full active goal. Use the stored progress summary and next step as context, inspect current workspace state before relying on memory, and update the goal after meaningful progress."; + case "paused": + return "The workspace goal is paused. Do not continue it unless the user asks to resume or update it."; + case "blocked": + return "The workspace goal is blocked. Explain the blocker and wait for user input or an external-state change before continuing it."; + case "complete": + return "The workspace goal is complete. Do not reopen it unless the user asks to resume or replace it."; + case "cancelled": + return "The workspace goal is cancelled. Do not continue it unless the user creates or replaces the goal."; + } +} + +function goalResultText(goal: WorkspaceGoal | undefined): string { + if (!goal) return "No goal is set for this workspace."; + + return [ + `Goal ${goal.goalId} is ${goal.status}.`, + `Objective: ${goal.objective}`, + goal.progressSummary ? `Progress: ${goal.progressSummary}` : undefined, + goal.nextStep ? `Next step: ${goal.nextStep}` : undefined, + goal.completedAt ? `Completed at: ${goal.completedAt}` : undefined, + goalInstruction(goal), + ].filter(Boolean).join("\n"); +} + +function goalToolResponse(tool: string, workspaceId: string, goal: WorkspaceGoal | undefined) { + const result = goalResultText(goal); + const content = [textBlock(result)]; + return { + content, + _meta: { + tool, + card: { + workspaceId, + summary: { + status: goal?.status ?? "none", + hasGoal: Boolean(goal), + }, + payload: { goal, instruction: goalInstruction(goal) }, + }, + }, + structuredContent: { + result, + goal: goal ?? null, + instruction: goalInstruction(goal), + }, + }; +} + +function registerGoalTools( + server: McpServer, + config: ServerConfig, + workspaces: WorkspaceRegistry, + goals: GoalStore, +): void { + registerAppTool( + server, + "get_goal", + { + title: "Get goal", + description: + "Get the workspace-scoped persistent goal for an open workspace. Call this after open_workspace when goal tools are available, after a compaction or summary message, after a long gap, when task context seems incomplete, and before marking multi-step work complete.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: resultOutputSchema({ + goal: workspaceGoalOutputSchema.nullable(), + instruction: z.string(), + }), + _meta: {}, + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = goals.getGoal(workspaceId); + logToolCall(config, { + tool: "get_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return goalToolResponse("get_goal", workspaceId, goal); + }, + ); + + registerAppTool( + server, + "set_goal", + { + title: "Set goal", + description: + "Create or replace the single persistent goal for an open workspace. Use this only when the user explicitly asks to track a goal or clearly gives a durable multi-step objective to preserve across compaction and future turns.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + objective: z.string().min(1).describe("Full user objective to preserve."), + progressSummary: z + .string() + .optional() + .describe("Optional concise progress summary for future context reloads."), + nextStep: z + .string() + .optional() + .describe("Optional next concrete step to continue the goal."), + }, + outputSchema: resultOutputSchema({ + goal: workspaceGoalOutputSchema, + instruction: z.string(), + }), + _meta: {}, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + async ({ workspaceId, objective, progressSummary, nextStep }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const goal = goals.setGoal({ + workspaceSessionId: workspaceId, + objective, + progressSummary, + nextStep, + }); + logToolCall(config, { + tool: "set_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return goalToolResponse("set_goal", workspaceId, goal); + }, + ); + + registerAppTool( + server, + "update_goal", + { + title: "Update goal", + description: + "Update the workspace goal's status, progress summary, or next step. Keep progress summaries compact and evidence-based. Mark complete only when current workspace evidence proves the full objective is satisfied; mark blocked only when meaningful progress is genuinely impossible without user input or an external-state change.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + status: z.enum(goalStatuses).optional().describe("Optional new goal status."), + progressSummary: z + .string() + .optional() + .describe("Concise durable progress summary for future context reloads."), + nextStep: z + .string() + .optional() + .describe("Next concrete step for continuing the goal."), + }, + outputSchema: resultOutputSchema({ + goal: workspaceGoalOutputSchema.nullable(), + instruction: z.string(), + }), + _meta: {}, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + async ({ workspaceId, status, progressSummary, nextStep }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + if (status === undefined && progressSummary === undefined && nextStep === undefined) { + throw new Error("Provide at least one of status, progressSummary, or nextStep."); + } + const goal = goals.updateGoal({ + workspaceSessionId: workspaceId, + status: status as GoalStatus | undefined, + progressSummary, + nextStep, + }); + logToolCall(config, { + tool: "update_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return goalToolResponse("update_goal", workspaceId, goal); + }, + ); + + registerAppTool( + server, + "clear_goal", + { + title: "Clear goal", + description: + "Clear the persistent goal for an open workspace. Use this when the user asks to remove goal tracking or replace the workflow with no active goal.", + inputSchema: { + workspaceId: z.string().describe("Workspace identifier returned by open_workspace."), + }, + outputSchema: resultOutputSchema({ + removed: z.boolean(), + }), + _meta: {}, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + workspaces.getWorkspace(workspaceId); + const removed = goals.clearGoal(workspaceId); + const result = removed ? "Cleared the workspace goal." : "No goal was set for this workspace."; + const content = [textBlock(result)]; + logToolCall(config, { + tool: "clear_goal", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); + return { + content, + _meta: { + tool: "clear_goal", + card: { + workspaceId, + summary: { removed }, + payload: { removed }, + }, + }, + structuredContent: { + result, + removed, + }, + }; + }, + ); +} + function registerCodexProcessTools( server: McpServer, config: ServerConfig, @@ -633,6 +887,7 @@ function createMcpServer( workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, processSessions: ProcessSessionManager, + goalStore?: GoalStore, ): McpServer { const server = new McpServer( { @@ -721,6 +976,7 @@ function createMcpServer( availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), skills: z.array(workspaceSkillOutputSchema), skillDiagnostics: z.array(z.unknown()), + goal: workspaceGoalOutputSchema.nullable().optional(), instruction: z.string(), }, ...toolWidgetDescriptorMeta(config, "workspace"), @@ -749,9 +1005,13 @@ function createMcpServer( const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), })); - const instruction = config.skillsEnabled + const goal = goalStore?.getGoal(workspace.id); + const baseInstruction = 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 instruction = goalStore + ? `${baseInstruction}${GOAL_WORKFLOW_INSTRUCTION}` + : baseInstruction; const resultContent: ToolContent[] = [ { type: "text" as const, @@ -768,6 +1028,7 @@ function createMcpServer( visibleSkills.length > 0 ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}` : undefined, + goal ? `Workspace goal: ${goal.status} - ${goal.objective}` : undefined, instruction, ].filter(Boolean).join("\n"), }, @@ -793,6 +1054,7 @@ function createMcpServer( availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, skillDiagnostics: workspace.skillDiagnostics.length, + goalStatus: goal?.status ?? "none", }, }, }, @@ -806,6 +1068,7 @@ function createMcpServer( availableAgentsFiles: availableAgentsFileOutputs, skills: visibleSkills, skillDiagnostics: workspace.skillDiagnostics, + goal: goalStore ? goal ?? null : undefined, instruction, }, }; @@ -1508,6 +1771,10 @@ function createMcpServer( ); } + if (goalStore) { + registerGoalTools(server, config, workspaces, goalStore); + } + if (config.toolMode === "codex") { registerCodexProcessTools(server, config, workspaces, processSessions); } @@ -1533,6 +1800,7 @@ export function createServer(config = loadConfig()): RunningServer { resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl), }); const workspaceStore = createWorkspaceStore(config.stateDir); + const goalStore = config.goalsEnabled ? createGoalStore(config.stateDir) : undefined; const workspaces = new WorkspaceRegistry(config, workspaceStore); const reviewCheckpoints = createReviewCheckpointManager(); const processSessions = new ProcessSessionManager(); @@ -1659,7 +1927,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"); @@ -1687,6 +1955,7 @@ export function createServer(config = loadConfig()): RunningServer { closed = true; processSessions.shutdown(); oauthProvider.close(); + goalStore?.close?.(); workspaceStore.close?.(); }, }; From a74b2e1cd3207054d6c2b8f6a3704f370c2bafad Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 3 Jul 2026 20:09:13 +0530 Subject: [PATCH 3/4] Document workspace goal tracking --- docs/chatgpt-coding-workflow.md | 26 ++++++++++++++++++++++++++ docs/configuration.md | 15 +++++++++++++++ src/oauth-store.test.ts | 1 + 3 files changed, 42 insertions(+) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 8f75d57b..657755c1 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -101,6 +101,29 @@ Skill paths may be outside the workspace. DevSpace only permits reading: Set `DEVSPACE_SKILLS=0` to hide skills from workspace output. +## Goal Tracking + +Goal tracking is optional and disabled by default. Start DevSpace with: + +```bash +DEVSPACE_GOALS=1 devspace serve +``` + +When enabled, DevSpace exposes workspace-scoped goal tools: + +- `get_goal` +- `set_goal` +- `update_goal` +- `clear_goal` + +A goal belongs to the opened `workspaceId`. It is durable DevSpace state that +helps the model recover the full objective, progress summary, and next step +after compaction, summary messages, long gaps, or lost context. The model should +call `get_goal` in those moments and before declaring a multi-step goal complete. + +This is not autonomous Codex goal mode: DevSpace does not wake the model for +continuation turns, control the host harness, or track model token budgets. + ## Tool Names DevSpace exposes these tool names: @@ -111,6 +134,9 @@ DevSpace exposes these tool names: - `edit` - `bash` +When `DEVSPACE_GOALS=1`, DevSpace also exposes `get_goal`, `set_goal`, +`update_goal`, and `clear_goal` across tool modes. + By default, DevSpace also runs in `DEVSPACE_TOOL_MODE=minimal`, so dedicated `grep`, `glob`, and `ls` tools are hidden. Use `bash` with command-line tools such as `rg`, `find`, and `ls` for search and directory inspection. diff --git a/docs/configuration.md b/docs/configuration.md index 84589ac3..3f4e3d74 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,6 +38,7 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com | `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. | | `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. | | `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. | +| `DEVSPACE_GOALS` | Set to `1` to enable workspace-scoped goal tools. Disabled by default. | ## OAuth @@ -77,6 +78,20 @@ Codex-mode commands run without a PTY by default. Set `tty: true` on `node-pty` dependency; `write_stdin` can send input, poll output, and resize PTY sessions. +## Goal Tracking + +Set `DEVSPACE_GOALS=1` to expose optional workspace-scoped goal tools: + +- `get_goal` +- `set_goal` +- `update_goal` +- `clear_goal` + +Goals are stored in DevSpace state and scoped to the opened `workspaceId`. They +help the model reload the full objective, progress summary, and next step after +compaction, summaries, long gaps, or context loss. DevSpace does not auto-start +new model turns, detect host compaction directly, or track model token budgets. + ## Widgets `DEVSPACE_WIDGETS` controls ChatGPT Apps iframe usage. diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 2f2a873c..c15e701f 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(); From c5a23315f8e6dd29634d7ef651a8dc00e5b0033a Mon Sep 17 00:00:00 2001 From: Waishnav Date: Fri, 3 Jul 2026 20:12:31 +0530 Subject: [PATCH 4/4] Clarify goal tracking parity gaps --- docs/chatgpt-coding-workflow.md | 23 +++++++++++++++++++++-- docs/configuration.md | 8 ++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 657755c1..4fcd0ccc 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -121,8 +121,27 @@ helps the model recover the full objective, progress summary, and next step after compaction, summary messages, long gaps, or lost context. The model should call `get_goal` in those moments and before declaring a multi-step goal complete. -This is not autonomous Codex goal mode: DevSpace does not wake the model for -continuation turns, control the host harness, or track model token budgets. +This is not autonomous Codex goal mode, and it is not intended to claim one-for-one +feature parity with Codex goals. DevSpace is an MCP server, so it can provide +durable goal state and model-visible tools, but it cannot currently control the +host model lifecycle. + +Current gaps and open questions: + +- DevSpace cannot wake the model for automatic continuation turns when a thread + becomes idle. +- DevSpace cannot inject hidden goal context into every model turn the way a + harness can. +- DevSpace cannot directly detect that the host compacted the conversation; + the model has to call `get_goal` after seeing a summary, losing context, or + resuming work. +- DevSpace does not receive reliable model token usage, so goal token budgets + are intentionally not implemented. +- DevSpace scopes goals to `workspaceId`, not to the host's chat thread, because + MCP does not expose a stable ChatGPT or Claude thread identifier. +- Future work may explore host-provided session/thread metadata, explicit UI + controls for goals, richer progress history, and better recovery hooks if MCP + hosts expose lifecycle events. ## Tool Names diff --git a/docs/configuration.md b/docs/configuration.md index 3f4e3d74..ff46c1e7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -89,8 +89,12 @@ Set `DEVSPACE_GOALS=1` to expose optional workspace-scoped goal tools: Goals are stored in DevSpace state and scoped to the opened `workspaceId`. They help the model reload the full objective, progress summary, and next step after -compaction, summaries, long gaps, or context loss. DevSpace does not auto-start -new model turns, detect host compaction directly, or track model token budgets. +compaction, summaries, long gaps, or context loss. + +This is not one-for-one Codex goal parity. DevSpace does not auto-start new model +turns, inject hidden goal context into every turn, detect host compaction +directly, or track model token budgets. See the ChatGPT coding workflow guide for +current gaps and open questions. ## Widgets