From d900ee8bc4237cfd9dfc5b4b8cfcde049748b5a2 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:02:04 +0900 Subject: [PATCH] feat: answer status, autoCommit, permission_mode, verbose logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - answer: planner が質問と判断したら COMPLETE で終了する仕組み - autoCommit: worktree タスク完了時に自動 git commit - permission_mode: workflow YAML でステップごとの権限指定 - verbose: verbose 時のファイル+stderr 二重出力修正 --- resources/global/en/workflows/default.yaml | 23 +- resources/global/en/workflows/simple.yaml | 19 +- resources/global/ja/workflows/default.yaml | 23 +- resources/global/ja/workflows/simple.yaml | 19 +- src/__tests__/autoCommit.test.ts | 142 +++++++++++++ src/__tests__/debug.test.ts | 233 +++++++++++++++++++++ src/__tests__/models.test.ts | 74 +++++++ src/agents/runner.ts | 20 +- src/claude/client.ts | 7 +- src/cli.ts | 32 +-- src/commands/taskExecution.ts | 116 ++++++---- src/commands/watchTasks.ts | 44 +--- src/config/workflowLoader.ts | 1 + src/models/schemas.ts | 9 + src/models/types.ts | 9 +- src/providers/claude.ts | 2 + src/providers/index.ts | 4 +- src/task/autoCommit.ts | 86 ++++++++ src/task/index.ts | 1 + src/utils/debug.ts | 62 +++--- src/workflow/engine.ts | 22 ++ src/workflow/transitions.ts | 1 + 22 files changed, 823 insertions(+), 126 deletions(-) create mode 100644 src/__tests__/autoCommit.test.ts create mode 100644 src/__tests__/debug.test.ts create mode 100644 src/task/autoCommit.ts diff --git a/resources/global/en/workflows/default.yaml b/resources/global/en/workflows/default.yaml index 09af0ff..28ad744 100644 --- a/resources/global/en/workflows/default.yaml +++ b/resources/global/en/workflows/default.yaml @@ -39,6 +39,7 @@ steps: | Situation | Judgment | |-----------|----------| | Requirements clear and implementable | DONE | + | User is asking a question (not an implementation task) | ANSWER | | Requirements unclear, insufficient info | BLOCKED | ## Output Format @@ -46,6 +47,7 @@ steps: | Situation | Tag | |-----------|-----| | Analysis complete | `[PLANNER:DONE]` | + | Question answered | `[PLANNER:ANSWER]` | | Insufficient info | `[PLANNER:BLOCKED]` | ### Output Examples @@ -55,6 +57,13 @@ steps: [PLANNER:DONE] ``` + **ANSWER case:** + ``` + {Answer to the question} + + [PLANNER:ANSWER] + ``` + **BLOCKED case:** ``` [PLANNER:BLOCKED] @@ -80,10 +89,15 @@ steps: ## Instructions Analyze the task and create an implementation plan. + **Judgment criteria:** + - If the user input is an implementation task → create a plan and output `[PLANNER:DONE]` + - If the user input is a question → research, answer, and output `[PLANNER:ANSWER]` + - If there is insufficient information → output `[PLANNER:BLOCKED]` + **Note:** If returned from implement step (Previous Response exists), review and revise the plan based on that feedback (replan). - **Tasks:** + **Tasks (for implementation tasks):** 1. Understand the requirements 2. Identify impact scope 3. Decide implementation approach @@ -117,6 +131,8 @@ steps: transitions: - condition: done next_step: implement + - condition: answer + next_step: COMPLETE - condition: blocked next_step: ABORT @@ -131,6 +147,7 @@ steps: - Bash - WebSearch - WebFetch + permission_mode: acceptEdits status_rules_prompt: | # ⚠️ REQUIRED: Status Output Rules ⚠️ @@ -360,6 +377,7 @@ steps: - Bash - WebSearch - WebFetch + permission_mode: acceptEdits status_rules_prompt: | # ⚠️ REQUIRED: Status Output Rules ⚠️ @@ -545,6 +563,7 @@ steps: - Bash - WebSearch - WebFetch + permission_mode: acceptEdits status_rules_prompt: | # ⚠️ REQUIRED: Status Output Rules ⚠️ @@ -727,6 +746,7 @@ steps: - Bash - WebSearch - WebFetch + permission_mode: acceptEdits status_rules_prompt: | # ⚠️ REQUIRED: Status Output Rules ⚠️ @@ -796,6 +816,7 @@ steps: - Bash - WebSearch - WebFetch + permission_mode: acceptEdits status_rules_prompt: | # ⚠️ REQUIRED: Status Output Rules ⚠️ diff --git a/resources/global/en/workflows/simple.yaml b/resources/global/en/workflows/simple.yaml index d368421..1eb2501 100644 --- a/resources/global/en/workflows/simple.yaml +++ b/resources/global/en/workflows/simple.yaml @@ -40,6 +40,7 @@ steps: | Situation | Judgment | |-----------|----------| | Requirements clear and implementable | DONE | + | User is asking a question (not an implementation task) | ANSWER | | Requirements unclear, insufficient info | BLOCKED | ## Output Format @@ -47,6 +48,7 @@ steps: | Situation | Tag | |-----------|-----| | Analysis complete | `[PLANNER:DONE]` | + | Question answered | `[PLANNER:ANSWER]` | | Insufficient info | `[PLANNER:BLOCKED]` | ### Output Examples @@ -56,6 +58,13 @@ steps: [PLANNER:DONE] ``` + **ANSWER case:** + ``` + {Answer to the question} + + [PLANNER:ANSWER] + ``` + **BLOCKED case:** ``` [PLANNER:BLOCKED] @@ -81,10 +90,15 @@ steps: ## Instructions Analyze the task and create an implementation plan. + **Judgment criteria:** + - If the user input is an implementation task → create a plan and output `[PLANNER:DONE]` + - If the user input is a question → research, answer, and output `[PLANNER:ANSWER]` + - If there is insufficient information → output `[PLANNER:BLOCKED]` + **Note:** If returned from implement step (Previous Response exists), review and revise the plan based on that feedback (replan). - **Tasks:** + **Tasks (for implementation tasks):** 1. Understand the requirements 2. Identify impact scope 3. Decide implementation approach @@ -118,6 +132,8 @@ steps: transitions: - condition: done next_step: implement + - condition: answer + next_step: COMPLETE - condition: blocked next_step: ABORT @@ -132,6 +148,7 @@ steps: - Bash - WebSearch - WebFetch + permission_mode: acceptEdits status_rules_prompt: | # ⚠️ REQUIRED: Status Output Rules ⚠️ diff --git a/resources/global/ja/workflows/default.yaml b/resources/global/ja/workflows/default.yaml index 3254e37..035a573 100644 --- a/resources/global/ja/workflows/default.yaml +++ b/resources/global/ja/workflows/default.yaml @@ -39,6 +39,7 @@ steps: | 状況 | 判定 | |------|------| | 要件が明確で実装可能 | DONE | + | ユーザーが質問をしている(実装タスクではない) | ANSWER | | 要件が不明確、情報不足 | BLOCKED | ## 出力フォーマット @@ -46,6 +47,7 @@ steps: | 状況 | タグ | |------|------| | 分析完了 | `[PLANNER:DONE]` | + | 質問への回答 | `[PLANNER:ANSWER]` | | 情報不足 | `[PLANNER:BLOCKED]` | ### 出力例 @@ -55,6 +57,13 @@ steps: [PLANNER:DONE] ``` + **ANSWER の場合:** + ``` + {質問への回答} + + [PLANNER:ANSWER] + ``` + **BLOCKED の場合:** ``` [PLANNER:BLOCKED] @@ -80,10 +89,15 @@ steps: ## Instructions タスクを分析し、実装方針を立ててください。 + **判断基準:** + - ユーザーの入力が実装タスクの場合 → 計画を立てて `[PLANNER:DONE]` + - ユーザーの入力が質問の場合 → 調査・回答して `[PLANNER:ANSWER]` + - 情報不足の場合 → `[PLANNER:BLOCKED]` + **注意:** Previous Responseがある場合は差し戻しのため、 その内容を踏まえて計画を見直してください(replan)。 - **やること:** + **やること(実装タスクの場合):** 1. タスクの要件を理解する 2. 影響範囲を特定する 3. 実装アプローチを決める @@ -117,6 +131,8 @@ steps: transitions: - condition: done next_step: implement + - condition: answer + next_step: COMPLETE - condition: blocked next_step: ABORT @@ -131,6 +147,7 @@ steps: - Bash - WebSearch - WebFetch + permission_mode: acceptEdits status_rules_prompt: | # ⚠️ 必須: ステータス出力ルール ⚠️ @@ -372,6 +389,7 @@ steps: - Bash - WebSearch - WebFetch + permission_mode: acceptEdits status_rules_prompt: | # ⚠️ 必須: ステータス出力ルール ⚠️ @@ -556,6 +574,7 @@ steps: - Bash - WebSearch - WebFetch + permission_mode: acceptEdits status_rules_prompt: | # ⚠️ 必須: ステータス出力ルール ⚠️ @@ -737,6 +756,7 @@ steps: - Bash - WebSearch - WebFetch + permission_mode: acceptEdits status_rules_prompt: | # ⚠️ 必須: ステータス出力ルール ⚠️ @@ -805,6 +825,7 @@ steps: - Bash - WebSearch - WebFetch + permission_mode: acceptEdits status_rules_prompt: | # ⚠️ 必須: ステータス出力ルール ⚠️ diff --git a/resources/global/ja/workflows/simple.yaml b/resources/global/ja/workflows/simple.yaml index 679eeff..0d5bcb5 100644 --- a/resources/global/ja/workflows/simple.yaml +++ b/resources/global/ja/workflows/simple.yaml @@ -40,6 +40,7 @@ steps: | 状況 | 判定 | |------|------| | 要件が明確で実装可能 | DONE | + | ユーザーが質問をしている(実装タスクではない) | ANSWER | | 要件が不明確、情報不足 | BLOCKED | ## 出力フォーマット @@ -47,6 +48,7 @@ steps: | 状況 | タグ | |------|------| | 分析完了 | `[PLANNER:DONE]` | + | 質問への回答 | `[PLANNER:ANSWER]` | | 情報不足 | `[PLANNER:BLOCKED]` | ### 出力例 @@ -56,6 +58,13 @@ steps: [PLANNER:DONE] ``` + **ANSWER の場合:** + ``` + {質問への回答} + + [PLANNER:ANSWER] + ``` + **BLOCKED の場合:** ``` [PLANNER:BLOCKED] @@ -81,10 +90,15 @@ steps: ## Instructions タスクを分析し、実装方針を立ててください。 + **判断基準:** + - ユーザーの入力が実装タスクの場合 → 計画を立てて `[PLANNER:DONE]` + - ユーザーの入力が質問の場合 → 調査・回答して `[PLANNER:ANSWER]` + - 情報不足の場合 → `[PLANNER:BLOCKED]` + **注意:** Previous Responseがある場合は差し戻しのため、 その内容を踏まえて計画を見直してください(replan)。 - **やること:** + **やること(実装タスクの場合):** 1. タスクの要件を理解する 2. 影響範囲を特定する 3. 実装アプローチを決める @@ -118,6 +132,8 @@ steps: transitions: - condition: done next_step: implement + - condition: answer + next_step: COMPLETE - condition: blocked next_step: ABORT @@ -132,6 +148,7 @@ steps: - Bash - WebSearch - WebFetch + permission_mode: acceptEdits status_rules_prompt: | # ⚠️ 必須: ステータス出力ルール ⚠️ diff --git a/src/__tests__/autoCommit.test.ts b/src/__tests__/autoCommit.test.ts new file mode 100644 index 0000000..e011818 --- /dev/null +++ b/src/__tests__/autoCommit.test.ts @@ -0,0 +1,142 @@ +/** + * Tests for autoCommitWorktree + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { autoCommitWorktree } from '../task/autoCommit.js'; + +// Mock child_process.execFileSync +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +import { execFileSync } from 'node:child_process'; +const mockExecFileSync = vi.mocked(execFileSync); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('autoCommitWorktree', () => { + it('should create a commit when there are changes', () => { + // git add -A: no output needed + mockExecFileSync.mockImplementation((cmd, args) => { + const argsArr = args as string[]; + if (argsArr[0] === 'status') { + return 'M src/index.ts\n'; + } + if (argsArr[0] === 'rev-parse') { + return 'abc1234\n'; + } + return Buffer.from(''); + }); + + const result = autoCommitWorktree('/tmp/worktree', 'my-task'); + + expect(result.success).toBe(true); + expect(result.commitHash).toBe('abc1234'); + expect(result.message).toContain('abc1234'); + + // Verify git add -A was called + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['add', '-A'], + expect.objectContaining({ cwd: '/tmp/worktree' }) + ); + + // Verify commit was called with correct message (no co-author) + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['commit', '-m', 'takt: my-task'], + expect.objectContaining({ cwd: '/tmp/worktree' }) + ); + }); + + it('should return success with no commit when there are no changes', () => { + mockExecFileSync.mockImplementation((cmd, args) => { + const argsArr = args as string[]; + if (argsArr[0] === 'status') { + return ''; // No changes + } + return Buffer.from(''); + }); + + const result = autoCommitWorktree('/tmp/worktree', 'my-task'); + + expect(result.success).toBe(true); + expect(result.commitHash).toBeUndefined(); + expect(result.message).toBe('No changes to commit'); + + // Verify git add -A was called + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['add', '-A'], + expect.objectContaining({ cwd: '/tmp/worktree' }) + ); + + // Verify commit was NOT called + expect(mockExecFileSync).not.toHaveBeenCalledWith( + 'git', + ['commit', '-m', expect.any(String)], + expect.anything() + ); + }); + + it('should return failure when git command fails', () => { + mockExecFileSync.mockImplementation(() => { + throw new Error('git error: not a git repository'); + }); + + const result = autoCommitWorktree('/tmp/worktree', 'my-task'); + + expect(result.success).toBe(false); + expect(result.commitHash).toBeUndefined(); + expect(result.message).toContain('Auto-commit failed'); + expect(result.message).toContain('not a git repository'); + }); + + it('should not include co-author in commit message', () => { + mockExecFileSync.mockImplementation((cmd, args) => { + const argsArr = args as string[]; + if (argsArr[0] === 'status') { + return 'M file.ts\n'; + } + if (argsArr[0] === 'rev-parse') { + return 'def5678\n'; + } + return Buffer.from(''); + }); + + autoCommitWorktree('/tmp/worktree', 'test-task'); + + // Find the commit call + const commitCall = mockExecFileSync.mock.calls.find( + call => (call[1] as string[])[0] === 'commit' + ); + + expect(commitCall).toBeDefined(); + const commitMessage = (commitCall![1] as string[])[2]; + expect(commitMessage).toBe('takt: test-task'); + expect(commitMessage).not.toContain('Co-Authored-By'); + }); + + it('should use the correct commit message format', () => { + mockExecFileSync.mockImplementation((cmd, args) => { + const argsArr = args as string[]; + if (argsArr[0] === 'status') { + return 'A new-file.ts\n'; + } + if (argsArr[0] === 'rev-parse') { + return 'aaa1111\n'; + } + return Buffer.from(''); + }); + + autoCommitWorktree('/tmp/worktree', '認証機能を追加する'); + + const commitCall = mockExecFileSync.mock.calls.find( + call => (call[1] as string[])[0] === 'commit' + ); + expect((commitCall![1] as string[])[2]).toBe('takt: 認証機能を追加する'); + }); +}); diff --git a/src/__tests__/debug.test.ts b/src/__tests__/debug.test.ts new file mode 100644 index 0000000..d29e362 --- /dev/null +++ b/src/__tests__/debug.test.ts @@ -0,0 +1,233 @@ +/** + * Tests for debug logging utilities + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + initDebugLogger, + resetDebugLogger, + createLogger, + isDebugEnabled, + getDebugLogFile, + setVerboseConsole, + isVerboseConsole, + debugLog, + infoLog, + errorLog, +} from '../utils/debug.js'; +import { existsSync, readFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +describe('debug logging', () => { + beforeEach(() => { + resetDebugLogger(); + }); + + afterEach(() => { + resetDebugLogger(); + }); + + describe('initDebugLogger', () => { + it('should not enable debug when config is undefined', () => { + initDebugLogger(undefined, '/tmp'); + expect(isDebugEnabled()).toBe(false); + expect(getDebugLogFile()).toBeNull(); + }); + + it('should not enable debug when enabled is false', () => { + initDebugLogger({ enabled: false }, '/tmp'); + expect(isDebugEnabled()).toBe(false); + }); + + it('should enable debug when enabled is true', () => { + initDebugLogger({ enabled: true }, '/tmp'); + expect(isDebugEnabled()).toBe(true); + expect(getDebugLogFile()).not.toBeNull(); + }); + + it('should use custom log file when provided', () => { + const logDir = join(tmpdir(), 'takt-test-debug-' + Date.now()); + mkdirSync(logDir, { recursive: true }); + const logFile = join(logDir, 'test.log'); + + try { + initDebugLogger({ enabled: true, logFile }, '/tmp'); + expect(getDebugLogFile()).toBe(logFile); + expect(existsSync(logFile)).toBe(true); + + const content = readFileSync(logFile, 'utf-8'); + expect(content).toContain('TAKT Debug Log'); + } finally { + rmSync(logDir, { recursive: true, force: true }); + } + }); + + it('should only initialize once', () => { + initDebugLogger({ enabled: true }, '/tmp'); + const firstFile = getDebugLogFile(); + + initDebugLogger({ enabled: false }, '/tmp'); + expect(isDebugEnabled()).toBe(true); + expect(getDebugLogFile()).toBe(firstFile); + }); + }); + + describe('resetDebugLogger', () => { + it('should reset all state', () => { + initDebugLogger({ enabled: true }, '/tmp'); + setVerboseConsole(true); + + resetDebugLogger(); + + expect(isDebugEnabled()).toBe(false); + expect(getDebugLogFile()).toBeNull(); + expect(isVerboseConsole()).toBe(false); + }); + }); + + describe('setVerboseConsole / isVerboseConsole', () => { + it('should default to false', () => { + expect(isVerboseConsole()).toBe(false); + }); + + it('should enable verbose console', () => { + setVerboseConsole(true); + expect(isVerboseConsole()).toBe(true); + }); + + it('should disable verbose console', () => { + setVerboseConsole(true); + setVerboseConsole(false); + expect(isVerboseConsole()).toBe(false); + }); + }); + + describe('verbose console output', () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + }); + + it('should not output to stderr when verbose is disabled', () => { + debugLog('test', 'hello'); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it('should output debug to stderr when verbose is enabled', () => { + setVerboseConsole(true); + debugLog('test', 'hello debug'); + + expect(stderrSpy).toHaveBeenCalledTimes(1); + const output = stderrSpy.mock.calls[0]?.[0] as string; + expect(output).toContain('[DEBUG]'); + expect(output).toContain('[test]'); + expect(output).toContain('hello debug'); + }); + + it('should output info to stderr when verbose is enabled', () => { + setVerboseConsole(true); + infoLog('mycomp', 'info message'); + + expect(stderrSpy).toHaveBeenCalledTimes(1); + const output = stderrSpy.mock.calls[0]?.[0] as string; + expect(output).toContain('[INFO]'); + expect(output).toContain('[mycomp]'); + expect(output).toContain('info message'); + }); + + it('should output error to stderr when verbose is enabled', () => { + setVerboseConsole(true); + errorLog('mycomp', 'error message'); + + expect(stderrSpy).toHaveBeenCalledTimes(1); + const output = stderrSpy.mock.calls[0]?.[0] as string; + expect(output).toContain('[ERROR]'); + expect(output).toContain('[mycomp]'); + expect(output).toContain('error message'); + }); + + it('should include timestamp in console output', () => { + setVerboseConsole(true); + debugLog('test', 'with timestamp'); + + const output = stderrSpy.mock.calls[0]?.[0] as string; + // Timestamp format: HH:mm:ss.SSS + expect(output).toMatch(/\[\d{2}:\d{2}:\d{2}\.\d{3}\]/); + }); + }); + + describe('createLogger', () => { + it('should create a logger with the given component name', () => { + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + setVerboseConsole(true); + + const log = createLogger('my-component'); + log.debug('test message'); + + const output = stderrSpy.mock.calls[0]?.[0] as string; + expect(output).toContain('[my-component]'); + + stderrSpy.mockRestore(); + }); + + it('should provide debug, info, error, enter, exit methods', () => { + const log = createLogger('test'); + expect(typeof log.debug).toBe('function'); + expect(typeof log.info).toBe('function'); + expect(typeof log.error).toBe('function'); + expect(typeof log.enter).toBe('function'); + expect(typeof log.exit).toBe('function'); + }); + }); + + describe('file logging with verbose console', () => { + it('should write to both file and stderr when both are enabled', () => { + const logDir = join(tmpdir(), 'takt-test-debug-both-' + Date.now()); + mkdirSync(logDir, { recursive: true }); + const logFile = join(logDir, 'test.log'); + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + initDebugLogger({ enabled: true, logFile }, '/tmp'); + setVerboseConsole(true); + + debugLog('test', 'dual output'); + + // Check stderr + expect(stderrSpy).toHaveBeenCalledTimes(1); + const stderrOutput = stderrSpy.mock.calls[0]?.[0] as string; + expect(stderrOutput).toContain('dual output'); + + // Check file + const fileContent = readFileSync(logFile, 'utf-8'); + expect(fileContent).toContain('dual output'); + } finally { + stderrSpy.mockRestore(); + rmSync(logDir, { recursive: true, force: true }); + } + }); + + it('should output to stderr even when file logging is disabled', () => { + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + try { + // File logging not enabled, but verbose console is + setVerboseConsole(true); + debugLog('test', 'console only'); + + expect(stderrSpy).toHaveBeenCalledTimes(1); + const output = stderrSpy.mock.calls[0]?.[0] as string; + expect(output).toContain('console only'); + } finally { + stderrSpy.mockRestore(); + } + }); + }); +}); diff --git a/src/__tests__/models.test.ts b/src/__tests__/models.test.ts index f3a9c58..f470c83 100644 --- a/src/__tests__/models.test.ts +++ b/src/__tests__/models.test.ts @@ -7,6 +7,7 @@ import { AgentTypeSchema, StatusSchema, TransitionConditionSchema, + PermissionModeSchema, WorkflowConfigRawSchema, CustomAgentConfigSchema, GlobalConfigSchema, @@ -33,6 +34,7 @@ describe('StatusSchema', () => { expect(StatusSchema.parse('approved')).toBe('approved'); expect(StatusSchema.parse('rejected')).toBe('rejected'); expect(StatusSchema.parse('blocked')).toBe('blocked'); + expect(StatusSchema.parse('answer')).toBe('answer'); }); it('should reject invalid statuses', () => { @@ -47,6 +49,7 @@ describe('TransitionConditionSchema', () => { expect(TransitionConditionSchema.parse('approved')).toBe('approved'); expect(TransitionConditionSchema.parse('rejected')).toBe('rejected'); expect(TransitionConditionSchema.parse('always')).toBe('always'); + expect(TransitionConditionSchema.parse('answer')).toBe('answer'); }); it('should reject invalid conditions', () => { @@ -55,6 +58,19 @@ describe('TransitionConditionSchema', () => { }); }); +describe('PermissionModeSchema', () => { + it('should accept valid permission modes', () => { + expect(PermissionModeSchema.parse('default')).toBe('default'); + expect(PermissionModeSchema.parse('acceptEdits')).toBe('acceptEdits'); + expect(PermissionModeSchema.parse('bypassPermissions')).toBe('bypassPermissions'); + }); + + it('should reject invalid permission modes', () => { + expect(() => PermissionModeSchema.parse('readOnly')).toThrow(); + expect(() => PermissionModeSchema.parse('admin')).toThrow(); + }); +}); + describe('WorkflowConfigRawSchema', () => { it('should parse valid workflow config', () => { const config = { @@ -80,6 +96,61 @@ describe('WorkflowConfigRawSchema', () => { expect(result.max_iterations).toBe(10); }); + it('should parse step with permission_mode', () => { + const config = { + name: 'test-workflow', + steps: [ + { + name: 'implement', + agent: 'coder', + allowed_tools: ['Read', 'Edit', 'Write', 'Bash'], + permission_mode: 'acceptEdits', + instruction: '{task}', + transitions: [ + { condition: 'done', next_step: 'COMPLETE' }, + ], + }, + ], + }; + + const result = WorkflowConfigRawSchema.parse(config); + expect(result.steps[0]?.permission_mode).toBe('acceptEdits'); + }); + + it('should allow omitting permission_mode', () => { + const config = { + name: 'test-workflow', + steps: [ + { + name: 'plan', + agent: 'planner', + instruction: '{task}', + transitions: [], + }, + ], + }; + + const result = WorkflowConfigRawSchema.parse(config); + expect(result.steps[0]?.permission_mode).toBeUndefined(); + }); + + it('should reject invalid permission_mode', () => { + const config = { + name: 'test-workflow', + steps: [ + { + name: 'step1', + agent: 'coder', + permission_mode: 'superAdmin', + instruction: '{task}', + transitions: [], + }, + ], + }; + + expect(() => WorkflowConfigRawSchema.parse(config)).toThrow(); + }); + it('should require at least one step', () => { const config = { name: 'empty-workflow', @@ -172,6 +243,7 @@ describe('GENERIC_STATUS_PATTERNS', () => { expect(GENERIC_STATUS_PATTERNS.done).toBeDefined(); expect(GENERIC_STATUS_PATTERNS.blocked).toBeDefined(); expect(GENERIC_STATUS_PATTERNS.improve).toBeDefined(); + expect(GENERIC_STATUS_PATTERNS.answer).toBeDefined(); }); it('should have valid regex patterns', () => { @@ -187,5 +259,7 @@ describe('GENERIC_STATUS_PATTERNS', () => { expect(new RegExp(GENERIC_STATUS_PATTERNS.done).test('[CUSTOM:DONE]')).toBe(true); expect(new RegExp(GENERIC_STATUS_PATTERNS.done).test('[CODER:FIXED]')).toBe(true); expect(new RegExp(GENERIC_STATUS_PATTERNS.improve).test('[MAGI:IMPROVE]')).toBe(true); + expect(new RegExp(GENERIC_STATUS_PATTERNS.answer).test('[PLANNER:ANSWER]')).toBe(true); + expect(new RegExp(GENERIC_STATUS_PATTERNS.answer).test('[MY_AGENT:ANSWER]')).toBe(true); }); }); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 4d9d99d..c2bcd34 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -15,7 +15,10 @@ import { loadCustomAgents, loadAgentPrompt } from '../config/loader.js'; import { loadGlobalConfig } from '../config/globalConfig.js'; import { loadProjectConfig } from '../config/projectConfig.js'; import { getProvider, type ProviderType, type ProviderCallOptions } from '../providers/index.js'; -import type { AgentResponse, CustomAgentConfig } from '../models/types.js'; +import type { AgentResponse, CustomAgentConfig, PermissionMode } from '../models/types.js'; +import { createLogger } from '../utils/debug.js'; + +const log = createLogger('runner'); export type { StreamCallback }; @@ -31,6 +34,8 @@ export interface RunAgentOptions { allowedTools?: string[]; /** Status output rules to inject into system prompt */ statusRulesPrompt?: string; + /** Permission mode for tool execution (from workflow step) */ + permissionMode?: PermissionMode; onStream?: StreamCallback; onPermissionRequest?: PermissionHandler; onAskUserQuestion?: AskUserQuestionHandler; @@ -103,6 +108,7 @@ export async function runCustomAgent( sessionId: options.sessionId, allowedTools, model: resolveModel(options.cwd, options, agentConfig), + permissionMode: options.permissionMode, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, @@ -118,6 +124,7 @@ export async function runCustomAgent( sessionId: options.sessionId, allowedTools, model: resolveModel(options.cwd, options, agentConfig), + permissionMode: options.permissionMode, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, @@ -143,6 +150,7 @@ export async function runCustomAgent( allowedTools, model: resolveModel(options.cwd, options, agentConfig), statusPatterns: agentConfig.statusPatterns, + permissionMode: options.permissionMode, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, @@ -194,6 +202,15 @@ export async function runAgent( 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) { @@ -216,6 +233,7 @@ export async function runAgent( allowedTools: options.allowedTools, model: resolveModel(options.cwd, options), systemPrompt, + permissionMode: options.permissionMode, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, diff --git a/src/claude/client.ts b/src/claude/client.ts index 9feef95..347b173 100644 --- a/src/claude/client.ts +++ b/src/claude/client.ts @@ -6,7 +6,7 @@ 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 } from '../models/types.js'; +import type { AgentResponse, Status, PermissionMode } from '../models/types.js'; import { GENERIC_STATUS_PATTERNS } from '../models/schemas.js'; /** Options for calling Claude */ @@ -20,6 +20,8 @@ export interface ClaudeCallOptions { statusPatterns?: Record; /** 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 */ @@ -112,6 +114,7 @@ export async function callClaude( maxTurns: options.maxTurns, systemPrompt: options.systemPrompt, agents: options.agents, + permissionMode: options.permissionMode, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, @@ -145,6 +148,7 @@ export async function callClaudeCustom( model: options.model, maxTurns: options.maxTurns, systemPrompt, + permissionMode: options.permissionMode, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, @@ -193,6 +197,7 @@ export async function callClaudeSkill( allowedTools: options.allowedTools, model: options.model, maxTurns: options.maxTurns, + permissionMode: options.permissionMode, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, diff --git a/src/cli.ts b/src/cli.ts index 799abd2..c0d96b4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,7 +22,7 @@ import { } from './config/index.js'; import { clearAgentSessions, getCurrentWorkflow, isVerboseMode } from './config/paths.js'; import { info, error, success, setLogLevel } from './utils/ui.js'; -import { initDebugLogger, createLogger } from './utils/debug.js'; +import { initDebugLogger, createLogger, setVerboseConsole } from './utils/debug.js'; import { executeTask, runAllTasks, @@ -57,25 +57,33 @@ program // Initialize project directories (.takt/) initProjectDirs(cwd); - // Initialize debug logger from config - const debugConfig = getEffectiveDebugConfig(cwd); + // Determine verbose mode and initialize logging + const verbose = isVerboseMode(cwd); + let debugConfig = getEffectiveDebugConfig(cwd); + + // verbose=true enables file logging automatically + if (verbose && (!debugConfig || !debugConfig.enabled)) { + debugConfig = { enabled: true }; + } + initDebugLogger(debugConfig, cwd); + // Enable verbose console output (stderr) for debug logs + if (verbose) { + setVerboseConsole(true); + setLogLevel('debug'); + } else { + const config = loadGlobalConfig(); + setLogLevel(config.logLevel); + } + log.info('TAKT CLI starting', { version: '0.1.0', cwd, task: task || null, + verbose, }); - // Set log level from config - if (isVerboseMode(cwd)) { - setLogLevel('debug'); - log.debug('Verbose mode enabled (from config)'); - } else { - const config = loadGlobalConfig(); - setLogLevel(config.logLevel); - } - // Handle slash commands if (task?.startsWith('/')) { const parts = task.slice(1).split(/\s+/); diff --git a/src/commands/taskExecution.ts b/src/commands/taskExecution.ts index 6c40c0f..4cf7a87 100644 --- a/src/commands/taskExecution.ts +++ b/src/commands/taskExecution.ts @@ -5,6 +5,7 @@ import { loadWorkflow } from '../config/index.js'; import { TaskRunner, type TaskInfo } from '../task/index.js'; import { createWorktree } from '../task/worktree.js'; +import { autoCommitWorktree } from '../task/autoCommit.js'; import { header, info, @@ -45,6 +46,71 @@ export async function executeTask( return result.success; } +/** + * Execute a task: resolve worktree → run workflow → auto-commit → record completion. + * + * Shared by runAllTasks() and watchTasks() to avoid duplicated + * resolve → execute → autoCommit → complete logic. + * + * @returns true if the task succeeded + */ +export async function executeAndCompleteTask( + task: TaskInfo, + taskRunner: TaskRunner, + cwd: string, + workflowName: string, +): Promise { + const startedAt = new Date().toISOString(); + const executionLog: string[] = []; + + try { + const { execCwd, execWorkflow, isWorktree } = resolveTaskExecution(task, cwd, workflowName); + + const taskSuccess = await executeTask(task.content, execCwd, execWorkflow); + const completedAt = new Date().toISOString(); + + if (taskSuccess && isWorktree) { + const commitResult = autoCommitWorktree(execCwd, task.name); + if (commitResult.success && commitResult.commitHash) { + info(`Auto-committed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + error(`Auto-commit failed: ${commitResult.message}`); + } + } + + taskRunner.completeTask({ + task, + success: taskSuccess, + response: taskSuccess ? 'Task completed successfully' : 'Task failed', + executionLog, + startedAt, + completedAt, + }); + + if (taskSuccess) { + success(`Task "${task.name}" completed`); + } else { + error(`Task "${task.name}" failed`); + } + + return taskSuccess; + } catch (err) { + const completedAt = new Date().toISOString(); + + taskRunner.completeTask({ + task, + success: false, + response: getErrorMessage(err), + executionLog, + startedAt, + completedAt, + }); + + error(`Task "${task.name}" error: ${getErrorMessage(err)}`); + return false; + } +} + /** * Run all pending tasks from .takt/tasks/ * @@ -76,46 +142,12 @@ export async function runAllTasks( info(`=== Task: ${task.name} ===`); console.log(); - const startedAt = new Date().toISOString(); - const executionLog: string[] = []; + const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName); - try { - // Resolve execution directory and workflow from task data - const { execCwd, execWorkflow } = resolveTaskExecution(task, cwd, workflowName); - - const taskSuccess = await executeTask(task.content, execCwd, execWorkflow); - const completedAt = new Date().toISOString(); - - taskRunner.completeTask({ - task, - success: taskSuccess, - response: taskSuccess ? 'Task completed successfully' : 'Task failed', - executionLog, - startedAt, - completedAt, - }); - - if (taskSuccess) { - successCount++; - success(`Task "${task.name}" completed`); - } else { - failCount++; - error(`Task "${task.name}" failed`); - } - } catch (err) { + if (taskSuccess) { + successCount++; + } else { failCount++; - const completedAt = new Date().toISOString(); - - taskRunner.completeTask({ - task, - success: false, - response: getErrorMessage(err), - executionLog, - startedAt, - completedAt, - }); - - error(`Task "${task.name}" error: ${getErrorMessage(err)}`); } // 次のタスクを動的に取得(新しく追加されたタスクも含む) @@ -140,15 +172,16 @@ export function resolveTaskExecution( task: TaskInfo, defaultCwd: string, defaultWorkflow: string -): { execCwd: string; execWorkflow: string } { +): { execCwd: string; execWorkflow: string; isWorktree: boolean } { const data = task.data; // No structured data: use defaults if (!data) { - return { execCwd: defaultCwd, execWorkflow: defaultWorkflow }; + return { execCwd: defaultCwd, execWorkflow: defaultWorkflow, isWorktree: false }; } let execCwd = defaultCwd; + let isWorktree = false; // Handle worktree if (data.worktree) { @@ -158,11 +191,12 @@ export function resolveTaskExecution( taskSlug: task.name, }); execCwd = result.path; + isWorktree = true; info(`Worktree created: ${result.path} (branch: ${result.branch})`); } // Handle workflow override const execWorkflow = data.workflow || defaultWorkflow; - return { execCwd, execWorkflow }; + return { execCwd, execWorkflow, isWorktree }; } diff --git a/src/commands/watchTasks.ts b/src/commands/watchTasks.ts index 91412b7..4e92e9f 100644 --- a/src/commands/watchTasks.ts +++ b/src/commands/watchTasks.ts @@ -11,12 +11,10 @@ import { getCurrentWorkflow } from '../config/paths.js'; import { header, info, - error, success, status, } from '../utils/ui.js'; -import { getErrorMessage } from '../utils/error.js'; -import { executeTask, resolveTaskExecution } from './taskExecution.js'; +import { executeAndCompleteTask } from './taskExecution.js'; import { DEFAULT_WORKFLOW_NAME } from '../constants.js'; /** @@ -53,44 +51,12 @@ export async function watchTasks(cwd: string): Promise { info(`=== Task ${taskCount}: ${task.name} ===`); console.log(); - const startedAt = new Date().toISOString(); - const executionLog: string[] = []; + const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName); - try { - const { execCwd, execWorkflow } = resolveTaskExecution(task, cwd, workflowName); - const taskSuccess = await executeTask(task.content, execCwd, execWorkflow); - const completedAt = new Date().toISOString(); - - taskRunner.completeTask({ - task, - success: taskSuccess, - response: taskSuccess ? 'Task completed successfully' : 'Task failed', - executionLog, - startedAt, - completedAt, - }); - - if (taskSuccess) { - successCount++; - success(`Task "${task.name}" completed`); - } else { - failCount++; - error(`Task "${task.name}" failed`); - } - } catch (err) { + if (taskSuccess) { + successCount++; + } else { failCount++; - const completedAt = new Date().toISOString(); - - taskRunner.completeTask({ - task, - success: false, - response: getErrorMessage(err), - executionLog, - startedAt, - completedAt, - }); - - error(`Task "${task.name}" error: ${getErrorMessage(err)}`); } console.log(); diff --git a/src/config/workflowLoader.ts b/src/config/workflowLoader.ts index 695a30a..adc6f0d 100644 --- a/src/config/workflowLoader.ts +++ b/src/config/workflowLoader.ts @@ -69,6 +69,7 @@ function normalizeWorkflowConfig(raw: unknown, workflowDir: string): WorkflowCon allowedTools: step.allowed_tools, provider: step.provider, model: step.model, + permissionMode: step.permission_mode, instructionTemplate: step.instruction_template || step.instruction || '{task}', statusRulesPrompt: step.status_rules_prompt, transitions: step.transitions.map((t) => ({ diff --git a/src/models/schemas.ts b/src/models/schemas.ts index 9aed9a0..0f3ae37 100644 --- a/src/models/schemas.ts +++ b/src/models/schemas.ts @@ -21,6 +21,7 @@ export const StatusSchema = z.enum([ 'improve', 'cancelled', 'interrupted', + 'answer', ]); /** @@ -33,6 +34,7 @@ export const StatusSchema = z.enum([ * - approved: Review passed * - rejected: Review failed, needs major rework * - improve: Needs improvement (security concerns, quality issues) + * - answer: Question answered (complete workflow as success) * - always: Unconditional transition */ export const TransitionConditionSchema = z.enum([ @@ -41,6 +43,7 @@ export const TransitionConditionSchema = z.enum([ 'approved', 'rejected', 'improve', + 'answer', 'always', ]); @@ -53,6 +56,9 @@ export const WorkflowTransitionSchema = z.object({ nextStep: z.string().min(1), }); +/** Permission mode schema for tool execution */ +export const PermissionModeSchema = z.enum(['default', 'acceptEdits', 'bypassPermissions']); + /** Workflow step schema - raw YAML format */ export const WorkflowStepRawSchema = z.object({ name: z.string().min(1), @@ -62,6 +68,8 @@ export const WorkflowStepRawSchema = z.object({ allowed_tools: z.array(z.string()).optional(), provider: z.enum(['claude', 'codex', 'mock']).optional(), model: z.string().optional(), + /** Permission mode for tool execution in this step */ + permission_mode: PermissionModeSchema.optional(), instruction: z.string().optional(), instruction_template: z.string().optional(), status_rules_prompt: z.string().optional(), @@ -141,4 +149,5 @@ export const GENERIC_STATUS_PATTERNS: Record = { improve: '\\[[\\w-]+:IMPROVE\\]', done: '\\[[\\w-]+:(DONE|FIXED)\\]', blocked: '\\[[\\w-]+:BLOCKED\\]', + answer: '\\[[\\w-]+:ANSWER\\]', }; diff --git a/src/models/types.ts b/src/models/types.ts index d39b3c3..5e12e95 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -15,7 +15,8 @@ export type Status = | 'rejected' | 'improve' | 'cancelled' - | 'interrupted'; + | 'interrupted' + | 'answer'; /** Condition types for workflow transitions */ export type TransitionCondition = @@ -24,6 +25,7 @@ export type TransitionCondition = | 'approved' | 'rejected' | 'improve' + | 'answer' | 'always'; /** Response from an agent execution */ @@ -53,6 +55,9 @@ export interface WorkflowTransition { /** Behavior when no status marker is found in agent output */ export type OnNoStatusBehavior = 'complete' | 'continue' | 'stay'; +/** Permission mode for tool execution */ +export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions'; + /** Single step in a workflow */ export interface WorkflowStep { name: string; @@ -68,6 +73,8 @@ export interface WorkflowStep { provider?: 'claude' | 'codex' | 'mock'; /** Model override for this step */ model?: string; + /** Permission mode for tool execution in this step */ + permissionMode?: PermissionMode; instructionTemplate: string; /** Status output rules to be injected into system prompt */ statusRulesPrompt?: string; diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 6988ec2..e5fdc4d 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -16,6 +16,7 @@ export class ClaudeProvider implements Provider { model: options.model, systemPrompt: options.systemPrompt, statusPatterns: options.statusPatterns, + permissionMode: options.permissionMode, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, @@ -32,6 +33,7 @@ export class ClaudeProvider implements Provider { allowedTools: options.allowedTools, model: options.model, statusPatterns: options.statusPatterns, + permissionMode: options.permissionMode, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, diff --git a/src/providers/index.ts b/src/providers/index.ts index d664178..56c31ec 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -6,7 +6,7 @@ */ import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/process.js'; -import type { AgentResponse } from '../models/types.js'; +import type { AgentResponse, PermissionMode } from '../models/types.js'; import { ClaudeProvider } from './claude.js'; import { CodexProvider } from './codex.js'; import { MockProvider } from './mock.js'; @@ -19,6 +19,8 @@ export interface ProviderCallOptions { systemPrompt?: string; allowedTools?: string[]; statusPatterns?: Record; + /** Permission mode for tool execution (from workflow step) */ + permissionMode?: PermissionMode; onStream?: StreamCallback; onPermissionRequest?: PermissionHandler; onAskUserQuestion?: AskUserQuestionHandler; diff --git a/src/task/autoCommit.ts b/src/task/autoCommit.ts new file mode 100644 index 0000000..3e4f57b --- /dev/null +++ b/src/task/autoCommit.ts @@ -0,0 +1,86 @@ +/** + * Auto-commit for worktree tasks + * + * After a successful workflow completion in a worktree, + * automatically stages all changes and creates a commit. + * No co-author trailer is added. + */ + +import { execFileSync } from 'node:child_process'; +import { createLogger } from '../utils/debug.js'; + +const log = createLogger('autoCommit'); + +export interface AutoCommitResult { + /** Whether the commit was created successfully */ + success: boolean; + /** The short commit hash (if committed) */ + commitHash?: string; + /** Human-readable message */ + message: string; +} + +/** + * Auto-commit all changes in a worktree directory. + * + * 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}" + * + * @param worktreeCwd - The worktree directory + * @param taskName - Task name used in commit message + */ +export function autoCommitWorktree(worktreeCwd: string, taskName: string): AutoCommitResult { + log.info('Auto-commit starting', { cwd: worktreeCwd, taskName }); + + try { + // Stage all changes + execFileSync('git', ['add', '-A'], { + cwd: worktreeCwd, + stdio: 'pipe', + }); + + // Check if there are staged changes + const statusOutput = execFileSync('git', ['status', '--porcelain'], { + cwd: worktreeCwd, + stdio: 'pipe', + encoding: 'utf-8', + }); + + if (!statusOutput.trim()) { + log.info('No changes to commit'); + return { success: true, message: 'No changes to commit' }; + } + + // Create commit (no co-author) + const commitMessage = `takt: ${taskName}`; + execFileSync('git', ['commit', '-m', commitMessage], { + cwd: worktreeCwd, + stdio: 'pipe', + }); + + // Get the short commit hash + const commitHash = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { + cwd: worktreeCwd, + stdio: 'pipe', + encoding: 'utf-8', + }).trim(); + + log.info('Auto-commit created', { commitHash, message: commitMessage }); + + return { + success: true, + commitHash, + message: `Committed: ${commitHash} - ${commitMessage}`, + }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.error('Auto-commit failed', { error: errorMessage }); + + return { + success: false, + message: `Auto-commit failed: ${errorMessage}`, + }; + } +} diff --git a/src/task/index.ts b/src/task/index.ts index 543f9ff..56497e0 100644 --- a/src/task/index.ts +++ b/src/task/index.ts @@ -13,4 +13,5 @@ export { showTaskList } from './display.js'; export { TaskFileSchema, type TaskFileData } from './schema.js'; export { parseTaskFile, parseTaskFiles, type ParsedTask } from './parser.js'; export { createWorktree, removeWorktree, type WorktreeOptions, type WorktreeResult } from './worktree.js'; +export { autoCommitWorktree, type AutoCommitResult } from './autoCommit.js'; export { TaskWatcher, type TaskWatcherOptions } from './watcher.js'; diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 83b740b..482d42e 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -1,6 +1,7 @@ /** * Debug logging utilities for takt - * Writes debug logs to file when enabled in config + * Writes debug logs to file when enabled in config. + * When verbose console is enabled, also outputs to stderr. */ import { existsSync, appendFileSync, mkdirSync, writeFileSync } from 'node:fs'; @@ -13,6 +14,9 @@ let debugEnabled = false; let debugLogFile: string | null = null; let initialized = false; +/** Verbose console output state */ +let verboseConsoleEnabled = false; + /** Get default debug log file path */ function getDefaultLogFile(): string { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); @@ -61,6 +65,17 @@ export function resetDebugLogger(): void { debugEnabled = false; debugLogFile = null; initialized = false; + verboseConsoleEnabled = false; +} + +/** Enable or disable verbose console output */ +export function setVerboseConsole(enabled: boolean): void { + verboseConsoleEnabled = enabled; +} + +/** Check if verbose console is enabled */ +export function isVerboseConsole(): boolean { + return verboseConsoleEnabled; } /** Check if debug is enabled */ @@ -92,13 +107,23 @@ function formatLogMessage(level: string, component: string, message: string, dat return logLine; } -/** Write a debug log entry */ -export function debugLog(component: string, message: string, data?: unknown): void { +/** 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('DEBUG', component, message, data); + const logLine = formatLogMessage(level, component, message, data); try { appendFileSync(debugLogFile, logLine + '\n', 'utf-8'); @@ -107,34 +132,19 @@ export function debugLog(component: string, message: string, data?: unknown): vo } } +/** Write a debug log entry */ +export function debugLog(component: string, message: string, data?: unknown): void { + writeLog('DEBUG', component, message, data); +} + /** Write an info log entry */ export function infoLog(component: string, message: string, data?: unknown): void { - if (!debugEnabled || !debugLogFile) { - return; - } - - const logLine = formatLogMessage('INFO', component, message, data); - - try { - appendFileSync(debugLogFile, logLine + '\n', 'utf-8'); - } catch { - // Silently fail - } + writeLog('INFO', component, message, data); } /** Write an error log entry */ export function errorLog(component: string, message: string, data?: unknown): void { - if (!debugEnabled || !debugLogFile) { - return; - } - - const logLine = formatLogMessage('ERROR', component, message, data); - - try { - appendFileSync(debugLogFile, logLine + '\n', 'utf-8'); - } catch { - // Silently fail - } + writeLog('ERROR', component, message, data); } /** Log function entry with arguments */ diff --git a/src/workflow/engine.ts b/src/workflow/engine.ts index 857dd50..9b6b389 100644 --- a/src/workflow/engine.ts +++ b/src/workflow/engine.ts @@ -25,6 +25,9 @@ import { incrementStepIteration, } from './state-manager.js'; import { generateReportDir } from '../utils/session.js'; +import { createLogger } from '../utils/debug.js'; + +const log = createLogger('engine'); // Re-export types for backward compatibility export type { @@ -60,6 +63,12 @@ export class WorkflowEngine extends EventEmitter { this.ensureReportDirExists(); this.validateConfig(); this.state = createInitialState(config, options); + log.debug('WorkflowEngine initialized', { + workflow: config.name, + steps: config.steps.map(s => s.name), + initialStep: config.initialStep, + maxIterations: config.maxIterations, + }); } /** Ensure report directory exists (always in original cwd) */ @@ -146,6 +155,13 @@ export class WorkflowEngine extends EventEmitter { const stepIteration = incrementStepIteration(this.state, step.name); const instruction = this.buildInstruction(step, stepIteration); const sessionId = this.state.agentSessions.get(step.agent); + log.debug('Running step', { + step: step.name, + agent: step.agent, + stepIteration, + iteration: this.state.iteration, + sessionId: sessionId ?? 'new', + }); const agentOptions: RunAgentOptions = { cwd: this.cwd, @@ -155,6 +171,7 @@ export class WorkflowEngine extends EventEmitter { statusRulesPrompt: step.statusRulesPrompt, provider: step.provider, model: step.model, + permissionMode: step.permissionMode, onStream: this.options.onStream, onPermissionRequest: this.options.onPermissionRequest, onAskUserQuestion: this.options.onAskUserQuestion, @@ -239,6 +256,11 @@ export class WorkflowEngine extends EventEmitter { } const nextStep = determineNextStep(step, response.status, this.config); + log.debug('Step transition', { + from: step.name, + status: response.status, + nextStep, + }); if (nextStep === COMPLETE_STEP) { this.state.status = 'completed'; diff --git a/src/workflow/transitions.ts b/src/workflow/transitions.ts index 084006d..5aff143 100644 --- a/src/workflow/transitions.ts +++ b/src/workflow/transitions.ts @@ -31,6 +31,7 @@ export function matchesCondition( approved: ['approved'], rejected: ['rejected'], improve: ['improve'], + answer: ['answer'], pending: [], in_progress: [], cancelled: [],