Add quick-add feature with improved workflows (#802)

* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.
This commit is contained in:
gsxdsm
2026-02-22 20:48:09 -08:00
committed by GitHub
parent 9305ecc242
commit e7504b247f
70 changed files with 3141 additions and 560 deletions

View File

@@ -282,11 +282,15 @@ function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
}
/**
* Build system prompt configuration based on autoLoadClaudeMd setting.
* When autoLoadClaudeMd is true:
* - Uses preset mode with 'claude_code' to enable CLAUDE.md auto-loading
* - If there's a custom systemPrompt, appends it to the preset
* - Sets settingSources to ['project'] for SDK to load CLAUDE.md files
* Build system prompt and settingSources based on two independent settings:
* - useClaudeCodeSystemPrompt: controls whether to use the 'claude_code' preset as the base prompt
* - autoLoadClaudeMd: controls whether to add settingSources for SDK to load CLAUDE.md files
*
* These combine independently (4 possible states):
* 1. Both ON: preset + settingSources (full Claude Code experience)
* 2. useClaudeCodeSystemPrompt ON, autoLoadClaudeMd OFF: preset only (no CLAUDE.md auto-loading)
* 3. useClaudeCodeSystemPrompt OFF, autoLoadClaudeMd ON: plain string + settingSources
* 4. Both OFF: plain string only
*
* @param config - The SDK options config
* @returns Object with systemPrompt and settingSources for SDK options
@@ -295,27 +299,34 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
systemPrompt?: string | SystemPromptConfig;
settingSources?: Array<'user' | 'project' | 'local'>;
} {
if (!config.autoLoadClaudeMd) {
// Standard mode - just pass through the system prompt as-is
return config.systemPrompt ? { systemPrompt: config.systemPrompt } : {};
}
// Auto-load CLAUDE.md mode - use preset with settingSources
const result: {
systemPrompt: SystemPromptConfig;
settingSources: Array<'user' | 'project' | 'local'>;
} = {
systemPrompt: {
systemPrompt?: string | SystemPromptConfig;
settingSources?: Array<'user' | 'project' | 'local'>;
} = {};
// Determine system prompt format based on useClaudeCodeSystemPrompt
if (config.useClaudeCodeSystemPrompt) {
// Use Claude Code's built-in system prompt as the base
const presetConfig: SystemPromptConfig = {
type: 'preset',
preset: 'claude_code',
},
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
settingSources: ['user', 'project'],
};
};
// If there's a custom system prompt, append it to the preset
if (config.systemPrompt) {
presetConfig.append = config.systemPrompt;
}
result.systemPrompt = presetConfig;
} else {
// Standard mode - just pass through the system prompt as-is
if (config.systemPrompt) {
result.systemPrompt = config.systemPrompt;
}
}
// If there's a custom system prompt, append it to the preset
if (config.systemPrompt) {
result.systemPrompt.append = config.systemPrompt;
// Determine settingSources based on autoLoadClaudeMd
if (config.autoLoadClaudeMd) {
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
result.settingSources = ['user', 'project'];
}
return result;
@@ -323,12 +334,14 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
/**
* System prompt configuration for SDK options
* When using preset mode with claude_code, CLAUDE.md files are automatically loaded
* The 'claude_code' preset provides the system prompt only — it does NOT auto-load
* CLAUDE.md files. CLAUDE.md auto-loading is controlled independently by
* settingSources (set via autoLoadClaudeMd). These two settings are orthogonal.
*/
export interface SystemPromptConfig {
/** Use preset mode with claude_code to enable CLAUDE.md auto-loading */
/** Use preset mode to select the base system prompt */
type: 'preset';
/** The preset to use - 'claude_code' enables CLAUDE.md loading */
/** The preset to use - 'claude_code' uses the Claude Code system prompt */
preset: 'claude_code';
/** Optional additional prompt to append to the preset */
append?: string;
@@ -362,6 +375,9 @@ export interface CreateSdkOptionsConfig {
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
autoLoadClaudeMd?: boolean;
/** Use Claude Code's built-in system prompt (claude_code preset) as the base prompt */
useClaudeCodeSystemPrompt?: boolean;
/** MCP servers to make available to the agent */
mcpServers?: Record<string, McpServerConfig>;

View File

@@ -80,6 +80,49 @@ export async function getAutoLoadClaudeMdSetting(
}
}
/**
* Get the useClaudeCodeSystemPrompt setting, with project settings taking precedence over global.
* Falls back to global settings and defaults to true when unset.
* Returns true if settings service is not available.
*
* @param projectPath - Path to the project
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to the useClaudeCodeSystemPrompt setting value
*/
export async function getUseClaudeCodeSystemPromptSetting(
projectPath: string,
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
logger.info(
`${logPrefix} SettingsService not available, useClaudeCodeSystemPrompt defaulting to true`
);
return true;
}
try {
// Check project settings first (takes precedence)
const projectSettings = await settingsService.getProjectSettings(projectPath);
if (projectSettings.useClaudeCodeSystemPrompt !== undefined) {
logger.info(
`${logPrefix} useClaudeCodeSystemPrompt from project settings: ${projectSettings.useClaudeCodeSystemPrompt}`
);
return projectSettings.useClaudeCodeSystemPrompt;
}
// Fall back to global settings
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.useClaudeCodeSystemPrompt ?? true;
logger.info(`${logPrefix} useClaudeCodeSystemPrompt from global settings: ${result}`);
return result;
} catch (error) {
logger.error(`${logPrefix} Failed to load useClaudeCodeSystemPrompt setting:`, error);
throw error;
}
}
/**
* Get the default max turns setting from global settings.
*

View File

@@ -33,8 +33,23 @@ const logger = createLogger('ClaudeProvider');
*/
type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
// System vars are always passed from process.env regardless of profile
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
// System vars are always passed from process.env regardless of profile.
// Includes filesystem, locale, and temp directory vars that the Claude CLI
// needs internally for config resolution and temp file creation.
const SYSTEM_ENV_VARS = [
'PATH',
'HOME',
'SHELL',
'TERM',
'USER',
'LANG',
'LC_ALL',
'TMPDIR',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'XDG_CACHE_HOME',
'XDG_STATE_HOME',
];
/**
* Check if the config is a ClaudeCompatibleProvider (new system)
@@ -213,6 +228,8 @@ export class ClaudeProvider extends BaseProvider {
env: buildEnv(providerConfig, credentials),
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
...(allowedTools && { allowedTools }),
// Restrict available built-in tools if specified (tools: [] disables all tools)
...(options.tools && { tools: options.tools }),
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,

View File

@@ -114,9 +114,20 @@ export function mapBacklogPlanError(rawMessage: string): string {
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
}
// Claude Code process crash
// Claude Code process crash - extract exit code for diagnostics
if (rawMessage.includes('Claude Code process exited')) {
return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.';
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
const exitCode = exitCodeMatch ? exitCodeMatch[1] : 'unknown';
logger.error(`[BacklogPlan] Claude process exit code: ${exitCode}`);
return `Claude exited unexpectedly (exit code: ${exitCode}). This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`;
}
// Claude Code process killed by signal
if (rawMessage.includes('Claude Code process terminated by signal')) {
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
const signal = signalMatch ? signalMatch[1] : 'unknown';
logger.error(`[BacklogPlan] Claude process terminated by signal: ${signal}`);
return `Claude was terminated by signal ${signal}. This may indicate a resource issue. Try again.`;
}
// Rate limiting

View File

@@ -3,6 +3,9 @@
*
* Model is configurable via phaseModels.backlogPlanningModel in settings
* (defaults to Sonnet). Can be overridden per-call via model parameter.
*
* Includes automatic retry for transient CLI failures (e.g., "Claude Code
* process exited unexpectedly") to improve reliability.
*/
import type { EventEmitter } from '../../lib/events.js';
@@ -12,8 +15,10 @@ import {
isCursorModel,
stripProviderPrefix,
type ThinkingLevel,
type SystemPromptPreset,
} from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { getCurrentBranch } from '@automaker/git-utils';
import { FeatureLoader } from '../../services/feature-loader.js';
import { ProviderFactory } from '../../providers/provider-factory.js';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
@@ -27,10 +32,27 @@ import {
import type { SettingsService } from '../../services/settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
getPromptCustomization,
getPhaseModelWithOverrides,
} from '../../lib/settings-helpers.js';
/** Maximum number of retry attempts for transient CLI failures */
const MAX_RETRIES = 2;
/** Delay between retries in milliseconds */
const RETRY_DELAY_MS = 2000;
/**
* Check if an error is retryable (transient CLI process failure)
*/
function isRetryableError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('Claude Code process exited') ||
message.includes('Claude Code process terminated by signal')
);
}
const featureLoader = new FeatureLoader();
/**
@@ -84,6 +106,53 @@ function parsePlanResponse(response: string): BacklogPlanResult {
};
}
/**
* Try to parse a valid plan response without fallback behavior.
* Returns null if parsing fails.
*/
function tryParsePlanResponse(response: string): BacklogPlanResult | null {
if (!response || response.trim().length === 0) {
return null;
}
return extractJsonWithArray<BacklogPlanResult>(response, 'changes', { logger });
}
/**
* Choose the most reliable response text between streamed assistant chunks
* and provider final result payload.
*/
function selectBestResponseText(accumulatedText: string, providerResultText: string): string {
const hasAccumulated = accumulatedText.trim().length > 0;
const hasProviderResult = providerResultText.trim().length > 0;
if (!hasProviderResult) {
return accumulatedText;
}
if (!hasAccumulated) {
return providerResultText;
}
const accumulatedParsed = tryParsePlanResponse(accumulatedText);
const providerParsed = tryParsePlanResponse(providerResultText);
if (providerParsed && !accumulatedParsed) {
logger.info('[BacklogPlan] Using provider result (parseable JSON)');
return providerResultText;
}
if (accumulatedParsed && !providerParsed) {
logger.info('[BacklogPlan] Keeping accumulated text (parseable JSON)');
return accumulatedText;
}
if (providerResultText.length > accumulatedText.length) {
logger.info('[BacklogPlan] Using provider result (longer content)');
return providerResultText;
}
logger.info('[BacklogPlan] Keeping accumulated text (longer content)');
return accumulatedText;
}
/**
* Generate a backlog modification plan based on user prompt
*/
@@ -93,11 +162,40 @@ export async function generateBacklogPlan(
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService,
model?: string
model?: string,
branchName?: string
): Promise<BacklogPlanResult> {
try {
// Load current features
const features = await featureLoader.getAll(projectPath);
const allFeatures = await featureLoader.getAll(projectPath);
// Filter features by branch if specified (worktree-scoped backlog)
let features: Feature[];
if (branchName) {
// Determine the primary branch so unassigned features show for the main worktree
let primaryBranch: string | null = null;
try {
primaryBranch = await getCurrentBranch(projectPath);
} catch {
// If git fails, fall back to 'main' so unassigned features are visible
// when branchName matches a common default branch name
primaryBranch = 'main';
}
const isMainBranch = branchName === primaryBranch;
features = allFeatures.filter((f) => {
if (!f.branchName) {
// Unassigned features belong to the main/primary worktree
return isMainBranch;
}
return f.branchName === branchName;
});
logger.info(
`[BacklogPlan] Filtered to ${features.length}/${allFeatures.length} features for branch: ${branchName}`
);
} else {
features = allFeatures;
}
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',
@@ -162,17 +260,23 @@ export async function generateBacklogPlan(
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(effectiveModel);
// Get autoLoadClaudeMd setting
// Get autoLoadClaudeMd and useClaudeCodeSystemPrompt settings
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
projectPath,
settingsService,
'[BacklogPlan]'
);
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
projectPath,
settingsService,
'[BacklogPlan]'
);
// For Cursor models, we need to combine prompts with explicit instructions
// because Cursor doesn't support systemPrompt separation like Claude SDK
let finalPrompt = userPrompt;
let finalSystemPrompt: string | undefined = systemPrompt;
let finalSystemPrompt: string | SystemPromptPreset | undefined = systemPrompt;
let finalSettingSources: Array<'user' | 'project' | 'local'> | undefined;
if (isCursorModel(effectiveModel)) {
logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions');
@@ -187,54 +291,141 @@ CRITICAL INSTRUCTIONS:
${userPrompt}`;
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
} else if (useClaudeCodeSystemPrompt) {
// Use claude_code preset for Claude models so the SDK subprocess
// authenticates via CLI OAuth or API key the same way all other SDK calls do.
finalSystemPrompt = {
type: 'preset',
preset: 'claude_code',
append: systemPrompt,
};
}
// Include settingSources when autoLoadClaudeMd is enabled
if (autoLoadClaudeMd) {
finalSettingSources = ['user', 'project'];
}
// Execute the query
const stream = provider.executeQuery({
// Execute the query with retry logic for transient CLI failures
const queryOptions = {
prompt: finalPrompt,
model: bareModel,
cwd: projectPath,
systemPrompt: finalSystemPrompt,
maxTurns: 1,
allowedTools: [], // No tools needed for this
tools: [] as string[], // Disable all built-in tools - plan generation only needs text output
abortController,
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
readOnly: true, // Plan generation only generates text, doesn't write files
settingSources: finalSettingSources,
thinkingLevel, // Pass thinking level for extended thinking
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
credentials, // Pass credentials for resolving 'credentials' apiKeySource
});
};
let responseText = '';
let bestResponseText = ''; // Preserve best response across all retry attempts
let recoveredResult: BacklogPlanResult | null = null;
let lastError: unknown = null;
for await (const msg of stream) {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (abortController.signal.aborted) {
throw new Error('Generation aborted');
}
if (msg.type === 'assistant') {
if (msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
if (attempt > 0) {
logger.info(
`[BacklogPlan] Retry attempt ${attempt}/${MAX_RETRIES} after transient failure`
);
events.emit('backlog-plan:event', {
type: 'backlog_plan_progress',
content: `Retrying... (attempt ${attempt + 1}/${MAX_RETRIES + 1})`,
});
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
}
let accumulatedText = '';
let providerResultText = '';
try {
const stream = provider.executeQuery(queryOptions);
for await (const msg of stream) {
if (abortController.signal.aborted) {
throw new Error('Generation aborted');
}
if (msg.type === 'assistant') {
if (msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
accumulatedText += block.text;
}
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
providerResultText = msg.result;
logger.info(
'[BacklogPlan] Received result from provider, length:',
providerResultText.length
);
logger.info('[BacklogPlan] Accumulated response length:', accumulatedText.length);
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if it's a final accumulated message (from Cursor provider)
logger.info('[BacklogPlan] Received result from Cursor, length:', msg.result.length);
logger.info('[BacklogPlan] Previous responseText length:', responseText.length);
if (msg.result.length > responseText.length) {
logger.info('[BacklogPlan] Using Cursor result (longer than accumulated text)');
responseText = msg.result;
} else {
logger.info('[BacklogPlan] Keeping accumulated text (longer than Cursor result)');
responseText = selectBestResponseText(accumulatedText, providerResultText);
// If we got here, the stream completed successfully
lastError = null;
break;
} catch (error) {
lastError = error;
const errorMessage = error instanceof Error ? error.message : String(error);
responseText = selectBestResponseText(accumulatedText, providerResultText);
// Preserve the best response text across all attempts so that if a retry
// crashes immediately (empty response), we can still recover from an earlier attempt
bestResponseText = selectBestResponseText(bestResponseText, responseText);
// Claude SDK can occasionally exit non-zero after emitting a complete response.
// If we already have valid JSON, recover instead of failing the entire planning flow.
if (isRetryableError(error)) {
const parsed = tryParsePlanResponse(bestResponseText);
if (parsed) {
logger.warn(
'[BacklogPlan] Recovered from transient CLI exit using accumulated valid response'
);
recoveredResult = parsed;
lastError = null;
break;
}
// On final retryable failure, degrade gracefully if we have text from any attempt.
if (attempt >= MAX_RETRIES && bestResponseText.trim().length > 0) {
logger.warn(
'[BacklogPlan] Final retryable CLI failure with non-empty response, attempting fallback parse'
);
recoveredResult = parsePlanResponse(bestResponseText);
lastError = null;
break;
}
}
// Only retry on transient CLI failures, not on user aborts or other errors
if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
throw error;
}
logger.warn(
`[BacklogPlan] Transient CLI failure (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${errorMessage}`
);
}
}
// If we exhausted retries, throw the last error
if (lastError) {
throw lastError;
}
// Parse the response
const result = parsePlanResponse(responseText);
const result = recoveredResult ?? parsePlanResponse(responseText);
await saveBacklogPlan(projectPath, {
savedAt: new Date().toISOString(),

View File

@@ -17,10 +17,11 @@ import type { SettingsService } from '../../../services/settings-service.js';
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, prompt, model } = req.body as {
const { projectPath, prompt, model, branchName } = req.body as {
projectPath: string;
prompt: string;
model?: string;
branchName?: string;
};
if (!projectPath) {
@@ -42,28 +43,30 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
return;
}
setRunningState(true);
const abortController = new AbortController();
setRunningState(true, abortController);
setRunningDetails({
projectPath,
prompt,
model,
startedAt: new Date().toISOString(),
});
const abortController = new AbortController();
setRunningState(true, abortController);
// Start generation in background
// Note: generateBacklogPlan handles its own error event emission,
// so we only log here to avoid duplicate error toasts
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
.catch((error) => {
// Just log - error event already emitted by generateBacklogPlan
logError(error, 'Generate backlog plan failed (background)');
})
.finally(() => {
setRunningState(false, null);
setRunningDetails(null);
});
// Note: generateBacklogPlan handles its own error event emission
// and state cleanup in its finally block, so we only log here
generateBacklogPlan(
projectPath,
prompt,
events,
abortController,
settingsService,
model,
branchName
).catch((error) => {
// Just log - error event already emitted by generateBacklogPlan
logError(error, 'Generate backlog plan failed (background)');
});
res.json({ success: true });
} catch (error) {

View File

@@ -142,11 +142,33 @@ function mapDescribeImageError(rawMessage: string | undefined): {
if (!rawMessage) return baseResponse;
if (rawMessage.includes('Claude Code process exited')) {
if (
rawMessage.includes('Claude Code process exited') ||
rawMessage.includes('Claude Code process terminated by signal')
) {
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
const detail = exitCodeMatch
? ` (exit code: ${exitCodeMatch[1]})`
: signalMatch
? ` (signal: ${signalMatch[1]})`
: '';
// Crash/OS-kill signals suggest a process crash, not an auth failure —
// omit auth recovery advice and suggest retry/reporting instead.
const crashSignals = ['SIGSEGV', 'SIGABRT', 'SIGKILL', 'SIGBUS', 'SIGTRAP'];
const isCrashSignal = signalMatch ? crashSignals.includes(signalMatch[1]) : false;
if (isCrashSignal) {
return {
statusCode: 503,
userMessage: `Claude crashed unexpectedly${detail} while describing the image. This may be a transient condition. Please try again. If the problem persists, collect logs and report the issue.`,
};
}
return {
statusCode: 503,
userMessage:
'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.',
userMessage: `Claude exited unexpectedly${detail} while describing the image. This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`,
};
}

View File

@@ -170,127 +170,125 @@ export function createGeneratePRDescriptionHandler(
// Determine the base branch for comparison
const base = baseBranch || 'main';
// Get the diff between current branch and base branch (committed changes)
// Track whether the diff method used only includes committed changes.
// `git diff base...HEAD` and `git diff origin/base...HEAD` only show committed changes,
// while the fallback methods (`git diff HEAD`, `git diff --cached + git diff`) already
// include uncommitted working directory changes.
let diff = '';
let diffIncludesUncommitted = false;
// Collect diffs in three layers and combine them:
// 1. Committed changes on the branch: `git diff base...HEAD`
// 2. Staged (cached) changes not yet committed: `git diff --cached`
// 3. Unstaged changes to tracked files: `git diff` (no --cached flag)
//
// Untracked files are intentionally excluded — they are typically build artifacts,
// planning files, hidden dotfiles, or other files unrelated to the PR.
// `git diff` and `git diff --cached` only show changes to files already tracked by git,
// which is exactly the correct scope.
//
// We combine all three sources and deduplicate by file path so that a file modified
// in commits AND with additional uncommitted changes is not double-counted.
/** Parse a unified diff into per-file hunks keyed by file path */
function parseDiffIntoFileHunks(diffText: string): Map<string, string> {
const fileHunks = new Map<string, string>();
if (!diffText.trim()) return fileHunks;
// Split on "diff --git" boundaries (keep the delimiter)
const sections = diffText.split(/(?=^diff --git )/m);
for (const section of sections) {
if (!section.trim()) continue;
// Use a back-reference pattern so the "b/" side must match the "a/" capture,
// correctly handling paths that contain " b/" in their name.
// Falls back to a two-capture pattern to handle renames (a/ and b/ differ).
const backrefMatch = section.match(/^diff --git a\/(.+) b\/\1$/m);
const renameMatch = !backrefMatch ? section.match(/^diff --git a\/(.+) b\/(.+)$/m) : null;
const match = backrefMatch || renameMatch;
if (match) {
// Prefer the backref capture (identical paths); for renames use the destination (match[2])
const filePath = backrefMatch ? match[1] : match[2];
// Merge hunks if the same file appears in multiple diff sources
const existing = fileHunks.get(filePath) ?? '';
fileHunks.set(filePath, existing + section);
}
}
return fileHunks;
}
// --- Step 1: committed changes (branch vs base) ---
let committedDiff = '';
try {
// First, try to get diff against the base branch
const { stdout: branchDiff } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
const { stdout } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
maxBuffer: 1024 * 1024 * 5,
});
diff = branchDiff;
// git diff base...HEAD only shows committed changes
diffIncludesUncommitted = false;
committedDiff = stdout;
} catch {
// If branch comparison fails (e.g., base branch doesn't exist locally),
// try fetching and comparing against remote base
// Base branch may not exist locally; try the remote tracking branch
try {
const { stdout: remoteDiff } = await execFileAsync(
'git',
['diff', `origin/${base}...HEAD`],
{
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
}
);
diff = remoteDiff;
// git diff origin/base...HEAD only shows committed changes
diffIncludesUncommitted = false;
const { stdout } = await execFileAsync('git', ['diff', `origin/${base}...HEAD`], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
committedDiff = stdout;
} catch {
// Fall back to getting all uncommitted + committed changes
try {
const { stdout: allDiff } = await execFileAsync('git', ['diff', 'HEAD'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
diff = allDiff;
// git diff HEAD includes uncommitted changes
diffIncludesUncommitted = true;
} catch {
// Last resort: get staged + unstaged changes
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
diff = stagedDiff + unstagedDiff;
// These already include uncommitted changes
diffIncludesUncommitted = true;
}
// Cannot compare against base — leave committedDiff empty; the uncommitted
// changes gathered below will still be included.
logger.warn(`Could not get committed diff against ${base} or origin/${base}`);
}
}
// Check for uncommitted changes (staged + unstaged) to include in the description.
// When creating a PR, uncommitted changes will be auto-committed, so they should be
// reflected in the generated description. We only need to fetch uncommitted diffs
// when the primary diff method (base...HEAD) was used, since it only shows committed changes.
let hasUncommittedChanges = false;
// --- Step 2: staged changes (tracked files only) ---
let stagedDiff = '';
try {
const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain'], {
const { stdout } = await execFileAsync('git', ['diff', '--cached'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
hasUncommittedChanges = statusOutput.trim().length > 0;
if (hasUncommittedChanges && !diffIncludesUncommitted) {
logger.info('Uncommitted changes detected, including in PR description context');
let uncommittedDiff = '';
// Get staged changes
try {
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
if (stagedDiff.trim()) {
uncommittedDiff += stagedDiff;
}
} catch {
// Ignore staged diff errors
}
// Get unstaged changes (tracked files only)
try {
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
if (unstagedDiff.trim()) {
uncommittedDiff += unstagedDiff;
}
} catch {
// Ignore unstaged diff errors
}
// Get list of untracked files for context
const untrackedFiles = statusOutput
.split('\n')
.filter((line) => line.startsWith('??'))
.map((line) => line.substring(3).trim());
if (untrackedFiles.length > 0) {
// Add a summary of untracked (new) files as context
uncommittedDiff += `\n# New untracked files:\n${untrackedFiles.map((f) => `# + ${f}`).join('\n')}\n`;
}
// Append uncommitted changes to the committed diff
if (uncommittedDiff.trim()) {
diff = diff + uncommittedDiff;
}
}
} catch {
// Ignore errors checking for uncommitted changes
stagedDiff = stdout;
} catch (err) {
// Non-fatal — staged diff is a best-effort supplement
logger.debug('Failed to get staged diff', err);
}
// Also get the commit log for context
// --- Step 3: unstaged changes (tracked files only) ---
let unstagedDiff = '';
try {
const { stdout } = await execFileAsync('git', ['diff'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
unstagedDiff = stdout;
} catch (err) {
// Non-fatal — unstaged diff is a best-effort supplement
logger.debug('Failed to get unstaged diff', err);
}
// --- Combine and deduplicate ---
// Build a map of filePath → diff content by concatenating hunks from all sources
// in chronological order (committed → staged → unstaged) so that no changes
// are lost when a file appears in multiple diff sources.
const combinedFileHunks = new Map<string, string>();
for (const source of [committedDiff, stagedDiff, unstagedDiff]) {
const hunks = parseDiffIntoFileHunks(source);
for (const [filePath, hunk] of hunks) {
if (combinedFileHunks.has(filePath)) {
combinedFileHunks.set(filePath, combinedFileHunks.get(filePath)! + hunk);
} else {
combinedFileHunks.set(filePath, hunk);
}
}
}
const diff = Array.from(combinedFileHunks.values()).join('');
// Log what files were included for observability
if (combinedFileHunks.size > 0) {
logger.info(`PR description scope: ${combinedFileHunks.size} file(s)`);
logger.debug(
`PR description scope files: ${Array.from(combinedFileHunks.keys()).join(', ')}`
);
}
// Also get the commit log for context — always scoped to the selected base branch
// so the log only contains commits that are part of this PR.
// We do NOT fall back to an unscoped `git log` because that would include commits
// from the base branch itself and produce misleading AI context.
let commitLog = '';
try {
const { stdout: logOutput } = await execFileAsync(
@@ -303,11 +301,11 @@ export function createGeneratePRDescriptionHandler(
);
commitLog = logOutput.trim();
} catch {
// If comparing against base fails, fall back to recent commits
// Base branch not available locally — try the remote tracking branch
try {
const { stdout: logOutput } = await execFileAsync(
'git',
['log', '--oneline', '-10', '--no-decorate'],
['log', `origin/${base}..HEAD`, '--oneline', '--no-decorate'],
{
cwd: worktreePath,
maxBuffer: 1024 * 1024,
@@ -315,7 +313,9 @@ export function createGeneratePRDescriptionHandler(
);
commitLog = logOutput.trim();
} catch {
// Ignore commit log errors
// Cannot scope commit log to base branch — leave empty rather than
// including unscoped commits that would pollute the AI context.
logger.warn(`Could not get commit log against ${base} or origin/${base}`);
}
}
@@ -341,10 +341,6 @@ export function createGeneratePRDescriptionHandler(
userPrompt += `\nCommit History:\n${commitLog}\n`;
}
if (hasUncommittedChanges) {
userPrompt += `\nNote: This branch has uncommitted changes that will be included in the PR.\n`;
}
if (truncatedDiff) {
userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
}

View File

@@ -9,6 +9,7 @@ import type { Request, Response } from 'express';
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from '../common.js';
import { getRemotesWithBranch } from '../../../services/worktree-service.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -131,6 +132,8 @@ export function createListBranchesHandler() {
let behindCount = 0;
let hasRemoteBranch = false;
let trackingRemote: string | undefined;
// List of remote names that have a branch matching the current branch name
let remotesWithBranch: string[] = [];
try {
// First check if there's a remote tracking branch
const { stdout: upstreamOutput } = await execFileAsync(
@@ -172,6 +175,12 @@ export function createListBranchesHandler() {
}
}
// Check which remotes have a branch matching the current branch name.
// This helps the UI distinguish between "branch exists on tracking remote" vs
// "branch was pushed to a different remote" (e.g., pushed to 'upstream' but tracking 'origin').
// Use for-each-ref to check cached remote refs (already fetched above if includeRemote was true)
remotesWithBranch = await getRemotesWithBranch(worktreePath, currentBranch, hasAnyRemotes);
res.json({
success: true,
result: {
@@ -182,6 +191,7 @@ export function createListBranchesHandler() {
hasRemoteBranch,
hasAnyRemotes,
trackingRemote,
remotesWithBranch,
},
});
} catch (error) {

View File

@@ -5,6 +5,7 @@
import type {
PlanningMode,
ThinkingLevel,
ReasoningEffort,
ParsedTask,
ClaudeCompatibleProvider,
Credentials,
@@ -24,7 +25,9 @@ export interface AgentExecutionOptions {
previousContent?: string;
systemPrompt?: string;
autoLoadClaudeMd?: boolean;
useClaudeCodeSystemPrompt?: boolean;
thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
branchName?: string | null;
credentials?: Credentials;
claudeCompatibleProvider?: ClaudeCompatibleProvider;

View File

@@ -128,6 +128,7 @@ export class AgentExecutor {
? (mcpServers as Record<string, { command: string }>)
: undefined,
thinkingLevel: options.thinkingLevel,
reasoningEffort: options.reasoningEffort,
credentials,
claudeCompatibleProvider,
sdkSessionId,
@@ -703,6 +704,7 @@ export class AgentExecutor {
allowedTools: o.sdkOptions?.allowedTools as string[] | undefined,
abortController: o.abortController,
thinkingLevel: o.thinkingLevel,
reasoningEffort: o.reasoningEffort,
mcpServers:
o.mcpServers && Object.keys(o.mcpServers).length > 0
? (o.mcpServers as Record<string, { command: string }>)

View File

@@ -21,6 +21,7 @@ import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.
import type { SettingsService } from './settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getPromptCustomization,
@@ -357,6 +358,22 @@ export class AgentService {
'[AgentService]'
);
// Load useClaudeCodeSystemPrompt setting (project setting takes precedence over global)
// Wrap in try/catch so transient settingsService errors don't abort message processing
let useClaudeCodeSystemPrompt = true;
try {
useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
effectiveWorkDir,
this.settingsService,
'[AgentService]'
);
} catch (err) {
this.logger.error(
'[AgentService] getUseClaudeCodeSystemPromptSetting failed, defaulting to true',
err
);
}
// Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
@@ -443,6 +460,7 @@ export class AgentService {
systemPrompt: combinedSystemPrompt,
abortController: session.abortController!,
autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
maxTurns: userMaxTurns, // User-configured max turns from settings
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,

View File

@@ -14,7 +14,7 @@
import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY, DEFAULT_MODELS, stripProviderPrefix } from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver';
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
@@ -213,7 +213,9 @@ export class AutoModeServiceFacade {
previousContent?: string;
systemPrompt?: string;
autoLoadClaudeMd?: boolean;
useClaudeCodeSystemPrompt?: boolean;
thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
branchName?: string | null;
[key: string]: unknown;
}
@@ -244,6 +246,7 @@ export class AutoModeServiceFacade {
// internal defaults which may be much lower than intended (e.g., Codex CLI's
// default turn limit can cause feature runs to stop prematurely).
const autoLoadClaudeMd = opts?.autoLoadClaudeMd ?? false;
const useClaudeCodeSystemPrompt = opts?.useClaudeCodeSystemPrompt ?? true;
let mcpServers: Record<string, unknown> | undefined;
try {
if (settingsService) {
@@ -265,6 +268,7 @@ export class AutoModeServiceFacade {
systemPrompt: opts?.systemPrompt,
abortController,
autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: opts?.thinkingLevel,
maxTurns: userMaxTurns,
mcpServers: mcpServers as
@@ -292,7 +296,9 @@ export class AutoModeServiceFacade {
previousContent: opts?.previousContent as string | undefined,
systemPrompt: opts?.systemPrompt as string | undefined,
autoLoadClaudeMd: opts?.autoLoadClaudeMd as boolean | undefined,
useClaudeCodeSystemPrompt,
thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined,
reasoningEffort: opts?.reasoningEffort as ReasoningEffort | undefined,
branchName: opts?.branchName as string | null | undefined,
provider,
effectiveBareModel,

View File

@@ -64,6 +64,8 @@ interface AutoModeEventPayload {
error?: string;
errorType?: string;
projectPath?: string;
/** Status field present when type === 'feature_status_changed' */
status?: string;
}
/**
@@ -75,6 +77,28 @@ interface FeatureCreatedPayload {
projectPath: string;
}
/**
* Feature status changed event payload structure
*/
interface FeatureStatusChangedPayload {
featureId: string;
projectPath: string;
status: string;
}
/**
* Type guard to safely narrow AutoModeEventPayload to FeatureStatusChangedPayload
*/
function isFeatureStatusChangedPayload(
payload: AutoModeEventPayload
): payload is AutoModeEventPayload & FeatureStatusChangedPayload {
return (
typeof payload.featureId === 'string' &&
typeof payload.projectPath === 'string' &&
typeof payload.status === 'string'
);
}
/**
* Event Hook Service
*
@@ -82,12 +106,30 @@ interface FeatureCreatedPayload {
* Also stores events to history for debugging and replay.
*/
export class EventHookService {
/** Feature status that indicates agent work is done and awaiting human review (tests skipped) */
private static readonly STATUS_WAITING_APPROVAL = 'waiting_approval';
/** Feature status that indicates agent work passed automated verification */
private static readonly STATUS_VERIFIED = 'verified';
private emitter: EventEmitter | null = null;
private settingsService: SettingsService | null = null;
private eventHistoryService: EventHistoryService | null = null;
private featureLoader: FeatureLoader | null = null;
private unsubscribe: (() => void) | null = null;
/**
* Track feature IDs that have already had hooks fired via auto_mode_feature_complete
* to prevent double-firing when feature_status_changed also fires for the same feature.
* Entries are automatically cleaned up after 30 seconds.
*/
private recentlyHandledFeatures = new Set<string>();
/**
* Timer IDs for pending cleanup of recentlyHandledFeatures entries,
* keyed by featureId. Stored so they can be cancelled in destroy().
*/
private recentlyHandledTimers = new Map<string, ReturnType<typeof setTimeout>>();
/**
* Initialize the service with event emitter, settings service, event history service, and feature loader
*/
@@ -122,6 +164,12 @@ export class EventHookService {
this.unsubscribe();
this.unsubscribe = null;
}
// Cancel all pending cleanup timers to avoid cross-session mutations
for (const timerId of this.recentlyHandledTimers.values()) {
clearTimeout(timerId);
}
this.recentlyHandledTimers.clear();
this.recentlyHandledFeatures.clear();
this.emitter = null;
this.settingsService = null;
this.eventHistoryService = null;
@@ -140,14 +188,27 @@ export class EventHookService {
switch (payload.type) {
case 'auto_mode_feature_complete':
trigger = payload.passes ? 'feature_success' : 'feature_error';
// Track this feature so feature_status_changed doesn't double-fire hooks
if (payload.featureId) {
this.markFeatureHandled(payload.featureId);
}
break;
case 'auto_mode_error':
// Feature-level error (has featureId) vs auto-mode level error
trigger = payload.featureId ? 'feature_error' : 'auto_mode_error';
// Track this feature so feature_status_changed doesn't double-fire hooks
if (payload.featureId) {
this.markFeatureHandled(payload.featureId);
}
break;
case 'auto_mode_idle':
trigger = 'auto_mode_complete';
break;
case 'feature_status_changed':
if (isFeatureStatusChangedPayload(payload)) {
this.handleFeatureStatusChanged(payload);
}
return;
default:
// Other event types don't trigger hooks
return;
@@ -203,6 +264,74 @@ export class EventHookService {
await this.executeHooksForTrigger('feature_created', context);
}
/**
* Handle feature_status_changed events for non-auto-mode feature completion.
*
* Auto-mode features already emit auto_mode_feature_complete which triggers hooks.
* This handler catches manual (non-auto-mode) feature completions by detecting
* status transitions to completion states (verified, waiting_approval).
*/
private async handleFeatureStatusChanged(payload: FeatureStatusChangedPayload): Promise<void> {
// Skip if this feature was already handled via auto_mode_feature_complete
if (this.recentlyHandledFeatures.has(payload.featureId)) {
return;
}
let trigger: EventHookTrigger | null = null;
if (
payload.status === EventHookService.STATUS_VERIFIED ||
payload.status === EventHookService.STATUS_WAITING_APPROVAL
) {
trigger = 'feature_success';
} else {
// Only completion statuses trigger hooks from status changes
return;
}
// Load feature name
let featureName: string | undefined = undefined;
if (this.featureLoader) {
try {
const feature = await this.featureLoader.get(payload.projectPath, payload.featureId);
if (feature?.title) {
featureName = feature.title;
}
} catch (error) {
logger.warn(`Failed to load feature ${payload.featureId} for status change hook:`, error);
}
}
const context: HookContext = {
featureId: payload.featureId,
featureName,
projectPath: payload.projectPath,
projectName: this.extractProjectName(payload.projectPath),
timestamp: new Date().toISOString(),
eventType: trigger,
};
await this.executeHooksForTrigger(trigger, context, { passes: true });
}
/**
* Mark a feature as recently handled to prevent double-firing hooks.
* Entries are cleaned up after 30 seconds.
*/
private markFeatureHandled(featureId: string): void {
// Cancel any existing timer for this feature before setting a new one
const existing = this.recentlyHandledTimers.get(featureId);
if (existing !== undefined) {
clearTimeout(existing);
}
this.recentlyHandledFeatures.add(featureId);
const timerId = setTimeout(() => {
this.recentlyHandledFeatures.delete(featureId);
this.recentlyHandledTimers.delete(featureId);
}, 30000);
this.recentlyHandledTimers.set(featureId, timerId);
}
/**
* Execute all enabled hooks matching the given trigger and store event to history
*/

View File

@@ -12,6 +12,7 @@ import * as secureFs from '../lib/secure-fs.js';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
filterClaudeMdFromContext,
} from '../lib/settings-helpers.js';
import { validateWorkingDirectory } from '../lib/sdk-options.js';
@@ -241,6 +242,11 @@ ${feature.spec}
this.settingsService,
'[ExecutionService]'
);
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
projectPath,
this.settingsService,
'[ExecutionService]'
);
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
let prompt: string;
const contextResult = await this.loadContextFilesFn({
@@ -289,7 +295,9 @@ ${feature.spec}
requirePlanApproval: feature.requirePlanApproval,
systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
branchName: feature.branchName ?? null,
}
);
@@ -353,7 +361,9 @@ Please continue from where you left off and complete all remaining tasks. Use th
requirePlanApproval: false,
systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
branchName: feature.branchName ?? null,
}
);
@@ -388,6 +398,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
branchName: feature.branchName ?? null,
abortController,
autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
testAttempts: 0,
maxTestAttempts: 5,
});

View File

@@ -5,7 +5,7 @@
* allowing the service to delegate to other services without circular dependencies.
*/
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import type { loadContextFiles } from '@automaker/utils';
import type { PipelineContext } from './pipeline-orchestrator.js';
@@ -31,7 +31,9 @@ export type RunAgentFn = (
previousContent?: string;
systemPrompt?: string;
autoLoadClaudeMd?: boolean;
useClaudeCodeSystemPrompt?: boolean;
thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
branchName?: string | null;
}
) => Promise<void>;

View File

@@ -16,6 +16,7 @@ import * as secureFs from '../lib/secure-fs.js';
import {
getPromptCustomization,
getAutoLoadClaudeMdSetting,
getUseClaudeCodeSystemPromptSetting,
filterClaudeMdFromContext,
} from '../lib/settings-helpers.js';
import { validateWorkingDirectory } from '../lib/sdk-options.js';
@@ -70,8 +71,16 @@ export class PipelineOrchestrator {
) {}
async executePipeline(ctx: PipelineContext): Promise<void> {
const { projectPath, featureId, feature, steps, workDir, abortController, autoLoadClaudeMd } =
ctx;
const {
projectPath,
featureId,
feature,
steps,
workDir,
abortController,
autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
} = ctx;
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
const contextResult = await this.loadContextFilesFn({
projectPath,
@@ -121,7 +130,9 @@ export class PipelineOrchestrator {
previousContent: previousContext,
systemPrompt: contextFilesPrompt || undefined,
autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
thinkingLevel: feature.thinkingLevel,
reasoningEffort: feature.reasoningEffort,
}
);
try {
@@ -354,6 +365,11 @@ export class PipelineOrchestrator {
this.settingsService,
'[AutoMode]'
);
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
projectPath,
this.settingsService,
'[AutoMode]'
);
const context: PipelineContext = {
projectPath,
featureId,
@@ -364,6 +380,7 @@ export class PipelineOrchestrator {
branchName: branchName ?? null,
abortController,
autoLoadClaudeMd,
useClaudeCodeSystemPrompt,
testAttempts: 0,
maxTestAttempts: 5,
};
@@ -462,7 +479,14 @@ export class PipelineOrchestrator {
projectPath,
undefined,
undefined,
{ projectPath, planningMode: 'skip', requirePlanApproval: false }
{
projectPath,
planningMode: 'skip',
requirePlanApproval: false,
useClaudeCodeSystemPrompt: context.useClaudeCodeSystemPrompt,
autoLoadClaudeMd: context.autoLoadClaudeMd,
reasoningEffort: context.feature.reasoningEffort,
}
);
}
}
@@ -596,7 +620,7 @@ export class PipelineOrchestrator {
}
// Only capture assertion details when they appear in failure context
// or match explicit assertion error / expect patterns
if (trimmed.includes('AssertionError') || trimmed.includes('AssertionError')) {
if (trimmed.includes('AssertionError')) {
failedTests.push(trimmed);
} else if (
inFailureContext &&

View File

@@ -14,6 +14,7 @@ export interface PipelineContext {
branchName: string | null;
abortController: AbortController;
autoLoadClaudeMd: boolean;
useClaudeCodeSystemPrompt?: boolean;
testAttempts: number;
maxTestAttempts: number;
}

View File

@@ -31,6 +31,7 @@ import type {
WorktreeInfo,
PhaseModelConfig,
PhaseModelEntry,
FeatureTemplate,
ClaudeApiProfile,
ClaudeCompatibleProvider,
ProviderModel,
@@ -40,6 +41,7 @@ import {
DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS,
DEFAULT_PHASE_MODELS,
DEFAULT_FEATURE_TEMPLATES,
SETTINGS_VERSION,
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
@@ -139,6 +141,11 @@ export class SettingsService {
// Migrate model IDs to canonical format
const migratedModelSettings = this.migrateModelSettings(settings);
// Merge built-in feature templates: ensure all built-in templates exist in user settings.
// User customizations (enabled/disabled state, order overrides) are preserved.
// New built-in templates added in code updates are injected for existing users.
const mergedFeatureTemplates = this.mergeBuiltInTemplates(settings.featureTemplates);
// Apply any missing defaults (for backwards compatibility)
let result: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
@@ -149,6 +156,7 @@ export class SettingsService {
...settings.keyboardShortcuts,
},
phaseModels: migratedPhaseModels,
featureTemplates: mergedFeatureTemplates,
};
// Version-based migrations
@@ -250,6 +258,32 @@ export class SettingsService {
return result;
}
/**
* Merge built-in feature templates with user's stored templates.
*
* Ensures new built-in templates added in code updates are available to existing users
* without overwriting their customizations (e.g., enabled/disabled state, custom order).
* Built-in templates missing from stored settings are appended with their defaults.
*
* @param storedTemplates - Templates from user's settings file (may be undefined for new installs)
* @returns Merged template list with all built-in templates present
*/
private mergeBuiltInTemplates(storedTemplates: FeatureTemplate[] | undefined): FeatureTemplate[] {
if (!storedTemplates) {
return DEFAULT_FEATURE_TEMPLATES;
}
const storedIds = new Set(storedTemplates.map((t) => t.id));
const missingBuiltIns = DEFAULT_FEATURE_TEMPLATES.filter((t) => !storedIds.has(t.id));
if (missingBuiltIns.length === 0) {
return storedTemplates;
}
// Append missing built-in templates after existing ones
return [...storedTemplates, ...missingBuiltIns];
}
/**
* Migrate legacy enhancementModel/validationModel fields to phaseModels structure
*

View File

@@ -8,9 +8,64 @@
import path from 'path';
import fs from 'fs/promises';
import { execFile } from 'child_process';
import { promisify } from 'util';
import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js';
const execFileAsync = promisify(execFile);
/**
* Get the list of remote names that have a branch matching the given branch name.
*
* Uses `git for-each-ref` to check cached remote refs, returning the names of
* any remotes that already have a branch with the same name as `currentBranch`.
* Returns an empty array when `hasAnyRemotes` is false or when no matching
* remote refs are found.
*
* This helps the UI distinguish between "branch exists on the tracking remote"
* vs "branch was pushed to a different remote".
*
* @param worktreePath - Path to the git worktree
* @param currentBranch - Branch name to search for on remotes
* @param hasAnyRemotes - Whether the repository has any remotes configured
* @returns Array of remote names (e.g. ["origin", "upstream"]) that contain the branch
*/
export async function getRemotesWithBranch(
worktreePath: string,
currentBranch: string,
hasAnyRemotes: boolean
): Promise<string[]> {
if (!hasAnyRemotes) {
return [];
}
try {
const { stdout: remoteRefsOutput } = await execFileAsync(
'git',
['for-each-ref', '--format=%(refname:short)', `refs/remotes/*/${currentBranch}`],
{ cwd: worktreePath }
);
if (!remoteRefsOutput.trim()) {
return [];
}
return remoteRefsOutput
.trim()
.split('\n')
.map((ref) => {
// Extract remote name from "remote/branch" format
const slashIdx = ref.indexOf('/');
return slashIdx !== -1 ? ref.slice(0, slashIdx) : ref;
})
.filter((name) => name.length > 0);
} catch {
// Ignore errors - return empty array
return [];
}
}
/**
* Error thrown when one or more file copy operations fail during
* `copyConfiguredFiles`. The caller can inspect `failures` for details.

View File

@@ -23,6 +23,7 @@ export type {
PhaseModelConfig,
PhaseModelKey,
PhaseModelEntry,
FeatureTemplate,
// Claude-compatible provider types
ApiKeySource,
ClaudeCompatibleProviderType,
@@ -41,6 +42,7 @@ export {
DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS,
DEFAULT_PHASE_MODELS,
DEFAULT_FEATURE_TEMPLATES,
SETTINGS_VERSION,
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,