From 25f28d7d464ed5f6bd2e5ee5e1e84587558a9d93 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Wed, 4 Feb 2026 17:23:13 +0700 Subject: [PATCH 01/30] Fix auto mode concurrency slider not updating when no worktree selected --- apps/ui/src/components/views/board-view.tsx | 8 +++++-- package-lock.json | 23 ++++++++++++++------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 2624514a3..1c7fd5832 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1274,9 +1274,13 @@ export function BoardView() { maxConcurrency={maxConcurrency} runningAgentsCount={runningAutoTasks.length} onConcurrencyChange={(newMaxConcurrency) => { - if (currentProject && selectedWorktree) { - const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch; + // Allow changing concurrency even if no worktree is explicitly selected yet. + // Default to main worktree in that case. + if (currentProject) { + const branchName = + selectedWorktree && !selectedWorktree.isMain ? selectedWorktree.branch : null; setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency); + // Also update backend if auto mode is running if (autoMode.isRunning) { // Restart auto mode with new concurrency (backend will handle this) diff --git a/package-lock.json b/package-lock.json index c86ba4aa9..ae8952661 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "automaker", - "version": "0.12.0rc", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "automaker", - "version": "0.12.0rc", + "version": "0.13.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -32,7 +32,7 @@ }, "apps/server": { "name": "@automaker/server", - "version": "0.12.0", + "version": "0.13.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.1.76", @@ -83,7 +83,7 @@ }, "apps/ui": { "name": "@automaker/ui", - "version": "0.12.0", + "version": "0.13.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -6218,7 +6218,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -6228,7 +6227,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -8439,7 +8438,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/d3-color": { @@ -11333,6 +11331,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11354,6 +11353,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11375,6 +11375,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11396,6 +11397,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11417,6 +11419,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11438,6 +11441,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11459,6 +11463,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11480,6 +11485,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11501,6 +11507,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11522,6 +11529,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11543,6 +11551,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, From 2f06db1e833f536b849f74d2a68c2b263eed3cd7 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 6 Feb 2026 01:39:43 +0700 Subject: [PATCH 02/30] Update default Claude Opus model to 4.6 --- CLAUDE.md | 2 +- libs/types/src/model.ts | 6 +++--- libs/types/src/settings.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 128cd8d71..84dd1fbb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,7 +161,7 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali - `haiku` → `claude-haiku-4-5` - `sonnet` → `claude-sonnet-4-20250514` -- `opus` → `claude-opus-4-5-20251101` +- `opus` → `claude-opus-4-6` ## Environment Variables diff --git a/libs/types/src/model.ts b/libs/types/src/model.ts index 2973a8927..99aa53961 100644 --- a/libs/types/src/model.ts +++ b/libs/types/src/model.ts @@ -17,7 +17,7 @@ export type ClaudeCanonicalId = 'claude-haiku' | 'claude-sonnet' | 'claude-opus' export const CLAUDE_CANONICAL_MAP: Record = { 'claude-haiku': 'claude-haiku-4-5-20251001', 'claude-sonnet': 'claude-sonnet-4-5-20250929', - 'claude-opus': 'claude-opus-4-5-20251101', + 'claude-opus': 'claude-opus-4-6', } as const; /** @@ -28,7 +28,7 @@ export const CLAUDE_CANONICAL_MAP: Record = { export const CLAUDE_MODEL_MAP: Record = { haiku: 'claude-haiku-4-5-20251001', sonnet: 'claude-sonnet-4-5-20250929', - opus: 'claude-opus-4-5-20251101', + opus: 'claude-opus-4-6', } as const; /** @@ -95,7 +95,7 @@ export function getAllCodexModelIds(): CodexModelId[] { * Uses canonical prefixed IDs for consistent routing. */ export const DEFAULT_MODELS = { - claude: 'claude-opus-4-5-20251101', + claude: 'claude-opus-4-6', cursor: 'cursor-auto', // Cursor's recommended default (with prefix) codex: CODEX_MODEL_MAP.gpt52Codex, // GPT-5.2-Codex is the most advanced agentic coding model } as const; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 8a10a6f81..317638961 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -276,7 +276,7 @@ export const CLAUDE_PROVIDER_TEMPLATES: ClaudeCompatibleProviderTemplate[] = [ defaultModels: [ { id: 'claude-haiku', displayName: 'Claude Haiku', mapsToClaudeModel: 'haiku' }, { id: 'claude-sonnet', displayName: 'Claude Sonnet', mapsToClaudeModel: 'sonnet' }, - { id: 'claude-opus', displayName: 'Claude Opus', mapsToClaudeModel: 'opus' }, + { id: 'claude-opus', displayName: 'Opus 4.6', mapsToClaudeModel: 'opus' }, ], }, { From 3d15732e7db6802f567654764d4d9cb410141541 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 6 Feb 2026 01:49:05 +0700 Subject: [PATCH 03/30] Label Opus as Opus 4.6 in UI --- libs/types/src/model-display.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/types/src/model-display.ts b/libs/types/src/model-display.ts index 235466cd0..c96c97f72 100644 --- a/libs/types/src/model-display.ts +++ b/libs/types/src/model-display.ts @@ -59,7 +59,7 @@ export const CLAUDE_MODELS: ModelOption[] = [ }, { id: 'opus', - label: 'Claude Opus', + label: 'Opus 4.6', description: 'Most capable model for complex work.', badge: 'Premium', provider: 'claude', @@ -193,7 +193,8 @@ export function getModelDisplayName(model: ModelAlias | string): string { const displayNames: Record = { haiku: 'Claude Haiku', sonnet: 'Claude Sonnet', - opus: 'Claude Opus', + opus: 'Opus 4.6', + 'claude-opus-4-6': 'Opus 4.6', [CODEX_MODEL_MAP.gpt52Codex]: 'GPT-5.2-Codex', [CODEX_MODEL_MAP.gpt51CodexMax]: 'GPT-5.1-Codex-Max', [CODEX_MODEL_MAP.gpt51CodexMini]: 'GPT-5.1-Codex-Mini', From a47cd04345f739e08518c3439adf5b7efa62be69 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 6 Feb 2026 03:06:20 +0700 Subject: [PATCH 04/30] Add Codex 5.3 model entries --- .../src/services/codex-model-cache-service.ts | 6 +++++- libs/types/src/model-display.ts | 16 ++++++++++++++++ libs/types/src/model.ts | 9 +++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/codex-model-cache-service.ts b/apps/server/src/services/codex-model-cache-service.ts index 7e171428c..78b994e01 100644 --- a/apps/server/src/services/codex-model-cache-service.ts +++ b/apps/server/src/services/codex-model-cache-service.ts @@ -193,7 +193,11 @@ export class CodexModelCacheService { * Infer tier from model ID */ private inferTier(modelId: string): 'premium' | 'standard' | 'basic' { - if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) { + if ( + modelId.includes('max') || + modelId.includes('gpt-5.3-codex') || + modelId.includes('gpt-5.2-codex') + ) { return 'premium'; } if (modelId.includes('mini')) { diff --git a/libs/types/src/model-display.ts b/libs/types/src/model-display.ts index c96c97f72..3fc6560e1 100644 --- a/libs/types/src/model-display.ts +++ b/libs/types/src/model-display.ts @@ -71,6 +71,14 @@ export const CLAUDE_MODELS: ModelOption[] = [ * Official models from https://developers.openai.com/codex/models/ */ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [ + { + id: CODEX_MODEL_MAP.gpt53Codex, + label: 'GPT-5.3-Codex', + description: 'Most advanced agentic coding model for complex software engineering.', + badge: 'Premium', + provider: 'codex', + hasReasoning: true, + }, { id: CODEX_MODEL_MAP.gpt52Codex, label: 'GPT-5.2-Codex', @@ -95,6 +103,14 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [ provider: 'codex', hasReasoning: false, }, + { + id: CODEX_MODEL_MAP.gpt53, + label: 'GPT-5.3', + description: 'Best general agentic model for tasks across industries and domains.', + badge: 'Balanced', + provider: 'codex', + hasReasoning: true, + }, { id: CODEX_MODEL_MAP.gpt52, label: 'GPT-5.2', diff --git a/libs/types/src/model.ts b/libs/types/src/model.ts index 99aa53961..c54600c19 100644 --- a/libs/types/src/model.ts +++ b/libs/types/src/model.ts @@ -49,7 +49,9 @@ export const LEGACY_CLAUDE_ALIAS_MAP: Record = { */ export const CODEX_MODEL_MAP = { // Recommended Codex-specific models - /** Most advanced agentic coding model for complex software engineering (default for ChatGPT users) */ + /** Most advanced agentic coding model for complex software engineering (when available) */ + gpt53Codex: 'codex-gpt-5.3-codex', + /** Most advanced agentic coding model for complex software engineering (current default in many accounts) */ gpt52Codex: 'codex-gpt-5.2-codex', /** Optimized for long-horizon, agentic coding tasks in Codex */ gpt51CodexMax: 'codex-gpt-5.1-codex-max', @@ -57,6 +59,8 @@ export const CODEX_MODEL_MAP = { gpt51CodexMini: 'codex-gpt-5.1-codex-mini', // General-purpose GPT models (also available in Codex) + /** Best general agentic model for tasks across industries and domains (when available) */ + gpt53: 'codex-gpt-5.3', /** Best general agentic model for tasks across industries and domains */ gpt52: 'codex-gpt-5.2', /** Great for coding and agentic tasks across domains */ @@ -97,7 +101,8 @@ export function getAllCodexModelIds(): CodexModelId[] { export const DEFAULT_MODELS = { claude: 'claude-opus-4-6', cursor: 'cursor-auto', // Cursor's recommended default (with prefix) - codex: CODEX_MODEL_MAP.gpt52Codex, // GPT-5.2-Codex is the most advanced agentic coding model + // Keep the safe default at 5.2 until 5.3 is confirmed available in the connected Codex account. + codex: CODEX_MODEL_MAP.gpt52Codex, } as const; export type ModelAlias = keyof typeof CLAUDE_MODEL_MAP; From cf479e8876269c3b4750713a97ca53e6b2603cf5 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 6 Feb 2026 03:54:28 +0700 Subject: [PATCH 05/30] Add debug endpoint for resolved model proof --- apps/server/src/index.ts | 2 + apps/server/src/routes/debug/index.ts | 88 +++++++++++++++++++++ apps/ui/public/model-proof.html | 106 ++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 apps/server/src/routes/debug/index.ts create mode 100644 apps/ui/public/model-proof.html diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 3c90fd385..ed1d84227 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -83,6 +83,7 @@ import { createNotificationsRoutes } from './routes/notifications/index.js'; import { getNotificationService } from './services/notification-service.js'; import { createEventHistoryRoutes } from './routes/event-history/index.js'; import { getEventHistoryService } from './services/event-history-service.js'; +import { createDebugRoutes } from './routes/debug/index.js'; // Load environment variables dotenv.config(); @@ -344,6 +345,7 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService)); app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader)); app.use('/api/notifications', createNotificationsRoutes(notificationService)); app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService)); +app.use('/api/debug', createDebugRoutes(settingsService)); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/debug/index.ts b/apps/server/src/routes/debug/index.ts new file mode 100644 index 000000000..d8eb85194 --- /dev/null +++ b/apps/server/src/routes/debug/index.ts @@ -0,0 +1,88 @@ +import type { Request, Response } from 'express'; +import express from 'express'; + +import { resolveModelString, resolvePhaseModel } from '@automaker/model-resolver'; +import { DEFAULT_MODELS } from '@automaker/types'; +import type { SettingsService } from '../../services/settings-service.js'; + +/** + * Debug routes (authenticated) + * + * These endpoints are intended for local verification and troubleshooting. + * Do not return secrets. + */ +export function createDebugRoutes(settingsService: SettingsService) { + const router = express.Router(); + + /** + * Return the raw configured model keys and their resolved effective model IDs. + * + * This is the authoritative source for "which model will be used" because it uses + * the same resolver as agent runs. + */ + router.get('/resolved-models', async (_req: Request, res: Response) => { + const settings = await settingsService.getGlobalSettings(); + + const defaultFeatureModelKey = settings.defaultFeatureModel?.model; + + const phaseModels = settings.phaseModels || ({} as any); + + const specGeneration = resolvePhaseModel( + phaseModels.specGenerationModel, + DEFAULT_MODELS.claude + ); + const backlogPlanning = resolvePhaseModel( + phaseModels.backlogPlanningModel, + DEFAULT_MODELS.claude + ); + const validation = resolvePhaseModel(phaseModels.validationModel, DEFAULT_MODELS.claude); + + // Also show what the legacy "validationModel" / "enhancementModel" shortcuts are set to (if present) + const legacyValidationModelKey = (settings as any).validationModel; + const legacyEnhancementModelKey = (settings as any).enhancementModel; + + const result = { + now: new Date().toISOString(), + defaults: { + DEFAULT_MODELS, + }, + configured: { + defaultFeatureModelKey, + phaseModels: { + specGenerationModel: phaseModels.specGenerationModel, + backlogPlanningModel: phaseModels.backlogPlanningModel, + validationModel: phaseModels.validationModel, + }, + legacy: { + validationModelKey: legacyValidationModelKey, + enhancementModelKey: legacyEnhancementModelKey, + }, + }, + resolved: { + defaultFeatureModel: { + key: defaultFeatureModelKey, + resolved: resolveModelString(defaultFeatureModelKey, DEFAULT_MODELS.claude), + }, + phaseModels: { + specGenerationModel: specGeneration, + backlogPlanningModel: backlogPlanning, + validationModel: validation, + }, + legacy: { + validationModel: { + key: legacyValidationModelKey, + resolved: resolveModelString(legacyValidationModelKey, DEFAULT_MODELS.claude), + }, + enhancementModel: { + key: legacyEnhancementModelKey, + resolved: resolveModelString(legacyEnhancementModelKey, DEFAULT_MODELS.claude), + }, + }, + }, + }; + + res.json(result); + }); + + return router; +} diff --git a/apps/ui/public/model-proof.html b/apps/ui/public/model-proof.html new file mode 100644 index 000000000..1a9f3d172 --- /dev/null +++ b/apps/ui/public/model-proof.html @@ -0,0 +1,106 @@ + + + + + + Automaker Model Proof + + + +

Automaker Model Proof (authoritative)

+

+ This page calls /api/debug/resolved-models and displays the server-side resolved + model IDs. That is the real proof of what Automaker will use. +

+ +

Status: Loading…

+ +

Key checks

+
    +
  • + Backlog planning model resolved to: + (loading) +
  • +
  • + Spec generation model resolved to: + (loading) +
  • +
  • + Default feature model resolved to: + (loading) +
  • +
+ +

Raw JSON

+
(loading)
+ + + + From 12df9eb805f45240c582ab7ef4e2511bd9b8573e Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 6 Feb 2026 10:55:45 +0700 Subject: [PATCH 06/30] platform: prefer working codex paths; support Volta locations --- libs/platform/src/system-paths.ts | 77 +++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 8c2125613..7d5fcb74e 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -25,6 +25,20 @@ import fs from 'fs/promises'; // System Tool Path Definitions // ============================================================================= +/** + * Get Volta installation directories (not the tool shims) + * Used for allowing system-path access checks for Volta-dependent shims. + */ +function getVoltaDirs(): string[] { + if (process.platform !== 'win32') { + return [path.join(os.homedir(), '.volta')]; + } + + const homeDir = os.homedir(); + const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); + return [path.join(localAppData, 'Volta'), path.join(homeDir, '.volta')]; +} + /** * Get common paths where GitHub CLI might be installed */ @@ -130,17 +144,27 @@ export function getCodexCliPaths(): string[] { if (isWindows) { const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'); const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); + // Prefer version-manager shims (Volta/pnpm) over legacy npm global bins + // so Automaker picks up the same Codex version your shell resolves. return [ path.join(homeDir, '.local', 'bin', 'codex.exe'), - path.join(appData, 'npm', 'codex.cmd'), - path.join(appData, 'npm', 'codex'), - path.join(appData, '.npm-global', 'bin', 'codex.cmd'), - path.join(appData, '.npm-global', 'bin', 'codex'), // Volta on Windows + // - Newer Volta installs often use %LOCALAPPDATA%\\Volta\\bin + // - Older installs may use %USERPROFILE%\\.volta\\bin + path.join(localAppData, 'Volta', 'bin', 'codex.exe'), + path.join(localAppData, 'Volta', 'bin', 'codex.cmd'), + path.join(localAppData, 'Volta', 'bin', 'codex'), path.join(homeDir, '.volta', 'bin', 'codex.exe'), + path.join(homeDir, '.volta', 'bin', 'codex.cmd'), + path.join(homeDir, '.volta', 'bin', 'codex'), // pnpm on Windows path.join(localAppData, 'pnpm', 'codex.cmd'), path.join(localAppData, 'pnpm', 'codex'), + // npm global fallback + path.join(appData, 'npm', 'codex.cmd'), + path.join(appData, 'npm', 'codex'), + path.join(appData, '.npm-global', 'bin', 'codex.cmd'), + path.join(appData, '.npm-global', 'bin', 'codex'), ]; } @@ -657,6 +681,7 @@ function getAllAllowedSystemDirs(): string[] { // Version managers (need recursive access for version directories) ...getNvmPaths(), ...getFnmPaths(), + ...getVoltaDirs(), ]; } @@ -963,7 +988,49 @@ export async function findClaudeCliPath(): Promise { } export async function findCodexCliPath(): Promise { - return findFirstExistingPath(getCodexCliPaths()); + const candidates = getCodexCliPaths(); + + // On Windows, Volta shims (codex.cmd / codex bash shim) require a working `volta` binary. + // In some setups, the shim files can exist without Volta itself being installed, which would + // cause Codex detection to pick an unusable path. Guard against that here. + if (process.platform === 'win32') { + const hasVolta = async (): Promise => { + const homeDir = os.homedir(); + const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'); + const pathsToCheck = [ + path.join(localAppData, 'Volta', 'bin', 'volta.exe'), + path.join(localAppData, 'Volta', 'bin', 'volta.cmd'), + path.join(homeDir, '.volta', 'bin', 'volta.exe'), + path.join(homeDir, '.volta', 'bin', 'volta.cmd'), + ]; + + for (const p of pathsToCheck) { + if (await systemPathAccess(p)) return true; + } + return false; + }; + + const voltaAvailable = await hasVolta(); + + for (const p of candidates) { + if (!(await systemPathAccess(p))) continue; + + const lower = p.toLowerCase(); + const isVoltaShim = + lower.includes('\\volta\\bin\\codex') || lower.includes('\\.volta\\bin\\codex'); + + if (isVoltaShim && !voltaAvailable) { + // Skip unusable shim + continue; + } + + return p; + } + + return null; + } + + return findFirstExistingPath(candidates); } /** From 0a77f7864eed7da10235db5bac1aa93dd74951d3 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 6 Feb 2026 11:56:57 +0700 Subject: [PATCH 07/30] Add restart/status scripts for automaker --- restart-automaker.cmd | 39 +++++++++++++++++++++++++++++++++++++++ status-automaker.cmd | 18 ++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 restart-automaker.cmd create mode 100644 status-automaker.cmd diff --git a/restart-automaker.cmd b/restart-automaker.cmd new file mode 100644 index 000000000..ce3fc5de2 --- /dev/null +++ b/restart-automaker.cmd @@ -0,0 +1,39 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +REM restart-automaker.cmd +REM Kills anything listening on ports 3007 (UI) and 3008 (backend), then starts both. +REM Run from: C:\Users\Robo1\.openclaw\workspace\automaker + +set "ROOT=%~dp0" + +echo. +echo === Automaker restart (%DATE% %TIME%) === +echo Root: %ROOT% +echo. + +call :killPort 3007 +call :killPort 3008 + +echo. +echo Starting backend (apps/server) on http://localhost:3008 ... +REM Using tsx directly is more reliable than watch-mode when starting/stopping frequently +start "Automaker Backend" cmd /k "cd /d "%ROOT%apps\server" ^&^& npx.cmd --yes tsx src/index.ts" + +echo Starting UI (apps/ui) on http://localhost:3007 ... +start "Automaker UI" cmd /k "cd /d "%ROOT%" ^&^& npm.cmd run _dev:web" + +echo. +echo Done. Open: http://localhost:3007/ +echo (If accessing from another device, use: http://YOUR-LAN-IP:3007/ ) +echo. +goto :eof + +:killPort +set "PORT=%~1" +echo --- Killing anything on port %PORT% --- +for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":%PORT%" ^| findstr LISTENING') do ( + echo taskkill /PID %%a /F + taskkill /PID %%a /F >nul 2>&1 +) +exit /b 0 diff --git a/status-automaker.cmd b/status-automaker.cmd new file mode 100644 index 000000000..c30838f83 --- /dev/null +++ b/status-automaker.cmd @@ -0,0 +1,18 @@ +@echo off +setlocal + +echo === Automaker status (%DATE% %TIME%) === +echo. + +echo Port 3007 (UI): +netstat -ano | findstr LISTENING | findstr ":3007" || echo (not listening) + +echo. +echo Port 3008 (Backend): +netstat -ano | findstr LISTENING | findstr ":3008" || echo (not listening) + +echo. +echo Try open: +echo http://localhost:3007/ +echo http://localhost:3008/api/health +echo. From 2b46a92589569630906e657e1dd47c149ff933f9 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 6 Feb 2026 13:07:55 +0700 Subject: [PATCH 08/30] Fix quoting in restart-automaker.cmd for cmd.exe --- restart-automaker.cmd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/restart-automaker.cmd b/restart-automaker.cmd index ce3fc5de2..566e196d6 100644 --- a/restart-automaker.cmd +++ b/restart-automaker.cmd @@ -18,10 +18,10 @@ call :killPort 3008 echo. echo Starting backend (apps/server) on http://localhost:3008 ... REM Using tsx directly is more reliable than watch-mode when starting/stopping frequently -start "Automaker Backend" cmd /k "cd /d "%ROOT%apps\server" ^&^& npx.cmd --yes tsx src/index.ts" +start "Automaker Backend" cmd /k "cd /d \"%ROOT%apps\server\" ^&^& npx.cmd --yes tsx src/index.ts" echo Starting UI (apps/ui) on http://localhost:3007 ... -start "Automaker UI" cmd /k "cd /d "%ROOT%" ^&^& npm.cmd run _dev:web" +start "Automaker UI" cmd /k "cd /d \"%ROOT%\" ^&^& npm.cmd run _dev:web" echo. echo Done. Open: http://localhost:3007/ From 93ccba2982417adf00fa7a30c48a0ff36b43f380 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 6 Feb 2026 13:10:46 +0700 Subject: [PATCH 09/30] Fix cmd quoting in restart-automaker.cmd --- restart-automaker.cmd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/restart-automaker.cmd b/restart-automaker.cmd index 566e196d6..a4d97cf4a 100644 --- a/restart-automaker.cmd +++ b/restart-automaker.cmd @@ -18,10 +18,10 @@ call :killPort 3008 echo. echo Starting backend (apps/server) on http://localhost:3008 ... REM Using tsx directly is more reliable than watch-mode when starting/stopping frequently -start "Automaker Backend" cmd /k "cd /d \"%ROOT%apps\server\" ^&^& npx.cmd --yes tsx src/index.ts" +start "Automaker Backend" cmd /k "cd /d ""%ROOT%apps\server"" && npx.cmd --yes tsx src/index.ts" echo Starting UI (apps/ui) on http://localhost:3007 ... -start "Automaker UI" cmd /k "cd /d \"%ROOT%\" ^&^& npm.cmd run _dev:web" +start "Automaker UI" cmd /k "cd /d ""%ROOT%"" && npm.cmd run _dev:web" echo. echo Done. Open: http://localhost:3007/ From 36fcb3faf5511845fdf2f653d27851c32e6d48a7 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 01:40:30 +0700 Subject: [PATCH 10/30] Allow auto mode max concurrency to be set to 0 --- apps/server/src/services/auto-mode-service.ts | 2 +- .../board-view/dialogs/auto-mode-settings-popover.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 9468f2b47..7c1a8ba5a 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -935,7 +935,7 @@ export class AutoModeService { ) { try { // Check if we have capacity - if (this.runningFeatures.size >= (this.config?.maxConcurrency || DEFAULT_MAX_CONCURRENCY)) { + if (this.runningFeatures.size >= (this.config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY)) { await this.sleep(5000); continue; } diff --git a/apps/ui/src/components/views/board-view/dialogs/auto-mode-settings-popover.tsx b/apps/ui/src/components/views/board-view/dialogs/auto-mode-settings-popover.tsx index 6928206e2..75e1f0cd3 100644 --- a/apps/ui/src/components/views/board-view/dialogs/auto-mode-settings-popover.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/auto-mode-settings-popover.tsx @@ -45,20 +45,22 @@ export function AutoModeSettingsPopover({ - {runningAgentsCount}/{maxConcurrency} + {maxConcurrency === 0 ? 'Paused' : `${runningAgentsCount}/${maxConcurrency}`}
onConcurrencyChange(value[0])} - min={1} + min={0} max={10} step={1} className="flex-1" data-testid="concurrency-slider" /> - {maxConcurrency} + + {maxConcurrency === 0 ? '0' : maxConcurrency} +

Higher values process more features in parallel but use more API resources. From efd760f1adf6b8615fbd0b9e22fc2eb86cbaa0c3 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 12:21:05 +0700 Subject: [PATCH 11/30] Add 9-column kanban board: Backlog, Ready, Assigned, In Progress, Blocked, In Review, Waiting Approval, Verified, Done Extends the board from 4 fixed columns to 9 to support a full workflow lifecycle. Pipeline steps now route to 'in_review' after completion instead of directly to 'waiting_approval'/'verified'. New CSS variables, status badges, drag-drop handlers, and graph view nodes for all new statuses. Co-Authored-By: Claude Opus 4.6 --- .../src/routes/features/routes/update.ts | 2 +- apps/server/src/services/pipeline-service.ts | 18 +- .../unit/services/pipeline-service.test.ts | 14 +- .../components/empty-state-card.tsx | 19 +- .../components/list-view/status-badge.tsx | 45 ++++- .../components/views/board-view/constants.ts | 52 ++++- .../hooks/use-board-column-features.ts | 5 + .../board-view/hooks/use-board-drag-drop.ts | 179 ++++++++---------- .../views/graph-view/components/task-node.tsx | 39 ++++ apps/ui/src/styles/global.css | 15 ++ libs/types/src/feature.ts | 15 +- libs/types/src/pipeline.ts | 5 + 12 files changed, 282 insertions(+), 126 deletions(-) diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index a5b532c1d..a535e2567 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -11,7 +11,7 @@ import { createLogger } from '@automaker/utils'; const logger = createLogger('features/update'); // Statuses that should trigger syncing to app_spec.txt -const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed']; +const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'done', 'completed']; export function createUpdateHandler(featureLoader: FeatureLoader) { return async (req: Request, res: Response): Promise => { diff --git a/apps/server/src/services/pipeline-service.ts b/apps/server/src/services/pipeline-service.ts index 407f34ced..ff987f006 100644 --- a/apps/server/src/services/pipeline-service.ts +++ b/apps/server/src/services/pipeline-service.ts @@ -250,10 +250,14 @@ export class PipelineService { // Sort steps by order const sortedSteps = [...steps].sort((a, b) => a.order - b.order); - // If no pipeline steps, use original logic + // Determine final status after all pipeline steps complete + // Flow: in_progress -> [pipeline steps] -> in_review -> (manual) waiting_approval/verified -> done + const postPipelineStatus: FeatureStatusWithPipeline = 'in_review'; + + // If no pipeline steps, go directly to in_review (or legacy behavior for skipTests) if (sortedSteps.length === 0) { if (currentStatus === 'in_progress') { - return skipTests ? 'waiting_approval' : 'verified'; + return skipTests ? 'waiting_approval' : postPipelineStatus; } return currentStatus; } @@ -263,14 +267,14 @@ export class PipelineService { return `pipeline_${sortedSteps[0].id}`; } - // Coming from a pipeline step -> go to next step or final status + // Coming from a pipeline step -> go to next step or in_review if (currentStatus.startsWith('pipeline_')) { const currentStepId = currentStatus.replace('pipeline_', ''); const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId); if (currentIndex === -1) { - // Step not found, go to final status - return skipTests ? 'waiting_approval' : 'verified'; + // Step not found, go to in_review + return postPipelineStatus; } if (currentIndex < sortedSteps.length - 1) { @@ -278,8 +282,8 @@ export class PipelineService { return `pipeline_${sortedSteps[currentIndex + 1].id}`; } - // Last step completed, go to final status - return skipTests ? 'waiting_approval' : 'verified'; + // Last step completed, go to in_review for final human review + return postPipelineStatus; } // For other statuses, don't change diff --git a/apps/server/tests/unit/services/pipeline-service.test.ts b/apps/server/tests/unit/services/pipeline-service.test.ts index c8917c978..bb46dbedc 100644 --- a/apps/server/tests/unit/services/pipeline-service.test.ts +++ b/apps/server/tests/unit/services/pipeline-service.test.ts @@ -632,9 +632,9 @@ describe('pipeline-service.ts', () => { expect(nextStatus).toBe('waiting_approval'); }); - it('should return verified when no pipeline and skipTests is false', () => { + it('should return in_review when no pipeline and skipTests is false', () => { const nextStatus = pipelineService.getNextStatus('in_progress', null, false); - expect(nextStatus).toBe('verified'); + expect(nextStatus).toBe('in_review'); }); it('should return first pipeline step when coming from in_progress', () => { @@ -686,7 +686,7 @@ describe('pipeline-service.ts', () => { expect(nextStatus).toBe('pipeline_step2'); }); - it('should go to final status when completing last pipeline step', () => { + it('should go to in_review when completing last pipeline step', () => { const config: PipelineConfig = { version: 1, steps: [ @@ -703,10 +703,10 @@ describe('pipeline-service.ts', () => { }; const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false); - expect(nextStatus).toBe('verified'); + expect(nextStatus).toBe('in_review'); }); - it('should go to waiting_approval when completing last step with skipTests', () => { + it('should go to in_review when completing last step even with skipTests', () => { const config: PipelineConfig = { version: 1, steps: [ @@ -723,7 +723,7 @@ describe('pipeline-service.ts', () => { }; const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true); - expect(nextStatus).toBe('waiting_approval'); + expect(nextStatus).toBe('in_review'); }); it('should handle invalid pipeline step ID gracefully', () => { @@ -743,7 +743,7 @@ describe('pipeline-service.ts', () => { }; const nextStatus = pipelineService.getNextStatus('pipeline_nonexistent', config, false); - expect(nextStatus).toBe('verified'); + expect(nextStatus).toBe('in_review'); }); it('should preserve other statuses unchanged', () => { diff --git a/apps/ui/src/components/views/board-view/components/empty-state-card.tsx b/apps/ui/src/components/views/board-view/components/empty-state-card.tsx index 30ccdefc9..a75446d8f 100644 --- a/apps/ui/src/components/views/board-view/components/empty-state-card.tsx +++ b/apps/ui/src/components/views/board-view/components/empty-state-card.tsx @@ -4,7 +4,19 @@ import { Button } from '@/components/ui/button'; import { Kbd } from '@/components/ui/kbd'; import { formatShortcut } from '@/store/app-store'; import { getEmptyStateConfig, type EmptyStateConfig } from '../constants'; -import { Lightbulb, Play, Clock, CheckCircle2, Sparkles, Wand2 } from 'lucide-react'; +import { + Lightbulb, + Play, + Clock, + CheckCircle2, + Sparkles, + Wand2, + ShieldCheck, + Eye, + Ban, + UserCheck, + Archive, +} from 'lucide-react'; const ICON_MAP = { lightbulb: Lightbulb, @@ -12,6 +24,11 @@ const ICON_MAP = { clock: Clock, check: CheckCircle2, sparkles: Sparkles, + shield: ShieldCheck, + eye: Eye, + ban: Ban, + user: UserCheck, + archive: Archive, } as const; interface EmptyStateCardProps { diff --git a/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx b/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx index a5ddca97f..e29719014 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx @@ -23,12 +23,36 @@ const BASE_STATUS_DISPLAY: Record = { bgClass: 'bg-[var(--status-backlog)]/15', borderClass: 'border-[var(--status-backlog)]/30', }, + ready: { + label: 'Ready', + colorClass: 'text-[var(--status-ready)]', + bgClass: 'bg-[var(--status-ready)]/15', + borderClass: 'border-[var(--status-ready)]/30', + }, + assigned: { + label: 'Assigned', + colorClass: 'text-[var(--status-assigned)]', + bgClass: 'bg-[var(--status-assigned)]/15', + borderClass: 'border-[var(--status-assigned)]/30', + }, in_progress: { label: 'In Progress', colorClass: 'text-[var(--status-in-progress)]', bgClass: 'bg-[var(--status-in-progress)]/15', borderClass: 'border-[var(--status-in-progress)]/30', }, + blocked: { + label: 'Blocked', + colorClass: 'text-[var(--status-blocked)]', + bgClass: 'bg-[var(--status-blocked)]/15', + borderClass: 'border-[var(--status-blocked)]/30', + }, + in_review: { + label: 'In Review', + colorClass: 'text-[var(--status-in-review)]', + bgClass: 'bg-[var(--status-in-review)]/15', + borderClass: 'border-[var(--status-in-review)]/30', + }, waiting_approval: { label: 'Waiting Approval', colorClass: 'text-[var(--status-waiting)]', @@ -41,6 +65,12 @@ const BASE_STATUS_DISPLAY: Record = { bgClass: 'bg-[var(--status-success)]/15', borderClass: 'border-[var(--status-success)]/30', }, + done: { + label: 'Done', + colorClass: 'text-[var(--status-done)]', + bgClass: 'bg-[var(--status-done)]/15', + borderClass: 'border-[var(--status-done)]/30', + }, }; /** @@ -204,14 +234,19 @@ export function getStatusLabel( export function getStatusOrder(status: FeatureStatusWithPipeline): number { const baseOrder: Record = { backlog: 0, - in_progress: 1, - waiting_approval: 2, - verified: 3, + ready: 1, + assigned: 2, + in_progress: 3, + blocked: 4, + in_review: 5, + waiting_approval: 7, + verified: 8, + done: 9, }; if (isPipelineStatus(status)) { - // Pipeline statuses come after in_progress but before waiting_approval - return 1.5; + // Pipeline statuses come after in_review but before waiting_approval + return 6; } return baseOrder[status] ?? 0; diff --git a/apps/ui/src/components/views/board-view/constants.ts b/apps/ui/src/components/views/board-view/constants.ts index fda19ebfb..e9135c84e 100644 --- a/apps/ui/src/components/views/board-view/constants.ts +++ b/apps/ui/src/components/views/board-view/constants.ts @@ -9,7 +9,17 @@ export type ColumnId = Feature['status']; export interface EmptyStateConfig { title: string; description: string; - icon: 'lightbulb' | 'play' | 'clock' | 'check' | 'sparkles'; + icon: + | 'lightbulb' + | 'play' + | 'clock' + | 'check' + | 'sparkles' + | 'shield' + | 'eye' + | 'ban' + | 'user' + | 'archive'; shortcutKey?: string; // Keyboard shortcut label (e.g., 'N', 'A') shortcutHint?: string; // Human-readable shortcut hint primaryAction?: { @@ -33,11 +43,33 @@ export const EMPTY_STATE_CONFIGS: Record = { actionType: 'none', }, }, + ready: { + title: 'Nothing Ready', + description: + 'Refined features with acceptance criteria and clear dependencies will appear here.', + icon: 'check', + }, + assigned: { + title: 'Nothing Assigned', + description: 'Features assigned to an agent but not yet started will appear here.', + icon: 'user', + }, in_progress: { title: 'Nothing in Progress', description: 'Drag a feature from the backlog here or click implement to start working on it.', icon: 'play', }, + blocked: { + title: 'Nothing Blocked', + description: 'Features stuck on blockers will appear here with documented reasons.', + icon: 'ban', + }, + in_review: { + title: 'Nothing in Review', + description: + 'Features with open PRs awaiting review will appear here after pipeline steps complete.', + icon: 'eye', + }, waiting_approval: { title: 'No Items Awaiting Approval', description: 'Features will appear here after implementation is complete and need your review.', @@ -48,6 +80,11 @@ export const EMPTY_STATE_CONFIGS: Record = { description: 'Approved features will appear here. They can then be completed and archived.', icon: 'check', }, + done: { + title: 'Nothing Done', + description: 'Merged and deployable features will appear here.', + icon: 'archive', + }, // Pipeline step default configuration pipeline_default: { title: 'Pipeline Step Empty', @@ -74,17 +111,21 @@ export interface Column { pipelineStepId?: string; } -// Base columns (start) +// Base columns (before pipeline steps) const BASE_COLUMNS: Column[] = [ { id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' }, + { id: 'ready', title: 'Ready', colorClass: 'bg-[var(--status-ready)]' }, + { id: 'assigned', title: 'Assigned', colorClass: 'bg-[var(--status-assigned)]' }, { id: 'in_progress', title: 'In Progress', colorClass: 'bg-[var(--status-in-progress)]', }, + { id: 'blocked', title: 'Blocked', colorClass: 'bg-[var(--status-blocked)]' }, + { id: 'in_review', title: 'In Review', colorClass: 'bg-[var(--status-in-review)]' }, ]; -// End columns (after pipeline) +// End columns (after pipeline steps) const END_COLUMNS: Column[] = [ { id: 'waiting_approval', @@ -96,6 +137,11 @@ const END_COLUMNS: Column[] = [ title: 'Verified', colorClass: 'bg-[var(--status-success)]', }, + { + id: 'done', + title: 'Done', + colorClass: 'bg-[var(--status-done)]', + }, ]; // Static COLUMNS for backwards compatibility (no pipeline) diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts index 6505da2ae..28ff66a46 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts @@ -31,9 +31,14 @@ export function useBoardColumnFeatures({ // Use a more flexible type to support dynamic pipeline statuses const map: Record = { backlog: [], + ready: [], + assigned: [], in_progress: [], + blocked: [], + in_review: [], waiting_approval: [], verified: [], + done: [], completed: [], // Completed features are shown in the archive modal, not as a column }; const featureMap = createFeatureMap(features); diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts index 327a28927..16c3f2afb 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts @@ -191,117 +191,94 @@ export function useBoardDragDrop({ // Handle different drag scenarios // Note: Worktrees are created server-side at execution time based on feature.branchName - if (draggedFeature.status === 'backlog') { - // From backlog + // + // Board flow: Backlog → Ready → Assigned → In Progress → [Pipeline] → In Review → Waiting Approval → Verified → Done + // "Blocked" can be reached from any status (and returned from) + + const truncDesc = (desc: string) => `${desc.slice(0, 50)}${desc.length > 50 ? '...' : ''}`; + + // Helper to move feature with toast + const doMove = ( + status: typeof targetStatus, + label: string, + variant: 'info' | 'success' = 'info' + ) => { + moveFeature(featureId, status); + persistFeatureUpdate(featureId, { status, justFinishedAt: undefined }); + if (variant === 'success') { + toast.success(`Feature ${label}`, { description: truncDesc(draggedFeature.description) }); + } else { + toast.info(`Feature moved to ${label}`, { + description: truncDesc(draggedFeature.description), + }); + } + }; + + if ( + targetStatus === 'in_progress' && + (draggedFeature.status === 'backlog' || + draggedFeature.status === 'ready' || + draggedFeature.status === 'assigned') + ) { + // Starting implementation from backlog/ready/assigned + await handleStartImplementation(draggedFeature); + } else if (targetStatus === 'blocked') { + // Any status can move to blocked + doMove('blocked', 'Blocked'); + } else if (draggedFeature.status === 'blocked') { + // From blocked, allow moving to any column if (targetStatus === 'in_progress') { - // Use helper function to handle concurrency check and start implementation - // Server will derive workDir from feature.branchName await handleStartImplementation(draggedFeature); } else { - moveFeature(featureId, targetStatus); - persistFeatureUpdate(featureId, { status: targetStatus }); - } - } else if (draggedFeature.status === 'waiting_approval') { - // waiting_approval features can be dragged to verified for manual verification - // NOTE: This check must come BEFORE skipTests check because waiting_approval - // features often have skipTests=true, and we want status-based handling first - if (targetStatus === 'verified') { - moveFeature(featureId, 'verified'); - // Clear justFinishedAt timestamp when manually verifying via drag - persistFeatureUpdate(featureId, { - status: 'verified', - justFinishedAt: undefined, - }); - toast.success('Feature verified', { - description: `Manually verified: ${draggedFeature.description.slice( - 0, - 50 - )}${draggedFeature.description.length > 50 ? '...' : ''}`, - }); - } else if (targetStatus === 'backlog') { - // Allow moving waiting_approval cards back to backlog - moveFeature(featureId, 'backlog'); - // Clear justFinishedAt timestamp when moving back to backlog - persistFeatureUpdate(featureId, { - status: 'backlog', - justFinishedAt: undefined, - }); - toast.info('Feature moved to backlog', { - description: `Moved to Backlog: ${draggedFeature.description.slice( - 0, - 50 - )}${draggedFeature.description.length > 50 ? '...' : ''}`, - }); + doMove(targetStatus, targetStatus.replace(/_/g, ' ')); } + } else if (draggedFeature.status === 'backlog') { + // Backlog can move to ready, assigned, or any earlier column + doMove(targetStatus, targetStatus.replace(/_/g, ' ')); + } else if (draggedFeature.status === 'ready') { + // Ready can move to assigned, backlog, or in_progress + doMove(targetStatus, targetStatus.replace(/_/g, ' ')); + } else if (draggedFeature.status === 'assigned') { + // Assigned can move back to ready/backlog + doMove(targetStatus, targetStatus.replace(/_/g, ' ')); } else if (draggedFeature.status === 'in_progress') { - // Handle in_progress features being moved - if (targetStatus === 'backlog') { - // Allow moving in_progress cards back to backlog - moveFeature(featureId, 'backlog'); - persistFeatureUpdate(featureId, { status: 'backlog' }); - toast.info('Feature moved to backlog', { - description: `Moved to Backlog: ${draggedFeature.description.slice( - 0, - 50 - )}${draggedFeature.description.length > 50 ? '...' : ''}`, - }); + if (targetStatus === 'backlog' || targetStatus === 'ready') { + doMove(targetStatus, targetStatus.replace(/_/g, ' ')); + } else if (targetStatus === 'in_review') { + doMove('in_review', 'In Review'); } else if (targetStatus === 'verified' && draggedFeature.skipTests) { - // Manual verify via drag (only for skipTests features) - moveFeature(featureId, 'verified'); - persistFeatureUpdate(featureId, { status: 'verified' }); - toast.success('Feature verified', { - description: `Marked as verified: ${draggedFeature.description.slice( - 0, - 50 - )}${draggedFeature.description.length > 50 ? '...' : ''}`, - }); + doMove('verified', 'verified', 'success'); } - } else if (draggedFeature.skipTests) { - // skipTests feature being moved between verified and waiting_approval - if (targetStatus === 'waiting_approval' && draggedFeature.status === 'verified') { - // Move verified feature back to waiting_approval - moveFeature(featureId, 'waiting_approval'); - persistFeatureUpdate(featureId, { status: 'waiting_approval' }); - toast.info('Feature moved back', { - description: `Moved back to Waiting Approval: ${draggedFeature.description.slice( - 0, - 50 - )}${draggedFeature.description.length > 50 ? '...' : ''}`, - }); - } else if (targetStatus === 'backlog') { - // Allow moving skipTests cards back to backlog (from verified) - moveFeature(featureId, 'backlog'); - persistFeatureUpdate(featureId, { status: 'backlog' }); - toast.info('Feature moved to backlog', { - description: `Moved to Backlog: ${draggedFeature.description.slice( - 0, - 50 - )}${draggedFeature.description.length > 50 ? '...' : ''}`, - }); + } else if (draggedFeature.status === 'in_review') { + // From in_review: can go to waiting_approval, verified, or back + if (targetStatus === 'waiting_approval') { + doMove('waiting_approval', 'Waiting Approval'); + } else if (targetStatus === 'verified') { + doMove('verified', 'verified', 'success'); + } else if (targetStatus === 'backlog' || targetStatus === 'in_progress') { + doMove(targetStatus, targetStatus.replace(/_/g, ' ')); + } + } else if (draggedFeature.status === 'waiting_approval') { + if (targetStatus === 'verified') { + doMove('verified', 'verified', 'success'); + } else if (targetStatus === 'backlog' || targetStatus === 'in_review') { + doMove(targetStatus, targetStatus.replace(/_/g, ' ')); } } else if (draggedFeature.status === 'verified') { - // Handle verified TDD (non-skipTests) features being moved back - if (targetStatus === 'waiting_approval') { - // Move verified feature back to waiting_approval - moveFeature(featureId, 'waiting_approval'); - persistFeatureUpdate(featureId, { status: 'waiting_approval' }); - toast.info('Feature moved back', { - description: `Moved back to Waiting Approval: ${draggedFeature.description.slice( - 0, - 50 - )}${draggedFeature.description.length > 50 ? '...' : ''}`, - }); - } else if (targetStatus === 'backlog') { - // Allow moving verified cards back to backlog - moveFeature(featureId, 'backlog'); - persistFeatureUpdate(featureId, { status: 'backlog' }); - toast.info('Feature moved to backlog', { - description: `Moved to Backlog: ${draggedFeature.description.slice( - 0, - 50 - )}${draggedFeature.description.length > 50 ? '...' : ''}`, - }); + if (targetStatus === 'done') { + doMove('done', 'Done', 'success'); + } else if (targetStatus === 'waiting_approval' || targetStatus === 'backlog') { + doMove(targetStatus, targetStatus.replace(/_/g, ' ')); } + } else if (draggedFeature.status === 'done') { + // Done can be moved back to verified or backlog if needed + if (targetStatus === 'verified' || targetStatus === 'backlog') { + doMove(targetStatus, targetStatus.replace(/_/g, ' ')); + } + } else { + // Generic fallback for any other status combination + moveFeature(featureId, targetStatus); + persistFeatureUpdate(featureId, { status: targetStatus }); } }, [ diff --git a/apps/ui/src/components/views/graph-view/components/task-node.tsx b/apps/ui/src/components/views/graph-view/components/task-node.tsx index 16cf6817c..3aee25b41 100644 --- a/apps/ui/src/components/views/graph-view/components/task-node.tsx +++ b/apps/ui/src/components/views/graph-view/components/task-node.tsx @@ -16,6 +16,10 @@ import { RotateCcw, GitFork, Trash2, + CircleDot, + UserCheck, + Ban, + Archive, } from 'lucide-react'; import { TaskNodeData } from '../hooks/use-graph-nodes'; import { GRAPH_RENDER_MODE_COMPACT } from '../constants'; @@ -40,6 +44,20 @@ const statusConfig = { borderClass: 'border-border', bgClass: 'bg-card', }, + ready: { + icon: CircleDot, + label: 'Ready', + colorClass: 'text-[var(--status-ready)]', + borderClass: 'border-[var(--status-ready)]', + bgClass: 'bg-[var(--status-ready)]/15', + }, + assigned: { + icon: UserCheck, + label: 'Assigned', + colorClass: 'text-[var(--status-assigned)]', + borderClass: 'border-[var(--status-assigned)]', + bgClass: 'bg-[var(--status-assigned)]/15', + }, in_progress: { icon: Play, label: 'In Progress', @@ -47,6 +65,20 @@ const statusConfig = { borderClass: 'border-[var(--status-in-progress)]', bgClass: 'bg-[var(--status-in-progress-bg)]', }, + blocked: { + icon: Ban, + label: 'Blocked', + colorClass: 'text-[var(--status-blocked)]', + borderClass: 'border-[var(--status-blocked)]', + bgClass: 'bg-[var(--status-blocked)]/15', + }, + in_review: { + icon: Eye, + label: 'In Review', + colorClass: 'text-[var(--status-in-review)]', + borderClass: 'border-[var(--status-in-review)]', + bgClass: 'bg-[var(--status-in-review)]/15', + }, waiting_approval: { icon: Pause, label: 'Waiting Approval', @@ -61,6 +93,13 @@ const statusConfig = { borderClass: 'border-[var(--status-success)]', bgClass: 'bg-[var(--status-success-bg)]', }, + done: { + icon: Archive, + label: 'Done', + colorClass: 'text-[var(--status-done)]', + borderClass: 'border-[var(--status-done)]', + bgClass: 'bg-[var(--status-done)]/15', + }, }; const priorityConfig = { diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index 6e942b888..8b049434d 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -119,8 +119,13 @@ --color-status-info: var(--status-info); --color-status-info-bg: var(--status-info-bg); --color-status-backlog: var(--status-backlog); + --color-status-ready: var(--status-ready); + --color-status-assigned: var(--status-assigned); --color-status-in-progress: var(--status-in-progress); + --color-status-blocked: var(--status-blocked); + --color-status-in-review: var(--status-in-review); --color-status-waiting: var(--status-waiting); + --color-status-done: var(--status-done); /* Border radius */ --radius-sm: calc(var(--radius) - 4px); @@ -197,8 +202,13 @@ --status-info: oklch(0.55 0.2 230); --status-info-bg: oklch(0.55 0.2 230 / 0.15); --status-backlog: oklch(0.5 0 0); + --status-ready: oklch(0.55 0.18 240); + --status-assigned: oklch(0.55 0.18 300); --status-in-progress: oklch(0.7 0.15 70); + --status-blocked: oklch(0.55 0.22 25); + --status-in-review: oklch(0.6 0.15 200); --status-waiting: oklch(0.65 0.18 50); + --status-done: oklch(0.45 0.18 150); /* Shadow tokens */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); @@ -295,8 +305,13 @@ --status-info: oklch(0.65 0.2 230); --status-info-bg: oklch(0.65 0.2 230 / 0.2); --status-backlog: oklch(0.6 0 0); + --status-ready: oklch(0.65 0.18 240); + --status-assigned: oklch(0.65 0.18 300); --status-in-progress: oklch(0.75 0.15 70); + --status-blocked: oklch(0.65 0.22 25); + --status-in-review: oklch(0.7 0.15 200); --status-waiting: oklch(0.7 0.18 50); + --status-done: oklch(0.55 0.18 150); /* Shadow tokens - darker for dark mode */ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3); diff --git a/libs/types/src/feature.ts b/libs/types/src/feature.ts index 7ba4dc81a..ba6ca38ad 100644 --- a/libs/types/src/feature.ts +++ b/libs/types/src/feature.ts @@ -70,4 +70,17 @@ export interface Feature { [key: string]: unknown; // Keep catch-all for extensibility } -export type FeatureStatus = 'pending' | 'running' | 'completed' | 'failed' | 'verified'; +export type FeatureStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'verified' + | 'backlog' + | 'ready' + | 'assigned' + | 'in_progress' + | 'blocked' + | 'in_review' + | 'waiting_approval' + | 'done'; diff --git a/libs/types/src/pipeline.ts b/libs/types/src/pipeline.ts index 23798d0ba..e85103b27 100644 --- a/libs/types/src/pipeline.ts +++ b/libs/types/src/pipeline.ts @@ -21,8 +21,13 @@ export type PipelineStatus = `pipeline_${string}`; export type FeatureStatusWithPipeline = | 'backlog' + | 'ready' + | 'assigned' | 'in_progress' + | 'blocked' + | 'in_review' | 'waiting_approval' | 'verified' + | 'done' | 'completed' | PipelineStatus; From 406bcff970d252c8a7bce4c9dcbf9669b9a618b4 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 12:58:20 +0700 Subject: [PATCH 12/30] Update Opus model references from 4.5 to 4.6 across source, tests, and docs Co-Authored-By: Claude Opus 4.6 --- apps/server/src/providers/claude-provider.ts | 6 +-- apps/server/src/providers/provider-factory.ts | 2 +- .../tests/unit/lib/model-resolver.test.ts | 4 +- .../unit/providers/claude-provider.test.ts | 32 +++++++-------- .../unit/providers/opencode-provider.test.ts | 4 +- .../unit/providers/provider-factory.test.ts | 4 +- apps/ui/docs/AGENT_ARCHITECTURE.md | 2 +- apps/ui/src/lib/agent-context-parser.ts | 4 +- apps/ui/src/lib/utils.ts | 2 +- docs/llm-shared-packages.md | 2 +- docs/server/providers.md | 6 +-- docs/server/utilities.md | 12 +++--- libs/model-resolver/README.md | 12 +++--- libs/model-resolver/tests/resolver.test.ts | 4 +- libs/types/src/cursor-models.ts | 40 +++++++++---------- 15 files changed, 68 insertions(+), 68 deletions(-) diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index cfb590932..1bf77b21e 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -349,9 +349,9 @@ export class ClaudeProvider extends BaseProvider { getAvailableModels(): ModelDefinition[] { const models = [ { - id: 'claude-opus-4-5-20251101', - name: 'Claude Opus 4.5', - modelString: 'claude-opus-4-5-20251101', + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + modelString: 'claude-opus-4-6', provider: 'anthropic', description: 'Most capable Claude model', contextWindow: 200000, diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index c2a181202..44f478d5f 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -94,7 +94,7 @@ export class ProviderFactory { /** * Get the appropriate provider for a given model ID * - * @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto") + * @param modelId Model identifier (e.g., "claude-opus-4-6", "cursor-gpt-4o", "cursor-auto") * @param options Optional settings * @param options.throwOnDisconnected Throw error if provider is disconnected (default: true) * @returns Provider instance for the model diff --git a/apps/server/tests/unit/lib/model-resolver.test.ts b/apps/server/tests/unit/lib/model-resolver.test.ts index c1bff78da..65e3115df 100644 --- a/apps/server/tests/unit/lib/model-resolver.test.ts +++ b/apps/server/tests/unit/lib/model-resolver.test.ts @@ -35,7 +35,7 @@ describe('model-resolver.ts', () => { it("should resolve 'opus' alias to full model string", () => { const result = resolveModelString('opus'); - expect(result).toBe('claude-opus-4-5-20251101'); + expect(result).toBe('claude-opus-4-6'); expect(consoleSpy.log).toHaveBeenCalledWith( expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"') ); @@ -117,7 +117,7 @@ describe('model-resolver.ts', () => { describe('getEffectiveModel', () => { it('should prioritize explicit model over session and default', () => { const result = getEffectiveModel('opus', 'haiku', 'gpt-5.2'); - expect(result).toBe('claude-opus-4-5-20251101'); + expect(result).toBe('claude-opus-4-6'); }); it('should use session model when explicit is not provided', () => { diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index c3f83f8fd..7df211ef3 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -39,7 +39,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Hello', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -59,7 +59,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test prompt', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test/dir', systemPrompt: 'You are helpful', maxTurns: 10, @@ -71,7 +71,7 @@ describe('claude-provider.ts', () => { expect(sdk.query).toHaveBeenCalledWith({ prompt: 'Test prompt', options: expect.objectContaining({ - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', systemPrompt: 'You are helpful', maxTurns: 10, cwd: '/test/dir', @@ -91,7 +91,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -116,7 +116,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', abortController, }); @@ -145,7 +145,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Current message', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', conversationHistory, sdkSessionId: 'test-session-id', @@ -176,7 +176,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: arrayPrompt as any, - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -196,7 +196,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -222,7 +222,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -286,7 +286,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -313,7 +313,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -341,7 +341,7 @@ describe('claude-provider.ts', () => { const generator = provider.executeQuery({ prompt: 'Test', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/test', }); @@ -366,12 +366,12 @@ describe('claude-provider.ts', () => { expect(models).toHaveLength(4); }); - it('should include Claude Opus 4.5', () => { + it('should include Claude Opus 4.6', () => { const models = provider.getAvailableModels(); - const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101'); + const opus = models.find((m) => m.id === 'claude-opus-4-6'); expect(opus).toBeDefined(); - expect(opus?.name).toBe('Claude Opus 4.5'); + expect(opus?.name).toBe('Claude Opus 4.6'); expect(opus?.provider).toBe('anthropic'); }); @@ -400,7 +400,7 @@ describe('claude-provider.ts', () => { it('should mark Opus as default', () => { const models = provider.getAvailableModels(); - const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101'); + const opus = models.find((m) => m.id === 'claude-opus-4-6'); expect(opus?.default).toBe(true); }); diff --git a/apps/server/tests/unit/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts index 641838efc..23759d5ce 100644 --- a/apps/server/tests/unit/providers/opencode-provider.test.ts +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -711,7 +711,7 @@ describe('opencode-provider.ts', () => { await collectAsyncGenerator( mockedProvider.executeQuery({ prompt: 'Test', - model: 'opencode-anthropic/claude-opus-4-5', + model: 'opencode-anthropic/claude-opus-4-6', cwd: '/tmp/workspace', }) ); @@ -721,7 +721,7 @@ describe('opencode-provider.ts', () => { expect(call.args).toContain('--format'); expect(call.args).toContain('json'); expect(call.args).toContain('--model'); - expect(call.args).toContain('anthropic/claude-opus-4-5'); + expect(call.args).toContain('anthropic/claude-opus-4-6'); }); it('should skip null-normalized events', async () => { diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts index 5b7173649..79c83a4bc 100644 --- a/apps/server/tests/unit/providers/provider-factory.test.ts +++ b/apps/server/tests/unit/providers/provider-factory.test.ts @@ -42,8 +42,8 @@ describe('provider-factory.ts', () => { describe('getProviderForModel', () => { describe('Claude models (claude-* prefix)', () => { - it('should return ClaudeProvider for claude-opus-4-5-20251101', () => { - const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101'); + it('should return ClaudeProvider for claude-opus-4-6', () => { + const provider = ProviderFactory.getProviderForModel('claude-opus-4-6'); expect(provider).toBeInstanceOf(ClaudeProvider); }); diff --git a/apps/ui/docs/AGENT_ARCHITECTURE.md b/apps/ui/docs/AGENT_ARCHITECTURE.md index 4c9f0d111..f5c374c47 100644 --- a/apps/ui/docs/AGENT_ARCHITECTURE.md +++ b/apps/ui/docs/AGENT_ARCHITECTURE.md @@ -199,7 +199,7 @@ The agent is configured with: ```javascript { - model: "claude-opus-4-5-20251101", + model: "claude-opus-4-6", maxTurns: 20, cwd: workingDirectory, allowedTools: [ diff --git a/apps/ui/src/lib/agent-context-parser.ts b/apps/ui/src/lib/agent-context-parser.ts index 35e2cceb0..a056c19e2 100644 --- a/apps/ui/src/lib/agent-context-parser.ts +++ b/apps/ui/src/lib/agent-context-parser.ts @@ -27,14 +27,14 @@ export interface AgentTaskInfo { /** * Default model used by the feature executor */ -export const DEFAULT_MODEL = 'claude-opus-4-5-20251101'; +export const DEFAULT_MODEL = 'claude-opus-4-6'; /** * Formats a model name for display */ export function formatModelName(model: string): string { // Claude models - if (model.includes('opus')) return 'Opus 4.5'; + if (model.includes('opus')) return 'Opus 4.6'; if (model.includes('sonnet')) return 'Sonnet 4.5'; if (model.includes('haiku')) return 'Haiku 4.5'; diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts index 933ea1fd6..3edc396f9 100644 --- a/apps/ui/src/lib/utils.ts +++ b/apps/ui/src/lib/utils.ts @@ -12,7 +12,7 @@ export function cn(...inputs: ClassValue[]) { * Note: This is for Claude's "thinking levels" only, not Codex's "reasoning effort" * * Rules: - * - Claude models: support thinking (sonnet-4.5-thinking, opus-4.5-thinking, etc.) + * - Claude models: support thinking (sonnet-4.5-thinking, opus-4.6-thinking, etc.) * - Cursor models: NO thinking controls (handled internally by Cursor CLI) * - Codex models: NO thinking controls (they use reasoningEffort instead) */ diff --git a/docs/llm-shared-packages.md b/docs/llm-shared-packages.md index 9a81ad904..9f558c960 100644 --- a/docs/llm-shared-packages.md +++ b/docs/llm-shared-packages.md @@ -142,7 +142,7 @@ const modelId = resolveModelString('sonnet'); // → 'claude-sonnet-4-20250514' - `haiku` → `claude-haiku-4-5` (fast, simple tasks) - `sonnet` → `claude-sonnet-4-20250514` (balanced, recommended) -- `opus` → `claude-opus-4-5-20251101` (maximum capability) +- `opus` → `claude-opus-4-6` (maximum capability) ### @automaker/dependency-resolver diff --git a/docs/server/providers.md b/docs/server/providers.md index 757ecab1a..4dae626e9 100644 --- a/docs/server/providers.md +++ b/docs/server/providers.md @@ -175,7 +175,7 @@ Uses `@anthropic-ai/claude-agent-sdk` for direct SDK integration. Routes models that: -- Start with `"claude-"` (e.g., `"claude-opus-4-5-20251101"`) +- Start with `"claude-"` (e.g., `"claude-opus-4-6"`) - Are Claude aliases: `"opus"`, `"sonnet"`, `"haiku"` #### Authentication @@ -191,7 +191,7 @@ const provider = new ClaudeProvider(); const stream = provider.executeQuery({ prompt: 'What is 2+2?', - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', cwd: '/project/path', systemPrompt: 'You are a helpful assistant.', maxTurns: 20, @@ -701,7 +701,7 @@ Test provider interaction with services: ```typescript describe('Provider Integration', () => { it('should work with AgentService', async () => { - const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101'); + const provider = ProviderFactory.getProviderForModel('claude-opus-4-6'); // Test full workflow }); diff --git a/docs/server/utilities.md b/docs/server/utilities.md index b12e60a20..91d301bb1 100644 --- a/docs/server/utilities.md +++ b/docs/server/utilities.md @@ -213,7 +213,7 @@ Model alias mapping for Claude models. export const CLAUDE_MODEL_MAP: Record = { haiku: 'claude-haiku-4-5', sonnet: 'claude-sonnet-4-20250514', - opus: 'claude-opus-4-5-20251101', + opus: 'claude-opus-4-6', } as const; ``` @@ -223,7 +223,7 @@ Default models per provider. ```typescript export const DEFAULT_MODELS = { - claude: 'claude-opus-4-5-20251101', + claude: 'claude-opus-4-6', openai: 'gpt-5.2', } as const; ``` @@ -248,8 +248,8 @@ Resolve a model key/alias to a full model string. import { resolveModelString, DEFAULT_MODELS } from '../lib/model-resolver.js'; resolveModelString('opus'); -// Returns: "claude-opus-4-5-20251101" -// Logs: "[ModelResolver] Resolved model alias: "opus" -> "claude-opus-4-5-20251101"" +// Returns: "claude-opus-4-6" +// Logs: "[ModelResolver] Resolved model alias: "opus" -> "claude-opus-4-6"" resolveModelString('gpt-5.2'); // Returns: "gpt-5.2" @@ -260,8 +260,8 @@ resolveModelString('claude-sonnet-4-20250514'); // Logs: "[ModelResolver] Using full Claude model string: claude-sonnet-4-20250514" resolveModelString('invalid-model'); -// Returns: "claude-opus-4-5-20251101" -// Logs: "[ModelResolver] Unknown model key "invalid-model", using default: "claude-opus-4-5-20251101"" +// Returns: "claude-opus-4-6" +// Logs: "[ModelResolver] Unknown model key "invalid-model", using default: "claude-opus-4-6"" ``` --- diff --git a/libs/model-resolver/README.md b/libs/model-resolver/README.md index 50bdf4f91..ce5aa3ce5 100644 --- a/libs/model-resolver/README.md +++ b/libs/model-resolver/README.md @@ -30,15 +30,15 @@ const model2 = resolveModelString('haiku'); // Returns: 'claude-haiku-4-5' const model3 = resolveModelString('opus'); -// Returns: 'claude-opus-4-5-20251101' +// Returns: 'claude-opus-4-6' // Use with custom default const model4 = resolveModelString(undefined, 'claude-sonnet-4-20250514'); // Returns: 'claude-sonnet-4-20250514' (default) // Direct model ID passthrough -const model5 = resolveModelString('claude-opus-4-5-20251101'); -// Returns: 'claude-opus-4-5-20251101' (unchanged) +const model5 = resolveModelString('claude-opus-4-6'); +// Returns: 'claude-opus-4-6' (unchanged) ``` ### Get Effective Model @@ -72,7 +72,7 @@ console.log(DEFAULT_MODELS.chat); // 'claude-sonnet-4-20250514' // Model alias mappings console.log(CLAUDE_MODEL_MAP.haiku); // 'claude-haiku-4-5' console.log(CLAUDE_MODEL_MAP.sonnet); // 'claude-sonnet-4-20250514' -console.log(CLAUDE_MODEL_MAP.opus); // 'claude-opus-4-5-20251101' +console.log(CLAUDE_MODEL_MAP.opus); // 'claude-opus-4-6' ``` ## Usage Example @@ -103,7 +103,7 @@ const feature: Feature = { }; prepareFeatureExecution(feature); -// Output: Executing feature with model: claude-opus-4-5-20251101 +// Output: Executing feature with model: claude-opus-4-6 ``` ## Supported Models @@ -112,7 +112,7 @@ prepareFeatureExecution(feature); - `haiku` → `claude-haiku-4-5` - `sonnet` → `claude-sonnet-4-20250514` -- `opus` → `claude-opus-4-5-20251101` +- `opus` → `claude-opus-4-6` ### Model Selection Guide diff --git a/libs/model-resolver/tests/resolver.test.ts b/libs/model-resolver/tests/resolver.test.ts index 84623b5b9..7b6af623b 100644 --- a/libs/model-resolver/tests/resolver.test.ts +++ b/libs/model-resolver/tests/resolver.test.ts @@ -484,12 +484,12 @@ describe('model-resolver', () => { it('should handle full Claude model string in entry', () => { const entry: PhaseModelEntry = { - model: 'claude-opus-4-5-20251101', + model: 'claude-opus-4-6', thinkingLevel: 'high', }; const result = resolvePhaseModel(entry); - expect(result.model).toBe('claude-opus-4-5-20251101'); + expect(result.model).toBe('claude-opus-4-6'); expect(result.thinkingLevel).toBe('high'); }); }); diff --git a/libs/types/src/cursor-models.ts b/libs/types/src/cursor-models.ts index 08db74d8c..97a3b09a0 100644 --- a/libs/types/src/cursor-models.ts +++ b/libs/types/src/cursor-models.ts @@ -10,8 +10,8 @@ export type CursorModelId = | 'cursor-composer-1' // Cursor Composer agent model | 'cursor-sonnet-4.5' // Claude Sonnet 4.5 | 'cursor-sonnet-4.5-thinking' // Claude Sonnet 4.5 with extended thinking - | 'cursor-opus-4.5' // Claude Opus 4.5 - | 'cursor-opus-4.5-thinking' // Claude Opus 4.5 with extended thinking + | 'cursor-opus-4.6' // Claude Opus 4.6 + | 'cursor-opus-4.6-thinking' // Claude Opus 4.6 with extended thinking | 'cursor-opus-4.1' // Claude Opus 4.1 | 'cursor-gemini-3-pro' // Gemini 3 Pro | 'cursor-gemini-3-flash' // Gemini 3 Flash @@ -37,8 +37,8 @@ export type LegacyCursorModelId = | 'composer-1' | 'sonnet-4.5' | 'sonnet-4.5-thinking' - | 'opus-4.5' - | 'opus-4.5-thinking' + | 'opus-4.6' + | 'opus-4.6-thinking' | 'opus-4.1' | 'gemini-3-pro' | 'gemini-3-flash' @@ -89,17 +89,17 @@ export const CURSOR_MODEL_MAP: Record = { hasThinking: true, supportsVision: false, }, - 'cursor-opus-4.5': { - id: 'cursor-opus-4.5', - label: 'Claude Opus 4.5', - description: 'Anthropic Claude Opus 4.5 via Cursor', + 'cursor-opus-4.6': { + id: 'cursor-opus-4.6', + label: 'Claude Opus 4.6', + description: 'Anthropic Claude Opus 4.6 via Cursor', hasThinking: false, supportsVision: false, }, - 'cursor-opus-4.5-thinking': { - id: 'cursor-opus-4.5-thinking', - label: 'Claude Opus 4.5 (Thinking)', - description: 'Claude Opus 4.5 with extended thinking enabled', + 'cursor-opus-4.6-thinking': { + id: 'cursor-opus-4.6-thinking', + label: 'Claude Opus 4.6 (Thinking)', + description: 'Claude Opus 4.6 with extended thinking enabled', hasThinking: true, supportsVision: false, }, @@ -225,8 +225,8 @@ export const LEGACY_CURSOR_MODEL_MAP: Record 'composer-1': 'cursor-composer-1', 'sonnet-4.5': 'cursor-sonnet-4.5', 'sonnet-4.5-thinking': 'cursor-sonnet-4.5-thinking', - 'opus-4.5': 'cursor-opus-4.5', - 'opus-4.5-thinking': 'cursor-opus-4.5-thinking', + 'opus-4.6': 'cursor-opus-4.6', + 'opus-4.6-thinking': 'cursor-opus-4.6-thinking', 'opus-4.1': 'cursor-opus-4.1', 'gemini-3-pro': 'cursor-gemini-3-pro', 'gemini-3-flash': 'cursor-gemini-3-flash', @@ -394,16 +394,16 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [ }, ], }, - // Opus 4.5 group (thinking mode) + // Opus 4.6 group (thinking mode) { - baseId: 'cursor-opus-4.5-group', - label: 'Claude Opus 4.5', - description: 'Anthropic Claude Opus 4.5 via Cursor', + baseId: 'cursor-opus-4.6-group', + label: 'Claude Opus 4.6', + description: 'Anthropic Claude Opus 4.6 via Cursor', variantType: 'thinking', variants: [ - { id: 'cursor-opus-4.5', label: 'Standard', description: 'Fast responses' }, + { id: 'cursor-opus-4.6', label: 'Standard', description: 'Fast responses' }, { - id: 'cursor-opus-4.5-thinking', + id: 'cursor-opus-4.6-thinking', label: 'Thinking', description: 'Extended reasoning', badge: 'Reasoning', From 49b050721367c122d08517000bb0e0f82934e5ba Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 17:22:01 +0700 Subject: [PATCH 13/30] Add setup prompt template to Automaker docs Copy of the generic project bootstrapper template for easy access when starting new projects. Co-Authored-By: Claude Opus 4.6 --- docs/automaker-setup-prompt-template.md | 291 ++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 docs/automaker-setup-prompt-template.md diff --git a/docs/automaker-setup-prompt-template.md b/docs/automaker-setup-prompt-template.md new file mode 100644 index 000000000..26feb4773 --- /dev/null +++ b/docs/automaker-setup-prompt-template.md @@ -0,0 +1,291 @@ +# Automaker Project Setup Prompt Template + +> Truly generic prompt for bootstrapping ANY project in Automaker. The agent roles, context files, tech stack, and governance structure are all determined dynamically by analyzing the project spec — nothing is hardcoded. + +--- + +## The Prompt + +```` +I want to build a new project using Automaker (located at {AUTOMAKER_PATH}). + +**Project spec**: {SPEC_FILE_PATH} +**Project directory**: {AUTOMAKER_PATH}/data/{project-slug} +**Git remote**: {GIT_REMOTE_URL} + +Read the ENTIRE spec file first. Then set up the complete Automaker multi-agent orchestration system by working through the steps below. Every section of the spec — including any "Future Enhancements", "Stretch Goals", or aspirational content — must be captured as features. Nothing gets dropped. + +--- + +### Step 1: Analyze the Spec & Determine Project Shape + +Before creating anything, analyze the spec and determine: + +1. **Tech stack** — What languages, frameworks, databases, and infrastructure does this project need? This drives everything else. + +2. **Agent roles** — Based on the project's tech stack and domains, determine what specialist agent roles are needed. Consider: + - What distinct skill domains does this project span? (e.g., frontend, backend, mobile, ML, data, infra, security, QA) + - Which roles need persistent memory (long-lived roles that accumulate knowledge across features)? + - Which roles only need per-worktree scratchpads? + - Every project should have at minimum: a Planner/PM, a Security Reviewer, a QA/Verifier, and an Integrator. Add domain-specific roles as needed. + - Assign each role a default model tier (1/2/3) based on the risk level of their decisions. + +3. **Epics** — Break the spec into 6-15 epics covering all functional areas. Identify: + - Dependencies between epics (which must come first?) + - Critical path (longest chain of dependent epics) + - Which epics can run in parallel + +4. **Security profile** — What security concerns apply? (web security, API auth, encryption, data privacy, payment processing, etc.) This determines the security docs and context files needed. + +5. **Context files needed** — Based on the project, determine what context files agents need injected into every prompt. At minimum include: + - Agent workflow (mandatory steps for all agents) + - Coding standards (language-specific rules, naming, formatting) + - Security rules (non-negotiable security requirements) + - Project structure (what's built vs not built) + - Worktree rules (git isolation, branch naming) + - Governance (approval gates, model selection) + - Add domain-specific context as needed (e.g., ML pipeline rules, API design guidelines, mobile platform guidelines, game design constraints) + +6. **Feature categories** — What categories make sense for this project's features? (e.g., Backend, Frontend, ML Pipeline, Data, Infrastructure, Testing, Documentation — whatever fits) + +7. **CI/CD pipeline** — What build, test, and deploy stages does this tech stack need? + +Present your analysis for my approval before proceeding. + +--- + +### Step 2: Create Documentation + +Based on the analysis from Step 1, create: + +**Product & Planning:** +- docs/product/product-spec.md — Scope boundaries, constraints, tech decisions +- docs/work-breakdown/epics.md — All epics with priorities, complexity, effort, dependencies +- docs/work-breakdown/dependency-dag.md — ASCII DAG with critical path +- docs/work-breakdown/parallelization-plan.md — Phased execution with agent assignments +- docs/work-breakdown/stories/ — Detailed stories per epic with acceptance criteria + +**Agents** (roles determined in Step 1): +- docs/agents/roster.md — All roles, hierarchy, communication flows, escalation chains +- docs/agents/shared-foundation.md — Mission, tech stack, security policy, coding standards, universal DoD +- docs/agents/role-cards/{role}.md — One per role with: responsibilities, decision authority, escalation rules, model tier guidance + +**Governance:** +- docs/governance/kanban-workflow.md — 9-column board (Backlog → Ready → Assigned → In Progress → Blocked → In Review → Waiting Approval → Verified → Done) +- docs/governance/model-selection-policy.md — 3-tier policy with decision matrix +- docs/governance/human-interaction-protocol.md — When/how agents ask humans +- docs/governance/approval-gates.md — Review stages with timeouts and escalation + +**Security** (scope determined in Step 1): +- docs/security/security-baseline.md — Access controls, credential handling, dependency security +- docs/security/threat-model.md — Asset sensitivity, threat categories, mitigations +- docs/security/security-policy.md — Non-negotiable rules, incident response + +**Memory:** +- docs/memory/project-memory.md — ADRs, patterns, conventions, known risks +- docs/memory/decisions-log.md — Significant decisions with rationale +- docs/memory/agent-memory/{role}.md — One per role (persistent memory for long-lived roles, templates for per-task roles) + +**Other:** +- docs/rag/doc-strategy.md — Documentation retrieval strategy +- CLAUDE.md — Root context: project overview, agent system, workflow, key docs table, tech stack, security quick ref +- .github/PULL_REQUEST_TEMPLATE.md — Checklist, risk assessment, model tier tracking +- .github/workflows/ci.yml — Pipeline matching the tech stack +- .github/CODEOWNERS — Security-sensitive paths require security reviewer +- .gitignore — Exclude .automaker/, secrets, build artifacts +- worktrees/README.md — Worktree isolation strategy +- kanban/board.md — Placeholder (populated after features are created) + +--- + +### Step 3: Configure Automaker (.automaker/) + +**settings.json:** +```json +{ + "autoLoadClaudeMd": true, + "defaultModel": "claude-opus-4-6", + "autoLoadMemory": true, + "maxMemoryFiles": 5 +} +```` + +**categories.json** — The categories determined in Step 1. + +**pipeline.json** — Review pipeline (minimum 3 steps): + +1. Code Review — Enforce project coding standards +2. Security Review — MANDATORY security checklist (items determined by project's security profile) +3. QA & Testing — Build verification, test execution, coverage gates + +Add additional pipeline steps if the project warrants it (e.g., ML model validation, performance benchmarking, accessibility audit). + +**Context files** (.automaker/context/) — The files determined in Step 1. Each must be: + +- Actionable (tells agents exactly what to do, not just guidelines) +- Enforced (violations labeled as BLOCKING where appropriate) +- Maintained (project-structure.md updated as features are completed) + +Include context-metadata.json indexing all files with descriptions. + +**Memory files** (.automaker/memory/) — Brain files for roles that need persistent knowledge. Include at minimum: + +- {planner-role}-brain.md — Velocity, decisions, risk register +- {security-role}-brain.md — Vulnerabilities, security decisions, audit patterns +- {integrator-role}-brain.md — Deployment history, merge patterns +- patterns files for major tech domains (e.g., backend-patterns.md, frontend-patterns.md) +- decisions-and-adrs.md — Architecture Decision Records +- gotchas.md — Common pitfalls and lessons learned + +--- + +### Step 4: Implement Foundation Code + +Build the foundation code in waves appropriate to the project. General pattern: + +- Wave 1: Data layer (schema, models, config) +- Wave 2: Auth & security layer +- Wave 3: Core business logic +- Wave 4: UI/interface layer + real-time features + +After each wave: run tests, fix errors, verify builds. +After ALL waves: run full test suite, ensure everything passes. + +The goal is to establish enough working code that agents can build ON TOP of it rather than from scratch. This foundation becomes the "verified" feature set. + +--- + +### Step 5: Load Features + +Create feature.json files in .automaker/features/ for EVERY feature in the spec. + +**Every feature.json MUST include ALL of these fields:** + +```json +{ + "id": "feature-{v|b}{number}-{slug}", + "title": "Human-readable title", + "category": "From categories.json", + "description": "Detailed — what to build, referencing spec requirements", + "status": "verified|backlog", + "priority": 1, + "model": "opus|sonnet|haiku", + "dependencies": ["other-feature-ids"], + "branchName": null, + "skipTests": false, + "thinkingLevel": "ultrathink|high|medium|low|none", + "planningMode": "full|spec|lite|skip", + "requirePlanApproval": true, + "epicId": "E01", + "epicName": "Epic Name", + "assignedAgent": "role-from-roster", + "complexity": "XL|L|M|S", + "modelTier": 1, + "tierJustification": "Why this tier was chosen" +} +``` + +**Model Selection — 3-Tier Policy (apply per-feature):** + +| Tier | Model | Use When | Target | +| ---- | ------ | --------------------------------------------------------------------- | ------- | +| 1 | opus | Security, architecture, complex algorithms, hard-to-reverse decisions | ~20-30% | +| 2 | sonnet | Standard implementation, UI, tests, well-scoped tasks | ~55-65% | +| 3 | haiku | Docs, config, boilerplate, mechanical/deterministic tasks | ~10-15% | + +Rule: If wrong decision is expensive or hard to reverse → Tier 1. + +**Thinking Level — Map from tier × complexity:** + +| Tier | Complexity | thinkingLevel | Token Budget | +| ---------- | ---------- | ------------- | ------------ | +| 1 (opus) | XL | ultrathink | 32K | +| 1 (opus) | L or M | high | 16K | +| 2 (sonnet) | XL or L | medium | 10K | +| 2 (sonnet) | M or S | low | 1K | +| 3 (haiku) | any | none | disabled | + +**Planning Mode — Map from complexity:** + +| Complexity | planningMode | requirePlanApproval | +| ---------------------------------------------------- | ------------ | ------------------- | +| XL | full | true | +| L + high-impact (security, architecture, algorithms) | full | true | +| L + standard (UI, tests, CRUD) | spec | true | +| M | lite | false | +| S | skip | false | + +**skipTests:** + +- `false` (default) — all features that produce code +- `true` — ONLY documentation-only, config-only, infrastructure setup, checklists + +**Feature Categories:** + +1. **Verified** (prefix: v) — Foundation code built in Step 4. Status "verified". Auto-mode skips these. They unblock dependent backlog features. + +2. **Core backlog** (prefix: b, priority 1-2) — Features needed for launch, from core spec. + +3. **Future backlog** (prefix: b, priority 3) — From "Future Enhancements" / aspirational spec sections. Lower priority but still tracked. + +**Dependencies:** Must form a valid DAG. Verified features satisfy dependencies for backlog features. + +--- + +### Step 6: Finalize & Verify + +1. **Update kanban/board.md** — All features by epic, verified features in Verified column. + +2. **Update project-structure.md** — Mark verified code as BUILT with feature IDs. Add "DO NOT REBUILD" warning. Mark backlog as NOT BUILT. + +3. **Verification checklist** (ALL must pass): + - [ ] Every spec section maps to at least one feature + - [ ] Every future/aspirational spec item maps to a priority-3 backlog feature + - [ ] Every feature.json has ALL required fields + - [ ] Dependencies form valid DAG (no cycles) + - [ ] Verified features properly unblock dependents + - [ ] Model tier matches policy (security/arch/algorithms → Tier 1) + - [ ] Thinking level matches tier × complexity table + - [ ] Planning mode matches complexity table + - [ ] skipTests true ONLY for non-code features + - [ ] Distribution roughly matches targets (~25% opus, ~60% sonnet, ~15% haiku) + - [ ] All agent roles have role cards AND memory files + - [ ] All docs referenced in CLAUDE.md exist and are non-empty + - [ ] Pipeline has at least 3 steps (code review, security, QA) + - [ ] Context files cover: workflow, standards, security, structure, worktrees, governance + - [ ] categories.json includes all categories used by features + - [ ] CI pipeline matches tech stack + - [ ] .gitignore excludes .automaker/ + - [ ] All tests pass + +4. Commit and push. + +--- + +Proceed step by step. Present your Step 1 analysis for my approval before continuing. Commit after each major step. Confirm with me before pushing. + +``` + +--- + +## What Makes This Generic + +- **No hardcoded agent roles** — roles are determined by analyzing the spec's tech domains +- **No hardcoded tech stack** — works for web apps, mobile, CLI tools, ML pipelines, games, anything +- **No hardcoded context files** — the set of context files is determined by project needs +- **No hardcoded categories** — derived from the project +- **No hardcoded security profile** — threat model scoped to actual project risks +- **No hardcoded CI pipeline** — stages match the tech stack +- **Step 1 is an analysis gate** — agent presents its understanding for human approval before building anything + +## What IS Fixed (Automaker Platform Constants) + +These are Automaker platform features that apply to every project: +- 3-tier model selection (opus/sonnet/haiku) with thinking level mapping +- Planning modes (full/spec/lite/skip) with complexity mapping +- 9-column kanban board workflow +- Feature.json schema with all required fields +- .automaker/ directory structure (settings, context, memory, pipeline, features) +- Worktree isolation strategy +- The principle that foundation code becomes "verified" features +``` From e4d352537e59d4fee98e90c22a04d44acde37f19 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 17:52:43 +0700 Subject: [PATCH 14/30] Add worktree auto-creation and update setup template Auto-create git worktrees when branchName is set but no worktree exists, instead of silently falling back to the main project directory. Updated setup template with branchName rules, pipeline model notes, and OAuth one-time code exchange pattern. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/services/auto-mode-service.ts | 77 +++++++++++++++++-- docs/automaker-setup-prompt-template.md | 20 ++++- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 7c1a8ba5a..88f291a77 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1156,14 +1156,19 @@ export class AutoModeService { if (useWorktrees && branchName) { // Try to find existing worktree for this branch - // Worktree should already exist (created when feature was added/edited) worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); if (worktreePath) { logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); } else { - // Worktree doesn't exist - log warning and continue with project path - logger.warn(`Worktree for branch "${branchName}" not found, using project path`); + // Worktree doesn't exist - auto-create it + logger.info(`Worktree for branch "${branchName}" not found, creating automatically`); + worktreePath = await this.createWorktreeForBranch(projectPath, branchName); + if (worktreePath) { + logger.info(`Created worktree for branch "${branchName}": ${worktreePath}`); + } else { + logger.warn(`Failed to create worktree for branch "${branchName}", using project path`); + } } } @@ -1780,11 +1785,15 @@ Complete the pipeline step instructions above. Review the previous work and appl if (useWorktrees && branchName) { worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); if (worktreePath) { - console.log(`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`); + logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); } else { - console.warn( - `[AutoMode] Worktree for branch "${branchName}" not found, using project path` - ); + logger.info(`Worktree for branch "${branchName}" not found, creating automatically`); + worktreePath = await this.createWorktreeForBranch(projectPath, branchName); + if (worktreePath) { + logger.info(`Created worktree for branch "${branchName}": ${worktreePath}`); + } else { + logger.warn(`Failed to create worktree for branch "${branchName}", using project path`); + } } } @@ -1907,12 +1916,20 @@ Complete the pipeline step instructions above. Review the previous work and appl const branchName = feature?.branchName || `feature/${featureId}`; if (useWorktrees && branchName) { - // Try to find existing worktree for this branch worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); if (worktreePath) { workDir = worktreePath; logger.info(`Follow-up using worktree for branch "${branchName}": ${workDir}`); + } else { + logger.info(`Worktree for branch "${branchName}" not found, creating automatically`); + worktreePath = await this.createWorktreeForBranch(projectPath, branchName); + if (worktreePath) { + workDir = worktreePath; + logger.info(`Created worktree for branch "${branchName}": ${workDir}`); + } else { + logger.warn(`Failed to create worktree for branch "${branchName}", using project path`); + } } } @@ -2811,6 +2828,50 @@ Format your response as a structured markdown document.`; } } + /** + * Auto-create a git worktree for a given branch when one doesn't already exist. + * This ensures features with branchName set (e.g. from JSON files) get proper + * worktree isolation even if they weren't created through the UI. + */ + private async createWorktreeForBranch( + projectPath: string, + branchName: string + ): Promise { + try { + const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreesDir = path.join(projectPath, '.worktrees'); + const worktreePath = path.join(worktreesDir, sanitizedName); + + // Create worktrees directory if it doesn't exist + await secureFs.mkdir(worktreesDir, { recursive: true }); + + // Check if branch already exists in git + let branchExists = false; + try { + await execAsync(`git rev-parse --verify "${branchName}"`, { cwd: projectPath }); + branchExists = true; + } catch { + // Branch doesn't exist yet + } + + // Create the worktree + if (branchExists) { + await execAsync(`git worktree add "${worktreePath}" "${branchName}"`, { + cwd: projectPath, + }); + } else { + await execAsync(`git worktree add -b "${branchName}" "${worktreePath}" HEAD`, { + cwd: projectPath, + }); + } + + return path.resolve(worktreePath); + } catch (error) { + logger.error(`Failed to create worktree for branch "${branchName}":`, error); + return null; + } + } + private async loadFeature(projectPath: string, featureId: string): Promise { // Features are stored in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); diff --git a/docs/automaker-setup-prompt-template.md b/docs/automaker-setup-prompt-template.md index 26feb4773..c799c3b03 100644 --- a/docs/automaker-setup-prompt-template.md +++ b/docs/automaker-setup-prompt-template.md @@ -120,6 +120,12 @@ Based on the analysis from Step 1, create: Add additional pipeline steps if the project warrants it (e.g., ML model validation, performance benchmarking, accessibility audit). +**Pipeline model & role pattern:** + +- Pipeline steps use the SAME model as the feature being reviewed (not a separate model) +- Role differentiation is achieved through detailed instructions in each step, not different models +- Each step's instructions should start with "You are a [role]" to set the agent's perspective (e.g., "You are a Security Reviewer...") + **Context files** (.automaker/context/) — The files determined in Step 1. Each must be: - Actionable (tells agents exactly what to do, not just guidelines) @@ -171,7 +177,7 @@ Create feature.json files in .automaker/features/ for EVERY feature in the spec. "priority": 1, "model": "opus|sonnet|haiku", "dependencies": ["other-feature-ids"], - "branchName": null, + "branchName": "feature/{feature-slug}", "skipTests": false, "thinkingLevel": "ultrathink|high|medium|low|none", "planningMode": "full|spec|lite|skip", @@ -228,8 +234,18 @@ Rule: If wrong decision is expensive or hard to reverse → Tier 1. 3. **Future backlog** (prefix: b, priority 3) — From "Future Enhancements" / aspirational spec sections. Lower priority but still tracked. +**branchName — Worktree Isolation:** + +- Verified features: set `branchName: null` (they run in main, already merged) +- Backlog features: MUST have `branchName` set (e.g. `"feature/b01-x-oauth"`) to enable git worktree isolation +- Automaker auto-creates the worktree when executing a feature with a `branchName` set +- Without `branchName`, features execute directly on the main branch (no isolation) + **Dependencies:** Must form a valid DAG. Verified features satisfy dependencies for backlog features. +**Security Pattern — OAuth One-Time Code Exchange:** +When implementing OAuth callbacks, never pass JWTs as URL query parameters. Instead, store a one-time code in Redis on the backend, redirect the user with that code as the query parameter, and have the frontend exchange the code for the actual JWT via a POST request. This prevents token leakage through browser history, referrer headers, and server logs. + --- ### Step 6: Finalize & Verify @@ -287,5 +303,7 @@ These are Automaker platform features that apply to every project: - Feature.json schema with all required fields - .automaker/ directory structure (settings, context, memory, pipeline, features) - Worktree isolation strategy +- Git worktree auto-creation for features with branchName set +- Pipeline uses single-agent, multi-role pattern (same model for all review steps, role differentiation via instructions) - The principle that foundation code becomes "verified" features ``` From ea5bc41ddabd9685ffc27daffdccc486b0d8d2e8 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 18:36:34 +0700 Subject: [PATCH 15/30] Auto-generate branchName at execution time instead of pre-setting Features now keep branchName=null until execution starts, preventing them from being filtered out of the main worktree view in the UI. The auto-mode-service generates and persists a branch name when a feature begins executing with worktrees enabled. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/services/auto-mode-service.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 88f291a77..af6be1764 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1152,7 +1152,24 @@ export class AutoModeService { // Derive workDir from feature.branchName // Worktrees should already be created when the feature is added/edited let worktreePath: string | null = null; - const branchName = feature.branchName; + let branchName = feature.branchName; + + // Auto-generate branchName if worktrees are enabled but no branch is set + if (useWorktrees && !branchName) { + branchName = `feature/${feature.id}`; + logger.info(`Auto-generating branch name for feature ${featureId}: ${branchName}`); + try { + await this.featureLoader.update(projectPath, featureId, { branchName }); + feature.branchName = branchName; + logger.info(`Saved auto-generated branch name "${branchName}" to feature ${featureId}`); + } catch (error) { + logger.error( + `Failed to save auto-generated branch name for feature ${featureId}:`, + error + ); + // Continue anyway - the branchName variable is set so worktree creation will still proceed + } + } if (useWorktrees && branchName) { // Try to find existing worktree for this branch From 3eb497ffa685ab4c7b40ba24a9490c5a094356b7 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 18:38:32 +0700 Subject: [PATCH 16/30] Fix setup template: branchName must be null, not pre-set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-setting branchName on backlog features causes them to be filtered out of the main worktree view in the UI. Updated guidance to leave branchName null — Automaker auto-generates it at execution time. Co-Authored-By: Claude Opus 4.6 --- docs/automaker-setup-prompt-template.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/automaker-setup-prompt-template.md b/docs/automaker-setup-prompt-template.md index c799c3b03..431728bb3 100644 --- a/docs/automaker-setup-prompt-template.md +++ b/docs/automaker-setup-prompt-template.md @@ -177,7 +177,7 @@ Create feature.json files in .automaker/features/ for EVERY feature in the spec. "priority": 1, "model": "opus|sonnet|haiku", "dependencies": ["other-feature-ids"], - "branchName": "feature/{feature-slug}", + "branchName": null, "skipTests": false, "thinkingLevel": "ultrathink|high|medium|low|none", "planningMode": "full|spec|lite|skip", @@ -236,10 +236,9 @@ Rule: If wrong decision is expensive or hard to reverse → Tier 1. **branchName — Worktree Isolation:** -- Verified features: set `branchName: null` (they run in main, already merged) -- Backlog features: MUST have `branchName` set (e.g. `"feature/b01-x-oauth"`) to enable git worktree isolation -- Automaker auto-creates the worktree when executing a feature with a `branchName` set -- Without `branchName`, features execute directly on the main branch (no isolation) +- Set `branchName: null` on ALL features (both verified and backlog) +- Automaker auto-generates a branch name (`feature/{feature-id}`) and creates the worktree automatically when execution starts +- Do NOT pre-set branchName on backlog features — this causes them to be filtered out of the main worktree view in the UI, making them invisible on the kanban board **Dependencies:** Must form a valid DAG. Verified features satisfy dependencies for backlog features. @@ -303,7 +302,7 @@ These are Automaker platform features that apply to every project: - Feature.json schema with all required fields - .automaker/ directory structure (settings, context, memory, pipeline, features) - Worktree isolation strategy -- Git worktree auto-creation for features with branchName set +- Git worktree auto-creation at execution time (branchName auto-generated, do NOT pre-set) - Pipeline uses single-agent, multi-role pattern (same model for all review steps, role differentiation via instructions) - The principle that foundation code becomes "verified" features ``` From c162dfd4ffdf6eff2ab32fab9cfe2588c6026747 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 19:08:28 +0700 Subject: [PATCH 17/30] Fix auto-mode bypassing maxConcurrency when branchName auto-generated The auto-branchName generation was persisting to feature.json and updating the running feature tracker, which moved features out of the main worktree scope. This caused getRunningCountForWorktree to report 0 running, allowing the loop to start ALL backlog features instead of respecting the maxConcurrency limit. Fix: generate branchName locally for worktree creation without persisting it, and preserve the original branchName in the running feature tracker for accurate concurrency counting. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/services/auto-mode-service.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index af6be1764..13c6ef08d 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1154,21 +1154,14 @@ export class AutoModeService { let worktreePath: string | null = null; let branchName = feature.branchName; - // Auto-generate branchName if worktrees are enabled but no branch is set + // Auto-generate branchName for worktree isolation if none is set. + // IMPORTANT: We do NOT persist this to feature.json here because doing so + // would move the feature out of the main worktree scope, causing the + // auto-loop's running count to miss it and bypass maxConcurrency limits. + // The branchName is saved later after the feature completes or is verified. if (useWorktrees && !branchName) { branchName = `feature/${feature.id}`; - logger.info(`Auto-generating branch name for feature ${featureId}: ${branchName}`); - try { - await this.featureLoader.update(projectPath, featureId, { branchName }); - feature.branchName = branchName; - logger.info(`Saved auto-generated branch name "${branchName}" to feature ${featureId}`); - } catch (error) { - logger.error( - `Failed to save auto-generated branch name for feature ${featureId}:`, - error - ); - // Continue anyway - the branchName variable is set so worktree creation will still proceed - } + logger.info(`Auto-generated branch name for worktree isolation: ${branchName}`); } if (useWorktrees && branchName) { @@ -1195,9 +1188,12 @@ export class AutoModeService { // Validate that working directory is allowed using centralized validation validateWorkingDirectory(workDir); - // Update running feature with actual worktree info + // Update running feature with actual worktree info. + // Keep the ORIGINAL feature.branchName for worktree-scoped counting so that + // features started from the main worktree (branchName: null) remain counted + // against the main worktree's maxConcurrency limit. tempRunningFeature.worktreePath = worktreePath; - tempRunningFeature.branchName = branchName ?? null; + tempRunningFeature.branchName = feature.branchName ?? null; // Update feature status to in_progress BEFORE emitting event // This ensures the frontend sees the updated status when it reloads features From 72f9e378ba061156b90cd523a44cadfd012e067a Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 19:15:59 +0700 Subject: [PATCH 18/30] Auto-mode picks from Ready only, not Backlog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features must be manually moved to Ready before auto-mode will execute them. This gives users explicit control over which features run, matching the intended kanban workflow: Backlog → Ready → execute. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/services/auto-mode-service.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 13c6ef08d..ad75e405e 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -3088,7 +3088,6 @@ Format your response as a structured markdown document.`; if ( feature.status === 'pending' || feature.status === 'ready' || - feature.status === 'backlog' || (feature.planSpec?.status === 'approved' && (feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) ) { @@ -3126,17 +3125,16 @@ Format your response as a structured markdown document.`; const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; logger.info( - `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/approved_with_pending_tasks) for ${worktreeDesc}` + `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/approved_with_pending_tasks) for ${worktreeDesc}` ); if (pendingFeatures.length === 0) { logger.warn( `[loadPendingFeatures] No pending features found for ${worktreeDesc}. Check branchName matching - looking for branchName: ${branchName === null ? 'null (main)' : branchName}` ); - // Log all backlog features to help debug branchName matching + // Log all ready/pending features to help debug branchName matching const allBacklogFeatures = allFeatures.filter( (f) => - f.status === 'backlog' || f.status === 'pending' || f.status === 'ready' || (f.planSpec?.status === 'approved' && From bff306f039cacdaa9871125fd9a69ef53400074a Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 19:17:30 +0700 Subject: [PATCH 19/30] Document Ready workflow in setup template Auto-mode picks from Ready only. Users must manually move features from Backlog to Ready to queue them for execution. Co-Authored-By: Claude Opus 4.6 --- docs/automaker-setup-prompt-template.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/automaker-setup-prompt-template.md b/docs/automaker-setup-prompt-template.md index 431728bb3..095b0fb51 100644 --- a/docs/automaker-setup-prompt-template.md +++ b/docs/automaker-setup-prompt-template.md @@ -242,6 +242,15 @@ Rule: If wrong decision is expensive or hard to reverse → Tier 1. **Dependencies:** Must form a valid DAG. Verified features satisfy dependencies for backlog features. +**Auto-Mode Execution Workflow:** + +- Auto-mode only picks features with status `ready` — it ignores `backlog` entirely +- To execute features: manually move them from Backlog → Ready in the UI, then start auto-mode +- Auto-mode respects `maxConcurrency` (e.g., 3 concurrent agents) and picks from Ready in priority/dependency order +- When a feature finishes, auto-mode picks the next one from Ready +- When Ready is empty, auto-mode goes idle until you move more features to Ready +- This gives you explicit control over what gets executed and when + **Security Pattern — OAuth One-Time Code Exchange:** When implementing OAuth callbacks, never pass JWTs as URL query parameters. Instead, store a one-time code in Redis on the backend, redirect the user with that code as the query parameter, and have the frontend exchange the code for the actual JWT via a POST request. This prevents token leakage through browser history, referrer headers, and server logs. @@ -303,6 +312,7 @@ These are Automaker platform features that apply to every project: - .automaker/ directory structure (settings, context, memory, pipeline, features) - Worktree isolation strategy - Git worktree auto-creation at execution time (branchName auto-generated, do NOT pre-set) +- Auto-mode picks from Ready only (not Backlog) — user controls what gets executed - Pipeline uses single-agent, multi-role pattern (same model for all review steps, role differentiation via instructions) - The principle that foundation code becomes "verified" features ``` From 88fc610184f66472881a702b40866c687689066f Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 19:49:20 +0700 Subject: [PATCH 20/30] Auto-merge verified feature branches back to main When auto-mode verifies a feature in a worktree, the branch now automatically squash-merges back to main so dependent features can start from updated code. Adds a safety net that blocks dependent features until their dependencies are actually merged (mergedToMain flag). Extracts reusable merge/cleanup utilities from the worktree merge route into @automaker/git-utils. Co-Authored-By: Claude Opus 4.6 --- .../src/routes/worktree/routes/merge.ts | 100 ++-------- apps/server/src/services/auto-mode-service.ts | 148 ++++++++++++++- libs/dependency-resolver/src/resolver.ts | 13 +- libs/git-utils/src/index.ts | 8 + libs/git-utils/src/merge.ts | 177 ++++++++++++++++++ libs/types/src/feature.ts | 1 + libs/types/src/settings.ts | 3 + 7 files changed, 365 insertions(+), 85 deletions(-) create mode 100644 libs/git-utils/src/merge.ts diff --git a/apps/server/src/routes/worktree/routes/merge.ts b/apps/server/src/routes/worktree/routes/merge.ts index 48df7893c..bd762f708 100644 --- a/apps/server/src/routes/worktree/routes/merge.ts +++ b/apps/server/src/routes/worktree/routes/merge.ts @@ -8,13 +8,8 @@ */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js'; -import { createLogger } from '@automaker/utils'; - -const execAsync = promisify(exec); -const logger = createLogger('Worktree'); +import { getErrorMessage, logError } from '../common.js'; +import { mergeWorktreeBranch, cleanupWorktree } from '@automaker/git-utils'; export function createMergeHandler() { return async (req: Request, res: Response): Promise => { @@ -38,102 +33,41 @@ export function createMergeHandler() { // Determine the target branch (default to 'main') const mergeTo = targetBranch || 'main'; - // Validate source branch exists - try { - await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath }); - } catch { - res.status(400).json({ - success: false, - error: `Branch "${branchName}" does not exist`, - }); - return; - } - - // Validate target branch exists - try { - await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath }); - } catch { - res.status(400).json({ - success: false, - error: `Target branch "${mergeTo}" does not exist`, - }); - return; - } - - // Merge the feature branch into the target branch - const mergeCmd = options?.squash - ? `git merge --squash ${branchName}` - : `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`; - - try { - await execAsync(mergeCmd, { cwd: projectPath }); - } catch (mergeError: unknown) { - // Check if this is a merge conflict - const err = mergeError as { stdout?: string; stderr?: string; message?: string }; - const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`; - const hasConflicts = - output.includes('CONFLICT') || output.includes('Automatic merge failed'); + // Merge using shared utility + const result = await mergeWorktreeBranch(projectPath, branchName, mergeTo, { + squash: options?.squash, + message: options?.message, + }); - if (hasConflicts) { - // Return conflict-specific error message that frontend can detect + if (!result.success) { + if (result.hasConflicts) { res.status(409).json({ success: false, - error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`, + error: result.error, hasConflicts: true, }); return; } - // Re-throw non-conflict errors to be handled by outer catch - throw mergeError; - } - - // If squash merge, need to commit - if (options?.squash) { - await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, { - cwd: projectPath, + res.status(400).json({ + success: false, + error: result.error, }); + return; } // Optionally delete the worktree and branch after merging - let worktreeDeleted = false; - let branchDeleted = false; + let deleted: { worktreeDeleted: boolean; branchDeleted: boolean } | undefined; if (options?.deleteWorktreeAndBranch) { - // Remove the worktree - try { - await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath); - worktreeDeleted = true; - } catch { - // Try with prune if remove fails - try { - await execGitCommand(['worktree', 'prune'], projectPath); - worktreeDeleted = true; - } catch { - logger.warn(`Failed to remove worktree: ${worktreePath}`); - } - } - - // Delete the branch (but not main/master) - if (branchName !== 'main' && branchName !== 'master') { - if (!isValidBranchName(branchName)) { - logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`); - } else { - try { - await execGitCommand(['branch', '-D', branchName], projectPath); - branchDeleted = true; - } catch { - logger.warn(`Failed to delete branch: ${branchName}`); - } - } - } + deleted = await cleanupWorktree(projectPath, worktreePath, branchName); } res.json({ success: true, mergedBranch: branchName, targetBranch: mergeTo, - deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined, + deleted, }); } catch (error) { logError(error, 'Merge worktree failed'); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index ad75e405e..703a9eb7b 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -43,6 +43,7 @@ import { const logger = createLogger('AutoMode'); import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver'; import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; +import { mergeWorktreeBranch, cleanupWorktree } from '@automaker/git-utils'; import { getFeatureDir, getAutomakerDir, @@ -1320,6 +1321,15 @@ export class AutoModeService { const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); + // Auto-merge verified feature branch back to main if enabled + await this.autoMergeIfVerified( + projectPath, + featureId, + finalStatus, + branchName ?? null, + worktreePath + ); + // Record success to reset consecutive failure tracking this.recordSuccess(); @@ -1703,6 +1713,18 @@ Complete the pipeline step instructions above. Review the previous work and appl await this.updateFeatureStatus(projectPath, featureId, finalStatus); + // Auto-merge verified feature branch back to main if enabled + const resumeWorktreePath = feature.branchName + ? await this.findExistingWorktreeForBranch(projectPath, feature.branchName) + : null; + await this.autoMergeIfVerified( + projectPath, + featureId, + finalStatus, + feature.branchName ?? null, + resumeWorktreePath + ); + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature.title, @@ -1861,6 +1883,15 @@ Complete the pipeline step instructions above. Review the previous work and appl const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); + // Auto-merge verified feature branch back to main if enabled + await this.autoMergeIfVerified( + projectPath, + featureId, + finalStatus, + branchName ?? null, + worktreePath + ); + console.log('[AutoMode] Pipeline resume completed successfully'); this.emitAutoModeEvent('auto_mode_feature_complete', { @@ -2126,6 +2157,15 @@ Address the follow-up instructions above. Review the previous work and make the const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); + // Auto-merge verified feature branch back to main if enabled + await this.autoMergeIfVerified( + projectPath, + featureId, + finalStatus, + branchName ?? null, + worktreePath + ); + // Record success to reset consecutive failure tracking this.recordSuccess(); @@ -2970,6 +3010,106 @@ Format your response as a structured markdown document.`; } } + /** + * Auto-merge a verified feature's branch back to main if autoMergeOnVerify is enabled. + * Called after updateFeatureStatus when a feature reaches 'verified' status. + */ + private async autoMergeIfVerified( + projectPath: string, + featureId: string, + status: string, + branchName: string | null, + worktreePath: string | null + ): Promise { + // Only auto-merge on verified status + if (status !== 'verified') return; + + // Nothing to merge if no branch or on main/master + if (!branchName || branchName === 'main' || branchName === 'master') return; + + // Check if auto-merge is enabled + const settings = await this.settingsService?.getGlobalSettings(); + if (!settings?.autoMergeOnVerify) return; + + logger.info(`Auto-merging verified feature ${featureId} branch "${branchName}" to main`); + + const result = await mergeWorktreeBranch(projectPath, branchName, 'main', { + squash: true, + message: `Merge feature/${featureId} (squash)`, + }); + + const notificationService = getNotificationService(); + + if (result.success) { + // Mark feature as merged in feature.json + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + try { + const featureResult = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + const feature = featureResult.data; + if (feature) { + feature.mergedToMain = true; + feature.branchName = branchName; // Preserve branch name in the record + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + } + } catch (error) { + logger.error(`Failed to update mergedToMain for feature ${featureId}:`, error); + } + + // Clean up worktree and branch + if (worktreePath) { + const cleanup = await cleanupWorktree(projectPath, worktreePath, branchName); + logger.info( + `Cleanup after auto-merge: worktree=${cleanup.worktreeDeleted}, branch=${cleanup.branchDeleted}` + ); + } + + logger.info(`Auto-merge successful for feature ${featureId}`); + await notificationService.createNotification({ + type: 'feature_verified', + title: 'Feature Auto-Merged', + message: `"${featureId}" branch merged to main and cleaned up.`, + featureId, + projectPath, + }); + } else if (result.hasConflicts) { + // Merge conflict - set feature to error status, don't clean up worktree + logger.error(`Auto-merge conflict for feature ${featureId}: ${result.error}`); + await this.updateFeatureStatus(projectPath, featureId, 'error'); + + // Update feature.json with error message + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + try { + const featureResult = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + const feature = featureResult.data; + if (feature) { + feature.error = `Auto-merge conflict: ${result.error}`; + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + } + } catch (error) { + logger.error(`Failed to update error for feature ${featureId}:`, error); + } + + await notificationService.createNotification({ + type: 'feature_waiting_approval', + title: 'Merge Conflict', + message: `Auto-merge failed for "${featureId}". Please resolve conflicts manually.`, + featureId, + projectPath, + }); + } else { + // Other merge error - log but don't block + logger.error(`Auto-merge failed for feature ${featureId}: ${result.error}`); + } + } + private isFeatureFinished(feature: Feature): boolean { const isCompleted = feature.status === 'completed' || feature.status === 'verified'; @@ -3189,13 +3329,19 @@ Format your response as a structured markdown document.`; // Get skipVerificationInAutoMode setting const settings = await this.settingsService?.getGlobalSettings(); const skipVerification = settings?.skipVerificationInAutoMode ?? false; + // When worktrees and auto-merge are both enabled, require dependencies to be merged to main + const requireMerged = + (settings?.useWorktrees ?? false) && (settings?.autoMergeOnVerify ?? false); // Filter to only features with satisfied dependencies const readyFeatures: Feature[] = []; const blockedFeatures: Array<{ feature: Feature; reason: string }> = []; for (const feature of orderedFeatures) { - const isSatisfied = areDependenciesSatisfied(feature, allFeatures, { skipVerification }); + const isSatisfied = areDependenciesSatisfied(feature, allFeatures, { + skipVerification, + requireMerged, + }); if (isSatisfied) { readyFeatures.push(feature); } else { diff --git a/libs/dependency-resolver/src/resolver.ts b/libs/dependency-resolver/src/resolver.ts index 02c87c265..93ae0aad0 100644 --- a/libs/dependency-resolver/src/resolver.ts +++ b/libs/dependency-resolver/src/resolver.ts @@ -177,6 +177,8 @@ function detectCycles(features: Feature[], featureMap: Map): st export interface DependencySatisfactionOptions { /** If true, only require dependencies to not be 'running' (ignore verification requirement) */ skipVerification?: boolean; + /** If true, also require dependencies to have mergedToMain === true (for worktree isolation) */ + requireMerged?: boolean; } /** @@ -197,6 +199,7 @@ export function areDependenciesSatisfied( } const skipVerification = options?.skipVerification ?? false; + const requireMerged = options?.requireMerged ?? false; return feature.dependencies.every((depId: string) => { const dep = allFeatures.find((f) => f.id === depId); @@ -207,7 +210,15 @@ export function areDependenciesSatisfied( return dep.status !== 'running'; } // Default: require 'completed' or 'verified' - return dep.status === 'completed' || dep.status === 'verified'; + const statusSatisfied = dep.status === 'completed' || dep.status === 'verified'; + if (!statusSatisfied) return false; + + // When requireMerged is set, also require the dependency's branch to be merged to main + if (requireMerged && dep.mergedToMain !== true) { + return false; + } + + return true; }); } diff --git a/libs/git-utils/src/index.ts b/libs/git-utils/src/index.ts index 33067e91e..0ecd57b48 100644 --- a/libs/git-utils/src/index.ts +++ b/libs/git-utils/src/index.ts @@ -9,6 +9,14 @@ export { BINARY_EXTENSIONS, GIT_STATUS_MAP, type FileStatus } from './types.js'; // Export status utilities export { isGitRepo, parseGitStatus } from './status.js'; +// Export merge utilities +export { + mergeWorktreeBranch, + cleanupWorktree, + type MergeResult, + type CleanupResult, +} from './merge.js'; + // Export diff utilities export { generateSyntheticDiffForNewFile, diff --git a/libs/git-utils/src/merge.ts b/libs/git-utils/src/merge.ts new file mode 100644 index 000000000..040d5c9bf --- /dev/null +++ b/libs/git-utils/src/merge.ts @@ -0,0 +1,177 @@ +/** + * Git merge and worktree cleanup utilities + * + * Reusable merge logic extracted from the worktree merge route + * for use in auto-mode auto-merge and other contexts. + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { createLogger } from '@automaker/utils'; + +const execAsync = promisify(exec); +const logger = createLogger('GitMerge'); + +export interface MergeResult { + success: boolean; + hasConflicts: boolean; + error?: string; +} + +export interface CleanupResult { + worktreeDeleted: boolean; + branchDeleted: boolean; +} + +/** + * Validate branch name to prevent command injection. + * Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars. + */ +function isValidBranchName(name: string): boolean { + return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < 250; +} + +/** + * Merge a worktree branch into a target branch. + * + * Checks out the target branch, runs `git merge` (squash or regular), + * and detects conflicts via stdout/stderr. + * + * @param projectPath - Path to the main git repository + * @param branchName - Source branch to merge from + * @param mergeTo - Target branch to merge into (e.g. 'main') + * @param options - Merge options (squash, custom message) + * @returns MergeResult indicating success/failure and conflict status + */ +export async function mergeWorktreeBranch( + projectPath: string, + branchName: string, + mergeTo: string, + options?: { squash?: boolean; message?: string } +): Promise { + // Validate branch names + if (!isValidBranchName(branchName) || !isValidBranchName(mergeTo)) { + return { success: false, hasConflicts: false, error: 'Invalid branch name' }; + } + + // Validate source branch exists + try { + await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath }); + } catch { + return { success: false, hasConflicts: false, error: `Branch "${branchName}" does not exist` }; + } + + // Validate target branch exists + try { + await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath }); + } catch { + return { + success: false, + hasConflicts: false, + error: `Target branch "${mergeTo}" does not exist`, + }; + } + + // Checkout target branch + try { + await execAsync(`git checkout ${mergeTo}`, { cwd: projectPath }); + } catch (error) { + const err = error as { message?: string }; + return { + success: false, + hasConflicts: false, + error: `Failed to checkout ${mergeTo}: ${err.message || 'unknown error'}`, + }; + } + + // Merge the feature branch + const mergeMsg = options?.message || `Merge ${branchName} into ${mergeTo}`; + const mergeCmd = options?.squash + ? `git merge --squash ${branchName}` + : `git merge ${branchName} -m "${mergeMsg}"`; + + try { + await execAsync(mergeCmd, { cwd: projectPath }); + } catch (mergeError: unknown) { + const err = mergeError as { stdout?: string; stderr?: string; message?: string }; + const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`; + const hasConflicts = output.includes('CONFLICT') || output.includes('Automatic merge failed'); + + if (hasConflicts) { + return { + success: false, + hasConflicts: true, + error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`, + }; + } + + return { success: false, hasConflicts: false, error: output.trim() }; + } + + // If squash merge, need to commit + if (options?.squash) { + try { + const squashMsg = options?.message || `Merge ${branchName} (squash)`; + await execAsync(`git commit -m "${squashMsg}"`, { cwd: projectPath }); + } catch (commitError: unknown) { + const err = commitError as { message?: string }; + return { + success: false, + hasConflicts: false, + error: `Squash commit failed: ${err.message || 'unknown error'}`, + }; + } + } + + return { success: true, hasConflicts: false }; +} + +/** + * Clean up a worktree and its branch after a successful merge. + * + * Removes the worktree (with fallback to prune) and deletes the branch + * (skipping main/master for safety). + * + * @param projectPath - Path to the main git repository + * @param worktreePath - Path to the worktree directory to remove + * @param branchName - Branch to delete after worktree removal + * @returns CleanupResult indicating what was deleted + */ +export async function cleanupWorktree( + projectPath: string, + worktreePath: string, + branchName: string +): Promise { + let worktreeDeleted = false; + let branchDeleted = false; + + // Remove the worktree + try { + await execAsync(`git worktree remove "${worktreePath}" --force`, { cwd: projectPath }); + worktreeDeleted = true; + } catch { + // Try with prune if remove fails + try { + await execAsync('git worktree prune', { cwd: projectPath }); + worktreeDeleted = true; + } catch { + logger.warn(`Failed to remove worktree: ${worktreePath}`); + } + } + + // Delete the branch (but not main/master) + if (branchName !== 'main' && branchName !== 'master') { + if (!isValidBranchName(branchName)) { + logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`); + } else { + try { + await execAsync(`git branch -D ${branchName}`, { cwd: projectPath }); + branchDeleted = true; + } catch { + logger.warn(`Failed to delete branch: ${branchName}`); + } + } + } + + return { worktreeDeleted, branchDeleted }; +} diff --git a/libs/types/src/feature.ts b/libs/types/src/feature.ts index ba6ca38ad..f86b982ec 100644 --- a/libs/types/src/feature.ts +++ b/libs/types/src/feature.ts @@ -66,6 +66,7 @@ export interface Feature { error?: string; summary?: string; startedAt?: string; + mergedToMain?: boolean; // Whether verified feature branch has been merged back to main descriptionHistory?: DescriptionHistoryEntry[]; // History of description changes [key: string]: unknown; // Keep catch-all for extensibility } diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 317638961..30ef55ee2 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -844,6 +844,8 @@ export interface GlobalSettings { enableDependencyBlocking: boolean; /** Skip verification requirement in auto-mode (treat 'completed' same as 'verified') */ skipVerificationInAutoMode: boolean; + /** Auto-merge verified feature branches back to main (squash merge) */ + autoMergeOnVerify: boolean; /** Default: use git worktrees for feature branches */ useWorktrees: boolean; /** Default: planning approach (skip/lite/spec/full) */ @@ -1272,6 +1274,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { defaultSkipTests: true, enableDependencyBlocking: true, skipVerificationInAutoMode: false, + autoMergeOnVerify: true, useWorktrees: true, defaultPlanningMode: 'skip', defaultRequirePlanApproval: false, From cdd10d27662f28785e6409458963af4e058ba822 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 20:00:46 +0700 Subject: [PATCH 21/30] Fix auto-mode still picking up pending (backlog) features The loadPendingFeatures filter still included status === 'pending' which maps to backlog. Now only 'ready' features are picked up, requiring manual move from Backlog to Ready before auto-mode runs them. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/services/auto-mode-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 703a9eb7b..3b2b1a55c 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -3222,11 +3222,11 @@ Format your response as a structured markdown document.`; allFeatures.push(feature); - // Track pending features separately, filtered by worktree/branch + // Track ready features separately, filtered by worktree/branch + // Only 'ready' features are picked up - 'pending' (backlog) requires manual move to Ready // Note: waiting_approval is NOT included - those features have completed execution // and are waiting for user review, they should not be picked up again if ( - feature.status === 'pending' || feature.status === 'ready' || (feature.planSpec?.status === 'approved' && (feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) From 4eb1354720c69ff0e6893f2f93875265492447c8 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 20:22:57 +0700 Subject: [PATCH 22/30] Fix loadPendingFeatures deleting valid dependencies The missing dependency removal logic compared against pendingFeatures (only ready/pending features) instead of allFeatures. Dependencies that were in_progress, verified, or any other non-pending status were incorrectly flagged as "missing" and permanently deleted from feature.json. Now only truly missing dependencies (not in allFeatures at all) are removed. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/services/auto-mode-service.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 3b2b1a55c..201a76bdd 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -3290,19 +3290,27 @@ Format your response as a structured markdown document.`; // Apply dependency-aware ordering const { orderedFeatures, missingDependencies } = resolveDependencies(pendingFeatures); - // Remove missing dependencies from features and save them - // This allows features to proceed when their dependencies have been deleted or don't exist + // Remove truly missing dependencies from features and save them + // Only remove deps that don't exist in allFeatures at all (i.e. deleted features). + // Deps that exist but aren't in pendingFeatures (e.g. in_progress, verified) are NOT removed. if (missingDependencies.size > 0) { for (const [featureId, missingDepIds] of missingDependencies) { + // Filter to only deps that are truly gone (not in allFeatures) + const trulyMissingDepIds = missingDepIds.filter( + (depId) => !allFeatures.find((f) => f.id === depId) + ); + + if (trulyMissingDepIds.length === 0) continue; + const feature = pendingFeatures.find((f) => f.id === featureId); if (feature && feature.dependencies) { - // Filter out the missing dependency IDs + // Filter out the truly missing dependency IDs const validDependencies = feature.dependencies.filter( - (depId) => !missingDepIds.includes(depId) + (depId) => !trulyMissingDepIds.includes(depId) ); logger.warn( - `[loadPendingFeatures] Feature ${featureId} has missing dependencies: ${missingDepIds.join(', ')}. Removing them automatically.` + `[loadPendingFeatures] Feature ${featureId} has truly missing dependencies (deleted): ${trulyMissingDepIds.join(', ')}. Removing them automatically.` ); // Update the feature in memory From 3a3ea97f126340cccd32e583aa2ce5b87515b8e5 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 20:49:52 +0700 Subject: [PATCH 23/30] Set startedAt on in_progress features, add auto-merge log and settings toggle UI - Set feature.startedAt timestamp when status changes to in_progress so the UI timer displays elapsed time instead of infinite spinner - Append Auto-Merge section to agent-output.md after successful merge (timestamp, branch, merge type, cleanup status) - Add autoMergeOnVerify toggle to settings UI with sync to server Co-Authored-By: Claude Opus 4.6 --- apps/server/src/services/auto-mode-service.ts | 30 +++++++++++++++++ .../ui/src/components/views/settings-view.tsx | 4 +++ .../feature-defaults-section.tsx | 32 +++++++++++++++++++ apps/ui/src/hooks/use-settings-migration.ts | 3 ++ apps/ui/src/hooks/use-settings-sync.ts | 2 ++ apps/ui/src/store/app-store.ts | 14 ++++++++ 6 files changed, 85 insertions(+) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 201a76bdd..f7a429df8 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -2964,6 +2964,10 @@ Format your response as a structured markdown document.`; feature.status = status; feature.updatedAt = new Date().toISOString(); + // Set startedAt timestamp when moving to in_progress (for UI timer) + if (status === 'in_progress') { + feature.startedAt = new Date().toISOString(); + } // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) // Badge will show for 2 minutes after this timestamp if (status === 'waiting_approval') { @@ -3060,13 +3064,39 @@ Format your response as a structured markdown document.`; } // Clean up worktree and branch + let cleanupSucceeded = false; if (worktreePath) { const cleanup = await cleanupWorktree(projectPath, worktreePath, branchName); + cleanupSucceeded = cleanup.worktreeDeleted && cleanup.branchDeleted; logger.info( `Cleanup after auto-merge: worktree=${cleanup.worktreeDeleted}, branch=${cleanup.branchDeleted}` ); } + // Append Auto-Merge section to agent-output.md + try { + const outputPath = path.join(featureDir, 'agent-output.md'); + const timestamp = new Date().toISOString(); + const autoMergeSection = `\n\n## Auto-Merge\n\n- **Timestamp:** ${timestamp}\n- **Branch Merged:** ${branchName}\n- **Target Branch:** main\n- **Merge Type:** squash\n- **Cleanup Succeeded:** ${cleanupSucceeded}\n`; + + let existingContent = ''; + try { + existingContent = (await secureFs.readFile(outputPath, 'utf-8')) as string; + } catch { + // File doesn't exist yet - will create it + } + + if (existingContent) { + await secureFs.appendFile(outputPath, autoMergeSection); + } else { + await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); + await secureFs.writeFile(outputPath, autoMergeSection.trimStart()); + } + logger.info(`Appended Auto-Merge section to agent-output.md for feature ${featureId}`); + } catch (error) { + logger.error(`Failed to append Auto-Merge section for feature ${featureId}:`, error); + } + logger.info(`Auto-merge successful for feature ${featureId}`); await notificationService.createNotification({ type: 'feature_verified', diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 3bcec3bb1..657c3fd99 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -43,6 +43,8 @@ export function SettingsView() { setEnableDependencyBlocking, skipVerificationInAutoMode, setSkipVerificationInAutoMode, + autoMergeOnVerify, + setAutoMergeOnVerify, enableAiCommitMessages, setEnableAiCommitMessages, useWorktrees, @@ -162,6 +164,7 @@ export function SettingsView() { defaultSkipTests={defaultSkipTests} enableDependencyBlocking={enableDependencyBlocking} skipVerificationInAutoMode={skipVerificationInAutoMode} + autoMergeOnVerify={autoMergeOnVerify} defaultPlanningMode={defaultPlanningMode} defaultRequirePlanApproval={defaultRequirePlanApproval} enableAiCommitMessages={enableAiCommitMessages} @@ -169,6 +172,7 @@ export function SettingsView() { onDefaultSkipTestsChange={setDefaultSkipTests} onEnableDependencyBlockingChange={setEnableDependencyBlocking} onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode} + onAutoMergeOnVerifyChange={setAutoMergeOnVerify} onDefaultPlanningModeChange={setDefaultPlanningMode} onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval} onEnableAiCommitMessagesChange={setEnableAiCommitMessages} diff --git a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx index 955d4dca6..de0f9bfe9 100644 --- a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx +++ b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx @@ -10,6 +10,7 @@ import { ScrollText, ShieldCheck, FastForward, + GitMerge, Sparkles, Cpu, } from 'lucide-react'; @@ -30,6 +31,7 @@ interface FeatureDefaultsSectionProps { defaultSkipTests: boolean; enableDependencyBlocking: boolean; skipVerificationInAutoMode: boolean; + autoMergeOnVerify: boolean; defaultPlanningMode: PlanningMode; defaultRequirePlanApproval: boolean; enableAiCommitMessages: boolean; @@ -37,6 +39,7 @@ interface FeatureDefaultsSectionProps { onDefaultSkipTestsChange: (value: boolean) => void; onEnableDependencyBlockingChange: (value: boolean) => void; onSkipVerificationInAutoModeChange: (value: boolean) => void; + onAutoMergeOnVerifyChange: (value: boolean) => void; onDefaultPlanningModeChange: (value: PlanningMode) => void; onDefaultRequirePlanApprovalChange: (value: boolean) => void; onEnableAiCommitMessagesChange: (value: boolean) => void; @@ -47,6 +50,7 @@ export function FeatureDefaultsSection({ defaultSkipTests, enableDependencyBlocking, skipVerificationInAutoMode, + autoMergeOnVerify, defaultPlanningMode, defaultRequirePlanApproval, enableAiCommitMessages, @@ -54,6 +58,7 @@ export function FeatureDefaultsSection({ onDefaultSkipTestsChange, onEnableDependencyBlockingChange, onSkipVerificationInAutoModeChange, + onAutoMergeOnVerifyChange, onDefaultPlanningModeChange, onDefaultRequirePlanApprovalChange, onEnableAiCommitMessagesChange, @@ -290,6 +295,33 @@ export function FeatureDefaultsSection({ {/* Separator */}

+ {/* Auto Merge on Verify Setting */} +
+ onAutoMergeOnVerifyChange(checked === true)} + className="mt-1" + data-testid="auto-merge-on-verify-checkbox" + /> +
+ +

+ When enabled, verified feature branches are automatically squash-merged back to the + main branch. Requires worktrees to be enabled. +

+
+
+ + {/* Separator */} +
+ {/* AI Commit Messages Setting */}
| null { defaultSkipTests: state.defaultSkipTests as boolean, enableDependencyBlocking: state.enableDependencyBlocking as boolean, skipVerificationInAutoMode: state.skipVerificationInAutoMode as boolean, + autoMergeOnVerify: state.autoMergeOnVerify as boolean, useWorktrees: state.useWorktrees as boolean, defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'], defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean, @@ -704,6 +705,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { defaultSkipTests: settings.defaultSkipTests ?? true, enableDependencyBlocking: settings.enableDependencyBlocking ?? true, skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false, + autoMergeOnVerify: settings.autoMergeOnVerify ?? true, useWorktrees: settings.useWorktrees ?? true, defaultPlanningMode: settings.defaultPlanningMode ?? 'skip', defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false, @@ -793,6 +795,7 @@ function buildSettingsUpdateFromStore(): Record { defaultSkipTests: state.defaultSkipTests, enableDependencyBlocking: state.enableDependencyBlocking, skipVerificationInAutoMode: state.skipVerificationInAutoMode, + autoMergeOnVerify: state.autoMergeOnVerify, useWorktrees: state.useWorktrees, defaultPlanningMode: state.defaultPlanningMode, defaultRequirePlanApproval: state.defaultRequirePlanApproval, diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 8ede5600a..c0b3bcbdf 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -51,6 +51,7 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'defaultSkipTests', 'enableDependencyBlocking', 'skipVerificationInAutoMode', + 'autoMergeOnVerify', 'useWorktrees', 'defaultPlanningMode', 'defaultRequirePlanApproval', @@ -642,6 +643,7 @@ export async function refreshSettingsFromServer(): Promise { defaultSkipTests: serverSettings.defaultSkipTests, enableDependencyBlocking: serverSettings.enableDependencyBlocking, skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode, + autoMergeOnVerify: serverSettings.autoMergeOnVerify, useWorktrees: serverSettings.useWorktrees, defaultPlanningMode: serverSettings.defaultPlanningMode, defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 63dd79601..3080d9746 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -650,6 +650,7 @@ export interface AppState { defaultSkipTests: boolean; // Default value for skip tests when creating new features enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true) skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running) + autoMergeOnVerify: boolean; // When true, auto-merge verified feature branches back to main (squash merge) enableAiCommitMessages: boolean; // When true, auto-generate commit messages using AI when opening commit dialog planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch @@ -1121,6 +1122,7 @@ export interface AppActions { setDefaultSkipTests: (skip: boolean) => void; setEnableDependencyBlocking: (enabled: boolean) => void; setSkipVerificationInAutoMode: (enabled: boolean) => Promise; + setAutoMergeOnVerify: (enabled: boolean) => Promise; setEnableAiCommitMessages: (enabled: boolean) => Promise; setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise; setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise; @@ -1455,6 +1457,7 @@ const initialState: AppState = { defaultSkipTests: true, // Default to manual verification (tests disabled) enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI) skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified) + autoMergeOnVerify: true, // Default to enabled (auto-merge verified feature branches back to main) enableAiCommitMessages: true, // Default to enabled (auto-generate commit messages) planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch) addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults) @@ -2432,6 +2435,17 @@ export const useAppStore = create()((set, get) => ({ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); await syncSettingsToServer(); }, + setAutoMergeOnVerify: async (enabled) => { + const previous = get().autoMergeOnVerify; + set({ autoMergeOnVerify: enabled }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + const ok = await syncSettingsToServer(); + if (!ok) { + logger.error('Failed to sync autoMergeOnVerify setting to server - reverting'); + set({ autoMergeOnVerify: previous }); + } + }, setEnableAiCommitMessages: async (enabled) => { const previous = get().enableAiCommitMessages; set({ enableAiCommitMessages: enabled }); From 474e6f7737759da7d9c9b6989570c6872dfc7b48 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 21:51:21 +0700 Subject: [PATCH 24/30] Fix timer, merge, and UI issues found during auto-merge testing - Clear startedAt when feature leaves in_progress (prevents stale timers) - Handle "nothing to squash" in merge utility when branches are identical - Show merged features on main board (mergedToMain bypasses worktree filter) - Fix useState ordering in worktree-panel (autoModeConfirmWorktree TDZ error) - Add auto-mode confirmation dialog with settings summary Co-Authored-By: Claude Opus 4.6 --- apps/server/src/services/auto-mode-service.ts | 2 + .../hooks/use-board-column-features.ts | 4 +- .../worktree-panel/worktree-panel.tsx | 125 +++++++++++++++++- libs/git-utils/src/merge.ts | 19 ++- 4 files changed, 140 insertions(+), 10 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index f7a429df8..e7f8f02ce 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -2967,6 +2967,8 @@ Format your response as a structured markdown document.`; // Set startedAt timestamp when moving to in_progress (for UI timer) if (status === 'in_progress') { feature.startedAt = new Date().toISOString(); + } else { + feature.startedAt = undefined; } // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) // Badge will show for 2 minutes after this timestamp diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts index 28ff66a46..6e7123918 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts @@ -73,8 +73,8 @@ export function useBoardColumnFeatures({ const featureBranch = f.branchName; let matchesWorktree: boolean; - if (!featureBranch) { - // No branch assigned - show only on primary worktree + if (!featureBranch || f.mergedToMain) { + // No branch assigned or merged to main (branch cleaned up) - show on primary worktree const isViewingPrimary = currentWorktreePath === null; matchesWorktree = isViewingPrimary; } else if (effectiveBranch === null) { diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index cb645ea6b..398db7618 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -25,7 +25,7 @@ import { import { useAppStore } from '@/store/app-store'; import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { Undo2 } from 'lucide-react'; +import { Undo2, Zap } from 'lucide-react'; import { getElectronAPI } from '@/lib/electron'; export function WorktreePanel({ @@ -120,13 +120,11 @@ export function WorktreePanel({ [currentProject, autoModeByWorktree, getAutoModeWorktreeKey] ); - // Handler to toggle auto-mode for a worktree - const handleToggleAutoMode = useCallback( + // Handler to actually start/stop auto-mode for a worktree (called directly for stop, or after confirmation for start) + const executeAutoModeToggle = useCallback( async (worktree: WorktreeInfo) => { if (!currentProject) return; - // Import the useAutoMode to get start/stop functions - // Since useAutoMode is a hook, we'll use the API client directly const api = getHttpApiClient(); const branchName = worktree.isMain ? null : worktree.branch; const isRunning = isAutoModeRunningForWorktree(worktree); @@ -157,6 +155,34 @@ export function WorktreePanel({ [currentProject, projectPath, isAutoModeRunningForWorktree] ); + // Auto-mode confirmation dialog state (must be before callbacks that reference it) + const [autoModeConfirmOpen, setAutoModeConfirmOpen] = useState(false); + const [autoModeConfirmWorktree, setAutoModeConfirmWorktree] = useState(null); + + // Handler to toggle auto-mode: shows confirmation dialog when starting, stops directly + const handleToggleAutoMode = useCallback( + (worktree: WorktreeInfo) => { + const isRunning = isAutoModeRunningForWorktree(worktree); + + if (isRunning) { + // Stop directly without confirmation + executeAutoModeToggle(worktree); + } else { + // Show confirmation dialog before starting + setAutoModeConfirmWorktree(worktree); + setAutoModeConfirmOpen(true); + } + }, + [isAutoModeRunningForWorktree, executeAutoModeToggle] + ); + + // Handler for confirming auto-mode start from the confirmation dialog + const handleConfirmAutoModeStart = useCallback(() => { + if (autoModeConfirmWorktree) { + executeAutoModeToggle(autoModeConfirmWorktree); + } + }, [autoModeConfirmWorktree, executeAutoModeToggle]); + // Check if init script exists for the project using React Query const { data: initScriptData } = useWorktreeInitScript(projectPath); const hasInitScript = initScriptData?.exists ?? false; @@ -181,6 +207,15 @@ export function WorktreePanel({ const [mergeDialogOpen, setMergeDialogOpen] = useState(false); const [mergeWorktree, setMergeWorktree] = useState(null); + // Settings for auto-mode confirmation dialog + const storeFeatures = useAppStore((state) => state.features); + const maxConcurrency = useAppStore((state) => state.maxConcurrency); + const autoMergeOnVerify = useAppStore((state) => state.autoMergeOnVerify); + const useWorktreesSetting = useAppStore((state) => state.useWorktrees); + + // Count features in "ready" status + const readyFeaturesCount = storeFeatures.filter((f) => f.status === 'ready').length; + const isMobile = useIsMobile(); // Periodic interval check (5 seconds) to detect branch changes on disk @@ -494,6 +529,46 @@ export function WorktreePanel({ onMerged={handleMerged} onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} /> + + {/* Auto-Mode Confirmation Dialog */} + +
+
+ Ready features + {readyFeaturesCount} +
+
+ Max concurrency + {maxConcurrency} +
+
+ Auto-merge on verify + + {autoMergeOnVerify ? 'Enabled' : 'Disabled'} + +
+
+ Worktrees + + {useWorktreesSetting ? 'Enabled' : 'Disabled'} + +
+
+
); } @@ -703,6 +778,46 @@ export function WorktreePanel({ onMerged={handleMerged} onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} /> + + {/* Auto-Mode Confirmation Dialog */} + +
+
+ Ready features + {readyFeaturesCount} +
+
+ Max concurrency + {maxConcurrency} +
+
+ Auto-merge on verify + + {autoMergeOnVerify ? 'Enabled' : 'Disabled'} + +
+
+ Worktrees + + {useWorktreesSetting ? 'Enabled' : 'Disabled'} + +
+
+
); } diff --git a/libs/git-utils/src/merge.ts b/libs/git-utils/src/merge.ts index 040d5c9bf..87f5992a9 100644 --- a/libs/git-utils/src/merge.ts +++ b/libs/git-utils/src/merge.ts @@ -90,8 +90,10 @@ export async function mergeWorktreeBranch( ? `git merge --squash ${branchName}` : `git merge ${branchName} -m "${mergeMsg}"`; + let mergeOutput = ''; try { - await execAsync(mergeCmd, { cwd: projectPath }); + const result = await execAsync(mergeCmd, { cwd: projectPath }); + mergeOutput = `${result.stdout || ''} ${result.stderr || ''}`; } catch (mergeError: unknown) { const err = mergeError as { stdout?: string; stderr?: string; message?: string }; const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`; @@ -108,17 +110,28 @@ export async function mergeWorktreeBranch( return { success: false, hasConflicts: false, error: output.trim() }; } - // If squash merge, need to commit + // If squash merge, need to commit (unless there's nothing to squash) if (options?.squash) { + // "Already up to date" / "nothing to squash" means branches are identical — no commit needed + if (mergeOutput.includes('nothing to squash') || mergeOutput.includes('Already up to date')) { + logger.info(`No changes to merge from ${branchName} into ${mergeTo} (branches identical)`); + return { success: true, hasConflicts: false }; + } try { const squashMsg = options?.message || `Merge ${branchName} (squash)`; await execAsync(`git commit -m "${squashMsg}"`, { cwd: projectPath }); } catch (commitError: unknown) { const err = commitError as { message?: string }; + const msg = err.message || ''; + // "nothing to commit" means the branch had no new changes vs target — treat as success + if (msg.includes('nothing to commit') || msg.includes('no changes added')) { + logger.info(`No changes to merge from ${branchName} into ${mergeTo} (branches identical)`); + return { success: true, hasConflicts: false }; + } return { success: false, hasConflicts: false, - error: `Squash commit failed: ${err.message || 'unknown error'}`, + error: `Squash commit failed: ${msg}`, }; } } From 49c78afd48c6421a342d211399b86904cd8d03ad Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 21:58:43 +0700 Subject: [PATCH 25/30] Fix unterminated regex in log-parser extractAutoMergeSection Co-Authored-By: Claude Opus 4.6 --- apps/ui/src/lib/log-parser.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/ui/src/lib/log-parser.ts b/apps/ui/src/lib/log-parser.ts index a6fa32785..2c1ad9013 100644 --- a/apps/ui/src/lib/log-parser.ts +++ b/apps/ui/src/lib/log-parser.ts @@ -1196,6 +1196,25 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] { return merged; } +/** + * Extracts the ## Auto-Merge section from raw log output + * Returns the auto-merge section markdown if found, or null if not present + */ +export function extractAutoMergeSection(rawOutput: string): string | null { + if (!rawOutput || !rawOutput.trim()) { + return null; + } + + // Look for ## Auto-Merge section - capture everything until next ## heading or tag or end + const autoMergeMatch = rawOutput.match( + /^(## Auto-Merge[^\n]*\n[\s\S]*?)(?=\n## |\n|$)/m + ); + if (autoMergeMatch) { + return autoMergeMatch[1].trim(); + } + + return null; +} /** * Extracts summary content from raw log output * Returns the summary text if found, or null if no summary exists From 95ed9031d62be4531ea4160e2918f1fc9a5e5152 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 22:26:47 +0700 Subject: [PATCH 26/30] Fix log-parser regex re-broken by agent during auto-mode test The extractAutoMergeSection regex was rewritten by an agent with literal newlines, breaking esbuild transform. Restored proper \n escapes. Co-Authored-By: Claude Opus 4.6 --- apps/ui/src/lib/log-parser.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/ui/src/lib/log-parser.ts b/apps/ui/src/lib/log-parser.ts index 2c1ad9013..5e3680dd0 100644 --- a/apps/ui/src/lib/log-parser.ts +++ b/apps/ui/src/lib/log-parser.ts @@ -1205,16 +1205,21 @@ export function extractAutoMergeSection(rawOutput: string): string | null { return null; } - // Look for ## Auto-Merge section - capture everything until next ## heading or tag or end - const autoMergeMatch = rawOutput.match( - /^(## Auto-Merge[^\n]*\n[\s\S]*?)(?=\n## |\n|$)/m - ); - if (autoMergeMatch) { - return autoMergeMatch[1].trim(); + // Find the ## Auto-Merge heading + const startMatch = rawOutput.match(/^## Auto-Merge/m); + if (!startMatch || startMatch.index === undefined) { + return null; } - return null; + const rest = rawOutput.substring(startMatch.index); + + // Find the end: next ## heading or tag + const endMatch = rest.match(/\n(?=## |)/); + const section = endMatch ? rest.substring(0, endMatch.index) : rest; + + return section.trim() || null; } + /** * Extracts summary content from raw log output * Returns the summary text if found, or null if no summary exists From 2a827299291f063ec5ead1f4de61acf988db1609 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 23:20:13 +0700 Subject: [PATCH 27/30] Add failure recovery with retry loop and model escalation for auto-mode When auto-mode features fail, they now automatically retry with error context injection and optional model escalation (e.g., sonnet -> opus) instead of silently moving to backlog. After all retries are exhausted, features move to 'failed' status for clear visibility. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/services/auto-mode-service.ts | 251 +++++++++++++++++- libs/model-resolver/src/escalation.ts | 44 +++ libs/model-resolver/src/index.ts | 3 + libs/types/src/feature.ts | 11 + libs/types/src/settings.ts | 6 + 5 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 libs/model-resolver/src/escalation.ts diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index e7f8f02ce..755692b63 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -41,7 +41,12 @@ import { } from '@automaker/utils'; const logger = createLogger('AutoMode'); -import { resolveModelString, resolvePhaseModel, DEFAULT_MODELS } from '@automaker/model-resolver'; +import { + resolveModelString, + resolvePhaseModel, + DEFAULT_MODELS, + getEscalatedModel, +} from '@automaker/model-resolver'; import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; import { mergeWorktreeBranch, cleanupWorktree } from '@automaker/git-utils'; import { @@ -1333,6 +1338,9 @@ export class AutoModeService { // Record success to reset consecutive failure tracking this.recordSuccess(); + // Clear retry state on success (restore original model if escalated) + await this.clearRetryStateOnSuccess(projectPath, featureId); + // Record learnings and memory usage after successful feature completion try { const featureDir = getFeatureDir(projectPath, featureId); @@ -1388,8 +1396,23 @@ export class AutoModeService { projectPath, }); } else { - logger.error(`Feature ${featureId} failed:`, error); - await this.updateFeatureStatus(projectPath, featureId, 'backlog'); + // Retry logic: check if we can retry before giving up + const retryResult = await this.handleRetryOrFail( + projectPath, + featureId, + feature, + errorInfo, + useWorktrees, + isAutoMode + ); + + if (retryResult === 'retrying') { + // Feature is being retried - don't track as failure, don't clean up runningFeatures + // (handleRetryOrFail already deleted from runningFeatures and re-called executeFeature) + return; + } + + // All retries exhausted - emit error and track failure this.emitAutoModeEvent('auto_mode_error', { featureId, featureName: feature?.title, @@ -1915,8 +1938,22 @@ Complete the pipeline step instructions above. Review the previous work and appl projectPath, }); } else { - console.error(`[AutoMode] Pipeline resume failed for feature ${featureId}:`, error); - await this.updateFeatureStatus(projectPath, featureId, 'backlog'); + // Retry logic: check if we can retry before giving up + const retryResult = await this.handleRetryOrFail( + projectPath, + featureId, + feature, + errorInfo, + useWorktrees, + true // isAutoMode + ); + + if (retryResult === 'retrying') { + // Feature is being retried - don't track as failure + return; + } + + // All retries exhausted this.emitAutoModeEvent('auto_mode_error', { featureId, featureName: feature.title, @@ -1925,6 +1962,19 @@ Complete the pipeline step instructions above. Review the previous work and appl errorType: errorInfo.type, projectPath, }); + + // Track this failure and check if we should pause auto mode + const shouldPause = this.trackFailureAndCheckPause({ + type: errorInfo.type, + message: errorInfo.message, + }); + + if (shouldPause) { + this.signalShouldPause({ + type: errorInfo.type, + message: errorInfo.message, + }); + } } } finally { this.runningFeatures.delete(featureId); @@ -2169,6 +2219,9 @@ Address the follow-up instructions above. Review the previous work and make the // Record success to reset consecutive failure tracking this.recordSuccess(); + // Clear retry state on success (restore original model if escalated) + await this.clearRetryStateOnSuccess(projectPath, featureId); + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature?.title, @@ -3016,6 +3069,194 @@ Format your response as a structured markdown document.`; } } + /** + * Handle retry logic for a failed feature, or mark as failed if retries exhausted. + * Returns 'retrying' if a retry was initiated, 'failed' if all retries exhausted. + */ + private async handleRetryOrFail( + projectPath: string, + featureId: string, + feature: Feature | null | undefined, + errorInfo: { type: string; message: string; isAbort?: boolean }, + useWorktrees: boolean, + isAutoMode: boolean + ): Promise<'retrying' | 'failed'> { + try { + const globalSettings = await this.settingsService?.getGlobalSettings(); + const maxRetries = globalSettings?.autoModeMaxRetries ?? 1; + const escalateModel = globalSettings?.autoModeRetryModelEscalation ?? true; + + const currentFeature = await this.loadFeature(projectPath, featureId); + const retryState = currentFeature?.retryState || { + attemptNumber: 0, + history: [], + }; + + if (retryState.attemptNumber < maxRetries) { + // Record this failure in retry history + retryState.attemptNumber++; + if (!retryState.originalModel) { + retryState.originalModel = currentFeature?.model; + } + retryState.history.push({ + attempt: retryState.attemptNumber, + model: currentFeature?.model || 'default', + error: errorInfo.message, + errorType: errorInfo.type, + timestamp: new Date().toISOString(), + }); + + // Escalate model if enabled + let retryModel = currentFeature?.model; + if (escalateModel) { + const escalated = getEscalatedModel(retryModel || 'claude-sonnet'); + if (escalated) retryModel = escalated; + } + + // Update feature with retry state and new model + await this.updateFeatureRetryState(projectPath, featureId, retryState, retryModel); + + // Log and emit retry event + logger.info( + `Feature ${featureId} failed (attempt ${retryState.attemptNumber}/${maxRetries}), retrying with model: ${retryModel}` + ); + this.emitAutoModeEvent('auto_mode_feature_retry', { + featureId, + featureName: currentFeature?.title, + attempt: retryState.attemptNumber, + maxRetries, + previousModel: currentFeature?.model, + nextModel: retryModel, + error: errorInfo.message, + projectPath, + }); + + // Build retry prompt with error context + const retryPrompt = this.buildRetryPrompt(currentFeature, errorInfo.message, retryState); + + // Remove from running features before re-executing to avoid "already running" error + this.runningFeatures.delete(featureId); + + // Re-execute with error context (fire-and-forget, executeFeature handles its own errors) + this.executeFeature(projectPath, featureId, useWorktrees, isAutoMode, undefined, { + continuationPrompt: retryPrompt, + }); + + return 'retrying'; + } + } catch (retryError) { + logger.error(`Error in retry logic for feature ${featureId}:`, retryError); + } + + // All retries exhausted or retry logic failed - move to 'failed' (not 'backlog') + const currentFeature = await this.loadFeature(projectPath, featureId); + const attemptCount = currentFeature?.retryState?.attemptNumber ?? 0; + logger.error(`Feature ${featureId} failed after ${attemptCount} retries: ${errorInfo.message}`); + await this.updateFeatureStatus(projectPath, featureId, 'failed'); + return 'failed'; + } + + /** + * Build a retry prompt with error context from previous attempts. + */ + private buildRetryPrompt( + feature: Feature | null | undefined, + errorMessage: string, + retryState: NonNullable + ): string { + const historyLines = retryState.history + .map((h) => `- Attempt ${h.attempt} (${h.model}): ${h.error}`) + .join('\n'); + + return [ + `## Retry Attempt ${retryState.attemptNumber}`, + '', + 'Your previous attempt to implement this feature failed with the following error:', + '', + `**Error:** ${errorMessage}`, + '', + '**Previous attempts:**', + historyLines, + '', + 'Please review what went wrong and try again. The feature workspace still contains your previous work.', + 'If the error was due to an API/authentication issue, focus on completing the remaining work.', + 'If the error was due to a code issue, fix the problem and continue.', + '', + `## Feature: ${feature?.title || 'Untitled'}`, + '', + feature?.description || '', + ].join('\n'); + } + + /** + * Update a feature's retry state and optionally its model. + */ + private async updateFeatureRetryState( + projectPath: string, + featureId: string, + retryState: NonNullable, + newModel?: string + ): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + const feature = result.data; + if (!feature) { + logger.warn(`Feature ${featureId} not found for retry state update`); + return; + } + + feature.retryState = retryState; + if (newModel) { + feature.model = newModel; + } + + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + } catch (error) { + logger.error(`Failed to update retry state for feature ${featureId}:`, error); + } + } + + /** + * Clear retry state after a successful feature completion. + * Restores the original model if it was escalated during retries. + */ + private async clearRetryStateOnSuccess(projectPath: string, featureId: string): Promise { + const feature = await this.loadFeature(projectPath, featureId); + if (!feature?.retryState) return; + + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + const current = result.data; + if (!current) return; + + // Restore original model if it was escalated + if (current.retryState?.originalModel) { + current.model = current.retryState.originalModel; + } + + // Clear retry state + current.retryState = undefined; + + await atomicWriteJson(featurePath, current, { backupCount: DEFAULT_BACKUP_COUNT }); + } catch (error) { + logger.error(`Failed to clear retry state for feature ${featureId}:`, error); + } + } + /** * Auto-merge a verified feature's branch back to main if autoMergeOnVerify is enabled. * Called after updateFeatureStatus when a feature reaches 'verified' status. diff --git a/libs/model-resolver/src/escalation.ts b/libs/model-resolver/src/escalation.ts new file mode 100644 index 000000000..b7bb1623d --- /dev/null +++ b/libs/model-resolver/src/escalation.ts @@ -0,0 +1,44 @@ +/** + * Model escalation utilities for retry logic + * + * Provides model escalation chain for automatic retry with increasingly capable models. + * Used by auto-mode retry system to escalate from cheaper to more capable models on failure. + */ + +import { resolveModelString } from './resolver.js'; + +/** + * Escalation chain from least to most capable Claude model. + * Uses full model strings for reliable comparison. + */ +const ESCALATION_CHAIN = [ + 'claude-haiku-4-5-20251001', + 'claude-sonnet-4-5-20250929', + 'claude-opus-4-6', +]; + +/** + * Get the next model in the escalation chain for retry attempts. + * + * @param currentModel - The current model string (alias or full ID) + * @returns The next tier model string, or null if already at the top or not a Claude model + */ +export function getEscalatedModel(currentModel: string): string | null { + // Resolve to full model string for comparison + const resolved = resolveModelString(currentModel); + + // Find position in escalation chain + const currentIndex = ESCALATION_CHAIN.indexOf(resolved); + + if (currentIndex === -1) { + // Not in escalation chain (non-Claude model like cursor, codex, opencode, or provider model) + return null; + } + + if (currentIndex >= ESCALATION_CHAIN.length - 1) { + // Already at the top of the chain (opus) + return null; + } + + return ESCALATION_CHAIN[currentIndex + 1]; +} diff --git a/libs/model-resolver/src/index.ts b/libs/model-resolver/src/index.ts index 8b1707641..5ce09a007 100644 --- a/libs/model-resolver/src/index.ts +++ b/libs/model-resolver/src/index.ts @@ -19,3 +19,6 @@ export { resolvePhaseModel, type ResolvedPhaseModel, } from './resolver.js'; + +// Export escalation utilities +export { getEscalatedModel } from './escalation.js'; diff --git a/libs/types/src/feature.ts b/libs/types/src/feature.ts index f86b982ec..8c146e389 100644 --- a/libs/types/src/feature.ts +++ b/libs/types/src/feature.ts @@ -64,6 +64,17 @@ export interface Feature { tasksTotal?: number; }; error?: string; + retryState?: { + attemptNumber: number; + originalModel?: string; + history: Array<{ + attempt: number; + model: string; + error: string; + errorType: string; + timestamp: string; + }>; + }; summary?: string; startedAt?: string; mergedToMain?: boolean; // Whether verified feature branch has been merged back to main diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 30ef55ee2..dcd86483f 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -846,6 +846,10 @@ export interface GlobalSettings { skipVerificationInAutoMode: boolean; /** Auto-merge verified feature branches back to main (squash merge) */ autoMergeOnVerify: boolean; + /** Maximum retry attempts for failed features in auto-mode (0 = no retries) */ + autoModeMaxRetries?: number; + /** Enable model escalation on retry (e.g., sonnet → opus) */ + autoModeRetryModelEscalation?: boolean; /** Default: use git worktrees for feature branches */ useWorktrees: boolean; /** Default: planning approach (skip/lite/spec/full) */ @@ -1275,6 +1279,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { enableDependencyBlocking: true, skipVerificationInAutoMode: false, autoMergeOnVerify: true, + autoModeMaxRetries: 1, + autoModeRetryModelEscalation: true, useWorktrees: true, defaultPlanningMode: 'skip', defaultRequirePlanApproval: false, From 82354293641cc9200413af5b13a7bef7c12fa988 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 23:30:04 +0700 Subject: [PATCH 28/30] Add Failed column to Kanban board for retry-exhausted features Co-Authored-By: Claude Opus 4.6 --- .../views/board-view/components/list-view/status-badge.tsx | 7 +++++++ apps/ui/src/components/views/board-view/constants.ts | 6 ++++++ .../views/board-view/hooks/use-board-column-features.ts | 1 + 3 files changed, 14 insertions(+) diff --git a/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx b/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx index e29719014..24d972b16 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/status-badge.tsx @@ -47,6 +47,12 @@ const BASE_STATUS_DISPLAY: Record = { bgClass: 'bg-[var(--status-blocked)]/15', borderClass: 'border-[var(--status-blocked)]/30', }, + failed: { + label: 'Failed', + colorClass: 'text-[var(--status-error)]', + bgClass: 'bg-[var(--status-error)]/15', + borderClass: 'border-[var(--status-error)]/30', + }, in_review: { label: 'In Review', colorClass: 'text-[var(--status-in-review)]', @@ -238,6 +244,7 @@ export function getStatusOrder(status: FeatureStatusWithPipeline): number { assigned: 2, in_progress: 3, blocked: 4, + failed: 4.5, in_review: 5, waiting_approval: 7, verified: 8, diff --git a/apps/ui/src/components/views/board-view/constants.ts b/apps/ui/src/components/views/board-view/constants.ts index e9135c84e..ec863c36e 100644 --- a/apps/ui/src/components/views/board-view/constants.ts +++ b/apps/ui/src/components/views/board-view/constants.ts @@ -64,6 +64,11 @@ export const EMPTY_STATE_CONFIGS: Record = { description: 'Features stuck on blockers will appear here with documented reasons.', icon: 'ban', }, + failed: { + title: 'No Failed Features', + description: 'Features that exhausted all retry attempts will appear here.', + icon: 'ban', + }, in_review: { title: 'Nothing in Review', description: @@ -122,6 +127,7 @@ const BASE_COLUMNS: Column[] = [ colorClass: 'bg-[var(--status-in-progress)]', }, { id: 'blocked', title: 'Blocked', colorClass: 'bg-[var(--status-blocked)]' }, + { id: 'failed', title: 'Failed', colorClass: 'bg-[var(--status-error)]' }, { id: 'in_review', title: 'In Review', colorClass: 'bg-[var(--status-in-review)]' }, ]; diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts index 6e7123918..cb55c0275 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts @@ -35,6 +35,7 @@ export function useBoardColumnFeatures({ assigned: [], in_progress: [], blocked: [], + failed: [], in_review: [], waiting_approval: [], verified: [], From ac6732d9d0b5ca94c5e0e00478a7687f07fa6b12 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Feb 2026 23:53:54 +0700 Subject: [PATCH 29/30] feat: Add self-review pass before feature completion Adds an optional self-review step that runs after implementation (and pipeline steps) but before setting final status. Uses simpleQuery for a lightweight read-only review of the diff, then runAgent for an agentic fix pass if issues are found. Controlled by enableSelfReview setting (default: true). Co-Authored-By: Claude Opus 4.6 --- apps/server/src/services/auto-mode-service.ts | 222 +++++++++++++++++- libs/prompts/src/defaults.ts | 26 ++ libs/prompts/src/merge.ts | 4 + libs/types/src/prompts.ts | 4 + libs/types/src/settings.ts | 3 + 5 files changed, 258 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 755692b63..7b6cd352a 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -48,7 +48,7 @@ import { getEscalatedModel, } from '@automaker/model-resolver'; import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; -import { mergeWorktreeBranch, cleanupWorktree } from '@automaker/git-utils'; +import { mergeWorktreeBranch, cleanupWorktree, getGitRepositoryDiffs } from '@automaker/git-utils'; import { getFeatureDir, getAutomakerDir, @@ -1320,6 +1320,19 @@ export class AutoModeService { ); } + // Self-review pass: review the diff for issues before setting final status + const globalSettings = await this.settingsService?.getGlobalSettings(); + if (globalSettings?.enableSelfReview !== false) { + await this.executeSelfReview( + projectPath, + featureId, + feature, + workDir, + abortController, + autoLoadClaudeMd + ); + } + // Determine final status based on testing mode: // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) // - skipTests=true (manual verification): go to 'waiting_approval' for manual review @@ -1567,6 +1580,187 @@ export class AutoModeService { logger.info(`All pipeline steps completed for feature ${featureId}`); } + /** + * Self-review pass: reviews the agent's diff for issues and optionally fixes them. + * Uses simpleQuery for a lightweight read-only review, then runAgent if fixes are needed. + */ + private async executeSelfReview( + projectPath: string, + featureId: string, + feature: Feature, + workDir: string, + abortController: AbortController, + autoLoadClaudeMd: boolean + ): Promise { + logger.info(`Starting self-review for feature ${featureId}`); + + this.emitAutoModeEvent('self_review_started', { + featureId, + featureName: feature.title, + projectPath, + }); + + // Get the diff of changes made by the agent + let diffResult: { diff: string; hasChanges: boolean }; + try { + diffResult = await getGitRepositoryDiffs(workDir); + } catch (err) { + logger.warn(`Failed to get git diffs for self-review of feature ${featureId}:`, err); + this.emitAutoModeEvent('self_review_complete', { + featureId, + featureName: feature.title, + issuesFound: 0, + fixesApplied: false, + skipped: true, + reason: 'failed_to_get_diff', + projectPath, + }); + return; + } + + if (!diffResult.hasChanges || !diffResult.diff) { + logger.info(`No changes detected for self-review of feature ${featureId}, skipping`); + this.emitAutoModeEvent('self_review_complete', { + featureId, + featureName: feature.title, + issuesFound: 0, + fixesApplied: false, + skipped: true, + reason: 'no_changes', + projectPath, + }); + return; + } + + // Truncate diff to ~50k chars to avoid token limits + const MAX_DIFF_LENGTH = 50000; + const truncatedDiff = + diffResult.diff.length > MAX_DIFF_LENGTH + ? diffResult.diff.substring(0, MAX_DIFF_LENGTH) + '\n\n... (diff truncated)' + : diffResult.diff; + + // Get customized prompts + const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + const reviewTemplate = prompts.autoMode.selfReviewPromptTemplate; + + // Build review prompt from template + const reviewPrompt = reviewTemplate + .replace('{{title}}', feature.title || 'Untitled Feature') + .replace('{{description}}', feature.description || 'No description') + .replace('{{diff}}', truncatedDiff); + + // Get the model for the review (use feature model) + const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); + + // Run a lightweight, read-only review + let reviewResponse: string; + try { + const result = await simpleQuery({ + prompt: reviewPrompt, + model, + cwd: workDir, + maxTurns: 1, + readOnly: true, + abortController, + }); + reviewResponse = result.text; + } catch (err) { + logger.warn(`Self-review query failed for feature ${featureId}:`, err); + this.emitAutoModeEvent('self_review_complete', { + featureId, + featureName: feature.title, + issuesFound: 0, + fixesApplied: false, + skipped: true, + reason: 'review_query_failed', + projectPath, + }); + return; + } + + // Check if issues were found + const noIssues = reviewResponse.includes('NO_ISSUES_FOUND'); + let fixesApplied = false; + + if (!noIssues) { + logger.info(`Self-review found issues for feature ${featureId}, running fix pass`); + + // Build fix prompt and run an agentic fix pass + const fixPrompt = `## Fix Issues Found During Self-Review + +The following issues were found in your implementation of "${feature.title || 'Untitled Feature'}": + +${reviewResponse} + +Please fix these issues. Only fix the specific problems listed above — do not make other changes.`; + + try { + await this.runAgent( + workDir, + featureId, + fixPrompt, + abortController, + projectPath, + undefined, + model, + { + projectPath, + planningMode: 'skip' as PlanningMode, + requirePlanApproval: false, + autoLoadClaudeMd, + thinkingLevel: feature.thinkingLevel, + branchName: feature.branchName ?? null, + } + ); + fixesApplied = true; + + this.emitAutoModeEvent('self_review_fix_applied', { + featureId, + featureName: feature.title, + projectPath, + }); + } catch (err) { + logger.warn(`Self-review fix pass failed for feature ${featureId}:`, err); + } + } else { + logger.info(`Self-review found no issues for feature ${featureId}`); + } + + // Append review results to agent-output.md + try { + const featureDir = getFeatureDir(projectPath, featureId); + const outputPath = path.join(featureDir, 'agent-output.md'); + let existingOutput = ''; + try { + existingOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string; + } catch { + // File may not exist yet + } + + const reviewSection = `\n\n---\n## Self-Review Results\n\n${ + noIssues + ? 'No issues found during self-review.' + : `### Issues Found\n${reviewResponse}\n\n${fixesApplied ? '**Fixes were applied automatically.**' : '**Fix attempt failed — issues may still exist.**'}` + }\n`; + + await secureFs.writeFile(outputPath, existingOutput + reviewSection, 'utf-8'); + } catch (err) { + logger.warn(`Failed to append self-review results to agent-output.md:`, err); + } + + this.emitAutoModeEvent('self_review_complete', { + featureId, + featureName: feature.title, + issuesFound: noIssues ? 0 : 1, + fixesApplied, + projectPath, + }); + + logger.info( + `Self-review complete for feature ${featureId}: ${noIssues ? 'no issues' : `issues found, fixes ${fixesApplied ? 'applied' : 'failed'}`}` + ); + } + /** * Build the prompt for a pipeline step */ @@ -1902,6 +2096,19 @@ Complete the pipeline step instructions above. Review the previous work and appl autoLoadClaudeMd ); + // Self-review pass after pipeline resume + const globalSettingsResume = await this.settingsService?.getGlobalSettings(); + if (globalSettingsResume?.enableSelfReview !== false) { + await this.executeSelfReview( + projectPath, + featureId, + feature, + workDir, + abortController, + autoLoadClaudeMd + ); + } + // Determine final status const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); @@ -2201,6 +2408,19 @@ Address the follow-up instructions above. Review the previous work and make the } ); + // Self-review pass after follow-up + const globalSettingsFollowUp = await this.settingsService?.getGlobalSettings(); + if (globalSettingsFollowUp?.enableSelfReview !== false && feature) { + await this.executeSelfReview( + projectPath, + featureId, + feature, + workDir, + abortController, + autoLoadClaudeMd + ); + } + // Determine final status based on testing mode: // - skipTests=false (automated testing): go directly to 'verified' (no manual verify needed) // - skipTests=true (manual verification): go to 'waiting_approval' for manual review diff --git a/libs/prompts/src/defaults.ts b/libs/prompts/src/defaults.ts index 550f635da..822ba60f2 100644 --- a/libs/prompts/src/defaults.ts +++ b/libs/prompts/src/defaults.ts @@ -247,6 +247,31 @@ export const DEFAULT_AUTO_MODE_CONTINUATION_PROMPT_TEMPLATE = `## Continuing Fea Review the previous work and continue the implementation. `; +export const DEFAULT_AUTO_MODE_SELF_REVIEW_PROMPT_TEMPLATE = `## Self-Review: Check Your Implementation + +### Feature +**Title:** {{title}} +**Description:** {{description}} + +### Your Changes (Git Diff) +\`\`\`diff +{{diff}} +\`\`\` + +### Instructions +Review the diff above for issues. Look for: +- Syntax errors, broken regexes, unterminated strings +- Missing imports or unused imports +- Logic errors or off-by-one mistakes +- Unintended side effects or broken existing functionality +- Hardcoded values that should be configurable +- Security issues (injection, XSS, secrets in code) + +If you find issues that need fixing, list each one clearly with what file and what to change. +If the implementation looks correct, respond with "NO_ISSUES_FOUND". + +Be concise. Only flag real problems, not style preferences.`; + export const DEFAULT_AUTO_MODE_PIPELINE_STEP_PROMPT_TEMPLATE = `## Pipeline Step: {{stepName}} ### Feature Context @@ -271,6 +296,7 @@ export const DEFAULT_AUTO_MODE_PROMPTS: ResolvedAutoModePrompts = { followUpPromptTemplate: DEFAULT_AUTO_MODE_FOLLOW_UP_PROMPT_TEMPLATE, continuationPromptTemplate: DEFAULT_AUTO_MODE_CONTINUATION_PROMPT_TEMPLATE, pipelineStepPromptTemplate: DEFAULT_AUTO_MODE_PIPELINE_STEP_PROMPT_TEMPLATE, + selfReviewPromptTemplate: DEFAULT_AUTO_MODE_SELF_REVIEW_PROMPT_TEMPLATE, }; /** diff --git a/libs/prompts/src/merge.ts b/libs/prompts/src/merge.ts index 41cc5db79..012fc826a 100644 --- a/libs/prompts/src/merge.ts +++ b/libs/prompts/src/merge.ts @@ -88,6 +88,10 @@ export function mergeAutoModePrompts(custom?: AutoModePrompts): ResolvedAutoMode custom?.pipelineStepPromptTemplate, DEFAULT_AUTO_MODE_PROMPTS.pipelineStepPromptTemplate ), + selfReviewPromptTemplate: resolvePrompt( + custom?.selfReviewPromptTemplate, + DEFAULT_AUTO_MODE_PROMPTS.selfReviewPromptTemplate + ), }; } diff --git a/libs/types/src/prompts.ts b/libs/types/src/prompts.ts index a3c582dc8..a3e7e6dc6 100644 --- a/libs/types/src/prompts.ts +++ b/libs/types/src/prompts.ts @@ -47,6 +47,9 @@ export interface AutoModePrompts { /** Template for pipeline step execution prompts */ pipelineStepPromptTemplate?: CustomPrompt; + + /** Template for self-review prompt after feature implementation */ + selfReviewPromptTemplate?: CustomPrompt; } /** @@ -298,6 +301,7 @@ export interface ResolvedAutoModePrompts { followUpPromptTemplate: string; continuationPromptTemplate: string; pipelineStepPromptTemplate: string; + selfReviewPromptTemplate: string; } export interface ResolvedAgentPrompts { diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index dcd86483f..4c6e26c07 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -850,6 +850,8 @@ export interface GlobalSettings { autoModeMaxRetries?: number; /** Enable model escalation on retry (e.g., sonnet → opus) */ autoModeRetryModelEscalation?: boolean; + /** Enable self-review step after feature implementation (default: true) */ + enableSelfReview?: boolean; /** Default: use git worktrees for feature branches */ useWorktrees: boolean; /** Default: planning approach (skip/lite/spec/full) */ @@ -1281,6 +1283,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { autoMergeOnVerify: true, autoModeMaxRetries: 1, autoModeRetryModelEscalation: true, + enableSelfReview: true, useWorktrees: true, defaultPlanningMode: 'skip', defaultRequirePlanApproval: false, From c3371540f9f578d7e5ed03fffea700105250042b Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sun, 8 Feb 2026 00:55:50 +0700 Subject: [PATCH 30/30] feat: Add per-feature token usage and duration tracking Track token consumption and execution time from Claude Agent SDK result messages across all agent call sites (implementation, pipeline steps, self-review, follow-ups). Usage data is persisted to feature.json and displayed on kanban cards and in the summary dialog breakdown. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/services/auto-mode-service.ts | 204 +++++++++++++++--- .../kanban-card/agent-info-panel.tsx | 25 ++- .../components/kanban-card/summary-dialog.tsx | 44 ++++ libs/types/src/feature.ts | 21 ++ libs/types/src/index.ts | 2 + libs/types/src/provider.ts | 10 + 6 files changed, 281 insertions(+), 25 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 7b6cd352a..ee69e3510 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -20,6 +20,8 @@ import type { PipelineConfig, ThinkingLevel, PlanningMode, + TokenUsage, + UsageEntry, } from '@automaker/types'; import { DEFAULT_PHASE_MODELS, @@ -38,6 +40,7 @@ import { readJsonWithRecovery, logRecoveryWarning, DEFAULT_BACKUP_COUNT, + extractSummary, } from '@automaker/utils'; const logger = createLogger('AutoMode'); @@ -47,7 +50,12 @@ import { DEFAULT_MODELS, getEscalatedModel, } from '@automaker/model-resolver'; -import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; +import { + resolveDependencies, + areDependenciesSatisfied, + getAncestors, + formatAncestorContextForPrompt, +} from '@automaker/dependency-resolver'; import { mergeWorktreeBranch, cleanupWorktree, getGitRepositoryDiffs } from '@automaker/git-utils'; import { getFeatureDir, @@ -1252,7 +1260,12 @@ export class AutoModeService { logger.info(`Using continuation prompt for feature ${featureId}`); } else { // Normal flow: build prompt with planning phase - const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution); + const ancestorContext = await this.buildAncestorContext(projectPath, feature); + const featurePrompt = this.buildFeaturePrompt( + feature, + prompts.taskExecution, + ancestorContext + ); const planningPrefix = await this.getPlanningPromptPrefix(feature); prompt = planningPrefix + featurePrompt; @@ -1284,7 +1297,7 @@ export class AutoModeService { // Run the agent with the feature's model and images // Context files are passed as system prompt for higher priority - await this.runAgent( + const mainEntry = await this.runAgent( workDir, featureId, prompt, @@ -1302,6 +1315,8 @@ export class AutoModeService { branchName: feature.branchName ?? null, } ); + if (mainEntry) mainEntry.label = 'Implementation'; + let featureUsage: TokenUsage | undefined = this.accumulateUsage(undefined, mainEntry); // Check for pipeline steps and execute them const pipelineConfig = await pipelineService.getPipelineConfig(projectPath); @@ -1309,7 +1324,7 @@ export class AutoModeService { if (sortedSteps.length > 0) { // Execute pipeline steps sequentially - await this.executePipelineSteps( + const pipelineUsage = await this.executePipelineSteps( projectPath, featureId, feature, @@ -1318,12 +1333,17 @@ export class AutoModeService { abortController, autoLoadClaudeMd ); + if (pipelineUsage) { + for (const entry of pipelineUsage.entries) { + featureUsage = this.accumulateUsage(featureUsage, entry); + } + } } // Self-review pass: review the diff for issues before setting final status const globalSettings = await this.settingsService?.getGlobalSettings(); if (globalSettings?.enableSelfReview !== false) { - await this.executeSelfReview( + const reviewEntry = await this.executeSelfReview( projectPath, featureId, feature, @@ -1331,6 +1351,7 @@ export class AutoModeService { abortController, autoLoadClaudeMd ); + featureUsage = this.accumulateUsage(featureUsage, reviewEntry); } // Determine final status based on testing mode: @@ -1367,6 +1388,14 @@ export class AutoModeService { // Agent output might not exist yet } + // Extract summary from agent output and save to feature for dependency context + if (agentOutput) { + const summary = extractSummary(agentOutput); + if (summary) { + await this.featureLoader.update(projectPath, featureId, { summary }); + } + } + // Record memory usage if we loaded any memory files if (contextResult.memoryFiles.length > 0 && agentOutput) { await recordMemoryUsage( @@ -1384,6 +1413,29 @@ export class AutoModeService { console.warn('[AutoMode] Failed to record learnings:', learningError); } + // Persist accumulated token usage to feature + if (featureUsage) { + try { + const currentFeature = await this.loadFeature(projectPath, featureId); + if (currentFeature?.tokenUsage) { + featureUsage = { + inputTokens: currentFeature.tokenUsage.inputTokens + featureUsage.inputTokens, + outputTokens: currentFeature.tokenUsage.outputTokens + featureUsage.outputTokens, + cacheReadTokens: + currentFeature.tokenUsage.cacheReadTokens + featureUsage.cacheReadTokens, + cacheWriteTokens: + currentFeature.tokenUsage.cacheWriteTokens + featureUsage.cacheWriteTokens, + durationMs: currentFeature.tokenUsage.durationMs + featureUsage.durationMs, + numTurns: currentFeature.tokenUsage.numTurns + featureUsage.numTurns, + entries: [...currentFeature.tokenUsage.entries, ...featureUsage.entries], + }; + } + await this.featureLoader.update(projectPath, featureId, { tokenUsage: featureUsage }); + } catch (usageError) { + logger.warn(`Failed to persist token usage for feature ${featureId}:`, usageError); + } + } + this.emitAutoModeEvent('auto_mode_feature_complete', { featureId, featureName: feature.title, @@ -1395,6 +1447,7 @@ export class AutoModeService { projectPath, model: tempRunningFeature.model, provider: tempRunningFeature.provider, + tokenUsage: featureUsage, }); } catch (error) { const errorInfo = classifyError(error); @@ -1475,7 +1528,8 @@ export class AutoModeService { workDir: string, abortController: AbortController, autoLoadClaudeMd: boolean - ): Promise { + ): Promise { + let pipelineUsage: TokenUsage | undefined; logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`); // Get customized prompts from settings @@ -1537,7 +1591,7 @@ export class AutoModeService { const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); // Run the agent for this pipeline step - await this.runAgent( + const stepEntry = await this.runAgent( workDir, featureId, prompt, @@ -1555,6 +1609,8 @@ export class AutoModeService { thinkingLevel: feature.thinkingLevel, } ); + if (stepEntry) stepEntry.label = `Pipeline: ${step.name}`; + pipelineUsage = this.accumulateUsage(pipelineUsage, stepEntry); // Load updated context for next step try { @@ -1578,6 +1634,7 @@ export class AutoModeService { } logger.info(`All pipeline steps completed for feature ${featureId}`); + return pipelineUsage; } /** @@ -1591,7 +1648,7 @@ export class AutoModeService { workDir: string, abortController: AbortController, autoLoadClaudeMd: boolean - ): Promise { + ): Promise { logger.info(`Starting self-review for feature ${featureId}`); this.emitAutoModeEvent('self_review_started', { @@ -1649,8 +1706,9 @@ export class AutoModeService { .replace('{{description}}', feature.description || 'No description') .replace('{{diff}}', truncatedDiff); - // Get the model for the review (use feature model) - const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); + // Prefer sonnet for review (fast, cheap), fall back to feature model + const featureModel = resolveModelString(feature.model, DEFAULT_MODELS.claude); + const model = resolveModelString('claude-sonnet', featureModel); // Run a lightweight, read-only review let reviewResponse: string; @@ -1681,6 +1739,7 @@ export class AutoModeService { // Check if issues were found const noIssues = reviewResponse.includes('NO_ISSUES_FOUND'); let fixesApplied = false; + let reviewFixEntry: UsageEntry | undefined; if (!noIssues) { logger.info(`Self-review found issues for feature ${featureId}, running fix pass`); @@ -1695,7 +1754,7 @@ ${reviewResponse} Please fix these issues. Only fix the specific problems listed above — do not make other changes.`; try { - await this.runAgent( + reviewFixEntry = await this.runAgent( workDir, featureId, fixPrompt, @@ -1712,6 +1771,7 @@ Please fix these issues. Only fix the specific problems listed above — do not branchName: feature.branchName ?? null, } ); + if (reviewFixEntry) reviewFixEntry.label = 'Self-review fix'; fixesApplied = true; this.emitAutoModeEvent('self_review_fix_applied', { @@ -1759,6 +1819,7 @@ Please fix these issues. Only fix the specific problems listed above — do not logger.info( `Self-review complete for feature ${featureId}: ${noIssues ? 'no issues' : `issues found, fixes ${fixesApplied ? 'applied' : 'failed'}`}` ); + return reviewFixEntry; } /** @@ -2390,7 +2451,7 @@ Address the follow-up instructions above. Review the previous work and make the // Note: Follow-ups skip planning mode - they continue from previous work // Pass previousContext so the history is preserved in the output file // Context files are passed as system prompt for higher priority - await this.runAgent( + const followUpEntry = await this.runAgent( workDir, featureId, fullPrompt, @@ -2407,11 +2468,19 @@ Address the follow-up instructions above. Review the previous work and make the thinkingLevel: feature?.thinkingLevel, } ); + if (followUpEntry) followUpEntry.label = 'Follow-up'; + let followUpUsage: TokenUsage | undefined; + + // Accumulate follow-up usage with existing feature usage + { + const currentFeature = await this.loadFeature(projectPath, featureId); + followUpUsage = this.accumulateUsage(currentFeature?.tokenUsage, followUpEntry); + } // Self-review pass after follow-up const globalSettingsFollowUp = await this.settingsService?.getGlobalSettings(); if (globalSettingsFollowUp?.enableSelfReview !== false && feature) { - await this.executeSelfReview( + const reviewEntry = await this.executeSelfReview( projectPath, featureId, feature, @@ -2419,6 +2488,16 @@ Address the follow-up instructions above. Review the previous work and make the abortController, autoLoadClaudeMd ); + followUpUsage = this.accumulateUsage(followUpUsage, reviewEntry); + } + + // Persist accumulated token usage + if (followUpUsage) { + try { + await this.featureLoader.update(projectPath, featureId, { tokenUsage: followUpUsage }); + } catch (usageError) { + logger.warn(`Failed to persist token usage for follow-up ${featureId}:`, usageError); + } } // Determine final status based on testing mode: @@ -2451,6 +2530,7 @@ Address the follow-up instructions above. Review the previous work and make the projectPath, model, provider, + tokenUsage: followUpUsage, }); } catch (error) { const errorInfo = classifyError(error); @@ -3934,12 +4014,28 @@ Format your response as a structured markdown document.`; return planningPrompt + '\n\n---\n\n## Feature Request\n\n'; } + private async buildAncestorContext(projectPath: string, feature: Feature): Promise { + if (!feature.dependencies || feature.dependencies.length === 0) { + return ''; + } + + const allFeatures = await this.featureLoader.getAll(projectPath); + const ancestors = getAncestors(feature, allFeatures, 2); + if (ancestors.length === 0) { + return ''; + } + + const selectedIds = new Set(ancestors.map((a) => a.id)); + return formatAncestorContextForPrompt(ancestors, selectedIds); + } + private buildFeaturePrompt( feature: Feature, taskExecutionPrompts: { implementationInstructions: string; playwrightVerificationInstructions: string; - } + }, + ancestorContext?: string ): string { const title = this.extractTitleFromDescription(feature.description); @@ -3957,6 +4053,10 @@ ${feature.spec} `; } + if (ancestorContext) { + prompt += `\n${ancestorContext}\n`; + } + // Add images note (like old implementation) if (feature.imagePaths && feature.imagePaths.length > 0) { const imagesList = feature.imagePaths @@ -3991,6 +4091,33 @@ You can use the Read tool to view these images at any time during implementation return prompt; } + private accumulateUsage( + existing: TokenUsage | undefined, + entry: UsageEntry | undefined + ): TokenUsage | undefined { + if (!entry) return existing; + if (!existing) { + return { + inputTokens: entry.inputTokens, + outputTokens: entry.outputTokens, + cacheReadTokens: entry.cacheReadTokens, + cacheWriteTokens: entry.cacheWriteTokens, + durationMs: entry.durationMs, + numTurns: entry.numTurns, + entries: [entry], + }; + } + return { + inputTokens: existing.inputTokens + entry.inputTokens, + outputTokens: existing.outputTokens + entry.outputTokens, + cacheReadTokens: existing.cacheReadTokens + entry.cacheReadTokens, + cacheWriteTokens: existing.cacheWriteTokens + entry.cacheWriteTokens, + durationMs: existing.durationMs + entry.durationMs, + numTurns: existing.numTurns + entry.numTurns, + entries: [...existing.entries, entry], + }; + } + private async runAgent( workDir: string, featureId: string, @@ -4009,7 +4136,8 @@ You can use the Read tool to view these images at any time during implementation thinkingLevel?: ThinkingLevel; branchName?: string | null; } - ): Promise { + ): Promise { + let usageEntry: UsageEntry | undefined; const finalProjectPath = options?.projectPath || projectPath; const branchName = options?.branchName ?? null; const planningMode = options?.planningMode || 'skip'; @@ -4769,8 +4897,21 @@ After generating the revised spec, output: } } else if (msg.type === 'error') { throw new Error(msg.error || 'Unknown error during implementation'); - } else if (msg.type === 'result' && msg.subtype === 'success') { - responseText += msg.result || ''; + } else if (msg.type === 'result') { + if (msg.subtype === 'success') { + responseText += msg.result || ''; + } + if (msg.usage) { + usageEntry = { + label: '', + inputTokens: msg.usage.input_tokens ?? 0, + outputTokens: msg.usage.output_tokens ?? 0, + cacheReadTokens: msg.usage.cache_read_input_tokens ?? 0, + cacheWriteTokens: msg.usage.cache_creation_input_tokens ?? 0, + durationMs: msg.duration_ms ?? 0, + numTurns: msg.num_turns ?? 0, + }; + } } } } @@ -4814,11 +4955,24 @@ After generating the revised spec, output: } else if (msg.type === 'error') { // Handle error messages throw new Error(msg.error || 'Unknown error'); - } else if (msg.type === 'result' && msg.subtype === 'success') { - // Don't replace responseText - the accumulated content is the full history - // The msg.result is just a summary which would lose all tool use details - // Just ensure final write happens - scheduleWrite(); + } else if (msg.type === 'result') { + if (msg.subtype === 'success') { + // Don't replace responseText - the accumulated content is the full history + // The msg.result is just a summary which would lose all tool use details + // Just ensure final write happens + scheduleWrite(); + } + if (msg.usage) { + usageEntry = { + label: '', + inputTokens: msg.usage.input_tokens ?? 0, + outputTokens: msg.usage.output_tokens ?? 0, + cacheReadTokens: msg.usage.cache_read_input_tokens ?? 0, + cacheWriteTokens: msg.usage.cache_creation_input_tokens ?? 0, + durationMs: msg.duration_ms ?? 0, + numTurns: msg.num_turns ?? 0, + }; + } } } @@ -4847,6 +5001,7 @@ After generating the revised spec, output: rawWriteTimeout = null; } } + return usageEntry; } private async executeFeatureWithContext( @@ -4863,8 +5018,9 @@ After generating the revised spec, output: // Get customized prompts from settings const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); - // Build the feature prompt - const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution); + // Build the feature prompt with ancestor context + const ancestorContext = await this.buildAncestorContext(projectPath, feature); + const featurePrompt = this.buildFeaturePrompt(feature, prompts.taskExecution, ancestorContext); // Use the resume feature template with variable substitution let prompt = prompts.taskExecution.resumeFeatureTemplate; diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index 9cd9d7931..c86550b8f 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -10,7 +10,7 @@ import { } from '@/lib/agent-context-parser'; import { cn } from '@/lib/utils'; import type { AutoModeEvent } from '@/types/electron'; -import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench } from 'lucide-react'; +import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench, Zap } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { SummaryDialog } from './summary-dialog'; @@ -48,6 +48,15 @@ function formatReasoningEffort(effort: ReasoningEffort | undefined): string { return labels[effort]; } +/** + * Formats a token count for compact display + */ +function formatTokenCount(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return String(count); +} + interface AgentInfoPanelProps { feature: Feature; projectPath: string; @@ -273,6 +282,20 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({ )}
+ {/* Token Usage */} + {feature.tokenUsage && ( +
+ + + {formatTokenCount(feature.tokenUsage.inputTokens + feature.tokenUsage.outputTokens)}{' '} + tokens + +
+ )} + {/* Task List Progress */} {effectiveTodos.length > 0 && (
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx index 11e986634..b9b3cfcf0 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx @@ -13,6 +13,20 @@ import { Button } from '@/components/ui/button'; import { Markdown } from '@/components/ui/markdown'; import { Sparkles } from 'lucide-react'; +function formatTokenCount(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return String(count); +} + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} + interface SummaryDialogProps { feature: Feature; agentInfo: AgentTaskInfo | null; @@ -57,6 +71,36 @@ export function SummaryDialog({ {feature.summary || summary || agentInfo?.summary || 'No summary available'}
+ {feature.tokenUsage && feature.tokenUsage.entries.length > 0 && ( +
+

Token Usage

+
+ {feature.tokenUsage.entries.map((entry, idx) => ( +
+ {entry.label} +
+ {formatTokenCount(entry.inputTokens + entry.outputTokens)} tokens + {formatDuration(entry.durationMs)} +
+
+ ))} + {feature.tokenUsage.entries.length > 1 && ( +
+ Total +
+ + {formatTokenCount( + feature.tokenUsage.inputTokens + feature.tokenUsage.outputTokens + )}{' '} + tokens + + {formatDuration(feature.tokenUsage.durationMs)} +
+
+ )} +
+
+ )}