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'; import { tmpdir } from 'node:os';
// Mock dependencies before importing the module under test // Mock dependencies before importing the module under test
vi.mock('../commands/interactive.js', () => ({ vi.mock('../commands/interactive/interactive.js', () => ({
interactiveMode: vi.fn(), interactiveMode: vi.fn(),
})); }));
@ -16,7 +16,7 @@ vi.mock('../providers/index.js', () => ({
getProvider: vi.fn(), getProvider: vi.fn(),
})); }));
vi.mock('../config/globalConfig.js', () => ({ vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })), loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })),
})); }));
@ -33,6 +33,7 @@ vi.mock('../task/summarize.js', () => ({
vi.mock('../utils/ui.js', () => ({ vi.mock('../utils/ui.js', () => ({
success: vi.fn(), success: vi.fn(),
info: vi.fn(), info: vi.fn(),
blankLine: vi.fn(),
})); }));
vi.mock('../utils/debug.js', () => ({ 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(), listWorkflows: vi.fn(),
})); }));
vi.mock('../config/paths.js', () => ({ vi.mock('../config/paths.js', async (importOriginal) => ({ ...(await importOriginal<Record<string, unknown>>()),
getCurrentWorkflow: vi.fn(() => 'default'), 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 { getProvider } from '../providers/index.js';
import { promptInput, confirm, selectOption } from '../prompt/index.js'; import { promptInput, confirm, selectOption } from '../prompt/index.js';
import { summarizeTaskName } from '../task/summarize.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 { 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); const mockResolveIssueTask = vi.mocked(resolveIssueTask);

View File

@ -32,7 +32,7 @@ vi.mock('../config/paths.js', async (importOriginal) => {
}); });
// Import after mocking // 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', () => { describe('GlobalConfigSchema API key fields', () => {
it('should accept config without API keys', () => { 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: '' })), interactiveMode: vi.fn(() => Promise.resolve({ confirmed: false, task: '' })),
})); }));
vi.mock('../config/workflowLoader.js', () => ({ vi.mock('../config/loaders/workflowLoader.js', () => ({
listWorkflows: vi.fn(() => []), listWorkflows: vi.fn(() => []),
})); }));
@ -92,7 +92,7 @@ import { confirm } from '../prompt/index.js';
import { createSharedClone } from '../task/clone.js'; import { createSharedClone } from '../task/clone.js';
import { summarizeTaskName } from '../task/summarize.js'; import { summarizeTaskName } from '../task/summarize.js';
import { info } from '../utils/ui.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 mockConfirm = vi.mocked(confirm);
const mockCreateSharedClone = vi.mocked(createSharedClone); 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(() => ({})), loadGlobalConfig: vi.fn(() => ({})),
})); }));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { existsSync } from 'node:fs'; 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'; 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) --- // --- Mock imports (consumers must call vi.mock before importing this) ---
import { runAgent } from '../agents/runner.js'; import { runAgent } from '../agents/runner.js';
import { detectMatchedRule } from '../workflow/rule-evaluator.js'; import { detectMatchedRule } from '../workflow/evaluation/index.js';
import type { RuleMatch } from '../workflow/rule-evaluator.js'; import type { RuleMatch } from '../workflow/evaluation/index.js';
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../workflow/phase-runner.js'; import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../workflow/phase-runner.js';
import { generateReportDir } from '../utils/session.js'; import { generateReportDir } from '../utils/session.js';

View File

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

View File

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

View File

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

View File

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

View File

@ -4,17 +4,28 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
buildInstruction, InstructionBuilder,
buildReportInstruction, isReportObjectConfig,
buildStatusJudgmentInstruction, } 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, buildExecutionMetadata,
renderExecutionMetadata, renderExecutionMetadata,
generateStatusRulesFromRules,
isReportObjectConfig,
type InstructionContext, type InstructionContext,
type ReportInstructionContext, } from '../workflow/instruction-context.js';
type StatusJudgmentContext, import { generateStatusRulesFromRules } from '../workflow/status-rules.js';
} from '../workflow/instruction-builder.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'; import type { WorkflowStep, WorkflowRule } from '../models/types.js';

View File

@ -4,7 +4,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; 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' })), loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
})); }));
@ -46,7 +46,7 @@ vi.mock('node:readline', () => ({
import { createInterface } from 'node:readline'; import { createInterface } from 'node:readline';
import { getProvider } from '../providers/index.js'; 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 mockGetProvider = vi.mocked(getProvider);
const mockCreateInterface = vi.mocked(createInterface); const mockCreateInterface = vi.mocked(createInterface);

View File

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

View File

@ -10,17 +10,26 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import type { WorkflowStep, WorkflowRule, AgentResponse } from '../models/types.js'; import type { WorkflowStep, WorkflowRule, AgentResponse } from '../models/types.js';
vi.mock('../config/globalConfig.js', () => ({ vi.mock('../config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'), getLanguage: vi.fn().mockReturnValue('en'),
})); }));
import { import { InstructionBuilder } from '../workflow/instruction/InstructionBuilder.js';
buildInstruction, import { ReportInstructionBuilder, type ReportInstructionContext } from '../workflow/instruction/ReportInstructionBuilder.js';
buildReportInstruction, import { StatusJudgmentBuilder, type StatusJudgmentContext } from '../workflow/instruction/StatusJudgmentBuilder.js';
buildStatusJudgmentInstruction, import type { InstructionContext } from '../workflow/instruction-context.js';
} from '../workflow/instruction-builder.js';
import type { InstructionContext } from '../workflow/instruction-builder.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 --- // --- Test helpers ---

View File

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

View File

