diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 60eb29d..2710a56 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -8,7 +8,7 @@ import * as path from 'node:path'; import { tmpdir } from 'node:os'; // Mock dependencies before importing the module under test -vi.mock('../commands/interactive.js', () => ({ +vi.mock('../commands/interactive/interactive.js', () => ({ interactiveMode: vi.fn(), })); @@ -16,7 +16,7 @@ vi.mock('../providers/index.js', () => ({ getProvider: vi.fn(), })); -vi.mock('../config/globalConfig.js', () => ({ +vi.mock('../config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })), })); @@ -33,6 +33,7 @@ vi.mock('../task/summarize.js', () => ({ vi.mock('../utils/ui.js', () => ({ success: vi.fn(), info: vi.fn(), + blankLine: vi.fn(), })); vi.mock('../utils/debug.js', () => ({ @@ -43,11 +44,11 @@ vi.mock('../utils/debug.js', () => ({ }), })); -vi.mock('../config/workflowLoader.js', () => ({ +vi.mock('../config/loaders/workflowLoader.js', () => ({ listWorkflows: vi.fn(), })); -vi.mock('../config/paths.js', () => ({ +vi.mock('../config/paths.js', async (importOriginal) => ({ ...(await importOriginal>()), getCurrentWorkflow: vi.fn(() => 'default'), })); @@ -66,13 +67,13 @@ vi.mock('../github/issue.js', () => ({ }), })); -import { interactiveMode } from '../commands/interactive.js'; +import { interactiveMode } from '../commands/interactive/interactive.js'; import { getProvider } from '../providers/index.js'; import { promptInput, confirm, selectOption } from '../prompt/index.js'; import { summarizeTaskName } from '../task/summarize.js'; -import { listWorkflows } from '../config/workflowLoader.js'; +import { listWorkflows } from '../config/loaders/workflowLoader.js'; import { resolveIssueTask } from '../github/issue.js'; -import { addTask, summarizeConversation } from '../commands/addTask.js'; +import { addTask, summarizeConversation } from '../commands/management/addTask.js'; const mockResolveIssueTask = vi.mocked(resolveIssueTask); diff --git a/src/__tests__/apiKeyAuth.test.ts b/src/__tests__/apiKeyAuth.test.ts index ed1ffa5..3859481 100644 --- a/src/__tests__/apiKeyAuth.test.ts +++ b/src/__tests__/apiKeyAuth.test.ts @@ -32,7 +32,7 @@ vi.mock('../config/paths.js', async (importOriginal) => { }); // Import after mocking -const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, invalidateGlobalConfigCache } = await import('../config/globalConfig.js'); +const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, invalidateGlobalConfigCache } = await import('../config/global/globalConfig.js'); describe('GlobalConfigSchema API key fields', () => { it('should accept config without API keys', () => { diff --git a/src/__tests__/cli-worktree.test.ts b/src/__tests__/cli-worktree.test.ts index 24d5a1b..e14bcb0 100644 --- a/src/__tests__/cli-worktree.test.ts +++ b/src/__tests__/cli-worktree.test.ts @@ -67,7 +67,7 @@ vi.mock('../commands/index.js', () => ({ interactiveMode: vi.fn(() => Promise.resolve({ confirmed: false, task: '' })), })); -vi.mock('../config/workflowLoader.js', () => ({ +vi.mock('../config/loaders/workflowLoader.js', () => ({ listWorkflows: vi.fn(() => []), })); @@ -92,7 +92,7 @@ import { confirm } from '../prompt/index.js'; import { createSharedClone } from '../task/clone.js'; import { summarizeTaskName } from '../task/summarize.js'; import { info } from '../utils/ui.js'; -import { confirmAndCreateWorktree } from '../commands/selectAndExecute.js'; +import { confirmAndCreateWorktree } from '../commands/execution/selectAndExecute.js'; const mockConfirm = vi.mocked(confirm); const mockCreateSharedClone = vi.mocked(createSharedClone); diff --git a/src/__tests__/clone.test.ts b/src/__tests__/clone.test.ts index f3c8d3f..583346f 100644 --- a/src/__tests__/clone.test.ts +++ b/src/__tests__/clone.test.ts @@ -29,7 +29,7 @@ vi.mock('../utils/debug.js', () => ({ }), })); -vi.mock('../config/globalConfig.js', () => ({ +vi.mock('../config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(() => ({})), })); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 96241c3..68d9e25 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -13,7 +13,7 @@ import { loadWorkflow, listWorkflows, loadAgentPromptFromPath, -} from '../config/loader.js'; +} from '../config/loaders/loader.js'; import { getCurrentWorkflow, setCurrentWorkflow, @@ -36,8 +36,8 @@ import { loadWorktreeSessions, updateWorktreeSession, } from '../config/paths.js'; -import { getLanguage } from '../config/globalConfig.js'; -import { loadProjectConfig } from '../config/projectConfig.js'; +import { getLanguage } from '../config/global/globalConfig.js'; +import { loadProjectConfig } from '../config/project/projectConfig.js'; describe('getBuiltinWorkflow', () => { it('should return builtin workflow when it exists in resources', () => { diff --git a/src/__tests__/engine-abort.test.ts b/src/__tests__/engine-abort.test.ts index 83c4c8c..2bda3ab 100644 --- a/src/__tests__/engine-abort.test.ts +++ b/src/__tests__/engine-abort.test.ts @@ -18,7 +18,7 @@ vi.mock('../agents/runner.js', () => ({ runAgent: vi.fn(), })); -vi.mock('../workflow/rule-evaluator.js', () => ({ +vi.mock('../workflow/evaluation/index.js', () => ({ detectMatchedRule: vi.fn(), })); @@ -38,7 +38,7 @@ vi.mock('../claude/query-manager.js', () => ({ // --- Imports (after mocks) --- -import { WorkflowEngine } from '../workflow/engine.js'; +import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js'; import { runAgent } from '../agents/runner.js'; import { interruptAllQueries } from '../claude/query-manager.js'; import { diff --git a/src/__tests__/engine-agent-overrides.test.ts b/src/__tests__/engine-agent-overrides.test.ts index 88d17c4..1f51dcf 100644 --- a/src/__tests__/engine-agent-overrides.test.ts +++ b/src/__tests__/engine-agent-overrides.test.ts @@ -11,7 +11,7 @@ vi.mock('../agents/runner.js', () => ({ runAgent: vi.fn(), })); -vi.mock('../workflow/rule-evaluator.js', () => ({ +vi.mock('../workflow/evaluation/index.js', () => ({ detectMatchedRule: vi.fn(), })); @@ -25,7 +25,7 @@ vi.mock('../utils/session.js', () => ({ generateReportDir: vi.fn().mockReturnValue('test-report-dir'), })); -import { WorkflowEngine } from '../workflow/engine.js'; +import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js'; import { runAgent } from '../agents/runner.js'; import type { WorkflowConfig } from '../models/types.js'; import { diff --git a/src/__tests__/engine-blocked.test.ts b/src/__tests__/engine-blocked.test.ts index ffc4c5d..f46c213 100644 --- a/src/__tests__/engine-blocked.test.ts +++ b/src/__tests__/engine-blocked.test.ts @@ -16,7 +16,7 @@ vi.mock('../agents/runner.js', () => ({ runAgent: vi.fn(), })); -vi.mock('../workflow/rule-evaluator.js', () => ({ +vi.mock('../workflow/evaluation/index.js', () => ({ detectMatchedRule: vi.fn(), })); @@ -32,7 +32,7 @@ vi.mock('../utils/session.js', () => ({ // --- Imports (after mocks) --- -import { WorkflowEngine } from '../workflow/engine.js'; +import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js'; import { makeResponse, buildDefaultWorkflowConfig, diff --git a/src/__tests__/engine-error.test.ts b/src/__tests__/engine-error.test.ts index 61251ec..2fd202a 100644 --- a/src/__tests__/engine-error.test.ts +++ b/src/__tests__/engine-error.test.ts @@ -17,7 +17,7 @@ vi.mock('../agents/runner.js', () => ({ runAgent: vi.fn(), })); -vi.mock('../workflow/rule-evaluator.js', () => ({ +vi.mock('../workflow/evaluation/index.js', () => ({ detectMatchedRule: vi.fn(), })); @@ -33,9 +33,9 @@ vi.mock('../utils/session.js', () => ({ // --- Imports (after mocks) --- -import { WorkflowEngine } from '../workflow/engine.js'; +import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js'; import { runAgent } from '../agents/runner.js'; -import { detectMatchedRule } from '../workflow/rule-evaluator.js'; +import { detectMatchedRule } from '../workflow/evaluation/index.js'; import { makeResponse, makeStep, diff --git a/src/__tests__/engine-happy-path.test.ts b/src/__tests__/engine-happy-path.test.ts index bc7b398..cc3ec94 100644 --- a/src/__tests__/engine-happy-path.test.ts +++ b/src/__tests__/engine-happy-path.test.ts @@ -21,7 +21,7 @@ vi.mock('../agents/runner.js', () => ({ runAgent: vi.fn(), })); -vi.mock('../workflow/rule-evaluator.js', () => ({ +vi.mock('../workflow/evaluation/index.js', () => ({ detectMatchedRule: vi.fn(), })); @@ -37,7 +37,7 @@ vi.mock('../utils/session.js', () => ({ // --- Imports (after mocks) --- -import { WorkflowEngine } from '../workflow/engine.js'; +import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js'; import { runAgent } from '../agents/runner.js'; import { makeResponse, diff --git a/src/__tests__/engine-parallel.test.ts b/src/__tests__/engine-parallel.test.ts index 02ec8ec..146cc6d 100644 --- a/src/__tests__/engine-parallel.test.ts +++ b/src/__tests__/engine-parallel.test.ts @@ -16,7 +16,7 @@ vi.mock('../agents/runner.js', () => ({ runAgent: vi.fn(), })); -vi.mock('../workflow/rule-evaluator.js', () => ({ +vi.mock('../workflow/evaluation/index.js', () => ({ detectMatchedRule: vi.fn(), })); @@ -32,7 +32,7 @@ vi.mock('../utils/session.js', () => ({ // --- Imports (after mocks) --- -import { WorkflowEngine } from '../workflow/engine.js'; +import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js'; import { runAgent } from '../agents/runner.js'; import { makeResponse, diff --git a/src/__tests__/engine-report.test.ts b/src/__tests__/engine-report.test.ts index d0aaf0e..707b5b0 100644 --- a/src/__tests__/engine-report.test.ts +++ b/src/__tests__/engine-report.test.ts @@ -8,7 +8,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { EventEmitter } from 'node:events'; import { existsSync } from 'node:fs'; -import { isReportObjectConfig } from '../workflow/instruction-builder.js'; +import { isReportObjectConfig } from '../workflow/instruction/InstructionBuilder.js'; import type { WorkflowStep, ReportObjectConfig, ReportConfig } from '../models/types.js'; /** diff --git a/src/__tests__/engine-test-helpers.ts b/src/__tests__/engine-test-helpers.ts index 9a79f11..3e31eeb 100644 --- a/src/__tests__/engine-test-helpers.ts +++ b/src/__tests__/engine-test-helpers.ts @@ -15,8 +15,8 @@ import type { WorkflowConfig, WorkflowStep, AgentResponse, WorkflowRule } from ' // --- Mock imports (consumers must call vi.mock before importing this) --- import { runAgent } from '../agents/runner.js'; -import { detectMatchedRule } from '../workflow/rule-evaluator.js'; -import type { RuleMatch } from '../workflow/rule-evaluator.js'; +import { detectMatchedRule } from '../workflow/evaluation/index.js'; +import type { RuleMatch } from '../workflow/evaluation/index.js'; import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../workflow/phase-runner.js'; import { generateReportDir } from '../utils/session.js'; diff --git a/src/__tests__/engine-worktree-report.test.ts b/src/__tests__/engine-worktree-report.test.ts index 3f77e94..896f896 100644 --- a/src/__tests__/engine-worktree-report.test.ts +++ b/src/__tests__/engine-worktree-report.test.ts @@ -17,7 +17,7 @@ vi.mock('../agents/runner.js', () => ({ runAgent: vi.fn(), })); -vi.mock('../workflow/rule-evaluator.js', () => ({ +vi.mock('../workflow/evaluation/index.js', () => ({ detectMatchedRule: vi.fn(), })); @@ -33,7 +33,7 @@ vi.mock('../utils/session.js', () => ({ // --- Imports (after mocks) --- -import { WorkflowEngine } from '../workflow/engine.js'; +import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js'; import { runReportPhase } from '../workflow/phase-runner.js'; import { makeResponse, diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 161bfe0..b48922a 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -20,7 +20,7 @@ vi.mock('node:os', async () => { }); // Import after mocks are set up -const { loadGlobalConfig, saveGlobalConfig, invalidateGlobalConfigCache } = await import('../config/globalConfig.js'); +const { loadGlobalConfig, saveGlobalConfig, invalidateGlobalConfigCache } = await import('../config/global/globalConfig.js'); const { getGlobalConfigPath } = await import('../config/paths.js'); describe('loadGlobalConfig', () => { diff --git a/src/__tests__/initialization-noninteractive.test.ts b/src/__tests__/initialization-noninteractive.test.ts index 391445c..eb45e75 100644 --- a/src/__tests__/initialization-noninteractive.test.ts +++ b/src/__tests__/initialization-noninteractive.test.ts @@ -25,7 +25,7 @@ vi.mock('../prompt/index.js', () => ({ })); // Import after mocks are set up -const { initGlobalDirs, needsLanguageSetup } = await import('../config/initialization.js'); +const { initGlobalDirs, needsLanguageSetup } = await import('../config/global/initialization.js'); const { getGlobalConfigPath, getGlobalConfigDir } = await import('../config/paths.js'); describe('initGlobalDirs with non-interactive mode', () => { diff --git a/src/__tests__/initialization.test.ts b/src/__tests__/initialization.test.ts index 499f6ca..ecb088c 100644 --- a/src/__tests__/initialization.test.ts +++ b/src/__tests__/initialization.test.ts @@ -25,7 +25,7 @@ vi.mock('../prompt/index.js', () => ({ })); // Import after mocks are set up -const { needsLanguageSetup } = await import('../config/initialization.js'); +const { needsLanguageSetup } = await import('../config/global/initialization.js'); const { getGlobalConfigPath } = await import('../config/paths.js'); const { copyProjectResourcesToDir, getLanguageResourcesDir, getProjectResourcesDir } = await import('../resources/index.js'); diff --git a/src/__tests__/instructionBuilder.test.ts b/src/__tests__/instructionBuilder.test.ts index e2243b0..09a4796 100644 --- a/src/__tests__/instructionBuilder.test.ts +++ b/src/__tests__/instructionBuilder.test.ts @@ -4,17 +4,28 @@ import { describe, it, expect } from 'vitest'; import { - buildInstruction, - buildReportInstruction, - buildStatusJudgmentInstruction, + InstructionBuilder, + isReportObjectConfig, +} from '../workflow/instruction/InstructionBuilder.js'; +import { ReportInstructionBuilder, type ReportInstructionContext } from '../workflow/instruction/ReportInstructionBuilder.js'; +import { StatusJudgmentBuilder, type StatusJudgmentContext } from '../workflow/instruction/StatusJudgmentBuilder.js'; +import { buildExecutionMetadata, renderExecutionMetadata, - generateStatusRulesFromRules, - isReportObjectConfig, type InstructionContext, - type ReportInstructionContext, - type StatusJudgmentContext, -} from '../workflow/instruction-builder.js'; +} from '../workflow/instruction-context.js'; +import { generateStatusRulesFromRules } from '../workflow/status-rules.js'; + +// Backward-compatible function wrappers for test readability +function buildInstruction(step: WorkflowStep, ctx: InstructionContext): string { + return new InstructionBuilder(step, ctx).build(); +} +function buildReportInstruction(step: WorkflowStep, ctx: ReportInstructionContext): string { + return new ReportInstructionBuilder(step, ctx).build(); +} +function buildStatusJudgmentInstruction(step: WorkflowStep, ctx: StatusJudgmentContext): string { + return new StatusJudgmentBuilder(step, ctx).build(); +} import type { WorkflowStep, WorkflowRule } from '../models/types.js'; diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index 1155349..867d37e 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('../config/globalConfig.js', () => ({ +vi.mock('../config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })), })); @@ -46,7 +46,7 @@ vi.mock('node:readline', () => ({ import { createInterface } from 'node:readline'; import { getProvider } from '../providers/index.js'; -import { interactiveMode } from '../commands/interactive.js'; +import { interactiveMode } from '../commands/interactive/interactive.js'; const mockGetProvider = vi.mocked(getProvider); const mockCreateInterface = vi.mocked(createInterface); diff --git a/src/__tests__/it-error-recovery.test.ts b/src/__tests__/it-error-recovery.test.ts index 77a3c01..f46225a 100644 --- a/src/__tests__/it-error-recovery.test.ts +++ b/src/__tests__/it-error-recovery.test.ts @@ -36,19 +36,19 @@ vi.mock('../utils/session.js', () => ({ generateSessionId: vi.fn().mockReturnValue('test-session-id'), })); -vi.mock('../config/globalConfig.js', () => ({ +vi.mock('../config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), getDisabledBuiltins: vi.fn().mockReturnValue([]), })); -vi.mock('../config/projectConfig.js', () => ({ +vi.mock('../config/project/projectConfig.js', () => ({ loadProjectConfig: vi.fn().mockReturnValue({}), })); // --- Imports (after mocks) --- -import { WorkflowEngine } from '../workflow/engine.js'; +import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js'; // --- Test helpers --- diff --git a/src/__tests__/it-instruction-builder.test.ts b/src/__tests__/it-instruction-builder.test.ts index c8d8614..dd98e86 100644 --- a/src/__tests__/it-instruction-builder.test.ts +++ b/src/__tests__/it-instruction-builder.test.ts @@ -10,17 +10,26 @@ import { describe, it, expect, vi } from 'vitest'; import type { WorkflowStep, WorkflowRule, AgentResponse } from '../models/types.js'; -vi.mock('../config/globalConfig.js', () => ({ +vi.mock('../config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), })); -import { - buildInstruction, - buildReportInstruction, - buildStatusJudgmentInstruction, -} from '../workflow/instruction-builder.js'; -import type { InstructionContext } from '../workflow/instruction-builder.js'; +import { InstructionBuilder } from '../workflow/instruction/InstructionBuilder.js'; +import { ReportInstructionBuilder, type ReportInstructionContext } from '../workflow/instruction/ReportInstructionBuilder.js'; +import { StatusJudgmentBuilder, type StatusJudgmentContext } from '../workflow/instruction/StatusJudgmentBuilder.js'; +import type { InstructionContext } from '../workflow/instruction-context.js'; + +// Function wrappers for test readability +function buildInstruction(step: WorkflowStep, ctx: InstructionContext): string { + return new InstructionBuilder(step, ctx).build(); +} +function buildReportInstruction(step: WorkflowStep, ctx: ReportInstructionContext): string { + return new ReportInstructionBuilder(step, ctx).build(); +} +function buildStatusJudgmentInstruction(step: WorkflowStep, ctx: StatusJudgmentContext): string { + return new StatusJudgmentBuilder(step, ctx).build(); +} // --- Test helpers --- diff --git a/src/__tests__/it-pipeline-modes.test.ts b/src/__tests__/it-pipeline-modes.test.ts index e9d1c72..539d0be 100644 --- a/src/__tests__/it-pipeline-modes.test.ts +++ b/src/__tests__/it-pipeline-modes.test.ts @@ -104,8 +104,8 @@ vi.mock('../config/paths.js', async (importOriginal) => { }; }); -vi.mock('../config/globalConfig.js', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('../config/global/globalConfig.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, loadGlobalConfig: vi.fn().mockReturnValue({}), @@ -114,8 +114,8 @@ vi.mock('../config/globalConfig.js', async (importOriginal) => { }; }); -vi.mock('../config/projectConfig.js', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('../config/project/projectConfig.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, loadProjectConfig: vi.fn().mockReturnValue({}), @@ -139,7 +139,7 @@ vi.mock('../workflow/phase-runner.js', () => ({ // --- Imports (after mocks) --- -import { executePipeline } from '../commands/pipelineExecution.js'; +import { executePipeline } from '../commands/execution/pipelineExecution.js'; import { EXIT_ISSUE_FETCH_FAILED, EXIT_WORKFLOW_FAILED, diff --git a/src/__tests__/it-pipeline.test.ts b/src/__tests__/it-pipeline.test.ts index 94202a4..6db621b 100644 --- a/src/__tests__/it-pipeline.test.ts +++ b/src/__tests__/it-pipeline.test.ts @@ -87,8 +87,8 @@ vi.mock('../config/paths.js', async (importOriginal) => { }; }); -vi.mock('../config/globalConfig.js', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('../config/global/globalConfig.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, loadGlobalConfig: vi.fn().mockReturnValue({}), @@ -96,8 +96,8 @@ vi.mock('../config/globalConfig.js', async (importOriginal) => { }; }); -vi.mock('../config/projectConfig.js', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('../config/project/projectConfig.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, loadProjectConfig: vi.fn().mockReturnValue({}), @@ -121,7 +121,7 @@ vi.mock('../workflow/phase-runner.js', () => ({ // --- Imports (after mocks) --- -import { executePipeline } from '../commands/pipelineExecution.js'; +import { executePipeline } from '../commands/execution/pipelineExecution.js'; // --- Test helpers --- diff --git a/src/__tests__/it-rule-evaluation.test.ts b/src/__tests__/it-rule-evaluation.test.ts index 74bade5..409e238 100644 --- a/src/__tests__/it-rule-evaluation.test.ts +++ b/src/__tests__/it-rule-evaluation.test.ts @@ -29,19 +29,19 @@ vi.mock('../claude/client.js', async (importOriginal) => { }; }); -vi.mock('../config/globalConfig.js', () => ({ +vi.mock('../config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), })); -vi.mock('../config/projectConfig.js', () => ({ +vi.mock('../config/project/projectConfig.js', () => ({ loadProjectConfig: vi.fn().mockReturnValue({}), })); // --- Imports (after mocks) --- -import { detectMatchedRule, evaluateAggregateConditions } from '../workflow/rule-evaluator.js'; -import type { RuleMatch, RuleEvaluatorContext } from '../workflow/rule-evaluator.js'; +import { detectMatchedRule, evaluateAggregateConditions } from '../workflow/evaluation/index.js'; +import type { RuleMatch, RuleEvaluatorContext } from '../workflow/evaluation/index.js'; // --- Test helpers --- diff --git a/src/__tests__/it-three-phase-execution.test.ts b/src/__tests__/it-three-phase-execution.test.ts index a427b9e..b80d21f 100644 --- a/src/__tests__/it-three-phase-execution.test.ts +++ b/src/__tests__/it-three-phase-execution.test.ts @@ -41,19 +41,19 @@ vi.mock('../utils/session.js', () => ({ generateSessionId: vi.fn().mockReturnValue('test-session-id'), })); -vi.mock('../config/globalConfig.js', () => ({ +vi.mock('../config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), getDisabledBuiltins: vi.fn().mockReturnValue([]), })); -vi.mock('../config/projectConfig.js', () => ({ +vi.mock('../config/project/projectConfig.js', () => ({ loadProjectConfig: vi.fn().mockReturnValue({}), })); // --- Imports (after mocks) --- -import { WorkflowEngine } from '../workflow/engine.js'; +import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js'; // --- Test helpers --- diff --git a/src/__tests__/it-workflow-execution.test.ts b/src/__tests__/it-workflow-execution.test.ts index d2898f4..596e831 100644 --- a/src/__tests__/it-workflow-execution.test.ts +++ b/src/__tests__/it-workflow-execution.test.ts @@ -40,18 +40,18 @@ vi.mock('../utils/session.js', () => ({ generateSessionId: vi.fn().mockReturnValue('test-session-id'), })); -vi.mock('../config/globalConfig.js', () => ({ +vi.mock('../config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), })); -vi.mock('../config/projectConfig.js', () => ({ +vi.mock('../config/project/projectConfig.js', () => ({ loadProjectConfig: vi.fn().mockReturnValue({}), })); // --- Imports (after mocks) --- -import { WorkflowEngine } from '../workflow/engine.js'; +import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js'; // --- Test helpers --- diff --git a/src/__tests__/it-workflow-loader.test.ts b/src/__tests__/it-workflow-loader.test.ts index 396f541..a224fe5 100644 --- a/src/__tests__/it-workflow-loader.test.ts +++ b/src/__tests__/it-workflow-loader.test.ts @@ -15,7 +15,7 @@ import { tmpdir } from 'node:os'; // --- Mocks --- -vi.mock('../config/globalConfig.js', () => ({ +vi.mock('../config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), getDisabledBuiltins: vi.fn().mockReturnValue([]), @@ -23,7 +23,7 @@ vi.mock('../config/globalConfig.js', () => ({ // --- Imports (after mocks) --- -import { loadWorkflow } from '../config/workflowLoader.js'; +import { loadWorkflow } from '../config/loaders/workflowLoader.js'; // --- Test helpers --- diff --git a/src/__tests__/it-workflow-patterns.test.ts b/src/__tests__/it-workflow-patterns.test.ts index 601813c..8611e15 100644 --- a/src/__tests__/it-workflow-patterns.test.ts +++ b/src/__tests__/it-workflow-patterns.test.ts @@ -35,20 +35,20 @@ vi.mock('../utils/session.js', () => ({ generateSessionId: vi.fn().mockReturnValue('test-session-id'), })); -vi.mock('../config/globalConfig.js', () => ({ +vi.mock('../config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), getDisabledBuiltins: vi.fn().mockReturnValue([]), })); -vi.mock('../config/projectConfig.js', () => ({ +vi.mock('../config/project/projectConfig.js', () => ({ loadProjectConfig: vi.fn().mockReturnValue({}), })); // --- Imports (after mocks) --- -import { WorkflowEngine } from '../workflow/engine.js'; -import { loadWorkflow } from '../config/workflowLoader.js'; +import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js'; +import { loadWorkflow } from '../config/loaders/workflowLoader.js'; import type { WorkflowConfig } from '../models/types.js'; // --- Test helpers --- diff --git a/src/__tests__/listTasks.test.ts b/src/__tests__/listTasks.test.ts index c620fac..7810b10 100644 --- a/src/__tests__/listTasks.test.ts +++ b/src/__tests__/listTasks.test.ts @@ -9,7 +9,7 @@ import { buildListItems, type BranchInfo, } from '../task/branchList.js'; -import { isBranchMerged, showFullDiff, type ListAction } from '../commands/listTasks.js'; +import { isBranchMerged, showFullDiff, type ListAction } from '../commands/management/listTasks.js'; describe('parseTaktBranches', () => { it('should parse takt/ branches from git branch output', () => { diff --git a/src/__tests__/pipelineExecution.test.ts b/src/__tests__/pipelineExecution.test.ts index d7c1902..2668a8f 100644 --- a/src/__tests__/pipelineExecution.test.ts +++ b/src/__tests__/pipelineExecution.test.ts @@ -27,13 +27,13 @@ vi.mock('../github/pr.js', () => ({ })); const mockExecuteTask = vi.fn(); -vi.mock('../commands/taskExecution.js', () => ({ +vi.mock('../commands/execution/taskExecution.js', () => ({ executeTask: mockExecuteTask, })); // Mock loadGlobalConfig const mockLoadGlobalConfig = vi.fn(); -vi.mock('../config/globalConfig.js', () => ({ +vi.mock('../config/global/globalConfig.js', async (importOriginal) => ({ ...(await importOriginal>()), loadGlobalConfig: mockLoadGlobalConfig, })); @@ -50,8 +50,11 @@ vi.mock('../utils/ui.js', () => ({ success: vi.fn(), status: vi.fn(), blankLine: vi.fn(), + header: vi.fn(), + section: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), })); - // Mock debug logger vi.mock('../utils/debug.js', () => ({ createLogger: () => ({ @@ -61,7 +64,7 @@ vi.mock('../utils/debug.js', () => ({ }), })); -const { executePipeline } = await import('../commands/pipelineExecution.js'); +const { executePipeline } = await import('../commands/execution/pipelineExecution.js'); describe('executePipeline', () => { beforeEach(() => { diff --git a/src/__tests__/summarize.test.ts b/src/__tests__/summarize.test.ts index 556528d..ef0c8d6 100644 --- a/src/__tests__/summarize.test.ts +++ b/src/__tests__/summarize.test.ts @@ -8,7 +8,7 @@ vi.mock('../providers/index.js', () => ({ getProvider: vi.fn(), })); -vi.mock('../config/globalConfig.js', () => ({ +vi.mock('../config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(), })); @@ -21,7 +21,7 @@ vi.mock('../utils/debug.js', () => ({ })); import { getProvider } from '../providers/index.js'; -import { loadGlobalConfig } from '../config/globalConfig.js'; +import { loadGlobalConfig } from '../config/global/globalConfig.js'; import { summarizeTaskName } from '../task/summarize.js'; const mockGetProvider = vi.mocked(getProvider); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index ad7c595..173fab5 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -65,7 +65,7 @@ vi.mock('../constants.js', () => ({ import { createSharedClone } from '../task/clone.js'; import { summarizeTaskName } from '../task/summarize.js'; import { info } from '../utils/ui.js'; -import { resolveTaskExecution } from '../commands/taskExecution.js'; +import { resolveTaskExecution } from '../commands/execution/taskExecution.js'; import type { TaskInfo } from '../task/index.js'; const mockCreateSharedClone = vi.mocked(createSharedClone); diff --git a/src/__tests__/workflow-expert-parallel.test.ts b/src/__tests__/workflow-expert-parallel.test.ts index 9e6bbea..2ddf36f 100644 --- a/src/__tests__/workflow-expert-parallel.test.ts +++ b/src/__tests__/workflow-expert-parallel.test.ts @@ -11,7 +11,7 @@ */ import { describe, it, expect } from 'vitest'; -import { loadWorkflow } from '../config/loader.js'; +import { loadWorkflow } from '../config/loaders/loader.js'; describe('expert workflow parallel structure', () => { const workflow = loadWorkflow('expert', process.cwd()); diff --git a/src/__tests__/workflowLoader.test.ts b/src/__tests__/workflowLoader.test.ts index 9e9185f..847a52a 100644 --- a/src/__tests__/workflowLoader.test.ts +++ b/src/__tests__/workflowLoader.test.ts @@ -11,7 +11,7 @@ import { loadWorkflowByIdentifier, listWorkflows, loadAllWorkflows, -} from '../config/workflowLoader.js'; +} from '../config/loaders/workflowLoader.js'; const SAMPLE_WORKFLOW = `name: test-workflow description: Test workflow diff --git a/src/agents/index.ts b/src/agents/index.ts index 83a553d..3e13811 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -2,4 +2,5 @@ * Agents module - exports agent execution utilities */ -export * from './runner.js'; +export { AgentRunner, runAgent, runCustomAgent } from './runner.js'; +export type { RunAgentOptions, StreamCallback } from './types.js'; diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 531898d..a7945b1 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -9,200 +9,143 @@ import { callClaudeSkill, type ClaudeCallOptions, } from '../claude/client.js'; -import { type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from '../claude/process.js'; -import { loadCustomAgents, loadAgentPrompt } from '../config/loader.js'; -import { loadGlobalConfig } from '../config/globalConfig.js'; -import { loadProjectConfig } from '../config/projectConfig.js'; +import { loadCustomAgents, loadAgentPrompt } from '../config/loaders/loader.js'; +import { loadGlobalConfig } from '../config/global/globalConfig.js'; +import { loadProjectConfig } from '../config/project/projectConfig.js'; import { getProvider, type ProviderType, type ProviderCallOptions } from '../providers/index.js'; -import type { AgentResponse, CustomAgentConfig, PermissionMode } from '../models/types.js'; +import type { AgentResponse, CustomAgentConfig } from '../models/types.js'; import { createLogger } from '../utils/debug.js'; +import type { RunAgentOptions } from './types.js'; + +// Re-export for backward compatibility +export type { RunAgentOptions, StreamCallback } from './types.js'; const log = createLogger('runner'); -export type { StreamCallback }; - -/** Common options for running agents */ -export interface RunAgentOptions { - cwd: string; - sessionId?: string; - model?: string; - provider?: 'claude' | 'codex' | 'mock'; - /** Resolved path to agent prompt file */ - agentPath?: string; - /** Allowed tools for this agent run */ - allowedTools?: string[]; - /** Maximum number of agentic turns */ - maxTurns?: number; - /** Permission mode for tool execution (from workflow step) */ - permissionMode?: PermissionMode; - onStream?: StreamCallback; - onPermissionRequest?: PermissionHandler; - onAskUserQuestion?: AskUserQuestionHandler; - /** Bypass all permission checks (sacrifice-my-pc mode) */ - bypassPermissions?: boolean; -} - -function resolveProvider(cwd: string, options?: RunAgentOptions, agentConfig?: CustomAgentConfig): ProviderType { - // Mock provider must be explicitly specified (no fallback) - if (options?.provider) return options.provider; - if (agentConfig?.provider) return agentConfig.provider; - const projectConfig = loadProjectConfig(cwd); - if (projectConfig.provider) return projectConfig.provider; - try { - const globalConfig = loadGlobalConfig(); - if (globalConfig.provider) return globalConfig.provider; - } catch { - // Ignore missing global config; fallback below - } - return 'claude'; -} - -function resolveModel(cwd: string, options?: RunAgentOptions, agentConfig?: CustomAgentConfig): string | undefined { - if (options?.model) return options.model; - if (agentConfig?.model) return agentConfig.model; - try { - const globalConfig = loadGlobalConfig(); - if (globalConfig.model) return globalConfig.model; - } catch { - // Ignore missing global config - } - return undefined; -} - - -/** Run a custom agent */ -export async function runCustomAgent( - agentConfig: CustomAgentConfig, - task: string, - options: RunAgentOptions -): Promise { - const allowedTools = options.allowedTools ?? agentConfig.allowedTools; - - // If agent references a Claude Code agent - if (agentConfig.claudeAgent) { - const callOptions: ClaudeCallOptions = { - cwd: options.cwd, - sessionId: options.sessionId, - allowedTools, - maxTurns: options.maxTurns, - model: resolveModel(options.cwd, options, agentConfig), - permissionMode: options.permissionMode, - onStream: options.onStream, - onPermissionRequest: options.onPermissionRequest, - onAskUserQuestion: options.onAskUserQuestion, - bypassPermissions: options.bypassPermissions, - }; - return callClaudeAgent(agentConfig.claudeAgent, task, callOptions); - } - - // If agent references a Claude Code skill - if (agentConfig.claudeSkill) { - const callOptions: ClaudeCallOptions = { - cwd: options.cwd, - sessionId: options.sessionId, - allowedTools, - maxTurns: options.maxTurns, - model: resolveModel(options.cwd, options, agentConfig), - permissionMode: options.permissionMode, - onStream: options.onStream, - onPermissionRequest: options.onPermissionRequest, - onAskUserQuestion: options.onAskUserQuestion, - bypassPermissions: options.bypassPermissions, - }; - return callClaudeSkill(agentConfig.claudeSkill, task, callOptions); - } - - // Custom agent with prompt - const systemPrompt = loadAgentPrompt(agentConfig); - - const providerType = resolveProvider(options.cwd, options, agentConfig); - const provider = getProvider(providerType); - - const callOptions: ProviderCallOptions = { - cwd: options.cwd, - sessionId: options.sessionId, - allowedTools, - maxTurns: options.maxTurns, - model: resolveModel(options.cwd, options, agentConfig), - permissionMode: options.permissionMode, - onStream: options.onStream, - onPermissionRequest: options.onPermissionRequest, - onAskUserQuestion: options.onAskUserQuestion, - bypassPermissions: options.bypassPermissions, - }; - - return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions); -} - /** - * Load agent prompt from file path. + * Agent execution runner. + * + * Resolves agent configuration (provider, model, prompt) and + * delegates execution to the appropriate provider. */ -function loadAgentPromptFromPath(agentPath: string): string { - if (!existsSync(agentPath)) { - throw new Error(`Agent file not found: ${agentPath}`); - } - return readFileSync(agentPath, 'utf-8'); -} - -/** - * Get agent name from path or spec. - * For agents in subdirectories, includes parent dir for pattern matching. - * - "~/.takt/agents/default/coder.md" -> "coder" - * - "~/.takt/agents/research/supervisor.md" -> "research/supervisor" - * - "./coder.md" -> "coder" - * - "coder" -> "coder" - */ -function extractAgentName(agentSpec: string): string { - if (!agentSpec.endsWith('.md')) { - return agentSpec; - } - - const name = basename(agentSpec, '.md'); - const dir = basename(dirname(agentSpec)); - - // If in 'default' directory, just use the agent name - // Otherwise, include the directory for disambiguation (e.g., 'research/supervisor') - if (dir === 'default' || dir === 'agents' || dir === '.') { - return name; - } - - return `${dir}/${name}`; -} - -/** Run an agent by name or path */ -export async function runAgent( - agentSpec: string, - task: string, - options: RunAgentOptions -): Promise { - const agentName = extractAgentName(agentSpec); - log.debug('Running agent', { - agentSpec, - agentName, - provider: options.provider, - model: options.model, - hasAgentPath: !!options.agentPath, - hasSession: !!options.sessionId, - permissionMode: options.permissionMode, - }); - - // If agentPath is provided (from workflow), use it to load prompt - if (options.agentPath) { - if (!existsSync(options.agentPath)) { - throw new Error(`Agent file not found: ${options.agentPath}`); +export class AgentRunner { + /** Resolve provider type from options, agent config, project config, global config */ + private static resolveProvider( + cwd: string, + options?: RunAgentOptions, + agentConfig?: CustomAgentConfig, + ): ProviderType { + if (options?.provider) return options.provider; + if (agentConfig?.provider) return agentConfig.provider; + const projectConfig = loadProjectConfig(cwd); + if (projectConfig.provider) return projectConfig.provider; + try { + const globalConfig = loadGlobalConfig(); + if (globalConfig.provider) return globalConfig.provider; + } catch { + // Ignore missing global config; fallback below } - const systemPrompt = loadAgentPromptFromPath(options.agentPath); + return 'claude'; + } - const providerType = resolveProvider(options.cwd, options); + /** Resolve model from options, agent config, global config */ + private static resolveModel( + cwd: string, + options?: RunAgentOptions, + agentConfig?: CustomAgentConfig, + ): string | undefined { + if (options?.model) return options.model; + if (agentConfig?.model) return agentConfig.model; + try { + const globalConfig = loadGlobalConfig(); + if (globalConfig.model) return globalConfig.model; + } catch { + // Ignore missing global config + } + return undefined; + } + + /** Load agent prompt from file path */ + private static loadAgentPromptFromPath(agentPath: string): string { + if (!existsSync(agentPath)) { + throw new Error(`Agent file not found: ${agentPath}`); + } + return readFileSync(agentPath, 'utf-8'); + } + + /** + * Get agent name from path or spec. + * For agents in subdirectories, includes parent dir for pattern matching. + */ + private static extractAgentName(agentSpec: string): string { + if (!agentSpec.endsWith('.md')) { + return agentSpec; + } + + const name = basename(agentSpec, '.md'); + const dir = basename(dirname(agentSpec)); + + if (dir === 'default' || dir === 'agents' || dir === '.') { + return name; + } + + return `${dir}/${name}`; + } + + /** Run a custom agent */ + async runCustom( + agentConfig: CustomAgentConfig, + task: string, + options: RunAgentOptions, + ): Promise { + const allowedTools = options.allowedTools ?? agentConfig.allowedTools; + + // If agent references a Claude Code agent + if (agentConfig.claudeAgent) { + const callOptions: ClaudeCallOptions = { + cwd: options.cwd, + sessionId: options.sessionId, + allowedTools, + maxTurns: options.maxTurns, + model: AgentRunner.resolveModel(options.cwd, options, agentConfig), + permissionMode: options.permissionMode, + onStream: options.onStream, + onPermissionRequest: options.onPermissionRequest, + onAskUserQuestion: options.onAskUserQuestion, + bypassPermissions: options.bypassPermissions, + }; + return callClaudeAgent(agentConfig.claudeAgent, task, callOptions); + } + + // If agent references a Claude Code skill + if (agentConfig.claudeSkill) { + const callOptions: ClaudeCallOptions = { + cwd: options.cwd, + sessionId: options.sessionId, + allowedTools, + maxTurns: options.maxTurns, + model: AgentRunner.resolveModel(options.cwd, options, agentConfig), + permissionMode: options.permissionMode, + onStream: options.onStream, + onPermissionRequest: options.onPermissionRequest, + onAskUserQuestion: options.onAskUserQuestion, + bypassPermissions: options.bypassPermissions, + }; + return callClaudeSkill(agentConfig.claudeSkill, task, callOptions); + } + + // Custom agent with prompt + const systemPrompt = loadAgentPrompt(agentConfig); + + const providerType = AgentRunner.resolveProvider(options.cwd, options, agentConfig); const provider = getProvider(providerType); const callOptions: ProviderCallOptions = { cwd: options.cwd, sessionId: options.sessionId, - allowedTools: options.allowedTools, + allowedTools, maxTurns: options.maxTurns, - model: resolveModel(options.cwd, options), - systemPrompt, + model: AgentRunner.resolveModel(options.cwd, options, agentConfig), permissionMode: options.permissionMode, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, @@ -210,16 +153,81 @@ export async function runAgent( bypassPermissions: options.bypassPermissions, }; - return provider.call(agentName, task, callOptions); + return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions); } - // Fallback: Look for custom agent by name - const customAgents = loadCustomAgents(); - const agentConfig = customAgents.get(agentName); + /** Run an agent by name or path */ + async run( + agentSpec: string, + task: string, + options: RunAgentOptions, + ): Promise { + const agentName = AgentRunner.extractAgentName(agentSpec); + log.debug('Running agent', { + agentSpec, + agentName, + provider: options.provider, + model: options.model, + hasAgentPath: !!options.agentPath, + hasSession: !!options.sessionId, + permissionMode: options.permissionMode, + }); - if (agentConfig) { - return runCustomAgent(agentConfig, task, options); + // If agentPath is provided (from workflow), use it to load prompt + if (options.agentPath) { + if (!existsSync(options.agentPath)) { + throw new Error(`Agent file not found: ${options.agentPath}`); + } + const systemPrompt = AgentRunner.loadAgentPromptFromPath(options.agentPath); + + const providerType = AgentRunner.resolveProvider(options.cwd, options); + const provider = getProvider(providerType); + + const callOptions: ProviderCallOptions = { + cwd: options.cwd, + sessionId: options.sessionId, + allowedTools: options.allowedTools, + maxTurns: options.maxTurns, + model: AgentRunner.resolveModel(options.cwd, options), + systemPrompt, + permissionMode: options.permissionMode, + onStream: options.onStream, + onPermissionRequest: options.onPermissionRequest, + onAskUserQuestion: options.onAskUserQuestion, + bypassPermissions: options.bypassPermissions, + }; + + return provider.call(agentName, task, callOptions); + } + + // Fallback: Look for custom agent by name + const customAgents = loadCustomAgents(); + const agentConfig = customAgents.get(agentName); + + if (agentConfig) { + return this.runCustom(agentConfig, task, options); + } + + throw new Error(`Unknown agent: ${agentSpec}`); } - - throw new Error(`Unknown agent: ${agentSpec}`); +} + +// ---- Backward-compatible module-level functions ---- + +const defaultRunner = new AgentRunner(); + +export async function runAgent( + agentSpec: string, + task: string, + options: RunAgentOptions, +): Promise { + return defaultRunner.run(agentSpec, task, options); +} + +export async function runCustomAgent( + agentConfig: CustomAgentConfig, + task: string, + options: RunAgentOptions, +): Promise { + return defaultRunner.runCustom(agentConfig, task, options); } diff --git a/src/agents/types.ts b/src/agents/types.ts new file mode 100644 index 0000000..6f33588 --- /dev/null +++ b/src/agents/types.ts @@ -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; +} diff --git a/src/claude/client.ts b/src/claude/client.ts index 66e5de0..6e6cd40 100644 --- a/src/claude/client.ts +++ b/src/claude/client.ts @@ -4,36 +4,15 @@ * Uses the Claude Agent SDK for native TypeScript integration. */ -import { executeClaudeCli, type ClaudeSpawnOptions, type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from './process.js'; -import type { AgentDefinition } from '@anthropic-ai/claude-agent-sdk'; -import type { AgentResponse, Status, PermissionMode } from '../models/types.js'; +import { executeClaudeCli } from './process.js'; +import type { ClaudeSpawnOptions, ClaudeCallOptions } from './types.js'; +import type { AgentResponse, Status } from '../models/types.js'; import { createLogger } from '../utils/debug.js'; -const log = createLogger('client'); +// Re-export for backward compatibility +export type { ClaudeCallOptions } from './types.js'; -/** Options for calling Claude */ -export interface ClaudeCallOptions { - cwd: string; - sessionId?: string; - allowedTools?: string[]; - model?: string; - maxTurns?: number; - systemPrompt?: string; - /** SDK agents to register for sub-agent execution */ - agents?: Record; - /** Permission mode for tool execution (from workflow step) */ - permissionMode?: PermissionMode; - /** Enable streaming mode with callback for real-time output */ - onStream?: StreamCallback; - /** Custom permission handler for interactive permission prompts */ - onPermissionRequest?: PermissionHandler; - /** Custom handler for AskUserQuestion tool */ - onAskUserQuestion?: AskUserQuestionHandler; - /** Bypass all permission checks (sacrifice-my-pc mode) */ - bypassPermissions?: boolean; - /** Anthropic API key to inject via env (bypasses CLI auth) */ - anthropicApiKey?: string; -} +const log = createLogger('client'); /** * Detect rule index from numbered tag pattern [STEP_NAME:N]. @@ -55,12 +34,10 @@ export function detectRuleIndex(content: string, stepName: string): number { /** Validate regex pattern for ReDoS safety */ export function isRegexSafe(pattern: string): boolean { - // Limit pattern length if (pattern.length > 200) { return false; } - // Dangerous patterns that can cause ReDoS const dangerousPatterns = [ /\(\.\*\)\+/, // (.*)+ /\(\.\+\)\*/, // (.+)* @@ -79,219 +56,270 @@ export function isRegexSafe(pattern: string): boolean { return true; } -/** Determine status from result */ -function determineStatus( - result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string }, -): Status { - if (!result.success) { - if (result.interrupted) { - return 'interrupted'; +/** + * High-level Claude client for calling Claude with various configurations. + * + * Handles agent prompts, custom agents, skills, and AI judge evaluation. + */ +export class ClaudeClient { + /** Determine status from execution result */ + private static determineStatus( + result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string }, + ): Status { + if (!result.success) { + if (result.interrupted) { + return 'interrupted'; + } + return 'blocked'; } - return 'blocked'; + return 'done'; + } + + /** Convert ClaudeCallOptions to ClaudeSpawnOptions */ + private static toSpawnOptions(options: ClaudeCallOptions): ClaudeSpawnOptions { + return { + cwd: options.cwd, + sessionId: options.sessionId, + allowedTools: options.allowedTools, + model: options.model, + maxTurns: options.maxTurns, + systemPrompt: options.systemPrompt, + agents: options.agents, + permissionMode: options.permissionMode, + onStream: options.onStream, + onPermissionRequest: options.onPermissionRequest, + onAskUserQuestion: options.onAskUserQuestion, + bypassPermissions: options.bypassPermissions, + anthropicApiKey: options.anthropicApiKey, + }; + } + + /** Call Claude with an agent prompt */ + async call( + agentType: string, + prompt: string, + options: ClaudeCallOptions, + ): Promise { + 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 { + 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 { + 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 { + 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 { + const prompt = ClaudeClient.buildJudgePrompt(agentOutput, aiConditions); + + const spawnOptions: ClaudeSpawnOptions = { + cwd: options.cwd, + model: 'haiku', + maxTurns: 1, + }; + + const result = await executeClaudeCli(prompt, spawnOptions); + if (!result.success) { + log.error('AI judge call failed', { error: result.error }); + return -1; + } + + return ClaudeClient.detectJudgeIndex(result.content); } - return 'done'; } -/** Call Claude with an agent prompt */ +// ---- Backward-compatible module-level functions ---- + +const defaultClient = new ClaudeClient(); + export async function callClaude( agentType: string, prompt: string, - options: ClaudeCallOptions + options: ClaudeCallOptions, ): Promise { - const spawnOptions: ClaudeSpawnOptions = { - cwd: options.cwd, - sessionId: options.sessionId, - allowedTools: options.allowedTools, - model: options.model, - maxTurns: options.maxTurns, - systemPrompt: options.systemPrompt, - agents: options.agents, - permissionMode: options.permissionMode, - onStream: options.onStream, - onPermissionRequest: options.onPermissionRequest, - onAskUserQuestion: options.onAskUserQuestion, - bypassPermissions: options.bypassPermissions, - anthropicApiKey: options.anthropicApiKey, - }; - - const result = await executeClaudeCli(prompt, spawnOptions); - const status = determineStatus(result); - - if (!result.success && result.error) { - log.error('Agent query failed', { agent: agentType, error: result.error }); - } - - return { - agent: agentType, - status, - content: result.content, - timestamp: new Date(), - sessionId: result.sessionId, - error: result.error, - }; + return defaultClient.call(agentType, prompt, options); } -/** Call Claude with a custom agent configuration */ export async function callClaudeCustom( agentName: string, prompt: string, systemPrompt: string, - options: ClaudeCallOptions + options: ClaudeCallOptions, ): Promise { - const spawnOptions: ClaudeSpawnOptions = { - cwd: options.cwd, - sessionId: options.sessionId, - allowedTools: options.allowedTools, - model: options.model, - maxTurns: options.maxTurns, - systemPrompt, - permissionMode: options.permissionMode, - onStream: options.onStream, - onPermissionRequest: options.onPermissionRequest, - onAskUserQuestion: options.onAskUserQuestion, - bypassPermissions: options.bypassPermissions, - anthropicApiKey: options.anthropicApiKey, - }; - - const result = await executeClaudeCli(prompt, spawnOptions); - const status = determineStatus(result); - - if (!result.success && result.error) { - log.error('Agent query failed', { agent: agentName, error: result.error }); - } - - return { - agent: agentName, - status, - content: result.content, - timestamp: new Date(), - sessionId: result.sessionId, - error: result.error, - }; + return defaultClient.callCustom(agentName, prompt, systemPrompt, options); +} + +export async function callClaudeAgent( + claudeAgentName: string, + prompt: string, + options: ClaudeCallOptions, +): Promise { + return defaultClient.callAgent(claudeAgentName, prompt, options); +} + +export async function callClaudeSkill( + skillName: string, + prompt: string, + options: ClaudeCallOptions, +): Promise { + return defaultClient.callSkill(skillName, prompt, options); } -/** - * Detect judge rule index from [JUDGE:N] tag pattern. - * Returns 0-based rule index, or -1 if no match. - */ export function detectJudgeIndex(content: string): number { - const regex = /\[JUDGE:(\d+)\]/i; - const match = content.match(regex); - if (match?.[1]) { - const index = Number.parseInt(match[1], 10) - 1; - return index >= 0 ? index : -1; - } - return -1; + return ClaudeClient.detectJudgeIndex(content); } -/** - * Build the prompt for the AI judge that evaluates agent output against ai() conditions. - */ export function buildJudgePrompt( agentOutput: string, aiConditions: { index: number; text: string }[], ): string { - const conditionList = aiConditions - .map((c) => `| ${c.index + 1} | ${c.text} |`) - .join('\n'); - - return [ - '# Judge Task', - '', - 'You are a judge evaluating an agent\'s output against a set of conditions.', - 'Read the agent output below, then determine which condition best matches.', - '', - '## Agent Output', - '```', - agentOutput, - '```', - '', - '## Conditions', - '| # | Condition |', - '|---|-----------|', - conditionList, - '', - '## Instructions', - 'Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition.', - 'Do not output anything else.', - ].join('\n'); + return ClaudeClient.buildJudgePrompt(agentOutput, aiConditions); } -/** - * Call AI judge to evaluate agent output against ai() conditions. - * Uses a lightweight model (haiku) for cost efficiency. - * Returns 0-based index of the matched ai() condition, or -1 if no match. - */ export async function callAiJudge( agentOutput: string, aiConditions: { index: number; text: string }[], options: { cwd: string }, ): Promise { - const prompt = buildJudgePrompt(agentOutput, aiConditions); - - const spawnOptions: ClaudeSpawnOptions = { - cwd: options.cwd, - model: 'haiku', - maxTurns: 1, - }; - - const result = await executeClaudeCli(prompt, spawnOptions); - if (!result.success) { - log.error('AI judge call failed', { error: result.error }); - return -1; - } - - return detectJudgeIndex(result.content); -} - -/** Call a Claude Code built-in agent (using claude --agent flag if available) */ -export async function callClaudeAgent( - claudeAgentName: string, - prompt: string, - options: ClaudeCallOptions -): Promise { - // 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 { - // Prepend skill invocation to prompt - const fullPrompt = `/${skillName}\n\n${prompt}`; - - const spawnOptions: ClaudeSpawnOptions = { - cwd: options.cwd, - sessionId: options.sessionId, - allowedTools: options.allowedTools, - model: options.model, - maxTurns: options.maxTurns, - permissionMode: options.permissionMode, - onStream: options.onStream, - onPermissionRequest: options.onPermissionRequest, - onAskUserQuestion: options.onAskUserQuestion, - bypassPermissions: options.bypassPermissions, - anthropicApiKey: options.anthropicApiKey, - }; - - const result = await executeClaudeCli(fullPrompt, spawnOptions); - - if (!result.success && result.error) { - log.error('Skill query failed', { skill: skillName, error: result.error }); - } - - return { - agent: `skill:${skillName}`, - status: result.success ? 'done' : 'blocked', - content: result.content, - timestamp: new Date(), - sessionId: result.sessionId, - error: result.error, - }; + return defaultClient.callAiJudge(agentOutput, aiConditions, options); } diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 7e1efa5..f94416a 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -8,11 +8,8 @@ import { query, AbortError, - type Options, type SDKResultMessage, type SDKAssistantMessage, - type AgentDefinition, - type PermissionMode, } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '../utils/debug.js'; import { getErrorMessage } from '../utils/error.js'; @@ -22,233 +19,168 @@ import { unregisterQuery, } from './query-manager.js'; import { sdkMessageToStreamEvent } from './stream-converter.js'; -import { - createCanUseToolCallback, - createAskUserQuestionHooks, -} from './options-builder.js'; +import { SdkOptionsBuilder } from './options-builder.js'; import type { - StreamCallback, - PermissionHandler, - AskUserQuestionHandler, + ClaudeSpawnOptions, ClaudeResult, } from './types.js'; const log = createLogger('claude-sdk'); -/** Options for executing Claude queries */ -export interface ExecuteOptions { - cwd: string; - sessionId?: string; - allowedTools?: string[]; - model?: string; - maxTurns?: number; - systemPrompt?: string; - onStream?: StreamCallback; - agents?: Record; - permissionMode?: PermissionMode; - onPermissionRequest?: PermissionHandler; - onAskUserQuestion?: AskUserQuestionHandler; - /** Bypass all permission checks (sacrifice-my-pc mode) */ - bypassPermissions?: boolean; - /** Anthropic API key to inject via env (bypasses CLI auth) */ - anthropicApiKey?: string; -} - /** - * Build SDK options from ExecuteOptions. + * Executes Claude queries using the Agent SDK. + * + * Handles query lifecycle (register/unregister), streaming, + * assistant text accumulation, and error classification. */ -function buildSdkOptions(options: ExecuteOptions): Options { - const canUseTool = options.onPermissionRequest - ? createCanUseToolCallback(options.onPermissionRequest) - : undefined; +export class QueryExecutor { + /** + * Execute a Claude query. + */ + async execute( + prompt: string, + options: ClaudeSpawnOptions, + ): Promise { + const queryId = generateQueryId(); - const hooks = options.onAskUserQuestion - ? createAskUserQuestionHooks(options.onAskUserQuestion) - : undefined; + log.debug('Executing Claude query via SDK', { + queryId, + cwd: options.cwd, + model: options.model, + hasSystemPrompt: !!options.systemPrompt, + allowedTools: options.allowedTools, + }); - // Determine permission mode - // Priority: bypassPermissions > explicit permissionMode > callback-based default - let permissionMode: PermissionMode; - if (options.bypassPermissions) { - permissionMode = 'bypassPermissions'; - } else if (options.permissionMode) { - permissionMode = options.permissionMode; - } else if (options.onPermissionRequest) { - permissionMode = 'default'; - } else { - permissionMode = 'acceptEdits'; - } + const sdkOptions = new SdkOptionsBuilder(options).build(); - // Only include defined values — the SDK treats key-present-but-undefined - // differently from key-absent for some options (e.g. model), causing hangs. - const sdkOptions: Options = { - cwd: options.cwd, - permissionMode, - }; + let sessionId: string | undefined; + let success = false; + let resultContent: string | undefined; + let hasResultMessage = false; + let accumulatedAssistantText = ''; - if (options.model) sdkOptions.model = options.model; - if (options.maxTurns != null) sdkOptions.maxTurns = options.maxTurns; - if (options.allowedTools) sdkOptions.allowedTools = options.allowedTools; - if (options.agents) sdkOptions.agents = options.agents; - if (options.systemPrompt) sdkOptions.systemPrompt = options.systemPrompt; - if (canUseTool) sdkOptions.canUseTool = canUseTool; - if (hooks) sdkOptions.hooks = hooks; + try { + const q = query({ prompt, options: sdkOptions }); + registerQuery(queryId, q); - if (options.anthropicApiKey) { - sdkOptions.env = { - ...process.env as Record, - ANTHROPIC_API_KEY: options.anthropicApiKey, - }; - } + for await (const message of q) { + if ('session_id' in message) { + sessionId = message.session_id; + } - if (options.onStream) { - sdkOptions.includePartialMessages = true; - } + if (options.onStream) { + sdkMessageToStreamEvent(message, options.onStream, true); + } - if (options.sessionId) { - sdkOptions.resume = options.sessionId; - } else { - sdkOptions.continue = false; - } + if (message.type === 'assistant') { + const assistantMsg = message as SDKAssistantMessage; + for (const block of assistantMsg.message.content) { + if (block.type === 'text') { + accumulatedAssistantText += block.text; + } + } + } - return sdkOptions; -} - -/** - * Execute a Claude query using the Agent SDK. - */ -export async function executeClaudeQuery( - prompt: string, - options: ExecuteOptions -): Promise { - const queryId = generateQueryId(); - - log.debug('Executing Claude query via SDK', { - queryId, - cwd: options.cwd, - model: options.model, - hasSystemPrompt: !!options.systemPrompt, - allowedTools: options.allowedTools, - }); - - const sdkOptions = buildSdkOptions(options); - - let sessionId: string | undefined; - let success = false; - let resultContent: string | undefined; - let hasResultMessage = false; - let accumulatedAssistantText = ''; - - try { - const q = query({ prompt, options: sdkOptions }); - registerQuery(queryId, q); - - for await (const message of q) { - if ('session_id' in message) { - sessionId = message.session_id; - } - - if (options.onStream) { - sdkMessageToStreamEvent(message, options.onStream, true); - } - - if (message.type === 'assistant') { - const assistantMsg = message as SDKAssistantMessage; - for (const block of assistantMsg.message.content) { - if (block.type === 'text') { - accumulatedAssistantText += block.text; + if (message.type === 'result') { + hasResultMessage = true; + const resultMsg = message as SDKResultMessage; + if (resultMsg.subtype === 'success') { + resultContent = resultMsg.result; + success = true; + } else { + success = false; + if (resultMsg.errors && resultMsg.errors.length > 0) { + resultContent = resultMsg.errors.join('\n'); + } } } } - if (message.type === 'result') { - hasResultMessage = true; - const resultMsg = message as SDKResultMessage; - if (resultMsg.subtype === 'success') { - resultContent = resultMsg.result; - success = true; - } else { - success = false; - if (resultMsg.errors && resultMsg.errors.length > 0) { - resultContent = resultMsg.errors.join('\n'); - } - } - } + unregisterQuery(queryId); + + const finalContent = resultContent || accumulatedAssistantText; + + log.info('Claude query completed', { + queryId, + sessionId, + contentLength: finalContent.length, + success, + hasResultMessage, + }); + + return { + success, + content: finalContent.trim(), + sessionId, + fullContent: accumulatedAssistantText.trim(), + }; + } catch (error) { + unregisterQuery(queryId); + return QueryExecutor.handleQueryError(error, queryId, sessionId, hasResultMessage, success, resultContent); + } + } + + /** + * Handle query execution errors. + * Classifies errors (abort, rate limit, auth, timeout) and returns appropriate ClaudeResult. + */ + private static handleQueryError( + error: unknown, + queryId: string, + sessionId: string | undefined, + hasResultMessage: boolean, + success: boolean, + resultContent: string | undefined, + ): ClaudeResult { + if (error instanceof AbortError) { + log.info('Claude query was interrupted', { queryId }); + return { + success: false, + content: '', + error: 'Query interrupted', + interrupted: true, + }; } - unregisterQuery(queryId); + const errorMessage = getErrorMessage(error); - const finalContent = resultContent || accumulatedAssistantText; + if (hasResultMessage && success) { + log.info('Claude query completed with post-completion error (ignoring)', { + queryId, + sessionId, + error: errorMessage, + }); + return { + success: true, + content: (resultContent ?? '').trim(), + sessionId, + }; + } - log.info('Claude query completed', { - queryId, - sessionId, - contentLength: finalContent.length, - success, - hasResultMessage, - }); + log.error('Claude query failed', { queryId, error: errorMessage }); - return { - success, - content: finalContent.trim(), - sessionId, - fullContent: accumulatedAssistantText.trim(), - }; - } catch (error) { - unregisterQuery(queryId); - return handleQueryError(error, queryId, sessionId, hasResultMessage, success, resultContent); + if (errorMessage.includes('rate_limit') || errorMessage.includes('rate limit')) { + return { success: false, content: '', error: 'Rate limit exceeded. Please try again later.' }; + } + + if (errorMessage.includes('authentication') || errorMessage.includes('unauthorized')) { + return { success: false, content: '', error: 'Authentication failed. Please check your API credentials.' }; + } + + if (errorMessage.includes('timeout')) { + return { success: false, content: '', error: 'Request timed out. Please try again.' }; + } + + return { success: false, content: '', error: errorMessage }; } } -/** - * Handle query execution errors. - */ -function handleQueryError( - error: unknown, - queryId: string, - sessionId: string | undefined, - hasResultMessage: boolean, - success: boolean, - resultContent: string | undefined -): ClaudeResult { - if (error instanceof AbortError) { - log.info('Claude query was interrupted', { queryId }); - return { - success: false, - content: '', - error: 'Query interrupted', - interrupted: true, - }; - } +// ---- Backward-compatible module-level function ---- - const errorMessage = getErrorMessage(error); - - if (hasResultMessage && success) { - log.info('Claude query completed with post-completion error (ignoring)', { - queryId, - sessionId, - error: errorMessage, - }); - return { - success: true, - content: (resultContent ?? '').trim(), - sessionId, - }; - } - - log.error('Claude query failed', { queryId, error: errorMessage }); - - if (errorMessage.includes('rate_limit') || errorMessage.includes('rate limit')) { - return { success: false, content: '', error: 'Rate limit exceeded. Please try again later.' }; - } - - if (errorMessage.includes('authentication') || errorMessage.includes('unauthorized')) { - return { success: false, content: '', error: 'Authentication failed. Please check your API credentials.' }; - } - - if (errorMessage.includes('timeout')) { - return { success: false, content: '', error: 'Request timed out. Please try again.' }; - } - - return { success: false, content: '', error: errorMessage }; +/** @deprecated Use QueryExecutor.execute() instead */ +export async function executeClaudeQuery( + prompt: string, + options: ClaudeSpawnOptions, +): Promise { + return new QueryExecutor().execute(prompt, options); } diff --git a/src/claude/index.ts b/src/claude/index.ts index bfafe55..9f0c1f8 100644 --- a/src/claude/index.ts +++ b/src/claude/index.ts @@ -5,11 +5,18 @@ * from the Claude integration module. */ -// Main process and execution -export { ClaudeProcess, executeClaudeCli, type ClaudeSpawnOptions } from './process.js'; -export { executeClaudeQuery, type ExecuteOptions } from './executor.js'; +// Classes +export { ClaudeClient } from './client.js'; +export { ClaudeProcess } from './process.js'; +export { QueryExecutor } from './executor.js'; +export { QueryRegistry } from './query-manager.js'; +export { SdkOptionsBuilder } from './options-builder.js'; -// Query management (only from query-manager, process.ts re-exports these) +// Main process and execution +export { executeClaudeCli } from './process.js'; +export { executeClaudeQuery } from './executor.js'; + +// Query management export { generateQueryId, hasActiveProcess, @@ -22,7 +29,7 @@ export { interruptCurrentProcess, } from './query-manager.js'; -// Types (only from types.ts, avoiding duplicates from process.ts) +// Types export type { StreamEvent, StreamCallback, @@ -32,6 +39,8 @@ export type { AskUserQuestionHandler, ClaudeResult, ClaudeResultWithQueryId, + ClaudeCallOptions, + ClaudeSpawnOptions, InitEventData, ToolUseEventData, ToolResultEventData, @@ -45,19 +54,22 @@ export type { // Stream conversion export { sdkMessageToStreamEvent } from './stream-converter.js'; -// Options building +// Options building (backward-compatible functions) export { createCanUseToolCallback, createAskUserQuestionHooks, + buildSdkOptions, } from './options-builder.js'; -// Client functions and types +// Client functions (backward-compatible) export { callClaude, callClaudeCustom, callClaudeAgent, callClaudeSkill, + callAiJudge, detectRuleIndex, + detectJudgeIndex, + buildJudgePrompt, isRegexSafe, - type ClaudeCallOptions, } from './client.js'; diff --git a/src/claude/options-builder.ts b/src/claude/options-builder.ts index 974b759..f5fa699 100644 --- a/src/claude/options-builder.ts +++ b/src/claude/options-builder.ts @@ -14,7 +14,6 @@ import type { HookInput, HookJSONOutput, PreToolUseHookInput, - AgentDefinition, PermissionMode, } from '@anthropic-ai/claude-agent-sdk'; import { createLogger } from '../utils/debug.js'; @@ -22,131 +21,165 @@ import type { PermissionHandler, AskUserQuestionInput, AskUserQuestionHandler, + ClaudeSpawnOptions, } from './types.js'; const log = createLogger('claude-sdk'); -/** Options for calling Claude via SDK */ -export interface ClaudeSpawnOptions { - cwd: string; - sessionId?: string; - allowedTools?: string[]; - model?: string; - maxTurns?: number; - systemPrompt?: string; - /** Enable streaming mode */ - hasStream?: boolean; - /** Custom agents to register */ - agents?: Record; - /** Permission mode for tool execution */ - permissionMode?: PermissionMode; - /** Custom permission handler for interactive permission prompts */ - onPermissionRequest?: PermissionHandler; - /** Custom handler for AskUserQuestion tool */ - onAskUserQuestion?: AskUserQuestionHandler; +/** + * Builds SDK options from ClaudeSpawnOptions. + * + * Handles permission mode resolution, canUseTool callback creation, + * and AskUserQuestion hook setup. + */ +export class SdkOptionsBuilder { + private readonly options: ClaudeSpawnOptions; + + constructor(options: ClaudeSpawnOptions) { + this.options = options; + } + + /** Build the full SDK Options object */ + build(): Options { + const canUseTool = this.options.onPermissionRequest + ? SdkOptionsBuilder.createCanUseToolCallback(this.options.onPermissionRequest) + : undefined; + + const hooks = this.options.onAskUserQuestion + ? SdkOptionsBuilder.createAskUserQuestionHooks(this.options.onAskUserQuestion) + : undefined; + + const permissionMode = this.resolvePermissionMode(); + + // Only include defined values — the SDK treats key-present-but-undefined + // differently from key-absent for some options (e.g. model), causing hangs. + const sdkOptions: Options = { + cwd: this.options.cwd, + permissionMode, + }; + + if (this.options.model) sdkOptions.model = this.options.model; + if (this.options.maxTurns != null) sdkOptions.maxTurns = this.options.maxTurns; + if (this.options.allowedTools) sdkOptions.allowedTools = this.options.allowedTools; + if (this.options.agents) sdkOptions.agents = this.options.agents; + if (this.options.systemPrompt) sdkOptions.systemPrompt = this.options.systemPrompt; + if (canUseTool) sdkOptions.canUseTool = canUseTool; + if (hooks) sdkOptions.hooks = hooks; + + if (this.options.anthropicApiKey) { + sdkOptions.env = { + ...process.env as Record, + 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, + callbackOptions: { + signal: AbortSignal; + suggestions?: PermissionUpdate[]; + blockedPath?: string; + decisionReason?: string; + } + ): Promise => { + return handler({ + toolName, + input, + suggestions: callbackOptions.suggestions, + blockedPath: callbackOptions.blockedPath, + decisionReason: callbackOptions.decisionReason, + }); + }; + } + + /** + * Create hooks for AskUserQuestion handling. + */ + static createAskUserQuestionHooks( + askUserHandler: AskUserQuestionHandler + ): Partial> { + const preToolUseHook = async ( + input: HookInput, + _toolUseID: string | undefined, + _options: { signal: AbortSignal } + ): Promise => { + const preToolInput = input as PreToolUseHookInput; + if (preToolInput.tool_name === 'AskUserQuestion') { + const toolInput = preToolInput.tool_input as AskUserQuestionInput; + try { + const answers = await askUserHandler(toolInput); + return { + continue: true, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + additionalContext: JSON.stringify(answers), + }, + }; + } catch (err) { + log.error('AskUserQuestion handler failed', { error: err }); + return { continue: true }; + } + } + return { continue: true }; + }; + + return { + PreToolUse: [{ + matcher: 'AskUserQuestion', + hooks: [preToolUseHook], + }], + }; + } } -/** - * Create canUseTool callback from permission handler. - */ +// ---- Backward-compatible module-level functions ---- + export function createCanUseToolCallback( handler: PermissionHandler ): CanUseTool { - return async ( - toolName: string, - input: Record, - callbackOptions: { - signal: AbortSignal; - suggestions?: PermissionUpdate[]; - blockedPath?: string; - decisionReason?: string; - } - ): Promise => { - return handler({ - toolName, - input, - suggestions: callbackOptions.suggestions, - blockedPath: callbackOptions.blockedPath, - decisionReason: callbackOptions.decisionReason, - }); - }; + return SdkOptionsBuilder.createCanUseToolCallback(handler); } -/** - * Create hooks for AskUserQuestion handling. - */ export function createAskUserQuestionHooks( askUserHandler: AskUserQuestionHandler ): Partial> { - const preToolUseHook = async ( - input: HookInput, - _toolUseID: string | undefined, - _options: { signal: AbortSignal } - ): Promise => { - const preToolInput = input as PreToolUseHookInput; - if (preToolInput.tool_name === 'AskUserQuestion') { - const toolInput = preToolInput.tool_input as AskUserQuestionInput; - try { - const answers = await askUserHandler(toolInput); - return { - continue: true, - hookSpecificOutput: { - hookEventName: 'PreToolUse', - additionalContext: JSON.stringify(answers), - }, - }; - } catch (err) { - log.error('AskUserQuestion handler failed', { error: err }); - return { continue: true }; - } - } - return { continue: true }; - }; - - return { - PreToolUse: [{ - matcher: 'AskUserQuestion', - hooks: [preToolUseHook], - }], - }; + return SdkOptionsBuilder.createAskUserQuestionHooks(askUserHandler); } -/** - * Build SDK options from ClaudeSpawnOptions. - */ export function buildSdkOptions(options: ClaudeSpawnOptions): Options { - // Create canUseTool callback if permission handler is provided - const canUseTool = options.onPermissionRequest - ? createCanUseToolCallback(options.onPermissionRequest) - : undefined; - - // Create hooks for AskUserQuestion handling - const hooks = options.onAskUserQuestion - ? createAskUserQuestionHooks(options.onAskUserQuestion) - : undefined; - - const sdkOptions: Options = { - cwd: options.cwd, - model: options.model, - maxTurns: options.maxTurns, - allowedTools: options.allowedTools, - agents: options.agents, - permissionMode: options.permissionMode ?? (options.onPermissionRequest ? 'default' : 'acceptEdits'), - includePartialMessages: options.hasStream, - canUseTool, - hooks, - }; - - if (options.systemPrompt) { - sdkOptions.systemPrompt = options.systemPrompt; - } - - // Session management - if (options.sessionId) { - sdkOptions.resume = options.sessionId; - } else { - sdkOptions.continue = false; - } - - return sdkOptions; + return new SdkOptionsBuilder(options).build(); } diff --git a/src/claude/process.ts b/src/claude/process.ts index 2bd8e08..c111bab 100644 --- a/src/claude/process.ts +++ b/src/claude/process.ts @@ -5,16 +5,13 @@ * instead of spawning CLI processes. */ -import type { AgentDefinition, PermissionMode } from '@anthropic-ai/claude-agent-sdk'; import { hasActiveProcess, interruptCurrentProcess, } from './query-manager.js'; import { executeClaudeQuery } from './executor.js'; import type { - StreamCallback, - PermissionHandler, - AskUserQuestionHandler, + ClaudeSpawnOptions, ClaudeResult, } from './types.js'; @@ -28,6 +25,7 @@ export type { AskUserQuestionHandler, ClaudeResult, ClaudeResultWithQueryId, + ClaudeSpawnOptions, InitEventData, ToolUseEventData, ToolResultEventData, @@ -49,37 +47,13 @@ export { interruptCurrentProcess, } from './query-manager.js'; -/** Options for calling Claude via SDK */ -export interface ClaudeSpawnOptions { - cwd: string; - sessionId?: string; - allowedTools?: string[]; - model?: string; - maxTurns?: number; - systemPrompt?: string; - /** Enable streaming mode with callback */ - onStream?: StreamCallback; - /** Custom agents to register */ - agents?: Record; - /** Permission mode for tool execution (default: 'default' for interactive) */ - permissionMode?: PermissionMode; - /** Custom permission handler for interactive permission prompts */ - onPermissionRequest?: PermissionHandler; - /** Custom handler for AskUserQuestion tool */ - onAskUserQuestion?: AskUserQuestionHandler; - /** Bypass all permission checks (sacrifice-my-pc mode) */ - bypassPermissions?: boolean; - /** Anthropic API key to inject via env (bypasses CLI auth) */ - anthropicApiKey?: string; -} - /** * Execute a Claude query using the Agent SDK. * Supports concurrent execution with query ID tracking. */ export async function executeClaudeCli( prompt: string, - options: ClaudeSpawnOptions + options: ClaudeSpawnOptions, ): Promise { return executeClaudeQuery(prompt, options); } diff --git a/src/claude/query-manager.ts b/src/claude/query-manager.ts index 5fb76e8..2f7f904 100644 --- a/src/claude/query-manager.ts +++ b/src/claude/query-manager.ts @@ -3,83 +3,134 @@ * * Handles tracking and lifecycle management of active Claude queries. * Supports concurrent query execution with interrupt capabilities. + * + * QueryRegistry is a singleton that encapsulates the global activeQueries Map. */ import type { Query } from '@anthropic-ai/claude-agent-sdk'; /** - * Active query registry for interrupt support. - * Uses a Map to support concurrent query execution. + * Registry for tracking active Claude queries. + * Singleton — use QueryRegistry.getInstance(). */ -const activeQueries = new Map(); +export class QueryRegistry { + private static instance: QueryRegistry | null = null; + private readonly activeQueries = new Map(); + + private constructor() {} + + static getInstance(): QueryRegistry { + if (!QueryRegistry.instance) { + QueryRegistry.instance = new QueryRegistry(); + } + return QueryRegistry.instance; + } + + /** Reset singleton for testing */ + static resetInstance(): void { + QueryRegistry.instance = null; + } + + /** Check if there is an active Claude process */ + hasActiveProcess(): boolean { + return this.activeQueries.size > 0; + } + + /** Check if a specific query is active */ + isQueryActive(queryId: string): boolean { + return this.activeQueries.has(queryId); + } + + /** Get count of active queries */ + getActiveQueryCount(): number { + return this.activeQueries.size; + } + + /** Register an active query */ + registerQuery(queryId: string, queryInstance: Query): void { + this.activeQueries.set(queryId, queryInstance); + } + + /** Unregister an active query */ + unregisterQuery(queryId: string): void { + this.activeQueries.delete(queryId); + } + + /** + * Interrupt a specific Claude query by ID. + * @returns true if the query was interrupted, false if not found + */ + interruptQuery(queryId: string): boolean { + const queryInstance = this.activeQueries.get(queryId); + if (queryInstance) { + queryInstance.interrupt(); + this.activeQueries.delete(queryId); + return true; + } + return false; + } + + /** + * Interrupt all active Claude queries. + * @returns number of queries that were interrupted + */ + interruptAllQueries(): number { + const count = this.activeQueries.size; + for (const [id, queryInstance] of this.activeQueries) { + queryInstance.interrupt(); + this.activeQueries.delete(id); + } + return count; + } + + /** + * Interrupt the most recently started Claude query (backward compatibility). + * @returns true if a query was interrupted, false if no query was running + */ + interruptCurrentProcess(): boolean { + if (this.activeQueries.size === 0) { + return false; + } + this.interruptAllQueries(); + return true; + } +} + +// ---- Backward-compatible module-level functions ---- /** Generate a unique query ID */ export function generateQueryId(): string { return `q-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } -/** Check if there is an active Claude process */ export function hasActiveProcess(): boolean { - return activeQueries.size > 0; + return QueryRegistry.getInstance().hasActiveProcess(); } -/** Check if a specific query is active */ export function isQueryActive(queryId: string): boolean { - return activeQueries.has(queryId); + return QueryRegistry.getInstance().isQueryActive(queryId); } -/** Get count of active queries */ export function getActiveQueryCount(): number { - return activeQueries.size; + return QueryRegistry.getInstance().getActiveQueryCount(); } -/** Register an active query */ export function registerQuery(queryId: string, queryInstance: Query): void { - activeQueries.set(queryId, queryInstance); + QueryRegistry.getInstance().registerQuery(queryId, queryInstance); } -/** Unregister an active query */ export function unregisterQuery(queryId: string): void { - activeQueries.delete(queryId); + QueryRegistry.getInstance().unregisterQuery(queryId); } -/** - * Interrupt a specific Claude query by ID. - * @returns true if the query was interrupted, false if not found - */ export function interruptQuery(queryId: string): boolean { - const queryInstance = activeQueries.get(queryId); - if (queryInstance) { - queryInstance.interrupt(); - activeQueries.delete(queryId); - return true; - } - return false; + return QueryRegistry.getInstance().interruptQuery(queryId); } -/** - * Interrupt all active Claude queries. - * @returns number of queries that were interrupted - */ export function interruptAllQueries(): number { - const count = activeQueries.size; - for (const [id, queryInstance] of activeQueries) { - queryInstance.interrupt(); - activeQueries.delete(id); - } - return count; + return QueryRegistry.getInstance().interruptAllQueries(); } -/** - * Interrupt the most recently started Claude query (backward compatibility). - * @returns true if a query was interrupted, false if no query was running - */ export function interruptCurrentProcess(): boolean { - if (activeQueries.size === 0) { - return false; - } - // Interrupt all queries for backward compatibility - // In the old design, there was only one query - interruptAllQueries(); - return true; + return QueryRegistry.getInstance().interruptCurrentProcess(); } diff --git a/src/claude/types.ts b/src/claude/types.ts index c395217..754f434 100644 --- a/src/claude/types.ts +++ b/src/claude/types.ts @@ -5,7 +5,8 @@ * used throughout the Claude integration layer. */ -import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; +import type { PermissionResult, PermissionUpdate, AgentDefinition, PermissionMode as SdkPermissionMode } from '@anthropic-ai/claude-agent-sdk'; +import type { PermissionMode } from '../models/types.js'; // Re-export PermissionResult for convenience export type { PermissionResult, PermissionUpdate }; @@ -113,3 +114,51 @@ export interface ClaudeResult { export interface ClaudeResultWithQueryId extends ClaudeResult { queryId: string; } + +/** Options for calling Claude (high-level, used by client/providers/agents) */ +export interface ClaudeCallOptions { + cwd: string; + sessionId?: string; + allowedTools?: string[]; + model?: string; + maxTurns?: number; + systemPrompt?: string; + /** SDK agents to register for sub-agent execution */ + agents?: Record; + /** 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; + /** 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; +} diff --git a/src/cli.ts b/src/cli.ts index 0c4c95b..f327252 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -48,8 +48,8 @@ import { resolveIssueTask, isIssueReference } from './github/issue.js'; import { selectAndExecuteTask, type SelectAndExecuteOptions, -} from './commands/selectAndExecute.js'; -import type { TaskExecutionOptions } from './commands/taskExecution.js'; +} from './commands/execution/selectAndExecute.js'; +import type { TaskExecutionOptions } from './commands/execution/taskExecution.js'; import type { ProviderType } from './providers/index.js'; const require = createRequire(import.meta.url); diff --git a/src/codex/client.ts b/src/codex/client.ts index 1ea44b7..05828f7 100644 --- a/src/codex/client.ts +++ b/src/codex/client.ts @@ -6,106 +6,16 @@ import { Codex } from '@openai/codex-sdk'; import type { AgentResponse, Status } from '../models/types.js'; -import type { StreamCallback } from '../claude/process.js'; +import type { StreamCallback } from '../claude/types.js'; import { createLogger } from '../utils/debug.js'; import { getErrorMessage } from '../utils/error.js'; +import type { CodexCallOptions } from './types.js'; + +// Re-export for backward compatibility +export type { CodexCallOptions } from './types.js'; const log = createLogger('codex-sdk'); -/** Options for calling Codex */ -export interface CodexCallOptions { - cwd: string; - sessionId?: string; - model?: string; - systemPrompt?: string; - /** Enable streaming mode with callback (best-effort) */ - onStream?: StreamCallback; - /** OpenAI API key (bypasses CLI auth) */ - openaiApiKey?: string; -} - -function extractThreadId(value: unknown): string | undefined { - if (!value || typeof value !== 'object') return undefined; - const record = value as Record; - 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, - id: string -): void { - if (!onStream) return; - onStream({ type: 'tool_use', data: { tool, input, id } }); -} - -function emitToolResult( - onStream: StreamCallback | undefined, - content: string, - isError: boolean -): void { - if (!onStream) return; - onStream({ type: 'tool_result', data: { content, isError } }); -} - -function emitToolOutput( - onStream: StreamCallback | undefined, - tool: string, - output: string -): void { - if (!onStream || !output) return; - onStream({ type: 'tool_output', data: { tool, output } }); -} - -function emitResult( - onStream: StreamCallback | undefined, - success: boolean, - result: string, - sessionId: string | undefined -): void { - if (!onStream) return; - onStream({ - type: 'result', - data: { - result, - sessionId: sessionId || 'unknown', - success, - error: success ? undefined : result || undefined, - }, - }); -} - -function determineStatus(success: boolean): Status { - return success ? 'done' : 'blocked'; -} - type CodexEvent = { type: string; [key: string]: unknown; @@ -117,354 +27,454 @@ type CodexItem = { [key: string]: unknown; }; -function formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }>): string { - if (!changes.length) return ''; - return changes - .map((change) => { - const kind = change.kind ? `${change.kind}: ` : ''; - return `${kind}${change.path ?? ''}`.trim(); - }) - .filter(Boolean) - .join('\n'); -} - -function emitCodexItemStart( - item: CodexItem, - onStream: StreamCallback | undefined, - startedItems: Set -): 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; - 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, - outputOffsets: Map, - textOffsets: Map, - thinkingOffsets: Map -): 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, - outputOffsets: Map, - textOffsets: Map, - thinkingOffsets: Map -): void { - if (!onStream) return; - const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; - - switch (item.type) { - case 'command_execution': { - if (!startedItems.has(id)) { - emitCodexItemStart(item, onStream, startedItems); - } - const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : ''; - if (output) { - const prev = outputOffsets.get(id) ?? 0; - if (output.length > prev) { - emitToolOutput(onStream, 'Bash', output.slice(prev)); - outputOffsets.set(id, output.length); - } - } - break; - } - case 'agent_message': { - const text = typeof item.text === 'string' ? item.text : ''; - if (text) { - const prev = textOffsets.get(id) ?? 0; - if (text.length > prev) { - emitText(onStream, text.slice(prev)); - textOffsets.set(id, text.length); - } - } - break; - } - case 'reasoning': { - const text = typeof item.text === 'string' ? item.text : ''; - if (text) { - const prev = thinkingOffsets.get(id) ?? 0; - if (text.length > prev) { - emitThinking(onStream, text.slice(prev)); - thinkingOffsets.set(id, text.length); - } - } - break; - } - case 'file_change': { - if (!startedItems.has(id)) { - emitCodexItemStart(item, onStream, startedItems); - } - break; - } - case 'mcp_tool_call': { - if (!startedItems.has(id)) { - emitCodexItemStart(item, onStream, startedItems); - } - break; - } - case 'web_search': { - if (!startedItems.has(id)) { - emitCodexItemStart(item, onStream, startedItems); - } - break; - } - default: - break; - } -} - /** - * Call Codex with an agent prompt. + * Client for Codex SDK agent interactions. + * + * Handles thread management, streaming event conversion, + * and response processing. */ -export async function callCodex( - agentType: string, - prompt: string, - options: CodexCallOptions -): Promise { - const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined); - const threadOptions = { - model: options.model, - workingDirectory: options.cwd, - }; - const thread = options.sessionId - ? await codex.resumeThread(options.sessionId, threadOptions) - : await codex.startThread(threadOptions); - let threadId = extractThreadId(thread) || options.sessionId; +export class CodexClient { + // ---- Stream emission helpers (private) ---- - const fullPrompt = options.systemPrompt - ? `${options.systemPrompt}\n\n${prompt}` - : prompt; + private static extractThreadId(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const record = value as Record; + const id = record.id ?? record.thread_id ?? record.threadId; + return typeof id === 'string' ? id : undefined; + } - try { - log.debug('Executing Codex thread', { - agentType, - model: options.model, - hasSystemPrompt: !!options.systemPrompt, + private static emitInit( + onStream: StreamCallback | undefined, + model: string | undefined, + sessionId: string | undefined, + ): void { + if (!onStream) return; + onStream({ + type: 'init', + data: { + model: model || 'codex', + sessionId: sessionId || 'unknown', + }, }); + } - const { events } = await thread.runStreamed(fullPrompt); - let content = ''; - const contentOffsets = new Map(); - let success = true; - let failureMessage = ''; - const startedItems = new Set(); - const outputOffsets = new Map(); - const textOffsets = new Map(); - const thinkingOffsets = new Map(); + private static emitText(onStream: StreamCallback | undefined, text: string): void { + if (!onStream || !text) return; + onStream({ type: 'text', data: { text } }); + } - for await (const event of events as AsyncGenerator) { - if (event.type === 'thread.started') { - threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId; - emitInit(options.onStream, options.model, threadId); - continue; - } + private static emitThinking(onStream: StreamCallback | undefined, thinking: string): void { + if (!onStream || !thinking) return; + onStream({ type: 'thinking', data: { thinking } }); + } - if (event.type === 'turn.failed') { - success = false; - if (event.error && typeof event.error === 'object' && 'message' in event.error) { - failureMessage = String((event.error as { message?: unknown }).message ?? ''); - } + private static emitToolUse( + onStream: StreamCallback | undefined, + tool: string, + input: Record, + 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, + ): void { + if (!onStream) return; + const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`; + if (startedItems.has(id)) return; + + switch (item.type) { + case 'command_execution': { + const command = typeof item.command === 'string' ? item.command : ''; + CodexClient.emitToolUse(onStream, 'Bash', { command }, id); + startedItems.add(id); break; } - - if (event.type === 'error') { - success = false; - failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error'; + case 'mcp_tool_call': { + const tool = typeof item.tool === 'string' ? item.tool : 'Tool'; + const args = (item.arguments ?? {}) as Record; + CodexClient.emitToolUse(onStream, tool, args, id); + startedItems.add(id); break; } - - if (event.type === 'item.started') { - const item = event.item as CodexItem | undefined; - if (item) { - emitCodexItemStart(item, options.onStream, startedItems); - } - continue; + case 'web_search': { + const query = typeof item.query === 'string' ? item.query : ''; + CodexClient.emitToolUse(onStream, 'WebSearch', { query }, id); + startedItems.add(id); + break; } - - if (event.type === 'item.updated') { - const item = event.item as CodexItem | undefined; - if (item) { - if (item.type === 'agent_message' && typeof item.text === 'string') { - const itemId = item.id; - const text = item.text; - if (itemId) { - const prev = contentOffsets.get(itemId) ?? 0; - if (text.length > prev) { - if (prev === 0 && content.length > 0) { - content += '\n'; - } - content += text.slice(prev); - contentOffsets.set(itemId, text.length); - } - } - } - emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets); - } - continue; - } - - if (event.type === 'item.completed') { - const item = event.item as CodexItem | undefined; - if (item) { - if (item.type === 'agent_message' && typeof item.text === 'string') { - const itemId = item.id; - const text = item.text; - if (itemId) { - const prev = contentOffsets.get(itemId) ?? 0; - if (text.length > prev) { - if (prev === 0 && content.length > 0) { - content += '\n'; - } - content += text.slice(prev); - contentOffsets.set(itemId, text.length); - } - } else if (text) { - if (content.length > 0) { - content += '\n'; - } - content += text; - } - } - emitCodexItemCompleted( - item, - options.onStream, - startedItems, - outputOffsets, - textOffsets, - thinkingOffsets - ); - } - continue; + case 'file_change': { + const changes = Array.isArray(item.changes) ? item.changes : []; + const summary = CodexClient.formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>); + CodexClient.emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id); + startedItems.add(id); + break; } + default: + break; } + } + + private static emitCodexItemCompleted( + item: CodexItem, + onStream: StreamCallback | undefined, + startedItems: Set, + outputOffsets: Map, + textOffsets: Map, + thinkingOffsets: Map, + ): 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, + outputOffsets: Map, + textOffsets: Map, + thinkingOffsets: Map, + ): 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 { + 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(); + let success = true; + let failureMessage = ''; + const startedItems = new Set(); + const outputOffsets = new Map(); + const textOffsets = new Map(); + const thinkingOffsets = new Map(); + + for await (const event of events as AsyncGenerator) { + if (event.type === 'thread.started') { + threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId; + CodexClient.emitInit(options.onStream, options.model, threadId); + continue; + } + + if (event.type === 'turn.failed') { + success = false; + if (event.error && typeof event.error === 'object' && 'message' in event.error) { + failureMessage = String((event.error as { message?: unknown }).message ?? ''); + } + break; + } + + if (event.type === 'error') { + success = false; + failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error'; + break; + } + + if (event.type === 'item.started') { + const item = event.item as CodexItem | undefined; + if (item) { + CodexClient.emitCodexItemStart(item, options.onStream, startedItems); + } + continue; + } + + if (event.type === 'item.updated') { + const item = event.item as CodexItem | undefined; + if (item) { + if (item.type === 'agent_message' && typeof item.text === 'string') { + const itemId = item.id; + const text = item.text; + if (itemId) { + const prev = contentOffsets.get(itemId) ?? 0; + if (text.length > prev) { + if (prev === 0 && content.length > 0) { + content += '\n'; + } + content += text.slice(prev); + contentOffsets.set(itemId, text.length); + } + } + } + CodexClient.emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets); + } + continue; + } + + if (event.type === 'item.completed') { + const item = event.item as CodexItem | undefined; + if (item) { + if (item.type === 'agent_message' && typeof item.text === 'string') { + const itemId = item.id; + const text = item.text; + if (itemId) { + const prev = contentOffsets.get(itemId) ?? 0; + if (text.length > prev) { + if (prev === 0 && content.length > 0) { + content += '\n'; + } + content += text.slice(prev); + contentOffsets.set(itemId, text.length); + } + } else if (text) { + if (content.length > 0) { + content += '\n'; + } + content += text; + } + } + CodexClient.emitCodexItemCompleted( + item, + options.onStream, + startedItems, + outputOffsets, + textOffsets, + thinkingOffsets, + ); + } + continue; + } + } + + if (!success) { + const message = failureMessage || 'Codex execution failed'; + CodexClient.emitResult(options.onStream, false, message, threadId); + return { + agent: agentType, + status: 'blocked', + content: message, + timestamp: new Date(), + sessionId: threadId, + }; + } + + const trimmed = content.trim(); + CodexClient.emitResult(options.onStream, true, trimmed, threadId); + + return { + agent: agentType, + status: 'done', + content: trimmed, + timestamp: new Date(), + sessionId: threadId, + }; + } catch (error) { + const message = getErrorMessage(error); + CodexClient.emitResult(options.onStream, false, message, threadId); - if (!success) { - const message = failureMessage || 'Codex execution failed'; - emitResult(options.onStream, false, message, threadId); return { agent: agentType, status: 'blocked', @@ -473,44 +483,39 @@ export async function callCodex( sessionId: threadId, }; } + } - const trimmed = content.trim(); - emitResult(options.onStream, true, trimmed, threadId); - - const status = determineStatus(true); - - return { - agent: agentType, - status, - content: trimmed, - timestamp: new Date(), - sessionId: threadId, - }; - } catch (error) { - const message = getErrorMessage(error); - emitResult(options.onStream, false, message, threadId); - - return { - agent: agentType, - status: 'blocked', - content: message, - timestamp: new Date(), - sessionId: threadId, - }; + /** Call Codex with a custom agent configuration (system prompt + prompt) */ + async callCustom( + agentName: string, + prompt: string, + systemPrompt: string, + options: CodexCallOptions, + ): Promise { + return this.call(agentName, prompt, { + ...options, + systemPrompt, + }); } } -/** - * Call Codex with a custom agent configuration (system prompt + prompt). - */ +// ---- Backward-compatible module-level functions ---- + +const defaultClient = new CodexClient(); + +export async function callCodex( + agentType: string, + prompt: string, + options: CodexCallOptions, +): Promise { + return defaultClient.call(agentType, prompt, options); +} + export async function callCodexCustom( agentName: string, prompt: string, systemPrompt: string, - options: CodexCallOptions + options: CodexCallOptions, ): Promise { - return callCodex(agentName, prompt, { - ...options, - systemPrompt, - }); + return defaultClient.callCustom(agentName, prompt, systemPrompt, options); } diff --git a/src/codex/index.ts b/src/codex/index.ts index d9229b7..884056d 100644 --- a/src/codex/index.ts +++ b/src/codex/index.ts @@ -2,4 +2,5 @@ * Codex integration exports */ -export * from './client.js'; +export { CodexClient, callCodex, callCodexCustom } from './client.js'; +export type { CodexCallOptions } from './types.js'; diff --git a/src/codex/types.ts b/src/codex/types.ts new file mode 100644 index 0000000..78f8b0a --- /dev/null +++ b/src/codex/types.ts @@ -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; +} diff --git a/src/commands/addTask.ts b/src/commands/addTask.ts deleted file mode 100644 index 29289b2..0000000 --- a/src/commands/addTask.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export shim — actual implementation in management/addTask.ts */ -export { addTask, summarizeConversation } from './management/addTask.js'; diff --git a/src/commands/config.ts b/src/commands/config.ts deleted file mode 100644 index e0bdaf5..0000000 --- a/src/commands/config.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export shim — actual implementation in management/config.ts */ -export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './management/config.js'; diff --git a/src/commands/eject.ts b/src/commands/eject.ts deleted file mode 100644 index 504efb1..0000000 --- a/src/commands/eject.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export shim — actual implementation in management/eject.ts */ -export { ejectBuiltin } from './management/eject.js'; diff --git a/src/commands/execution/pipelineExecution.ts b/src/commands/execution/pipelineExecution.ts index 267b592..5977d81 100644 --- a/src/commands/execution/pipelineExecution.ts +++ b/src/commands/execution/pipelineExecution.ts @@ -13,8 +13,8 @@ import { execFileSync } from 'node:child_process'; import { fetchIssue, formatIssueAsTask, checkGhCli, type GitHubIssue } from '../../github/issue.js'; import { createPullRequest, pushBranch, buildPrBody } from '../../github/pr.js'; import { stageAndCommit } from '../../task/git.js'; -import { executeTask, type TaskExecutionOptions } from '../taskExecution.js'; -import { loadGlobalConfig } from '../../config/globalConfig.js'; +import { executeTask, type TaskExecutionOptions } from './taskExecution.js'; +import { loadGlobalConfig } from '../../config/global/globalConfig.js'; import { info, error, success, status, blankLine } from '../../utils/ui.js'; import { createLogger } from '../../utils/debug.js'; import { getErrorMessage } from '../../utils/error.js'; diff --git a/src/commands/execution/selectAndExecute.ts b/src/commands/execution/selectAndExecute.ts index 933c454..6128bb4 100644 --- a/src/commands/execution/selectAndExecute.ts +++ b/src/commands/execution/selectAndExecute.ts @@ -7,7 +7,7 @@ */ import { getCurrentWorkflow } from '../../config/paths.js'; -import { listWorkflows, isWorkflowPath } from '../../config/workflowLoader.js'; +import { listWorkflows, isWorkflowPath } from '../../config/loaders/workflowLoader.js'; import { selectOptionWithDefault, confirm } from '../../prompt/index.js'; import { createSharedClone } from '../../task/clone.js'; import { autoCommitAndPush } from '../../task/autoCommit.js'; @@ -16,8 +16,8 @@ import { DEFAULT_WORKFLOW_NAME } from '../../constants.js'; import { info, error, success } from '../../utils/ui.js'; import { createLogger } from '../../utils/debug.js'; import { createPullRequest, buildPrBody } from '../../github/pr.js'; -import { executeTask } from '../taskExecution.js'; -import type { TaskExecutionOptions } from '../taskExecution.js'; +import { executeTask } from './taskExecution.js'; +import type { TaskExecutionOptions } from './taskExecution.js'; const log = createLogger('selectAndExecute'); diff --git a/src/commands/execution/session.ts b/src/commands/execution/session.ts index b50e7f1..3719ee4 100644 --- a/src/commands/execution/session.ts +++ b/src/commands/execution/session.ts @@ -3,7 +3,7 @@ */ import { loadAgentSessions, updateAgentSession } from '../../config/paths.js'; -import { loadGlobalConfig } from '../../config/globalConfig.js'; +import { loadGlobalConfig } from '../../config/global/globalConfig.js'; import type { AgentResponse } from '../../models/types.js'; /** diff --git a/src/commands/execution/taskExecution.ts b/src/commands/execution/taskExecution.ts index 83bc00d..0aff497 100644 --- a/src/commands/execution/taskExecution.ts +++ b/src/commands/execution/taskExecution.ts @@ -17,7 +17,7 @@ import { } from '../../utils/ui.js'; import { createLogger } from '../../utils/debug.js'; import { getErrorMessage } from '../../utils/error.js'; -import { executeWorkflow } from '../workflowExecution.js'; +import { executeWorkflow } from './workflowExecution.js'; import { DEFAULT_WORKFLOW_NAME } from '../../constants.js'; import type { ProviderType } from '../../providers/index.js'; diff --git a/src/commands/execution/workflowExecution.ts b/src/commands/execution/workflowExecution.ts index ed2c921..d42fb4d 100644 --- a/src/commands/execution/workflowExecution.ts +++ b/src/commands/execution/workflowExecution.ts @@ -3,7 +3,7 @@ */ import { readFileSync } from 'node:fs'; -import { WorkflowEngine } from '../../workflow/engine.js'; +import { WorkflowEngine } from '../../workflow/engine/WorkflowEngine.js'; import type { WorkflowConfig, Language } from '../../models/types.js'; import type { IterationLimitRequest } from '../../workflow/types.js'; import type { ProviderType } from '../../providers/index.js'; @@ -13,7 +13,7 @@ import { loadWorktreeSessions, updateWorktreeSession, } from '../../config/paths.js'; -import { loadGlobalConfig } from '../../config/globalConfig.js'; +import { loadGlobalConfig } from '../../config/global/globalConfig.js'; import { isQuietMode } from '../../context.js'; import { header, diff --git a/src/commands/index.ts b/src/commands/index.ts index 2aab10b..bb41bc9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -2,20 +2,20 @@ * Command exports */ -export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './workflowExecution.js'; -export { executeTask, runAllTasks } from './taskExecution.js'; -export { addTask } from './addTask.js'; -export { ejectBuiltin } from './eject.js'; -export { watchTasks } from './watchTasks.js'; -export { withAgentSession } from './session.js'; -export { switchWorkflow } from './workflow.js'; -export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js'; -export { listTasks } from './listTasks.js'; -export { interactiveMode } from './interactive.js'; -export { executePipeline, type PipelineExecutionOptions } from './pipelineExecution.js'; +export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './execution/workflowExecution.js'; +export { executeTask, runAllTasks, type TaskExecutionOptions } from './execution/taskExecution.js'; +export { addTask } from './management/addTask.js'; +export { ejectBuiltin } from './management/eject.js'; +export { watchTasks } from './management/watchTasks.js'; +export { withAgentSession } from './execution/session.js'; +export { switchWorkflow } from './management/workflow.js'; +export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './management/config.js'; +export { listTasks } from './management/listTasks.js'; +export { interactiveMode } from './interactive/interactive.js'; +export { executePipeline, type PipelineExecutionOptions } from './execution/pipelineExecution.js'; export { selectAndExecuteTask, confirmAndCreateWorktree, type SelectAndExecuteOptions, type WorktreeConfirmationResult, -} from './selectAndExecute.js'; +} from './execution/selectAndExecute.js'; diff --git a/src/commands/interactive.ts b/src/commands/interactive.ts deleted file mode 100644 index 5284c94..0000000 --- a/src/commands/interactive.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export shim — actual implementation in interactive/interactive.ts */ -export { interactiveMode } from './interactive/interactive.js'; diff --git a/src/commands/interactive/interactive.ts b/src/commands/interactive/interactive.ts index 427628f..10c362b 100644 --- a/src/commands/interactive/interactive.ts +++ b/src/commands/interactive/interactive.ts @@ -12,7 +12,7 @@ import * as readline from 'node:readline'; import chalk from 'chalk'; -import { loadGlobalConfig } from '../../config/globalConfig.js'; +import { loadGlobalConfig } from '../../config/global/globalConfig.js'; import { isQuietMode } from '../../context.js'; import { loadAgentSessions, updateAgentSession } from '../../config/paths.js'; import { getProvider, type ProviderType } from '../../providers/index.js'; diff --git a/src/commands/listTasks.ts b/src/commands/listTasks.ts deleted file mode 100644 index ec21f42..0000000 --- a/src/commands/listTasks.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export shim — actual implementation in management/listTasks.ts */ -export { listTasks, isBranchMerged, showFullDiff, type ListAction } from './management/listTasks.js'; diff --git a/src/commands/management/addTask.ts b/src/commands/management/addTask.ts index a96f736..cc422d5 100644 --- a/src/commands/management/addTask.ts +++ b/src/commands/management/addTask.ts @@ -11,13 +11,13 @@ import { stringify as stringifyYaml } from 'yaml'; import { promptInput, confirm, selectOption } from '../../prompt/index.js'; import { success, info } from '../../utils/ui.js'; import { summarizeTaskName } from '../../task/summarize.js'; -import { loadGlobalConfig } from '../../config/globalConfig.js'; +import { loadGlobalConfig } from '../../config/global/globalConfig.js'; import { getProvider, type ProviderType } from '../../providers/index.js'; import { createLogger } from '../../utils/debug.js'; import { getErrorMessage } from '../../utils/error.js'; -import { listWorkflows } from '../../config/workflowLoader.js'; +import { listWorkflows } from '../../config/loaders/workflowLoader.js'; import { getCurrentWorkflow } from '../../config/paths.js'; -import { interactiveMode } from '../interactive.js'; +import { interactiveMode } from '../interactive/interactive.js'; import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../../github/issue.js'; import type { TaskFileData } from '../../task/schema.js'; diff --git a/src/commands/management/config.ts b/src/commands/management/config.ts index b7374aa..99dfd72 100644 --- a/src/commands/management/config.ts +++ b/src/commands/management/config.ts @@ -12,10 +12,10 @@ import { loadProjectConfig, updateProjectConfig, type PermissionMode, -} from '../../config/projectConfig.js'; +} from '../../config/project/projectConfig.js'; // Re-export for convenience -export type { PermissionMode } from '../../config/projectConfig.js'; +export type { PermissionMode } from '../../config/project/projectConfig.js'; /** * Get permission mode options for selection diff --git a/src/commands/management/eject.ts b/src/commands/management/eject.ts index 44b889f..0511882 100644 --- a/src/commands/management/eject.ts +++ b/src/commands/management/eject.ts @@ -8,7 +8,7 @@ import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { getGlobalWorkflowsDir, getGlobalAgentsDir, getBuiltinWorkflowsDir, getBuiltinAgentsDir } from '../../config/paths.js'; -import { getLanguage } from '../../config/globalConfig.js'; +import { getLanguage } from '../../config/global/globalConfig.js'; import { header, success, info, warn, error, blankLine } from '../../utils/ui.js'; /** diff --git a/src/commands/management/listTasks.ts b/src/commands/management/listTasks.ts index b962d6c..be2c81f 100644 --- a/src/commands/management/listTasks.ts +++ b/src/commands/management/listTasks.ts @@ -25,8 +25,8 @@ import { selectOption, confirm, promptInput } from '../../prompt/index.js'; import { info, success, error as logError, warn, header, blankLine } from '../../utils/ui.js'; import { createLogger } from '../../utils/debug.js'; import { getErrorMessage } from '../../utils/error.js'; -import { executeTask, type TaskExecutionOptions } from '../taskExecution.js'; -import { listWorkflows } from '../../config/workflowLoader.js'; +import { executeTask, type TaskExecutionOptions } from '../execution/taskExecution.js'; +import { listWorkflows } from '../../config/loaders/workflowLoader.js'; import { getCurrentWorkflow } from '../../config/paths.js'; import { DEFAULT_WORKFLOW_NAME } from '../../constants.js'; diff --git a/src/commands/management/watchTasks.ts b/src/commands/management/watchTasks.ts index 1d321d9..7399767 100644 --- a/src/commands/management/watchTasks.ts +++ b/src/commands/management/watchTasks.ts @@ -15,9 +15,9 @@ import { status, blankLine, } from '../../utils/ui.js'; -import { executeAndCompleteTask } from '../taskExecution.js'; +import { executeAndCompleteTask } from '../execution/taskExecution.js'; import { DEFAULT_WORKFLOW_NAME } from '../../constants.js'; -import type { TaskExecutionOptions } from '../taskExecution.js'; +import type { TaskExecutionOptions } from '../execution/taskExecution.js'; /** * Watch for tasks and execute them as they appear. diff --git a/src/commands/pipelineExecution.ts b/src/commands/pipelineExecution.ts deleted file mode 100644 index 71e3cc3..0000000 --- a/src/commands/pipelineExecution.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export shim — actual implementation in execution/pipelineExecution.ts */ -export { executePipeline, type PipelineExecutionOptions } from './execution/pipelineExecution.js'; diff --git a/src/commands/selectAndExecute.ts b/src/commands/selectAndExecute.ts deleted file mode 100644 index aaf2693..0000000 --- a/src/commands/selectAndExecute.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Re-export shim — actual implementation in execution/selectAndExecute.ts */ -export { - selectAndExecuteTask, - confirmAndCreateWorktree, - type SelectAndExecuteOptions, - type WorktreeConfirmationResult, -} from './execution/selectAndExecute.js'; diff --git a/src/commands/session.ts b/src/commands/session.ts deleted file mode 100644 index b7ae7a3..0000000 --- a/src/commands/session.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export shim — actual implementation in execution/session.ts */ -export { withAgentSession } from './execution/session.js'; diff --git a/src/commands/taskExecution.ts b/src/commands/taskExecution.ts deleted file mode 100644 index 27413f0..0000000 --- a/src/commands/taskExecution.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export shim — actual implementation in execution/taskExecution.ts */ -export { executeTask, runAllTasks, executeAndCompleteTask, resolveTaskExecution, type TaskExecutionOptions } from './execution/taskExecution.js'; diff --git a/src/commands/watchTasks.ts b/src/commands/watchTasks.ts deleted file mode 100644 index 38cc77c..0000000 --- a/src/commands/watchTasks.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export shim — actual implementation in management/watchTasks.ts */ -export { watchTasks } from './management/watchTasks.js'; diff --git a/src/commands/workflow.ts b/src/commands/workflow.ts deleted file mode 100644 index 9ba3b9e..0000000 --- a/src/commands/workflow.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export shim — actual implementation in management/workflow.ts */ -export { switchWorkflow } from './management/workflow.js'; diff --git a/src/commands/workflowExecution.ts b/src/commands/workflowExecution.ts deleted file mode 100644 index 5289523..0000000 --- a/src/commands/workflowExecution.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Re-export shim — actual implementation in execution/workflowExecution.ts */ -export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './execution/workflowExecution.js'; diff --git a/src/config/agentLoader.ts b/src/config/agentLoader.ts deleted file mode 100644 index b6eccb3..0000000 --- a/src/config/agentLoader.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Re-export shim — actual implementation in loaders/agentLoader.ts - */ -export { - loadAgentsFromDir, - loadCustomAgents, - listCustomAgents, - loadAgentPrompt, - loadAgentPromptFromPath, -} from './loaders/agentLoader.js'; diff --git a/src/config/global/initialization.ts b/src/config/global/initialization.ts index bb85828..d250305 100644 --- a/src/config/global/initialization.ts +++ b/src/config/global/initialization.ts @@ -18,7 +18,7 @@ import { ensureDir, } from '../paths.js'; import { copyProjectResourcesToDir, getLanguageResourcesDir } from '../../resources/index.js'; -import { setLanguage, setProvider } from '../globalConfig.js'; +import { setLanguage, setProvider } from './globalConfig.js'; /** * Check if initial setup is needed. diff --git a/src/config/globalConfig.ts b/src/config/globalConfig.ts deleted file mode 100644 index d48b54e..0000000 --- a/src/config/globalConfig.ts +++ /dev/null @@ -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'; diff --git a/src/config/index.ts b/src/config/index.ts index f7d488d..06829c1 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -3,5 +3,5 @@ */ export * from './paths.js'; -export * from './loader.js'; -export * from './initialization.js'; +export * from './loaders/loader.js'; +export * from './global/initialization.js'; diff --git a/src/config/initialization.ts b/src/config/initialization.ts deleted file mode 100644 index e936862..0000000 --- a/src/config/initialization.ts +++ /dev/null @@ -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'; diff --git a/src/config/loader.ts b/src/config/loader.ts deleted file mode 100644 index 544d9eb..0000000 --- a/src/config/loader.ts +++ /dev/null @@ -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'; diff --git a/src/config/loaders/agentLoader.ts b/src/config/loaders/agentLoader.ts index 939421e..726a1a9 100644 --- a/src/config/loaders/agentLoader.ts +++ b/src/config/loaders/agentLoader.ts @@ -16,7 +16,7 @@ import { getBuiltinWorkflowsDir, isPathSafe, } from '../paths.js'; -import { getLanguage } from '../globalConfig.js'; +import { getLanguage } from '../global/globalConfig.js'; /** Get all allowed base directories for agent prompt files */ function getAllowedAgentBases(): string[] { diff --git a/src/config/loaders/loader.ts b/src/config/loaders/loader.ts index 7c6bf65..c5566a5 100644 --- a/src/config/loaders/loader.ts +++ b/src/config/loaders/loader.ts @@ -12,7 +12,7 @@ export { isWorkflowPath, loadAllWorkflows, listWorkflows, -} from '../workflowLoader.js'; +} from './workflowLoader.js'; // Agent loading export { @@ -21,7 +21,7 @@ export { listCustomAgents, loadAgentPrompt, loadAgentPromptFromPath, -} from '../agentLoader.js'; +} from './agentLoader.js'; // Global configuration export { @@ -32,4 +32,4 @@ export { isDirectoryTrusted, loadProjectDebugConfig, getEffectiveDebugConfig, -} from '../globalConfig.js'; +} from '../global/globalConfig.js'; diff --git a/src/config/loaders/workflowLoader.ts b/src/config/loaders/workflowLoader.ts index 6e2dbed..0f9784e 100644 --- a/src/config/loaders/workflowLoader.ts +++ b/src/config/loaders/workflowLoader.ts @@ -15,7 +15,7 @@ import { parse as parseYaml } from 'yaml'; import { WorkflowConfigRawSchema } from '../../models/schemas.js'; import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../../models/types.js'; import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from '../paths.js'; -import { getLanguage, getDisabledBuiltins } from '../globalConfig.js'; +import { getLanguage, getDisabledBuiltins } from '../global/globalConfig.js'; /** Get builtin workflow by name */ export function getBuiltinWorkflow(name: string): WorkflowConfig | null { diff --git a/src/config/paths.ts b/src/config/paths.ts index 558595c..22fea78 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -94,7 +94,7 @@ export { setCurrentWorkflow, isVerboseMode, type ProjectLocalConfig, -} from './projectConfig.js'; +} from './project/projectConfig.js'; // Re-export session storage functions for backward compatibility export { @@ -116,4 +116,4 @@ export { getWorktreeSessionPath, loadWorktreeSessions, updateWorktreeSession, -} from './sessionStore.js'; +} from './project/sessionStore.js'; diff --git a/src/config/projectConfig.ts b/src/config/projectConfig.ts deleted file mode 100644 index 4170320..0000000 --- a/src/config/projectConfig.ts +++ /dev/null @@ -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'; diff --git a/src/config/sessionStore.ts b/src/config/sessionStore.ts deleted file mode 100644 index c7686ef..0000000 --- a/src/config/sessionStore.ts +++ /dev/null @@ -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'; diff --git a/src/config/workflowLoader.ts b/src/config/workflowLoader.ts deleted file mode 100644 index b8b012c..0000000 --- a/src/config/workflowLoader.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Re-export shim — actual implementation in loaders/workflowLoader.ts - */ -export { - getBuiltinWorkflow, - loadWorkflow, - loadWorkflowByIdentifier, - isWorkflowPath, - loadAllWorkflows, - listWorkflows, -} from './loaders/workflowLoader.js'; diff --git a/src/models/global-config.ts b/src/models/global-config.ts new file mode 100644 index 0000000..5e27666 --- /dev/null +++ b/src/models/global-config.ts @@ -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'; +} diff --git a/src/models/response.ts b/src/models/response.ts new file mode 100644 index 0000000..d29afff --- /dev/null +++ b/src/models/response.ts @@ -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; +} + diff --git a/src/models/session.ts b/src/models/session.ts index e0ea567..63e1a6c 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -2,7 +2,8 @@ * Session type definitions */ -import type { AgentResponse, Status } from './types.js'; +import type { AgentResponse } from './response.js'; +import type { Status } from './status.js'; /** * Session state for workflow execution diff --git a/src/models/status.ts b/src/models/status.ts new file mode 100644 index 0000000..687ecc5 --- /dev/null +++ b/src/models/status.ts @@ -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'; diff --git a/src/models/types.ts b/src/models/types.ts index da890ae..4622fc7 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -1,219 +1,45 @@ /** * Core type definitions for TAKT orchestration system + * + * This file re-exports all types from categorized sub-modules. + * Consumers import from './types.js' — no path changes needed. */ -/** Built-in agent types */ -export type AgentType = 'coder' | 'architect' | 'supervisor' | 'custom'; +// Status and classification types +export type { + AgentType, + Status, + RuleMatchMethod, + PermissionMode, +} from './status.js'; -/** Execution status for agents and workflows */ -export type Status = - | 'pending' - | 'done' - | 'blocked' - | 'approved' - | 'rejected' - | 'improve' - | 'cancelled' - | 'interrupted' - | 'answer'; +// Agent response +export type { + AgentResponse, +} from './response.js'; -/** How a rule match was detected */ -export type RuleMatchMethod = - | 'aggregate' - | 'phase3_tag' - | 'phase1_tag' - | 'ai_judge' - | 'ai_judge_fallback'; +// Session state (authoritative definition with createSessionState) +export type { + SessionState, +} from './session.js'; -/** Response from an agent execution */ -export interface AgentResponse { - agent: string; - status: Status; - content: string; - timestamp: Date; - sessionId?: string; - /** Error message when the query failed (e.g., API error, rate limit) */ - error?: string; - /** Matched rule index (0-based) when rules-based detection was used */ - matchedRuleIndex?: number; - /** How the rule match was detected */ - matchedRuleMethod?: RuleMatchMethod; -} +// Workflow configuration and runtime state +export type { + WorkflowRule, + ReportConfig, + ReportObjectConfig, + WorkflowStep, + LoopDetectionConfig, + WorkflowConfig, + WorkflowState, +} from './workflow-types.js'; -/** Session state for workflow execution */ -export interface SessionState { - task: string; - projectDir: string; - iterations: number; - history: AgentResponse[]; - context: Record; -} - -/** 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; - userInputs: string[]; - agentSessions: Map; - /** Per-step iteration counters (how many times each step has been executed) */ - stepIterations: Map; - status: 'running' | 'completed' | 'aborted'; -} - -/** Custom agent configuration */ -export interface CustomAgentConfig { - name: string; - promptFile?: string; - prompt?: string; - allowedTools?: string[]; - claudeAgent?: string; - claudeSkill?: string; - provider?: 'claude' | 'codex' | 'mock'; - model?: string; -} - -/** Debug configuration for takt */ -export interface DebugConfig { - enabled: boolean; - logFile?: string; -} - -/** Language setting for takt */ -export type Language = 'en' | 'ja'; - -/** Pipeline execution configuration */ -export interface PipelineConfig { - /** Branch name prefix for pipeline-created branches (default: "takt/") */ - defaultBranchPrefix?: string; - /** Commit message template. Variables: {title}, {issue} */ - commitMessageTemplate?: string; - /** PR body template. Variables: {issue_body}, {report}, {issue} */ - prBodyTemplate?: string; -} - -/** Global configuration for takt */ -export interface GlobalConfig { - language: Language; - trustedDirectories: string[]; - defaultWorkflow: string; - logLevel: 'debug' | 'info' | 'warn' | 'error'; - provider?: 'claude' | 'codex' | 'mock'; - model?: string; - debug?: DebugConfig; - /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ - worktreeDir?: string; - /** List of builtin workflow/agent names to exclude from fallback loading */ - disabledBuiltins?: string[]; - /** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */ - anthropicApiKey?: string; - /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ - openaiApiKey?: string; - /** Pipeline execution settings */ - pipeline?: PipelineConfig; - /** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */ - minimalOutput?: boolean; -} - -/** Project-level configuration */ -export interface ProjectConfig { - workflow?: string; - agents?: CustomAgentConfig[]; - provider?: 'claude' | 'codex' | 'mock'; -} +// Configuration types (global and project) +export type { + CustomAgentConfig, + DebugConfig, + Language, + PipelineConfig, + GlobalConfig, + ProjectConfig, +} from './global-config.js'; diff --git a/src/models/workflow-types.ts b/src/models/workflow-types.ts new file mode 100644 index 0000000..8e94b8c --- /dev/null +++ b/src/models/workflow-types.ts @@ -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; + userInputs: string[]; + agentSessions: Map; + /** Per-step iteration counters (how many times each step has been executed) */ + stepIterations: Map; + status: 'running' | 'completed' | 'aborted'; +} diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 8b7870f..96b0f4d 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -3,9 +3,9 @@ */ import { callClaude, callClaudeCustom, type ClaudeCallOptions } from '../claude/client.js'; -import { resolveAnthropicApiKey } from '../config/globalConfig.js'; +import { resolveAnthropicApiKey } from '../config/global/globalConfig.js'; import type { AgentResponse } from '../models/types.js'; -import type { Provider, ProviderCallOptions } from './index.js'; +import type { Provider, ProviderCallOptions } from './types.js'; /** Claude provider - wraps existing Claude client */ export class ClaudeProvider implements Provider { diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 3e7a9db..3913f6d 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -3,9 +3,9 @@ */ import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/client.js'; -import { resolveOpenaiApiKey } from '../config/globalConfig.js'; +import { resolveOpenaiApiKey } from '../config/global/globalConfig.js'; import type { AgentResponse } from '../models/types.js'; -import type { Provider, ProviderCallOptions } from './index.js'; +import type { Provider, ProviderCallOptions } from './types.js'; /** Codex provider - wraps existing Codex client */ export class CodexProvider implements Provider { diff --git a/src/providers/index.ts b/src/providers/index.ts index 7c60484..ef4c52e 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -5,66 +5,56 @@ * This enables adding new providers without modifying the runner logic. */ -import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/process.js'; -import type { AgentResponse, PermissionMode } from '../models/types.js'; import { ClaudeProvider } from './claude.js'; import { CodexProvider } from './codex.js'; import { MockProvider } from './mock.js'; +import type { Provider, ProviderType } from './types.js'; -/** Common options for all providers */ -export interface ProviderCallOptions { - cwd: string; - sessionId?: string; - model?: string; - systemPrompt?: string; - allowedTools?: string[]; - /** Maximum number of agentic turns */ - maxTurns?: number; - /** Permission mode for tool execution (from workflow step) */ - permissionMode?: PermissionMode; - onStream?: StreamCallback; - onPermissionRequest?: PermissionHandler; - onAskUserQuestion?: AskUserQuestionHandler; - bypassPermissions?: boolean; - /** Anthropic API key for Claude provider */ - anthropicApiKey?: string; - /** OpenAI API key for Codex provider */ - openaiApiKey?: string; -} - -/** Provider interface - all providers must implement this */ -export interface Provider { - /** Call the provider with a prompt (using systemPrompt from options if provided) */ - call(agentName: string, prompt: string, options: ProviderCallOptions): Promise; - - /** Call the provider with explicit system prompt */ - callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise; -} - -/** Provider type */ -export type ProviderType = 'claude' | 'codex' | 'mock'; - -/** Provider registry */ -const providers: Record = { - claude: new ClaudeProvider(), - codex: new CodexProvider(), - mock: new MockProvider(), -}; +// Re-export types for backward compatibility +export type { ProviderCallOptions, Provider, ProviderType } from './types.js'; /** - * Get a provider instance by type + * Registry for agent providers. + * Singleton — use ProviderRegistry.getInstance(). */ -export function getProvider(type: ProviderType): Provider { - const provider = providers[type]; - if (!provider) { - throw new Error(`Unknown provider type: ${type}`); +export class ProviderRegistry { + private static instance: ProviderRegistry | null = null; + private readonly providers: Record; + + private constructor() { + this.providers = { + claude: new ClaudeProvider(), + codex: new CodexProvider(), + mock: new MockProvider(), + }; } - return provider; + + static getInstance(): ProviderRegistry { + if (!ProviderRegistry.instance) { + ProviderRegistry.instance = new ProviderRegistry(); + } + return ProviderRegistry.instance; + } + + /** Reset singleton for testing */ + static resetInstance(): void { + ProviderRegistry.instance = null; + } + + /** Get a provider instance by type */ + get(type: ProviderType): Provider { + const provider = this.providers[type]; + if (!provider) { + throw new Error(`Unknown provider type: ${type}`); + } + return provider; + } + } -/** - * Register a custom provider - */ -export function registerProvider(type: string, provider: Provider): void { - (providers as Record)[type] = provider; +// ---- Backward-compatible module-level functions ---- + +export function getProvider(type: ProviderType): Provider { + return ProviderRegistry.getInstance().get(type); } + diff --git a/src/providers/mock.ts b/src/providers/mock.ts index d046988..f99d699 100644 --- a/src/providers/mock.ts +++ b/src/providers/mock.ts @@ -4,7 +4,7 @@ import { callMock, callMockCustom, type MockCallOptions } from '../mock/client.js'; import type { AgentResponse } from '../models/types.js'; -import type { Provider, ProviderCallOptions } from './index.js'; +import type { Provider, ProviderCallOptions } from './types.js'; /** Mock provider - wraps existing Mock client */ export class MockProvider implements Provider { diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..612d880 --- /dev/null +++ b/src/providers/types.ts @@ -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; + + /** Call the provider with explicit system prompt */ + callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise; +} + +/** Provider type */ +export type ProviderType = 'claude' | 'codex' | 'mock'; diff --git a/src/task/autoCommit.ts b/src/task/autoCommit.ts index 810788c..8a9d640 100644 --- a/src/task/autoCommit.ts +++ b/src/task/autoCommit.ts @@ -24,52 +24,60 @@ export interface AutoCommitResult { } /** - * Auto-commit all changes and push to origin. - * - * Steps: - * 1. Stage all changes (git add -A) - * 2. Check if there are staged changes (git status --porcelain) - * 3. If changes exist, create a commit with "takt: {taskName}" - * 4. Push to origin (git push origin HEAD) - * - * @param cloneCwd - The clone directory - * @param taskName - Task name used in commit message - * @param projectDir - The main project directory (push target) + * Handles auto-commit and push operations for clone tasks. */ -export function autoCommitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult { - log.info('Auto-commit starting', { cwd: cloneCwd, taskName }); +export class AutoCommitter { + /** + * Auto-commit all changes and push to the main project. + * + * Steps: + * 1. Stage all changes (git add -A) + * 2. Check if there are staged changes + * 3. If changes exist, create a commit with "takt: {taskName}" + * 4. Push to the main project directory + */ + commitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult { + log.info('Auto-commit starting', { cwd: cloneCwd, taskName }); - try { - const commitMessage = `takt: ${taskName}`; - const commitHash = stageAndCommit(cloneCwd, commitMessage); + try { + const commitMessage = `takt: ${taskName}`; + const commitHash = stageAndCommit(cloneCwd, commitMessage); - if (!commitHash) { - log.info('No changes to commit'); - return { success: true, message: 'No changes to commit' }; + if (!commitHash) { + log.info('No changes to commit'); + return { success: true, message: 'No changes to commit' }; + } + + log.info('Auto-commit created', { commitHash, message: commitMessage }); + + execFileSync('git', ['push', projectDir, 'HEAD'], { + cwd: cloneCwd, + stdio: 'pipe', + }); + + log.info('Pushed to main repo', { projectDir }); + + return { + success: true, + commitHash, + message: `Committed & pushed: ${commitHash} - ${commitMessage}`, + }; + } catch (err) { + const errorMessage = getErrorMessage(err); + log.error('Auto-commit failed', { error: errorMessage }); + + return { + success: false, + message: `Auto-commit failed: ${errorMessage}`, + }; } - - log.info('Auto-commit created', { commitHash, message: commitMessage }); - - // Push directly to the main repo (origin was removed to isolate the clone) - execFileSync('git', ['push', projectDir, 'HEAD'], { - cwd: cloneCwd, - stdio: 'pipe', - }); - - log.info('Pushed to main repo', { projectDir }); - - return { - success: true, - commitHash, - message: `Committed & pushed: ${commitHash} - ${commitMessage}`, - }; - } catch (err) { - const errorMessage = getErrorMessage(err); - log.error('Auto-commit failed', { error: errorMessage }); - - return { - success: false, - message: `Auto-commit failed: ${errorMessage}`, - }; } } + +// ---- Backward-compatible module-level function ---- + +const defaultCommitter = new AutoCommitter(); + +export function autoCommitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult { + return defaultCommitter.commitAndPush(cloneCwd, taskName, projectDir); +} diff --git a/src/task/branchList.ts b/src/task/branchList.ts index 1d62982..babbeab 100644 --- a/src/task/branchList.ts +++ b/src/task/branchList.ts @@ -1,7 +1,7 @@ /** * Branch list helpers * - * Functions for listing, parsing, and enriching takt-managed branches + * Listing, parsing, and enriching takt-managed branches * with metadata (diff stats, original instruction, task slug). * Used by the /list command. */ @@ -29,147 +29,171 @@ export interface BranchListItem { const TAKT_BRANCH_PREFIX = 'takt/'; /** - * Detect the default branch name (main or master). - * Checks local branch refs directly. Falls back to 'main'. + * Manages takt branch listing and metadata enrichment. */ -export function detectDefaultBranch(cwd: string): string { - try { - const ref = execFileSync( - 'git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], - { cwd, encoding: 'utf-8', stdio: 'pipe' }, - ).trim(); - const parts = ref.split('/'); - return parts[parts.length - 1] || 'main'; - } catch { +export class BranchManager { + /** Detect the default branch name (main or master) */ + detectDefaultBranch(cwd: string): string { try { - execFileSync('git', ['rev-parse', '--verify', 'main'], { - cwd, encoding: 'utf-8', stdio: 'pipe', - }); - return 'main'; + const ref = execFileSync( + 'git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], + { cwd, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + const parts = ref.split('/'); + return parts[parts.length - 1] || 'main'; } catch { try { - execFileSync('git', ['rev-parse', '--verify', 'master'], { + execFileSync('git', ['rev-parse', '--verify', 'main'], { cwd, encoding: 'utf-8', stdio: 'pipe', }); - return 'master'; - } catch { return 'main'; + } catch { + try { + execFileSync('git', ['rev-parse', '--verify', 'master'], { + cwd, encoding: 'utf-8', stdio: 'pipe', + }); + return 'master'; + } catch { + return 'main'; + } } } } + + /** List all takt-managed branches */ + listTaktBranches(projectDir: string): BranchInfo[] { + try { + const output = execFileSync( + 'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, + ); + return BranchManager.parseTaktBranches(output); + } catch (err) { + log.error('Failed to list takt branches', { error: String(err) }); + return []; + } + } + + /** Parse `git branch --list` formatted output into BranchInfo entries */ + static parseTaktBranches(output: string): BranchInfo[] { + const entries: BranchInfo[] = []; + const lines = output.trim().split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const spaceIdx = trimmed.lastIndexOf(' '); + if (spaceIdx === -1) continue; + + const branch = trimmed.slice(0, spaceIdx); + const commit = trimmed.slice(spaceIdx + 1); + + if (branch.startsWith(TAKT_BRANCH_PREFIX)) { + entries.push({ branch, commit }); + } + } + + return entries; + } + + /** Get the number of files changed between the default branch and a given branch */ + getFilesChanged(cwd: string, defaultBranch: string, branch: string): number { + try { + const output = execFileSync( + 'git', ['diff', '--numstat', `${defaultBranch}...${branch}`], + { cwd, encoding: 'utf-8', stdio: 'pipe' }, + ); + return output.trim().split('\n').filter(l => l.length > 0).length; + } catch { + return 0; + } + } + + /** Extract a human-readable task slug from a takt branch name */ + static extractTaskSlug(branch: string): string { + const name = branch.replace(TAKT_BRANCH_PREFIX, ''); + const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, ''); + return withoutTimestamp || name; + } + + /** + * Extract the original task instruction from the first commit message on a branch. + * The first commit on a takt branch has the format: "takt: {original instruction}". + */ + getOriginalInstruction( + cwd: string, + defaultBranch: string, + branch: string, + ): string { + try { + const output = execFileSync( + 'git', + ['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`], + { cwd, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + + if (!output) return ''; + + const firstLine = output.split('\n')[0] || ''; + const TAKT_COMMIT_PREFIX = 'takt:'; + if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) { + return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim(); + } + + return firstLine; + } catch { + return ''; + } + } + + /** Build list items from branch list, enriching with diff stats */ + buildListItems( + projectDir: string, + branches: BranchInfo[], + defaultBranch: string, + ): BranchListItem[] { + return branches.map(br => ({ + info: br, + filesChanged: this.getFilesChanged(projectDir, defaultBranch, br.branch), + taskSlug: BranchManager.extractTaskSlug(br.branch), + originalInstruction: this.getOriginalInstruction(projectDir, defaultBranch, br.branch), + })); + } +} + +// ---- Backward-compatible module-level functions ---- + +const defaultManager = new BranchManager(); + +export function detectDefaultBranch(cwd: string): string { + return defaultManager.detectDefaultBranch(cwd); } -/** - * List all takt-managed branches. - */ export function listTaktBranches(projectDir: string): BranchInfo[] { - try { - const output = execFileSync( - 'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'], - { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, - ); - return parseTaktBranches(output); - } catch (err) { - log.error('Failed to list takt branches', { error: String(err) }); - return []; - } + return defaultManager.listTaktBranches(projectDir); } -/** - * Parse `git branch --list` formatted output into BranchInfo entries. - */ export function parseTaktBranches(output: string): BranchInfo[] { - const entries: BranchInfo[] = []; - const lines = output.trim().split('\n'); - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - const spaceIdx = trimmed.lastIndexOf(' '); - if (spaceIdx === -1) continue; - - const branch = trimmed.slice(0, spaceIdx); - const commit = trimmed.slice(spaceIdx + 1); - - if (branch.startsWith(TAKT_BRANCH_PREFIX)) { - entries.push({ branch, commit }); - } - } - - return entries; + return BranchManager.parseTaktBranches(output); } -/** - * Get the number of files changed between the default branch and a given branch. - */ export function getFilesChanged(cwd: string, defaultBranch: string, branch: string): number { - try { - const output = execFileSync( - 'git', ['diff', '--numstat', `${defaultBranch}...${branch}`], - { cwd, encoding: 'utf-8', stdio: 'pipe' }, - ); - return output.trim().split('\n').filter(l => l.length > 0).length; - } catch { - return 0; - } + return defaultManager.getFilesChanged(cwd, defaultBranch, branch); } -/** - * Extract a human-readable task slug from a takt branch name. - * e.g. "takt/20260128T032800-fix-auth" -> "fix-auth" - */ export function extractTaskSlug(branch: string): string { - const name = branch.replace(TAKT_BRANCH_PREFIX, ''); - const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, ''); - return withoutTimestamp || name; + return BranchManager.extractTaskSlug(branch); } -/** - * Extract the original task instruction from the first commit message on a branch. - * - * The first commit on a takt branch has the format: "takt: {original instruction}". - * Strips the "takt: " prefix and returns the instruction text. - * Returns empty string if extraction fails. - */ -export function getOriginalInstruction( - cwd: string, - defaultBranch: string, - branch: string, -): string { - try { - const output = execFileSync( - 'git', - ['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`], - { cwd, encoding: 'utf-8', stdio: 'pipe' }, - ).trim(); - - if (!output) return ''; - - const firstLine = output.split('\n')[0] || ''; - const TAKT_COMMIT_PREFIX = 'takt:'; - if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) { - return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim(); - } - - return firstLine; - } catch { - return ''; - } +export function getOriginalInstruction(cwd: string, defaultBranch: string, branch: string): string { + return defaultManager.getOriginalInstruction(cwd, defaultBranch, branch); } -/** - * Build list items from branch list, enriching with diff stats. - */ export function buildListItems( projectDir: string, branches: BranchInfo[], defaultBranch: string, ): BranchListItem[] { - return branches.map(br => ({ - info: br, - filesChanged: getFilesChanged(projectDir, defaultBranch, br.branch), - taskSlug: extractTaskSlug(br.branch), - originalInstruction: getOriginalInstruction(projectDir, defaultBranch, br.branch), - })); + return defaultManager.buildListItems(projectDir, branches, defaultBranch); } diff --git a/src/task/clone.ts b/src/task/clone.ts index c4dac85..fa4da70 100644 --- a/src/task/clone.ts +++ b/src/task/clone.ts @@ -12,7 +12,7 @@ import * as path from 'node:path'; import { execFileSync } from 'node:child_process'; import { createLogger } from '../utils/debug.js'; import { slugify } from '../utils/slug.js'; -import { loadGlobalConfig } from '../config/globalConfig.js'; +import { loadGlobalConfig } from '../config/global/globalConfig.js'; const log = createLogger('clone'); @@ -34,234 +34,231 @@ export interface WorktreeResult { branch: string; } -function generateTimestamp(): string { - return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13); -} +const CLONE_META_DIR = 'clone-meta'; /** - * Resolve the base directory for clones from global config. - * Returns the configured worktree_dir (resolved to absolute), or ../ - */ -function resolveCloneBaseDir(projectDir: string): string { - const globalConfig = loadGlobalConfig(); - if (globalConfig.worktreeDir) { - return path.isAbsolute(globalConfig.worktreeDir) - ? globalConfig.worktreeDir - : path.resolve(projectDir, globalConfig.worktreeDir); - } - return path.join(projectDir, '..'); -} - -/** - * Resolve the clone path based on options and global config. + * Manages git clone lifecycle for task isolation. * - * Priority: - * 1. Custom path in options.worktree (string) - * 2. worktree_dir from config.yaml (if set) - * 3. Default: ../{dir-name} - * - * Format with issue: {timestamp}-{issue}-{slug} - * Format without issue: {timestamp}-{slug} + * Handles creation, removal, and metadata tracking of clones + * used for parallel task execution. */ -function resolveClonePath(projectDir: string, options: WorktreeOptions): string { - const timestamp = generateTimestamp(); - const slug = slugify(options.taskSlug); - - let dirName: string; - if (options.issueNumber !== undefined && slug) { - dirName = `${timestamp}-${options.issueNumber}-${slug}`; - } else if (slug) { - dirName = `${timestamp}-${slug}`; - } else { - dirName = timestamp; +export class CloneManager { + private static generateTimestamp(): string { + return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13); } - if (typeof options.worktree === 'string') { - return path.isAbsolute(options.worktree) - ? options.worktree - : path.resolve(projectDir, options.worktree); + /** + * Resolve the base directory for clones from global config. + * Returns the configured worktree_dir (resolved to absolute), or ../ + */ + private static resolveCloneBaseDir(projectDir: string): string { + const globalConfig = loadGlobalConfig(); + if (globalConfig.worktreeDir) { + return path.isAbsolute(globalConfig.worktreeDir) + ? globalConfig.worktreeDir + : path.resolve(projectDir, globalConfig.worktreeDir); + } + return path.join(projectDir, '..'); } - return path.join(resolveCloneBaseDir(projectDir), dirName); -} + /** Resolve the clone path based on options and global config */ + private static resolveClonePath(projectDir: string, options: WorktreeOptions): string { + const timestamp = CloneManager.generateTimestamp(); + const slug = slugify(options.taskSlug); -/** - * Resolve branch name from options. - * - * Format with issue: takt/#{issue}/{slug} - * Format without issue: takt/{timestamp}-{slug} - * Custom branch: use as-is - */ -function resolveBranchName(options: WorktreeOptions): string { - if (options.branch) { - return options.branch; + let dirName: string; + if (options.issueNumber !== undefined && slug) { + dirName = `${timestamp}-${options.issueNumber}-${slug}`; + } else if (slug) { + dirName = `${timestamp}-${slug}`; + } else { + dirName = timestamp; + } + + if (typeof options.worktree === 'string') { + return path.isAbsolute(options.worktree) + ? options.worktree + : path.resolve(projectDir, options.worktree); + } + + return path.join(CloneManager.resolveCloneBaseDir(projectDir), dirName); } - const slug = slugify(options.taskSlug); + /** Resolve branch name from options */ + private static resolveBranchName(options: WorktreeOptions): string { + if (options.branch) { + return options.branch; + } - if (options.issueNumber !== undefined && slug) { - return `takt/#${options.issueNumber}/${slug}`; + const slug = slugify(options.taskSlug); + + if (options.issueNumber !== undefined && slug) { + return `takt/#${options.issueNumber}/${slug}`; + } + + const timestamp = CloneManager.generateTimestamp(); + return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`; } - const timestamp = generateTimestamp(); - return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`; -} + private static branchExists(projectDir: string, branch: string): boolean { + try { + execFileSync('git', ['rev-parse', '--verify', branch], { + cwd: projectDir, + stdio: 'pipe', + }); + return true; + } catch { + return false; + } + } -function branchExists(projectDir: string, branch: string): boolean { - try { - execFileSync('git', ['rev-parse', '--verify', branch], { + /** Clone a repository and remove origin to isolate from the main repo */ + private static cloneAndIsolate(projectDir: string, clonePath: string): void { + fs.mkdirSync(path.dirname(clonePath), { recursive: true }); + + execFileSync('git', ['clone', '--reference', projectDir, '--dissociate', projectDir, clonePath], { cwd: projectDir, stdio: 'pipe', }); - return true; - } catch { - return false; - } -} -/** - * Clone a repository and remove origin to isolate from the main repo. - */ -function cloneAndIsolate(projectDir: string, clonePath: string): void { - fs.mkdirSync(path.dirname(clonePath), { recursive: true }); + execFileSync('git', ['remote', 'remove', 'origin'], { + cwd: clonePath, + stdio: 'pipe', + }); - execFileSync('git', ['clone', '--reference', projectDir, '--dissociate', projectDir, clonePath], { - cwd: projectDir, - stdio: 'pipe', - }); - - execFileSync('git', ['remote', 'remove', 'origin'], { - cwd: clonePath, - stdio: 'pipe', - }); - - // Propagate local git user config from source repo to clone - for (const key of ['user.name', 'user.email']) { - try { - const value = execFileSync('git', ['config', '--local', key], { - cwd: projectDir, - stdio: 'pipe', - }).toString().trim(); - if (value) { - execFileSync('git', ['config', key, value], { - cwd: clonePath, + // Propagate local git user config from source repo to clone + for (const key of ['user.name', 'user.email']) { + try { + const value = execFileSync('git', ['config', '--local', key], { + cwd: projectDir, stdio: 'pipe', - }); + }).toString().trim(); + if (value) { + execFileSync('git', ['config', key, value], { + cwd: clonePath, + stdio: 'pipe', + }); + } + } catch { + // not set locally — skip + } + } + } + + private static encodeBranchName(branch: string): string { + return branch.replace(/\//g, '--'); + } + + private static getCloneMetaPath(projectDir: string, branch: string): string { + return path.join(projectDir, '.takt', CLONE_META_DIR, `${CloneManager.encodeBranchName(branch)}.json`); + } + + /** Create a git clone for a task */ + createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult { + const clonePath = CloneManager.resolveClonePath(projectDir, options); + const branch = CloneManager.resolveBranchName(options); + + log.info('Creating shared clone', { path: clonePath, branch }); + + CloneManager.cloneAndIsolate(projectDir, clonePath); + + if (CloneManager.branchExists(clonePath, branch)) { + execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' }); + } else { + execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' }); + } + + this.saveCloneMeta(projectDir, branch, clonePath); + log.info('Clone created', { path: clonePath, branch }); + + return { path: clonePath, branch }; + } + + /** Create a temporary clone for an existing branch */ + createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult { + const timestamp = CloneManager.generateTimestamp(); + const clonePath = path.join(CloneManager.resolveCloneBaseDir(projectDir), `tmp-${timestamp}`); + + log.info('Creating temp clone for branch', { path: clonePath, branch }); + + CloneManager.cloneAndIsolate(projectDir, clonePath); + execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' }); + + this.saveCloneMeta(projectDir, branch, clonePath); + log.info('Temp clone created', { path: clonePath, branch }); + + return { path: clonePath, branch }; + } + + /** Remove a clone directory */ + removeClone(clonePath: string): void { + log.info('Removing clone', { path: clonePath }); + try { + fs.rmSync(clonePath, { recursive: true, force: true }); + log.info('Clone removed', { path: clonePath }); + } catch (err) { + log.error('Failed to remove clone', { path: clonePath, error: String(err) }); + } + } + + /** Save clone metadata (branch → clonePath mapping) */ + saveCloneMeta(projectDir: string, branch: string, clonePath: string): void { + const filePath = CloneManager.getCloneMetaPath(projectDir, branch); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath })); + log.info('Clone meta saved', { branch, clonePath }); + } + + /** Remove clone metadata for a branch */ + removeCloneMeta(projectDir: string, branch: string): void { + try { + fs.unlinkSync(CloneManager.getCloneMetaPath(projectDir, branch)); + log.info('Clone meta removed', { branch }); + } catch { + // File may not exist — ignore + } + } + + /** Clean up an orphaned clone directory associated with a branch */ + cleanupOrphanedClone(projectDir: string, branch: string): void { + try { + const raw = fs.readFileSync(CloneManager.getCloneMetaPath(projectDir, branch), 'utf-8'); + const meta = JSON.parse(raw) as { clonePath: string }; + if (fs.existsSync(meta.clonePath)) { + this.removeClone(meta.clonePath); + log.info('Orphaned clone cleaned up', { branch, clonePath: meta.clonePath }); } } catch { - // not set locally — skip + // No metadata or parse error — nothing to clean up } + this.removeCloneMeta(projectDir, branch); } } -/** - * Create a git clone for a task. - * - * Uses `git clone --reference --dissociate` to create an independent clone, - * then removes origin and checks out a new branch. - */ +// ---- Backward-compatible module-level functions ---- + +const defaultManager = new CloneManager(); + export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult { - const clonePath = resolveClonePath(projectDir, options); - const branch = resolveBranchName(options); - - log.info('Creating shared clone', { path: clonePath, branch }); - - cloneAndIsolate(projectDir, clonePath); - - if (branchExists(clonePath, branch)) { - execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' }); - } else { - execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' }); - } - - saveCloneMeta(projectDir, branch, clonePath); - log.info('Clone created', { path: clonePath, branch }); - - return { path: clonePath, branch }; + return defaultManager.createSharedClone(projectDir, options); } -/** - * Create a temporary clone for an existing branch. - * Used by review/instruct to work on a branch that was previously pushed. - */ export function createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult { - const timestamp = generateTimestamp(); - const clonePath = path.join(resolveCloneBaseDir(projectDir), `tmp-${timestamp}`); - - log.info('Creating temp clone for branch', { path: clonePath, branch }); - - cloneAndIsolate(projectDir, clonePath); - - execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' }); - - saveCloneMeta(projectDir, branch, clonePath); - log.info('Temp clone created', { path: clonePath, branch }); - - return { path: clonePath, branch }; + return defaultManager.createTempCloneForBranch(projectDir, branch); } -/** - * Remove a clone directory. - */ export function removeClone(clonePath: string): void { - log.info('Removing clone', { path: clonePath }); - try { - fs.rmSync(clonePath, { recursive: true, force: true }); - log.info('Clone removed', { path: clonePath }); - } catch (err) { - log.error('Failed to remove clone', { path: clonePath, error: String(err) }); - } + defaultManager.removeClone(clonePath); } -// --- Clone metadata --- - -const CLONE_META_DIR = 'clone-meta'; - -function encodeBranchName(branch: string): string { - return branch.replace(/\//g, '--'); -} - -function getCloneMetaPath(projectDir: string, branch: string): string { - return path.join(projectDir, '.takt', CLONE_META_DIR, `${encodeBranchName(branch)}.json`); -} - -/** - * Save clone metadata (branch → clonePath mapping). - * Used to clean up orphaned clone directories on merge/delete. - */ export function saveCloneMeta(projectDir: string, branch: string, clonePath: string): void { - const filePath = getCloneMetaPath(projectDir, branch); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath })); - log.info('Clone meta saved', { branch, clonePath }); + defaultManager.saveCloneMeta(projectDir, branch, clonePath); } -/** - * Remove clone metadata for a branch. - */ export function removeCloneMeta(projectDir: string, branch: string): void { - try { - fs.unlinkSync(getCloneMetaPath(projectDir, branch)); - log.info('Clone meta removed', { branch }); - } catch { - // File may not exist — ignore - } + defaultManager.removeCloneMeta(projectDir, branch); } -/** - * Clean up an orphaned clone directory associated with a branch. - * Reads metadata, removes clone directory if it still exists, then removes metadata. - */ export function cleanupOrphanedClone(projectDir: string, branch: string): void { - try { - const raw = fs.readFileSync(getCloneMetaPath(projectDir, branch), 'utf-8'); - const meta = JSON.parse(raw) as { clonePath: string }; - if (fs.existsSync(meta.clonePath)) { - removeClone(meta.clonePath); - log.info('Orphaned clone cleaned up', { branch, clonePath: meta.clonePath }); - } - } catch { - // No metadata or parse error — nothing to clean up - } - removeCloneMeta(projectDir, branch); + defaultManager.cleanupOrphanedClone(projectDir, branch); } diff --git a/src/task/index.ts b/src/task/index.ts index 5e8a846..5188dac 100644 --- a/src/task/index.ts +++ b/src/task/index.ts @@ -2,6 +2,12 @@ * Task execution module */ +// Classes +export { CloneManager } from './clone.js'; +export { AutoCommitter } from './autoCommit.js'; +export { TaskSummarizer } from './summarize.js'; +export { BranchManager } from './branchList.js'; + export { TaskRunner, type TaskInfo, @@ -34,4 +40,5 @@ export { type BranchListItem, } from './branchList.js'; export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js'; +export { summarizeTaskName, type SummarizeOptions } from './summarize.js'; export { TaskWatcher, type TaskWatcherOptions } from './watcher.js'; diff --git a/src/task/summarize.ts b/src/task/summarize.ts index 76aac60..b1bc918 100644 --- a/src/task/summarize.ts +++ b/src/task/summarize.ts @@ -5,31 +5,12 @@ */ import * as wanakana from 'wanakana'; -import { loadGlobalConfig } from '../config/globalConfig.js'; +import { loadGlobalConfig } from '../config/global/globalConfig.js'; import { getProvider, type ProviderType } from '../providers/index.js'; import { createLogger } from '../utils/debug.js'; const log = createLogger('summarize'); -/** - * Sanitize a string for use as git branch name and directory name. - * - * Git branch restrictions: no spaces, ~, ^, :, ?, *, [, \, .., @{, leading - - * Directory restrictions: no /, \, :, *, ?, ", <, >, | - * - * This function allows only: a-z, 0-9, hyphen - */ -function sanitizeSlug(input: string, maxLength = 30): string { - return input - .trim() - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-') // Replace invalid chars with hyphen - .replace(/-+/g, '-') // Collapse multiple hyphens - .replace(/^-+/, '') // Remove leading hyphens - .slice(0, maxLength) - .replace(/-+$/, ''); // Remove trailing hyphens (after slice) -} - const SUMMARIZE_SYSTEM_PROMPT = `You are a slug generator. Given a task description, output ONLY a slug. NEVER output sentences. NEVER start with "this", "the", "i", "we", or "it". @@ -53,53 +34,79 @@ export interface SummarizeOptions { useLLM?: boolean; } +/** + * Sanitize a string for use as git branch name and directory name. + * Allows only: a-z, 0-9, hyphen. + */ +function sanitizeSlug(input: string, maxLength = 30): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+/, '') + .slice(0, maxLength) + .replace(/-+$/, ''); +} + /** * Convert Japanese text to romaji slug. - * Handles hiragana, katakana, and passes through alphanumeric. - * Kanji and other non-convertible characters become hyphens. */ function toRomajiSlug(text: string): string { - // Convert to romaji (hiragana/katakana → romaji, kanji stays as-is) const romaji = wanakana.toRomaji(text, { customRomajiMapping: {} }); return sanitizeSlug(romaji); } /** - * Summarize a task name into a concise slug using AI or romanization. - * - * @param taskName - Original task name (can be in any language) - * @param options - Summarization options - * @returns Slug suitable for branch names (English if LLM, romaji if not) + * Summarizes task names into concise slugs using AI or romanization. */ -export async function summarizeTaskName( - taskName: string, - options: SummarizeOptions -): Promise { - const useLLM = options.useLLM ?? true; - log.info('Summarizing task name', { taskName, useLLM }); +export class TaskSummarizer { + /** + * Summarize a task name into a concise slug. + * + * @param taskName - Original task name (can be in any language) + * @param options - Summarization options + * @returns Slug suitable for branch names (English if LLM, romaji if not) + */ + async summarize( + taskName: string, + options: SummarizeOptions, + ): Promise { + const useLLM = options.useLLM ?? true; + log.info('Summarizing task name', { taskName, useLLM }); + + if (!useLLM) { + const slug = toRomajiSlug(taskName); + log.info('Task name romanized', { original: taskName, slug }); + return slug || 'task'; + } + + const globalConfig = loadGlobalConfig(); + const providerType = (globalConfig.provider as ProviderType) ?? 'claude'; + const model = options.model ?? globalConfig.model ?? 'haiku'; + + const provider = getProvider(providerType); + const response = await provider.call('summarizer', taskName, { + cwd: options.cwd, + model, + systemPrompt: SUMMARIZE_SYSTEM_PROMPT, + allowedTools: [], + }); + + const slug = sanitizeSlug(response.content); + log.info('Task name summarized', { original: taskName, slug }); - // Use romanization if LLM is disabled - if (!useLLM) { - const slug = toRomajiSlug(taskName); - log.info('Task name romanized', { original: taskName, slug }); return slug || 'task'; } - - // Use LLM for summarization - const globalConfig = loadGlobalConfig(); - const providerType = (globalConfig.provider as ProviderType) ?? 'claude'; - const model = options.model ?? globalConfig.model ?? 'haiku'; - - const provider = getProvider(providerType); - const response = await provider.call('summarizer', taskName, { - cwd: options.cwd, - model, - systemPrompt: SUMMARIZE_SYSTEM_PROMPT, - allowedTools: [], - }); - - const slug = sanitizeSlug(response.content); - log.info('Task name summarized', { original: taskName, slug }); - - return slug || 'task'; +} + +// ---- Backward-compatible module-level function ---- + +const defaultSummarizer = new TaskSummarizer(); + +export async function summarizeTaskName( + taskName: string, + options: SummarizeOptions, +): Promise { + return defaultSummarizer.summarize(taskName, options); } diff --git a/src/utils/debug.ts b/src/utils/debug.ts index b9c7950..bd15245 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -8,163 +8,205 @@ import { existsSync, appendFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import type { DebugConfig } from '../models/types.js'; -/** Debug logger state */ -let debugEnabled = false; -let debugLogFile: string | null = null; -let initialized = false; +/** + * Debug logger singleton. + * Manages file-based debug logging and verbose console output. + */ +export class DebugLogger { + private static instance: DebugLogger | null = null; -/** Verbose console output state */ -let verboseConsoleEnabled = false; + private debugEnabled = false; + private debugLogFile: string | null = null; + private initialized = false; + private verboseConsoleEnabled = false; -/** Get default debug log file path (requires projectDir) */ -function getDefaultLogFile(projectDir: string): string { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - return join(projectDir, '.takt', 'logs', `debug-${timestamp}.log`); -} + private constructor() {} -/** Initialize debug logger from config */ -export function initDebugLogger(config?: DebugConfig, projectDir?: string): void { - if (initialized) { - return; + static getInstance(): DebugLogger { + if (!DebugLogger.instance) { + DebugLogger.instance = new DebugLogger(); + } + return DebugLogger.instance; } - debugEnabled = config?.enabled ?? false; + /** Reset singleton for testing */ + static resetInstance(): void { + DebugLogger.instance = null; + } - if (debugEnabled) { - if (config?.logFile) { - debugLogFile = config.logFile; - } else if (projectDir) { - debugLogFile = getDefaultLogFile(projectDir); + /** Get default debug log file path */ + private static getDefaultLogFile(projectDir: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + return join(projectDir, '.takt', 'logs', `debug-${timestamp}.log`); + } + + /** Initialize debug logger from config */ + init(config?: DebugConfig, projectDir?: string): void { + if (this.initialized) { + return; } - if (debugLogFile) { - // Ensure log directory exists - const logDir = dirname(debugLogFile); - if (!existsSync(logDir)) { - mkdirSync(logDir, { recursive: true }); + this.debugEnabled = config?.enabled ?? false; + + if (this.debugEnabled) { + if (config?.logFile) { + this.debugLogFile = config.logFile; + } else if (projectDir) { + this.debugLogFile = DebugLogger.getDefaultLogFile(projectDir); } - // Write initial log header - const header = [ - '='.repeat(60), - `TAKT Debug Log`, - `Started: ${new Date().toISOString()}`, - `Project: ${projectDir || 'N/A'}`, - '='.repeat(60), - '', - ].join('\n'); + if (this.debugLogFile) { + const logDir = dirname(this.debugLogFile); + if (!existsSync(logDir)) { + mkdirSync(logDir, { recursive: true }); + } - writeFileSync(debugLogFile, header, 'utf-8'); + const header = [ + '='.repeat(60), + `TAKT Debug Log`, + `Started: ${new Date().toISOString()}`, + `Project: ${projectDir || 'N/A'}`, + '='.repeat(60), + '', + ].join('\n'); + + writeFileSync(this.debugLogFile, header, 'utf-8'); + } } + + this.initialized = true; } - initialized = true; -} + /** Reset state (for testing) */ + reset(): void { + this.debugEnabled = false; + this.debugLogFile = null; + this.initialized = false; + this.verboseConsoleEnabled = false; + } -/** Reset debug logger (for testing) */ -export function resetDebugLogger(): void { - debugEnabled = false; - debugLogFile = null; - initialized = false; - verboseConsoleEnabled = false; -} + /** Enable or disable verbose console output */ + setVerboseConsole(enabled: boolean): void { + this.verboseConsoleEnabled = enabled; + } -/** Enable or disable verbose console output */ -export function setVerboseConsole(enabled: boolean): void { - verboseConsoleEnabled = enabled; -} + /** Check if verbose console is enabled */ + isVerboseConsole(): boolean { + return this.verboseConsoleEnabled; + } -/** Check if verbose console is enabled */ -export function isVerboseConsole(): boolean { - return verboseConsoleEnabled; -} + /** Check if debug is enabled */ + isEnabled(): boolean { + return this.debugEnabled; + } -/** Check if debug is enabled */ -export function isDebugEnabled(): boolean { - return debugEnabled; -} + /** Get current debug log file path */ + getLogFile(): string | null { + return this.debugLogFile; + } -/** Get current debug log file path */ -export function getDebugLogFile(): string | null { - return debugLogFile; -} + /** Format log message with timestamp and level */ + private static formatLogMessage(level: string, component: string, message: string, data?: unknown): string { + const timestamp = new Date().toISOString(); + const prefix = `[${timestamp}] [${level.toUpperCase()}] [${component}]`; -/** Format log message with timestamp and level */ -function formatLogMessage(level: string, component: string, message: string, data?: unknown): string { - const timestamp = new Date().toISOString(); - const prefix = `[${timestamp}] [${level.toUpperCase()}] [${component}]`; + let logLine = `${prefix} ${message}`; - let logLine = `${prefix} ${message}`; + if (data !== undefined) { + try { + const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + logLine += `\n${dataStr}`; + } catch { + logLine += `\n[Unable to serialize data]`; + } + } + + return logLine; + } + + /** Format a compact console log line */ + private static formatConsoleMessage(level: string, component: string, message: string): string { + const timestamp = new Date().toISOString().slice(11, 23); + return `[${timestamp}] [${level}] [${component}] ${message}`; + } + + /** Write a log entry to verbose console (stderr) and/or file */ + writeLog(level: string, component: string, message: string, data?: unknown): void { + if (this.verboseConsoleEnabled) { + process.stderr.write(DebugLogger.formatConsoleMessage(level, component, message) + '\n'); + } + + if (!this.debugEnabled || !this.debugLogFile) { + return; + } + + const logLine = DebugLogger.formatLogMessage(level, component, message, data); - if (data !== undefined) { try { - const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2); - logLine += `\n${dataStr}`; + appendFileSync(this.debugLogFile, logLine + '\n', 'utf-8'); } catch { - logLine += `\n[Unable to serialize data]`; + // Silently fail - logging errors should not interrupt main flow } } - return logLine; -} - -/** Format a compact console log line */ -function formatConsoleMessage(level: string, component: string, message: string): string { - const timestamp = new Date().toISOString().slice(11, 23); // HH:mm:ss.SSS - return `[${timestamp}] [${level}] [${component}] ${message}`; -} - -/** Write a log entry to verbose console (stderr) and/or file */ -function writeLog(level: string, component: string, message: string, data?: unknown): void { - if (verboseConsoleEnabled) { - process.stderr.write(formatConsoleMessage(level, component, message) + '\n'); - } - - if (!debugEnabled || !debugLogFile) { - return; - } - - const logLine = formatLogMessage(level, component, message, data); - - try { - appendFileSync(debugLogFile, logLine + '\n', 'utf-8'); - } catch { - // Silently fail - logging errors should not interrupt main flow + /** Create a scoped logger for a component */ + createLogger(component: string) { + return { + debug: (message: string, data?: unknown) => this.writeLog('DEBUG', component, message, data), + info: (message: string, data?: unknown) => this.writeLog('INFO', component, message, data), + error: (message: string, data?: unknown) => this.writeLog('ERROR', component, message, data), + enter: (funcName: string, args?: Record) => this.writeLog('DEBUG', component, `>> ${funcName}()`, args), + exit: (funcName: string, result?: unknown) => this.writeLog('DEBUG', component, `<< ${funcName}()`, result), + }; } } -/** Write a debug log entry */ +// ---- Backward-compatible module-level functions ---- + +export function initDebugLogger(config?: DebugConfig, projectDir?: string): void { + DebugLogger.getInstance().init(config, projectDir); +} + +export function resetDebugLogger(): void { + DebugLogger.getInstance().reset(); +} + +export function setVerboseConsole(enabled: boolean): void { + DebugLogger.getInstance().setVerboseConsole(enabled); +} + +export function isVerboseConsole(): boolean { + return DebugLogger.getInstance().isVerboseConsole(); +} + +export function isDebugEnabled(): boolean { + return DebugLogger.getInstance().isEnabled(); +} + +export function getDebugLogFile(): string | null { + return DebugLogger.getInstance().getLogFile(); +} + export function debugLog(component: string, message: string, data?: unknown): void { - writeLog('DEBUG', component, message, data); + DebugLogger.getInstance().writeLog('DEBUG', component, message, data); } -/** Write an info log entry */ export function infoLog(component: string, message: string, data?: unknown): void { - writeLog('INFO', component, message, data); + DebugLogger.getInstance().writeLog('INFO', component, message, data); } -/** Write an error log entry */ export function errorLog(component: string, message: string, data?: unknown): void { - writeLog('ERROR', component, message, data); + DebugLogger.getInstance().writeLog('ERROR', component, message, data); } -/** Log function entry with arguments */ export function traceEnter(component: string, funcName: string, args?: Record): void { debugLog(component, `>> ${funcName}()`, args); } -/** Log function exit with result */ export function traceExit(component: string, funcName: string, result?: unknown): void { debugLog(component, `<< ${funcName}()`, result); } -/** Create a scoped logger for a component */ export function createLogger(component: string) { - return { - debug: (message: string, data?: unknown) => debugLog(component, message, data), - info: (message: string, data?: unknown) => infoLog(component, message, data), - error: (message: string, data?: unknown) => errorLog(component, message, data), - enter: (funcName: string, args?: Record) => traceEnter(component, funcName, args), - exit: (funcName: string, result?: unknown) => traceExit(component, funcName, result), - }; + return DebugLogger.getInstance().createLogger(component); } diff --git a/src/utils/session.ts b/src/utils/session.ts index 98f555e..ed743c9 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -32,7 +32,6 @@ export interface SessionLog { // --- NDJSON log types --- -/** NDJSON record: workflow started */ export interface NdjsonWorkflowStart { type: 'workflow_start'; task: string; @@ -40,18 +39,15 @@ export interface NdjsonWorkflowStart { startTime: string; } -/** NDJSON record: step started */ export interface NdjsonStepStart { type: 'step_start'; step: string; agent: string; iteration: number; timestamp: string; - /** Instruction (prompt) sent to the agent. Empty for parallel parent steps. */ instruction?: string; } -/** NDJSON record: step completed */ export interface NdjsonStepComplete { type: 'step_complete'; step: string; @@ -65,14 +61,12 @@ export interface NdjsonStepComplete { timestamp: string; } -/** NDJSON record: workflow completed successfully */ export interface NdjsonWorkflowComplete { type: 'workflow_complete'; iterations: number; endTime: string; } -/** NDJSON record: workflow aborted */ export interface NdjsonWorkflowAbort { type: 'workflow_abort'; iterations: number; @@ -80,7 +74,6 @@ export interface NdjsonWorkflowAbort { endTime: string; } -/** Union of all NDJSON record types */ export type NdjsonRecord = | NdjsonWorkflowStart | NdjsonStepStart @@ -88,205 +81,6 @@ export type NdjsonRecord = | NdjsonWorkflowComplete | NdjsonWorkflowAbort; -/** - * Append a single NDJSON line to a log file. - * Uses appendFileSync for atomic open→write→close (no file lock held). - */ -export function appendNdjsonLine(filepath: string, record: NdjsonRecord): void { - appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8'); -} - -/** - * Initialize an NDJSON log file with the workflow_start record. - * Creates the logs directory if needed and returns the file path. - */ -export function initNdjsonLog( - sessionId: string, - task: string, - workflowName: string, - projectDir?: string, -): string { - const logsDir = projectDir - ? getProjectLogsDir(projectDir) - : getGlobalLogsDir(); - ensureDir(logsDir); - - const filepath = join(logsDir, `${sessionId}.jsonl`); - const record: NdjsonWorkflowStart = { - type: 'workflow_start', - task, - workflowName, - startTime: new Date().toISOString(), - }; - appendNdjsonLine(filepath, record); - return filepath; -} - -/** - * Load an NDJSON log file and convert it to a SessionLog for backward compatibility. - * Parses each line as a JSON record and reconstructs the SessionLog structure. - */ -export function loadNdjsonLog(filepath: string): SessionLog | null { - if (!existsSync(filepath)) { - return null; - } - - const content = readFileSync(filepath, 'utf-8'); - const lines = content.trim().split('\n').filter((line) => line.length > 0); - if (lines.length === 0) return null; - - let sessionLog: SessionLog | null = null; - - for (const line of lines) { - const record = JSON.parse(line) as NdjsonRecord; - - switch (record.type) { - case 'workflow_start': - sessionLog = { - task: record.task, - projectDir: '', - workflowName: record.workflowName, - iterations: 0, - startTime: record.startTime, - status: 'running', - history: [], - }; - break; - - case 'step_complete': - if (sessionLog) { - sessionLog.history.push({ - step: record.step, - agent: record.agent, - instruction: record.instruction, - status: record.status, - timestamp: record.timestamp, - content: record.content, - ...(record.error ? { error: record.error } : {}), - ...(record.matchedRuleIndex != null ? { matchedRuleIndex: record.matchedRuleIndex } : {}), - ...(record.matchedRuleMethod ? { matchedRuleMethod: record.matchedRuleMethod } : {}), - }); - sessionLog.iterations++; - } - break; - - case 'workflow_complete': - if (sessionLog) { - sessionLog.status = 'completed'; - sessionLog.endTime = record.endTime; - } - break; - - case 'workflow_abort': - if (sessionLog) { - sessionLog.status = 'aborted'; - sessionLog.endTime = record.endTime; - } - break; - - // step_start records are not stored in SessionLog - default: - break; - } - } - - return sessionLog; -} - -/** Generate a session ID */ -export function generateSessionId(): string { - const now = new Date(); - const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String( - now.getHours(), - ).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`; - const random = Math.random().toString(36).slice(2, 8); - return `${timestamp}-${random}`; -} - -/** - * Generate report directory name from task and timestamp. - * Format: YYYYMMDD-HHMMSS-task-summary - */ -export function generateReportDir(task: string): string { - const now = new Date(); - const timestamp = now.toISOString() - .replace(/[-:T]/g, '') - .slice(0, 14) - .replace(/(\d{8})(\d{6})/, '$1-$2'); - - // Extract first 30 chars of task, sanitize for directory name - const summary = task - .slice(0, 30) - .toLowerCase() - .replace(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]+/g, '-') - .replace(/^-+|-+$/g, '') - || 'task'; - - return `${timestamp}-${summary}`; -} - -/** Create a new session log */ -export function createSessionLog( - task: string, - projectDir: string, - workflowName: string -): SessionLog { - return { - task, - projectDir, - workflowName, - iterations: 0, - startTime: new Date().toISOString(), - status: 'running', - history: [], - }; -} - -/** Create a finalized copy of a session log (immutable — does not modify the original) */ -export function finalizeSessionLog( - log: SessionLog, - status: 'completed' | 'aborted' -): SessionLog { - return { - ...log, - status, - endTime: new Date().toISOString(), - }; -} - -/** Load session log from file (supports both .json and .jsonl formats) */ -export function loadSessionLog(filepath: string): SessionLog | null { - // Try NDJSON format for .jsonl files - if (filepath.endsWith('.jsonl')) { - return loadNdjsonLog(filepath); - } - - if (!existsSync(filepath)) { - return null; - } - const content = readFileSync(filepath, 'utf-8'); - return JSON.parse(content) as SessionLog; -} - -/** Load project context (CLAUDE.md files) */ -export function loadProjectContext(projectDir: string): string { - const contextParts: string[] = []; - - // Check project root CLAUDE.md - const rootClaudeMd = join(projectDir, 'CLAUDE.md'); - if (existsSync(rootClaudeMd)) { - contextParts.push(readFileSync(rootClaudeMd, 'utf-8')); - } - - // Check .claude/CLAUDE.md - const dotClaudeMd = join(projectDir, '.claude', 'CLAUDE.md'); - if (existsSync(dotClaudeMd)) { - contextParts.push(readFileSync(dotClaudeMd, 'utf-8')); - } - - return contextParts.join('\n\n---\n\n'); -} - /** Pointer metadata for latest/previous log files */ export interface LatestLogPointer { sessionId: string; @@ -300,39 +94,283 @@ export interface LatestLogPointer { } /** - * Update latest.json pointer file. - * On first call (workflow start), copies existing latest.json to previous.json. - * On subsequent calls (step complete / workflow end), only overwrites latest.json. + * Manages session lifecycle: ID generation, NDJSON logging, + * session log creation/loading, and latest pointer maintenance. */ +export class SessionManager { + /** Append a single NDJSON line to a log file */ + appendNdjsonLine(filepath: string, record: NdjsonRecord): void { + appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8'); + } + + /** Initialize an NDJSON log file with the workflow_start record */ + initNdjsonLog( + sessionId: string, + task: string, + workflowName: string, + projectDir?: string, + ): string { + const logsDir = projectDir + ? getProjectLogsDir(projectDir) + : getGlobalLogsDir(); + ensureDir(logsDir); + + const filepath = join(logsDir, `${sessionId}.jsonl`); + const record: NdjsonWorkflowStart = { + type: 'workflow_start', + task, + workflowName, + startTime: new Date().toISOString(), + }; + this.appendNdjsonLine(filepath, record); + return filepath; + } + + /** Load an NDJSON log file and convert it to a SessionLog */ + loadNdjsonLog(filepath: string): SessionLog | null { + if (!existsSync(filepath)) { + return null; + } + + const content = readFileSync(filepath, 'utf-8'); + const lines = content.trim().split('\n').filter((line) => line.length > 0); + if (lines.length === 0) return null; + + let sessionLog: SessionLog | null = null; + + for (const line of lines) { + const record = JSON.parse(line) as NdjsonRecord; + + switch (record.type) { + case 'workflow_start': + sessionLog = { + task: record.task, + projectDir: '', + workflowName: record.workflowName, + iterations: 0, + startTime: record.startTime, + status: 'running', + history: [], + }; + break; + + case 'step_complete': + if (sessionLog) { + sessionLog.history.push({ + step: record.step, + agent: record.agent, + instruction: record.instruction, + status: record.status, + timestamp: record.timestamp, + content: record.content, + ...(record.error ? { error: record.error } : {}), + ...(record.matchedRuleIndex != null ? { matchedRuleIndex: record.matchedRuleIndex } : {}), + ...(record.matchedRuleMethod ? { matchedRuleMethod: record.matchedRuleMethod } : {}), + }); + sessionLog.iterations++; + } + break; + + case 'workflow_complete': + if (sessionLog) { + sessionLog.status = 'completed'; + sessionLog.endTime = record.endTime; + } + break; + + case 'workflow_abort': + if (sessionLog) { + sessionLog.status = 'aborted'; + sessionLog.endTime = record.endTime; + } + break; + + default: + break; + } + } + + return sessionLog; + } + + /** Generate a session ID */ + generateSessionId(): string { + const now = new Date(); + const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String( + now.getHours(), + ).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`; + const random = Math.random().toString(36).slice(2, 8); + return `${timestamp}-${random}`; + } + + /** Generate report directory name from task and timestamp */ + generateReportDir(task: string): string { + const now = new Date(); + const timestamp = now.toISOString() + .replace(/[-:T]/g, '') + .slice(0, 14) + .replace(/(\d{8})(\d{6})/, '$1-$2'); + + const summary = task + .slice(0, 30) + .toLowerCase() + .replace(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]+/g, '-') + .replace(/^-+|-+$/g, '') + || 'task'; + + return `${timestamp}-${summary}`; + } + + /** Create a new session log */ + createSessionLog( + task: string, + projectDir: string, + workflowName: string, + ): SessionLog { + return { + task, + projectDir, + workflowName, + iterations: 0, + startTime: new Date().toISOString(), + status: 'running', + history: [], + }; + } + + /** Create a finalized copy of a session log (immutable) */ + finalizeSessionLog( + log: SessionLog, + status: 'completed' | 'aborted', + ): SessionLog { + return { + ...log, + status, + endTime: new Date().toISOString(), + }; + } + + /** Load session log from file (supports both .json and .jsonl formats) */ + loadSessionLog(filepath: string): SessionLog | null { + if (filepath.endsWith('.jsonl')) { + return this.loadNdjsonLog(filepath); + } + + if (!existsSync(filepath)) { + return null; + } + const content = readFileSync(filepath, 'utf-8'); + return JSON.parse(content) as SessionLog; + } + + /** Load project context (CLAUDE.md files) */ + loadProjectContext(projectDir: string): string { + const contextParts: string[] = []; + + const rootClaudeMd = join(projectDir, 'CLAUDE.md'); + if (existsSync(rootClaudeMd)) { + contextParts.push(readFileSync(rootClaudeMd, 'utf-8')); + } + + const dotClaudeMd = join(projectDir, '.claude', 'CLAUDE.md'); + if (existsSync(dotClaudeMd)) { + contextParts.push(readFileSync(dotClaudeMd, 'utf-8')); + } + + return contextParts.join('\n\n---\n\n'); + } + + /** Update latest.json pointer file */ + updateLatestPointer( + log: SessionLog, + sessionId: string, + projectDir?: string, + options?: { copyToPrevious?: boolean }, + ): void { + const logsDir = projectDir + ? getProjectLogsDir(projectDir) + : getGlobalLogsDir(); + ensureDir(logsDir); + + const latestPath = join(logsDir, 'latest.json'); + const previousPath = join(logsDir, 'previous.json'); + + if (options?.copyToPrevious && existsSync(latestPath)) { + copyFileSync(latestPath, previousPath); + } + + const pointer: LatestLogPointer = { + sessionId, + logFile: `${sessionId}.jsonl`, + task: log.task, + workflowName: log.workflowName, + status: log.status, + startTime: log.startTime, + updatedAt: new Date().toISOString(), + iterations: log.iterations, + }; + + writeFileAtomic(latestPath, JSON.stringify(pointer, null, 2)); + } +} + +// ---- Backward-compatible module-level functions ---- + +const defaultManager = new SessionManager(); + +export function appendNdjsonLine(filepath: string, record: NdjsonRecord): void { + defaultManager.appendNdjsonLine(filepath, record); +} + +export function initNdjsonLog( + sessionId: string, + task: string, + workflowName: string, + projectDir?: string, +): string { + return defaultManager.initNdjsonLog(sessionId, task, workflowName, projectDir); +} + +export function loadNdjsonLog(filepath: string): SessionLog | null { + return defaultManager.loadNdjsonLog(filepath); +} + +export function generateSessionId(): string { + return defaultManager.generateSessionId(); +} + +export function generateReportDir(task: string): string { + return defaultManager.generateReportDir(task); +} + +export function createSessionLog( + task: string, + projectDir: string, + workflowName: string, +): SessionLog { + return defaultManager.createSessionLog(task, projectDir, workflowName); +} + +export function finalizeSessionLog( + log: SessionLog, + status: 'completed' | 'aborted', +): SessionLog { + return defaultManager.finalizeSessionLog(log, status); +} + +export function loadSessionLog(filepath: string): SessionLog | null { + return defaultManager.loadSessionLog(filepath); +} + +export function loadProjectContext(projectDir: string): string { + return defaultManager.loadProjectContext(projectDir); +} + export function updateLatestPointer( log: SessionLog, sessionId: string, projectDir?: string, - options?: { copyToPrevious?: boolean } + options?: { copyToPrevious?: boolean }, ): void { - const logsDir = projectDir - ? getProjectLogsDir(projectDir) - : getGlobalLogsDir(); - ensureDir(logsDir); - - const latestPath = join(logsDir, 'latest.json'); - const previousPath = join(logsDir, 'previous.json'); - - // Copy latest → previous only when explicitly requested (workflow start) - if (options?.copyToPrevious && existsSync(latestPath)) { - copyFileSync(latestPath, previousPath); - } - - const pointer: LatestLogPointer = { - sessionId, - logFile: `${sessionId}.jsonl`, - task: log.task, - workflowName: log.workflowName, - status: log.status, - startTime: log.startTime, - updatedAt: new Date().toISOString(), - iterations: log.iterations, - }; - - writeFileAtomic(latestPath, JSON.stringify(pointer, null, 2)); + defaultManager.updateLatestPointer(log, sessionId, projectDir, options); } diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 2ae935f..7654e7b 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -3,14 +3,11 @@ */ import chalk from 'chalk'; -import type { StreamEvent, StreamCallback } from '../claude/process.js'; +import type { StreamEvent, StreamCallback } from '../claude/types.js'; /** Log levels */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; -/** Current log level */ -let currentLogLevel: LogLevel = 'info'; - /** Log level priorities */ const LOG_PRIORITIES: Record = { debug: 0, @@ -19,67 +16,112 @@ const LOG_PRIORITIES: Record = { error: 3, }; -/** Set log level */ +/** + * Manages console log output level and provides formatted logging. + * Singleton — use LogManager.getInstance(). + */ +export class LogManager { + private static instance: LogManager | null = null; + private currentLogLevel: LogLevel = 'info'; + + private constructor() {} + + static getInstance(): LogManager { + if (!LogManager.instance) { + LogManager.instance = new LogManager(); + } + return LogManager.instance; + } + + /** Reset singleton for testing */ + static resetInstance(): void { + LogManager.instance = null; + } + + /** Set log level */ + setLogLevel(level: LogLevel): void { + this.currentLogLevel = level; + } + + /** Check if a log level should be shown */ + shouldLog(level: LogLevel): boolean { + return LOG_PRIORITIES[level] >= LOG_PRIORITIES[this.currentLogLevel]; + } + + /** Log a debug message */ + debug(message: string): void { + if (this.shouldLog('debug')) { + console.log(chalk.gray(`[DEBUG] ${message}`)); + } + } + + /** Log an info message */ + info(message: string): void { + if (this.shouldLog('info')) { + console.log(chalk.blue(`[INFO] ${message}`)); + } + } + + /** Log a warning message */ + warn(message: string): void { + if (this.shouldLog('warn')) { + console.log(chalk.yellow(`[WARN] ${message}`)); + } + } + + /** Log an error message */ + error(message: string): void { + if (this.shouldLog('error')) { + console.log(chalk.red(`[ERROR] ${message}`)); + } + } + + /** Log a success message */ + success(message: string): void { + console.log(chalk.green(message)); + } +} + +// ---- Backward-compatible module-level functions ---- + export function setLogLevel(level: LogLevel): void { - currentLogLevel = level; + LogManager.getInstance().setLogLevel(level); } -/** Check if a log level should be shown */ -function shouldLog(level: LogLevel): boolean { - return LOG_PRIORITIES[level] >= LOG_PRIORITIES[currentLogLevel]; -} - -/** Print a blank line */ export function blankLine(): void { console.log(); } -/** Log a debug message */ export function debug(message: string): void { - if (shouldLog('debug')) { - console.log(chalk.gray(`[DEBUG] ${message}`)); - } + LogManager.getInstance().debug(message); } -/** Log an info message */ export function info(message: string): void { - if (shouldLog('info')) { - console.log(chalk.blue(`[INFO] ${message}`)); - } + LogManager.getInstance().info(message); } -/** Log a warning message */ export function warn(message: string): void { - if (shouldLog('warn')) { - console.log(chalk.yellow(`[WARN] ${message}`)); - } + LogManager.getInstance().warn(message); } -/** Log an error message */ export function error(message: string): void { - if (shouldLog('error')) { - console.log(chalk.red(`[ERROR] ${message}`)); - } + LogManager.getInstance().error(message); } -/** Log a success message */ export function success(message: string): void { - console.log(chalk.green(message)); + LogManager.getInstance().success(message); } -/** Print a header */ export function header(title: string): void { console.log(); console.log(chalk.bold.cyan(`=== ${title} ===`)); console.log(); } -/** Print a section title */ export function section(title: string): void { console.log(chalk.bold(`\n${title}`)); } -/** Print status */ export function status(label: string, value: string, color?: 'green' | 'yellow' | 'red'): void { const colorFn = color ? chalk[color] : chalk.white; console.log(`${chalk.gray(label)}: ${colorFn(value)}`); @@ -96,7 +138,6 @@ export class Spinner { this.message = message; } - /** Start the spinner */ start(): void { this.intervalId = setInterval(() => { process.stdout.write( @@ -106,7 +147,6 @@ export class Spinner { }, 80); } - /** Stop the spinner */ stop(finalMessage?: string): void { if (this.intervalId) { clearInterval(this.intervalId); @@ -118,13 +158,11 @@ export class Spinner { } } - /** Update spinner message */ update(message: string): void { this.message = message; } } -/** Create a progress bar */ export function progressBar(current: number, total: number, width = 30): string { const percentage = Math.floor((current / total) * 100); const filled = Math.floor((current / total) * width); @@ -133,19 +171,16 @@ export function progressBar(current: number, total: number, width = 30): string return `[${bar}] ${percentage}%`; } -/** Format a list of items */ export function list(items: string[], bullet = '‱'): void { for (const item of items) { console.log(chalk.gray(bullet) + ' ' + item); } } -/** Print a divider */ export function divider(char = '─', length = 40): void { console.log(chalk.gray(char.repeat(length))); } -/** Truncate text with ellipsis */ export function truncate(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; @@ -176,13 +211,11 @@ export class StreamDisplay { private quiet = false, ) {} - /** Display initialization event */ showInit(model: string): void { if (this.quiet) return; console.log(chalk.gray(`[${this.agentName}] Model: ${model}`)); } - /** Start spinner for tool execution */ private startToolSpinner(tool: string, inputPreview: string): void { this.stopToolSpinner(); @@ -198,26 +231,19 @@ export class StreamDisplay { }; } - /** Stop the tool spinner */ private stopToolSpinner(): void { if (this.toolSpinner) { clearInterval(this.toolSpinner.intervalId); - // Clear the entire line to avoid artifacts from ANSI color codes process.stdout.write('\r' + ' '.repeat(120) + '\r'); this.toolSpinner = null; this.spinnerFrame = 0; } } - /** Display tool use event */ showToolUse(tool: string, input: Record): void { if (this.quiet) return; - - // Clear any buffered text first this.flushText(); - const inputPreview = this.formatToolInput(tool, input); - // Start spinner to show tool is executing this.startToolSpinner(tool, inputPreview); this.lastToolUse = tool; this.currentToolInputPreview = inputPreview; @@ -225,7 +251,6 @@ export class StreamDisplay { this.toolOutputPrinted = false; } - /** Display tool output streaming */ showToolOutput(output: string, tool?: string): void { if (this.quiet) return; if (!output) return; @@ -248,13 +273,10 @@ export class StreamDisplay { } } - /** Display tool result event */ showToolResult(content: string, isError: boolean): void { - // Stop the spinner first (always, even in quiet mode to prevent spinner artifacts) this.stopToolSpinner(); if (this.quiet) { - // In quiet mode: show errors but suppress success messages if (isError) { const toolName = this.lastToolUse || 'Tool'; const errorContent = content || 'Unknown error'; @@ -276,7 +298,6 @@ export class StreamDisplay { const errorContent = content || 'Unknown error'; console.log(chalk.red(` ✗ ${toolName}:`), chalk.red(truncate(errorContent, 70))); } else if (content && content.length > 0) { - // Show a brief preview of the result const preview = content.split('\n')[0] || content; console.log(chalk.green(` ✓ ${toolName}`), chalk.gray(truncate(preview, 60))); } else { @@ -287,13 +308,9 @@ export class StreamDisplay { this.toolOutputPrinted = false; } - /** Display streaming thinking (Claude's internal reasoning) */ showThinking(thinking: string): void { if (this.quiet) return; - - // Stop spinner if running this.stopToolSpinner(); - // Flush any regular text first this.flushText(); if (this.isFirstThinking) { @@ -301,12 +318,10 @@ export class StreamDisplay { console.log(chalk.magenta(`💭 [${this.agentName} thinking]:`)); this.isFirstThinking = false; } - // Write thinking in a dimmed/italic style process.stdout.write(chalk.gray.italic(thinking)); this.thinkingBuffer += thinking; } - /** Flush any remaining thinking */ flushThinking(): void { if (this.thinkingBuffer) { if (!this.thinkingBuffer.endsWith('\n')) { @@ -317,13 +332,9 @@ export class StreamDisplay { } } - /** Display streaming text (accumulated) */ showText(text: string): void { if (this.quiet) return; - - // Stop spinner if running this.stopToolSpinner(); - // Flush any thinking first this.flushThinking(); if (this.isFirstText) { @@ -331,15 +342,12 @@ export class StreamDisplay { console.log(chalk.cyan(`[${this.agentName}]:`)); this.isFirstText = false; } - // Write directly to stdout without newline for smooth streaming process.stdout.write(text); this.textBuffer += text; } - /** Flush any remaining text */ flushText(): void { if (this.textBuffer) { - // Ensure we end with a newline if (!this.textBuffer.endsWith('\n')) { console.log(); } @@ -348,14 +356,12 @@ export class StreamDisplay { } } - /** Flush both thinking and text buffers */ flush(): void { this.stopToolSpinner(); this.flushThinking(); this.flushText(); } - /** Display final result */ showResult(success: boolean, error?: string): void { this.stopToolSpinner(); this.flushThinking(); @@ -371,7 +377,6 @@ export class StreamDisplay { } } - /** Reset state for new interaction */ reset(): void { this.stopToolSpinner(); this.lastToolUse = null; @@ -384,10 +389,6 @@ export class StreamDisplay { this.isFirstThinking = true; } - /** - * Create a stream event handler for this display. - * This centralizes the event handling logic to avoid code duplication. - */ createHandler(): StreamCallback { return (event: StreamEvent): void => { switch (event.type) { @@ -413,13 +414,11 @@ export class StreamDisplay { this.showResult(event.data.success, event.data.error); break; case 'error': - // Parse errors are logged but not displayed to user break; } }; } - /** Format tool input for display */ private formatToolInput(tool: string, input: Record): string { switch (tool) { case 'Bash': diff --git a/src/workflow/engine.ts b/src/workflow/engine.ts deleted file mode 100644 index 08a6b3d..0000000 --- a/src/workflow/engine.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Re-export shim for backward compatibility. - * - * The actual implementation has been split into: - * - engine/WorkflowEngine.ts — Main orchestration loop - * - engine/StepExecutor.ts — Single-step 3-phase execution - * - engine/ParallelRunner.ts — Parallel step execution - * - engine/OptionsBuilder.ts — RunAgentOptions construction - */ - -export { WorkflowEngine } from './engine/WorkflowEngine.js'; - -// Re-export types for backward compatibility -export type { - WorkflowEvents, - UserInputRequest, - IterationLimitRequest, - SessionUpdateCallback, - IterationLimitCallback, - WorkflowEngineOptions, -} from './types.js'; -export { COMPLETE_STEP, ABORT_STEP } from './constants.js'; diff --git a/src/workflow/engine/ParallelRunner.ts b/src/workflow/engine/ParallelRunner.ts index 459973d..cda282a 100644 --- a/src/workflow/engine/ParallelRunner.ts +++ b/src/workflow/engine/ParallelRunner.ts @@ -13,7 +13,7 @@ import type { import { runAgent } from '../../agents/runner.js'; import { ParallelLogger } from '../parallel-logger.js'; import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../phase-runner.js'; -import { detectMatchedRule } from '../rule-evaluator.js'; +import { detectMatchedRule } from '../evaluation/index.js'; import { incrementStepIteration } from '../state-manager.js'; import { createLogger } from '../../utils/debug.js'; import type { OptionsBuilder } from './OptionsBuilder.js'; diff --git a/src/workflow/engine/StepExecutor.ts b/src/workflow/engine/StepExecutor.ts index db5edea..9130945 100644 --- a/src/workflow/engine/StepExecutor.ts +++ b/src/workflow/engine/StepExecutor.ts @@ -15,9 +15,9 @@ import type { Language, } from '../../models/types.js'; import { runAgent } from '../../agents/runner.js'; -import { buildInstruction as buildInstructionFromTemplate, isReportObjectConfig } from '../instruction-builder.js'; +import { InstructionBuilder, isReportObjectConfig } from '../instruction/InstructionBuilder.js'; import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../phase-runner.js'; -import { detectMatchedRule } from '../rule-evaluator.js'; +import { detectMatchedRule } from '../evaluation/index.js'; import { incrementStepIteration, getPreviousOutput } from '../state-manager.js'; import { createLogger } from '../../utils/debug.js'; import type { OptionsBuilder } from './OptionsBuilder.js'; @@ -45,7 +45,7 @@ export class StepExecutor { task: string, maxIterations: number, ): string { - return buildInstructionFromTemplate(step, { + return new InstructionBuilder(step, { task, iteration: state.iteration, maxIterations, @@ -56,7 +56,7 @@ export class StepExecutor { previousOutput: getPreviousOutput(state), reportDir: join(this.deps.getCwd(), this.deps.getReportDir()), language: this.deps.getLanguage(), - }); + }).build(); } /** diff --git a/src/workflow/evaluation/AggregateEvaluator.ts b/src/workflow/evaluation/AggregateEvaluator.ts new file mode 100644 index 0000000..d0b1b26 --- /dev/null +++ b/src/workflow/evaluation/AggregateEvaluator.ts @@ -0,0 +1,74 @@ +/** + * Aggregate condition evaluator for parallel workflow steps + * + * Evaluates all()/any() aggregate conditions against sub-step results. + */ + +import type { WorkflowStep, WorkflowState } from '../../models/types.js'; +import { createLogger } from '../../utils/debug.js'; + +const log = createLogger('aggregate-evaluator'); + +/** + * Evaluates aggregate conditions (all()/any()) for parallel parent steps. + * + * For each aggregate rule, checks the matched condition text of sub-steps: + * - all("X"): true when ALL sub-steps have matched condition === X + * - any("X"): true when at least ONE sub-step has matched condition === X + * + * Edge cases per spec: + * - Sub-step with no matched rule: all() → false, any() → skip that sub-step + * - No sub-steps (0 ä»¶): both → false + * - Non-parallel step: both → false + */ +export class AggregateEvaluator { + constructor( + private readonly step: WorkflowStep, + private readonly state: WorkflowState, + ) {} + + /** + * Evaluate aggregate conditions. + * Returns the 0-based rule index in the step's rules array, or -1 if no match. + */ + evaluate(): number { + if (!this.step.rules || !this.step.parallel || this.step.parallel.length === 0) return -1; + + for (let i = 0; i < this.step.rules.length; i++) { + const rule = this.step.rules[i]!; + if (!rule.isAggregateCondition || !rule.aggregateType || !rule.aggregateConditionText) { + continue; + } + + const subSteps = this.step.parallel; + const targetCondition = rule.aggregateConditionText; + + if (rule.aggregateType === 'all') { + const allMatch = subSteps.every((sub) => { + const output = this.state.stepOutputs.get(sub.name); + if (!output || output.matchedRuleIndex == null || !sub.rules) return false; + const matchedRule = sub.rules[output.matchedRuleIndex]; + return matchedRule?.condition === targetCondition; + }); + if (allMatch) { + log.debug('Aggregate all() matched', { step: this.step.name, condition: targetCondition, ruleIndex: i }); + return i; + } + } else { + // 'any' + const anyMatch = subSteps.some((sub) => { + const output = this.state.stepOutputs.get(sub.name); + if (!output || output.matchedRuleIndex == null || !sub.rules) return false; + const matchedRule = sub.rules[output.matchedRuleIndex]; + return matchedRule?.condition === targetCondition; + }); + if (anyMatch) { + log.debug('Aggregate any() matched', { step: this.step.name, condition: targetCondition, ruleIndex: i }); + return i; + } + } + } + + return -1; + } +} diff --git a/src/workflow/evaluation/RuleEvaluator.ts b/src/workflow/evaluation/RuleEvaluator.ts new file mode 100644 index 0000000..0b3c0b7 --- /dev/null +++ b/src/workflow/evaluation/RuleEvaluator.ts @@ -0,0 +1,160 @@ +/** + * Rule evaluation logic for workflow steps + * + * Evaluates workflow step rules to determine the matched rule index. + * Supports tag-based detection, ai() conditions, aggregate conditions, + * and AI judge fallback. + */ + +import type { + WorkflowStep, + WorkflowState, + RuleMatchMethod, +} from '../../models/types.js'; +import { detectRuleIndex, callAiJudge } from '../../claude/client.js'; +import { createLogger } from '../../utils/debug.js'; +import { AggregateEvaluator } from './AggregateEvaluator.js'; + +const log = createLogger('rule-evaluator'); + +export interface RuleMatch { + index: number; + method: RuleMatchMethod; +} + +export interface RuleEvaluatorContext { + /** Workflow state (for accessing stepOutputs in aggregate evaluation) */ + state: WorkflowState; + /** Working directory (for AI judge calls) */ + cwd: string; +} + +/** + * Evaluates rules for a workflow step to determine the next transition. + * + * Evaluation order (first match wins): + * 1. Aggregate conditions: all()/any() — evaluate sub-step results + * 2. Tag detection from Phase 3 output + * 3. Tag detection from Phase 1 output (fallback) + * 4. ai() condition evaluation via AI judge + * 5. All-conditions AI judge (final fallback) + * + * Returns undefined for steps without rules. + * Throws if rules exist but no rule matched (Fail Fast). + */ +export class RuleEvaluator { + constructor( + private readonly step: WorkflowStep, + private readonly ctx: RuleEvaluatorContext, + ) {} + + async evaluate(agentContent: string, tagContent: string): Promise { + if (!this.step.rules || this.step.rules.length === 0) return undefined; + + // 1. Aggregate conditions (all/any) — only meaningful for parallel parent steps + const aggEvaluator = new AggregateEvaluator(this.step, this.ctx.state); + const aggIndex = aggEvaluator.evaluate(); + if (aggIndex >= 0) { + return { index: aggIndex, method: 'aggregate' }; + } + + // 2. Tag detection from Phase 3 output + if (tagContent) { + const ruleIndex = detectRuleIndex(tagContent, this.step.name); + if (ruleIndex >= 0 && ruleIndex < this.step.rules.length) { + return { index: ruleIndex, method: 'phase3_tag' }; + } + } + + // 3. Tag detection from Phase 1 output (fallback) + if (agentContent) { + const ruleIndex = detectRuleIndex(agentContent, this.step.name); + if (ruleIndex >= 0 && ruleIndex < this.step.rules.length) { + return { index: ruleIndex, method: 'phase1_tag' }; + } + } + + // 4. AI judge for ai() conditions only + const aiRuleIndex = await this.evaluateAiConditions(agentContent); + if (aiRuleIndex >= 0) { + return { index: aiRuleIndex, method: 'ai_judge' }; + } + + // 5. AI judge for all conditions (final fallback) + const fallbackIndex = await this.evaluateAllConditionsViaAiJudge(agentContent); + if (fallbackIndex >= 0) { + return { index: fallbackIndex, method: 'ai_judge_fallback' }; + } + + throw new Error(`Status not found for step "${this.step.name}": no rule matched after all detection phases`); + } + + /** + * Evaluate ai() conditions via AI judge. + * Returns the 0-based rule index, or -1 if no match. + */ + private async evaluateAiConditions(agentOutput: string): Promise { + if (!this.step.rules) return -1; + + const aiConditions: { index: number; text: string }[] = []; + for (let i = 0; i < this.step.rules.length; i++) { + const rule = this.step.rules[i]!; + if (rule.isAiCondition && rule.aiConditionText) { + aiConditions.push({ index: i, text: rule.aiConditionText }); + } + } + + if (aiConditions.length === 0) return -1; + + log.debug('Evaluating ai() conditions via judge', { + step: this.step.name, + conditionCount: aiConditions.length, + }); + + const judgeConditions = aiConditions.map((c, i) => ({ index: i, text: c.text })); + const judgeResult = await callAiJudge(agentOutput, judgeConditions, { cwd: this.ctx.cwd }); + + if (judgeResult >= 0 && judgeResult < aiConditions.length) { + const matched = aiConditions[judgeResult]!; + log.debug('AI judge matched condition', { + step: this.step.name, + judgeResult, + originalRuleIndex: matched.index, + condition: matched.text, + }); + return matched.index; + } + + log.debug('AI judge did not match any condition', { step: this.step.name }); + return -1; + } + + /** + * Final fallback: evaluate ALL rule conditions via AI judge. + * Returns the 0-based rule index, or -1 if no match. + */ + private async evaluateAllConditionsViaAiJudge(agentOutput: string): Promise { + if (!this.step.rules || this.step.rules.length === 0) return -1; + + const conditions = this.step.rules.map((rule, i) => ({ index: i, text: rule.condition })); + + log.debug('Evaluating all conditions via AI judge (final fallback)', { + step: this.step.name, + conditionCount: conditions.length, + }); + + const judgeResult = await callAiJudge(agentOutput, conditions, { cwd: this.ctx.cwd }); + + if (judgeResult >= 0 && judgeResult < conditions.length) { + log.debug('AI judge (fallback) matched condition', { + step: this.step.name, + ruleIndex: judgeResult, + condition: conditions[judgeResult]!.text, + }); + return judgeResult; + } + + log.debug('AI judge (fallback) did not match any condition', { step: this.step.name }); + return -1; + } +} diff --git a/src/workflow/evaluation/index.ts b/src/workflow/evaluation/index.ts new file mode 100644 index 0000000..65f5634 --- /dev/null +++ b/src/workflow/evaluation/index.ts @@ -0,0 +1,35 @@ +/** + * Rule evaluation - barrel exports + */ + +import type { WorkflowStep, WorkflowState } from '../../models/types.js'; +import { RuleEvaluator } from './RuleEvaluator.js'; +import { AggregateEvaluator } from './AggregateEvaluator.js'; + +export { RuleEvaluator, type RuleMatch, type RuleEvaluatorContext } from './RuleEvaluator.js'; +export { AggregateEvaluator } from './AggregateEvaluator.js'; + +// ---- Function facades for consumers that prefer the function API ---- + +import type { RuleMatch, RuleEvaluatorContext } from './RuleEvaluator.js'; + +/** + * Detect matched rule for a step's response. + * Function facade over RuleEvaluator class. + */ +export async function detectMatchedRule( + step: WorkflowStep, + agentContent: string, + tagContent: string, + ctx: RuleEvaluatorContext, +): Promise { + return new RuleEvaluator(step, ctx).evaluate(agentContent, tagContent); +} + +/** + * Evaluate aggregate conditions. + * Function facade over AggregateEvaluator class. + */ +export function evaluateAggregateConditions(step: WorkflowStep, state: WorkflowState): number { + return new AggregateEvaluator(step, state).evaluate(); +} diff --git a/src/workflow/index.ts b/src/workflow/index.ts index 879d735..cb699b8 100644 --- a/src/workflow/index.ts +++ b/src/workflow/index.ts @@ -36,13 +36,14 @@ export { } from './state-manager.js'; // Instruction building -export { - buildInstruction, - buildExecutionMetadata, - renderExecutionMetadata, - type InstructionContext, - type ExecutionMetadata, -} from './instruction-builder.js'; +export { InstructionBuilder, isReportObjectConfig } from './instruction/InstructionBuilder.js'; +export { ReportInstructionBuilder, type ReportInstructionContext } from './instruction/ReportInstructionBuilder.js'; +export { StatusJudgmentBuilder, type StatusJudgmentContext } from './instruction/StatusJudgmentBuilder.js'; +export { buildExecutionMetadata, renderExecutionMetadata, type InstructionContext, type ExecutionMetadata } from './instruction-context.js'; + +// Rule evaluation +export { RuleEvaluator, type RuleMatch, type RuleEvaluatorContext, detectMatchedRule, evaluateAggregateConditions } from './evaluation/index.js'; +export { AggregateEvaluator } from './evaluation/AggregateEvaluator.js'; // Blocked handling export { handleBlocked, type BlockedHandlerResult } from './blocked-handler.js'; diff --git a/src/workflow/instruction-builder.ts b/src/workflow/instruction-builder.ts deleted file mode 100644 index 6c637fb..0000000 --- a/src/workflow/instruction-builder.ts +++ /dev/null @@ -1,463 +0,0 @@ -/** - * Instruction template builder for workflow steps - * - * Builds the instruction string for agent execution by: - * 1. Auto-injecting standard sections (Execution Context, Workflow Context, - * User Request, Previous Response, Additional User Inputs, Instructions header, - * Status Output Rules) - * 2. Replacing template placeholders with actual values - * - * Status rules are injected into Phase 1 for tag-based detection, - * and also used in Phase 3 (buildStatusJudgmentInstruction) as a dedicated follow-up. - */ - -import type { WorkflowStep, Language, ReportConfig, ReportObjectConfig } from '../models/types.js'; -import { hasTagBasedRules } from './rule-utils.js'; -import type { InstructionContext } from './instruction-context.js'; -import { buildExecutionMetadata, renderExecutionMetadata, METADATA_STRINGS } from './instruction-context.js'; -import { generateStatusRulesFromRules } from './status-rules.js'; - -// Re-export from sub-modules for backward compatibility -export type { InstructionContext, ExecutionMetadata } from './instruction-context.js'; -export { buildExecutionMetadata, renderExecutionMetadata } from './instruction-context.js'; -export { generateStatusRulesFromRules } from './status-rules.js'; - -/** - * Escape special characters in dynamic content to prevent template injection. - */ -function escapeTemplateChars(str: string): string { - return str.replace(/\{/g, '').replace(/\}/g, ''); -} - -/** - * Check if a report config is the object form (ReportObjectConfig). - */ -export function isReportObjectConfig(report: string | ReportConfig[] | ReportObjectConfig): report is ReportObjectConfig { - return typeof report === 'object' && !Array.isArray(report) && 'name' in report; -} - -/** Localized strings for auto-injected sections */ -const SECTION_STRINGS = { - en: { - workflowContext: '## Workflow Context', - iteration: 'Iteration', - iterationWorkflowWide: '(workflow-wide)', - stepIteration: 'Step Iteration', - stepIterationTimes: '(times this step has run)', - step: 'Step', - reportDirectory: 'Report Directory', - reportFile: 'Report File', - reportFiles: 'Report Files', - userRequest: '## User Request', - previousResponse: '## Previous Response', - additionalUserInputs: '## Additional User Inputs', - instructions: '## Instructions', - }, - ja: { - workflowContext: '## Workflow Context', - iteration: 'Iteration', - iterationWorkflowWide: 'ïŒˆăƒŻăƒŒă‚Żăƒ•ăƒ­ăƒŒć…šäœ“ïŒ‰', - stepIteration: 'Step Iteration', - stepIterationTimes: 'ïŒˆă“ăźă‚čăƒ†ăƒƒăƒ—ăźćźŸèĄŒć›žæ•°ïŒ‰', - step: 'Step', - reportDirectory: 'Report Directory', - reportFile: 'Report File', - reportFiles: 'Report Files', - userRequest: '## User Request', - previousResponse: '## Previous Response', - additionalUserInputs: '## Additional User Inputs', - instructions: '## Instructions', - }, -} as const; - -/** Localized strings for auto-generated report output instructions */ -const REPORT_OUTPUT_STRINGS = { - en: { - singleHeading: '**Report output:** Output to the `Report File` specified above.', - multiHeading: '**Report output:** Output to the `Report Files` specified above.', - createRule: '- If file does not exist: Create new file', - appendRule: '- If file exists: Append with `## Iteration {step_iteration}` section', - }, - ja: { - singleHeading: '**ăƒŹăƒăƒŒăƒˆć‡ș抛:** `Report File` にć‡ș抛しどください。', - multiHeading: '**ăƒŹăƒăƒŒăƒˆć‡ș抛:** Report Files にć‡ș抛しどください。', - createRule: '- ăƒ•ă‚Ąă‚€ăƒ«ăŒć­˜ćœšă—ăȘい栮搈: 新芏䜜成', - appendRule: '- ăƒ•ă‚Ąă‚€ăƒ«ăŒć­˜ćœšă™ă‚‹ć Žćˆ: `## Iteration {step_iteration}` ă‚»ă‚Żă‚·ăƒ§ăƒłă‚’èżœèš˜', - }, -} as const; - -/** - * Generate report output instructions from step.report config. - * Returns undefined if step has no report or no reportDir. - * - * This replaces the manual `order:` fields and instruction_template - * report output blocks that were previously hand-written in each YAML. - */ -function renderReportOutputInstruction( - step: WorkflowStep, - context: InstructionContext, - language: Language, -): string | undefined { - if (!step.report || !context.reportDir) return undefined; - - const s = REPORT_OUTPUT_STRINGS[language]; - const isMulti = Array.isArray(step.report); - const heading = isMulti ? s.multiHeading : s.singleHeading; - const appendRule = s.appendRule.replace('{step_iteration}', String(context.stepIteration)); - - return [heading, s.createRule, appendRule].join('\n'); -} - -/** - * Render the Workflow Context section. - */ -function renderWorkflowContext( - step: WorkflowStep, - context: InstructionContext, - language: Language, -): string { - const s = SECTION_STRINGS[language]; - const lines: string[] = [ - s.workflowContext, - `- ${s.iteration}: ${context.iteration}/${context.maxIterations}${s.iterationWorkflowWide}`, - `- ${s.stepIteration}: ${context.stepIteration}${s.stepIterationTimes}`, - `- ${s.step}: ${step.name}`, - ]; - - return lines.join('\n'); -} - -/** - * Render report info for the Workflow Context section. - * Used only by buildReportInstruction() (phase 2). - */ -function renderReportContext( - report: string | ReportConfig[] | ReportObjectConfig, - reportDir: string, - language: Language, -): string { - const s = SECTION_STRINGS[language]; - const lines: string[] = [ - `- ${s.reportDirectory}: ${reportDir}/`, - ]; - - if (typeof report === 'string') { - lines.push(`- ${s.reportFile}: ${reportDir}/${report}`); - } else if (isReportObjectConfig(report)) { - lines.push(`- ${s.reportFile}: ${reportDir}/${report.name}`); - } else { - lines.push(`- ${s.reportFiles}:`); - for (const file of report) { - lines.push(` - ${file.label}: ${reportDir}/${file.path}`); - } - } - - return lines.join('\n'); -} - -/** - * Replace template placeholders in the instruction_template body. - * - * These placeholders may still be used in instruction_template for - * backward compatibility or special cases. - */ -function replaceTemplatePlaceholders( - template: string, - step: WorkflowStep, - context: InstructionContext, -): string { - let result = template; - - // These placeholders are also covered by auto-injected sections - // (User Request, Previous Response, Additional User Inputs), but kept here - // for backward compatibility with workflows that still embed them in - // instruction_template (e.g., research.yaml, magi.yaml). - // New workflows should NOT use {task} or {user_inputs} in instruction_template - // since they are auto-injected as separate sections. - - // Replace {task} - result = result.replace(/\{task\}/g, escapeTemplateChars(context.task)); - - // Replace {iteration}, {max_iterations}, and {step_iteration} - result = result.replace(/\{iteration\}/g, String(context.iteration)); - result = result.replace(/\{max_iterations\}/g, String(context.maxIterations)); - result = result.replace(/\{step_iteration\}/g, String(context.stepIteration)); - - // Replace {previous_response} - if (step.passPreviousResponse) { - if (context.previousOutput) { - result = result.replace( - /\{previous_response\}/g, - escapeTemplateChars(context.previousOutput.content), - ); - } else { - result = result.replace(/\{previous_response\}/g, ''); - } - } - - // Replace {user_inputs} - const userInputsStr = context.userInputs.join('\n'); - result = result.replace( - /\{user_inputs\}/g, - escapeTemplateChars(userInputsStr), - ); - - // Replace {report_dir} - if (context.reportDir) { - result = result.replace(/\{report_dir\}/g, context.reportDir); - } - - // Replace {report:filename} with reportDir/filename - if (context.reportDir) { - result = result.replace(/\{report:([^}]+)\}/g, (_match, filename: string) => { - return `${context.reportDir}/${filename}`; - }); - } - - return result; -} - -/** - * Build instruction from template with context values. - * - * Generates a complete instruction by auto-injecting standard sections - * around the step-specific instruction_template content: - * - * 1. Execution Context (working directory, rules) — always - * 2. Workflow Context (iteration, step, report info) — always - * 3. User Request ({task}) — unless template contains {task} - * 4. Previous Response — if passPreviousResponse and has content, unless template contains {previous_response} - * 5. Additional User Inputs — unless template contains {user_inputs} - * 6. Instructions header + instruction_template content — always - * 7. Status Output Rules — when step has tag-based rules (not all ai()/aggregate) - * - * Template placeholders ({task}, {previous_response}, etc.) are still replaced - * within the instruction_template body for backward compatibility. - * When a placeholder is present in the template, the corresponding - * auto-injected section is skipped to avoid duplication. - */ -export function buildInstruction( - step: WorkflowStep, - context: InstructionContext, -): string { - const language = context.language ?? 'en'; - const s = SECTION_STRINGS[language]; - const sections: string[] = []; - - // 1. Execution context metadata (working directory + rules + edit permission) - const metadata = buildExecutionMetadata(context, step.edit); - sections.push(renderExecutionMetadata(metadata)); - - // 2. Workflow Context (iteration, step, report info) - sections.push(renderWorkflowContext(step, context, language)); - - // Skip auto-injection for sections whose placeholders exist in the template, - // to avoid duplicate content. Templates using placeholders handle their own layout. - const tmpl = step.instructionTemplate; - const hasTaskPlaceholder = tmpl.includes('{task}'); - const hasPreviousResponsePlaceholder = tmpl.includes('{previous_response}'); - const hasUserInputsPlaceholder = tmpl.includes('{user_inputs}'); - - // 3. User Request (skip if template embeds {task} directly) - if (!hasTaskPlaceholder) { - sections.push(`${s.userRequest}\n${escapeTemplateChars(context.task)}`); - } - - // 4. Previous Response (skip if template embeds {previous_response} directly) - if (step.passPreviousResponse && context.previousOutput && !hasPreviousResponsePlaceholder) { - sections.push( - `${s.previousResponse}\n${escapeTemplateChars(context.previousOutput.content)}`, - ); - } - - // 5. Additional User Inputs (skip if template embeds {user_inputs} directly) - if (!hasUserInputsPlaceholder) { - const userInputsStr = context.userInputs.join('\n'); - sections.push(`${s.additionalUserInputs}\n${escapeTemplateChars(userInputsStr)}`); - } - - // 6. Instructions header + instruction_template content - const processedTemplate = replaceTemplatePlaceholders( - step.instructionTemplate, - step, - context, - ); - sections.push(`${s.instructions}\n${processedTemplate}`); - - // 7. Status Output Rules (for tag-based detection in Phase 1) - // Skip if all rules are ai() or aggregate conditions (no tags needed) - if (hasTagBasedRules(step)) { - const statusRulesPrompt = generateStatusRulesFromRules(step.name, step.rules!, language); - sections.push(statusRulesPrompt); - } - - return sections.join('\n\n'); -} - -/** Localized strings for report phase execution rules */ -const REPORT_PHASE_STRINGS = { - en: { - noSourceEdit: '**Do NOT modify project source files.** Only output report files.', - instructionBody: 'Output the results of your previous work as a report.', - }, - ja: { - noSourceEdit: '**ăƒ—ăƒ­ă‚žă‚§ă‚Żăƒˆăźă‚œăƒŒă‚čăƒ•ă‚Ąă‚€ăƒ«ă‚’ć€‰æ›Žă—ăȘいでください。** ăƒŹăƒăƒŒăƒˆăƒ•ă‚Ąă‚€ăƒ«ăźăżć‡ș抛しどください。', - instructionBody: '才ぼă‚čăƒ†ăƒƒăƒ—ăźäœœæ„­ç”æžœă‚’ăƒŹăƒăƒŒăƒˆăšă—ăŠć‡ș抛しどください。', - }, -} as const; - -/** - * Context for building report phase instruction. - */ -export interface ReportInstructionContext { - /** Working directory */ - cwd: string; - /** Report directory path */ - reportDir: string; - /** Step iteration (for {step_iteration} replacement) */ - stepIteration: number; - /** Language */ - language?: Language; -} - -/** - * Build instruction for phase 2 (report output). - * - * Separate from buildInstruction() — only includes: - * - Execution Context (cwd + rules) - * - Workflow Context (report info only) - * - Report output instruction + format - * - * Does NOT include: User Request, Previous Response, User Inputs, - * Status rules, instruction_template. - */ -export function buildReportInstruction( - step: WorkflowStep, - context: ReportInstructionContext, -): string { - if (!step.report) { - throw new Error(`buildReportInstruction called for step "${step.name}" which has no report config`); - } - - const language = context.language ?? 'en'; - const s = SECTION_STRINGS[language]; - const r = REPORT_PHASE_STRINGS[language]; - const m = METADATA_STRINGS[language]; - const sections: string[] = []; - - // 1. Execution Context - const execLines = [ - m.heading, - `- ${m.workingDirectory}: ${context.cwd}`, - '', - m.rulesHeading, - `- ${m.noCommit}`, - `- ${m.noCd}`, - `- ${r.noSourceEdit}`, - ]; - if (m.note) { - execLines.push(''); - execLines.push(m.note); - } - execLines.push(''); - sections.push(execLines.join('\n')); - - // 2. Workflow Context (report info only) - const workflowLines = [ - s.workflowContext, - renderReportContext(step.report, context.reportDir, language), - ]; - sections.push(workflowLines.join('\n')); - - // 3. Instructions + report output instruction + format - const instrParts: string[] = [ - `${s.instructions}`, - r.instructionBody, - ]; - - // Report output instruction (auto-generated or explicit order) - const reportContext: InstructionContext = { - task: '', - iteration: 0, - maxIterations: 0, - stepIteration: context.stepIteration, - cwd: context.cwd, - projectCwd: context.cwd, - userInputs: [], - reportDir: context.reportDir, - language, - }; - - if (isReportObjectConfig(step.report) && step.report.order) { - const processedOrder = replaceTemplatePlaceholders(step.report.order.trimEnd(), step, reportContext); - instrParts.push(''); - instrParts.push(processedOrder); - } else { - const reportInstruction = renderReportOutputInstruction(step, reportContext, language); - if (reportInstruction) { - instrParts.push(''); - instrParts.push(reportInstruction); - } - } - - // Report format - if (isReportObjectConfig(step.report) && step.report.format) { - const processedFormat = replaceTemplatePlaceholders(step.report.format.trimEnd(), step, reportContext); - instrParts.push(''); - instrParts.push(processedFormat); - } - - sections.push(instrParts.join('\n')); - - return sections.join('\n\n'); -} - -/** Localized strings for status judgment phase (Phase 3) */ -const STATUS_JUDGMENT_STRINGS = { - en: { - header: 'Review your work results and determine the status. Do NOT perform any additional work.', - }, - ja: { - header: 'äœœæ„­ç”æžœă‚’æŒŻă‚Šèż”ă‚Šă€ă‚čăƒ†ăƒŒă‚żă‚čă‚’ćˆ€ćźšă—ăŠăă ă•ă„ă€‚èżœćŠ ăźäœœæ„­ăŻèĄŒă‚ăȘいでください。', - }, -} as const; - -/** - * Context for building status judgment instruction (Phase 3). - */ -export interface StatusJudgmentContext { - /** Language */ - language?: Language; -} - -/** - * Build instruction for Phase 3 (status judgment). - * - * Resumes the agent session and asks it to evaluate its work - * and output the appropriate status tag. No tools are allowed. - * - * Includes: - * - Header instruction (review and determine status) - * - Status rules (criteria table + output format) from generateStatusRulesFromRules() - */ -export function buildStatusJudgmentInstruction( - step: WorkflowStep, - context: StatusJudgmentContext, -): string { - if (!step.rules || step.rules.length === 0) { - throw new Error(`buildStatusJudgmentInstruction called for step "${step.name}" which has no rules`); - } - - const language = context.language ?? 'en'; - const s = STATUS_JUDGMENT_STRINGS[language]; - const sections: string[] = []; - - // Header - sections.push(s.header); - - // Status rules (criteria table + output format) - const generatedPrompt = generateStatusRulesFromRules(step.name, step.rules, language); - sections.push(generatedPrompt); - - return sections.join('\n\n'); -} diff --git a/src/workflow/instruction/InstructionBuilder.ts b/src/workflow/instruction/InstructionBuilder.ts new file mode 100644 index 0000000..42554e5 --- /dev/null +++ b/src/workflow/instruction/InstructionBuilder.ts @@ -0,0 +1,203 @@ +/** + * Phase 1 instruction builder + * + * Builds the instruction string for main agent execution by: + * 1. Auto-injecting standard sections (Execution Context, Workflow Context, + * User Request, Previous Response, Additional User Inputs, Instructions header, + * Status Output Rules) + * 2. Replacing template placeholders with actual values + */ + +import type { WorkflowStep, Language, ReportConfig, ReportObjectConfig } from '../../models/types.js'; +import { hasTagBasedRules } from '../rule-utils.js'; +import type { InstructionContext } from '../instruction-context.js'; +import { buildExecutionMetadata, renderExecutionMetadata } from '../instruction-context.js'; +import { generateStatusRulesFromRules } from '../status-rules.js'; +import { escapeTemplateChars, replaceTemplatePlaceholders } from './escape.js'; + +/** + * Check if a report config is the object form (ReportObjectConfig). + */ +export function isReportObjectConfig(report: string | ReportConfig[] | ReportObjectConfig): report is ReportObjectConfig { + return typeof report === 'object' && !Array.isArray(report) && 'name' in report; +} + +/** Localized strings for auto-injected sections */ +const SECTION_STRINGS = { + en: { + workflowContext: '## Workflow Context', + iteration: 'Iteration', + iterationWorkflowWide: '(workflow-wide)', + stepIteration: 'Step Iteration', + stepIterationTimes: '(times this step has run)', + step: 'Step', + reportDirectory: 'Report Directory', + reportFile: 'Report File', + reportFiles: 'Report Files', + userRequest: '## User Request', + previousResponse: '## Previous Response', + additionalUserInputs: '## Additional User Inputs', + instructions: '## Instructions', + }, + ja: { + workflowContext: '## Workflow Context', + iteration: 'Iteration', + iterationWorkflowWide: 'ïŒˆăƒŻăƒŒă‚Żăƒ•ăƒ­ăƒŒć…šäœ“ïŒ‰', + stepIteration: 'Step Iteration', + stepIterationTimes: 'ïŒˆă“ăźă‚čăƒ†ăƒƒăƒ—ăźćźŸèĄŒć›žæ•°ïŒ‰', + step: 'Step', + reportDirectory: 'Report Directory', + reportFile: 'Report File', + reportFiles: 'Report Files', + userRequest: '## User Request', + previousResponse: '## Previous Response', + additionalUserInputs: '## Additional User Inputs', + instructions: '## Instructions', + }, +} as const; + +/** Localized strings for auto-generated report output instructions */ +const REPORT_OUTPUT_STRINGS = { + en: { + singleHeading: '**Report output:** Output to the `Report File` specified above.', + multiHeading: '**Report output:** Output to the `Report Files` specified above.', + createRule: '- If file does not exist: Create new file', + appendRule: '- If file exists: Append with `## Iteration {step_iteration}` section', + }, + ja: { + singleHeading: '**ăƒŹăƒăƒŒăƒˆć‡ș抛:** `Report File` にć‡ș抛しどください。', + multiHeading: '**ăƒŹăƒăƒŒăƒˆć‡ș抛:** Report Files にć‡ș抛しどください。', + createRule: '- ăƒ•ă‚Ąă‚€ăƒ«ăŒć­˜ćœšă—ăȘい栮搈: 新芏䜜成', + appendRule: '- ăƒ•ă‚Ąă‚€ăƒ«ăŒć­˜ćœšă™ă‚‹ć Žćˆ: `## Iteration {step_iteration}` ă‚»ă‚Żă‚·ăƒ§ăƒłă‚’èżœèš˜', + }, +} as const; + +/** + * Builds Phase 1 instructions for agent execution. + * + * Stateless builder — all data is passed via constructor context. + */ +export class InstructionBuilder { + constructor( + private readonly step: WorkflowStep, + private readonly context: InstructionContext, + ) {} + + /** + * Build the complete instruction string. + * + * Generates a complete instruction by auto-injecting standard sections + * around the step-specific instruction_template content. + */ + build(): string { + const language = this.context.language ?? 'en'; + const s = SECTION_STRINGS[language]; + const sections: string[] = []; + + // 1. Execution context metadata (working directory + rules + edit permission) + const metadata = buildExecutionMetadata(this.context, this.step.edit); + sections.push(renderExecutionMetadata(metadata)); + + // 2. Workflow Context (iteration, step, report info) + sections.push(this.renderWorkflowContext(language)); + + // Skip auto-injection for sections whose placeholders exist in the template, + // to avoid duplicate content. + const tmpl = this.step.instructionTemplate; + const hasTaskPlaceholder = tmpl.includes('{task}'); + const hasPreviousResponsePlaceholder = tmpl.includes('{previous_response}'); + const hasUserInputsPlaceholder = tmpl.includes('{user_inputs}'); + + // 3. User Request (skip if template embeds {task} directly) + if (!hasTaskPlaceholder) { + sections.push(`${s.userRequest}\n${escapeTemplateChars(this.context.task)}`); + } + + // 4. Previous Response (skip if template embeds {previous_response} directly) + if (this.step.passPreviousResponse && this.context.previousOutput && !hasPreviousResponsePlaceholder) { + sections.push( + `${s.previousResponse}\n${escapeTemplateChars(this.context.previousOutput.content)}`, + ); + } + + // 5. Additional User Inputs (skip if template embeds {user_inputs} directly) + if (!hasUserInputsPlaceholder) { + const userInputsStr = this.context.userInputs.join('\n'); + sections.push(`${s.additionalUserInputs}\n${escapeTemplateChars(userInputsStr)}`); + } + + // 6. Instructions header + instruction_template content + const processedTemplate = replaceTemplatePlaceholders( + this.step.instructionTemplate, + this.step, + this.context, + ); + sections.push(`${s.instructions}\n${processedTemplate}`); + + // 7. Status Output Rules (for tag-based detection in Phase 1) + if (hasTagBasedRules(this.step)) { + const statusRulesPrompt = generateStatusRulesFromRules(this.step.name, this.step.rules!, language); + sections.push(statusRulesPrompt); + } + + return sections.join('\n\n'); + } + + private renderWorkflowContext(language: Language): string { + const s = SECTION_STRINGS[language]; + const lines: string[] = [ + s.workflowContext, + `- ${s.iteration}: ${this.context.iteration}/${this.context.maxIterations}${s.iterationWorkflowWide}`, + `- ${s.stepIteration}: ${this.context.stepIteration}${s.stepIterationTimes}`, + `- ${s.step}: ${this.step.name}`, + ]; + return lines.join('\n'); + } +} + +/** + * Render report context info for Workflow Context section. + * Used by ReportInstructionBuilder. + */ +export function renderReportContext( + report: string | ReportConfig[] | ReportObjectConfig, + reportDir: string, + language: Language, +): string { + const s = SECTION_STRINGS[language]; + const lines: string[] = [ + `- ${s.reportDirectory}: ${reportDir}/`, + ]; + + if (typeof report === 'string') { + lines.push(`- ${s.reportFile}: ${reportDir}/${report}`); + } else if (isReportObjectConfig(report)) { + lines.push(`- ${s.reportFile}: ${reportDir}/${report.name}`); + } else { + lines.push(`- ${s.reportFiles}:`); + for (const file of report) { + lines.push(` - ${file.label}: ${reportDir}/${file.path}`); + } + } + + return lines.join('\n'); +} + +/** + * Generate report output instructions from step.report config. + * Returns undefined if step has no report or no reportDir. + */ +export function renderReportOutputInstruction( + step: WorkflowStep, + context: InstructionContext, + language: Language, +): string | undefined { + if (!step.report || !context.reportDir) return undefined; + + const s = REPORT_OUTPUT_STRINGS[language]; + const isMulti = Array.isArray(step.report); + const heading = isMulti ? s.multiHeading : s.singleHeading; + const appendRule = s.appendRule.replace('{step_iteration}', String(context.stepIteration)); + + return [heading, s.createRule, appendRule].join('\n'); +} diff --git a/src/workflow/instruction/ReportInstructionBuilder.ts b/src/workflow/instruction/ReportInstructionBuilder.ts new file mode 100644 index 0000000..78ba5f9 --- /dev/null +++ b/src/workflow/instruction/ReportInstructionBuilder.ts @@ -0,0 +1,137 @@ +/** + * Phase 2 instruction builder (report output) + * + * Builds the instruction for the report output phase. Includes: + * - Execution Context (cwd + rules) + * - Workflow Context (report info only) + * - Report output instruction + format + * + * Does NOT include: User Request, Previous Response, User Inputs, + * Status rules, instruction_template. + */ + +import type { WorkflowStep, Language } from '../../models/types.js'; +import type { InstructionContext } from '../instruction-context.js'; +import { METADATA_STRINGS } from '../instruction-context.js'; +import { replaceTemplatePlaceholders } from './escape.js'; +import { isReportObjectConfig, renderReportContext, renderReportOutputInstruction } from './InstructionBuilder.js'; + +/** Localized strings for report phase execution rules */ +const REPORT_PHASE_STRINGS = { + en: { + noSourceEdit: '**Do NOT modify project source files.** Only output report files.', + instructionBody: 'Output the results of your previous work as a report.', + }, + ja: { + noSourceEdit: '**ăƒ—ăƒ­ă‚žă‚§ă‚Żăƒˆăźă‚œăƒŒă‚čăƒ•ă‚Ąă‚€ăƒ«ă‚’ć€‰æ›Žă—ăȘいでください。** ăƒŹăƒăƒŒăƒˆăƒ•ă‚Ąă‚€ăƒ«ăźăżć‡ș抛しどください。', + instructionBody: '才ぼă‚čăƒ†ăƒƒăƒ—ăźäœœæ„­ç”æžœă‚’ăƒŹăƒăƒŒăƒˆăšă—ăŠć‡ș抛しどください。', + }, +} as const; + +/** Localized section strings (shared subset) */ +const SECTION_STRINGS = { + en: { workflowContext: '## Workflow Context', instructions: '## Instructions' }, + ja: { workflowContext: '## Workflow Context', instructions: '## Instructions' }, +} as const; + +/** + * Context for building report phase instruction. + */ +export interface ReportInstructionContext { + /** Working directory */ + cwd: string; + /** Report directory path */ + reportDir: string; + /** Step iteration (for {step_iteration} replacement) */ + stepIteration: number; + /** Language */ + language?: Language; +} + +/** + * Builds Phase 2 (report output) instructions. + */ +export class ReportInstructionBuilder { + constructor( + private readonly step: WorkflowStep, + private readonly context: ReportInstructionContext, + ) {} + + build(): string { + if (!this.step.report) { + throw new Error(`ReportInstructionBuilder called for step "${this.step.name}" which has no report config`); + } + + const language = this.context.language ?? 'en'; + const s = SECTION_STRINGS[language]; + const r = REPORT_PHASE_STRINGS[language]; + const m = METADATA_STRINGS[language]; + const sections: string[] = []; + + // 1. Execution Context + const execLines = [ + m.heading, + `- ${m.workingDirectory}: ${this.context.cwd}`, + '', + m.rulesHeading, + `- ${m.noCommit}`, + `- ${m.noCd}`, + `- ${r.noSourceEdit}`, + ]; + if (m.note) { + execLines.push(''); + execLines.push(m.note); + } + execLines.push(''); + sections.push(execLines.join('\n')); + + // 2. Workflow Context (report info only) + const workflowLines = [ + s.workflowContext, + renderReportContext(this.step.report, this.context.reportDir, language), + ]; + sections.push(workflowLines.join('\n')); + + // 3. Instructions + report output instruction + format + const instrParts: string[] = [ + s.instructions, + r.instructionBody, + ]; + + // Report output instruction (auto-generated or explicit order) + const reportContext: InstructionContext = { + task: '', + iteration: 0, + maxIterations: 0, + stepIteration: this.context.stepIteration, + cwd: this.context.cwd, + projectCwd: this.context.cwd, + userInputs: [], + reportDir: this.context.reportDir, + language, + }; + + if (isReportObjectConfig(this.step.report) && this.step.report.order) { + const processedOrder = replaceTemplatePlaceholders(this.step.report.order.trimEnd(), this.step, reportContext); + instrParts.push(''); + instrParts.push(processedOrder); + } else { + const reportInstruction = renderReportOutputInstruction(this.step, reportContext, language); + if (reportInstruction) { + instrParts.push(''); + instrParts.push(reportInstruction); + } + } + + // Report format + if (isReportObjectConfig(this.step.report) && this.step.report.format) { + const processedFormat = replaceTemplatePlaceholders(this.step.report.format.trimEnd(), this.step, reportContext); + instrParts.push(''); + instrParts.push(processedFormat); + } + + sections.push(instrParts.join('\n')); + + return sections.join('\n\n'); + } +} diff --git a/src/workflow/instruction/StatusJudgmentBuilder.ts b/src/workflow/instruction/StatusJudgmentBuilder.ts new file mode 100644 index 0000000..7c0f52f --- /dev/null +++ b/src/workflow/instruction/StatusJudgmentBuilder.ts @@ -0,0 +1,60 @@ +/** + * Phase 3 instruction builder (status judgment) + * + * Resumes the agent session and asks it to evaluate its work + * and output the appropriate status tag. No tools are allowed. + * + * Includes: + * - Header instruction (review and determine status) + * - Status rules (criteria table + output format) + */ + +import type { WorkflowStep, Language } from '../../models/types.js'; +import { generateStatusRulesFromRules } from '../status-rules.js'; + +/** Localized strings for status judgment phase */ +const STATUS_JUDGMENT_STRINGS = { + en: { + header: 'Review your work results and determine the status. Do NOT perform any additional work.', + }, + ja: { + header: 'äœœæ„­ç”æžœă‚’æŒŻă‚Šèż”ă‚Šă€ă‚čăƒ†ăƒŒă‚żă‚čă‚’ćˆ€ćźšă—ăŠăă ă•ă„ă€‚èżœćŠ ăźäœœæ„­ăŻèĄŒă‚ăȘいでください。', + }, +} as const; + +/** + * Context for building status judgment instruction. + */ +export interface StatusJudgmentContext { + /** Language */ + language?: Language; +} + +/** + * Builds Phase 3 (status judgment) instructions. + */ +export class StatusJudgmentBuilder { + constructor( + private readonly step: WorkflowStep, + private readonly context: StatusJudgmentContext, + ) {} + + build(): string { + if (!this.step.rules || this.step.rules.length === 0) { + throw new Error(`StatusJudgmentBuilder called for step "${this.step.name}" which has no rules`); + } + + const language = this.context.language ?? 'en'; + const s = STATUS_JUDGMENT_STRINGS[language]; + const sections: string[] = []; + + // Header + sections.push(s.header); + + // Status rules (criteria table + output format) + const generatedPrompt = generateStatusRulesFromRules(this.step.name, this.step.rules, language); + sections.push(generatedPrompt); + + return sections.join('\n\n'); + } +} diff --git a/src/workflow/instruction/escape.ts b/src/workflow/instruction/escape.ts new file mode 100644 index 0000000..11aa2be --- /dev/null +++ b/src/workflow/instruction/escape.ts @@ -0,0 +1,70 @@ +/** + * Template escaping and placeholder replacement utilities + * + * Used by instruction builders to process instruction_template content. + */ + +import type { WorkflowStep } from '../../models/types.js'; +import type { InstructionContext } from '../instruction-context.js'; + +/** + * Escape special characters in dynamic content to prevent template injection. + */ +export function escapeTemplateChars(str: string): string { + return str.replace(/\{/g, '').replace(/\}/g, ''); +} + +/** + * Replace template placeholders in the instruction_template body. + * + * These placeholders may still be used in instruction_template for + * backward compatibility or special cases. + */ +export function replaceTemplatePlaceholders( + template: string, + step: WorkflowStep, + context: InstructionContext, +): string { + let result = template; + + // Replace {task} + result = result.replace(/\{task\}/g, escapeTemplateChars(context.task)); + + // Replace {iteration}, {max_iterations}, and {step_iteration} + result = result.replace(/\{iteration\}/g, String(context.iteration)); + result = result.replace(/\{max_iterations\}/g, String(context.maxIterations)); + result = result.replace(/\{step_iteration\}/g, String(context.stepIteration)); + + // Replace {previous_response} + if (step.passPreviousResponse) { + if (context.previousOutput) { + result = result.replace( + /\{previous_response\}/g, + escapeTemplateChars(context.previousOutput.content), + ); + } else { + result = result.replace(/\{previous_response\}/g, ''); + } + } + + // Replace {user_inputs} + const userInputsStr = context.userInputs.join('\n'); + result = result.replace( + /\{user_inputs\}/g, + escapeTemplateChars(userInputsStr), + ); + + // Replace {report_dir} + if (context.reportDir) { + result = result.replace(/\{report_dir\}/g, context.reportDir); + } + + // Replace {report:filename} with reportDir/filename + if (context.reportDir) { + result = result.replace(/\{report:([^}]+)\}/g, (_match, filename: string) => { + return `${context.reportDir}/${filename}`; + }); + } + + return result; +} diff --git a/src/workflow/instruction/index.ts b/src/workflow/instruction/index.ts new file mode 100644 index 0000000..af8faf2 --- /dev/null +++ b/src/workflow/instruction/index.ts @@ -0,0 +1,8 @@ +/** + * Instruction builders - barrel exports + */ + +export { InstructionBuilder, isReportObjectConfig, renderReportContext, renderReportOutputInstruction } from './InstructionBuilder.js'; +export { ReportInstructionBuilder, type ReportInstructionContext } from './ReportInstructionBuilder.js'; +export { StatusJudgmentBuilder, type StatusJudgmentContext } from './StatusJudgmentBuilder.js'; +export { escapeTemplateChars, replaceTemplatePlaceholders } from './escape.js'; diff --git a/src/workflow/phase-runner.ts b/src/workflow/phase-runner.ts index 2e5b215..6a0630e 100644 --- a/src/workflow/phase-runner.ts +++ b/src/workflow/phase-runner.ts @@ -7,10 +7,8 @@ import type { WorkflowStep, Language } from '../models/types.js'; import { runAgent, type RunAgentOptions } from '../agents/runner.js'; -import { - buildReportInstruction as buildReportInstructionFromTemplate, - buildStatusJudgmentInstruction as buildStatusJudgmentInstructionFromTemplate, -} from './instruction-builder.js'; +import { ReportInstructionBuilder } from './instruction/ReportInstructionBuilder.js'; +import { StatusJudgmentBuilder } from './instruction/StatusJudgmentBuilder.js'; import { hasTagBasedRules } from './rule-utils.js'; import { createLogger } from '../utils/debug.js'; @@ -56,12 +54,12 @@ export async function runReportPhase( log.debug('Running report phase', { step: step.name, sessionId }); - const reportInstruction = buildReportInstructionFromTemplate(step, { + const reportInstruction = new ReportInstructionBuilder(step, { cwd: ctx.cwd, reportDir: ctx.reportDir, stepIteration, language: ctx.language, - }); + }).build(); const reportOptions = ctx.buildResumeOptions(step, sessionId, { allowedTools: ['Write'], @@ -92,9 +90,9 @@ export async function runStatusJudgmentPhase( log.debug('Running status judgment phase', { step: step.name, sessionId }); - const judgmentInstruction = buildStatusJudgmentInstructionFromTemplate(step, { + const judgmentInstruction = new StatusJudgmentBuilder(step, { language: ctx.language, - }); + }).build(); const judgmentOptions = ctx.buildResumeOptions(step, sessionId, { allowedTools: [], diff --git a/src/workflow/rule-evaluator.ts b/src/workflow/rule-evaluator.ts deleted file mode 100644 index 3d00ac9..0000000 --- a/src/workflow/rule-evaluator.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Rule evaluation logic extracted from engine.ts. - * - * Evaluates workflow step rules to determine the matched rule index. - * Supports tag-based detection, ai() conditions, aggregate conditions, - * and AI judge fallback. - */ - -import type { - WorkflowStep, - WorkflowState, - RuleMatchMethod, -} from '../models/types.js'; -import { detectRuleIndex, callAiJudge } from '../claude/client.js'; -import { createLogger } from '../utils/debug.js'; - -const log = createLogger('rule-evaluator'); - -export interface RuleMatch { - index: number; - method: RuleMatchMethod; -} - -export interface RuleEvaluatorContext { - /** Workflow state (for accessing stepOutputs in aggregate evaluation) */ - state: WorkflowState; - /** Working directory (for AI judge calls) */ - cwd: string; -} - -/** - * Detect matched rule for a step's response. - * Evaluation order (first match wins): - * 1. Aggregate conditions: all()/any() — evaluate sub-step results - * 2. Tag detection from Phase 3 output - * 3. Tag detection from Phase 1 output (fallback) - * 4. ai() condition evaluation via AI judge - * 5. All-conditions AI judge (final fallback) - * - * Returns undefined for steps without rules. - * Throws if rules exist but no rule matched (Fail Fast). - * - * @param step - The workflow step - * @param agentContent - Phase 1 output (main execution) - * @param tagContent - Phase 3 output (status judgment); empty string skips tag detection - * @param ctx - Evaluation context (state, cwd) - */ -export async function detectMatchedRule( - step: WorkflowStep, - agentContent: string, - tagContent: string, - ctx: RuleEvaluatorContext, -): Promise { - if (!step.rules || step.rules.length === 0) return undefined; - - // 1. Aggregate conditions (all/any) — only meaningful for parallel parent steps - const aggIndex = evaluateAggregateConditions(step, ctx.state); - if (aggIndex >= 0) { - return { index: aggIndex, method: 'aggregate' }; - } - - // 2. Tag detection from Phase 3 output - if (tagContent) { - const ruleIndex = detectRuleIndex(tagContent, step.name); - if (ruleIndex >= 0 && ruleIndex < step.rules.length) { - return { index: ruleIndex, method: 'phase3_tag' }; - } - } - - // 3. Tag detection from Phase 1 output (fallback) - if (agentContent) { - const ruleIndex = detectRuleIndex(agentContent, step.name); - if (ruleIndex >= 0 && ruleIndex < step.rules.length) { - return { index: ruleIndex, method: 'phase1_tag' }; - } - } - - // 4. AI judge for ai() conditions only - const aiRuleIndex = await evaluateAiConditions(step, agentContent, ctx.cwd); - if (aiRuleIndex >= 0) { - return { index: aiRuleIndex, method: 'ai_judge' }; - } - - // 5. AI judge for all conditions (final fallback) - const fallbackIndex = await evaluateAllConditionsViaAiJudge(step, agentContent, ctx.cwd); - if (fallbackIndex >= 0) { - return { index: fallbackIndex, method: 'ai_judge_fallback' }; - } - - throw new Error(`Status not found for step "${step.name}": no rule matched after all detection phases`); -} - -/** - * Evaluate aggregate conditions (all()/any()) against sub-step results. - * Returns the 0-based rule index in the step's rules array, or -1 if no match. - * - * For each aggregate rule, checks the matched condition text of sub-steps: - * - all("X"): true when ALL sub-steps have matched condition === X - * - any("X"): true when at least ONE sub-step has matched condition === X - * - * Edge cases per spec: - * - Sub-step with no matched rule: all() → false, any() → skip that sub-step - * - No sub-steps (0 ä»¶): both → false - * - Non-parallel step: both → false - */ -export function evaluateAggregateConditions(step: WorkflowStep, state: WorkflowState): number { - if (!step.rules || !step.parallel || step.parallel.length === 0) return -1; - - for (let i = 0; i < step.rules.length; i++) { - const rule = step.rules[i]!; - if (!rule.isAggregateCondition || !rule.aggregateType || !rule.aggregateConditionText) { - continue; - } - - const subSteps = step.parallel; - const targetCondition = rule.aggregateConditionText; - - if (rule.aggregateType === 'all') { - const allMatch = subSteps.every((sub) => { - const output = state.stepOutputs.get(sub.name); - if (!output || output.matchedRuleIndex == null || !sub.rules) return false; - const matchedRule = sub.rules[output.matchedRuleIndex]; - return matchedRule?.condition === targetCondition; - }); - if (allMatch) { - log.debug('Aggregate all() matched', { step: step.name, condition: targetCondition, ruleIndex: i }); - return i; - } - } else { - // 'any' - const anyMatch = subSteps.some((sub) => { - const output = state.stepOutputs.get(sub.name); - if (!output || output.matchedRuleIndex == null || !sub.rules) return false; - const matchedRule = sub.rules[output.matchedRuleIndex]; - return matchedRule?.condition === targetCondition; - }); - if (anyMatch) { - log.debug('Aggregate any() matched', { step: step.name, condition: targetCondition, ruleIndex: i }); - return i; - } - } - } - - return -1; -} - -/** - * Evaluate ai() conditions via AI judge. - * Collects all ai() rules, calls the judge, and maps the result back to the original rule index. - * Returns the 0-based rule index in the step's rules array, or -1 if no match. - */ -export async function evaluateAiConditions(step: WorkflowStep, agentOutput: string, cwd: string): Promise { - if (!step.rules) return -1; - - const aiConditions: { index: number; text: string }[] = []; - for (let i = 0; i < step.rules.length; i++) { - const rule = step.rules[i]!; - if (rule.isAiCondition && rule.aiConditionText) { - aiConditions.push({ index: i, text: rule.aiConditionText }); - } - } - - if (aiConditions.length === 0) return -1; - - log.debug('Evaluating ai() conditions via judge', { - step: step.name, - conditionCount: aiConditions.length, - }); - - // Remap: judge returns 0-based index within aiConditions array - const judgeConditions = aiConditions.map((c, i) => ({ index: i, text: c.text })); - const judgeResult = await callAiJudge(agentOutput, judgeConditions, { cwd }); - - if (judgeResult >= 0 && judgeResult < aiConditions.length) { - const matched = aiConditions[judgeResult]!; - log.debug('AI judge matched condition', { - step: step.name, - judgeResult, - originalRuleIndex: matched.index, - condition: matched.text, - }); - return matched.index; - } - - log.debug('AI judge did not match any condition', { step: step.name }); - return -1; -} - -/** - * Final fallback: evaluate ALL rule conditions via AI judge. - * Unlike evaluateAiConditions (which only handles ai() flagged rules), - * this sends every rule's condition text to the judge. - * Returns the 0-based rule index, or -1 if no match. - */ -export async function evaluateAllConditionsViaAiJudge(step: WorkflowStep, agentOutput: string, cwd: string): Promise { - if (!step.rules || step.rules.length === 0) return -1; - - const conditions = step.rules.map((rule, i) => ({ index: i, text: rule.condition })); - - log.debug('Evaluating all conditions via AI judge (final fallback)', { - step: step.name, - conditionCount: conditions.length, - }); - - const judgeResult = await callAiJudge(agentOutput, conditions, { cwd }); - - if (judgeResult >= 0 && judgeResult < conditions.length) { - log.debug('AI judge (fallback) matched condition', { - step: step.name, - ruleIndex: judgeResult, - condition: conditions[judgeResult]!.text, - }); - return judgeResult; - } - - log.debug('AI judge (fallback) did not match any condition', { step: step.name }); - return -1; -}