Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/chatgpt-coding-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,48 @@ 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, 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

DevSpace exposes these tool names:
Expand All @@ -111,6 +153,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.
Expand Down
19 changes: 19 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -77,6 +78,24 @@ 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.

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

`DEVSPACE_WIDGETS` controls ChatGPT Apps iframe usage.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
3 changes: 3 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface ServerConfig {
widgets: WidgetMode;
stateDir: string;
worktreeRoot: string;
goalsEnabled: boolean;
skillsEnabled: boolean;
skillPaths: string[];
agentDir: string;
Expand Down Expand Up @@ -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())),
Expand Down
27 changes: 27 additions & 0 deletions src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
Expand Down
22 changes: 22 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down Expand Up @@ -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;
74 changes: 74 additions & 0 deletions src/goal-store.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading
Loading