@ -87,8 +87,8 @@ vi.mock('../config/paths.js', async (importOriginal) => {
}; };
}); });
vi.mock('../config/globalConfig.js', async (importOriginal) => { vi.mock('../config/global/globalConfig.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../config/globalConfig.js')>(); const original = await importOriginal<typeof import('../config/global/globalConfig.js')>();
return { return {
...original, ...original,
loadGlobalConfig: vi.fn().mockReturnValue({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
@ -96,8 +96,8 @@ vi.mock('../config/globalConfig.js', async (importOriginal) => {
}; };
}); });
vi.mock('../config/projectConfig.js', async (importOriginal) => { vi.mock('../config/project/projectConfig.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../config/projectConfig.js')>(); const original = await importOriginal<typeof import('../config/project/projectConfig.js')>();
return { return {
...original, ...original,
loadProjectConfig: vi.fn().mockReturnValue({}), loadProjectConfig: vi.fn().mockReturnValue({}),
@ -121,7 +121,7 @@ vi.mock('../workflow/phase-runner.js', () => ({
// --- Imports (after mocks) --- // --- Imports (after mocks) ---
import { executePipeline } from '../commands/pipelineExecution.js'; import { executePipeline } from '../commands/execution/pipelineExecution.js';
// --- Test helpers --- // --- 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({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'), getLanguage: vi.fn().mockReturnValue('en'),
})); }));
vi.mock('../config/projectConfig.js', () => ({ vi.mock('../config/project/projectConfig.js', () => ({
loadProjectConfig: vi.fn().mockReturnValue({}), loadProjectConfig: vi.fn().mockReturnValue({}),
})); }));
// --- Imports (after mocks) --- // --- Imports (after mocks) ---
import { detectMatchedRule, evaluateAggregateConditions } from '../workflow/rule-evaluator.js'; import { detectMatchedRule, evaluateAggregateConditions } from '../workflow/evaluation/index.js';
import type { RuleMatch, RuleEvaluatorContext } from '../workflow/rule-evaluator.js'; import type { RuleMatch, RuleEvaluatorContext } from '../workflow/evaluation/index.js';
// --- Test helpers --- // --- Test helpers ---

View File

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

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import {
buildListItems, buildListItems,
type BranchInfo, type BranchInfo,
} from '../task/branchList.js'; } 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', () => { describe('parseTaktBranches', () => {
it('should parse takt/ branches from git branch output', () => { it('should parse takt/ branches from git branch output', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,4 +2,5 @@
* Agents module - exports agent execution utilities * 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,41 +9,32 @@ import {
callClaudeSkill, callClaudeSkill,
type ClaudeCallOptions, type ClaudeCallOptions,
} from '../claude/client.js'; } from '../claude/client.js';
import { type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from '../claude/process.js'; import { loadCustomAgents, loadAgentPrompt } from '../config/loaders/loader.js';
import { loadCustomAgents, loadAgentPrompt } from '../config/loader.js'; import { loadGlobalConfig } from '../config/global/globalConfig.js';
import { loadGlobalConfig } from '../config/globalConfig.js'; import { loadProjectConfig } from '../config/project/projectConfig.js';
import { loadProjectConfig } from '../config/projectConfig.js';
import { getProvider, type ProviderType, type ProviderCallOptions } from '../providers/index.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 { 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'); const log = createLogger('runner');
export type { StreamCallback }; /**
* Agent execution runner.
/** Common options for running agents */ *
export interface RunAgentOptions { * Resolves agent configuration (provider, model, prompt) and
cwd: string; * delegates execution to the appropriate provider.
sessionId?: string; */
model?: string; export class AgentRunner {
provider?: 'claude' | 'codex' | 'mock'; /** Resolve provider type from options, agent config, project config, global config */
/** Resolved path to agent prompt file */ private static resolveProvider(
agentPath?: string; cwd: string,
/** Allowed tools for this agent run */ options?: RunAgentOptions,
allowedTools?: string[]; agentConfig?: CustomAgentConfig,
/** Maximum number of agentic turns */ ): ProviderType {
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 (options?.provider) return options.provider;
if (agentConfig?.provider) return agentConfig.provider; if (agentConfig?.provider) return agentConfig.provider;
const projectConfig = loadProjectConfig(cwd); const projectConfig = loadProjectConfig(cwd);
@ -55,9 +46,14 @@ function resolveProvider(cwd: string, options?: RunAgentOptions, agentConfig?: C
// Ignore missing global config; fallback below // Ignore missing global config; fallback below
} }
return 'claude'; return 'claude';
} }
function resolveModel(cwd: string, options?: RunAgentOptions, agentConfig?: CustomAgentConfig): string | undefined { /** 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 (options?.model) return options.model;
if (agentConfig?.model) return agentConfig.model; if (agentConfig?.model) return agentConfig.model;
try { try {
@ -67,15 +63,41 @@ function resolveModel(cwd: string, options?: RunAgentOptions, agentConfig?: Cust
// Ignore missing global config // Ignore missing global config
} }
return undefined; 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');
}
/** Run a custom agent */ /**
export async function runCustomAgent( * 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, agentConfig: CustomAgentConfig,
task: string, task: string,
options: RunAgentOptions options: RunAgentOptions,
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const allowedTools = options.allowedTools ?? agentConfig.allowedTools; const allowedTools = options.allowedTools ?? agentConfig.allowedTools;
// If agent references a Claude Code agent // If agent references a Claude Code agent
@ -85,7 +107,7 @@ export async function runCustomAgent(
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools, allowedTools,
maxTurns: options.maxTurns, maxTurns: options.maxTurns,
model: resolveModel(options.cwd, options, agentConfig), model: AgentRunner.resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode, permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
@ -102,7 +124,7 @@ export async function runCustomAgent(
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools, allowedTools,
maxTurns: options.maxTurns, maxTurns: options.maxTurns,
model: resolveModel(options.cwd, options, agentConfig), model: AgentRunner.resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode, permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
@ -115,7 +137,7 @@ export async function runCustomAgent(
// Custom agent with prompt // Custom agent with prompt
const systemPrompt = loadAgentPrompt(agentConfig); const systemPrompt = loadAgentPrompt(agentConfig);
const providerType = resolveProvider(options.cwd, options, agentConfig); const providerType = AgentRunner.resolveProvider(options.cwd, options, agentConfig);
const provider = getProvider(providerType); const provider = getProvider(providerType);
const callOptions: ProviderCallOptions = { const callOptions: ProviderCallOptions = {
@ -123,7 +145,7 @@ export async function runCustomAgent(
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools, allowedTools,
maxTurns: options.maxTurns, maxTurns: options.maxTurns,
model: resolveModel(options.cwd, options, agentConfig), model: AgentRunner.resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode, permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
@ -132,50 +154,15 @@ export async function runCustomAgent(
}; };
return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions); return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions);
}
/**
* Load agent prompt from file path.
*/
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'); /** Run an agent by name or path */
const dir = basename(dirname(agentSpec)); async run(
// 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, agentSpec: string,
task: string, task: string,
options: RunAgentOptions options: RunAgentOptions,
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const agentName = extractAgentName(agentSpec); const agentName = AgentRunner.extractAgentName(agentSpec);
log.debug('Running agent', { log.debug('Running agent', {
agentSpec, agentSpec,
agentName, agentName,
@ -191,9 +178,9 @@ export async function runAgent(
if (!existsSync(options.agentPath)) { if (!existsSync(options.agentPath)) {
throw new Error(`Agent file not found: ${options.agentPath}`); throw new Error(`Agent file not found: ${options.agentPath}`);
} }
const systemPrompt = loadAgentPromptFromPath(options.agentPath); const systemPrompt = AgentRunner.loadAgentPromptFromPath(options.agentPath);
const providerType = resolveProvider(options.cwd, options); const providerType = AgentRunner.resolveProvider(options.cwd, options);
const provider = getProvider(providerType); const provider = getProvider(providerType);
const callOptions: ProviderCallOptions = { const callOptions: ProviderCallOptions = {
@ -201,7 +188,7 @@ export async function runAgent(
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools: options.allowedTools, allowedTools: options.allowedTools,
maxTurns: options.maxTurns, maxTurns: options.maxTurns,
model: resolveModel(options.cwd, options), model: AgentRunner.resolveModel(options.cwd, options),
systemPrompt, systemPrompt,
permissionMode: options.permissionMode, permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
@ -218,8 +205,29 @@ export async function runAgent(
const agentConfig = customAgents.get(agentName); const agentConfig = customAgents.get(agentName);
if (agentConfig) { if (agentConfig) {
return runCustomAgent(agentConfig, task, options); 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. * Uses the Claude Agent SDK for native TypeScript integration.
*/ */
import { executeClaudeCli, type ClaudeSpawnOptions, type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from './process.js'; import { executeClaudeCli } from './process.js';
import type { AgentDefinition } from '@anthropic-ai/claude-agent-sdk'; import type { ClaudeSpawnOptions, ClaudeCallOptions } from './types.js';
import type { AgentResponse, Status, PermissionMode } from '../models/types.js'; import type { AgentResponse, Status } from '../models/types.js';
import { createLogger } from '../utils/debug.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 */ const log = createLogger('client');
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;
}
/** /**
* Detect rule index from numbered tag pattern [STEP_NAME:N]. * 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 */ /** Validate regex pattern for ReDoS safety */
export function isRegexSafe(pattern: string): boolean { export function isRegexSafe(pattern: string): boolean {
// Limit pattern length
if (pattern.length > 200) { if (pattern.length > 200) {
return false; return false;
} }
// Dangerous patterns that can cause ReDoS
const dangerousPatterns = [ const dangerousPatterns = [
/\(\.\*\)\+/, // (.*)+ /\(\.\*\)\+/, // (.*)+
/\(\.\+\)\*/, // (.+)* /\(\.\+\)\*/, // (.+)*
@ -79,10 +56,16 @@ export function isRegexSafe(pattern: string): boolean {
return true; return true;
} }
/** Determine status from result */ /**
function determineStatus( * 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 }, result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string },
): Status { ): Status {
if (!result.success) { if (!result.success) {
if (result.interrupted) { if (result.interrupted) {
return 'interrupted'; return 'interrupted';
@ -90,15 +73,11 @@ function determineStatus(
return 'blocked'; return 'blocked';
} }
return 'done'; return 'done';
} }
/** Call Claude with an agent prompt */ /** Convert ClaudeCallOptions to ClaudeSpawnOptions */
export async function callClaude( private static toSpawnOptions(options: ClaudeCallOptions): ClaudeSpawnOptions {
agentType: string, return {
prompt: string,
options: ClaudeCallOptions
): Promise<AgentResponse> {
const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd, cwd: options.cwd,
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools: options.allowedTools, allowedTools: options.allowedTools,
@ -113,9 +92,17 @@ export async function callClaude(
bypassPermissions: options.bypassPermissions, bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey, 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 result = await executeClaudeCli(prompt, spawnOptions);
const status = determineStatus(result); const status = ClaudeClient.determineStatus(result);
if (!result.success && result.error) { if (!result.success && result.error) {
log.error('Agent query failed', { agent: agentType, error: result.error }); log.error('Agent query failed', { agent: agentType, error: result.error });
@ -129,32 +116,21 @@ export async function callClaude(
sessionId: result.sessionId, sessionId: result.sessionId,
error: result.error, error: result.error,
}; };
} }
/** Call Claude with a custom agent configuration */ /** Call Claude with a custom agent configuration */
export async function callClaudeCustom( async callCustom(
agentName: string, agentName: string,
prompt: string, prompt: string,
systemPrompt: string, systemPrompt: string,
options: ClaudeCallOptions options: ClaudeCallOptions,
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const spawnOptions: ClaudeSpawnOptions = { const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd, ...ClaudeClient.toSpawnOptions(options),
sessionId: options.sessionId,
allowedTools: options.allowedTools,
model: options.model,
maxTurns: options.maxTurns,
systemPrompt, 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 result = await executeClaudeCli(prompt, spawnOptions);
const status = determineStatus(result); const status = ClaudeClient.determineStatus(result);
if (!result.success && result.error) { if (!result.success && result.error) {
log.error('Agent query failed', { agent: agentName, error: result.error }); log.error('Agent query failed', { agent: agentName, error: result.error });
@ -168,104 +144,25 @@ export async function callClaudeCustom(
sessionId: result.sessionId, sessionId: result.sessionId,
error: result.error, error: result.error,
}; };
}
/**
* 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;
}
/**
* 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');
}
/**
* 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 */
} async callAgent(
/** Call a Claude Code built-in agent (using claude --agent flag if available) */
export async function callClaudeAgent(
claudeAgentName: string, claudeAgentName: string,
prompt: string, prompt: string,
options: ClaudeCallOptions options: ClaudeCallOptions,
): Promise<AgentResponse> { ): 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.`; const systemPrompt = `You are the ${claudeAgentName} agent. Follow the standard ${claudeAgentName} workflow.`;
return this.callCustom(claudeAgentName, prompt, systemPrompt, options);
}
return callClaudeCustom(claudeAgentName, prompt, systemPrompt, options); /** Call a Claude Code skill (using /skill command) */
} async callSkill(
/** Call a Claude Code skill (using /skill command) */
export async function callClaudeSkill(
skillName: string, skillName: string,
prompt: string, prompt: string,
options: ClaudeCallOptions options: ClaudeCallOptions,
): Promise<AgentResponse> { ): Promise<AgentResponse> {
// Prepend skill invocation to prompt
const fullPrompt = `/${skillName}\n\n${prompt}`; const fullPrompt = `/${skillName}\n\n${prompt}`;
const spawnOptions: ClaudeSpawnOptions = { const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd, cwd: options.cwd,
sessionId: options.sessionId, sessionId: options.sessionId,
@ -294,4 +191,135 @@ export async function callClaudeSkill(
sessionId: result.sessionId, sessionId: result.sessionId,
error: result.error, 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);
}
}
// ---- Backward-compatible module-level functions ----
const defaultClient = new ClaudeClient();
export async function callClaude(
agentType: string,
prompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
return defaultClient.call(agentType, prompt, options);
}
export async function callClaudeCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
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);
}
export function detectJudgeIndex(content: string): number {
return ClaudeClient.detectJudgeIndex(content);
}
export function buildJudgePrompt(
agentOutput: string,
aiConditions: { index: number; text: string }[],
): string {
return ClaudeClient.buildJudgePrompt(agentOutput, aiConditions);
}
export async function callAiJudge(
agentOutput: string,
aiConditions: { index: number; text: string }[],
options: { cwd: string },
): Promise<number> {
return defaultClient.callAiJudge(agentOutput, aiConditions, options);
} }

View File

@ -8,11 +8,8 @@
import { import {
query, query,
AbortError, AbortError,
type Options,
type SDKResultMessage, type SDKResultMessage,
type SDKAssistantMessage, type SDKAssistantMessage,
type AgentDefinition,
type PermissionMode,
} from '@anthropic-ai/claude-agent-sdk'; } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '../utils/debug.js'; import { createLogger } from '../utils/debug.js';
import { getErrorMessage } from '../utils/error.js'; import { getErrorMessage } from '../utils/error.js';
@ -22,105 +19,28 @@ import {
unregisterQuery, unregisterQuery,
} from './query-manager.js'; } from './query-manager.js';
import { sdkMessageToStreamEvent } from './stream-converter.js'; import { sdkMessageToStreamEvent } from './stream-converter.js';
import { import { SdkOptionsBuilder } from './options-builder.js';
createCanUseToolCallback,
createAskUserQuestionHooks,
} from './options-builder.js';
import type { import type {
StreamCallback, ClaudeSpawnOptions,
PermissionHandler,
AskUserQuestionHandler,
ClaudeResult, ClaudeResult,
} from './types.js'; } from './types.js';
const log = createLogger('claude-sdk'); 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 { export class QueryExecutor {
const canUseTool = options.onPermissionRequest /**
? createCanUseToolCallback(options.onPermissionRequest) * Execute a Claude query.
: undefined;
const hooks = options.onAskUserQuestion
? createAskUserQuestionHooks(options.onAskUserQuestion)
: undefined;
// 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';
}
// 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,
};
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;
if (options.anthropicApiKey) {
sdkOptions.env = {
...process.env as Record<string, string>,
ANTHROPIC_API_KEY: options.anthropicApiKey,
};
}
if (options.onStream) {
sdkOptions.includePartialMessages = true;
}
if (options.sessionId) {
sdkOptions.resume = options.sessionId;
} else {
sdkOptions.continue = false;
}
return sdkOptions;
}
/**
* Execute a Claude query using the Agent SDK.
*/ */
export async function executeClaudeQuery( async execute(
prompt: string, prompt: string,
options: ExecuteOptions options: ClaudeSpawnOptions,
): Promise<ClaudeResult> { ): Promise<ClaudeResult> {
const queryId = generateQueryId(); const queryId = generateQueryId();
log.debug('Executing Claude query via SDK', { log.debug('Executing Claude query via SDK', {
@ -131,7 +51,7 @@ export async function executeClaudeQuery(
allowedTools: options.allowedTools, allowedTools: options.allowedTools,
}); });
const sdkOptions = buildSdkOptions(options); const sdkOptions = new SdkOptionsBuilder(options).build();
let sessionId: string | undefined; let sessionId: string | undefined;
let success = false; let success = false;
@ -196,21 +116,22 @@ export async function executeClaudeQuery(
}; };
} catch (error) { } catch (error) {
unregisterQuery(queryId); unregisterQuery(queryId);
return handleQueryError(error, queryId, sessionId, hasResultMessage, success, resultContent); return QueryExecutor.handleQueryError(error, queryId, sessionId, hasResultMessage, success, resultContent);
}
} }
}
/** /**
* Handle query execution errors. * Handle query execution errors.
* Classifies errors (abort, rate limit, auth, timeout) and returns appropriate ClaudeResult.
*/ */
function handleQueryError( private static handleQueryError(
error: unknown, error: unknown,
queryId: string, queryId: string,
sessionId: string | undefined, sessionId: string | undefined,
hasResultMessage: boolean, hasResultMessage: boolean,
success: boolean, success: boolean,
resultContent: string | undefined resultContent: string | undefined,
): ClaudeResult { ): ClaudeResult {
if (error instanceof AbortError) { if (error instanceof AbortError) {
log.info('Claude query was interrupted', { queryId }); log.info('Claude query was interrupted', { queryId });
return { return {
@ -251,4 +172,15 @@ function handleQueryError(
} }
return { success: false, content: '', error: errorMessage }; return { success: false, content: '', error: errorMessage };
}
}
// ---- Backward-compatible module-level function ----
/** @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. * from the Claude integration module.
*/ */
// Main process and execution // Classes
export { ClaudeProcess, executeClaudeCli, type ClaudeSpawnOptions } from './process.js'; export { ClaudeClient } from './client.js';
export { executeClaudeQuery, type ExecuteOptions } from './executor.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 { export {
generateQueryId, generateQueryId,
hasActiveProcess, hasActiveProcess,
@ -22,7 +29,7 @@ export {
interruptCurrentProcess, interruptCurrentProcess,
} from './query-manager.js'; } from './query-manager.js';
// Types (only from types.ts, avoiding duplicates from process.ts) // Types
export type { export type {
StreamEvent, StreamEvent,
StreamCallback, StreamCallback,
@ -32,6 +39,8 @@ export type {
AskUserQuestionHandler, AskUserQuestionHandler,
ClaudeResult, ClaudeResult,
ClaudeResultWithQueryId, ClaudeResultWithQueryId,
ClaudeCallOptions,
ClaudeSpawnOptions,
InitEventData, InitEventData,
ToolUseEventData, ToolUseEventData,
ToolResultEventData, ToolResultEventData,
@ -45,19 +54,22 @@ export type {
// Stream conversion // Stream conversion
export { sdkMessageToStreamEvent } from './stream-converter.js'; export { sdkMessageToStreamEvent } from './stream-converter.js';
// Options building // Options building (backward-compatible functions)
export { export {
createCanUseToolCallback, createCanUseToolCallback,
createAskUserQuestionHooks, createAskUserQuestionHooks,
buildSdkOptions,
} from './options-builder.js'; } from './options-builder.js';
// Client functions and types // Client functions (backward-compatible)
export { export {
callClaude, callClaude,
callClaudeCustom, callClaudeCustom,
callClaudeAgent, callClaudeAgent,
callClaudeSkill, callClaudeSkill,
callAiJudge,
detectRuleIndex, detectRuleIndex,
detectJudgeIndex,
buildJudgePrompt,
isRegexSafe, isRegexSafe,
type ClaudeCallOptions,
} from './client.js'; } from './client.js';

View File

@ -14,7 +14,6 @@ import type {
HookInput, HookInput,
HookJSONOutput, HookJSONOutput,
PreToolUseHookInput, PreToolUseHookInput,
AgentDefinition,
PermissionMode, PermissionMode,
} from '@anthropic-ai/claude-agent-sdk'; } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '../utils/debug.js'; import { createLogger } from '../utils/debug.js';
@ -22,36 +21,91 @@ import type {
PermissionHandler, PermissionHandler,
AskUserQuestionInput, AskUserQuestionInput,
AskUserQuestionHandler, AskUserQuestionHandler,
ClaudeSpawnOptions,
} from './types.js'; } from './types.js';
const log = createLogger('claude-sdk'); 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. * Create canUseTool callback from permission handler.
*/ */
export function createCanUseToolCallback( static createCanUseToolCallback(
handler: PermissionHandler handler: PermissionHandler
): CanUseTool { ): CanUseTool {
return async ( return async (
toolName: string, toolName: string,
input: Record<string, unknown>, input: Record<string, unknown>,
@ -70,14 +124,14 @@ export function createCanUseToolCallback(
decisionReason: callbackOptions.decisionReason, decisionReason: callbackOptions.decisionReason,
}); });
}; };
} }
/** /**
* Create hooks for AskUserQuestion handling. * Create hooks for AskUserQuestion handling.
*/ */
export function createAskUserQuestionHooks( static createAskUserQuestionHooks(
askUserHandler: AskUserQuestionHandler askUserHandler: AskUserQuestionHandler
): Partial<Record<string, HookCallbackMatcher[]>> { ): Partial<Record<string, HookCallbackMatcher[]>> {
const preToolUseHook = async ( const preToolUseHook = async (
input: HookInput, input: HookInput,
_toolUseID: string | undefined, _toolUseID: string | undefined,
@ -109,44 +163,23 @@ export function createAskUserQuestionHooks(
hooks: [preToolUseHook], hooks: [preToolUseHook],
}], }],
}; };
}
}
// ---- Backward-compatible module-level functions ----
export function createCanUseToolCallback(
handler: PermissionHandler
): CanUseTool {
return SdkOptionsBuilder.createCanUseToolCallback(handler);
}
export function createAskUserQuestionHooks(
askUserHandler: AskUserQuestionHandler
): Partial<Record<string, HookCallbackMatcher[]>> {
return SdkOptionsBuilder.createAskUserQuestionHooks(askUserHandler);
} }
/**
* Build SDK options from ClaudeSpawnOptions.
*/
export function buildSdkOptions(options: ClaudeSpawnOptions): Options { export function buildSdkOptions(options: ClaudeSpawnOptions): Options {
// Create canUseTool callback if permission handler is provided return new SdkOptionsBuilder(options).build();
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;
} }

View File

@ -5,16 +5,13 @@
* instead of spawning CLI processes. * instead of spawning CLI processes.
*/ */
import type { AgentDefinition, PermissionMode } from '@anthropic-ai/claude-agent-sdk';
import { import {
hasActiveProcess, hasActiveProcess,
interruptCurrentProcess, interruptCurrentProcess,
} from './query-manager.js'; } from './query-manager.js';
import { executeClaudeQuery } from './executor.js'; import { executeClaudeQuery } from './executor.js';
import type { import type {
StreamCallback, ClaudeSpawnOptions,
PermissionHandler,
AskUserQuestionHandler,
ClaudeResult, ClaudeResult,
} from './types.js'; } from './types.js';
@ -28,6 +25,7 @@ export type {
AskUserQuestionHandler, AskUserQuestionHandler,
ClaudeResult, ClaudeResult,
ClaudeResultWithQueryId, ClaudeResultWithQueryId,
ClaudeSpawnOptions,
InitEventData, InitEventData,
ToolUseEventData, ToolUseEventData,
ToolResultEventData, ToolResultEventData,
@ -49,37 +47,13 @@ export {
interruptCurrentProcess, interruptCurrentProcess,
} from './query-manager.js'; } 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. * Execute a Claude query using the Agent SDK.
* Supports concurrent execution with query ID tracking. * Supports concurrent execution with query ID tracking.
*/ */
export async function executeClaudeCli( export async function executeClaudeCli(
prompt: string, prompt: string,
options: ClaudeSpawnOptions options: ClaudeSpawnOptions,
): Promise<ClaudeResult> { ): Promise<ClaudeResult> {
return executeClaudeQuery(prompt, options); return executeClaudeQuery(prompt, options);
} }

View File

@ -3,83 +3,134 @@
* *
* Handles tracking and lifecycle management of active Claude queries. * Handles tracking and lifecycle management of active Claude queries.
* Supports concurrent query execution with interrupt capabilities. * 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'; import type { Query } from '@anthropic-ai/claude-agent-sdk';
/** /**
* Active query registry for interrupt support. * Registry for tracking active Claude queries.
* Uses a Map to support concurrent query execution. * 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 */ /** Generate a unique query ID */
export function generateQueryId(): string { export function generateQueryId(): string {
return `q-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; return `q-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
} }
/** Check if there is an active Claude process */
export function hasActiveProcess(): boolean { 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 { export function isQueryActive(queryId: string): boolean {
return activeQueries.has(queryId); return QueryRegistry.getInstance().isQueryActive(queryId);
} }
/** Get count of active queries */
export function getActiveQueryCount(): number { export function getActiveQueryCount(): number {
return activeQueries.size; return QueryRegistry.getInstance().getActiveQueryCount();
} }
/** Register an active query */
export function registerQuery(queryId: string, queryInstance: Query): void { 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 { 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 { export function interruptQuery(queryId: string): boolean {
const queryInstance = activeQueries.get(queryId); return QueryRegistry.getInstance().interruptQuery(queryId);
if (queryInstance) {
queryInstance.interrupt();
activeQueries.delete(queryId);
return true;
}
return false;
} }
/**
* Interrupt all active Claude queries.
* @returns number of queries that were interrupted
*/
export function interruptAllQueries(): number { export function interruptAllQueries(): number {
const count = activeQueries.size; return QueryRegistry.getInstance().interruptAllQueries();
for (const [id, queryInstance] of activeQueries) {
queryInstance.interrupt();
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
*/
export function interruptCurrentProcess(): boolean { export function interruptCurrentProcess(): boolean {
if (activeQueries.size === 0) { return QueryRegistry.getInstance().interruptCurrentProcess();
return false;
}
// Interrupt all queries for backward compatibility
// In the old design, there was only one query
interruptAllQueries();
return true;
} }

View File

@ -5,7 +5,8 @@
* used throughout the Claude integration layer. * 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 // Re-export PermissionResult for convenience
export type { PermissionResult, PermissionUpdate }; export type { PermissionResult, PermissionUpdate };
@ -113,3 +114,51 @@ export interface ClaudeResult {
export interface ClaudeResultWithQueryId extends ClaudeResult { export interface ClaudeResultWithQueryId extends ClaudeResult {
queryId: string; 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 { import {
selectAndExecuteTask, selectAndExecuteTask,
type SelectAndExecuteOptions, type SelectAndExecuteOptions,
} from './commands/selectAndExecute.js'; } from './commands/execution/selectAndExecute.js';
import type { TaskExecutionOptions } from './commands/taskExecution.js'; import type { TaskExecutionOptions } from './commands/execution/taskExecution.js';
import type { ProviderType } from './providers/index.js'; import type { ProviderType } from './providers/index.js';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);

View File

@ -6,106 +6,16 @@
import { Codex } from '@openai/codex-sdk'; import { Codex } from '@openai/codex-sdk';
import type { AgentResponse, Status } from '../models/types.js'; 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 { createLogger } from '../utils/debug.js';
import { getErrorMessage } from '../utils/error.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'); 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 CodexEvent = {
type: string; type: string;
[key: string]: unknown; [key: string]: unknown;
@ -117,7 +27,94 @@ type CodexItem = {
[key: string]: unknown; [key: string]: unknown;
}; };
function formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }>): string { /**
* Client for Codex SDK agent interactions.
*
* Handles thread management, streaming event conversion,
* and response processing.
*/
export class CodexClient {
// ---- Stream emission helpers (private) ----
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;
}
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',
},
});
}
private static emitText(onStream: StreamCallback | undefined, text: string): void {
if (!onStream || !text) return;
onStream({ type: 'text', data: { text } });
}
private static emitThinking(onStream: StreamCallback | undefined, thinking: string): void {
if (!onStream || !thinking) return;
onStream({ type: 'thinking', data: { thinking } });
}
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 ''; if (!changes.length) return '';
return changes return changes
.map((change) => { .map((change) => {
@ -126,13 +123,13 @@ function formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }
}) })
.filter(Boolean) .filter(Boolean)
.join('\n'); .join('\n');
} }
function emitCodexItemStart( private static emitCodexItemStart(
item: CodexItem, item: CodexItem,
onStream: StreamCallback | undefined, onStream: StreamCallback | undefined,
startedItems: Set<string> startedItems: Set<string>,
): void { ): void {
if (!onStream) return; if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
if (startedItems.has(id)) return; if (startedItems.has(id)) return;
@ -140,43 +137,43 @@ function emitCodexItemStart(
switch (item.type) { switch (item.type) {
case 'command_execution': { case 'command_execution': {
const command = typeof item.command === 'string' ? item.command : ''; const command = typeof item.command === 'string' ? item.command : '';
emitToolUse(onStream, 'Bash', { command }, id); CodexClient.emitToolUse(onStream, 'Bash', { command }, id);
startedItems.add(id); startedItems.add(id);
break; break;
} }
case 'mcp_tool_call': { case 'mcp_tool_call': {
const tool = typeof item.tool === 'string' ? item.tool : 'Tool'; const tool = typeof item.tool === 'string' ? item.tool : 'Tool';
const args = (item.arguments ?? {}) as Record<string, unknown>; const args = (item.arguments ?? {}) as Record<string, unknown>;
emitToolUse(onStream, tool, args, id); CodexClient.emitToolUse(onStream, tool, args, id);
startedItems.add(id); startedItems.add(id);
break; break;
} }
case 'web_search': { case 'web_search': {
const query = typeof item.query === 'string' ? item.query : ''; const query = typeof item.query === 'string' ? item.query : '';
emitToolUse(onStream, 'WebSearch', { query }, id); CodexClient.emitToolUse(onStream, 'WebSearch', { query }, id);
startedItems.add(id); startedItems.add(id);
break; break;
} }
case 'file_change': { case 'file_change': {
const changes = Array.isArray(item.changes) ? item.changes : []; const changes = Array.isArray(item.changes) ? item.changes : [];
const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>); const summary = CodexClient.formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id); CodexClient.emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id);
startedItems.add(id); startedItems.add(id);
break; break;
} }
default: default:
break; break;
} }
} }
function emitCodexItemCompleted( private static emitCodexItemCompleted(
item: CodexItem, item: CodexItem,
onStream: StreamCallback | undefined, onStream: StreamCallback | undefined,
startedItems: Set<string>, startedItems: Set<string>,
outputOffsets: Map<string, number>, outputOffsets: Map<string, number>,
textOffsets: Map<string, number>, textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number> thinkingOffsets: Map<string, number>,
): void { ): void {
if (!onStream) return; if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
@ -186,7 +183,7 @@ function emitCodexItemCompleted(
if (text) { if (text) {
const prev = thinkingOffsets.get(id) ?? 0; const prev = thinkingOffsets.get(id) ?? 0;
if (text.length > prev) { if (text.length > prev) {
emitThinking(onStream, text.slice(prev) + '\n'); CodexClient.emitThinking(onStream, text.slice(prev) + '\n');
thinkingOffsets.set(id, text.length); thinkingOffsets.set(id, text.length);
} }
} }
@ -197,7 +194,7 @@ function emitCodexItemCompleted(
if (text) { if (text) {
const prev = textOffsets.get(id) ?? 0; const prev = textOffsets.get(id) ?? 0;
if (text.length > prev) { if (text.length > prev) {
emitText(onStream, text.slice(prev)); CodexClient.emitText(onStream, text.slice(prev));
textOffsets.set(id, text.length); textOffsets.set(id, text.length);
} }
} }
@ -205,13 +202,13 @@ function emitCodexItemCompleted(
} }
case 'command_execution': { case 'command_execution': {
if (!startedItems.has(id)) { if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems); CodexClient.emitCodexItemStart(item, onStream, startedItems);
} }
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : ''; const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
if (output) { if (output) {
const prev = outputOffsets.get(id) ?? 0; const prev = outputOffsets.get(id) ?? 0;
if (output.length > prev) { if (output.length > prev) {
emitToolOutput(onStream, 'Bash', output.slice(prev)); CodexClient.emitToolOutput(onStream, 'Bash', output.slice(prev));
outputOffsets.set(id, output.length); outputOffsets.set(id, output.length);
} }
} }
@ -219,12 +216,12 @@ function emitCodexItemCompleted(
const status = typeof item.status === 'string' ? item.status : ''; const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed' || (exitCode !== undefined && exitCode !== 0); const isError = status === 'failed' || (exitCode !== undefined && exitCode !== 0);
const content = output || (exitCode !== undefined ? `Exit code: ${exitCode}` : ''); const content = output || (exitCode !== undefined ? `Exit code: ${exitCode}` : '');
emitToolResult(onStream, content, isError); CodexClient.emitToolResult(onStream, content, isError);
break; break;
} }
case 'mcp_tool_call': { case 'mcp_tool_call': {
if (!startedItems.has(id)) { if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems); CodexClient.emitCodexItemStart(item, onStream, startedItems);
} }
const status = typeof item.status === 'string' ? item.status : ''; const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed' || !!item.error; const isError = status === 'failed' || !!item.error;
@ -240,53 +237,53 @@ function emitCodexItemCompleted(
content = ''; content = '';
} }
} }
emitToolResult(onStream, content, isError); CodexClient.emitToolResult(onStream, content, isError);
break; break;
} }
case 'web_search': { case 'web_search': {
if (!startedItems.has(id)) { if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems); CodexClient.emitCodexItemStart(item, onStream, startedItems);
} }
emitToolResult(onStream, 'Search completed', false); CodexClient.emitToolResult(onStream, 'Search completed', false);
break; break;
} }
case 'file_change': { case 'file_change': {
if (!startedItems.has(id)) { if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems); CodexClient.emitCodexItemStart(item, onStream, startedItems);
} }
const status = typeof item.status === 'string' ? item.status : ''; const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed'; const isError = status === 'failed';
const changes = Array.isArray(item.changes) ? item.changes : []; const changes = Array.isArray(item.changes) ? item.changes : [];
const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>); const summary = CodexClient.formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
emitToolResult(onStream, summary || 'Applied patch', isError); CodexClient.emitToolResult(onStream, summary || 'Applied patch', isError);
break; break;
} }
default: default:
break; break;
} }
} }
function emitCodexItemUpdate( private static emitCodexItemUpdate(
item: CodexItem, item: CodexItem,
onStream: StreamCallback | undefined, onStream: StreamCallback | undefined,
startedItems: Set<string>, startedItems: Set<string>,
outputOffsets: Map<string, number>, outputOffsets: Map<string, number>,
textOffsets: Map<string, number>, textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number> thinkingOffsets: Map<string, number>,
): void { ): void {
if (!onStream) return; if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
switch (item.type) { switch (item.type) {
case 'command_execution': { case 'command_execution': {
if (!startedItems.has(id)) { if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems); CodexClient.emitCodexItemStart(item, onStream, startedItems);
} }
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : ''; const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
if (output) { if (output) {
const prev = outputOffsets.get(id) ?? 0; const prev = outputOffsets.get(id) ?? 0;
if (output.length > prev) { if (output.length > prev) {
emitToolOutput(onStream, 'Bash', output.slice(prev)); CodexClient.emitToolOutput(onStream, 'Bash', output.slice(prev));
outputOffsets.set(id, output.length); outputOffsets.set(id, output.length);
} }
} }
@ -297,7 +294,7 @@ function emitCodexItemUpdate(
if (text) { if (text) {
const prev = textOffsets.get(id) ?? 0; const prev = textOffsets.get(id) ?? 0;
if (text.length > prev) { if (text.length > prev) {
emitText(onStream, text.slice(prev)); CodexClient.emitText(onStream, text.slice(prev));
textOffsets.set(id, text.length); textOffsets.set(id, text.length);
} }
} }
@ -308,43 +305,33 @@ function emitCodexItemUpdate(
if (text) { if (text) {
const prev = thinkingOffsets.get(id) ?? 0; const prev = thinkingOffsets.get(id) ?? 0;
if (text.length > prev) { if (text.length > prev) {
emitThinking(onStream, text.slice(prev)); CodexClient.emitThinking(onStream, text.slice(prev));
thinkingOffsets.set(id, text.length); thinkingOffsets.set(id, text.length);
} }
} }
break; break;
} }
case 'file_change': { case 'file_change':
if (!startedItems.has(id)) { case 'mcp_tool_call':
emitCodexItemStart(item, onStream, startedItems);
}
break;
}
case 'mcp_tool_call': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
break;
}
case 'web_search': { case 'web_search': {
if (!startedItems.has(id)) { if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems); CodexClient.emitCodexItemStart(item, onStream, startedItems);
} }
break; break;
} }
default: default:
break; break;
} }
} }
/** // ---- Public API ----
* Call Codex with an agent prompt.
*/ /** Call Codex with an agent prompt */
export async function callCodex( async call(
agentType: string, agentType: string,
prompt: string, prompt: string,
options: CodexCallOptions options: CodexCallOptions,
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined); const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined);
const threadOptions = { const threadOptions = {
model: options.model, model: options.model,
@ -353,7 +340,7 @@ export async function callCodex(
const thread = options.sessionId const thread = options.sessionId
? await codex.resumeThread(options.sessionId, threadOptions) ? await codex.resumeThread(options.sessionId, threadOptions)
: await codex.startThread(threadOptions); : await codex.startThread(threadOptions);
let threadId = extractThreadId(thread) || options.sessionId; let threadId = CodexClient.extractThreadId(thread) || options.sessionId;
const fullPrompt = options.systemPrompt const fullPrompt = options.systemPrompt
? `${options.systemPrompt}\n\n${prompt}` ? `${options.systemPrompt}\n\n${prompt}`
@ -379,7 +366,7 @@ export async function callCodex(
for await (const event of events as AsyncGenerator<CodexEvent>) { for await (const event of events as AsyncGenerator<CodexEvent>) {
if (event.type === 'thread.started') { if (event.type === 'thread.started') {
threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId; threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId;
emitInit(options.onStream, options.model, threadId); CodexClient.emitInit(options.onStream, options.model, threadId);
continue; continue;
} }
@ -400,7 +387,7 @@ export async function callCodex(
if (event.type === 'item.started') { if (event.type === 'item.started') {
const item = event.item as CodexItem | undefined; const item = event.item as CodexItem | undefined;
if (item) { if (item) {
emitCodexItemStart(item, options.onStream, startedItems); CodexClient.emitCodexItemStart(item, options.onStream, startedItems);
} }
continue; continue;
} }
@ -422,7 +409,7 @@ export async function callCodex(
} }
} }
} }
emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets); CodexClient.emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets);
} }
continue; continue;
} }
@ -449,13 +436,13 @@ export async function callCodex(
content += text; content += text;
} }
} }
emitCodexItemCompleted( CodexClient.emitCodexItemCompleted(
item, item,
options.onStream, options.onStream,
startedItems, startedItems,
outputOffsets, outputOffsets,
textOffsets, textOffsets,
thinkingOffsets thinkingOffsets,
); );
} }
continue; continue;
@ -464,7 +451,7 @@ export async function callCodex(
if (!success) { if (!success) {
const message = failureMessage || 'Codex execution failed'; const message = failureMessage || 'Codex execution failed';
emitResult(options.onStream, false, message, threadId); CodexClient.emitResult(options.onStream, false, message, threadId);
return { return {
agent: agentType, agent: agentType,
status: 'blocked', status: 'blocked',
@ -475,20 +462,18 @@ export async function callCodex(
} }
const trimmed = content.trim(); const trimmed = content.trim();
emitResult(options.onStream, true, trimmed, threadId); CodexClient.emitResult(options.onStream, true, trimmed, threadId);
const status = determineStatus(true);
return { return {
agent: agentType, agent: agentType,
status, status: 'done',
content: trimmed, content: trimmed,
timestamp: new Date(), timestamp: new Date(),
sessionId: threadId, sessionId: threadId,
}; };
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
emitResult(options.onStream, false, message, threadId); CodexClient.emitResult(options.onStream, false, message, threadId);
return { return {
agent: agentType, agent: agentType,
@ -498,19 +483,39 @@ export async function callCodex(
sessionId: threadId, 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,
});
}
}
// ---- 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);
} }
/**
* Call Codex with a custom agent configuration (system prompt + prompt).
*/
export async function callCodexCustom( export async function callCodexCustom(
agentName: string, agentName: string,
prompt: string, prompt: string,
systemPrompt: string, systemPrompt: string,
options: CodexCallOptions options: CodexCallOptions,
): Promise<AgentResponse> { ): Promise<AgentResponse> {
return callCodex(agentName, prompt, { return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
...options,
systemPrompt,
});
} }

View File

@ -2,4 +2,5 @@
* Codex integration exports * 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 { fetchIssue, formatIssueAsTask, checkGhCli, type GitHubIssue } from '../../github/issue.js';
import { createPullRequest, pushBranch, buildPrBody } from '../../github/pr.js'; import { createPullRequest, pushBranch, buildPrBody } from '../../github/pr.js';
import { stageAndCommit } from '../../task/git.js'; import { stageAndCommit } from '../../task/git.js';
import { executeTask, type TaskExecutionOptions } from '../taskExecution.js'; import { executeTask, type TaskExecutionOptions } from './taskExecution.js';
import { loadGlobalConfig } from '../../config/globalConfig.js'; import { loadGlobalConfig } from '../../config/global/globalConfig.js';
import { info, error, success, status, blankLine } from '../../utils/ui.js'; import { info, error, success, status, blankLine } from '../../utils/ui.js';
import { createLogger } from '../../utils/debug.js'; import { createLogger } from '../../utils/debug.js';
import { getErrorMessage } from '../../utils/error.js'; import { getErrorMessage } from '../../utils/error.js';

View File

@ -7,7 +7,7 @@
*/ */
import { getCurrentWorkflow } from '../../config/paths.js'; 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 { selectOptionWithDefault, confirm } from '../../prompt/index.js';
import { createSharedClone } from '../../task/clone.js'; import { createSharedClone } from '../../task/clone.js';
import { autoCommitAndPush } from '../../task/autoCommit.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 { info, error, success } from '../../utils/ui.js';
import { createLogger } from '../../utils/debug.js'; import { createLogger } from '../../utils/debug.js';
import { createPullRequest, buildPrBody } from '../../github/pr.js'; import { createPullRequest, buildPrBody } from '../../github/pr.js';
import { executeTask } from '../taskExecution.js'; import { executeTask } from './taskExecution.js';
import type { TaskExecutionOptions } from '../taskExecution.js'; import type { TaskExecutionOptions } from './taskExecution.js';
const log = createLogger('selectAndExecute'); const log = createLogger('selectAndExecute');

View File

@ -3,7 +3,7 @@
*/ */
import { loadAgentSessions, updateAgentSession } from '../../config/paths.js'; 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'; import type { AgentResponse } from '../../models/types.js';
/** /**

View File

@ -17,7 +17,7 @@ import {
} from '../../utils/ui.js'; } from '../../utils/ui.js';
import { createLogger } from '../../utils/debug.js'; import { createLogger } from '../../utils/debug.js';
import { getErrorMessage } from '../../utils/error.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 { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
import type { ProviderType } from '../../providers/index.js'; import type { ProviderType } from '../../providers/index.js';

View File

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

View File

@ -2,20 +2,20 @@
* Command exports * Command exports
*/ */
export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './workflowExecution.js'; export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './execution/workflowExecution.js';
export { executeTask, runAllTasks } from './taskExecution.js'; export { executeTask, runAllTasks, type TaskExecutionOptions } from './execution/taskExecution.js';
export { addTask } from './addTask.js'; export { addTask } from './management/addTask.js';
export { ejectBuiltin } from './eject.js'; export { ejectBuiltin } from './management/eject.js';
export { watchTasks } from './watchTasks.js'; export { watchTasks } from './management/watchTasks.js';
export { withAgentSession } from './session.js'; export { withAgentSession } from './execution/session.js';
export { switchWorkflow } from './workflow.js'; export { switchWorkflow } from './management/workflow.js';
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js'; export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './management/config.js';
export { listTasks } from './listTasks.js'; export { listTasks } from './management/listTasks.js';
export { interactiveMode } from './interactive.js'; export { interactiveMode } from './interactive/interactive.js';
export { executePipeline, type PipelineExecutionOptions } from './pipelineExecution.js'; export { executePipeline, type PipelineExecutionOptions } from './execution/pipelineExecution.js';
export { export {
selectAndExecuteTask, selectAndExecuteTask,
confirmAndCreateWorktree, confirmAndCreateWorktree,
type SelectAndExecuteOptions, type SelectAndExecuteOptions,
type WorktreeConfirmationResult, 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 * as readline from 'node:readline';
import chalk from 'chalk'; import chalk from 'chalk';
import { loadGlobalConfig } from '../../config/globalConfig.js'; import { loadGlobalConfig } from '../../config/global/globalConfig.js';
import { isQuietMode } from '../../context.js'; import { isQuietMode } from '../../context.js';
import { loadAgentSessions, updateAgentSession } from '../../config/paths.js'; import { loadAgentSessions, updateAgentSession } from '../../config/paths.js';
import { getProvider, type ProviderType } from '../../providers/index.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 { promptInput, confirm, selectOption } from '../../prompt/index.js';
import { success, info } from '../../utils/ui.js'; import { success, info } from '../../utils/ui.js';
import { summarizeTaskName } from '../../task/summarize.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 { getProvider, type ProviderType } from '../../providers/index.js';
import { createLogger } from '../../utils/debug.js'; import { createLogger } from '../../utils/debug.js';
import { getErrorMessage } from '../../utils/error.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 { 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 { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../../github/issue.js';
import type { TaskFileData } from '../../task/schema.js'; import type { TaskFileData } from '../../task/schema.js';

View File

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

View File

@ -8,7 +8,7 @@
import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { getGlobalWorkflowsDir, getGlobalAgentsDir, getBuiltinWorkflowsDir, getBuiltinAgentsDir } from '../../config/paths.js'; 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'; 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 { info, success, error as logError, warn, header, blankLine } from '../../utils/ui.js';
import { createLogger } from '../../utils/debug.js'; import { createLogger } from '../../utils/debug.js';
import { getErrorMessage } from '../../utils/error.js'; import { getErrorMessage } from '../../utils/error.js';
import { executeTask, type TaskExecutionOptions } from '../taskExecution.js'; import { executeTask, type TaskExecutionOptions } from '../execution/taskExecution.js';
import { listWorkflows } from '../../config/workflowLoader.js'; import { listWorkflows } from '../../config/loaders/workflowLoader.js';
import { getCurrentWorkflow } from '../../config/paths.js'; import { getCurrentWorkflow } from '../../config/paths.js';
import { DEFAULT_WORKFLOW_NAME } from '../../constants.js'; import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';

View File

@ -15,9 +15,9 @@ import {
status, status,
blankLine, blankLine,
} from '../../utils/ui.js'; } from '../../utils/ui.js';
import { executeAndCompleteTask } from '../taskExecution.js'; import { executeAndCompleteTask } from '../execution/taskExecution.js';
import { DEFAULT_WORKFLOW_NAME } from '../../constants.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. * 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, ensureDir,
} from '../paths.js'; } from '../paths.js';
import { copyProjectResourcesToDir, getLanguageResourcesDir } from '../../resources/index.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. * 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 './paths.js';
export * from './loader.js'; export * from './loaders/loader.js';
export * from './initialization.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, getBuiltinWorkflowsDir,
isPathSafe, isPathSafe,
} from '../paths.js'; } from '../paths.js';
import { getLanguage } from '../globalConfig.js'; import { getLanguage } from '../global/globalConfig.js';
/** Get all allowed base directories for agent prompt files */ /** Get all allowed base directories for agent prompt files */
function getAllowedAgentBases(): string[] { function getAllowedAgentBases(): string[] {

View File

@ -12,7 +12,7 @@ export {
isWorkflowPath, isWorkflowPath,
loadAllWorkflows, loadAllWorkflows,
listWorkflows, listWorkflows,
} from '../workflowLoader.js'; } from './workflowLoader.js';
// Agent loading // Agent loading
export { export {
@ -21,7 +21,7 @@ export {
listCustomAgents, listCustomAgents,
loadAgentPrompt, loadAgentPrompt,
loadAgentPromptFromPath, loadAgentPromptFromPath,
} from '../agentLoader.js'; } from './agentLoader.js';
// Global configuration // Global configuration
export { export {
@ -32,4 +32,4 @@ export {
isDirectoryTrusted, isDirectoryTrusted,
loadProjectDebugConfig, loadProjectDebugConfig,
getEffectiveDebugConfig, 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 { WorkflowConfigRawSchema } from '../../models/schemas.js';
import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../../models/types.js'; import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../../models/types.js';
import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from '../paths.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 */ /** Get builtin workflow by name */
export function getBuiltinWorkflow(name: string): WorkflowConfig | null { export function getBuiltinWorkflow(name: string): WorkflowConfig | null {

View File

@ -94,7 +94,7 @@ export {
setCurrentWorkflow, setCurrentWorkflow,
isVerboseMode, isVerboseMode,
type ProjectLocalConfig, type ProjectLocalConfig,
} from './projectConfig.js'; } from './project/projectConfig.js';
// Re-export session storage functions for backward compatibility // Re-export session storage functions for backward compatibility
export { export {
@ -116,4 +116,4 @@ export {
getWorktreeSessionPath, getWorktreeSessionPath,
loadWorktreeSessions, loadWorktreeSessions,
updateWorktreeSession, 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 * 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 * 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 * 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 */ // Status and classification types
export type AgentType = 'coder' | 'architect' | 'supervisor' | 'custom'; export type {
AgentType,
Status,
RuleMatchMethod,
PermissionMode,
} from './status.js';
/** Execution status for agents and workflows */ // Agent response
export type Status = export type {
| 'pending' AgentResponse,
| 'done' } from './response.js';
| 'blocked'
| 'approved'
| 'rejected'
| 'improve'
| 'cancelled'
| 'interrupted'
| 'answer';
/** How a rule match was detected */ // Session state (authoritative definition with createSessionState)
export type RuleMatchMethod = export type {
| 'aggregate' SessionState,
| 'phase3_tag' } from './session.js';
| 'phase1_tag'
| 'ai_judge'
| 'ai_judge_fallback';
/** Response from an agent execution */ // Workflow configuration and runtime state
export interface AgentResponse { export type {
agent: string; WorkflowRule,
status: Status; ReportConfig,
content: string; ReportObjectConfig,
timestamp: Date; WorkflowStep,
sessionId?: string; LoopDetectionConfig,
/** Error message when the query failed (e.g., API error, rate limit) */ WorkflowConfig,
error?: string; WorkflowState,
/** Matched rule index (0-based) when rules-based detection was used */ } from './workflow-types.js';
matchedRuleIndex?: number;
/** How the rule match was detected */
matchedRuleMethod?: RuleMatchMethod;
}
/** Session state for workflow execution */ // Configuration types (global and project)
export interface SessionState { export type {
task: string; CustomAgentConfig,
projectDir: string; DebugConfig,
iterations: number; Language,
history: AgentResponse[]; PipelineConfig,
context: Record<string, string>; GlobalConfig,
} ProjectConfig,
} from './global-config.js';
/** 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';
}

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 { 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 { 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 */ /** Claude provider - wraps existing Claude client */
export class ClaudeProvider implements Provider { export class ClaudeProvider implements Provider {

View File

@ -3,9 +3,9 @@
*/ */
import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/client.js'; 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 { 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 */ /** Codex provider - wraps existing Codex client */
export class CodexProvider implements Provider { export class CodexProvider implements Provider {

View File

@ -5,66 +5,56 @@
* This enables adding new providers without modifying the runner logic. * 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 { ClaudeProvider } from './claude.js';
import { CodexProvider } from './codex.js'; import { CodexProvider } from './codex.js';
import { MockProvider } from './mock.js'; import { MockProvider } from './mock.js';
import type { Provider, ProviderType } from './types.js';
/** Common options for all providers */ // Re-export types for backward compatibility
export interface ProviderCallOptions { export type { ProviderCallOptions, Provider, ProviderType } from './types.js';
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 { * Registry for agent providers.
/** Call the provider with a prompt (using systemPrompt from options if provided) */ * Singleton use ProviderRegistry.getInstance().
call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse>; */
export class ProviderRegistry {
private static instance: ProviderRegistry | null = null;
private readonly providers: Record<string, Provider>;
/** Call the provider with explicit system prompt */ private constructor() {
callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse>; this.providers = {
}
/** Provider type */
export type ProviderType = 'claude' | 'codex' | 'mock';
/** Provider registry */
const providers: Record<ProviderType, Provider> = {
claude: new ClaudeProvider(), claude: new ClaudeProvider(),
codex: new CodexProvider(), codex: new CodexProvider(),
mock: new MockProvider(), mock: new MockProvider(),
}; };
}
/** static getInstance(): ProviderRegistry {
* Get a provider instance by type if (!ProviderRegistry.instance) {
*/ ProviderRegistry.instance = new ProviderRegistry();
export function getProvider(type: ProviderType): Provider { }
const provider = providers[type]; 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) { if (!provider) {
throw new Error(`Unknown provider type: ${type}`); throw new Error(`Unknown provider type: ${type}`);
} }
return provider; return provider;
}
} }
/** // ---- Backward-compatible module-level functions ----
* Register a custom provider
*/ export function getProvider(type: ProviderType): Provider {
export function registerProvider(type: string, provider: Provider): void { return ProviderRegistry.getInstance().get(type);
(providers as Record<string, Provider>)[type] = provider;
} }

View File

@ -4,7 +4,7 @@
import { callMock, callMockCustom, type MockCallOptions } from '../mock/client.js'; import { callMock, callMockCustom, type MockCallOptions } from '../mock/client.js';
import type { AgentResponse } from '../models/types.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 */ /** Mock provider - wraps existing Mock client */
export class MockProvider implements Provider { 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,19 +24,19 @@ export interface AutoCommitResult {
} }
/** /**
* Auto-commit all changes and push to origin. * Handles auto-commit and push operations for clone tasks.
*/
export class AutoCommitter {
/**
* Auto-commit all changes and push to the main project.
* *
* Steps: * Steps:
* 1. Stage all changes (git add -A) * 1. Stage all changes (git add -A)
* 2. Check if there are staged changes (git status --porcelain) * 2. Check if there are staged changes
* 3. If changes exist, create a commit with "takt: {taskName}" * 3. If changes exist, create a commit with "takt: {taskName}"
* 4. Push to origin (git push origin HEAD) * 4. Push to the main project directory
*
* @param cloneCwd - The clone directory
* @param taskName - Task name used in commit message
* @param projectDir - The main project directory (push target)
*/ */
export function autoCommitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult { commitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult {
log.info('Auto-commit starting', { cwd: cloneCwd, taskName }); log.info('Auto-commit starting', { cwd: cloneCwd, taskName });
try { try {
@ -50,7 +50,6 @@ export function autoCommitAndPush(cloneCwd: string, taskName: string, projectDir
log.info('Auto-commit created', { commitHash, message: commitMessage }); 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'], { execFileSync('git', ['push', projectDir, 'HEAD'], {
cwd: cloneCwd, cwd: cloneCwd,
stdio: 'pipe', stdio: 'pipe',
@ -72,4 +71,13 @@ export function autoCommitAndPush(cloneCwd: string, taskName: string, projectDir
message: `Auto-commit failed: ${errorMessage}`, 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 * 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). * with metadata (diff stats, original instruction, task slug).
* Used by the /list command. * Used by the /list command.
*/ */
@ -29,10 +29,11 @@ export interface BranchListItem {
const TAKT_BRANCH_PREFIX = 'takt/'; const TAKT_BRANCH_PREFIX = 'takt/';
/** /**
* Detect the default branch name (main or master). * Manages takt branch listing and metadata enrichment.
* Checks local branch refs directly. Falls back to 'main'.
*/ */
export function detectDefaultBranch(cwd: string): string { export class BranchManager {
/** Detect the default branch name (main or master) */
detectDefaultBranch(cwd: string): string {
try { try {
const ref = execFileSync( const ref = execFileSync(
'git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], 'git', ['symbolic-ref', 'refs/remotes/origin/HEAD'],
@ -57,28 +58,24 @@ export function detectDefaultBranch(cwd: string): string {
} }
} }
} }
} }
/** /** List all takt-managed branches */
* List all takt-managed branches. listTaktBranches(projectDir: string): BranchInfo[] {
*/
export function listTaktBranches(projectDir: string): BranchInfo[] {
try { try {
const output = execFileSync( const output = execFileSync(
'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'], 'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
); );
return parseTaktBranches(output); return BranchManager.parseTaktBranches(output);
} catch (err) { } catch (err) {
log.error('Failed to list takt branches', { error: String(err) }); log.error('Failed to list takt branches', { error: String(err) });
return []; return [];
} }
} }
/** /** Parse `git branch --list` formatted output into BranchInfo entries */
* Parse `git branch --list` formatted output into BranchInfo entries. static parseTaktBranches(output: string): BranchInfo[] {
*/
export function parseTaktBranches(output: string): BranchInfo[] {
const entries: BranchInfo[] = []; const entries: BranchInfo[] = [];
const lines = output.trim().split('\n'); const lines = output.trim().split('\n');
@ -98,12 +95,10 @@ export function parseTaktBranches(output: string): BranchInfo[] {
} }
return entries; return entries;
} }
/** /** Get the number of files changed between the default branch and a given branch */
* Get the number of files changed between the default branch and a given branch. getFilesChanged(cwd: string, defaultBranch: string, branch: string): number {
*/
export function getFilesChanged(cwd: string, defaultBranch: string, branch: string): number {
try { try {
const output = execFileSync( const output = execFileSync(
'git', ['diff', '--numstat', `${defaultBranch}...${branch}`], 'git', ['diff', '--numstat', `${defaultBranch}...${branch}`],
@ -113,30 +108,24 @@ export function getFilesChanged(cwd: string, defaultBranch: string, branch: stri
} catch { } catch {
return 0; return 0;
} }
} }
/** /** Extract a human-readable task slug from a takt branch name */
* Extract a human-readable task slug from a takt branch name. static extractTaskSlug(branch: string): string {
* e.g. "takt/20260128T032800-fix-auth" -> "fix-auth"
*/
export function extractTaskSlug(branch: string): string {
const name = branch.replace(TAKT_BRANCH_PREFIX, ''); const name = branch.replace(TAKT_BRANCH_PREFIX, '');
const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, ''); const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, '');
return withoutTimestamp || name; return withoutTimestamp || name;
} }
/** /**
* Extract the original task instruction from the first commit message on a 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}". * 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( getOriginalInstruction(
cwd: string, cwd: string,
defaultBranch: string, defaultBranch: string,
branch: string, branch: string,
): string { ): string {
try { try {
const output = execFileSync( const output = execFileSync(
'git', 'git',
@ -156,20 +145,55 @@ export function getOriginalInstruction(
} catch { } catch {
return ''; 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);
}
export function listTaktBranches(projectDir: string): BranchInfo[] {
return defaultManager.listTaktBranches(projectDir);
}
export function parseTaktBranches(output: string): BranchInfo[] {
return BranchManager.parseTaktBranches(output);
}
export function getFilesChanged(cwd: string, defaultBranch: string, branch: string): number {
return defaultManager.getFilesChanged(cwd, defaultBranch, branch);
}
export function extractTaskSlug(branch: string): string {
return BranchManager.extractTaskSlug(branch);
}
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( export function buildListItems(
projectDir: string, projectDir: string,
branches: BranchInfo[], branches: BranchInfo[],
defaultBranch: string, defaultBranch: string,
): BranchListItem[] { ): BranchListItem[] {
return branches.map(br => ({ return defaultManager.buildListItems(projectDir, branches, defaultBranch);
info: br,
filesChanged: getFilesChanged(projectDir, defaultBranch, br.branch),
taskSlug: extractTaskSlug(br.branch),
originalInstruction: getOriginalInstruction(projectDir, defaultBranch, br.branch),
}));
} }

View File

@ -12,7 +12,7 @@ import * as path from 'node:path';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { createLogger } from '../utils/debug.js'; import { createLogger } from '../utils/debug.js';
import { slugify } from '../utils/slug.js'; import { slugify } from '../utils/slug.js';
import { loadGlobalConfig } from '../config/globalConfig.js'; import { loadGlobalConfig } from '../config/global/globalConfig.js';
const log = createLogger('clone'); const log = createLogger('clone');
@ -34,15 +34,24 @@ export interface WorktreeResult {
branch: string; branch: string;
} }
function generateTimestamp(): string { const CLONE_META_DIR = 'clone-meta';
return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
}
/** /**
* Manages git clone lifecycle for task isolation.
*
* Handles creation, removal, and metadata tracking of clones
* used for parallel task execution.
*/
export class CloneManager {
private static generateTimestamp(): string {
return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
}
/**
* Resolve the base directory for clones from global config. * Resolve the base directory for clones from global config.
* Returns the configured worktree_dir (resolved to absolute), or ../ * Returns the configured worktree_dir (resolved to absolute), or ../
*/ */
function resolveCloneBaseDir(projectDir: string): string { private static resolveCloneBaseDir(projectDir: string): string {
const globalConfig = loadGlobalConfig(); const globalConfig = loadGlobalConfig();
if (globalConfig.worktreeDir) { if (globalConfig.worktreeDir) {
return path.isAbsolute(globalConfig.worktreeDir) return path.isAbsolute(globalConfig.worktreeDir)
@ -50,21 +59,11 @@ function resolveCloneBaseDir(projectDir: string): string {
: path.resolve(projectDir, globalConfig.worktreeDir); : path.resolve(projectDir, globalConfig.worktreeDir);
} }
return path.join(projectDir, '..'); return path.join(projectDir, '..');
} }
/** /** Resolve the clone path based on options and global config */
* Resolve the clone path based on options and global config. private static resolveClonePath(projectDir: string, options: WorktreeOptions): string {
* const timestamp = CloneManager.generateTimestamp();
* 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}
*/
function resolveClonePath(projectDir: string, options: WorktreeOptions): string {
const timestamp = generateTimestamp();
const slug = slugify(options.taskSlug); const slug = slugify(options.taskSlug);
let dirName: string; let dirName: string;
@ -82,17 +81,11 @@ function resolveClonePath(projectDir: string, options: WorktreeOptions): string
: path.resolve(projectDir, options.worktree); : path.resolve(projectDir, options.worktree);
} }
return path.join(resolveCloneBaseDir(projectDir), dirName); return path.join(CloneManager.resolveCloneBaseDir(projectDir), dirName);
} }
/** /** Resolve branch name from options */
* Resolve branch name from options. private static resolveBranchName(options: WorktreeOptions): string {
*
* 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) { if (options.branch) {
return options.branch; return options.branch;
} }
@ -103,11 +96,11 @@ function resolveBranchName(options: WorktreeOptions): string {
return `takt/#${options.issueNumber}/${slug}`; return `takt/#${options.issueNumber}/${slug}`;
} }
const timestamp = generateTimestamp(); const timestamp = CloneManager.generateTimestamp();
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`; return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
} }
function branchExists(projectDir: string, branch: string): boolean { private static branchExists(projectDir: string, branch: string): boolean {
try { try {
execFileSync('git', ['rev-parse', '--verify', branch], { execFileSync('git', ['rev-parse', '--verify', branch], {
cwd: projectDir, cwd: projectDir,
@ -117,12 +110,10 @@ function branchExists(projectDir: string, branch: string): boolean {
} catch { } catch {
return false; return false;
} }
} }
/** /** Clone a repository and remove origin to isolate from the main repo */
* Clone a repository and remove origin to isolate from the main repo. private static cloneAndIsolate(projectDir: string, clonePath: string): void {
*/
function cloneAndIsolate(projectDir: string, clonePath: string): void {
fs.mkdirSync(path.dirname(clonePath), { recursive: true }); fs.mkdirSync(path.dirname(clonePath), { recursive: true });
execFileSync('git', ['clone', '--reference', projectDir, '--dissociate', projectDir, clonePath], { execFileSync('git', ['clone', '--reference', projectDir, '--dissociate', projectDir, clonePath], {
@ -152,58 +143,55 @@ function cloneAndIsolate(projectDir: string, clonePath: string): void {
// not set locally — skip // not set locally — skip
} }
} }
} }
/** private static encodeBranchName(branch: string): string {
* Create a git clone for a task. return branch.replace(/\//g, '--');
* }
* Uses `git clone --reference --dissociate` to create an independent clone,
* then removes origin and checks out a new branch. private static getCloneMetaPath(projectDir: string, branch: string): string {
*/ return path.join(projectDir, '.takt', CLONE_META_DIR, `${CloneManager.encodeBranchName(branch)}.json`);
export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult { }
const clonePath = resolveClonePath(projectDir, options);
const branch = resolveBranchName(options); /** 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 }); log.info('Creating shared clone', { path: clonePath, branch });
cloneAndIsolate(projectDir, clonePath); CloneManager.cloneAndIsolate(projectDir, clonePath);
if (branchExists(clonePath, branch)) { if (CloneManager.branchExists(clonePath, branch)) {
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' }); execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
} else { } else {
execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' }); execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' });
} }
saveCloneMeta(projectDir, branch, clonePath); this.saveCloneMeta(projectDir, branch, clonePath);
log.info('Clone created', { path: clonePath, branch }); log.info('Clone created', { path: clonePath, branch });
return { path: clonePath, branch }; return { path: clonePath, branch };
} }
/** /** Create a temporary clone for an existing branch */
* Create a temporary clone for an existing branch. createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
* Used by review/instruct to work on a branch that was previously pushed. const timestamp = CloneManager.generateTimestamp();
*/ const clonePath = path.join(CloneManager.resolveCloneBaseDir(projectDir), `tmp-${timestamp}`);
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 }); log.info('Creating temp clone for branch', { path: clonePath, branch });
cloneAndIsolate(projectDir, clonePath); CloneManager.cloneAndIsolate(projectDir, clonePath);
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' }); execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
saveCloneMeta(projectDir, branch, clonePath); this.saveCloneMeta(projectDir, branch, clonePath);
log.info('Temp clone created', { path: clonePath, branch }); log.info('Temp clone created', { path: clonePath, branch });
return { path: clonePath, branch }; return { path: clonePath, branch };
} }
/** /** Remove a clone directory */
* Remove a clone directory. removeClone(clonePath: string): void {
*/
export function removeClone(clonePath: string): void {
log.info('Removing clone', { path: clonePath }); log.info('Removing clone', { path: clonePath });
try { try {
fs.rmSync(clonePath, { recursive: true, force: true }); fs.rmSync(clonePath, { recursive: true, force: true });
@ -211,57 +199,66 @@ export function removeClone(clonePath: string): void {
} catch (err) { } catch (err) {
log.error('Failed to remove clone', { path: clonePath, error: String(err) }); log.error('Failed to remove clone', { path: clonePath, error: String(err) });
} }
} }
// --- Clone metadata --- /** Save clone metadata (branch → clonePath mapping) */
saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
const CLONE_META_DIR = 'clone-meta'; const filePath = CloneManager.getCloneMetaPath(projectDir, branch);
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.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath })); fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
log.info('Clone meta saved', { branch, clonePath }); log.info('Clone meta saved', { branch, clonePath });
} }
/** /** Remove clone metadata for a branch */
* Remove clone metadata for a branch. removeCloneMeta(projectDir: string, branch: string): void {
*/
export function removeCloneMeta(projectDir: string, branch: string): void {
try { try {
fs.unlinkSync(getCloneMetaPath(projectDir, branch)); fs.unlinkSync(CloneManager.getCloneMetaPath(projectDir, branch));
log.info('Clone meta removed', { branch }); log.info('Clone meta removed', { branch });
} catch { } catch {
// File may not exist — ignore // File may not exist — ignore
} }
} }
/** /** Clean up an orphaned clone directory associated with a branch */
* Clean up an orphaned clone directory associated with a branch. cleanupOrphanedClone(projectDir: string, branch: string): void {
* Reads metadata, removes clone directory if it still exists, then removes metadata.
*/
export function cleanupOrphanedClone(projectDir: string, branch: string): void {
try { try {
const raw = fs.readFileSync(getCloneMetaPath(projectDir, branch), 'utf-8'); const raw = fs.readFileSync(CloneManager.getCloneMetaPath(projectDir, branch), 'utf-8');
const meta = JSON.parse(raw) as { clonePath: string }; const meta = JSON.parse(raw) as { clonePath: string };
if (fs.existsSync(meta.clonePath)) { if (fs.existsSync(meta.clonePath)) {
removeClone(meta.clonePath); this.removeClone(meta.clonePath);
log.info('Orphaned clone cleaned up', { branch, clonePath: meta.clonePath }); log.info('Orphaned clone cleaned up', { branch, clonePath: meta.clonePath });
} }
} catch { } catch {
// No metadata or parse error — nothing to clean up // No metadata or parse error — nothing to clean up
} }
removeCloneMeta(projectDir, branch); this.removeCloneMeta(projectDir, branch);
}
}
// ---- Backward-compatible module-level functions ----
const defaultManager = new CloneManager();
export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
return defaultManager.createSharedClone(projectDir, options);
}
export function createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
return defaultManager.createTempCloneForBranch(projectDir, branch);
}
export function removeClone(clonePath: string): void {
defaultManager.removeClone(clonePath);
}
export function saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
defaultManager.saveCloneMeta(projectDir, branch, clonePath);
}
export function removeCloneMeta(projectDir: string, branch: string): void {
defaultManager.removeCloneMeta(projectDir, branch);
}
export function cleanupOrphanedClone(projectDir: string, branch: string): void {
defaultManager.cleanupOrphanedClone(projectDir, branch);
} }

View File

@ -2,6 +2,12 @@
* Task execution module * 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 { export {
TaskRunner, TaskRunner,
type TaskInfo, type TaskInfo,
@ -34,4 +40,5 @@ export {
type BranchListItem, type BranchListItem,
} from './branchList.js'; } from './branchList.js';
export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js'; export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js';
export { summarizeTaskName, type SummarizeOptions } from './summarize.js';
export { TaskWatcher, type TaskWatcherOptions } from './watcher.js'; export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';

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