structuring

This commit is contained in:
nrslib 2026-02-02 10:14:12 +09:00
parent 24e12b6c85
commit 710d108f53
119 changed files with 3616 additions and 3358 deletions

View File

@ -8,7 +8,7 @@ import * as path from 'node:path';
import { tmpdir } from 'node:os';
// Mock dependencies before importing the module under test
vi.mock('../commands/interactive.js', () => ({
vi.mock('../commands/interactive/interactive.js', () => ({
interactiveMode: vi.fn(),
}));
@ -16,7 +16,7 @@ vi.mock('../providers/index.js', () => ({
getProvider: vi.fn(),
}));
vi.mock('../config/globalConfig.js', () => ({
vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })),
}));
@ -33,6 +33,7 @@ vi.mock('../task/summarize.js', () => ({
vi.mock('../utils/ui.js', () => ({
success: vi.fn(),
info: vi.fn(),
blankLine: vi.fn(),
}));
vi.mock('../utils/debug.js', () => ({
@ -43,11 +44,11 @@ vi.mock('../utils/debug.js', () => ({
}),
}));
vi.mock('../config/workflowLoader.js', () => ({
vi.mock('../config/loaders/workflowLoader.js', () => ({
listWorkflows: vi.fn(),
}));
vi.mock('../config/paths.js', () => ({
vi.mock('../config/paths.js', async (importOriginal) => ({ ...(await importOriginal<Record<string, unknown>>()),
getCurrentWorkflow: vi.fn(() => 'default'),
}));
@ -66,13 +67,13 @@ vi.mock('../github/issue.js', () => ({
}),
}));
import { interactiveMode } from '../commands/interactive.js';
import { interactiveMode } from '../commands/interactive/interactive.js';
import { getProvider } from '../providers/index.js';
import { promptInput, confirm, selectOption } from '../prompt/index.js';
import { summarizeTaskName } from '../task/summarize.js';
import { listWorkflows } from '../config/workflowLoader.js';
import { listWorkflows } from '../config/loaders/workflowLoader.js';
import { resolveIssueTask } from '../github/issue.js';
import { addTask, summarizeConversation } from '../commands/addTask.js';
import { addTask, summarizeConversation } from '../commands/management/addTask.js';
const mockResolveIssueTask = vi.mocked(resolveIssueTask);

View File

@ -32,7 +32,7 @@ vi.mock('../config/paths.js', async (importOriginal) => {
});
// Import after mocking
const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, invalidateGlobalConfigCache } = await import('../config/globalConfig.js');
const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, invalidateGlobalConfigCache } = await import('../config/global/globalConfig.js');
describe('GlobalConfigSchema API key fields', () => {
it('should accept config without API keys', () => {

View File

@ -67,7 +67,7 @@ vi.mock('../commands/index.js', () => ({
interactiveMode: vi.fn(() => Promise.resolve({ confirmed: false, task: '' })),
}));
vi.mock('../config/workflowLoader.js', () => ({
vi.mock('../config/loaders/workflowLoader.js', () => ({
listWorkflows: vi.fn(() => []),
}));
@ -92,7 +92,7 @@ import { confirm } from '../prompt/index.js';
import { createSharedClone } from '../task/clone.js';
import { summarizeTaskName } from '../task/summarize.js';
import { info } from '../utils/ui.js';
import { confirmAndCreateWorktree } from '../commands/selectAndExecute.js';
import { confirmAndCreateWorktree } from '../commands/execution/selectAndExecute.js';
const mockConfirm = vi.mocked(confirm);
const mockCreateSharedClone = vi.mocked(createSharedClone);

View File

@ -29,7 +29,7 @@ vi.mock('../utils/debug.js', () => ({
}),
}));
vi.mock('../config/globalConfig.js', () => ({
vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({})),
}));

View File

@ -13,7 +13,7 @@ import {
loadWorkflow,
listWorkflows,
loadAgentPromptFromPath,
} from '../config/loader.js';
} from '../config/loaders/loader.js';
import {
getCurrentWorkflow,
setCurrentWorkflow,
@ -36,8 +36,8 @@ import {
loadWorktreeSessions,
updateWorktreeSession,
} from '../config/paths.js';
import { getLanguage } from '../config/globalConfig.js';
import { loadProjectConfig } from '../config/projectConfig.js';
import { getLanguage } from '../config/global/globalConfig.js';
import { loadProjectConfig } from '../config/project/projectConfig.js';
describe('getBuiltinWorkflow', () => {
it('should return builtin workflow when it exists in resources', () => {

View File

@ -18,7 +18,7 @@ vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
vi.mock('../workflow/rule-evaluator.js', () => ({
vi.mock('../workflow/evaluation/index.js', () => ({
detectMatchedRule: vi.fn(),
}));
@ -38,7 +38,7 @@ vi.mock('../claude/query-manager.js', () => ({
// --- Imports (after mocks) ---
import { WorkflowEngine } from '../workflow/engine.js';
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
import { runAgent } from '../agents/runner.js';
import { interruptAllQueries } from '../claude/query-manager.js';
import {

View File

@ -11,7 +11,7 @@ vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
vi.mock('../workflow/rule-evaluator.js', () => ({
vi.mock('../workflow/evaluation/index.js', () => ({
detectMatchedRule: vi.fn(),
}));
@ -25,7 +25,7 @@ vi.mock('../utils/session.js', () => ({
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
}));
import { WorkflowEngine } from '../workflow/engine.js';
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
import { runAgent } from '../agents/runner.js';
import type { WorkflowConfig } from '../models/types.js';
import {

View File

@ -16,7 +16,7 @@ vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
vi.mock('../workflow/rule-evaluator.js', () => ({
vi.mock('../workflow/evaluation/index.js', () => ({
detectMatchedRule: vi.fn(),
}));
@ -32,7 +32,7 @@ vi.mock('../utils/session.js', () => ({
// --- Imports (after mocks) ---
import { WorkflowEngine } from '../workflow/engine.js';
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
import {
makeResponse,
buildDefaultWorkflowConfig,

View File

@ -17,7 +17,7 @@ vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
vi.mock('../workflow/rule-evaluator.js', () => ({
vi.mock('../workflow/evaluation/index.js', () => ({
detectMatchedRule: vi.fn(),
}));
@ -33,9 +33,9 @@ vi.mock('../utils/session.js', () => ({
// --- Imports (after mocks) ---
import { WorkflowEngine } from '../workflow/engine.js';
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
import { runAgent } from '../agents/runner.js';
import { detectMatchedRule } from '../workflow/rule-evaluator.js';
import { detectMatchedRule } from '../workflow/evaluation/index.js';
import {
makeResponse,
makeStep,

View File

@ -21,7 +21,7 @@ vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
vi.mock('../workflow/rule-evaluator.js', () => ({
vi.mock('../workflow/evaluation/index.js', () => ({
detectMatchedRule: vi.fn(),
}));
@ -37,7 +37,7 @@ vi.mock('../utils/session.js', () => ({
// --- Imports (after mocks) ---
import { WorkflowEngine } from '../workflow/engine.js';
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
import { runAgent } from '../agents/runner.js';
import {
makeResponse,

View File

@ -16,7 +16,7 @@ vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
vi.mock('../workflow/rule-evaluator.js', () => ({
vi.mock('../workflow/evaluation/index.js', () => ({
detectMatchedRule: vi.fn(),
}));
@ -32,7 +32,7 @@ vi.mock('../utils/session.js', () => ({
// --- Imports (after mocks) ---
import { WorkflowEngine } from '../workflow/engine.js';
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
import { runAgent } from '../agents/runner.js';
import {
makeResponse,

View File

@ -8,7 +8,7 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { EventEmitter } from 'node:events';
import { existsSync } from 'node:fs';
import { isReportObjectConfig } from '../workflow/instruction-builder.js';
import { isReportObjectConfig } from '../workflow/instruction/InstructionBuilder.js';
import type { WorkflowStep, ReportObjectConfig, ReportConfig } from '../models/types.js';
/**

View File

@ -15,8 +15,8 @@ import type { WorkflowConfig, WorkflowStep, AgentResponse, WorkflowRule } from '
// --- Mock imports (consumers must call vi.mock before importing this) ---
import { runAgent } from '../agents/runner.js';
import { detectMatchedRule } from '../workflow/rule-evaluator.js';
import type { RuleMatch } from '../workflow/rule-evaluator.js';
import { detectMatchedRule } from '../workflow/evaluation/index.js';
import type { RuleMatch } from '../workflow/evaluation/index.js';
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../workflow/phase-runner.js';
import { generateReportDir } from '../utils/session.js';

View File

@ -17,7 +17,7 @@ vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
vi.mock('../workflow/rule-evaluator.js', () => ({
vi.mock('../workflow/evaluation/index.js', () => ({
detectMatchedRule: vi.fn(),
}));
@ -33,7 +33,7 @@ vi.mock('../utils/session.js', () => ({
// --- Imports (after mocks) ---
import { WorkflowEngine } from '../workflow/engine.js';
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
import { runReportPhase } from '../workflow/phase-runner.js';
import {
makeResponse,

View File

@ -20,7 +20,7 @@ vi.mock('node:os', async () => {
});
// Import after mocks are set up
const { loadGlobalConfig, saveGlobalConfig, invalidateGlobalConfigCache } = await import('../config/globalConfig.js');
const { loadGlobalConfig, saveGlobalConfig, invalidateGlobalConfigCache } = await import('../config/global/globalConfig.js');
const { getGlobalConfigPath } = await import('../config/paths.js');
describe('loadGlobalConfig', () => {

View File

@ -25,7 +25,7 @@ vi.mock('../prompt/index.js', () => ({
}));
// Import after mocks are set up
const { initGlobalDirs, needsLanguageSetup } = await import('../config/initialization.js');
const { initGlobalDirs, needsLanguageSetup } = await import('../config/global/initialization.js');
const { getGlobalConfigPath, getGlobalConfigDir } = await import('../config/paths.js');
describe('initGlobalDirs with non-interactive mode', () => {

View File

@ -25,7 +25,7 @@ vi.mock('../prompt/index.js', () => ({
}));
// Import after mocks are set up
const { needsLanguageSetup } = await import('../config/initialization.js');
const { needsLanguageSetup } = await import('../config/global/initialization.js');
const { getGlobalConfigPath } = await import('../config/paths.js');
const { copyProjectResourcesToDir, getLanguageResourcesDir, getProjectResourcesDir } = await import('../resources/index.js');

View File

@ -4,17 +4,28 @@
import { describe, it, expect } from 'vitest';
import {
buildInstruction,
buildReportInstruction,
buildStatusJudgmentInstruction,
InstructionBuilder,
isReportObjectConfig,
} from '../workflow/instruction/InstructionBuilder.js';
import { ReportInstructionBuilder, type ReportInstructionContext } from '../workflow/instruction/ReportInstructionBuilder.js';
import { StatusJudgmentBuilder, type StatusJudgmentContext } from '../workflow/instruction/StatusJudgmentBuilder.js';
import {
buildExecutionMetadata,
renderExecutionMetadata,
generateStatusRulesFromRules,
isReportObjectConfig,
type InstructionContext,
type ReportInstructionContext,
type StatusJudgmentContext,
} from '../workflow/instruction-builder.js';
} from '../workflow/instruction-context.js';
import { generateStatusRulesFromRules } from '../workflow/status-rules.js';
// Backward-compatible function wrappers for test readability
function buildInstruction(step: WorkflowStep, ctx: InstructionContext): string {
return new InstructionBuilder(step, ctx).build();
}
function buildReportInstruction(step: WorkflowStep, ctx: ReportInstructionContext): string {
return new ReportInstructionBuilder(step, ctx).build();
}
function buildStatusJudgmentInstruction(step: WorkflowStep, ctx: StatusJudgmentContext): string {
return new StatusJudgmentBuilder(step, ctx).build();
}
import type { WorkflowStep, WorkflowRule } from '../models/types.js';

View File

@ -4,7 +4,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../config/globalConfig.js', () => ({
vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
}));
@ -46,7 +46,7 @@ vi.mock('node:readline', () => ({
import { createInterface } from 'node:readline';
import { getProvider } from '../providers/index.js';
import { interactiveMode } from '../commands/interactive.js';
import { interactiveMode } from '../commands/interactive/interactive.js';
const mockGetProvider = vi.mocked(getProvider);
const mockCreateInterface = vi.mocked(createInterface);

View File

@ -36,19 +36,19 @@ vi.mock('../utils/session.js', () => ({
generateSessionId: vi.fn().mockReturnValue('test-session-id'),
}));
vi.mock('../config/globalConfig.js', () => ({
vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]),
}));
vi.mock('../config/projectConfig.js', () => ({
vi.mock('../config/project/projectConfig.js', () => ({
loadProjectConfig: vi.fn().mockReturnValue({}),
}));
// --- Imports (after mocks) ---
import { WorkflowEngine } from '../workflow/engine.js';
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
// --- Test helpers ---

View File

@ -10,17 +10,26 @@
import { describe, it, expect, vi } from 'vitest';
import type { WorkflowStep, WorkflowRule, AgentResponse } from '../models/types.js';
vi.mock('../config/globalConfig.js', () => ({
vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'),
}));
import {
buildInstruction,
buildReportInstruction,
buildStatusJudgmentInstruction,
} from '../workflow/instruction-builder.js';
import type { InstructionContext } from '../workflow/instruction-builder.js';
import { InstructionBuilder } from '../workflow/instruction/InstructionBuilder.js';
import { ReportInstructionBuilder, type ReportInstructionContext } from '../workflow/instruction/ReportInstructionBuilder.js';
import { StatusJudgmentBuilder, type StatusJudgmentContext } from '../workflow/instruction/StatusJudgmentBuilder.js';
import type { InstructionContext } from '../workflow/instruction-context.js';
// Function wrappers for test readability
function buildInstruction(step: WorkflowStep, ctx: InstructionContext): string {
return new InstructionBuilder(step, ctx).build();
}
function buildReportInstruction(step: WorkflowStep, ctx: ReportInstructionContext): string {
return new ReportInstructionBuilder(step, ctx).build();
}
function buildStatusJudgmentInstruction(step: WorkflowStep, ctx: StatusJudgmentContext): string {
return new StatusJudgmentBuilder(step, ctx).build();
}
// --- Test helpers ---

View File

@ -104,8 +104,8 @@ vi.mock('../config/paths.js', async (importOriginal) => {
};
});
vi.mock('../config/globalConfig.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../config/globalConfig.js')>();
vi.mock('../config/global/globalConfig.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../config/global/globalConfig.js')>();
return {
...original,
loadGlobalConfig: vi.fn().mockReturnValue({}),
@ -114,8 +114,8 @@ vi.mock('../config/globalConfig.js', async (importOriginal) => {
};
});
vi.mock('../config/projectConfig.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../config/projectConfig.js')>();
vi.mock('../config/project/projectConfig.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../config/project/projectConfig.js')>();
return {
...original,
loadProjectConfig: vi.fn().mockReturnValue({}),
@ -139,7 +139,7 @@ vi.mock('../workflow/phase-runner.js', () => ({
// --- Imports (after mocks) ---
import { executePipeline } from '../commands/pipelineExecution.js';
import { executePipeline } from '../commands/execution/pipelineExecution.js';
import {
EXIT_ISSUE_FETCH_FAILED,
EXIT_WORKFLOW_FAILED,

View File

@ -87,8 +87,8 @@ vi.mock('../config/paths.js', async (importOriginal) => {
};
});
vi.mock('../config/globalConfig.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../config/globalConfig.js')>();
vi.mock('../config/global/globalConfig.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../config/global/globalConfig.js')>();
return {
...original,
loadGlobalConfig: vi.fn().mockReturnValue({}),
@ -96,8 +96,8 @@ vi.mock('../config/globalConfig.js', async (importOriginal) => {
};
});
vi.mock('../config/projectConfig.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../config/projectConfig.js')>();
vi.mock('../config/project/projectConfig.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../config/project/projectConfig.js')>();
return {
...original,
loadProjectConfig: vi.fn().mockReturnValue({}),
@ -121,7 +121,7 @@ vi.mock('../workflow/phase-runner.js', () => ({
// --- Imports (after mocks) ---
import { executePipeline } from '../commands/pipelineExecution.js';
import { executePipeline } from '../commands/execution/pipelineExecution.js';
// --- Test helpers ---

View File

@ -29,19 +29,19 @@ vi.mock('../claude/client.js', async (importOriginal) => {
};
});
vi.mock('../config/globalConfig.js', () => ({
vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'),
}));
vi.mock('../config/projectConfig.js', () => ({
vi.mock('../config/project/projectConfig.js', () => ({
loadProjectConfig: vi.fn().mockReturnValue({}),
}));
// --- Imports (after mocks) ---
import { detectMatchedRule, evaluateAggregateConditions } from '../workflow/rule-evaluator.js';
import type { RuleMatch, RuleEvaluatorContext } from '../workflow/rule-evaluator.js';
import { detectMatchedRule, evaluateAggregateConditions } from '../workflow/evaluation/index.js';
import type { RuleMatch, RuleEvaluatorContext } from '../workflow/evaluation/index.js';
// --- Test helpers ---

View File

@ -41,19 +41,19 @@ vi.mock('../utils/session.js', () => ({
generateSessionId: vi.fn().mockReturnValue('test-session-id'),
}));
vi.mock('../config/globalConfig.js', () => ({
vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]),
}));
vi.mock('../config/projectConfig.js', () => ({
vi.mock('../config/project/projectConfig.js', () => ({
loadProjectConfig: vi.fn().mockReturnValue({}),
}));
// --- Imports (after mocks) ---
import { WorkflowEngine } from '../workflow/engine.js';
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
// --- Test helpers ---

View File

@ -40,18 +40,18 @@ vi.mock('../utils/session.js', () => ({
generateSessionId: vi.fn().mockReturnValue('test-session-id'),
}));
vi.mock('../config/globalConfig.js', () => ({
vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'),
}));
vi.mock('../config/projectConfig.js', () => ({
vi.mock('../config/project/projectConfig.js', () => ({
loadProjectConfig: vi.fn().mockReturnValue({}),
}));
// --- Imports (after mocks) ---
import { WorkflowEngine } from '../workflow/engine.js';
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
// --- Test helpers ---

View File

@ -15,7 +15,7 @@ import { tmpdir } from 'node:os';
// --- Mocks ---
vi.mock('../config/globalConfig.js', () => ({
vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]),
@ -23,7 +23,7 @@ vi.mock('../config/globalConfig.js', () => ({
// --- Imports (after mocks) ---
import { loadWorkflow } from '../config/workflowLoader.js';
import { loadWorkflow } from '../config/loaders/workflowLoader.js';
// --- Test helpers ---

View File

@ -35,20 +35,20 @@ vi.mock('../utils/session.js', () => ({
generateSessionId: vi.fn().mockReturnValue('test-session-id'),
}));
vi.mock('../config/globalConfig.js', () => ({
vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]),
}));
vi.mock('../config/projectConfig.js', () => ({
vi.mock('../config/project/projectConfig.js', () => ({
loadProjectConfig: vi.fn().mockReturnValue({}),
}));
// --- Imports (after mocks) ---
import { WorkflowEngine } from '../workflow/engine.js';
import { loadWorkflow } from '../config/workflowLoader.js';
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
import { loadWorkflow } from '../config/loaders/workflowLoader.js';
import type { WorkflowConfig } from '../models/types.js';
// --- Test helpers ---

View File

@ -9,7 +9,7 @@ import {
buildListItems,
type BranchInfo,
} from '../task/branchList.js';
import { isBranchMerged, showFullDiff, type ListAction } from '../commands/listTasks.js';
import { isBranchMerged, showFullDiff, type ListAction } from '../commands/management/listTasks.js';
describe('parseTaktBranches', () => {
it('should parse takt/ branches from git branch output', () => {

View File

@ -27,13 +27,13 @@ vi.mock('../github/pr.js', () => ({
}));
const mockExecuteTask = vi.fn();
vi.mock('../commands/taskExecution.js', () => ({
vi.mock('../commands/execution/taskExecution.js', () => ({
executeTask: mockExecuteTask,
}));
// Mock loadGlobalConfig
const mockLoadGlobalConfig = vi.fn();
vi.mock('../config/globalConfig.js', () => ({
vi.mock('../config/global/globalConfig.js', async (importOriginal) => ({ ...(await importOriginal<Record<string, unknown>>()),
loadGlobalConfig: mockLoadGlobalConfig,
}));
@ -50,8 +50,11 @@ vi.mock('../utils/ui.js', () => ({
success: vi.fn(),
status: vi.fn(),
blankLine: vi.fn(),
header: vi.fn(),
section: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}));
// Mock debug logger
vi.mock('../utils/debug.js', () => ({
createLogger: () => ({
@ -61,7 +64,7 @@ vi.mock('../utils/debug.js', () => ({
}),
}));
const { executePipeline } = await import('../commands/pipelineExecution.js');
const { executePipeline } = await import('../commands/execution/pipelineExecution.js');
describe('executePipeline', () => {
beforeEach(() => {

View File

@ -8,7 +8,7 @@ vi.mock('../providers/index.js', () => ({
getProvider: vi.fn(),
}));
vi.mock('../config/globalConfig.js', () => ({
vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(),
}));
@ -21,7 +21,7 @@ vi.mock('../utils/debug.js', () => ({
}));
import { getProvider } from '../providers/index.js';
import { loadGlobalConfig } from '../config/globalConfig.js';
import { loadGlobalConfig } from '../config/global/globalConfig.js';
import { summarizeTaskName } from '../task/summarize.js';
const mockGetProvider = vi.mocked(getProvider);

View File

@ -65,7 +65,7 @@ vi.mock('../constants.js', () => ({
import { createSharedClone } from '../task/clone.js';
import { summarizeTaskName } from '../task/summarize.js';
import { info } from '../utils/ui.js';
import { resolveTaskExecution } from '../commands/taskExecution.js';
import { resolveTaskExecution } from '../commands/execution/taskExecution.js';
import type { TaskInfo } from '../task/index.js';
const mockCreateSharedClone = vi.mocked(createSharedClone);

View File

@ -11,7 +11,7 @@
*/
import { describe, it, expect } from 'vitest';
import { loadWorkflow } from '../config/loader.js';
import { loadWorkflow } from '../config/loaders/loader.js';
describe('expert workflow parallel structure', () => {
const workflow = loadWorkflow('expert', process.cwd());

View File

@ -11,7 +11,7 @@ import {
loadWorkflowByIdentifier,
listWorkflows,
loadAllWorkflows,
} from '../config/workflowLoader.js';
} from '../config/loaders/workflowLoader.js';
const SAMPLE_WORKFLOW = `name: test-workflow
description: Test workflow

View File

@ -2,4 +2,5 @@
* Agents module - exports agent execution utilities
*/
export * from './runner.js';
export { AgentRunner, runAgent, runCustomAgent } from './runner.js';
export type { RunAgentOptions, StreamCallback } from './types.js';

View File

@ -9,200 +9,143 @@ import {
callClaudeSkill,
type ClaudeCallOptions,
} from '../claude/client.js';
import { type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from '../claude/process.js';
import { loadCustomAgents, loadAgentPrompt } from '../config/loader.js';
import { loadGlobalConfig } from '../config/globalConfig.js';
import { loadProjectConfig } from '../config/projectConfig.js';
import { loadCustomAgents, loadAgentPrompt } from '../config/loaders/loader.js';
import { loadGlobalConfig } from '../config/global/globalConfig.js';
import { loadProjectConfig } from '../config/project/projectConfig.js';
import { getProvider, type ProviderType, type ProviderCallOptions } from '../providers/index.js';
import type { AgentResponse, CustomAgentConfig, PermissionMode } from '../models/types.js';
import type { AgentResponse, CustomAgentConfig } from '../models/types.js';
import { createLogger } from '../utils/debug.js';
import type { RunAgentOptions } from './types.js';
// Re-export for backward compatibility
export type { RunAgentOptions, StreamCallback } from './types.js';
const log = createLogger('runner');
export type { StreamCallback };
/** Common options for running agents */
export interface RunAgentOptions {
cwd: string;
sessionId?: string;
model?: string;
provider?: 'claude' | 'codex' | 'mock';
/** Resolved path to agent prompt file */
agentPath?: string;
/** Allowed tools for this agent run */
allowedTools?: string[];
/** Maximum number of agentic turns */
maxTurns?: number;
/** Permission mode for tool execution (from workflow step) */
permissionMode?: PermissionMode;
onStream?: StreamCallback;
onPermissionRequest?: PermissionHandler;
onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */
bypassPermissions?: boolean;
}
function resolveProvider(cwd: string, options?: RunAgentOptions, agentConfig?: CustomAgentConfig): ProviderType {
// Mock provider must be explicitly specified (no fallback)
if (options?.provider) return options.provider;
if (agentConfig?.provider) return agentConfig.provider;
const projectConfig = loadProjectConfig(cwd);
if (projectConfig.provider) return projectConfig.provider;
try {
const globalConfig = loadGlobalConfig();
if (globalConfig.provider) return globalConfig.provider;
} catch {
// Ignore missing global config; fallback below
}
return 'claude';
}
function resolveModel(cwd: string, options?: RunAgentOptions, agentConfig?: CustomAgentConfig): string | undefined {
if (options?.model) return options.model;
if (agentConfig?.model) return agentConfig.model;
try {
const globalConfig = loadGlobalConfig();
if (globalConfig.model) return globalConfig.model;
} catch {
// Ignore missing global config
}
return undefined;
}
/** Run a custom agent */
export async function runCustomAgent(
agentConfig: CustomAgentConfig,
task: string,
options: RunAgentOptions
): Promise<AgentResponse> {
const allowedTools = options.allowedTools ?? agentConfig.allowedTools;
// If agent references a Claude Code agent
if (agentConfig.claudeAgent) {
const callOptions: ClaudeCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools,
maxTurns: options.maxTurns,
model: resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
return callClaudeAgent(agentConfig.claudeAgent, task, callOptions);
}
// If agent references a Claude Code skill
if (agentConfig.claudeSkill) {
const callOptions: ClaudeCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools,
maxTurns: options.maxTurns,
model: resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
return callClaudeSkill(agentConfig.claudeSkill, task, callOptions);
}
// Custom agent with prompt
const systemPrompt = loadAgentPrompt(agentConfig);
const providerType = resolveProvider(options.cwd, options, agentConfig);
const provider = getProvider(providerType);
const callOptions: ProviderCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools,
maxTurns: options.maxTurns,
model: resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions);
}
/**
* Load agent prompt from file path.
* Agent execution runner.
*
* Resolves agent configuration (provider, model, prompt) and
* delegates execution to the appropriate provider.
*/
function loadAgentPromptFromPath(agentPath: string): string {
if (!existsSync(agentPath)) {
throw new Error(`Agent file not found: ${agentPath}`);
}
return readFileSync(agentPath, 'utf-8');
}
/**
* Get agent name from path or spec.
* For agents in subdirectories, includes parent dir for pattern matching.
* - "~/.takt/agents/default/coder.md" -> "coder"
* - "~/.takt/agents/research/supervisor.md" -> "research/supervisor"
* - "./coder.md" -> "coder"
* - "coder" -> "coder"
*/
function extractAgentName(agentSpec: string): string {
if (!agentSpec.endsWith('.md')) {
return agentSpec;
}
const name = basename(agentSpec, '.md');
const dir = basename(dirname(agentSpec));
// If in 'default' directory, just use the agent name
// Otherwise, include the directory for disambiguation (e.g., 'research/supervisor')
if (dir === 'default' || dir === 'agents' || dir === '.') {
return name;
}
return `${dir}/${name}`;
}
/** Run an agent by name or path */
export async function runAgent(
agentSpec: string,
task: string,
options: RunAgentOptions
): Promise<AgentResponse> {
const agentName = extractAgentName(agentSpec);
log.debug('Running agent', {
agentSpec,
agentName,
provider: options.provider,
model: options.model,
hasAgentPath: !!options.agentPath,
hasSession: !!options.sessionId,
permissionMode: options.permissionMode,
});
// If agentPath is provided (from workflow), use it to load prompt
if (options.agentPath) {
if (!existsSync(options.agentPath)) {
throw new Error(`Agent file not found: ${options.agentPath}`);
export class AgentRunner {
/** Resolve provider type from options, agent config, project config, global config */
private static resolveProvider(
cwd: string,
options?: RunAgentOptions,
agentConfig?: CustomAgentConfig,
): ProviderType {
if (options?.provider) return options.provider;
if (agentConfig?.provider) return agentConfig.provider;
const projectConfig = loadProjectConfig(cwd);
if (projectConfig.provider) return projectConfig.provider;
try {
const globalConfig = loadGlobalConfig();
if (globalConfig.provider) return globalConfig.provider;
} catch {
// Ignore missing global config; fallback below
}
const systemPrompt = loadAgentPromptFromPath(options.agentPath);
return 'claude';
}
const providerType = resolveProvider(options.cwd, options);
/** Resolve model from options, agent config, global config */
private static resolveModel(
cwd: string,
options?: RunAgentOptions,
agentConfig?: CustomAgentConfig,
): string | undefined {
if (options?.model) return options.model;
if (agentConfig?.model) return agentConfig.model;
try {
const globalConfig = loadGlobalConfig();
if (globalConfig.model) return globalConfig.model;
} catch {
// Ignore missing global config
}
return undefined;
}
/** Load agent prompt from file path */
private static loadAgentPromptFromPath(agentPath: string): string {
if (!existsSync(agentPath)) {
throw new Error(`Agent file not found: ${agentPath}`);
}
return readFileSync(agentPath, 'utf-8');
}
/**
* Get agent name from path or spec.
* For agents in subdirectories, includes parent dir for pattern matching.
*/
private static extractAgentName(agentSpec: string): string {
if (!agentSpec.endsWith('.md')) {
return agentSpec;
}
const name = basename(agentSpec, '.md');
const dir = basename(dirname(agentSpec));
if (dir === 'default' || dir === 'agents' || dir === '.') {
return name;
}
return `${dir}/${name}`;
}
/** Run a custom agent */
async runCustom(
agentConfig: CustomAgentConfig,
task: string,
options: RunAgentOptions,
): Promise<AgentResponse> {
const allowedTools = options.allowedTools ?? agentConfig.allowedTools;
// If agent references a Claude Code agent
if (agentConfig.claudeAgent) {
const callOptions: ClaudeCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools,
maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
return callClaudeAgent(agentConfig.claudeAgent, task, callOptions);
}
// If agent references a Claude Code skill
if (agentConfig.claudeSkill) {
const callOptions: ClaudeCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools,
maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
return callClaudeSkill(agentConfig.claudeSkill, task, callOptions);
}
// Custom agent with prompt
const systemPrompt = loadAgentPrompt(agentConfig);
const providerType = AgentRunner.resolveProvider(options.cwd, options, agentConfig);
const provider = getProvider(providerType);
const callOptions: ProviderCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
allowedTools,
maxTurns: options.maxTurns,
model: resolveModel(options.cwd, options),
systemPrompt,
model: AgentRunner.resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
@ -210,16 +153,81 @@ export async function runAgent(
bypassPermissions: options.bypassPermissions,
};
return provider.call(agentName, task, callOptions);
return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions);
}
// Fallback: Look for custom agent by name
const customAgents = loadCustomAgents();
const agentConfig = customAgents.get(agentName);
/** Run an agent by name or path */
async run(
agentSpec: string,
task: string,
options: RunAgentOptions,
): Promise<AgentResponse> {
const agentName = AgentRunner.extractAgentName(agentSpec);
log.debug('Running agent', {
agentSpec,
agentName,
provider: options.provider,
model: options.model,
hasAgentPath: !!options.agentPath,
hasSession: !!options.sessionId,
permissionMode: options.permissionMode,
});
if (agentConfig) {
return runCustomAgent(agentConfig, task, options);
// If agentPath is provided (from workflow), use it to load prompt
if (options.agentPath) {
if (!existsSync(options.agentPath)) {
throw new Error(`Agent file not found: ${options.agentPath}`);
}
const systemPrompt = AgentRunner.loadAgentPromptFromPath(options.agentPath);
const providerType = AgentRunner.resolveProvider(options.cwd, options);
const provider = getProvider(providerType);
const callOptions: ProviderCallOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(options.cwd, options),
systemPrompt,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
};
return provider.call(agentName, task, callOptions);
}
// Fallback: Look for custom agent by name
const customAgents = loadCustomAgents();
const agentConfig = customAgents.get(agentName);
if (agentConfig) {
return this.runCustom(agentConfig, task, options);
}
throw new Error(`Unknown agent: ${agentSpec}`);
}
throw new Error(`Unknown agent: ${agentSpec}`);
}
// ---- Backward-compatible module-level functions ----
const defaultRunner = new AgentRunner();
export async function runAgent(
agentSpec: string,
task: string,
options: RunAgentOptions,
): Promise<AgentResponse> {
return defaultRunner.run(agentSpec, task, options);
}
export async function runCustomAgent(
agentConfig: CustomAgentConfig,
task: string,
options: RunAgentOptions,
): Promise<AgentResponse> {
return defaultRunner.runCustom(agentConfig, task, options);
}

29
src/agents/types.ts Normal file
View File

@ -0,0 +1,29 @@
/**
* Type definitions for agent execution
*/
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/types.js';
import type { PermissionMode } from '../models/types.js';
export type { StreamCallback };
/** Common options for running agents */
export interface RunAgentOptions {
cwd: string;
sessionId?: string;
model?: string;
provider?: 'claude' | 'codex' | 'mock';
/** Resolved path to agent prompt file */
agentPath?: string;
/** Allowed tools for this agent run */
allowedTools?: string[];
/** Maximum number of agentic turns */
maxTurns?: number;
/** Permission mode for tool execution (from workflow step) */
permissionMode?: PermissionMode;
onStream?: StreamCallback;
onPermissionRequest?: PermissionHandler;
onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */
bypassPermissions?: boolean;
}

View File

@ -4,36 +4,15 @@
* Uses the Claude Agent SDK for native TypeScript integration.
*/
import { executeClaudeCli, type ClaudeSpawnOptions, type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from './process.js';
import type { AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
import type { AgentResponse, Status, PermissionMode } from '../models/types.js';
import { executeClaudeCli } from './process.js';
import type { ClaudeSpawnOptions, ClaudeCallOptions } from './types.js';
import type { AgentResponse, Status } from '../models/types.js';
import { createLogger } from '../utils/debug.js';
const log = createLogger('client');
// Re-export for backward compatibility
export type { ClaudeCallOptions } from './types.js';
/** Options for calling Claude */
export interface ClaudeCallOptions {
cwd: string;
sessionId?: string;
allowedTools?: string[];
model?: string;
maxTurns?: number;
systemPrompt?: string;
/** SDK agents to register for sub-agent execution */
agents?: Record<string, AgentDefinition>;
/** Permission mode for tool execution (from workflow step) */
permissionMode?: PermissionMode;
/** Enable streaming mode with callback for real-time output */
onStream?: StreamCallback;
/** Custom permission handler for interactive permission prompts */
onPermissionRequest?: PermissionHandler;
/** Custom handler for AskUserQuestion tool */
onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */
bypassPermissions?: boolean;
/** Anthropic API key to inject via env (bypasses CLI auth) */
anthropicApiKey?: string;
}
const log = createLogger('client');
/**
* Detect rule index from numbered tag pattern [STEP_NAME:N].
@ -55,12 +34,10 @@ export function detectRuleIndex(content: string, stepName: string): number {
/** Validate regex pattern for ReDoS safety */
export function isRegexSafe(pattern: string): boolean {
// Limit pattern length
if (pattern.length > 200) {
return false;
}
// Dangerous patterns that can cause ReDoS
const dangerousPatterns = [
/\(\.\*\)\+/, // (.*)+
/\(\.\+\)\*/, // (.+)*
@ -79,219 +56,270 @@ export function isRegexSafe(pattern: string): boolean {
return true;
}
/** Determine status from result */
function determineStatus(
result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string },
): Status {
if (!result.success) {
if (result.interrupted) {
return 'interrupted';
/**
* High-level Claude client for calling Claude with various configurations.
*
* Handles agent prompts, custom agents, skills, and AI judge evaluation.
*/
export class ClaudeClient {
/** Determine status from execution result */
private static determineStatus(
result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string },
): Status {
if (!result.success) {
if (result.interrupted) {
return 'interrupted';
}
return 'blocked';
}
return 'blocked';
return 'done';
}
/** Convert ClaudeCallOptions to ClaudeSpawnOptions */
private static toSpawnOptions(options: ClaudeCallOptions): ClaudeSpawnOptions {
return {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
model: options.model,
maxTurns: options.maxTurns,
systemPrompt: options.systemPrompt,
agents: options.agents,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
};
}
/** Call Claude with an agent prompt */
async call(
agentType: string,
prompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
const spawnOptions = ClaudeClient.toSpawnOptions(options);
const result = await executeClaudeCli(prompt, spawnOptions);
const status = ClaudeClient.determineStatus(result);
if (!result.success && result.error) {
log.error('Agent query failed', { agent: agentType, error: result.error });
}
return {
agent: agentType,
status,
content: result.content,
timestamp: new Date(),
sessionId: result.sessionId,
error: result.error,
};
}
/** Call Claude with a custom agent configuration */
async callCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
const spawnOptions: ClaudeSpawnOptions = {
...ClaudeClient.toSpawnOptions(options),
systemPrompt,
};
const result = await executeClaudeCli(prompt, spawnOptions);
const status = ClaudeClient.determineStatus(result);
if (!result.success && result.error) {
log.error('Agent query failed', { agent: agentName, error: result.error });
}
return {
agent: agentName,
status,
content: result.content,
timestamp: new Date(),
sessionId: result.sessionId,
error: result.error,
};
}
/** Call a Claude Code built-in agent */
async callAgent(
claudeAgentName: string,
prompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
const systemPrompt = `You are the ${claudeAgentName} agent. Follow the standard ${claudeAgentName} workflow.`;
return this.callCustom(claudeAgentName, prompt, systemPrompt, options);
}
/** Call a Claude Code skill (using /skill command) */
async callSkill(
skillName: string,
prompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
const fullPrompt = `/${skillName}\n\n${prompt}`;
const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
model: options.model,
maxTurns: options.maxTurns,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
};
const result = await executeClaudeCli(fullPrompt, spawnOptions);
if (!result.success && result.error) {
log.error('Skill query failed', { skill: skillName, error: result.error });
}
return {
agent: `skill:${skillName}`,
status: result.success ? 'done' : 'blocked',
content: result.content,
timestamp: new Date(),
sessionId: result.sessionId,
error: result.error,
};
}
/**
* Detect judge rule index from [JUDGE:N] tag pattern.
* Returns 0-based rule index, or -1 if no match.
*/
static detectJudgeIndex(content: string): number {
const regex = /\[JUDGE:(\d+)\]/i;
const match = content.match(regex);
if (match?.[1]) {
const index = Number.parseInt(match[1], 10) - 1;
return index >= 0 ? index : -1;
}
return -1;
}
/**
* Build the prompt for the AI judge that evaluates agent output against ai() conditions.
*/
static buildJudgePrompt(
agentOutput: string,
aiConditions: { index: number; text: string }[],
): string {
const conditionList = aiConditions
.map((c) => `| ${c.index + 1} | ${c.text} |`)
.join('\n');
return [
'# Judge Task',
'',
'You are a judge evaluating an agent\'s output against a set of conditions.',
'Read the agent output below, then determine which condition best matches.',
'',
'## Agent Output',
'```',
agentOutput,
'```',
'',
'## Conditions',
'| # | Condition |',
'|---|-----------|',
conditionList,
'',
'## Instructions',
'Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition.',
'Do not output anything else.',
].join('\n');
}
/**
* Call AI judge to evaluate agent output against ai() conditions.
* Uses a lightweight model (haiku) for cost efficiency.
* Returns 0-based index of the matched ai() condition, or -1 if no match.
*/
async callAiJudge(
agentOutput: string,
aiConditions: { index: number; text: string }[],
options: { cwd: string },
): Promise<number> {
const prompt = ClaudeClient.buildJudgePrompt(agentOutput, aiConditions);
const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd,
model: 'haiku',
maxTurns: 1,
};
const result = await executeClaudeCli(prompt, spawnOptions);
if (!result.success) {
log.error('AI judge call failed', { error: result.error });
return -1;
}
return ClaudeClient.detectJudgeIndex(result.content);
}
return 'done';
}
/** Call Claude with an agent prompt */
// ---- Backward-compatible module-level functions ----
const defaultClient = new ClaudeClient();
export async function callClaude(
agentType: string,
prompt: string,
options: ClaudeCallOptions
options: ClaudeCallOptions,
): Promise<AgentResponse> {
const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
model: options.model,
maxTurns: options.maxTurns,
systemPrompt: options.systemPrompt,
agents: options.agents,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
};
const result = await executeClaudeCli(prompt, spawnOptions);
const status = determineStatus(result);
if (!result.success && result.error) {
log.error('Agent query failed', { agent: agentType, error: result.error });
}
return {
agent: agentType,
status,
content: result.content,
timestamp: new Date(),
sessionId: result.sessionId,
error: result.error,
};
return defaultClient.call(agentType, prompt, options);
}
/** Call Claude with a custom agent configuration */
export async function callClaudeCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: ClaudeCallOptions
options: ClaudeCallOptions,
): Promise<AgentResponse> {
const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
model: options.model,
maxTurns: options.maxTurns,
systemPrompt,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
};
const result = await executeClaudeCli(prompt, spawnOptions);
const status = determineStatus(result);
if (!result.success && result.error) {
log.error('Agent query failed', { agent: agentName, error: result.error });
}
return {
agent: agentName,
status,
content: result.content,
timestamp: new Date(),
sessionId: result.sessionId,
error: result.error,
};
return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
}
export async function callClaudeAgent(
claudeAgentName: string,
prompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
return defaultClient.callAgent(claudeAgentName, prompt, options);
}
export async function callClaudeSkill(
skillName: string,
prompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
return defaultClient.callSkill(skillName, prompt, options);
}
/**
* Detect judge rule index from [JUDGE:N] tag pattern.
* Returns 0-based rule index, or -1 if no match.
*/
export function detectJudgeIndex(content: string): number {
const regex = /\[JUDGE:(\d+)\]/i;
const match = content.match(regex);
if (match?.[1]) {
const index = Number.parseInt(match[1], 10) - 1;
return index >= 0 ? index : -1;
}
return -1;
return ClaudeClient.detectJudgeIndex(content);
}
/**
* Build the prompt for the AI judge that evaluates agent output against ai() conditions.
*/
export function buildJudgePrompt(
agentOutput: string,
aiConditions: { index: number; text: string }[],
): string {
const conditionList = aiConditions
.map((c) => `| ${c.index + 1} | ${c.text} |`)
.join('\n');
return [
'# Judge Task',
'',
'You are a judge evaluating an agent\'s output against a set of conditions.',
'Read the agent output below, then determine which condition best matches.',
'',
'## Agent Output',
'```',
agentOutput,
'```',
'',
'## Conditions',
'| # | Condition |',
'|---|-----------|',
conditionList,
'',
'## Instructions',
'Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition.',
'Do not output anything else.',
].join('\n');
return ClaudeClient.buildJudgePrompt(agentOutput, aiConditions);
}
/**
* Call AI judge to evaluate agent output against ai() conditions.
* Uses a lightweight model (haiku) for cost efficiency.
* Returns 0-based index of the matched ai() condition, or -1 if no match.
*/
export async function callAiJudge(
agentOutput: string,
aiConditions: { index: number; text: string }[],
options: { cwd: string },
): Promise<number> {
const prompt = buildJudgePrompt(agentOutput, aiConditions);
const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd,
model: 'haiku',
maxTurns: 1,
};
const result = await executeClaudeCli(prompt, spawnOptions);
if (!result.success) {
log.error('AI judge call failed', { error: result.error });
return -1;
}
return detectJudgeIndex(result.content);
}
/** Call a Claude Code built-in agent (using claude --agent flag if available) */
export async function callClaudeAgent(
claudeAgentName: string,
prompt: string,
options: ClaudeCallOptions
): Promise<AgentResponse> {
// For now, use system prompt approach
// In future, could use --agent flag if Claude CLI supports it
const systemPrompt = `You are the ${claudeAgentName} agent. Follow the standard ${claudeAgentName} workflow.`;
return callClaudeCustom(claudeAgentName, prompt, systemPrompt, options);
}
/** Call a Claude Code skill (using /skill command) */
export async function callClaudeSkill(
skillName: string,
prompt: string,
options: ClaudeCallOptions
): Promise<AgentResponse> {
// Prepend skill invocation to prompt
const fullPrompt = `/${skillName}\n\n${prompt}`;
const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
model: options.model,
maxTurns: options.maxTurns,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
};
const result = await executeClaudeCli(fullPrompt, spawnOptions);
if (!result.success && result.error) {
log.error('Skill query failed', { skill: skillName, error: result.error });
}
return {
agent: `skill:${skillName}`,
status: result.success ? 'done' : 'blocked',
content: result.content,
timestamp: new Date(),
sessionId: result.sessionId,
error: result.error,
};
return defaultClient.callAiJudge(agentOutput, aiConditions, options);
}

View File

@ -8,11 +8,8 @@
import {
query,
AbortError,
type Options,
type SDKResultMessage,
type SDKAssistantMessage,
type AgentDefinition,
type PermissionMode,
} from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '../utils/debug.js';
import { getErrorMessage } from '../utils/error.js';
@ -22,233 +19,168 @@ import {
unregisterQuery,
} from './query-manager.js';
import { sdkMessageToStreamEvent } from './stream-converter.js';
import {
createCanUseToolCallback,
createAskUserQuestionHooks,
} from './options-builder.js';
import { SdkOptionsBuilder } from './options-builder.js';
import type {
StreamCallback,
PermissionHandler,
AskUserQuestionHandler,
ClaudeSpawnOptions,
ClaudeResult,
} from './types.js';
const log = createLogger('claude-sdk');
/** Options for executing Claude queries */
export interface ExecuteOptions {
cwd: string;
sessionId?: string;
allowedTools?: string[];
model?: string;
maxTurns?: number;
systemPrompt?: string;
onStream?: StreamCallback;
agents?: Record<string, AgentDefinition>;
permissionMode?: PermissionMode;
onPermissionRequest?: PermissionHandler;
onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */
bypassPermissions?: boolean;
/** Anthropic API key to inject via env (bypasses CLI auth) */
anthropicApiKey?: string;
}
/**
* Build SDK options from ExecuteOptions.
* Executes Claude queries using the Agent SDK.
*
* Handles query lifecycle (register/unregister), streaming,
* assistant text accumulation, and error classification.
*/
function buildSdkOptions(options: ExecuteOptions): Options {
const canUseTool = options.onPermissionRequest
? createCanUseToolCallback(options.onPermissionRequest)
: undefined;
export class QueryExecutor {
/**
* Execute a Claude query.
*/
async execute(
prompt: string,
options: ClaudeSpawnOptions,
): Promise<ClaudeResult> {
const queryId = generateQueryId();
const hooks = options.onAskUserQuestion
? createAskUserQuestionHooks(options.onAskUserQuestion)
: undefined;
log.debug('Executing Claude query via SDK', {
queryId,
cwd: options.cwd,
model: options.model,
hasSystemPrompt: !!options.systemPrompt,
allowedTools: options.allowedTools,
});
// Determine permission mode
// Priority: bypassPermissions > explicit permissionMode > callback-based default
let permissionMode: PermissionMode;
if (options.bypassPermissions) {
permissionMode = 'bypassPermissions';
} else if (options.permissionMode) {
permissionMode = options.permissionMode;
} else if (options.onPermissionRequest) {
permissionMode = 'default';
} else {
permissionMode = 'acceptEdits';
}
const sdkOptions = new SdkOptionsBuilder(options).build();
// Only include defined values — the SDK treats key-present-but-undefined
// differently from key-absent for some options (e.g. model), causing hangs.
const sdkOptions: Options = {
cwd: options.cwd,
permissionMode,
};
let sessionId: string | undefined;
let success = false;
let resultContent: string | undefined;
let hasResultMessage = false;
let accumulatedAssistantText = '';
if (options.model) sdkOptions.model = options.model;
if (options.maxTurns != null) sdkOptions.maxTurns = options.maxTurns;
if (options.allowedTools) sdkOptions.allowedTools = options.allowedTools;
if (options.agents) sdkOptions.agents = options.agents;
if (options.systemPrompt) sdkOptions.systemPrompt = options.systemPrompt;
if (canUseTool) sdkOptions.canUseTool = canUseTool;
if (hooks) sdkOptions.hooks = hooks;
try {
const q = query({ prompt, options: sdkOptions });
registerQuery(queryId, q);
if (options.anthropicApiKey) {
sdkOptions.env = {
...process.env as Record<string, string>,
ANTHROPIC_API_KEY: options.anthropicApiKey,
};
}
for await (const message of q) {
if ('session_id' in message) {
sessionId = message.session_id;
}
if (options.onStream) {
sdkOptions.includePartialMessages = true;
}
if (options.onStream) {
sdkMessageToStreamEvent(message, options.onStream, true);
}
if (options.sessionId) {
sdkOptions.resume = options.sessionId;
} else {
sdkOptions.continue = false;
}
if (message.type === 'assistant') {
const assistantMsg = message as SDKAssistantMessage;
for (const block of assistantMsg.message.content) {
if (block.type === 'text') {
accumulatedAssistantText += block.text;
}
}
}
return sdkOptions;
}
/**
* Execute a Claude query using the Agent SDK.
*/
export async function executeClaudeQuery(
prompt: string,
options: ExecuteOptions
): Promise<ClaudeResult> {
const queryId = generateQueryId();
log.debug('Executing Claude query via SDK', {
queryId,
cwd: options.cwd,
model: options.model,
hasSystemPrompt: !!options.systemPrompt,
allowedTools: options.allowedTools,
});
const sdkOptions = buildSdkOptions(options);
let sessionId: string | undefined;
let success = false;
let resultContent: string | undefined;
let hasResultMessage = false;
let accumulatedAssistantText = '';
try {
const q = query({ prompt, options: sdkOptions });
registerQuery(queryId, q);
for await (const message of q) {
if ('session_id' in message) {
sessionId = message.session_id;
}
if (options.onStream) {
sdkMessageToStreamEvent(message, options.onStream, true);
}
if (message.type === 'assistant') {
const assistantMsg = message as SDKAssistantMessage;
for (const block of assistantMsg.message.content) {
if (block.type === 'text') {
accumulatedAssistantText += block.text;
if (message.type === 'result') {
hasResultMessage = true;
const resultMsg = message as SDKResultMessage;
if (resultMsg.subtype === 'success') {
resultContent = resultMsg.result;
success = true;
} else {
success = false;
if (resultMsg.errors && resultMsg.errors.length > 0) {
resultContent = resultMsg.errors.join('\n');
}
}
}
}
if (message.type === 'result') {
hasResultMessage = true;
const resultMsg = message as SDKResultMessage;
if (resultMsg.subtype === 'success') {
resultContent = resultMsg.result;
success = true;
} else {
success = false;
if (resultMsg.errors && resultMsg.errors.length > 0) {
resultContent = resultMsg.errors.join('\n');
}
}
}
unregisterQuery(queryId);
const finalContent = resultContent || accumulatedAssistantText;
log.info('Claude query completed', {
queryId,
sessionId,
contentLength: finalContent.length,
success,
hasResultMessage,
});
return {
success,
content: finalContent.trim(),
sessionId,
fullContent: accumulatedAssistantText.trim(),
};
} catch (error) {
unregisterQuery(queryId);
return QueryExecutor.handleQueryError(error, queryId, sessionId, hasResultMessage, success, resultContent);
}
}
/**
* Handle query execution errors.
* Classifies errors (abort, rate limit, auth, timeout) and returns appropriate ClaudeResult.
*/
private static handleQueryError(
error: unknown,
queryId: string,
sessionId: string | undefined,
hasResultMessage: boolean,
success: boolean,
resultContent: string | undefined,
): ClaudeResult {
if (error instanceof AbortError) {
log.info('Claude query was interrupted', { queryId });
return {
success: false,
content: '',
error: 'Query interrupted',
interrupted: true,
};
}
unregisterQuery(queryId);
const errorMessage = getErrorMessage(error);
const finalContent = resultContent || accumulatedAssistantText;
if (hasResultMessage && success) {
log.info('Claude query completed with post-completion error (ignoring)', {
queryId,
sessionId,
error: errorMessage,
});
return {
success: true,
content: (resultContent ?? '').trim(),
sessionId,
};
}
log.info('Claude query completed', {
queryId,
sessionId,
contentLength: finalContent.length,
success,
hasResultMessage,
});
log.error('Claude query failed', { queryId, error: errorMessage });
return {
success,
content: finalContent.trim(),
sessionId,
fullContent: accumulatedAssistantText.trim(),
};
} catch (error) {
unregisterQuery(queryId);
return handleQueryError(error, queryId, sessionId, hasResultMessage, success, resultContent);
if (errorMessage.includes('rate_limit') || errorMessage.includes('rate limit')) {
return { success: false, content: '', error: 'Rate limit exceeded. Please try again later.' };
}
if (errorMessage.includes('authentication') || errorMessage.includes('unauthorized')) {
return { success: false, content: '', error: 'Authentication failed. Please check your API credentials.' };
}
if (errorMessage.includes('timeout')) {
return { success: false, content: '', error: 'Request timed out. Please try again.' };
}
return { success: false, content: '', error: errorMessage };
}
}
/**
* Handle query execution errors.
*/
function handleQueryError(
error: unknown,
queryId: string,
sessionId: string | undefined,
hasResultMessage: boolean,
success: boolean,
resultContent: string | undefined
): ClaudeResult {
if (error instanceof AbortError) {
log.info('Claude query was interrupted', { queryId });
return {
success: false,
content: '',
error: 'Query interrupted',
interrupted: true,
};
}
// ---- Backward-compatible module-level function ----
const errorMessage = getErrorMessage(error);
if (hasResultMessage && success) {
log.info('Claude query completed with post-completion error (ignoring)', {
queryId,
sessionId,
error: errorMessage,
});
return {
success: true,
content: (resultContent ?? '').trim(),
sessionId,
};
}
log.error('Claude query failed', { queryId, error: errorMessage });
if (errorMessage.includes('rate_limit') || errorMessage.includes('rate limit')) {
return { success: false, content: '', error: 'Rate limit exceeded. Please try again later.' };
}
if (errorMessage.includes('authentication') || errorMessage.includes('unauthorized')) {
return { success: false, content: '', error: 'Authentication failed. Please check your API credentials.' };
}
if (errorMessage.includes('timeout')) {
return { success: false, content: '', error: 'Request timed out. Please try again.' };
}
return { success: false, content: '', error: errorMessage };
/** @deprecated Use QueryExecutor.execute() instead */
export async function executeClaudeQuery(
prompt: string,
options: ClaudeSpawnOptions,
): Promise<ClaudeResult> {
return new QueryExecutor().execute(prompt, options);
}

View File

@ -5,11 +5,18 @@
* from the Claude integration module.
*/
// Main process and execution
export { ClaudeProcess, executeClaudeCli, type ClaudeSpawnOptions } from './process.js';
export { executeClaudeQuery, type ExecuteOptions } from './executor.js';
// Classes
export { ClaudeClient } from './client.js';
export { ClaudeProcess } from './process.js';
export { QueryExecutor } from './executor.js';
export { QueryRegistry } from './query-manager.js';
export { SdkOptionsBuilder } from './options-builder.js';
// Query management (only from query-manager, process.ts re-exports these)
// Main process and execution
export { executeClaudeCli } from './process.js';
export { executeClaudeQuery } from './executor.js';
// Query management
export {
generateQueryId,
hasActiveProcess,
@ -22,7 +29,7 @@ export {
interruptCurrentProcess,
} from './query-manager.js';
// Types (only from types.ts, avoiding duplicates from process.ts)
// Types
export type {
StreamEvent,
StreamCallback,
@ -32,6 +39,8 @@ export type {
AskUserQuestionHandler,
ClaudeResult,
ClaudeResultWithQueryId,
ClaudeCallOptions,
ClaudeSpawnOptions,
InitEventData,
ToolUseEventData,
ToolResultEventData,
@ -45,19 +54,22 @@ export type {
// Stream conversion
export { sdkMessageToStreamEvent } from './stream-converter.js';
// Options building
// Options building (backward-compatible functions)
export {
createCanUseToolCallback,
createAskUserQuestionHooks,
buildSdkOptions,
} from './options-builder.js';
// Client functions and types
// Client functions (backward-compatible)
export {
callClaude,
callClaudeCustom,
callClaudeAgent,
callClaudeSkill,
callAiJudge,
detectRuleIndex,
detectJudgeIndex,
buildJudgePrompt,
isRegexSafe,
type ClaudeCallOptions,
} from './client.js';

View File

@ -14,7 +14,6 @@ import type {
HookInput,
HookJSONOutput,
PreToolUseHookInput,
AgentDefinition,
PermissionMode,
} from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '../utils/debug.js';
@ -22,131 +21,165 @@ import type {
PermissionHandler,
AskUserQuestionInput,
AskUserQuestionHandler,
ClaudeSpawnOptions,
} from './types.js';
const log = createLogger('claude-sdk');
/** Options for calling Claude via SDK */
export interface ClaudeSpawnOptions {
cwd: string;
sessionId?: string;
allowedTools?: string[];
model?: string;
maxTurns?: number;
systemPrompt?: string;
/** Enable streaming mode */
hasStream?: boolean;
/** Custom agents to register */
agents?: Record<string, AgentDefinition>;
/** Permission mode for tool execution */
permissionMode?: PermissionMode;
/** Custom permission handler for interactive permission prompts */
onPermissionRequest?: PermissionHandler;
/** Custom handler for AskUserQuestion tool */
onAskUserQuestion?: AskUserQuestionHandler;
/**
* Builds SDK options from ClaudeSpawnOptions.
*
* Handles permission mode resolution, canUseTool callback creation,
* and AskUserQuestion hook setup.
*/
export class SdkOptionsBuilder {
private readonly options: ClaudeSpawnOptions;
constructor(options: ClaudeSpawnOptions) {
this.options = options;
}
/** Build the full SDK Options object */
build(): Options {
const canUseTool = this.options.onPermissionRequest
? SdkOptionsBuilder.createCanUseToolCallback(this.options.onPermissionRequest)
: undefined;
const hooks = this.options.onAskUserQuestion
? SdkOptionsBuilder.createAskUserQuestionHooks(this.options.onAskUserQuestion)
: undefined;
const permissionMode = this.resolvePermissionMode();
// Only include defined values — the SDK treats key-present-but-undefined
// differently from key-absent for some options (e.g. model), causing hangs.
const sdkOptions: Options = {
cwd: this.options.cwd,
permissionMode,
};
if (this.options.model) sdkOptions.model = this.options.model;
if (this.options.maxTurns != null) sdkOptions.maxTurns = this.options.maxTurns;
if (this.options.allowedTools) sdkOptions.allowedTools = this.options.allowedTools;
if (this.options.agents) sdkOptions.agents = this.options.agents;
if (this.options.systemPrompt) sdkOptions.systemPrompt = this.options.systemPrompt;
if (canUseTool) sdkOptions.canUseTool = canUseTool;
if (hooks) sdkOptions.hooks = hooks;
if (this.options.anthropicApiKey) {
sdkOptions.env = {
...process.env as Record<string, string>,
ANTHROPIC_API_KEY: this.options.anthropicApiKey,
};
}
if (this.options.onStream) {
sdkOptions.includePartialMessages = true;
}
if (this.options.sessionId) {
sdkOptions.resume = this.options.sessionId;
} else {
sdkOptions.continue = false;
}
return sdkOptions;
}
/** Resolve permission mode with priority: bypassPermissions > explicit > callback-based > default */
private resolvePermissionMode(): PermissionMode {
if (this.options.bypassPermissions) {
return 'bypassPermissions';
}
if (this.options.permissionMode) {
return this.options.permissionMode;
}
if (this.options.onPermissionRequest) {
return 'default';
}
return 'acceptEdits';
}
/**
* Create canUseTool callback from permission handler.
*/
static createCanUseToolCallback(
handler: PermissionHandler
): CanUseTool {
return async (
toolName: string,
input: Record<string, unknown>,
callbackOptions: {
signal: AbortSignal;
suggestions?: PermissionUpdate[];
blockedPath?: string;
decisionReason?: string;
}
): Promise<PermissionResult> => {
return handler({
toolName,
input,
suggestions: callbackOptions.suggestions,
blockedPath: callbackOptions.blockedPath,
decisionReason: callbackOptions.decisionReason,
});
};
}
/**
* Create hooks for AskUserQuestion handling.
*/
static createAskUserQuestionHooks(
askUserHandler: AskUserQuestionHandler
): Partial<Record<string, HookCallbackMatcher[]>> {
const preToolUseHook = async (
input: HookInput,
_toolUseID: string | undefined,
_options: { signal: AbortSignal }
): Promise<HookJSONOutput> => {
const preToolInput = input as PreToolUseHookInput;
if (preToolInput.tool_name === 'AskUserQuestion') {
const toolInput = preToolInput.tool_input as AskUserQuestionInput;
try {
const answers = await askUserHandler(toolInput);
return {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: JSON.stringify(answers),
},
};
} catch (err) {
log.error('AskUserQuestion handler failed', { error: err });
return { continue: true };
}
}
return { continue: true };
};
return {
PreToolUse: [{
matcher: 'AskUserQuestion',
hooks: [preToolUseHook],
}],
};
}
}
/**
* Create canUseTool callback from permission handler.
*/
// ---- Backward-compatible module-level functions ----
export function createCanUseToolCallback(
handler: PermissionHandler
): CanUseTool {
return async (
toolName: string,
input: Record<string, unknown>,
callbackOptions: {
signal: AbortSignal;
suggestions?: PermissionUpdate[];
blockedPath?: string;
decisionReason?: string;
}
): Promise<PermissionResult> => {
return handler({
toolName,
input,
suggestions: callbackOptions.suggestions,
blockedPath: callbackOptions.blockedPath,
decisionReason: callbackOptions.decisionReason,
});
};
return SdkOptionsBuilder.createCanUseToolCallback(handler);
}
/**
* Create hooks for AskUserQuestion handling.
*/
export function createAskUserQuestionHooks(
askUserHandler: AskUserQuestionHandler
): Partial<Record<string, HookCallbackMatcher[]>> {
const preToolUseHook = async (
input: HookInput,
_toolUseID: string | undefined,
_options: { signal: AbortSignal }
): Promise<HookJSONOutput> => {
const preToolInput = input as PreToolUseHookInput;
if (preToolInput.tool_name === 'AskUserQuestion') {
const toolInput = preToolInput.tool_input as AskUserQuestionInput;
try {
const answers = await askUserHandler(toolInput);
return {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: JSON.stringify(answers),
},
};
} catch (err) {
log.error('AskUserQuestion handler failed', { error: err });
return { continue: true };
}
}
return { continue: true };
};
return {
PreToolUse: [{
matcher: 'AskUserQuestion',
hooks: [preToolUseHook],
}],
};
return SdkOptionsBuilder.createAskUserQuestionHooks(askUserHandler);
}
/**
* Build SDK options from ClaudeSpawnOptions.
*/
export function buildSdkOptions(options: ClaudeSpawnOptions): Options {
// Create canUseTool callback if permission handler is provided
const canUseTool = options.onPermissionRequest
? createCanUseToolCallback(options.onPermissionRequest)
: undefined;
// Create hooks for AskUserQuestion handling
const hooks = options.onAskUserQuestion
? createAskUserQuestionHooks(options.onAskUserQuestion)
: undefined;
const sdkOptions: Options = {
cwd: options.cwd,
model: options.model,
maxTurns: options.maxTurns,
allowedTools: options.allowedTools,
agents: options.agents,
permissionMode: options.permissionMode ?? (options.onPermissionRequest ? 'default' : 'acceptEdits'),
includePartialMessages: options.hasStream,
canUseTool,
hooks,
};
if (options.systemPrompt) {
sdkOptions.systemPrompt = options.systemPrompt;
}
// Session management
if (options.sessionId) {
sdkOptions.resume = options.sessionId;
} else {
sdkOptions.continue = false;
}
return sdkOptions;
return new SdkOptionsBuilder(options).build();
}

View File

@ -5,16 +5,13 @@
* instead of spawning CLI processes.
*/
import type { AgentDefinition, PermissionMode } from '@anthropic-ai/claude-agent-sdk';
import {
hasActiveProcess,
interruptCurrentProcess,
} from './query-manager.js';
import { executeClaudeQuery } from './executor.js';
import type {
StreamCallback,
PermissionHandler,
AskUserQuestionHandler,
ClaudeSpawnOptions,
ClaudeResult,
} from './types.js';
@ -28,6 +25,7 @@ export type {
AskUserQuestionHandler,
ClaudeResult,
ClaudeResultWithQueryId,
ClaudeSpawnOptions,
InitEventData,
ToolUseEventData,
ToolResultEventData,
@ -49,37 +47,13 @@ export {
interruptCurrentProcess,
} from './query-manager.js';
/** Options for calling Claude via SDK */
export interface ClaudeSpawnOptions {
cwd: string;
sessionId?: string;
allowedTools?: string[];
model?: string;
maxTurns?: number;
systemPrompt?: string;
/** Enable streaming mode with callback */
onStream?: StreamCallback;
/** Custom agents to register */
agents?: Record<string, AgentDefinition>;
/** Permission mode for tool execution (default: 'default' for interactive) */
permissionMode?: PermissionMode;
/** Custom permission handler for interactive permission prompts */
onPermissionRequest?: PermissionHandler;
/** Custom handler for AskUserQuestion tool */
onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */
bypassPermissions?: boolean;
/** Anthropic API key to inject via env (bypasses CLI auth) */
anthropicApiKey?: string;
}
/**
* Execute a Claude query using the Agent SDK.
* Supports concurrent execution with query ID tracking.
*/
export async function executeClaudeCli(
prompt: string,
options: ClaudeSpawnOptions
options: ClaudeSpawnOptions,
): Promise<ClaudeResult> {
return executeClaudeQuery(prompt, options);
}

View File

@ -3,83 +3,134 @@
*
* Handles tracking and lifecycle management of active Claude queries.
* Supports concurrent query execution with interrupt capabilities.
*
* QueryRegistry is a singleton that encapsulates the global activeQueries Map.
*/
import type { Query } from '@anthropic-ai/claude-agent-sdk';
/**
* Active query registry for interrupt support.
* Uses a Map to support concurrent query execution.
* Registry for tracking active Claude queries.
* Singleton use QueryRegistry.getInstance().
*/
const activeQueries = new Map<string, Query>();
export class QueryRegistry {
private static instance: QueryRegistry | null = null;
private readonly activeQueries = new Map<string, Query>();
private constructor() {}
static getInstance(): QueryRegistry {
if (!QueryRegistry.instance) {
QueryRegistry.instance = new QueryRegistry();
}
return QueryRegistry.instance;
}
/** Reset singleton for testing */
static resetInstance(): void {
QueryRegistry.instance = null;
}
/** Check if there is an active Claude process */
hasActiveProcess(): boolean {
return this.activeQueries.size > 0;
}
/** Check if a specific query is active */
isQueryActive(queryId: string): boolean {
return this.activeQueries.has(queryId);
}
/** Get count of active queries */
getActiveQueryCount(): number {
return this.activeQueries.size;
}
/** Register an active query */
registerQuery(queryId: string, queryInstance: Query): void {
this.activeQueries.set(queryId, queryInstance);
}
/** Unregister an active query */
unregisterQuery(queryId: string): void {
this.activeQueries.delete(queryId);
}
/**
* Interrupt a specific Claude query by ID.
* @returns true if the query was interrupted, false if not found
*/
interruptQuery(queryId: string): boolean {
const queryInstance = this.activeQueries.get(queryId);
if (queryInstance) {
queryInstance.interrupt();
this.activeQueries.delete(queryId);
return true;
}
return false;
}
/**
* Interrupt all active Claude queries.
* @returns number of queries that were interrupted
*/
interruptAllQueries(): number {
const count = this.activeQueries.size;
for (const [id, queryInstance] of this.activeQueries) {
queryInstance.interrupt();
this.activeQueries.delete(id);
}
return count;
}
/**
* Interrupt the most recently started Claude query (backward compatibility).
* @returns true if a query was interrupted, false if no query was running
*/
interruptCurrentProcess(): boolean {
if (this.activeQueries.size === 0) {
return false;
}
this.interruptAllQueries();
return true;
}
}
// ---- Backward-compatible module-level functions ----
/** Generate a unique query ID */
export function generateQueryId(): string {
return `q-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
/** Check if there is an active Claude process */
export function hasActiveProcess(): boolean {
return activeQueries.size > 0;
return QueryRegistry.getInstance().hasActiveProcess();
}
/** Check if a specific query is active */
export function isQueryActive(queryId: string): boolean {
return activeQueries.has(queryId);
return QueryRegistry.getInstance().isQueryActive(queryId);
}
/** Get count of active queries */
export function getActiveQueryCount(): number {
return activeQueries.size;
return QueryRegistry.getInstance().getActiveQueryCount();
}
/** Register an active query */
export function registerQuery(queryId: string, queryInstance: Query): void {
activeQueries.set(queryId, queryInstance);
QueryRegistry.getInstance().registerQuery(queryId, queryInstance);
}
/** Unregister an active query */
export function unregisterQuery(queryId: string): void {
activeQueries.delete(queryId);
QueryRegistry.getInstance().unregisterQuery(queryId);
}
/**
* Interrupt a specific Claude query by ID.
* @returns true if the query was interrupted, false if not found
*/
export function interruptQuery(queryId: string): boolean {
const queryInstance = activeQueries.get(queryId);
if (queryInstance) {
queryInstance.interrupt();
activeQueries.delete(queryId);
return true;
}
return false;
return QueryRegistry.getInstance().interruptQuery(queryId);
}
/**
* Interrupt all active Claude queries.
* @returns number of queries that were interrupted
*/
export function interruptAllQueries(): number {
const count = activeQueries.size;
for (const [id, queryInstance] of activeQueries) {
queryInstance.interrupt();
activeQueries.delete(id);
}
return count;
return QueryRegistry.getInstance().interruptAllQueries();
}
/**
* Interrupt the most recently started Claude query (backward compatibility).
* @returns true if a query was interrupted, false if no query was running
*/
export function interruptCurrentProcess(): boolean {
if (activeQueries.size === 0) {
return false;
}
// Interrupt all queries for backward compatibility
// In the old design, there was only one query
interruptAllQueries();
return true;
return QueryRegistry.getInstance().interruptCurrentProcess();
}

View File

@ -5,7 +5,8 @@
* used throughout the Claude integration layer.
*/
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
import type { PermissionResult, PermissionUpdate, AgentDefinition, PermissionMode as SdkPermissionMode } from '@anthropic-ai/claude-agent-sdk';
import type { PermissionMode } from '../models/types.js';
// Re-export PermissionResult for convenience
export type { PermissionResult, PermissionUpdate };
@ -113,3 +114,51 @@ export interface ClaudeResult {
export interface ClaudeResultWithQueryId extends ClaudeResult {
queryId: string;
}
/** Options for calling Claude (high-level, used by client/providers/agents) */
export interface ClaudeCallOptions {
cwd: string;
sessionId?: string;
allowedTools?: string[];
model?: string;
maxTurns?: number;
systemPrompt?: string;
/** SDK agents to register for sub-agent execution */
agents?: Record<string, AgentDefinition>;
/** Permission mode for tool execution (from workflow step) */
permissionMode?: PermissionMode;
/** Enable streaming mode with callback for real-time output */
onStream?: StreamCallback;
/** Custom permission handler for interactive permission prompts */
onPermissionRequest?: PermissionHandler;
/** Custom handler for AskUserQuestion tool */
onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */
bypassPermissions?: boolean;
/** Anthropic API key to inject via env (bypasses CLI auth) */
anthropicApiKey?: string;
}
/** Options for spawning a Claude SDK query (low-level, used by executor/process) */
export interface ClaudeSpawnOptions {
cwd: string;
sessionId?: string;
allowedTools?: string[];
model?: string;
maxTurns?: number;
systemPrompt?: string;
/** Enable streaming mode with callback */
onStream?: StreamCallback;
/** Custom agents to register */
agents?: Record<string, AgentDefinition>;
/** Permission mode for tool execution (default: 'default' for interactive) */
permissionMode?: SdkPermissionMode;
/** Custom permission handler for interactive permission prompts */
onPermissionRequest?: PermissionHandler;
/** Custom handler for AskUserQuestion tool */
onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */
bypassPermissions?: boolean;
/** Anthropic API key to inject via env (bypasses CLI auth) */
anthropicApiKey?: string;
}

View File

@ -48,8 +48,8 @@ import { resolveIssueTask, isIssueReference } from './github/issue.js';
import {
selectAndExecuteTask,
type SelectAndExecuteOptions,
} from './commands/selectAndExecute.js';
import type { TaskExecutionOptions } from './commands/taskExecution.js';
} from './commands/execution/selectAndExecute.js';
import type { TaskExecutionOptions } from './commands/execution/taskExecution.js';
import type { ProviderType } from './providers/index.js';
const require = createRequire(import.meta.url);

View File

@ -6,106 +6,16 @@
import { Codex } from '@openai/codex-sdk';
import type { AgentResponse, Status } from '../models/types.js';
import type { StreamCallback } from '../claude/process.js';
import type { StreamCallback } from '../claude/types.js';
import { createLogger } from '../utils/debug.js';
import { getErrorMessage } from '../utils/error.js';
import type { CodexCallOptions } from './types.js';
// Re-export for backward compatibility
export type { CodexCallOptions } from './types.js';
const log = createLogger('codex-sdk');
/** Options for calling Codex */
export interface CodexCallOptions {
cwd: string;
sessionId?: string;
model?: string;
systemPrompt?: string;
/** Enable streaming mode with callback (best-effort) */
onStream?: StreamCallback;
/** OpenAI API key (bypasses CLI auth) */
openaiApiKey?: string;
}
function extractThreadId(value: unknown): string | undefined {
if (!value || typeof value !== 'object') return undefined;
const record = value as Record<string, unknown>;
const id = record.id ?? record.thread_id ?? record.threadId;
return typeof id === 'string' ? id : undefined;
}
function emitInit(
onStream: StreamCallback | undefined,
model: string | undefined,
sessionId: string | undefined
): void {
if (!onStream) return;
onStream({
type: 'init',
data: {
model: model || 'codex',
sessionId: sessionId || 'unknown',
},
});
}
function emitText(onStream: StreamCallback | undefined, text: string): void {
if (!onStream || !text) return;
onStream({ type: 'text', data: { text } });
}
function emitThinking(onStream: StreamCallback | undefined, thinking: string): void {
if (!onStream || !thinking) return;
onStream({ type: 'thinking', data: { thinking } });
}
function emitToolUse(
onStream: StreamCallback | undefined,
tool: string,
input: Record<string, unknown>,
id: string
): void {
if (!onStream) return;
onStream({ type: 'tool_use', data: { tool, input, id } });
}
function emitToolResult(
onStream: StreamCallback | undefined,
content: string,
isError: boolean
): void {
if (!onStream) return;
onStream({ type: 'tool_result', data: { content, isError } });
}
function emitToolOutput(
onStream: StreamCallback | undefined,
tool: string,
output: string
): void {
if (!onStream || !output) return;
onStream({ type: 'tool_output', data: { tool, output } });
}
function emitResult(
onStream: StreamCallback | undefined,
success: boolean,
result: string,
sessionId: string | undefined
): void {
if (!onStream) return;
onStream({
type: 'result',
data: {
result,
sessionId: sessionId || 'unknown',
success,
error: success ? undefined : result || undefined,
},
});
}
function determineStatus(success: boolean): Status {
return success ? 'done' : 'blocked';
}
type CodexEvent = {
type: string;
[key: string]: unknown;
@ -117,354 +27,454 @@ type CodexItem = {
[key: string]: unknown;
};
function formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }>): string {
if (!changes.length) return '';
return changes
.map((change) => {
const kind = change.kind ? `${change.kind}: ` : '';
return `${kind}${change.path ?? ''}`.trim();
})
.filter(Boolean)
.join('\n');
}
function emitCodexItemStart(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
if (startedItems.has(id)) return;
switch (item.type) {
case 'command_execution': {
const command = typeof item.command === 'string' ? item.command : '';
emitToolUse(onStream, 'Bash', { command }, id);
startedItems.add(id);
break;
}
case 'mcp_tool_call': {
const tool = typeof item.tool === 'string' ? item.tool : 'Tool';
const args = (item.arguments ?? {}) as Record<string, unknown>;
emitToolUse(onStream, tool, args, id);
startedItems.add(id);
break;
}
case 'web_search': {
const query = typeof item.query === 'string' ? item.query : '';
emitToolUse(onStream, 'WebSearch', { query }, id);
startedItems.add(id);
break;
}
case 'file_change': {
const changes = Array.isArray(item.changes) ? item.changes : [];
const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id);
startedItems.add(id);
break;
}
default:
break;
}
}
function emitCodexItemCompleted(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>,
outputOffsets: Map<string, number>,
textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number>
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
switch (item.type) {
case 'reasoning': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = thinkingOffsets.get(id) ?? 0;
if (text.length > prev) {
emitThinking(onStream, text.slice(prev) + '\n');
thinkingOffsets.set(id, text.length);
}
}
break;
}
case 'agent_message': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = textOffsets.get(id) ?? 0;
if (text.length > prev) {
emitText(onStream, text.slice(prev));
textOffsets.set(id, text.length);
}
}
break;
}
case 'command_execution': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
if (output) {
const prev = outputOffsets.get(id) ?? 0;
if (output.length > prev) {
emitToolOutput(onStream, 'Bash', output.slice(prev));
outputOffsets.set(id, output.length);
}
}
const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined;
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed' || (exitCode !== undefined && exitCode !== 0);
const content = output || (exitCode !== undefined ? `Exit code: ${exitCode}` : '');
emitToolResult(onStream, content, isError);
break;
}
case 'mcp_tool_call': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed' || !!item.error;
const errorMessage =
item.error && typeof item.error === 'object' && 'message' in item.error
? String((item.error as { message?: unknown }).message ?? '')
: '';
let content = errorMessage;
if (!content && item.result && typeof item.result === 'object') {
try {
content = JSON.stringify(item.result);
} catch {
content = '';
}
}
emitToolResult(onStream, content, isError);
break;
}
case 'web_search': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
emitToolResult(onStream, 'Search completed', false);
break;
}
case 'file_change': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed';
const changes = Array.isArray(item.changes) ? item.changes : [];
const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
emitToolResult(onStream, summary || 'Applied patch', isError);
break;
}
default:
break;
}
}
function emitCodexItemUpdate(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>,
outputOffsets: Map<string, number>,
textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number>
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
switch (item.type) {
case 'command_execution': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
if (output) {
const prev = outputOffsets.get(id) ?? 0;
if (output.length > prev) {
emitToolOutput(onStream, 'Bash', output.slice(prev));
outputOffsets.set(id, output.length);
}
}
break;
}
case 'agent_message': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = textOffsets.get(id) ?? 0;
if (text.length > prev) {
emitText(onStream, text.slice(prev));
textOffsets.set(id, text.length);
}
}
break;
}
case 'reasoning': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = thinkingOffsets.get(id) ?? 0;
if (text.length > prev) {
emitThinking(onStream, text.slice(prev));
thinkingOffsets.set(id, text.length);
}
}
break;
}
case 'file_change': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
break;
}
case 'mcp_tool_call': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
break;
}
case 'web_search': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
break;
}
default:
break;
}
}
/**
* Call Codex with an agent prompt.
* Client for Codex SDK agent interactions.
*
* Handles thread management, streaming event conversion,
* and response processing.
*/
export async function callCodex(
agentType: string,
prompt: string,
options: CodexCallOptions
): Promise<AgentResponse> {
const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined);
const threadOptions = {
model: options.model,
workingDirectory: options.cwd,
};
const thread = options.sessionId
? await codex.resumeThread(options.sessionId, threadOptions)
: await codex.startThread(threadOptions);
let threadId = extractThreadId(thread) || options.sessionId;
export class CodexClient {
// ---- Stream emission helpers (private) ----
const fullPrompt = options.systemPrompt
? `${options.systemPrompt}\n\n${prompt}`
: prompt;
private static extractThreadId(value: unknown): string | undefined {
if (!value || typeof value !== 'object') return undefined;
const record = value as Record<string, unknown>;
const id = record.id ?? record.thread_id ?? record.threadId;
return typeof id === 'string' ? id : undefined;
}
try {
log.debug('Executing Codex thread', {
agentType,
model: options.model,
hasSystemPrompt: !!options.systemPrompt,
private static emitInit(
onStream: StreamCallback | undefined,
model: string | undefined,
sessionId: string | undefined,
): void {
if (!onStream) return;
onStream({
type: 'init',
data: {
model: model || 'codex',
sessionId: sessionId || 'unknown',
},
});
}
const { events } = await thread.runStreamed(fullPrompt);
let content = '';
const contentOffsets = new Map<string, number>();
let success = true;
let failureMessage = '';
const startedItems = new Set<string>();
const outputOffsets = new Map<string, number>();
const textOffsets = new Map<string, number>();
const thinkingOffsets = new Map<string, number>();
private static emitText(onStream: StreamCallback | undefined, text: string): void {
if (!onStream || !text) return;
onStream({ type: 'text', data: { text } });
}
for await (const event of events as AsyncGenerator<CodexEvent>) {
if (event.type === 'thread.started') {
threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId;
emitInit(options.onStream, options.model, threadId);
continue;
}
private static emitThinking(onStream: StreamCallback | undefined, thinking: string): void {
if (!onStream || !thinking) return;
onStream({ type: 'thinking', data: { thinking } });
}
if (event.type === 'turn.failed') {
success = false;
if (event.error && typeof event.error === 'object' && 'message' in event.error) {
failureMessage = String((event.error as { message?: unknown }).message ?? '');
}
private static emitToolUse(
onStream: StreamCallback | undefined,
tool: string,
input: Record<string, unknown>,
id: string,
): void {
if (!onStream) return;
onStream({ type: 'tool_use', data: { tool, input, id } });
}
private static emitToolResult(
onStream: StreamCallback | undefined,
content: string,
isError: boolean,
): void {
if (!onStream) return;
onStream({ type: 'tool_result', data: { content, isError } });
}
private static emitToolOutput(
onStream: StreamCallback | undefined,
tool: string,
output: string,
): void {
if (!onStream || !output) return;
onStream({ type: 'tool_output', data: { tool, output } });
}
private static emitResult(
onStream: StreamCallback | undefined,
success: boolean,
result: string,
sessionId: string | undefined,
): void {
if (!onStream) return;
onStream({
type: 'result',
data: {
result,
sessionId: sessionId || 'unknown',
success,
error: success ? undefined : result || undefined,
},
});
}
private static formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }>): string {
if (!changes.length) return '';
return changes
.map((change) => {
const kind = change.kind ? `${change.kind}: ` : '';
return `${kind}${change.path ?? ''}`.trim();
})
.filter(Boolean)
.join('\n');
}
private static emitCodexItemStart(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>,
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
if (startedItems.has(id)) return;
switch (item.type) {
case 'command_execution': {
const command = typeof item.command === 'string' ? item.command : '';
CodexClient.emitToolUse(onStream, 'Bash', { command }, id);
startedItems.add(id);
break;
}
if (event.type === 'error') {
success = false;
failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error';
case 'mcp_tool_call': {
const tool = typeof item.tool === 'string' ? item.tool : 'Tool';
const args = (item.arguments ?? {}) as Record<string, unknown>;
CodexClient.emitToolUse(onStream, tool, args, id);
startedItems.add(id);
break;
}
if (event.type === 'item.started') {
const item = event.item as CodexItem | undefined;
if (item) {
emitCodexItemStart(item, options.onStream, startedItems);
}
continue;
case 'web_search': {
const query = typeof item.query === 'string' ? item.query : '';
CodexClient.emitToolUse(onStream, 'WebSearch', { query }, id);
startedItems.add(id);
break;
}
if (event.type === 'item.updated') {
const item = event.item as CodexItem | undefined;
if (item) {
if (item.type === 'agent_message' && typeof item.text === 'string') {
const itemId = item.id;
const text = item.text;
if (itemId) {
const prev = contentOffsets.get(itemId) ?? 0;
if (text.length > prev) {
if (prev === 0 && content.length > 0) {
content += '\n';
}
content += text.slice(prev);
contentOffsets.set(itemId, text.length);
}
}
}
emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets);
}
continue;
}
if (event.type === 'item.completed') {
const item = event.item as CodexItem | undefined;
if (item) {
if (item.type === 'agent_message' && typeof item.text === 'string') {
const itemId = item.id;
const text = item.text;
if (itemId) {
const prev = contentOffsets.get(itemId) ?? 0;
if (text.length > prev) {
if (prev === 0 && content.length > 0) {
content += '\n';
}
content += text.slice(prev);
contentOffsets.set(itemId, text.length);
}
} else if (text) {
if (content.length > 0) {
content += '\n';
}
content += text;
}
}
emitCodexItemCompleted(
item,
options.onStream,
startedItems,
outputOffsets,
textOffsets,
thinkingOffsets
);
}
continue;
case 'file_change': {
const changes = Array.isArray(item.changes) ? item.changes : [];
const summary = CodexClient.formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
CodexClient.emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id);
startedItems.add(id);
break;
}
default:
break;
}
}
private static emitCodexItemCompleted(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>,
outputOffsets: Map<string, number>,
textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number>,
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
switch (item.type) {
case 'reasoning': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = thinkingOffsets.get(id) ?? 0;
if (text.length > prev) {
CodexClient.emitThinking(onStream, text.slice(prev) + '\n');
thinkingOffsets.set(id, text.length);
}
}
break;
}
case 'agent_message': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = textOffsets.get(id) ?? 0;
if (text.length > prev) {
CodexClient.emitText(onStream, text.slice(prev));
textOffsets.set(id, text.length);
}
}
break;
}
case 'command_execution': {
if (!startedItems.has(id)) {
CodexClient.emitCodexItemStart(item, onStream, startedItems);
}
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
if (output) {
const prev = outputOffsets.get(id) ?? 0;
if (output.length > prev) {
CodexClient.emitToolOutput(onStream, 'Bash', output.slice(prev));
outputOffsets.set(id, output.length);
}
}
const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined;
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed' || (exitCode !== undefined && exitCode !== 0);
const content = output || (exitCode !== undefined ? `Exit code: ${exitCode}` : '');
CodexClient.emitToolResult(onStream, content, isError);
break;
}
case 'mcp_tool_call': {
if (!startedItems.has(id)) {
CodexClient.emitCodexItemStart(item, onStream, startedItems);
}
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed' || !!item.error;
const errorMessage =
item.error && typeof item.error === 'object' && 'message' in item.error
? String((item.error as { message?: unknown }).message ?? '')
: '';
let content = errorMessage;
if (!content && item.result && typeof item.result === 'object') {
try {
content = JSON.stringify(item.result);
} catch {
content = '';
}
}
CodexClient.emitToolResult(onStream, content, isError);
break;
}
case 'web_search': {
if (!startedItems.has(id)) {
CodexClient.emitCodexItemStart(item, onStream, startedItems);
}
CodexClient.emitToolResult(onStream, 'Search completed', false);
break;
}
case 'file_change': {
if (!startedItems.has(id)) {
CodexClient.emitCodexItemStart(item, onStream, startedItems);
}
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed';
const changes = Array.isArray(item.changes) ? item.changes : [];
const summary = CodexClient.formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
CodexClient.emitToolResult(onStream, summary || 'Applied patch', isError);
break;
}
default:
break;
}
}
private static emitCodexItemUpdate(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>,
outputOffsets: Map<string, number>,
textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number>,
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
switch (item.type) {
case 'command_execution': {
if (!startedItems.has(id)) {
CodexClient.emitCodexItemStart(item, onStream, startedItems);
}
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
if (output) {
const prev = outputOffsets.get(id) ?? 0;
if (output.length > prev) {
CodexClient.emitToolOutput(onStream, 'Bash', output.slice(prev));
outputOffsets.set(id, output.length);
}
}
break;
}
case 'agent_message': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = textOffsets.get(id) ?? 0;
if (text.length > prev) {
CodexClient.emitText(onStream, text.slice(prev));
textOffsets.set(id, text.length);
}
}
break;
}
case 'reasoning': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = thinkingOffsets.get(id) ?? 0;
if (text.length > prev) {
CodexClient.emitThinking(onStream, text.slice(prev));
thinkingOffsets.set(id, text.length);
}
}
break;
}
case 'file_change':
case 'mcp_tool_call':
case 'web_search': {
if (!startedItems.has(id)) {
CodexClient.emitCodexItemStart(item, onStream, startedItems);
}
break;
}
default:
break;
}
}
// ---- Public API ----
/** Call Codex with an agent prompt */
async call(
agentType: string,
prompt: string,
options: CodexCallOptions,
): Promise<AgentResponse> {
const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined);
const threadOptions = {
model: options.model,
workingDirectory: options.cwd,
};
const thread = options.sessionId
? await codex.resumeThread(options.sessionId, threadOptions)
: await codex.startThread(threadOptions);
let threadId = CodexClient.extractThreadId(thread) || options.sessionId;
const fullPrompt = options.systemPrompt
? `${options.systemPrompt}\n\n${prompt}`
: prompt;
try {
log.debug('Executing Codex thread', {
agentType,
model: options.model,
hasSystemPrompt: !!options.systemPrompt,
});
const { events } = await thread.runStreamed(fullPrompt);
let content = '';
const contentOffsets = new Map<string, number>();
let success = true;
let failureMessage = '';
const startedItems = new Set<string>();
const outputOffsets = new Map<string, number>();
const textOffsets = new Map<string, number>();
const thinkingOffsets = new Map<string, number>();
for await (const event of events as AsyncGenerator<CodexEvent>) {
if (event.type === 'thread.started') {
threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId;
CodexClient.emitInit(options.onStream, options.model, threadId);
continue;
}
if (event.type === 'turn.failed') {
success = false;
if (event.error && typeof event.error === 'object' && 'message' in event.error) {
failureMessage = String((event.error as { message?: unknown }).message ?? '');
}
break;
}
if (event.type === 'error') {
success = false;
failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error';
break;
}
if (event.type === 'item.started') {
const item = event.item as CodexItem | undefined;
if (item) {
CodexClient.emitCodexItemStart(item, options.onStream, startedItems);
}
continue;
}
if (event.type === 'item.updated') {
const item = event.item as CodexItem | undefined;
if (item) {
if (item.type === 'agent_message' && typeof item.text === 'string') {
const itemId = item.id;
const text = item.text;
if (itemId) {
const prev = contentOffsets.get(itemId) ?? 0;
if (text.length > prev) {
if (prev === 0 && content.length > 0) {
content += '\n';
}
content += text.slice(prev);
contentOffsets.set(itemId, text.length);
}
}
}
CodexClient.emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets);
}
continue;
}
if (event.type === 'item.completed') {
const item = event.item as CodexItem | undefined;
if (item) {
if (item.type === 'agent_message' && typeof item.text === 'string') {
const itemId = item.id;
const text = item.text;
if (itemId) {
const prev = contentOffsets.get(itemId) ?? 0;
if (text.length > prev) {
if (prev === 0 && content.length > 0) {
content += '\n';
}
content += text.slice(prev);
contentOffsets.set(itemId, text.length);
}
} else if (text) {
if (content.length > 0) {
content += '\n';
}
content += text;
}
}
CodexClient.emitCodexItemCompleted(
item,
options.onStream,
startedItems,
outputOffsets,
textOffsets,
thinkingOffsets,
);
}
continue;
}
}
if (!success) {
const message = failureMessage || 'Codex execution failed';
CodexClient.emitResult(options.onStream, false, message, threadId);
return {
agent: agentType,
status: 'blocked',
content: message,
timestamp: new Date(),
sessionId: threadId,
};
}
const trimmed = content.trim();
CodexClient.emitResult(options.onStream, true, trimmed, threadId);
return {
agent: agentType,
status: 'done',
content: trimmed,
timestamp: new Date(),
sessionId: threadId,
};
} catch (error) {
const message = getErrorMessage(error);
CodexClient.emitResult(options.onStream, false, message, threadId);
if (!success) {
const message = failureMessage || 'Codex execution failed';
emitResult(options.onStream, false, message, threadId);
return {
agent: agentType,
status: 'blocked',
@ -473,44 +483,39 @@ export async function callCodex(
sessionId: threadId,
};
}
}
const trimmed = content.trim();
emitResult(options.onStream, true, trimmed, threadId);
const status = determineStatus(true);
return {
agent: agentType,
status,
content: trimmed,
timestamp: new Date(),
sessionId: threadId,
};
} catch (error) {
const message = getErrorMessage(error);
emitResult(options.onStream, false, message, threadId);
return {
agent: agentType,
status: 'blocked',
content: message,
timestamp: new Date(),
sessionId: threadId,
};
/** Call Codex with a custom agent configuration (system prompt + prompt) */
async callCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: CodexCallOptions,
): Promise<AgentResponse> {
return this.call(agentName, prompt, {
...options,
systemPrompt,
});
}
}
/**
* Call Codex with a custom agent configuration (system prompt + prompt).
*/
// ---- Backward-compatible module-level functions ----
const defaultClient = new CodexClient();
export async function callCodex(
agentType: string,
prompt: string,
options: CodexCallOptions,
): Promise<AgentResponse> {
return defaultClient.call(agentType, prompt, options);
}
export async function callCodexCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: CodexCallOptions
options: CodexCallOptions,
): Promise<AgentResponse> {
return callCodex(agentName, prompt, {
...options,
systemPrompt,
});
return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
}

View File

@ -2,4 +2,5 @@
* Codex integration exports
*/
export * from './client.js';
export { CodexClient, callCodex, callCodexCustom } from './client.js';
export type { CodexCallOptions } from './types.js';

17
src/codex/types.ts Normal file
View File

@ -0,0 +1,17 @@
/**
* Type definitions for Codex SDK integration
*/
import type { StreamCallback } from '../claude/types.js';
/** Options for calling Codex */
export interface CodexCallOptions {
cwd: string;
sessionId?: string;
model?: string;
systemPrompt?: string;
/** Enable streaming mode with callback (best-effort) */
onStream?: StreamCallback;
/** OpenAI API key (bypasses CLI auth) */
openaiApiKey?: string;
}

View File

@ -1,2 +0,0 @@
/** Re-export shim — actual implementation in management/addTask.ts */
export { addTask, summarizeConversation } from './management/addTask.js';

View File

@ -1,2 +0,0 @@
/** Re-export shim — actual implementation in management/config.ts */
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './management/config.js';

View File

@ -1,2 +0,0 @@
/** Re-export shim — actual implementation in management/eject.ts */
export { ejectBuiltin } from './management/eject.js';

View File

@ -13,8 +13,8 @@ import { execFileSync } from 'node:child_process';
import { fetchIssue, formatIssueAsTask, checkGhCli, type GitHubIssue } from '../../github/issue.js';
import { createPullRequest, pushBranch, buildPrBody } from '../../github/pr.js';
import { stageAndCommit } from '../../task/git.js';
import { executeTask, type TaskExecutionOptions } from '../taskExecution.js';
import { loadGlobalConfig } from '../../config/globalConfig.js';
import { executeTask, type TaskExecutionOptions } from './taskExecution.js';
import { loadGlobalConfig } from '../../config/global/globalConfig.js';
import { info, error, success, status, blankLine } from '../../utils/ui.js';
import { createLogger } from '../../utils/debug.js';
import { getErrorMessage } from '../../utils/error.js';

View File

@ -7,7 +7,7 @@
*/
import { getCurrentWorkflow } from '../../config/paths.js';
import { listWorkflows, isWorkflowPath } from '../../config/workflowLoader.js';
import { listWorkflows, isWorkflowPath } from '../../config/loaders/workflowLoader.js';
import { selectOptionWithDefault, confirm } from '../../prompt/index.js';
import { createSharedClone } from '../../task/clone.js';
import { autoCommitAndPush } from '../../task/autoCommit.js';
@ -16,8 +16,8 @@ import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
import { info, error, success } from '../../utils/ui.js';
import { createLogger } from '../../utils/debug.js';
import { createPullRequest, buildPrBody } from '../../github/pr.js';
import { executeTask } from '../taskExecution.js';
import type { TaskExecutionOptions } from '../taskExecution.js';
import { executeTask } from './taskExecution.js';
import type { TaskExecutionOptions } from './taskExecution.js';
const log = createLogger('selectAndExecute');

View File

@ -3,7 +3,7 @@
*/
import { loadAgentSessions, updateAgentSession } from '../../config/paths.js';
import { loadGlobalConfig } from '../../config/globalConfig.js';
import { loadGlobalConfig } from '../../config/global/globalConfig.js';
import type { AgentResponse } from '../../models/types.js';
/**

View File

@ -17,7 +17,7 @@ import {
} from '../../utils/ui.js';
import { createLogger } from '../../utils/debug.js';
import { getErrorMessage } from '../../utils/error.js';
import { executeWorkflow } from '../workflowExecution.js';
import { executeWorkflow } from './workflowExecution.js';
import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
import type { ProviderType } from '../../providers/index.js';

View File

@ -3,7 +3,7 @@
*/
import { readFileSync } from 'node:fs';
import { WorkflowEngine } from '../../workflow/engine.js';
import { WorkflowEngine } from '../../workflow/engine/WorkflowEngine.js';
import type { WorkflowConfig, Language } from '../../models/types.js';
import type { IterationLimitRequest } from '../../workflow/types.js';
import type { ProviderType } from '../../providers/index.js';
@ -13,7 +13,7 @@ import {
loadWorktreeSessions,
updateWorktreeSession,
} from '../../config/paths.js';
import { loadGlobalConfig } from '../../config/globalConfig.js';
import { loadGlobalConfig } from '../../config/global/globalConfig.js';
import { isQuietMode } from '../../context.js';
import {
header,

View File

@ -2,20 +2,20 @@
* Command exports
*/
export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './workflowExecution.js';
export { executeTask, runAllTasks } from './taskExecution.js';
export { addTask } from './addTask.js';
export { ejectBuiltin } from './eject.js';
export { watchTasks } from './watchTasks.js';
export { withAgentSession } from './session.js';
export { switchWorkflow } from './workflow.js';
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js';
export { listTasks } from './listTasks.js';
export { interactiveMode } from './interactive.js';
export { executePipeline, type PipelineExecutionOptions } from './pipelineExecution.js';
export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './execution/workflowExecution.js';
export { executeTask, runAllTasks, type TaskExecutionOptions } from './execution/taskExecution.js';
export { addTask } from './management/addTask.js';
export { ejectBuiltin } from './management/eject.js';
export { watchTasks } from './management/watchTasks.js';
export { withAgentSession } from './execution/session.js';
export { switchWorkflow } from './management/workflow.js';
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './management/config.js';
export { listTasks } from './management/listTasks.js';
export { interactiveMode } from './interactive/interactive.js';
export { executePipeline, type PipelineExecutionOptions } from './execution/pipelineExecution.js';
export {
selectAndExecuteTask,
confirmAndCreateWorktree,
type SelectAndExecuteOptions,
type WorktreeConfirmationResult,
} from './selectAndExecute.js';
} from './execution/selectAndExecute.js';

View File

@ -1,2 +0,0 @@
/** Re-export shim — actual implementation in interactive/interactive.ts */
export { interactiveMode } from './interactive/interactive.js';

View File

@ -12,7 +12,7 @@
import * as readline from 'node:readline';
import chalk from 'chalk';
import { loadGlobalConfig } from '../../config/globalConfig.js';
import { loadGlobalConfig } from '../../config/global/globalConfig.js';
import { isQuietMode } from '../../context.js';
import { loadAgentSessions, updateAgentSession } from '../../config/paths.js';
import { getProvider, type ProviderType } from '../../providers/index.js';

View File

@ -1,2 +0,0 @@
/** Re-export shim — actual implementation in management/listTasks.ts */
export { listTasks, isBranchMerged, showFullDiff, type ListAction } from './management/listTasks.js';

View File

@ -11,13 +11,13 @@ import { stringify as stringifyYaml } from 'yaml';
import { promptInput, confirm, selectOption } from '../../prompt/index.js';
import { success, info } from '../../utils/ui.js';
import { summarizeTaskName } from '../../task/summarize.js';
import { loadGlobalConfig } from '../../config/globalConfig.js';
import { loadGlobalConfig } from '../../config/global/globalConfig.js';
import { getProvider, type ProviderType } from '../../providers/index.js';
import { createLogger } from '../../utils/debug.js';
import { getErrorMessage } from '../../utils/error.js';
import { listWorkflows } from '../../config/workflowLoader.js';
import { listWorkflows } from '../../config/loaders/workflowLoader.js';
import { getCurrentWorkflow } from '../../config/paths.js';
import { interactiveMode } from '../interactive.js';
import { interactiveMode } from '../interactive/interactive.js';
import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../../github/issue.js';
import type { TaskFileData } from '../../task/schema.js';

View File

@ -12,10 +12,10 @@ import {
loadProjectConfig,
updateProjectConfig,
type PermissionMode,
} from '../../config/projectConfig.js';
} from '../../config/project/projectConfig.js';
// Re-export for convenience
export type { PermissionMode } from '../../config/projectConfig.js';
export type { PermissionMode } from '../../config/project/projectConfig.js';
/**
* Get permission mode options for selection

View File

@ -8,7 +8,7 @@
import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { getGlobalWorkflowsDir, getGlobalAgentsDir, getBuiltinWorkflowsDir, getBuiltinAgentsDir } from '../../config/paths.js';
import { getLanguage } from '../../config/globalConfig.js';
import { getLanguage } from '../../config/global/globalConfig.js';
import { header, success, info, warn, error, blankLine } from '../../utils/ui.js';
/**

View File

@ -25,8 +25,8 @@ import { selectOption, confirm, promptInput } from '../../prompt/index.js';
import { info, success, error as logError, warn, header, blankLine } from '../../utils/ui.js';
import { createLogger } from '../../utils/debug.js';
import { getErrorMessage } from '../../utils/error.js';
import { executeTask, type TaskExecutionOptions } from '../taskExecution.js';
import { listWorkflows } from '../../config/workflowLoader.js';
import { executeTask, type TaskExecutionOptions } from '../execution/taskExecution.js';
import { listWorkflows } from '../../config/loaders/workflowLoader.js';
import { getCurrentWorkflow } from '../../config/paths.js';
import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';

View File

@ -15,9 +15,9 @@ import {
status,
blankLine,
} from '../../utils/ui.js';
import { executeAndCompleteTask } from '../taskExecution.js';
import { executeAndCompleteTask } from '../execution/taskExecution.js';
import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
import type { TaskExecutionOptions } from '../taskExecution.js';
import type { TaskExecutionOptions } from '../execution/taskExecution.js';
/**
* Watch for tasks and execute them as they appear.

View File

@ -1,2 +0,0 @@
/** Re-export shim — actual implementation in execution/pipelineExecution.ts */
export { executePipeline, type PipelineExecutionOptions } from './execution/pipelineExecution.js';

View File

@ -1,7 +0,0 @@
/** Re-export shim — actual implementation in execution/selectAndExecute.ts */
export {
selectAndExecuteTask,
confirmAndCreateWorktree,
type SelectAndExecuteOptions,
type WorktreeConfirmationResult,
} from './execution/selectAndExecute.js';

View File

@ -1,2 +0,0 @@
/** Re-export shim — actual implementation in execution/session.ts */
export { withAgentSession } from './execution/session.js';

View File

@ -1,2 +0,0 @@
/** Re-export shim — actual implementation in execution/taskExecution.ts */
export { executeTask, runAllTasks, executeAndCompleteTask, resolveTaskExecution, type TaskExecutionOptions } from './execution/taskExecution.js';

View File

@ -1,2 +0,0 @@
/** Re-export shim — actual implementation in management/watchTasks.ts */
export { watchTasks } from './management/watchTasks.js';

View File

@ -1,2 +0,0 @@
/** Re-export shim — actual implementation in management/workflow.ts */
export { switchWorkflow } from './management/workflow.js';

View File

@ -1,2 +0,0 @@
/** Re-export shim — actual implementation in execution/workflowExecution.ts */
export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './execution/workflowExecution.js';

View File

@ -1,10 +0,0 @@
/**
* Re-export shim actual implementation in loaders/agentLoader.ts
*/
export {
loadAgentsFromDir,
loadCustomAgents,
listCustomAgents,
loadAgentPrompt,
loadAgentPromptFromPath,
} from './loaders/agentLoader.js';

View File

@ -18,7 +18,7 @@ import {
ensureDir,
} from '../paths.js';
import { copyProjectResourcesToDir, getLanguageResourcesDir } from '../../resources/index.js';
import { setLanguage, setProvider } from '../globalConfig.js';
import { setLanguage, setProvider } from './globalConfig.js';
/**
* Check if initial setup is needed.

View File

@ -1,18 +0,0 @@
/**
* Re-export shim actual implementation in global/globalConfig.ts
*/
export {
invalidateGlobalConfigCache,
loadGlobalConfig,
saveGlobalConfig,
getDisabledBuiltins,
getLanguage,
setLanguage,
setProvider,
addTrustedDirectory,
isDirectoryTrusted,
resolveAnthropicApiKey,
resolveOpenaiApiKey,
loadProjectDebugConfig,
getEffectiveDebugConfig,
} from './global/globalConfig.js';

View File

@ -3,5 +3,5 @@
*/
export * from './paths.js';
export * from './loader.js';
export * from './initialization.js';
export * from './loaders/loader.js';
export * from './global/initialization.js';

View File

@ -1,11 +0,0 @@
/**
* Re-export shim actual implementation in global/initialization.ts
*/
export {
needsLanguageSetup,
promptLanguageSelection,
promptProviderSelection,
initGlobalDirs,
initProjectDirs,
type InitGlobalDirsOptions,
} from './global/initialization.js';

View File

@ -1,35 +0,0 @@
/**
* Re-export shim actual implementation in loaders/loader.ts
*
* Re-exports from specialized loaders for backward compatibility.
*/
// Workflow loading
export {
getBuiltinWorkflow,
loadWorkflow,
loadWorkflowByIdentifier,
isWorkflowPath,
loadAllWorkflows,
listWorkflows,
} from './loaders/workflowLoader.js';
// Agent loading
export {
loadAgentsFromDir,
loadCustomAgents,
listCustomAgents,
loadAgentPrompt,
loadAgentPromptFromPath,
} from './loaders/agentLoader.js';
// Global configuration
export {
loadGlobalConfig,
saveGlobalConfig,
invalidateGlobalConfigCache,
addTrustedDirectory,
isDirectoryTrusted,
loadProjectDebugConfig,
getEffectiveDebugConfig,
} from './global/globalConfig.js';

View File

@ -16,7 +16,7 @@ import {
getBuiltinWorkflowsDir,
isPathSafe,
} from '../paths.js';
import { getLanguage } from '../globalConfig.js';
import { getLanguage } from '../global/globalConfig.js';
/** Get all allowed base directories for agent prompt files */
function getAllowedAgentBases(): string[] {

View File

@ -12,7 +12,7 @@ export {
isWorkflowPath,
loadAllWorkflows,
listWorkflows,
} from '../workflowLoader.js';
} from './workflowLoader.js';
// Agent loading
export {
@ -21,7 +21,7 @@ export {
listCustomAgents,
loadAgentPrompt,
loadAgentPromptFromPath,
} from '../agentLoader.js';
} from './agentLoader.js';
// Global configuration
export {
@ -32,4 +32,4 @@ export {
isDirectoryTrusted,
loadProjectDebugConfig,
getEffectiveDebugConfig,
} from '../globalConfig.js';
} from '../global/globalConfig.js';

View File

@ -15,7 +15,7 @@ import { parse as parseYaml } from 'yaml';
import { WorkflowConfigRawSchema } from '../../models/schemas.js';
import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../../models/types.js';
import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from '../paths.js';
import { getLanguage, getDisabledBuiltins } from '../globalConfig.js';
import { getLanguage, getDisabledBuiltins } from '../global/globalConfig.js';
/** Get builtin workflow by name */
export function getBuiltinWorkflow(name: string): WorkflowConfig | null {

View File

@ -94,7 +94,7 @@ export {
setCurrentWorkflow,
isVerboseMode,
type ProjectLocalConfig,
} from './projectConfig.js';
} from './project/projectConfig.js';
// Re-export session storage functions for backward compatibility
export {
@ -116,4 +116,4 @@ export {
getWorktreeSessionPath,
loadWorktreeSessions,
updateWorktreeSession,
} from './sessionStore.js';
} from './project/sessionStore.js';

View File

@ -1,14 +0,0 @@
/**
* Re-export shim actual implementation in project/projectConfig.ts
*/
export {
loadProjectConfig,
saveProjectConfig,
updateProjectConfig,
getCurrentWorkflow,
setCurrentWorkflow,
isVerboseMode,
type PermissionMode,
type ProjectPermissionMode,
type ProjectLocalConfig,
} from './project/projectConfig.js';

View File

@ -1,24 +0,0 @@
/**
* Re-export shim actual implementation in project/sessionStore.ts
*/
export {
writeFileAtomic,
getInputHistoryPath,
MAX_INPUT_HISTORY,
loadInputHistory,
saveInputHistory,
addToInputHistory,
type AgentSessionData,
getAgentSessionsPath,
loadAgentSessions,
saveAgentSessions,
updateAgentSession,
clearAgentSessions,
getWorktreeSessionsDir,
encodeWorktreePath,
getWorktreeSessionPath,
loadWorktreeSessions,
updateWorktreeSession,
getClaudeProjectSessionsDir,
clearClaudeProjectSessions,
} from './project/sessionStore.js';

View File

@ -1,11 +0,0 @@
/**
* Re-export shim actual implementation in loaders/workflowLoader.ts
*/
export {
getBuiltinWorkflow,
loadWorkflow,
loadWorkflowByIdentifier,
isWorkflowPath,
loadAllWorkflows,
listWorkflows,
} from './loaders/workflowLoader.js';

View File

@ -0,0 +1,64 @@
/**
* Configuration types (global and project)
*/
/** Custom agent configuration */
export interface CustomAgentConfig {
name: string;
promptFile?: string;
prompt?: string;
allowedTools?: string[];
claudeAgent?: string;
claudeSkill?: string;
provider?: 'claude' | 'codex' | 'mock';
model?: string;
}
/** Debug configuration for takt */
export interface DebugConfig {
enabled: boolean;
logFile?: string;
}
/** Language setting for takt */
export type Language = 'en' | 'ja';
/** Pipeline execution configuration */
export interface PipelineConfig {
/** Branch name prefix for pipeline-created branches (default: "takt/") */
defaultBranchPrefix?: string;
/** Commit message template. Variables: {title}, {issue} */
commitMessageTemplate?: string;
/** PR body template. Variables: {issue_body}, {report}, {issue} */
prBodyTemplate?: string;
}
/** Global configuration for takt */
export interface GlobalConfig {
language: Language;
trustedDirectories: string[];
defaultWorkflow: string;
logLevel: 'debug' | 'info' | 'warn' | 'error';
provider?: 'claude' | 'codex' | 'mock';
model?: string;
debug?: DebugConfig;
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktreeDir?: string;
/** List of builtin workflow/agent names to exclude from fallback loading */
disabledBuiltins?: string[];
/** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */
anthropicApiKey?: string;
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
openaiApiKey?: string;
/** Pipeline execution settings */
pipeline?: PipelineConfig;
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
minimalOutput?: boolean;
}
/** Project-level configuration */
export interface ProjectConfig {
workflow?: string;
agents?: CustomAgentConfig[];
provider?: 'claude' | 'codex' | 'mock';
}

21
src/models/response.ts Normal file
View File

@ -0,0 +1,21 @@
/**
* Agent response and session state types
*/
import type { Status, RuleMatchMethod } from './status.js';
/** Response from an agent execution */
export interface AgentResponse {
agent: string;
status: Status;
content: string;
timestamp: Date;
sessionId?: string;
/** Error message when the query failed (e.g., API error, rate limit) */
error?: string;
/** Matched rule index (0-based) when rules-based detection was used */
matchedRuleIndex?: number;
/** How the rule match was detected */
matchedRuleMethod?: RuleMatchMethod;
}

View File

@ -2,7 +2,8 @@
* Session type definitions
*/
import type { AgentResponse, Status } from './types.js';
import type { AgentResponse } from './response.js';
import type { Status } from './status.js';
/**
* Session state for workflow execution

29
src/models/status.ts Normal file
View File

@ -0,0 +1,29 @@
/**
* Status and classification types
*/
/** Built-in agent types */
export type AgentType = 'coder' | 'architect' | 'supervisor' | 'custom';
/** Execution status for agents and workflows */
export type Status =
| 'pending'
| 'done'
| 'blocked'
| 'approved'
| 'rejected'
| 'improve'
| 'cancelled'
| 'interrupted'
| 'answer';
/** How a rule match was detected */
export type RuleMatchMethod =
| 'aggregate'
| 'phase3_tag'
| 'phase1_tag'
| 'ai_judge'
| 'ai_judge_fallback';
/** Permission mode for tool execution */
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';

View File

@ -1,219 +1,45 @@
/**
* Core type definitions for TAKT orchestration system
*
* This file re-exports all types from categorized sub-modules.
* Consumers import from './types.js' no path changes needed.
*/
/** Built-in agent types */
export type AgentType = 'coder' | 'architect' | 'supervisor' | 'custom';
// Status and classification types
export type {
AgentType,
Status,
RuleMatchMethod,
PermissionMode,
} from './status.js';
/** Execution status for agents and workflows */
export type Status =
| 'pending'
| 'done'
| 'blocked'
| 'approved'
| 'rejected'
| 'improve'
| 'cancelled'
| 'interrupted'
| 'answer';
// Agent response
export type {
AgentResponse,
} from './response.js';
/** How a rule match was detected */
export type RuleMatchMethod =
| 'aggregate'
| 'phase3_tag'
| 'phase1_tag'
| 'ai_judge'
| 'ai_judge_fallback';
// Session state (authoritative definition with createSessionState)
export type {
SessionState,
} from './session.js';
/** Response from an agent execution */
export interface AgentResponse {
agent: string;
status: Status;
content: string;
timestamp: Date;
sessionId?: string;
/** Error message when the query failed (e.g., API error, rate limit) */
error?: string;
/** Matched rule index (0-based) when rules-based detection was used */
matchedRuleIndex?: number;
/** How the rule match was detected */
matchedRuleMethod?: RuleMatchMethod;
}
// Workflow configuration and runtime state
export type {
WorkflowRule,
ReportConfig,
ReportObjectConfig,
WorkflowStep,
LoopDetectionConfig,
WorkflowConfig,
WorkflowState,
} from './workflow-types.js';
/** Session state for workflow execution */
export interface SessionState {
task: string;
projectDir: string;
iterations: number;
history: AgentResponse[];
context: Record<string, string>;
}
/** Rule-based transition configuration (new unified format) */
export interface WorkflowRule {
/** Human-readable condition text */
condition: string;
/** Next step name (e.g., implement, COMPLETE, ABORT). Optional for parallel sub-steps. */
next?: string;
/** Template for additional AI output */
appendix?: string;
/** Whether this condition uses ai() expression (set by loader) */
isAiCondition?: boolean;
/** The condition text inside ai("...") for AI judge evaluation (set by loader) */
aiConditionText?: string;
/** Whether this condition uses all()/any() aggregate expression (set by loader) */
isAggregateCondition?: boolean;
/** Aggregate type: 'all' requires all sub-steps match, 'any' requires at least one (set by loader) */
aggregateType?: 'all' | 'any';
/** The condition text inside all("...")/any("...") to match against sub-step results (set by loader) */
aggregateConditionText?: string;
}
/** Report file configuration for a workflow step (label: path pair) */
export interface ReportConfig {
/** Display label (e.g., "Scope", "Decisions") */
label: string;
/** File path relative to report directory (e.g., "01-coder-scope.md") */
path: string;
}
/** Report object configuration with order/format instructions */
export interface ReportObjectConfig {
/** Report file name (e.g., "00-plan.md") */
name: string;
/** Instruction prepended before instruction_template (e.g., output destination) */
order?: string;
/** Instruction appended after instruction_template (e.g., output format) */
format?: string;
}
/** Permission mode for tool execution */
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
/** Single step in a workflow */
export interface WorkflowStep {
name: string;
/** Agent name or path as specified in workflow YAML */
agent: string;
/** Display name for the agent (shown in output). Falls back to agent basename if not specified */
agentDisplayName: string;
/** Allowed tools for this step (optional, passed to agent execution) */
allowedTools?: string[];
/** Resolved absolute path to agent prompt file (set by loader) */
agentPath?: string;
/** Provider override for this step */
provider?: 'claude' | 'codex' | 'mock';
/** Model override for this step */
model?: string;
/** Permission mode for tool execution in this step */
permissionMode?: PermissionMode;
/** Whether this step is allowed to edit project files (true=allowed, false=prohibited, undefined=no prompt) */
edit?: boolean;
instructionTemplate: string;
/** Rules for step routing */
rules?: WorkflowRule[];
/** Report file configuration. Single string, array of label:path, or object with order/format. */
report?: string | ReportConfig[] | ReportObjectConfig;
passPreviousResponse: boolean;
/** Sub-steps to execute in parallel. When set, this step runs all sub-steps concurrently. */
parallel?: WorkflowStep[];
}
/** Loop detection configuration */
export interface LoopDetectionConfig {
/** Maximum consecutive runs of the same step before triggering (default: 10) */
maxConsecutiveSameStep?: number;
/** Action to take when loop is detected (default: 'warn') */
action?: 'abort' | 'warn' | 'ignore';
}
/** Workflow configuration */
export interface WorkflowConfig {
name: string;
description?: string;
steps: WorkflowStep[];
initialStep: string;
maxIterations: number;
/** Loop detection settings */
loopDetection?: LoopDetectionConfig;
/**
* Agent to use for answering AskUserQuestion prompts automatically.
* When specified, questions from Claude Code are routed to this agent
* instead of prompting the user interactively.
*/
answerAgent?: string;
}
/** Runtime state of a workflow execution */
export interface WorkflowState {
workflowName: string;
currentStep: string;
iteration: number;
stepOutputs: Map<string, AgentResponse>;
userInputs: string[];
agentSessions: Map<string, string>;
/** Per-step iteration counters (how many times each step has been executed) */
stepIterations: Map<string, number>;
status: 'running' | 'completed' | 'aborted';
}
/** Custom agent configuration */
export interface CustomAgentConfig {
name: string;
promptFile?: string;
prompt?: string;
allowedTools?: string[];
claudeAgent?: string;
claudeSkill?: string;
provider?: 'claude' | 'codex' | 'mock';
model?: string;
}
/** Debug configuration for takt */
export interface DebugConfig {
enabled: boolean;
logFile?: string;
}
/** Language setting for takt */
export type Language = 'en' | 'ja';
/** Pipeline execution configuration */
export interface PipelineConfig {
/** Branch name prefix for pipeline-created branches (default: "takt/") */
defaultBranchPrefix?: string;
/** Commit message template. Variables: {title}, {issue} */
commitMessageTemplate?: string;
/** PR body template. Variables: {issue_body}, {report}, {issue} */
prBodyTemplate?: string;
}
/** Global configuration for takt */
export interface GlobalConfig {
language: Language;
trustedDirectories: string[];
defaultWorkflow: string;
logLevel: 'debug' | 'info' | 'warn' | 'error';
provider?: 'claude' | 'codex' | 'mock';
model?: string;
debug?: DebugConfig;
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktreeDir?: string;
/** List of builtin workflow/agent names to exclude from fallback loading */
disabledBuiltins?: string[];
/** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */
anthropicApiKey?: string;
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
openaiApiKey?: string;
/** Pipeline execution settings */
pipeline?: PipelineConfig;
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
minimalOutput?: boolean;
}
/** Project-level configuration */
export interface ProjectConfig {
workflow?: string;
agents?: CustomAgentConfig[];
provider?: 'claude' | 'codex' | 'mock';
}
// Configuration types (global and project)
export type {
CustomAgentConfig,
DebugConfig,
Language,
PipelineConfig,
GlobalConfig,
ProjectConfig,
} from './global-config.js';

View File

@ -0,0 +1,111 @@
/**
* Workflow configuration and runtime state types
*/
import type { PermissionMode } from './status.js';
import type { AgentResponse } from './response.js';
/** Rule-based transition configuration (unified format) */
export interface WorkflowRule {
/** Human-readable condition text */
condition: string;
/** Next step name (e.g., implement, COMPLETE, ABORT). Optional for parallel sub-steps. */
next?: string;
/** Template for additional AI output */
appendix?: string;
/** Whether this condition uses ai() expression (set by loader) */
isAiCondition?: boolean;
/** The condition text inside ai("...") for AI judge evaluation (set by loader) */
aiConditionText?: string;
/** Whether this condition uses all()/any() aggregate expression (set by loader) */
isAggregateCondition?: boolean;
/** Aggregate type: 'all' requires all sub-steps match, 'any' requires at least one (set by loader) */
aggregateType?: 'all' | 'any';
/** The condition text inside all("...")/any("...") to match against sub-step results (set by loader) */
aggregateConditionText?: string;
}
/** Report file configuration for a workflow step (label: path pair) */
export interface ReportConfig {
/** Display label (e.g., "Scope", "Decisions") */
label: string;
/** File path relative to report directory (e.g., "01-coder-scope.md") */
path: string;
}
/** Report object configuration with order/format instructions */
export interface ReportObjectConfig {
/** Report file name (e.g., "00-plan.md") */
name: string;
/** Instruction prepended before instruction_template (e.g., output destination) */
order?: string;
/** Instruction appended after instruction_template (e.g., output format) */
format?: string;
}
/** Single step in a workflow */
export interface WorkflowStep {
name: string;
/** Agent name or path as specified in workflow YAML */
agent: string;
/** Display name for the agent (shown in output). Falls back to agent basename if not specified */
agentDisplayName: string;
/** Allowed tools for this step (optional, passed to agent execution) */
allowedTools?: string[];
/** Resolved absolute path to agent prompt file (set by loader) */
agentPath?: string;
/** Provider override for this step */
provider?: 'claude' | 'codex' | 'mock';
/** Model override for this step */
model?: string;
/** Permission mode for tool execution in this step */
permissionMode?: PermissionMode;
/** Whether this step is allowed to edit project files (true=allowed, false=prohibited, undefined=no prompt) */
edit?: boolean;
instructionTemplate: string;
/** Rules for step routing */
rules?: WorkflowRule[];
/** Report file configuration. Single string, array of label:path, or object with order/format. */
report?: string | ReportConfig[] | ReportObjectConfig;
passPreviousResponse: boolean;
/** Sub-steps to execute in parallel. When set, this step runs all sub-steps concurrently. */
parallel?: WorkflowStep[];
}
/** Loop detection configuration */
export interface LoopDetectionConfig {
/** Maximum consecutive runs of the same step before triggering (default: 10) */
maxConsecutiveSameStep?: number;
/** Action to take when loop is detected (default: 'warn') */
action?: 'abort' | 'warn' | 'ignore';
}
/** Workflow configuration */
export interface WorkflowConfig {
name: string;
description?: string;
steps: WorkflowStep[];
initialStep: string;
maxIterations: number;
/** Loop detection settings */
loopDetection?: LoopDetectionConfig;
/**
* Agent to use for answering AskUserQuestion prompts automatically.
* When specified, questions from Claude Code are routed to this agent
* instead of prompting the user interactively.
*/
answerAgent?: string;
}
/** Runtime state of a workflow execution */
export interface WorkflowState {
workflowName: string;
currentStep: string;
iteration: number;
stepOutputs: Map<string, AgentResponse>;
userInputs: string[];
agentSessions: Map<string, string>;
/** Per-step iteration counters (how many times each step has been executed) */
stepIterations: Map<string, number>;
status: 'running' | 'completed' | 'aborted';
}

View File

@ -3,9 +3,9 @@
*/
import { callClaude, callClaudeCustom, type ClaudeCallOptions } from '../claude/client.js';
import { resolveAnthropicApiKey } from '../config/globalConfig.js';
import { resolveAnthropicApiKey } from '../config/global/globalConfig.js';
import type { AgentResponse } from '../models/types.js';
import type { Provider, ProviderCallOptions } from './index.js';
import type { Provider, ProviderCallOptions } from './types.js';
/** Claude provider - wraps existing Claude client */
export class ClaudeProvider implements Provider {

View File

@ -3,9 +3,9 @@
*/
import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/client.js';
import { resolveOpenaiApiKey } from '../config/globalConfig.js';
import { resolveOpenaiApiKey } from '../config/global/globalConfig.js';
import type { AgentResponse } from '../models/types.js';
import type { Provider, ProviderCallOptions } from './index.js';
import type { Provider, ProviderCallOptions } from './types.js';
/** Codex provider - wraps existing Codex client */
export class CodexProvider implements Provider {

View File

@ -5,66 +5,56 @@
* This enables adding new providers without modifying the runner logic.
*/
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/process.js';
import type { AgentResponse, PermissionMode } from '../models/types.js';
import { ClaudeProvider } from './claude.js';
import { CodexProvider } from './codex.js';
import { MockProvider } from './mock.js';
import type { Provider, ProviderType } from './types.js';
/** Common options for all providers */
export interface ProviderCallOptions {
cwd: string;
sessionId?: string;
model?: string;
systemPrompt?: string;
allowedTools?: string[];
/** Maximum number of agentic turns */
maxTurns?: number;
/** Permission mode for tool execution (from workflow step) */
permissionMode?: PermissionMode;
onStream?: StreamCallback;
onPermissionRequest?: PermissionHandler;
onAskUserQuestion?: AskUserQuestionHandler;
bypassPermissions?: boolean;
/** Anthropic API key for Claude provider */
anthropicApiKey?: string;
/** OpenAI API key for Codex provider */
openaiApiKey?: string;
}
/** Provider interface - all providers must implement this */
export interface Provider {
/** Call the provider with a prompt (using systemPrompt from options if provided) */
call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
/** Call the provider with explicit system prompt */
callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
}
/** Provider type */
export type ProviderType = 'claude' | 'codex' | 'mock';
/** Provider registry */
const providers: Record<ProviderType, Provider> = {
claude: new ClaudeProvider(),
codex: new CodexProvider(),
mock: new MockProvider(),
};
// Re-export types for backward compatibility
export type { ProviderCallOptions, Provider, ProviderType } from './types.js';
/**
* Get a provider instance by type
* Registry for agent providers.
* Singleton use ProviderRegistry.getInstance().
*/
export function getProvider(type: ProviderType): Provider {
const provider = providers[type];
if (!provider) {
throw new Error(`Unknown provider type: ${type}`);
export class ProviderRegistry {
private static instance: ProviderRegistry | null = null;
private readonly providers: Record<string, Provider>;
private constructor() {
this.providers = {
claude: new ClaudeProvider(),
codex: new CodexProvider(),
mock: new MockProvider(),
};
}
return provider;
static getInstance(): ProviderRegistry {
if (!ProviderRegistry.instance) {
ProviderRegistry.instance = new ProviderRegistry();
}
return ProviderRegistry.instance;
}
/** Reset singleton for testing */
static resetInstance(): void {
ProviderRegistry.instance = null;
}
/** Get a provider instance by type */
get(type: ProviderType): Provider {
const provider = this.providers[type];
if (!provider) {
throw new Error(`Unknown provider type: ${type}`);
}
return provider;
}
}
/**
* Register a custom provider
*/
export function registerProvider(type: string, provider: Provider): void {
(providers as Record<string, Provider>)[type] = provider;
// ---- Backward-compatible module-level functions ----
export function getProvider(type: ProviderType): Provider {
return ProviderRegistry.getInstance().get(type);
}

View File

@ -4,7 +4,7 @@
import { callMock, callMockCustom, type MockCallOptions } from '../mock/client.js';
import type { AgentResponse } from '../models/types.js';
import type { Provider, ProviderCallOptions } from './index.js';
import type { Provider, ProviderCallOptions } from './types.js';
/** Mock provider - wraps existing Mock client */
export class MockProvider implements Provider {

39
src/providers/types.ts Normal file
View File

@ -0,0 +1,39 @@
/**
* Type definitions for the provider abstraction layer
*/
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/types.js';
import type { AgentResponse, PermissionMode } from '../models/types.js';
/** Common options for all providers */
export interface ProviderCallOptions {
cwd: string;
sessionId?: string;
model?: string;
systemPrompt?: string;
allowedTools?: string[];
/** Maximum number of agentic turns */
maxTurns?: number;
/** Permission mode for tool execution (from workflow step) */
permissionMode?: PermissionMode;
onStream?: StreamCallback;
onPermissionRequest?: PermissionHandler;
onAskUserQuestion?: AskUserQuestionHandler;
bypassPermissions?: boolean;
/** Anthropic API key for Claude provider */
anthropicApiKey?: string;
/** OpenAI API key for Codex provider */
openaiApiKey?: string;
}
/** Provider interface - all providers must implement this */
export interface Provider {
/** Call the provider with a prompt (using systemPrompt from options if provided) */
call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
/** Call the provider with explicit system prompt */
callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
}
/** Provider type */
export type ProviderType = 'claude' | 'codex' | 'mock';

View File

@ -24,52 +24,60 @@ export interface AutoCommitResult {
}
/**
* Auto-commit all changes and push to origin.
*
* Steps:
* 1. Stage all changes (git add -A)
* 2. Check if there are staged changes (git status --porcelain)
* 3. If changes exist, create a commit with "takt: {taskName}"
* 4. Push to origin (git push origin HEAD)
*
* @param cloneCwd - The clone directory
* @param taskName - Task name used in commit message
* @param projectDir - The main project directory (push target)
* Handles auto-commit and push operations for clone tasks.
*/
export function autoCommitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult {
log.info('Auto-commit starting', { cwd: cloneCwd, taskName });
export class AutoCommitter {
/**
* Auto-commit all changes and push to the main project.
*
* Steps:
* 1. Stage all changes (git add -A)
* 2. Check if there are staged changes
* 3. If changes exist, create a commit with "takt: {taskName}"
* 4. Push to the main project directory
*/
commitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult {
log.info('Auto-commit starting', { cwd: cloneCwd, taskName });
try {
const commitMessage = `takt: ${taskName}`;
const commitHash = stageAndCommit(cloneCwd, commitMessage);
try {
const commitMessage = `takt: ${taskName}`;
const commitHash = stageAndCommit(cloneCwd, commitMessage);
if (!commitHash) {
log.info('No changes to commit');
return { success: true, message: 'No changes to commit' };
if (!commitHash) {
log.info('No changes to commit');
return { success: true, message: 'No changes to commit' };
}
log.info('Auto-commit created', { commitHash, message: commitMessage });
execFileSync('git', ['push', projectDir, 'HEAD'], {
cwd: cloneCwd,
stdio: 'pipe',
});
log.info('Pushed to main repo', { projectDir });
return {
success: true,
commitHash,
message: `Committed & pushed: ${commitHash} - ${commitMessage}`,
};
} catch (err) {
const errorMessage = getErrorMessage(err);
log.error('Auto-commit failed', { error: errorMessage });
return {
success: false,
message: `Auto-commit failed: ${errorMessage}`,
};
}
log.info('Auto-commit created', { commitHash, message: commitMessage });
// Push directly to the main repo (origin was removed to isolate the clone)
execFileSync('git', ['push', projectDir, 'HEAD'], {
cwd: cloneCwd,
stdio: 'pipe',
});
log.info('Pushed to main repo', { projectDir });
return {
success: true,
commitHash,
message: `Committed & pushed: ${commitHash} - ${commitMessage}`,
};
} catch (err) {
const errorMessage = getErrorMessage(err);
log.error('Auto-commit failed', { error: errorMessage });
return {
success: false,
message: `Auto-commit failed: ${errorMessage}`,
};
}
}
// ---- Backward-compatible module-level function ----
const defaultCommitter = new AutoCommitter();
export function autoCommitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult {
return defaultCommitter.commitAndPush(cloneCwd, taskName, projectDir);
}

View File

@ -1,7 +1,7 @@
/**
* Branch list helpers
*
* Functions for listing, parsing, and enriching takt-managed branches
* Listing, parsing, and enriching takt-managed branches
* with metadata (diff stats, original instruction, task slug).
* Used by the /list command.
*/
@ -29,147 +29,171 @@ export interface BranchListItem {
const TAKT_BRANCH_PREFIX = 'takt/';
/**
* Detect the default branch name (main or master).
* Checks local branch refs directly. Falls back to 'main'.
* Manages takt branch listing and metadata enrichment.
*/
export function detectDefaultBranch(cwd: string): string {
try {
const ref = execFileSync(
'git', ['symbolic-ref', 'refs/remotes/origin/HEAD'],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
).trim();
const parts = ref.split('/');
return parts[parts.length - 1] || 'main';
} catch {
export class BranchManager {
/** Detect the default branch name (main or master) */
detectDefaultBranch(cwd: string): string {
try {
execFileSync('git', ['rev-parse', '--verify', 'main'], {
cwd, encoding: 'utf-8', stdio: 'pipe',
});
return 'main';
const ref = execFileSync(
'git', ['symbolic-ref', 'refs/remotes/origin/HEAD'],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
).trim();
const parts = ref.split('/');
return parts[parts.length - 1] || 'main';
} catch {
try {
execFileSync('git', ['rev-parse', '--verify', 'master'], {
execFileSync('git', ['rev-parse', '--verify', 'main'], {
cwd, encoding: 'utf-8', stdio: 'pipe',
});
return 'master';
} catch {
return 'main';
} catch {
try {
execFileSync('git', ['rev-parse', '--verify', 'master'], {
cwd, encoding: 'utf-8', stdio: 'pipe',
});
return 'master';
} catch {
return 'main';
}
}
}
}
/** List all takt-managed branches */
listTaktBranches(projectDir: string): BranchInfo[] {
try {
const output = execFileSync(
'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
);
return BranchManager.parseTaktBranches(output);
} catch (err) {
log.error('Failed to list takt branches', { error: String(err) });
return [];
}
}
/** Parse `git branch --list` formatted output into BranchInfo entries */
static parseTaktBranches(output: string): BranchInfo[] {
const entries: BranchInfo[] = [];
const lines = output.trim().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const spaceIdx = trimmed.lastIndexOf(' ');
if (spaceIdx === -1) continue;
const branch = trimmed.slice(0, spaceIdx);
const commit = trimmed.slice(spaceIdx + 1);
if (branch.startsWith(TAKT_BRANCH_PREFIX)) {
entries.push({ branch, commit });
}
}
return entries;
}
/** Get the number of files changed between the default branch and a given branch */
getFilesChanged(cwd: string, defaultBranch: string, branch: string): number {
try {
const output = execFileSync(
'git', ['diff', '--numstat', `${defaultBranch}...${branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
);
return output.trim().split('\n').filter(l => l.length > 0).length;
} catch {
return 0;
}
}
/** Extract a human-readable task slug from a takt branch name */
static extractTaskSlug(branch: string): string {
const name = branch.replace(TAKT_BRANCH_PREFIX, '');
const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, '');
return withoutTimestamp || name;
}
/**
* Extract the original task instruction from the first commit message on a branch.
* The first commit on a takt branch has the format: "takt: {original instruction}".
*/
getOriginalInstruction(
cwd: string,
defaultBranch: string,
branch: string,
): string {
try {
const output = execFileSync(
'git',
['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (!output) return '';
const firstLine = output.split('\n')[0] || '';
const TAKT_COMMIT_PREFIX = 'takt:';
if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) {
return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim();
}
return firstLine;
} catch {
return '';
}
}
/** Build list items from branch list, enriching with diff stats */
buildListItems(
projectDir: string,
branches: BranchInfo[],
defaultBranch: string,
): BranchListItem[] {
return branches.map(br => ({
info: br,
filesChanged: this.getFilesChanged(projectDir, defaultBranch, br.branch),
taskSlug: BranchManager.extractTaskSlug(br.branch),
originalInstruction: this.getOriginalInstruction(projectDir, defaultBranch, br.branch),
}));
}
}
// ---- Backward-compatible module-level functions ----
const defaultManager = new BranchManager();
export function detectDefaultBranch(cwd: string): string {
return defaultManager.detectDefaultBranch(cwd);
}
/**
* List all takt-managed branches.
*/
export function listTaktBranches(projectDir: string): BranchInfo[] {
try {
const output = execFileSync(
'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
);
return parseTaktBranches(output);
} catch (err) {
log.error('Failed to list takt branches', { error: String(err) });
return [];
}
return defaultManager.listTaktBranches(projectDir);
}
/**
* Parse `git branch --list` formatted output into BranchInfo entries.
*/
export function parseTaktBranches(output: string): BranchInfo[] {
const entries: BranchInfo[] = [];
const lines = output.trim().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const spaceIdx = trimmed.lastIndexOf(' ');
if (spaceIdx === -1) continue;
const branch = trimmed.slice(0, spaceIdx);
const commit = trimmed.slice(spaceIdx + 1);
if (branch.startsWith(TAKT_BRANCH_PREFIX)) {
entries.push({ branch, commit });
}
}
return entries;
return BranchManager.parseTaktBranches(output);
}
/**
* Get the number of files changed between the default branch and a given branch.
*/
export function getFilesChanged(cwd: string, defaultBranch: string, branch: string): number {
try {
const output = execFileSync(
'git', ['diff', '--numstat', `${defaultBranch}...${branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
);
return output.trim().split('\n').filter(l => l.length > 0).length;
} catch {
return 0;
}
return defaultManager.getFilesChanged(cwd, defaultBranch, branch);
}
/**
* Extract a human-readable task slug from a takt branch name.
* e.g. "takt/20260128T032800-fix-auth" -> "fix-auth"
*/
export function extractTaskSlug(branch: string): string {
const name = branch.replace(TAKT_BRANCH_PREFIX, '');
const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, '');
return withoutTimestamp || name;
return BranchManager.extractTaskSlug(branch);
}
/**
* Extract the original task instruction from the first commit message on a branch.
*
* The first commit on a takt branch has the format: "takt: {original instruction}".
* Strips the "takt: " prefix and returns the instruction text.
* Returns empty string if extraction fails.
*/
export function getOriginalInstruction(
cwd: string,
defaultBranch: string,
branch: string,
): string {
try {
const output = execFileSync(
'git',
['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (!output) return '';
const firstLine = output.split('\n')[0] || '';
const TAKT_COMMIT_PREFIX = 'takt:';
if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) {
return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim();
}
return firstLine;
} catch {
return '';
}
export function getOriginalInstruction(cwd: string, defaultBranch: string, branch: string): string {
return defaultManager.getOriginalInstruction(cwd, defaultBranch, branch);
}
/**
* Build list items from branch list, enriching with diff stats.
*/
export function buildListItems(
projectDir: string,
branches: BranchInfo[],
defaultBranch: string,
): BranchListItem[] {
return branches.map(br => ({
info: br,
filesChanged: getFilesChanged(projectDir, defaultBranch, br.branch),
taskSlug: extractTaskSlug(br.branch),
originalInstruction: getOriginalInstruction(projectDir, defaultBranch, br.branch),
}));
return defaultManager.buildListItems(projectDir, branches, defaultBranch);
}

View File

@ -12,7 +12,7 @@ import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import { createLogger } from '../utils/debug.js';
import { slugify } from '../utils/slug.js';
import { loadGlobalConfig } from '../config/globalConfig.js';
import { loadGlobalConfig } from '../config/global/globalConfig.js';
const log = createLogger('clone');
@ -34,234 +34,231 @@ export interface WorktreeResult {
branch: string;
}
function generateTimestamp(): string {
return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
}
const CLONE_META_DIR = 'clone-meta';
/**
* Resolve the base directory for clones from global config.
* Returns the configured worktree_dir (resolved to absolute), or ../
*/
function resolveCloneBaseDir(projectDir: string): string {
const globalConfig = loadGlobalConfig();
if (globalConfig.worktreeDir) {
return path.isAbsolute(globalConfig.worktreeDir)
? globalConfig.worktreeDir
: path.resolve(projectDir, globalConfig.worktreeDir);
}
return path.join(projectDir, '..');
}
/**
* Resolve the clone path based on options and global config.
* Manages git clone lifecycle for task isolation.
*
* Priority:
* 1. Custom path in options.worktree (string)
* 2. worktree_dir from config.yaml (if set)
* 3. Default: ../{dir-name}
*
* Format with issue: {timestamp}-{issue}-{slug}
* Format without issue: {timestamp}-{slug}
* Handles creation, removal, and metadata tracking of clones
* used for parallel task execution.
*/
function resolveClonePath(projectDir: string, options: WorktreeOptions): string {
const timestamp = generateTimestamp();
const slug = slugify(options.taskSlug);
let dirName: string;
if (options.issueNumber !== undefined && slug) {
dirName = `${timestamp}-${options.issueNumber}-${slug}`;
} else if (slug) {
dirName = `${timestamp}-${slug}`;
} else {
dirName = timestamp;
export class CloneManager {
private static generateTimestamp(): string {
return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
}
if (typeof options.worktree === 'string') {
return path.isAbsolute(options.worktree)
? options.worktree
: path.resolve(projectDir, options.worktree);
/**
* Resolve the base directory for clones from global config.
* Returns the configured worktree_dir (resolved to absolute), or ../
*/
private static resolveCloneBaseDir(projectDir: string): string {
const globalConfig = loadGlobalConfig();
if (globalConfig.worktreeDir) {
return path.isAbsolute(globalConfig.worktreeDir)
? globalConfig.worktreeDir
: path.resolve(projectDir, globalConfig.worktreeDir);
}
return path.join(projectDir, '..');
}
return path.join(resolveCloneBaseDir(projectDir), dirName);
}
/** Resolve the clone path based on options and global config */
private static resolveClonePath(projectDir: string, options: WorktreeOptions): string {
const timestamp = CloneManager.generateTimestamp();
const slug = slugify(options.taskSlug);
/**
* Resolve branch name from options.
*
* Format with issue: takt/#{issue}/{slug}
* Format without issue: takt/{timestamp}-{slug}
* Custom branch: use as-is
*/
function resolveBranchName(options: WorktreeOptions): string {
if (options.branch) {
return options.branch;
let dirName: string;
if (options.issueNumber !== undefined && slug) {
dirName = `${timestamp}-${options.issueNumber}-${slug}`;
} else if (slug) {
dirName = `${timestamp}-${slug}`;
} else {
dirName = timestamp;
}
if (typeof options.worktree === 'string') {
return path.isAbsolute(options.worktree)
? options.worktree
: path.resolve(projectDir, options.worktree);
}
return path.join(CloneManager.resolveCloneBaseDir(projectDir), dirName);
}
const slug = slugify(options.taskSlug);
/** Resolve branch name from options */
private static resolveBranchName(options: WorktreeOptions): string {
if (options.branch) {
return options.branch;
}
if (options.issueNumber !== undefined && slug) {
return `takt/#${options.issueNumber}/${slug}`;
const slug = slugify(options.taskSlug);
if (options.issueNumber !== undefined && slug) {
return `takt/#${options.issueNumber}/${slug}`;
}
const timestamp = CloneManager.generateTimestamp();
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
}
const timestamp = generateTimestamp();
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
}
private static branchExists(projectDir: string, branch: string): boolean {
try {
execFileSync('git', ['rev-parse', '--verify', branch], {
cwd: projectDir,
stdio: 'pipe',
});
return true;
} catch {
return false;
}
}
function branchExists(projectDir: string, branch: string): boolean {
try {
execFileSync('git', ['rev-parse', '--verify', branch], {
/** Clone a repository and remove origin to isolate from the main repo */
private static cloneAndIsolate(projectDir: string, clonePath: string): void {
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
execFileSync('git', ['clone', '--reference', projectDir, '--dissociate', projectDir, clonePath], {
cwd: projectDir,
stdio: 'pipe',
});
return true;
} catch {
return false;
}
}
/**
* Clone a repository and remove origin to isolate from the main repo.
*/
function cloneAndIsolate(projectDir: string, clonePath: string): void {
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
execFileSync('git', ['remote', 'remove', 'origin'], {
cwd: clonePath,
stdio: 'pipe',
});
execFileSync('git', ['clone', '--reference', projectDir, '--dissociate', projectDir, clonePath], {
cwd: projectDir,
stdio: 'pipe',
});
execFileSync('git', ['remote', 'remove', 'origin'], {
cwd: clonePath,
stdio: 'pipe',
});
// Propagate local git user config from source repo to clone
for (const key of ['user.name', 'user.email']) {
try {
const value = execFileSync('git', ['config', '--local', key], {
cwd: projectDir,
stdio: 'pipe',
}).toString().trim();
if (value) {
execFileSync('git', ['config', key, value], {
cwd: clonePath,
// Propagate local git user config from source repo to clone
for (const key of ['user.name', 'user.email']) {
try {
const value = execFileSync('git', ['config', '--local', key], {
cwd: projectDir,
stdio: 'pipe',
});
}).toString().trim();
if (value) {
execFileSync('git', ['config', key, value], {
cwd: clonePath,
stdio: 'pipe',
});
}
} catch {
// not set locally — skip
}
}
}
private static encodeBranchName(branch: string): string {
return branch.replace(/\//g, '--');
}
private static getCloneMetaPath(projectDir: string, branch: string): string {
return path.join(projectDir, '.takt', CLONE_META_DIR, `${CloneManager.encodeBranchName(branch)}.json`);
}
/** Create a git clone for a task */
createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
const clonePath = CloneManager.resolveClonePath(projectDir, options);
const branch = CloneManager.resolveBranchName(options);
log.info('Creating shared clone', { path: clonePath, branch });
CloneManager.cloneAndIsolate(projectDir, clonePath);
if (CloneManager.branchExists(clonePath, branch)) {
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
} else {
execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' });
}
this.saveCloneMeta(projectDir, branch, clonePath);
log.info('Clone created', { path: clonePath, branch });
return { path: clonePath, branch };
}
/** Create a temporary clone for an existing branch */
createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
const timestamp = CloneManager.generateTimestamp();
const clonePath = path.join(CloneManager.resolveCloneBaseDir(projectDir), `tmp-${timestamp}`);
log.info('Creating temp clone for branch', { path: clonePath, branch });
CloneManager.cloneAndIsolate(projectDir, clonePath);
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
this.saveCloneMeta(projectDir, branch, clonePath);
log.info('Temp clone created', { path: clonePath, branch });
return { path: clonePath, branch };
}
/** Remove a clone directory */
removeClone(clonePath: string): void {
log.info('Removing clone', { path: clonePath });
try {
fs.rmSync(clonePath, { recursive: true, force: true });
log.info('Clone removed', { path: clonePath });
} catch (err) {
log.error('Failed to remove clone', { path: clonePath, error: String(err) });
}
}
/** Save clone metadata (branch → clonePath mapping) */
saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
const filePath = CloneManager.getCloneMetaPath(projectDir, branch);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
log.info('Clone meta saved', { branch, clonePath });
}
/** Remove clone metadata for a branch */
removeCloneMeta(projectDir: string, branch: string): void {
try {
fs.unlinkSync(CloneManager.getCloneMetaPath(projectDir, branch));
log.info('Clone meta removed', { branch });
} catch {
// File may not exist — ignore
}
}
/** Clean up an orphaned clone directory associated with a branch */
cleanupOrphanedClone(projectDir: string, branch: string): void {
try {
const raw = fs.readFileSync(CloneManager.getCloneMetaPath(projectDir, branch), 'utf-8');
const meta = JSON.parse(raw) as { clonePath: string };
if (fs.existsSync(meta.clonePath)) {
this.removeClone(meta.clonePath);
log.info('Orphaned clone cleaned up', { branch, clonePath: meta.clonePath });
}
} catch {
// not set locally — skip
// No metadata or parse error — nothing to clean up
}
this.removeCloneMeta(projectDir, branch);
}
}
/**
* Create a git clone for a task.
*
* Uses `git clone --reference --dissociate` to create an independent clone,
* then removes origin and checks out a new branch.
*/
// ---- Backward-compatible module-level functions ----
const defaultManager = new CloneManager();
export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
const clonePath = resolveClonePath(projectDir, options);
const branch = resolveBranchName(options);
log.info('Creating shared clone', { path: clonePath, branch });
cloneAndIsolate(projectDir, clonePath);
if (branchExists(clonePath, branch)) {
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
} else {
execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' });
}
saveCloneMeta(projectDir, branch, clonePath);
log.info('Clone created', { path: clonePath, branch });
return { path: clonePath, branch };
return defaultManager.createSharedClone(projectDir, options);
}
/**
* Create a temporary clone for an existing branch.
* Used by review/instruct to work on a branch that was previously pushed.
*/
export function createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
const timestamp = generateTimestamp();
const clonePath = path.join(resolveCloneBaseDir(projectDir), `tmp-${timestamp}`);
log.info('Creating temp clone for branch', { path: clonePath, branch });
cloneAndIsolate(projectDir, clonePath);
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
saveCloneMeta(projectDir, branch, clonePath);
log.info('Temp clone created', { path: clonePath, branch });
return { path: clonePath, branch };
return defaultManager.createTempCloneForBranch(projectDir, branch);
}
/**
* Remove a clone directory.
*/
export function removeClone(clonePath: string): void {
log.info('Removing clone', { path: clonePath });
try {
fs.rmSync(clonePath, { recursive: true, force: true });
log.info('Clone removed', { path: clonePath });
} catch (err) {
log.error('Failed to remove clone', { path: clonePath, error: String(err) });
}
defaultManager.removeClone(clonePath);
}
// --- Clone metadata ---
const CLONE_META_DIR = 'clone-meta';
function encodeBranchName(branch: string): string {
return branch.replace(/\//g, '--');
}
function getCloneMetaPath(projectDir: string, branch: string): string {
return path.join(projectDir, '.takt', CLONE_META_DIR, `${encodeBranchName(branch)}.json`);
}
/**
* Save clone metadata (branch clonePath mapping).
* Used to clean up orphaned clone directories on merge/delete.
*/
export function saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
const filePath = getCloneMetaPath(projectDir, branch);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
log.info('Clone meta saved', { branch, clonePath });
defaultManager.saveCloneMeta(projectDir, branch, clonePath);
}
/**
* Remove clone metadata for a branch.
*/
export function removeCloneMeta(projectDir: string, branch: string): void {
try {
fs.unlinkSync(getCloneMetaPath(projectDir, branch));
log.info('Clone meta removed', { branch });
} catch {
// File may not exist — ignore
}
defaultManager.removeCloneMeta(projectDir, branch);
}
/**
* Clean up an orphaned clone directory associated with a branch.
* Reads metadata, removes clone directory if it still exists, then removes metadata.
*/
export function cleanupOrphanedClone(projectDir: string, branch: string): void {
try {
const raw = fs.readFileSync(getCloneMetaPath(projectDir, branch), 'utf-8');
const meta = JSON.parse(raw) as { clonePath: string };
if (fs.existsSync(meta.clonePath)) {
removeClone(meta.clonePath);
log.info('Orphaned clone cleaned up', { branch, clonePath: meta.clonePath });
}
} catch {
// No metadata or parse error — nothing to clean up
}
removeCloneMeta(projectDir, branch);
defaultManager.cleanupOrphanedClone(projectDir, branch);
}

View File

@ -2,6 +2,12 @@
* Task execution module
*/
// Classes
export { CloneManager } from './clone.js';
export { AutoCommitter } from './autoCommit.js';
export { TaskSummarizer } from './summarize.js';
export { BranchManager } from './branchList.js';
export {
TaskRunner,
type TaskInfo,
@ -34,4 +40,5 @@ export {
type BranchListItem,
} from './branchList.js';
export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js';
export { summarizeTaskName, type SummarizeOptions } from './summarize.js';
export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';

Some files were not shown because too many files have changed in this diff Show More