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

@@ -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.