mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 22:13:08 +00:00
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:
@@ -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>;
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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\`\`\``;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }>)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface PipelineContext {
|
||||
branchName: string | null;
|
||||
abortController: AbortController;
|
||||
autoLoadClaudeMd: boolean;
|
||||
useClaudeCodeSystemPrompt?: boolean;
|
||||
testAttempts: number;
|
||||
maxTestAttempts: number;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user