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

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

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

View File

@ -4,36 +4,15 @@
* Uses the Claude Agent SDK for native TypeScript integration. * 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,219 +56,270 @@ 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.
result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string }, *
): Status { * Handles agent prompts, custom agents, skills, and AI judge evaluation.
if (!result.success) { */
if (result.interrupted) { export class ClaudeClient {
return 'interrupted'; /** Determine status from execution result */
private static determineStatus(
result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string },
): Status {
if (!result.success) {
if (result.interrupted) {
return 'interrupted';
}
return 'blocked';
} }
return 'blocked'; return 'done';
}
/** Convert ClaudeCallOptions to ClaudeSpawnOptions */
private static toSpawnOptions(options: ClaudeCallOptions): ClaudeSpawnOptions {
return {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
model: options.model,
maxTurns: options.maxTurns,
systemPrompt: options.systemPrompt,
agents: options.agents,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
};
}
/** Call Claude with an agent prompt */
async call(
agentType: string,
prompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
const spawnOptions = ClaudeClient.toSpawnOptions(options);
const result = await executeClaudeCli(prompt, spawnOptions);
const status = ClaudeClient.determineStatus(result);
if (!result.success && result.error) {
log.error('Agent query failed', { agent: agentType, error: result.error });
}
return {
agent: agentType,
status,
content: result.content,
timestamp: new Date(),
sessionId: result.sessionId,
error: result.error,
};
}
/** Call Claude with a custom agent configuration */
async callCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
const spawnOptions: ClaudeSpawnOptions = {
...ClaudeClient.toSpawnOptions(options),
systemPrompt,
};
const result = await executeClaudeCli(prompt, spawnOptions);
const status = ClaudeClient.determineStatus(result);
if (!result.success && result.error) {
log.error('Agent query failed', { agent: agentName, error: result.error });
}
return {
agent: agentName,
status,
content: result.content,
timestamp: new Date(),
sessionId: result.sessionId,
error: result.error,
};
}
/** Call a Claude Code built-in agent */
async callAgent(
claudeAgentName: string,
prompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
const systemPrompt = `You are the ${claudeAgentName} agent. Follow the standard ${claudeAgentName} workflow.`;
return this.callCustom(claudeAgentName, prompt, systemPrompt, options);
}
/** Call a Claude Code skill (using /skill command) */
async callSkill(
skillName: string,
prompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
const fullPrompt = `/${skillName}\n\n${prompt}`;
const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
model: options.model,
maxTurns: options.maxTurns,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
};
const result = await executeClaudeCli(fullPrompt, spawnOptions);
if (!result.success && result.error) {
log.error('Skill query failed', { skill: skillName, error: result.error });
}
return {
agent: `skill:${skillName}`,
status: result.success ? 'done' : 'blocked',
content: result.content,
timestamp: new Date(),
sessionId: result.sessionId,
error: result.error,
};
}
/**
* Detect judge rule index from [JUDGE:N] tag pattern.
* Returns 0-based rule index, or -1 if no match.
*/
static detectJudgeIndex(content: string): number {
const regex = /\[JUDGE:(\d+)\]/i;
const match = content.match(regex);
if (match?.[1]) {
const index = Number.parseInt(match[1], 10) - 1;
return index >= 0 ? index : -1;
}
return -1;
}
/**
* Build the prompt for the AI judge that evaluates agent output against ai() conditions.
*/
static buildJudgePrompt(
agentOutput: string,
aiConditions: { index: number; text: string }[],
): string {
const conditionList = aiConditions
.map((c) => `| ${c.index + 1} | ${c.text} |`)
.join('\n');
return [
'# Judge Task',
'',
'You are a judge evaluating an agent\'s output against a set of conditions.',
'Read the agent output below, then determine which condition best matches.',
'',
'## Agent Output',
'```',
agentOutput,
'```',
'',
'## Conditions',
'| # | Condition |',
'|---|-----------|',
conditionList,
'',
'## Instructions',
'Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition.',
'Do not output anything else.',
].join('\n');
}
/**
* Call AI judge to evaluate agent output against ai() conditions.
* Uses a lightweight model (haiku) for cost efficiency.
* Returns 0-based index of the matched ai() condition, or -1 if no match.
*/
async callAiJudge(
agentOutput: string,
aiConditions: { index: number; text: string }[],
options: { cwd: string },
): Promise<number> {
const prompt = ClaudeClient.buildJudgePrompt(agentOutput, aiConditions);
const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd,
model: 'haiku',
maxTurns: 1,
};
const result = await executeClaudeCli(prompt, spawnOptions);
if (!result.success) {
log.error('AI judge call failed', { error: result.error });
return -1;
}
return ClaudeClient.detectJudgeIndex(result.content);
} }
return 'done';
} }
/** Call Claude with an agent prompt */ // ---- Backward-compatible module-level functions ----
const defaultClient = new ClaudeClient();
export async function callClaude( export async function callClaude(
agentType: string, agentType: string,
prompt: string, prompt: string,
options: ClaudeCallOptions options: ClaudeCallOptions,
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const spawnOptions: ClaudeSpawnOptions = { return defaultClient.call(agentType, prompt, options);
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
model: options.model,
maxTurns: options.maxTurns,
systemPrompt: options.systemPrompt,
agents: options.agents,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
};
const result = await executeClaudeCli(prompt, spawnOptions);
const status = determineStatus(result);
if (!result.success && result.error) {
log.error('Agent query failed', { agent: agentType, error: result.error });
}
return {
agent: agentType,
status,
content: result.content,
timestamp: new Date(),
sessionId: result.sessionId,
error: result.error,
};
} }
/** Call Claude with a custom agent configuration */
export async function callClaudeCustom( export async function callClaudeCustom(
agentName: string, agentName: string,
prompt: string, prompt: string,
systemPrompt: string, systemPrompt: string,
options: ClaudeCallOptions options: ClaudeCallOptions,
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const spawnOptions: ClaudeSpawnOptions = { return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
cwd: options.cwd, }
sessionId: options.sessionId,
allowedTools: options.allowedTools, export async function callClaudeAgent(
model: options.model, claudeAgentName: string,
maxTurns: options.maxTurns, prompt: string,
systemPrompt, options: ClaudeCallOptions,
permissionMode: options.permissionMode, ): Promise<AgentResponse> {
onStream: options.onStream, return defaultClient.callAgent(claudeAgentName, prompt, options);
onPermissionRequest: options.onPermissionRequest, }
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions, export async function callClaudeSkill(
anthropicApiKey: options.anthropicApiKey, skillName: string,
}; prompt: string,
options: ClaudeCallOptions,
const result = await executeClaudeCli(prompt, spawnOptions); ): Promise<AgentResponse> {
const status = determineStatus(result); return defaultClient.callSkill(skillName, prompt, options);
if (!result.success && result.error) {
log.error('Agent query failed', { agent: agentName, error: result.error });
}
return {
agent: agentName,
status,
content: result.content,
timestamp: new Date(),
sessionId: result.sessionId,
error: result.error,
};
} }
/**
* 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 { export function detectJudgeIndex(content: string): number {
const regex = /\[JUDGE:(\d+)\]/i; return ClaudeClient.detectJudgeIndex(content);
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( export function buildJudgePrompt(
agentOutput: string, agentOutput: string,
aiConditions: { index: number; text: string }[], aiConditions: { index: number; text: string }[],
): string { ): string {
const conditionList = aiConditions return ClaudeClient.buildJudgePrompt(agentOutput, 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( export async function callAiJudge(
agentOutput: string, agentOutput: string,
aiConditions: { index: number; text: string }[], aiConditions: { index: number; text: string }[],
options: { cwd: string }, options: { cwd: string },
): Promise<number> { ): Promise<number> {
const prompt = buildJudgePrompt(agentOutput, aiConditions); return defaultClient.callAiJudge(agentOutput, aiConditions, options);
const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd,
model: 'haiku',
maxTurns: 1,
};
const result = await executeClaudeCli(prompt, spawnOptions);
if (!result.success) {
log.error('AI judge call failed', { error: result.error });
return -1;
}
return detectJudgeIndex(result.content);
}
/** Call a Claude Code built-in agent (using claude --agent flag if available) */
export async function callClaudeAgent(
claudeAgentName: string,
prompt: string,
options: ClaudeCallOptions
): Promise<AgentResponse> {
// For now, use system prompt approach
// In future, could use --agent flag if Claude CLI supports it
const systemPrompt = `You are the ${claudeAgentName} agent. Follow the standard ${claudeAgentName} workflow.`;
return callClaudeCustom(claudeAgentName, prompt, systemPrompt, options);
}
/** Call a Claude Code skill (using /skill command) */
export async function callClaudeSkill(
skillName: string,
prompt: string,
options: ClaudeCallOptions
): Promise<AgentResponse> {
// Prepend skill invocation to prompt
const fullPrompt = `/${skillName}\n\n${prompt}`;
const spawnOptions: ClaudeSpawnOptions = {
cwd: options.cwd,
sessionId: options.sessionId,
allowedTools: options.allowedTools,
model: options.model,
maxTurns: options.maxTurns,
permissionMode: options.permissionMode,
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
};
const result = await executeClaudeCli(fullPrompt, spawnOptions);
if (!result.success && result.error) {
log.error('Skill query failed', { skill: skillName, error: result.error });
}
return {
agent: `skill:${skillName}`,
status: result.success ? 'done' : 'blocked',
content: result.content,
timestamp: new Date(),
sessionId: result.sessionId,
error: result.error,
};
} }

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,233 +19,168 @@ 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; */
async execute(
prompt: string,
options: ClaudeSpawnOptions,
): Promise<ClaudeResult> {
const queryId = generateQueryId();
const hooks = options.onAskUserQuestion log.debug('Executing Claude query via SDK', {
? createAskUserQuestionHooks(options.onAskUserQuestion) queryId,
: undefined; cwd: options.cwd,
model: options.model,
hasSystemPrompt: !!options.systemPrompt,
allowedTools: options.allowedTools,
});
// Determine permission mode const sdkOptions = new SdkOptionsBuilder(options).build();
// 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 let sessionId: string | undefined;
// differently from key-absent for some options (e.g. model), causing hangs. let success = false;
const sdkOptions: Options = { let resultContent: string | undefined;
cwd: options.cwd, let hasResultMessage = false;
permissionMode, let accumulatedAssistantText = '';
};
if (options.model) sdkOptions.model = options.model; try {
if (options.maxTurns != null) sdkOptions.maxTurns = options.maxTurns; const q = query({ prompt, options: sdkOptions });
if (options.allowedTools) sdkOptions.allowedTools = options.allowedTools; registerQuery(queryId, q);
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) { for await (const message of q) {
sdkOptions.env = { if ('session_id' in message) {
...process.env as Record<string, string>, sessionId = message.session_id;
ANTHROPIC_API_KEY: options.anthropicApiKey, }
};
}
if (options.onStream) { if (options.onStream) {
sdkOptions.includePartialMessages = true; sdkMessageToStreamEvent(message, options.onStream, true);
} }
if (options.sessionId) { if (message.type === 'assistant') {
sdkOptions.resume = options.sessionId; const assistantMsg = message as SDKAssistantMessage;
} else { for (const block of assistantMsg.message.content) {
sdkOptions.continue = false; if (block.type === 'text') {
} accumulatedAssistantText += block.text;
}
}
}
return sdkOptions; if (message.type === 'result') {
} hasResultMessage = true;
const resultMsg = message as SDKResultMessage;
/** if (resultMsg.subtype === 'success') {
* Execute a Claude query using the Agent SDK. resultContent = resultMsg.result;
*/ success = true;
export async function executeClaudeQuery( } else {
prompt: string, success = false;
options: ExecuteOptions if (resultMsg.errors && resultMsg.errors.length > 0) {
): Promise<ClaudeResult> { resultContent = resultMsg.errors.join('\n');
const queryId = generateQueryId(); }
log.debug('Executing Claude query via SDK', {
queryId,
cwd: options.cwd,
model: options.model,
hasSystemPrompt: !!options.systemPrompt,
allowedTools: options.allowedTools,
});
const sdkOptions = buildSdkOptions(options);
let sessionId: string | undefined;
let success = false;
let resultContent: string | undefined;
let hasResultMessage = false;
let accumulatedAssistantText = '';
try {
const q = query({ prompt, options: sdkOptions });
registerQuery(queryId, q);
for await (const message of q) {
if ('session_id' in message) {
sessionId = message.session_id;
}
if (options.onStream) {
sdkMessageToStreamEvent(message, options.onStream, true);
}
if (message.type === 'assistant') {
const assistantMsg = message as SDKAssistantMessage;
for (const block of assistantMsg.message.content) {
if (block.type === 'text') {
accumulatedAssistantText += block.text;
} }
} }
} }
if (message.type === 'result') { unregisterQuery(queryId);
hasResultMessage = true;
const resultMsg = message as SDKResultMessage; const finalContent = resultContent || accumulatedAssistantText;
if (resultMsg.subtype === 'success') {
resultContent = resultMsg.result; log.info('Claude query completed', {
success = true; queryId,
} else { sessionId,
success = false; contentLength: finalContent.length,
if (resultMsg.errors && resultMsg.errors.length > 0) { success,
resultContent = resultMsg.errors.join('\n'); hasResultMessage,
} });
}
} return {
success,
content: finalContent.trim(),
sessionId,
fullContent: accumulatedAssistantText.trim(),
};
} catch (error) {
unregisterQuery(queryId);
return QueryExecutor.handleQueryError(error, queryId, sessionId, hasResultMessage, success, resultContent);
}
}
/**
* Handle query execution errors.
* Classifies errors (abort, rate limit, auth, timeout) and returns appropriate ClaudeResult.
*/
private static handleQueryError(
error: unknown,
queryId: string,
sessionId: string | undefined,
hasResultMessage: boolean,
success: boolean,
resultContent: string | undefined,
): ClaudeResult {
if (error instanceof AbortError) {
log.info('Claude query was interrupted', { queryId });
return {
success: false,
content: '',
error: 'Query interrupted',
interrupted: true,
};
} }
unregisterQuery(queryId); const errorMessage = getErrorMessage(error);
const finalContent = resultContent || accumulatedAssistantText; if (hasResultMessage && success) {
log.info('Claude query completed with post-completion error (ignoring)', {
queryId,
sessionId,
error: errorMessage,
});
return {
success: true,
content: (resultContent ?? '').trim(),
sessionId,
};
}
log.info('Claude query completed', { log.error('Claude query failed', { queryId, error: errorMessage });
queryId,
sessionId,
contentLength: finalContent.length,
success,
hasResultMessage,
});
return { if (errorMessage.includes('rate_limit') || errorMessage.includes('rate limit')) {
success, return { success: false, content: '', error: 'Rate limit exceeded. Please try again later.' };
content: finalContent.trim(), }
sessionId,
fullContent: accumulatedAssistantText.trim(), if (errorMessage.includes('authentication') || errorMessage.includes('unauthorized')) {
}; return { success: false, content: '', error: 'Authentication failed. Please check your API credentials.' };
} catch (error) { }
unregisterQuery(queryId);
return handleQueryError(error, queryId, sessionId, hasResultMessage, success, resultContent); if (errorMessage.includes('timeout')) {
return { success: false, content: '', error: 'Request timed out. Please try again.' };
}
return { success: false, content: '', error: errorMessage };
} }
} }
/** // ---- Backward-compatible module-level function ----
* Handle query execution errors.
*/
function handleQueryError(
error: unknown,
queryId: string,
sessionId: string | undefined,
hasResultMessage: boolean,
success: boolean,
resultContent: string | undefined
): ClaudeResult {
if (error instanceof AbortError) {
log.info('Claude query was interrupted', { queryId });
return {
success: false,
content: '',
error: 'Query interrupted',
interrupted: true,
};
}
const errorMessage = getErrorMessage(error); /** @deprecated Use QueryExecutor.execute() instead */
export async function executeClaudeQuery(
if (hasResultMessage && success) { prompt: string,
log.info('Claude query completed with post-completion error (ignoring)', { options: ClaudeSpawnOptions,
queryId, ): Promise<ClaudeResult> {
sessionId, return new QueryExecutor().execute(prompt, options);
error: errorMessage,
});
return {
success: true,
content: (resultContent ?? '').trim(),
sessionId,
};
}
log.error('Claude query failed', { queryId, error: errorMessage });
if (errorMessage.includes('rate_limit') || errorMessage.includes('rate limit')) {
return { success: false, content: '', error: 'Rate limit exceeded. Please try again later.' };
}
if (errorMessage.includes('authentication') || errorMessage.includes('unauthorized')) {
return { success: false, content: '', error: 'Authentication failed. Please check your API credentials.' };
}
if (errorMessage.includes('timeout')) {
return { success: false, content: '', error: 'Request timed out. Please try again.' };
}
return { success: false, content: '', error: errorMessage };
} }

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,131 +21,165 @@ 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 { * Builds SDK options from ClaudeSpawnOptions.
cwd: string; *
sessionId?: string; * Handles permission mode resolution, canUseTool callback creation,
allowedTools?: string[]; * and AskUserQuestion hook setup.
model?: string; */
maxTurns?: number; export class SdkOptionsBuilder {
systemPrompt?: string; private readonly options: ClaudeSpawnOptions;
/** Enable streaming mode */
hasStream?: boolean; constructor(options: ClaudeSpawnOptions) {
/** Custom agents to register */ this.options = options;
agents?: Record<string, AgentDefinition>; }
/** Permission mode for tool execution */
permissionMode?: PermissionMode; /** Build the full SDK Options object */
/** Custom permission handler for interactive permission prompts */ build(): Options {
onPermissionRequest?: PermissionHandler; const canUseTool = this.options.onPermissionRequest
/** Custom handler for AskUserQuestion tool */ ? SdkOptionsBuilder.createCanUseToolCallback(this.options.onPermissionRequest)
onAskUserQuestion?: AskUserQuestionHandler; : undefined;
const hooks = this.options.onAskUserQuestion
? SdkOptionsBuilder.createAskUserQuestionHooks(this.options.onAskUserQuestion)
: undefined;
const permissionMode = this.resolvePermissionMode();
// Only include defined values — the SDK treats key-present-but-undefined
// differently from key-absent for some options (e.g. model), causing hangs.
const sdkOptions: Options = {
cwd: this.options.cwd,
permissionMode,
};
if (this.options.model) sdkOptions.model = this.options.model;
if (this.options.maxTurns != null) sdkOptions.maxTurns = this.options.maxTurns;
if (this.options.allowedTools) sdkOptions.allowedTools = this.options.allowedTools;
if (this.options.agents) sdkOptions.agents = this.options.agents;
if (this.options.systemPrompt) sdkOptions.systemPrompt = this.options.systemPrompt;
if (canUseTool) sdkOptions.canUseTool = canUseTool;
if (hooks) sdkOptions.hooks = hooks;
if (this.options.anthropicApiKey) {
sdkOptions.env = {
...process.env as Record<string, string>,
ANTHROPIC_API_KEY: this.options.anthropicApiKey,
};
}
if (this.options.onStream) {
sdkOptions.includePartialMessages = true;
}
if (this.options.sessionId) {
sdkOptions.resume = this.options.sessionId;
} else {
sdkOptions.continue = false;
}
return sdkOptions;
}
/** Resolve permission mode with priority: bypassPermissions > explicit > callback-based > default */
private resolvePermissionMode(): PermissionMode {
if (this.options.bypassPermissions) {
return 'bypassPermissions';
}
if (this.options.permissionMode) {
return this.options.permissionMode;
}
if (this.options.onPermissionRequest) {
return 'default';
}
return 'acceptEdits';
}
/**
* Create canUseTool callback from permission handler.
*/
static createCanUseToolCallback(
handler: PermissionHandler
): CanUseTool {
return async (
toolName: string,
input: Record<string, unknown>,
callbackOptions: {
signal: AbortSignal;
suggestions?: PermissionUpdate[];
blockedPath?: string;
decisionReason?: string;
}
): Promise<PermissionResult> => {
return handler({
toolName,
input,
suggestions: callbackOptions.suggestions,
blockedPath: callbackOptions.blockedPath,
decisionReason: callbackOptions.decisionReason,
});
};
}
/**
* Create hooks for AskUserQuestion handling.
*/
static createAskUserQuestionHooks(
askUserHandler: AskUserQuestionHandler
): Partial<Record<string, HookCallbackMatcher[]>> {
const preToolUseHook = async (
input: HookInput,
_toolUseID: string | undefined,
_options: { signal: AbortSignal }
): Promise<HookJSONOutput> => {
const preToolInput = input as PreToolUseHookInput;
if (preToolInput.tool_name === 'AskUserQuestion') {
const toolInput = preToolInput.tool_input as AskUserQuestionInput;
try {
const answers = await askUserHandler(toolInput);
return {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: JSON.stringify(answers),
},
};
} catch (err) {
log.error('AskUserQuestion handler failed', { error: err });
return { continue: true };
}
}
return { continue: true };
};
return {
PreToolUse: [{
matcher: 'AskUserQuestion',
hooks: [preToolUseHook],
}],
};
}
} }
/** // ---- Backward-compatible module-level functions ----
* Create canUseTool callback from permission handler.
*/
export function createCanUseToolCallback( export function createCanUseToolCallback(
handler: PermissionHandler handler: PermissionHandler
): CanUseTool { ): CanUseTool {
return async ( return SdkOptionsBuilder.createCanUseToolCallback(handler);
toolName: string,
input: Record<string, unknown>,
callbackOptions: {
signal: AbortSignal;
suggestions?: PermissionUpdate[];
blockedPath?: string;
decisionReason?: string;
}
): Promise<PermissionResult> => {
return handler({
toolName,
input,
suggestions: callbackOptions.suggestions,
blockedPath: callbackOptions.blockedPath,
decisionReason: callbackOptions.decisionReason,
});
};
} }
/**
* Create hooks for AskUserQuestion handling.
*/
export function createAskUserQuestionHooks( export function createAskUserQuestionHooks(
askUserHandler: AskUserQuestionHandler askUserHandler: AskUserQuestionHandler
): Partial<Record<string, HookCallbackMatcher[]>> { ): Partial<Record<string, HookCallbackMatcher[]>> {
const preToolUseHook = async ( return SdkOptionsBuilder.createAskUserQuestionHooks(askUserHandler);
input: HookInput,
_toolUseID: string | undefined,
_options: { signal: AbortSignal }
): Promise<HookJSONOutput> => {
const preToolInput = input as PreToolUseHookInput;
if (preToolInput.tool_name === 'AskUserQuestion') {
const toolInput = preToolInput.tool_input as AskUserQuestionInput;
try {
const answers = await askUserHandler(toolInput);
return {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: JSON.stringify(answers),
},
};
} catch (err) {
log.error('AskUserQuestion handler failed', { error: err });
return { continue: true };
}
}
return { continue: true };
};
return {
PreToolUse: [{
matcher: 'AskUserQuestion',
hooks: [preToolUseHook],
}],
};
} }
/**
* 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,354 +27,454 @@ type CodexItem = {
[key: string]: unknown; [key: string]: unknown;
}; };
function formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }>): string {
if (!changes.length) return '';
return changes
.map((change) => {
const kind = change.kind ? `${change.kind}: ` : '';
return `${kind}${change.path ?? ''}`.trim();
})
.filter(Boolean)
.join('\n');
}
function emitCodexItemStart(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
if (startedItems.has(id)) return;
switch (item.type) {
case 'command_execution': {
const command = typeof item.command === 'string' ? item.command : '';
emitToolUse(onStream, 'Bash', { command }, id);
startedItems.add(id);
break;
}
case 'mcp_tool_call': {
const tool = typeof item.tool === 'string' ? item.tool : 'Tool';
const args = (item.arguments ?? {}) as Record<string, unknown>;
emitToolUse(onStream, tool, args, id);
startedItems.add(id);
break;
}
case 'web_search': {
const query = typeof item.query === 'string' ? item.query : '';
emitToolUse(onStream, 'WebSearch', { query }, id);
startedItems.add(id);
break;
}
case 'file_change': {
const changes = Array.isArray(item.changes) ? item.changes : [];
const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id);
startedItems.add(id);
break;
}
default:
break;
}
}
function emitCodexItemCompleted(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>,
outputOffsets: Map<string, number>,
textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number>
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
switch (item.type) {
case 'reasoning': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = thinkingOffsets.get(id) ?? 0;
if (text.length > prev) {
emitThinking(onStream, text.slice(prev) + '\n');
thinkingOffsets.set(id, text.length);
}
}
break;
}
case 'agent_message': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = textOffsets.get(id) ?? 0;
if (text.length > prev) {
emitText(onStream, text.slice(prev));
textOffsets.set(id, text.length);
}
}
break;
}
case 'command_execution': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
if (output) {
const prev = outputOffsets.get(id) ?? 0;
if (output.length > prev) {
emitToolOutput(onStream, 'Bash', output.slice(prev));
outputOffsets.set(id, output.length);
}
}
const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined;
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed' || (exitCode !== undefined && exitCode !== 0);
const content = output || (exitCode !== undefined ? `Exit code: ${exitCode}` : '');
emitToolResult(onStream, content, isError);
break;
}
case 'mcp_tool_call': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed' || !!item.error;
const errorMessage =
item.error && typeof item.error === 'object' && 'message' in item.error
? String((item.error as { message?: unknown }).message ?? '')
: '';
let content = errorMessage;
if (!content && item.result && typeof item.result === 'object') {
try {
content = JSON.stringify(item.result);
} catch {
content = '';
}
}
emitToolResult(onStream, content, isError);
break;
}
case 'web_search': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
emitToolResult(onStream, 'Search completed', false);
break;
}
case 'file_change': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed';
const changes = Array.isArray(item.changes) ? item.changes : [];
const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
emitToolResult(onStream, summary || 'Applied patch', isError);
break;
}
default:
break;
}
}
function emitCodexItemUpdate(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>,
outputOffsets: Map<string, number>,
textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number>
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
switch (item.type) {
case 'command_execution': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
if (output) {
const prev = outputOffsets.get(id) ?? 0;
if (output.length > prev) {
emitToolOutput(onStream, 'Bash', output.slice(prev));
outputOffsets.set(id, output.length);
}
}
break;
}
case 'agent_message': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = textOffsets.get(id) ?? 0;
if (text.length > prev) {
emitText(onStream, text.slice(prev));
textOffsets.set(id, text.length);
}
}
break;
}
case 'reasoning': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = thinkingOffsets.get(id) ?? 0;
if (text.length > prev) {
emitThinking(onStream, text.slice(prev));
thinkingOffsets.set(id, text.length);
}
}
break;
}
case 'file_change': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
break;
}
case 'mcp_tool_call': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
break;
}
case 'web_search': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
break;
}
default:
break;
}
}
/** /**
* Call Codex with an agent prompt. * Client for Codex SDK agent interactions.
*
* Handles thread management, streaming event conversion,
* and response processing.
*/ */
export async function callCodex( export class CodexClient {
agentType: string, // ---- Stream emission helpers (private) ----
prompt: string,
options: CodexCallOptions
): Promise<AgentResponse> {
const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined);
const threadOptions = {
model: options.model,
workingDirectory: options.cwd,
};
const thread = options.sessionId
? await codex.resumeThread(options.sessionId, threadOptions)
: await codex.startThread(threadOptions);
let threadId = extractThreadId(thread) || options.sessionId;
const fullPrompt = options.systemPrompt private static extractThreadId(value: unknown): string | undefined {
? `${options.systemPrompt}\n\n${prompt}` if (!value || typeof value !== 'object') return undefined;
: prompt; const record = value as Record<string, unknown>;
const id = record.id ?? record.thread_id ?? record.threadId;
return typeof id === 'string' ? id : undefined;
}
try { private static emitInit(
log.debug('Executing Codex thread', { onStream: StreamCallback | undefined,
agentType, model: string | undefined,
model: options.model, sessionId: string | undefined,
hasSystemPrompt: !!options.systemPrompt, ): void {
if (!onStream) return;
onStream({
type: 'init',
data: {
model: model || 'codex',
sessionId: sessionId || 'unknown',
},
}); });
}
const { events } = await thread.runStreamed(fullPrompt); private static emitText(onStream: StreamCallback | undefined, text: string): void {
let content = ''; if (!onStream || !text) return;
const contentOffsets = new Map<string, number>(); onStream({ type: 'text', data: { text } });
let success = true; }
let failureMessage = '';
const startedItems = new Set<string>();
const outputOffsets = new Map<string, number>();
const textOffsets = new Map<string, number>();
const thinkingOffsets = new Map<string, number>();
for await (const event of events as AsyncGenerator<CodexEvent>) { private static emitThinking(onStream: StreamCallback | undefined, thinking: string): void {
if (event.type === 'thread.started') { if (!onStream || !thinking) return;
threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId; onStream({ type: 'thinking', data: { thinking } });
emitInit(options.onStream, options.model, threadId); }
continue;
}
if (event.type === 'turn.failed') { private static emitToolUse(
success = false; onStream: StreamCallback | undefined,
if (event.error && typeof event.error === 'object' && 'message' in event.error) { tool: string,
failureMessage = String((event.error as { message?: unknown }).message ?? ''); input: Record<string, unknown>,
} id: string,
): void {
if (!onStream) return;
onStream({ type: 'tool_use', data: { tool, input, id } });
}
private static emitToolResult(
onStream: StreamCallback | undefined,
content: string,
isError: boolean,
): void {
if (!onStream) return;
onStream({ type: 'tool_result', data: { content, isError } });
}
private static emitToolOutput(
onStream: StreamCallback | undefined,
tool: string,
output: string,
): void {
if (!onStream || !output) return;
onStream({ type: 'tool_output', data: { tool, output } });
}
private static emitResult(
onStream: StreamCallback | undefined,
success: boolean,
result: string,
sessionId: string | undefined,
): void {
if (!onStream) return;
onStream({
type: 'result',
data: {
result,
sessionId: sessionId || 'unknown',
success,
error: success ? undefined : result || undefined,
},
});
}
private static formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }>): string {
if (!changes.length) return '';
return changes
.map((change) => {
const kind = change.kind ? `${change.kind}: ` : '';
return `${kind}${change.path ?? ''}`.trim();
})
.filter(Boolean)
.join('\n');
}
private static emitCodexItemStart(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>,
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
if (startedItems.has(id)) return;
switch (item.type) {
case 'command_execution': {
const command = typeof item.command === 'string' ? item.command : '';
CodexClient.emitToolUse(onStream, 'Bash', { command }, id);
startedItems.add(id);
break; break;
} }
case 'mcp_tool_call': {
if (event.type === 'error') { const tool = typeof item.tool === 'string' ? item.tool : 'Tool';
success = false; const args = (item.arguments ?? {}) as Record<string, unknown>;
failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error'; CodexClient.emitToolUse(onStream, tool, args, id);
startedItems.add(id);
break; break;
} }
case 'web_search': {
if (event.type === 'item.started') { const query = typeof item.query === 'string' ? item.query : '';
const item = event.item as CodexItem | undefined; CodexClient.emitToolUse(onStream, 'WebSearch', { query }, id);
if (item) { startedItems.add(id);
emitCodexItemStart(item, options.onStream, startedItems); break;
}
continue;
} }
case 'file_change': {
if (event.type === 'item.updated') { const changes = Array.isArray(item.changes) ? item.changes : [];
const item = event.item as CodexItem | undefined; const summary = CodexClient.formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
if (item) { CodexClient.emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id);
if (item.type === 'agent_message' && typeof item.text === 'string') { startedItems.add(id);
const itemId = item.id; break;
const text = item.text;
if (itemId) {
const prev = contentOffsets.get(itemId) ?? 0;
if (text.length > prev) {
if (prev === 0 && content.length > 0) {
content += '\n';
}
content += text.slice(prev);
contentOffsets.set(itemId, text.length);
}
}
}
emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets);
}
continue;
}
if (event.type === 'item.completed') {
const item = event.item as CodexItem | undefined;
if (item) {
if (item.type === 'agent_message' && typeof item.text === 'string') {
const itemId = item.id;
const text = item.text;
if (itemId) {
const prev = contentOffsets.get(itemId) ?? 0;
if (text.length > prev) {
if (prev === 0 && content.length > 0) {
content += '\n';
}
content += text.slice(prev);
contentOffsets.set(itemId, text.length);
}
} else if (text) {
if (content.length > 0) {
content += '\n';
}
content += text;
}
}
emitCodexItemCompleted(
item,
options.onStream,
startedItems,
outputOffsets,
textOffsets,
thinkingOffsets
);
}
continue;
} }
default:
break;
} }
}
private static emitCodexItemCompleted(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>,
outputOffsets: Map<string, number>,
textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number>,
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
switch (item.type) {
case 'reasoning': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = thinkingOffsets.get(id) ?? 0;
if (text.length > prev) {
CodexClient.emitThinking(onStream, text.slice(prev) + '\n');
thinkingOffsets.set(id, text.length);
}
}
break;
}
case 'agent_message': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = textOffsets.get(id) ?? 0;
if (text.length > prev) {
CodexClient.emitText(onStream, text.slice(prev));
textOffsets.set(id, text.length);
}
}
break;
}
case 'command_execution': {
if (!startedItems.has(id)) {
CodexClient.emitCodexItemStart(item, onStream, startedItems);
}
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
if (output) {
const prev = outputOffsets.get(id) ?? 0;
if (output.length > prev) {
CodexClient.emitToolOutput(onStream, 'Bash', output.slice(prev));
outputOffsets.set(id, output.length);
}
}
const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined;
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed' || (exitCode !== undefined && exitCode !== 0);
const content = output || (exitCode !== undefined ? `Exit code: ${exitCode}` : '');
CodexClient.emitToolResult(onStream, content, isError);
break;
}
case 'mcp_tool_call': {
if (!startedItems.has(id)) {
CodexClient.emitCodexItemStart(item, onStream, startedItems);
}
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed' || !!item.error;
const errorMessage =
item.error && typeof item.error === 'object' && 'message' in item.error
? String((item.error as { message?: unknown }).message ?? '')
: '';
let content = errorMessage;
if (!content && item.result && typeof item.result === 'object') {
try {
content = JSON.stringify(item.result);
} catch {
content = '';
}
}
CodexClient.emitToolResult(onStream, content, isError);
break;
}
case 'web_search': {
if (!startedItems.has(id)) {
CodexClient.emitCodexItemStart(item, onStream, startedItems);
}
CodexClient.emitToolResult(onStream, 'Search completed', false);
break;
}
case 'file_change': {
if (!startedItems.has(id)) {
CodexClient.emitCodexItemStart(item, onStream, startedItems);
}
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed';
const changes = Array.isArray(item.changes) ? item.changes : [];
const summary = CodexClient.formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
CodexClient.emitToolResult(onStream, summary || 'Applied patch', isError);
break;
}
default:
break;
}
}
private static emitCodexItemUpdate(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>,
outputOffsets: Map<string, number>,
textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number>,
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
switch (item.type) {
case 'command_execution': {
if (!startedItems.has(id)) {
CodexClient.emitCodexItemStart(item, onStream, startedItems);
}
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
if (output) {
const prev = outputOffsets.get(id) ?? 0;
if (output.length > prev) {
CodexClient.emitToolOutput(onStream, 'Bash', output.slice(prev));
outputOffsets.set(id, output.length);
}
}
break;
}
case 'agent_message': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = textOffsets.get(id) ?? 0;
if (text.length > prev) {
CodexClient.emitText(onStream, text.slice(prev));
textOffsets.set(id, text.length);
}
}
break;
}
case 'reasoning': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = thinkingOffsets.get(id) ?? 0;
if (text.length > prev) {
CodexClient.emitThinking(onStream, text.slice(prev));
thinkingOffsets.set(id, text.length);
}
}
break;
}
case 'file_change':
case 'mcp_tool_call':
case 'web_search': {
if (!startedItems.has(id)) {
CodexClient.emitCodexItemStart(item, onStream, startedItems);
}
break;
}
default:
break;
}
}
// ---- Public API ----
/** Call Codex with an agent prompt */
async call(
agentType: string,
prompt: string,
options: CodexCallOptions,
): Promise<AgentResponse> {
const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined);
const threadOptions = {
model: options.model,
workingDirectory: options.cwd,
};
const thread = options.sessionId
? await codex.resumeThread(options.sessionId, threadOptions)
: await codex.startThread(threadOptions);
let threadId = CodexClient.extractThreadId(thread) || options.sessionId;
const fullPrompt = options.systemPrompt
? `${options.systemPrompt}\n\n${prompt}`
: prompt;
try {
log.debug('Executing Codex thread', {
agentType,
model: options.model,
hasSystemPrompt: !!options.systemPrompt,
});
const { events } = await thread.runStreamed(fullPrompt);
let content = '';
const contentOffsets = new Map<string, number>();
let success = true;
let failureMessage = '';
const startedItems = new Set<string>();
const outputOffsets = new Map<string, number>();
const textOffsets = new Map<string, number>();
const thinkingOffsets = new Map<string, number>();
for await (const event of events as AsyncGenerator<CodexEvent>) {
if (event.type === 'thread.started') {
threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId;
CodexClient.emitInit(options.onStream, options.model, threadId);
continue;
}
if (event.type === 'turn.failed') {
success = false;
if (event.error && typeof event.error === 'object' && 'message' in event.error) {
failureMessage = String((event.error as { message?: unknown }).message ?? '');
}
break;
}
if (event.type === 'error') {
success = false;
failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error';
break;
}
if (event.type === 'item.started') {
const item = event.item as CodexItem | undefined;
if (item) {
CodexClient.emitCodexItemStart(item, options.onStream, startedItems);
}
continue;
}
if (event.type === 'item.updated') {
const item = event.item as CodexItem | undefined;
if (item) {
if (item.type === 'agent_message' && typeof item.text === 'string') {
const itemId = item.id;
const text = item.text;
if (itemId) {
const prev = contentOffsets.get(itemId) ?? 0;
if (text.length > prev) {
if (prev === 0 && content.length > 0) {
content += '\n';
}
content += text.slice(prev);
contentOffsets.set(itemId, text.length);
}
}
}
CodexClient.emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets);
}
continue;
}
if (event.type === 'item.completed') {
const item = event.item as CodexItem | undefined;
if (item) {
if (item.type === 'agent_message' && typeof item.text === 'string') {
const itemId = item.id;
const text = item.text;
if (itemId) {
const prev = contentOffsets.get(itemId) ?? 0;
if (text.length > prev) {
if (prev === 0 && content.length > 0) {
content += '\n';
}
content += text.slice(prev);
contentOffsets.set(itemId, text.length);
}
} else if (text) {
if (content.length > 0) {
content += '\n';
}
content += text;
}
}
CodexClient.emitCodexItemCompleted(
item,
options.onStream,
startedItems,
outputOffsets,
textOffsets,
thinkingOffsets,
);
}
continue;
}
}
if (!success) {
const message = failureMessage || 'Codex execution failed';
CodexClient.emitResult(options.onStream, false, message, threadId);
return {
agent: agentType,
status: 'blocked',
content: message,
timestamp: new Date(),
sessionId: threadId,
};
}
const trimmed = content.trim();
CodexClient.emitResult(options.onStream, true, trimmed, threadId);
return {
agent: agentType,
status: 'done',
content: trimmed,
timestamp: new Date(),
sessionId: threadId,
};
} catch (error) {
const message = getErrorMessage(error);
CodexClient.emitResult(options.onStream, false, message, threadId);
if (!success) {
const message = failureMessage || 'Codex execution failed';
emitResult(options.onStream, false, message, threadId);
return { return {
agent: agentType, agent: agentType,
status: 'blocked', status: 'blocked',
@ -473,44 +483,39 @@ export async function callCodex(
sessionId: threadId, sessionId: threadId,
}; };
} }
}
const trimmed = content.trim(); /** Call Codex with a custom agent configuration (system prompt + prompt) */
emitResult(options.onStream, true, trimmed, threadId); async callCustom(
agentName: string,
const status = determineStatus(true); prompt: string,
systemPrompt: string,
return { options: CodexCallOptions,
agent: agentType, ): Promise<AgentResponse> {
status, return this.call(agentName, prompt, {
content: trimmed, ...options,
timestamp: new Date(), systemPrompt,
sessionId: threadId, });
};
} catch (error) {
const message = getErrorMessage(error);
emitResult(options.onStream, false, message, threadId);
return {
agent: agentType,
status: 'blocked',
content: message,
timestamp: new Date(),
sessionId: threadId,
};
} }
} }
/** // ---- Backward-compatible module-level functions ----
* Call Codex with a custom agent configuration (system prompt + prompt).
*/ const defaultClient = new CodexClient();
export async function callCodex(
agentType: string,
prompt: string,
options: CodexCallOptions,
): Promise<AgentResponse> {
return defaultClient.call(agentType, prompt, options);
}
export async function callCodexCustom( 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 {
/** Call the provider with a prompt (using systemPrompt from options if provided) */
call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
/** Call the provider with explicit system prompt */
callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
}
/** Provider type */
export type ProviderType = 'claude' | 'codex' | 'mock';
/** Provider registry */
const providers: Record<ProviderType, Provider> = {
claude: new ClaudeProvider(),
codex: new CodexProvider(),
mock: new MockProvider(),
};
/** /**
* Get a provider instance by type * Registry for agent providers.
* Singleton use ProviderRegistry.getInstance().
*/ */
export function getProvider(type: ProviderType): Provider { export class ProviderRegistry {
const provider = providers[type]; private static instance: ProviderRegistry | null = null;
if (!provider) { private readonly providers: Record<string, Provider>;
throw new Error(`Unknown provider type: ${type}`);
private constructor() {
this.providers = {
claude: new ClaudeProvider(),
codex: new CodexProvider(),
mock: new MockProvider(),
};
} }
return provider;
static getInstance(): ProviderRegistry {
if (!ProviderRegistry.instance) {
ProviderRegistry.instance = new ProviderRegistry();
}
return ProviderRegistry.instance;
}
/** Reset singleton for testing */
static resetInstance(): void {
ProviderRegistry.instance = null;
}
/** Get a provider instance by type */
get(type: ProviderType): Provider {
const provider = this.providers[type];
if (!provider) {
throw new Error(`Unknown provider type: ${type}`);
}
return provider;
}
} }
/** // ---- 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,52 +24,60 @@ export interface AutoCommitResult {
} }
/** /**
* Auto-commit all changes and push to origin. * Handles auto-commit and push operations for clone tasks.
*
* Steps:
* 1. Stage all changes (git add -A)
* 2. Check if there are staged changes (git status --porcelain)
* 3. If changes exist, create a commit with "takt: {taskName}"
* 4. Push to origin (git push origin HEAD)
*
* @param cloneCwd - The clone directory
* @param taskName - Task name used in commit message
* @param projectDir - The main project directory (push target)
*/ */
export function autoCommitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult { export class AutoCommitter {
log.info('Auto-commit starting', { cwd: cloneCwd, taskName }); /**
* Auto-commit all changes and push to the main project.
*
* Steps:
* 1. Stage all changes (git add -A)
* 2. Check if there are staged changes
* 3. If changes exist, create a commit with "takt: {taskName}"
* 4. Push to the main project directory
*/
commitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult {
log.info('Auto-commit starting', { cwd: cloneCwd, taskName });
try { try {
const commitMessage = `takt: ${taskName}`; const commitMessage = `takt: ${taskName}`;
const commitHash = stageAndCommit(cloneCwd, commitMessage); const commitHash = stageAndCommit(cloneCwd, commitMessage);
if (!commitHash) { if (!commitHash) {
log.info('No changes to commit'); log.info('No changes to commit');
return { success: true, message: 'No changes to commit' }; return { success: true, message: 'No changes to commit' };
}
log.info('Auto-commit created', { commitHash, message: commitMessage });
execFileSync('git', ['push', projectDir, 'HEAD'], {
cwd: cloneCwd,
stdio: 'pipe',
});
log.info('Pushed to main repo', { projectDir });
return {
success: true,
commitHash,
message: `Committed & pushed: ${commitHash} - ${commitMessage}`,
};
} catch (err) {
const errorMessage = getErrorMessage(err);
log.error('Auto-commit failed', { error: errorMessage });
return {
success: false,
message: `Auto-commit failed: ${errorMessage}`,
};
} }
log.info('Auto-commit created', { commitHash, message: commitMessage });
// Push directly to the main repo (origin was removed to isolate the clone)
execFileSync('git', ['push', projectDir, 'HEAD'], {
cwd: cloneCwd,
stdio: 'pipe',
});
log.info('Pushed to main repo', { projectDir });
return {
success: true,
commitHash,
message: `Committed & pushed: ${commitHash} - ${commitMessage}`,
};
} catch (err) {
const errorMessage = getErrorMessage(err);
log.error('Auto-commit failed', { error: errorMessage });
return {
success: false,
message: `Auto-commit failed: ${errorMessage}`,
};
} }
} }
// ---- Backward-compatible module-level function ----
const defaultCommitter = new AutoCommitter();
export function autoCommitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult {
return defaultCommitter.commitAndPush(cloneCwd, taskName, projectDir);
}

View File

@ -1,7 +1,7 @@
/** /**
* Branch list helpers * 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,147 +29,171 @@ 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 {
try { /** Detect the default branch name (main or master) */
const ref = execFileSync( detectDefaultBranch(cwd: string): string {
'git', ['symbolic-ref', 'refs/remotes/origin/HEAD'],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
).trim();
const parts = ref.split('/');
return parts[parts.length - 1] || 'main';
} catch {
try { try {
execFileSync('git', ['rev-parse', '--verify', 'main'], { const ref = execFileSync(
cwd, encoding: 'utf-8', stdio: 'pipe', 'git', ['symbolic-ref', 'refs/remotes/origin/HEAD'],
}); { cwd, encoding: 'utf-8', stdio: 'pipe' },
return 'main'; ).trim();
const parts = ref.split('/');
return parts[parts.length - 1] || 'main';
} catch { } catch {
try { try {
execFileSync('git', ['rev-parse', '--verify', 'master'], { execFileSync('git', ['rev-parse', '--verify', 'main'], {
cwd, encoding: 'utf-8', stdio: 'pipe', cwd, encoding: 'utf-8', stdio: 'pipe',
}); });
return 'master';
} catch {
return 'main'; return 'main';
} catch {
try {
execFileSync('git', ['rev-parse', '--verify', 'master'], {
cwd, encoding: 'utf-8', stdio: 'pipe',
});
return 'master';
} catch {
return 'main';
}
} }
} }
} }
/** List all takt-managed branches */
listTaktBranches(projectDir: string): BranchInfo[] {
try {
const output = execFileSync(
'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
);
return BranchManager.parseTaktBranches(output);
} catch (err) {
log.error('Failed to list takt branches', { error: String(err) });
return [];
}
}
/** Parse `git branch --list` formatted output into BranchInfo entries */
static parseTaktBranches(output: string): BranchInfo[] {
const entries: BranchInfo[] = [];
const lines = output.trim().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const spaceIdx = trimmed.lastIndexOf(' ');
if (spaceIdx === -1) continue;
const branch = trimmed.slice(0, spaceIdx);
const commit = trimmed.slice(spaceIdx + 1);
if (branch.startsWith(TAKT_BRANCH_PREFIX)) {
entries.push({ branch, commit });
}
}
return entries;
}
/** Get the number of files changed between the default branch and a given branch */
getFilesChanged(cwd: string, defaultBranch: string, branch: string): number {
try {
const output = execFileSync(
'git', ['diff', '--numstat', `${defaultBranch}...${branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
);
return output.trim().split('\n').filter(l => l.length > 0).length;
} catch {
return 0;
}
}
/** Extract a human-readable task slug from a takt branch name */
static extractTaskSlug(branch: string): string {
const name = branch.replace(TAKT_BRANCH_PREFIX, '');
const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, '');
return withoutTimestamp || name;
}
/**
* Extract the original task instruction from the first commit message on a branch.
* The first commit on a takt branch has the format: "takt: {original instruction}".
*/
getOriginalInstruction(
cwd: string,
defaultBranch: string,
branch: string,
): string {
try {
const output = execFileSync(
'git',
['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (!output) return '';
const firstLine = output.split('\n')[0] || '';
const TAKT_COMMIT_PREFIX = 'takt:';
if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) {
return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim();
}
return firstLine;
} catch {
return '';
}
}
/** Build list items from branch list, enriching with diff stats */
buildListItems(
projectDir: string,
branches: BranchInfo[],
defaultBranch: string,
): BranchListItem[] {
return branches.map(br => ({
info: br,
filesChanged: this.getFilesChanged(projectDir, defaultBranch, br.branch),
taskSlug: BranchManager.extractTaskSlug(br.branch),
originalInstruction: this.getOriginalInstruction(projectDir, defaultBranch, br.branch),
}));
}
}
// ---- Backward-compatible module-level functions ----
const defaultManager = new BranchManager();
export function detectDefaultBranch(cwd: string): string {
return defaultManager.detectDefaultBranch(cwd);
} }
/**
* List all takt-managed branches.
*/
export function listTaktBranches(projectDir: string): BranchInfo[] { export function listTaktBranches(projectDir: string): BranchInfo[] {
try { return defaultManager.listTaktBranches(projectDir);
const output = execFileSync(
'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
);
return parseTaktBranches(output);
} catch (err) {
log.error('Failed to list takt branches', { error: String(err) });
return [];
}
} }
/**
* Parse `git branch --list` formatted output into BranchInfo entries.
*/
export function parseTaktBranches(output: string): BranchInfo[] { export function parseTaktBranches(output: string): BranchInfo[] {
const entries: BranchInfo[] = []; return BranchManager.parseTaktBranches(output);
const lines = output.trim().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const spaceIdx = trimmed.lastIndexOf(' ');
if (spaceIdx === -1) continue;
const branch = trimmed.slice(0, spaceIdx);
const commit = trimmed.slice(spaceIdx + 1);
if (branch.startsWith(TAKT_BRANCH_PREFIX)) {
entries.push({ branch, commit });
}
}
return entries;
} }
/**
* Get the number of files changed between the default branch and a given branch.
*/
export function getFilesChanged(cwd: string, defaultBranch: string, branch: string): number { export function getFilesChanged(cwd: string, defaultBranch: string, branch: string): number {
try { return defaultManager.getFilesChanged(cwd, defaultBranch, branch);
const output = execFileSync(
'git', ['diff', '--numstat', `${defaultBranch}...${branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
);
return output.trim().split('\n').filter(l => l.length > 0).length;
} catch {
return 0;
}
} }
/**
* Extract a human-readable task slug from a takt branch name.
* e.g. "takt/20260128T032800-fix-auth" -> "fix-auth"
*/
export function extractTaskSlug(branch: string): string { export function extractTaskSlug(branch: string): string {
const name = branch.replace(TAKT_BRANCH_PREFIX, ''); return BranchManager.extractTaskSlug(branch);
const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, '');
return withoutTimestamp || name;
} }
/** export function getOriginalInstruction(cwd: string, defaultBranch: string, branch: string): string {
* Extract the original task instruction from the first commit message on a branch. return defaultManager.getOriginalInstruction(cwd, defaultBranch, branch);
*
* The first commit on a takt branch has the format: "takt: {original instruction}".
* Strips the "takt: " prefix and returns the instruction text.
* Returns empty string if extraction fails.
*/
export function getOriginalInstruction(
cwd: string,
defaultBranch: string,
branch: string,
): string {
try {
const output = execFileSync(
'git',
['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (!output) return '';
const firstLine = output.split('\n')[0] || '';
const TAKT_COMMIT_PREFIX = 'takt:';
if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) {
return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim();
}
return firstLine;
} catch {
return '';
}
} }
/**
* 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,234 +34,231 @@ 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);
}
/** /**
* Resolve the base directory for clones from global config. * Manages git clone lifecycle for task isolation.
* Returns the configured worktree_dir (resolved to absolute), or ../
*/
function resolveCloneBaseDir(projectDir: string): string {
const globalConfig = loadGlobalConfig();
if (globalConfig.worktreeDir) {
return path.isAbsolute(globalConfig.worktreeDir)
? globalConfig.worktreeDir
: path.resolve(projectDir, globalConfig.worktreeDir);
}
return path.join(projectDir, '..');
}
/**
* Resolve the clone path based on options and global config.
* *
* Priority: * Handles creation, removal, and metadata tracking of clones
* 1. Custom path in options.worktree (string) * used for parallel task execution.
* 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 { export class CloneManager {
const timestamp = generateTimestamp(); private static generateTimestamp(): string {
const slug = slugify(options.taskSlug); return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
let dirName: string;
if (options.issueNumber !== undefined && slug) {
dirName = `${timestamp}-${options.issueNumber}-${slug}`;
} else if (slug) {
dirName = `${timestamp}-${slug}`;
} else {
dirName = timestamp;
} }
if (typeof options.worktree === 'string') { /**
return path.isAbsolute(options.worktree) * Resolve the base directory for clones from global config.
? options.worktree * Returns the configured worktree_dir (resolved to absolute), or ../
: path.resolve(projectDir, options.worktree); */
private static resolveCloneBaseDir(projectDir: string): string {
const globalConfig = loadGlobalConfig();
if (globalConfig.worktreeDir) {
return path.isAbsolute(globalConfig.worktreeDir)
? globalConfig.worktreeDir
: path.resolve(projectDir, globalConfig.worktreeDir);
}
return path.join(projectDir, '..');
} }
return path.join(resolveCloneBaseDir(projectDir), dirName); /** Resolve the clone path based on options and global config */
} private static resolveClonePath(projectDir: string, options: WorktreeOptions): string {
const timestamp = CloneManager.generateTimestamp();
const slug = slugify(options.taskSlug);
/** let dirName: string;
* Resolve branch name from options. if (options.issueNumber !== undefined && slug) {
* dirName = `${timestamp}-${options.issueNumber}-${slug}`;
* Format with issue: takt/#{issue}/{slug} } else if (slug) {
* Format without issue: takt/{timestamp}-{slug} dirName = `${timestamp}-${slug}`;
* Custom branch: use as-is } else {
*/ dirName = timestamp;
function resolveBranchName(options: WorktreeOptions): string { }
if (options.branch) {
return options.branch; if (typeof options.worktree === 'string') {
return path.isAbsolute(options.worktree)
? options.worktree
: path.resolve(projectDir, options.worktree);
}
return path.join(CloneManager.resolveCloneBaseDir(projectDir), dirName);
} }
const slug = slugify(options.taskSlug); /** Resolve branch name from options */
private static resolveBranchName(options: WorktreeOptions): string {
if (options.branch) {
return options.branch;
}
if (options.issueNumber !== undefined && slug) { const slug = slugify(options.taskSlug);
return `takt/#${options.issueNumber}/${slug}`;
if (options.issueNumber !== undefined && slug) {
return `takt/#${options.issueNumber}/${slug}`;
}
const timestamp = CloneManager.generateTimestamp();
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
} }
const timestamp = generateTimestamp(); private static branchExists(projectDir: string, branch: string): boolean {
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`; try {
} execFileSync('git', ['rev-parse', '--verify', branch], {
cwd: projectDir,
stdio: 'pipe',
});
return true;
} catch {
return false;
}
}
function branchExists(projectDir: string, branch: string): boolean { /** Clone a repository and remove origin to isolate from the main repo */
try { private static cloneAndIsolate(projectDir: string, clonePath: string): void {
execFileSync('git', ['rev-parse', '--verify', branch], { fs.mkdirSync(path.dirname(clonePath), { recursive: true });
execFileSync('git', ['clone', '--reference', projectDir, '--dissociate', projectDir, clonePath], {
cwd: projectDir, cwd: projectDir,
stdio: 'pipe', stdio: 'pipe',
}); });
return true;
} catch {
return false;
}
}
/** execFileSync('git', ['remote', 'remove', 'origin'], {
* Clone a repository and remove origin to isolate from the main repo. cwd: clonePath,
*/ stdio: 'pipe',
function cloneAndIsolate(projectDir: string, clonePath: string): void { });
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
execFileSync('git', ['clone', '--reference', projectDir, '--dissociate', projectDir, clonePath], { // Propagate local git user config from source repo to clone
cwd: projectDir, for (const key of ['user.name', 'user.email']) {
stdio: 'pipe', try {
}); const value = execFileSync('git', ['config', '--local', key], {
cwd: projectDir,
execFileSync('git', ['remote', 'remove', 'origin'], {
cwd: clonePath,
stdio: 'pipe',
});
// Propagate local git user config from source repo to clone
for (const key of ['user.name', 'user.email']) {
try {
const value = execFileSync('git', ['config', '--local', key], {
cwd: projectDir,
stdio: 'pipe',
}).toString().trim();
if (value) {
execFileSync('git', ['config', key, value], {
cwd: clonePath,
stdio: 'pipe', stdio: 'pipe',
}); }).toString().trim();
if (value) {
execFileSync('git', ['config', key, value], {
cwd: clonePath,
stdio: 'pipe',
});
}
} catch {
// not set locally — skip
}
}
}
private static encodeBranchName(branch: string): string {
return branch.replace(/\//g, '--');
}
private static getCloneMetaPath(projectDir: string, branch: string): string {
return path.join(projectDir, '.takt', CLONE_META_DIR, `${CloneManager.encodeBranchName(branch)}.json`);
}
/** Create a git clone for a task */
createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
const clonePath = CloneManager.resolveClonePath(projectDir, options);
const branch = CloneManager.resolveBranchName(options);
log.info('Creating shared clone', { path: clonePath, branch });
CloneManager.cloneAndIsolate(projectDir, clonePath);
if (CloneManager.branchExists(clonePath, branch)) {
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
} else {
execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' });
}
this.saveCloneMeta(projectDir, branch, clonePath);
log.info('Clone created', { path: clonePath, branch });
return { path: clonePath, branch };
}
/** Create a temporary clone for an existing branch */
createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
const timestamp = CloneManager.generateTimestamp();
const clonePath = path.join(CloneManager.resolveCloneBaseDir(projectDir), `tmp-${timestamp}`);
log.info('Creating temp clone for branch', { path: clonePath, branch });
CloneManager.cloneAndIsolate(projectDir, clonePath);
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
this.saveCloneMeta(projectDir, branch, clonePath);
log.info('Temp clone created', { path: clonePath, branch });
return { path: clonePath, branch };
}
/** Remove a clone directory */
removeClone(clonePath: string): void {
log.info('Removing clone', { path: clonePath });
try {
fs.rmSync(clonePath, { recursive: true, force: true });
log.info('Clone removed', { path: clonePath });
} catch (err) {
log.error('Failed to remove clone', { path: clonePath, error: String(err) });
}
}
/** Save clone metadata (branch → clonePath mapping) */
saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
const filePath = CloneManager.getCloneMetaPath(projectDir, branch);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
log.info('Clone meta saved', { branch, clonePath });
}
/** Remove clone metadata for a branch */
removeCloneMeta(projectDir: string, branch: string): void {
try {
fs.unlinkSync(CloneManager.getCloneMetaPath(projectDir, branch));
log.info('Clone meta removed', { branch });
} catch {
// File may not exist — ignore
}
}
/** Clean up an orphaned clone directory associated with a branch */
cleanupOrphanedClone(projectDir: string, branch: string): void {
try {
const raw = fs.readFileSync(CloneManager.getCloneMetaPath(projectDir, branch), 'utf-8');
const meta = JSON.parse(raw) as { clonePath: string };
if (fs.existsSync(meta.clonePath)) {
this.removeClone(meta.clonePath);
log.info('Orphaned clone cleaned up', { branch, clonePath: meta.clonePath });
} }
} catch { } catch {
// not set locally — skip // No metadata or parse error — nothing to clean up
} }
this.removeCloneMeta(projectDir, branch);
} }
} }
/** // ---- Backward-compatible module-level functions ----
* Create a git clone for a task.
* const defaultManager = new CloneManager();
* Uses `git clone --reference --dissociate` to create an independent clone,
* then removes origin and checks out a new branch.
*/
export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult { export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
const clonePath = resolveClonePath(projectDir, options); return defaultManager.createSharedClone(projectDir, options);
const branch = resolveBranchName(options);
log.info('Creating shared clone', { path: clonePath, branch });
cloneAndIsolate(projectDir, clonePath);
if (branchExists(clonePath, branch)) {
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
} else {
execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' });
}
saveCloneMeta(projectDir, branch, clonePath);
log.info('Clone created', { path: clonePath, branch });
return { path: clonePath, branch };
} }
/**
* Create a temporary clone for an existing branch.
* Used by review/instruct to work on a branch that was previously pushed.
*/
export function createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult { export function createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
const timestamp = generateTimestamp(); return defaultManager.createTempCloneForBranch(projectDir, branch);
const clonePath = path.join(resolveCloneBaseDir(projectDir), `tmp-${timestamp}`);
log.info('Creating temp clone for branch', { path: clonePath, branch });
cloneAndIsolate(projectDir, clonePath);
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
saveCloneMeta(projectDir, branch, clonePath);
log.info('Temp clone created', { path: clonePath, branch });
return { path: clonePath, branch };
} }
/**
* Remove a clone directory.
*/
export function removeClone(clonePath: string): void { export function removeClone(clonePath: string): void {
log.info('Removing clone', { path: clonePath }); defaultManager.removeClone(clonePath);
try {
fs.rmSync(clonePath, { recursive: true, force: true });
log.info('Clone removed', { path: clonePath });
} catch (err) {
log.error('Failed to remove clone', { path: clonePath, error: String(err) });
}
} }
// --- Clone metadata ---
const CLONE_META_DIR = 'clone-meta';
function encodeBranchName(branch: string): string {
return branch.replace(/\//g, '--');
}
function getCloneMetaPath(projectDir: string, branch: string): string {
return path.join(projectDir, '.takt', CLONE_META_DIR, `${encodeBranchName(branch)}.json`);
}
/**
* Save clone metadata (branch clonePath mapping).
* Used to clean up orphaned clone directories on merge/delete.
*/
export function saveCloneMeta(projectDir: string, branch: string, clonePath: string): void { export function saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
const filePath = getCloneMetaPath(projectDir, branch); defaultManager.saveCloneMeta(projectDir, branch, clonePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
log.info('Clone meta saved', { branch, clonePath });
} }
/**
* Remove clone metadata for a branch.
*/
export function removeCloneMeta(projectDir: string, branch: string): void { export function removeCloneMeta(projectDir: string, branch: string): void {
try { defaultManager.removeCloneMeta(projectDir, branch);
fs.unlinkSync(getCloneMetaPath(projectDir, branch));
log.info('Clone meta removed', { branch });
} catch {
// File may not exist — ignore
}
} }
/**
* Clean up an orphaned clone directory associated with a branch.
* Reads metadata, removes clone directory if it still exists, then removes metadata.
*/
export function cleanupOrphanedClone(projectDir: string, branch: string): void { export function cleanupOrphanedClone(projectDir: string, branch: string): void {
try { defaultManager.cleanupOrphanedClone(projectDir, branch);
const raw = fs.readFileSync(getCloneMetaPath(projectDir, branch), 'utf-8');
const meta = JSON.parse(raw) as { clonePath: string };
if (fs.existsSync(meta.clonePath)) {
removeClone(meta.clonePath);
log.info('Orphaned clone cleaned up', { branch, clonePath: meta.clonePath });
}
} catch {
// No metadata or parse error — nothing to clean up
}
removeCloneMeta(projectDir, branch);
} }

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