feat: answer status, autoCommit, permission_mode, verbose logging

- answer: planner が質問と判断したら COMPLETE で終了する仕組み
- autoCommit: worktree タスク完了時に自動 git commit
- permission_mode: workflow YAML でステップごとの権限指定
- verbose: verbose 時のファイル+stderr 二重出力修正
This commit is contained in:
nrslib 2026-01-28 10:02:04 +09:00
parent 354e9c48a3
commit d900ee8bc4
22 changed files with 823 additions and 126 deletions

View File

@ -39,6 +39,7 @@ steps:
| Situation | Judgment | | Situation | Judgment |
|-----------|----------| |-----------|----------|
| Requirements clear and implementable | DONE | | Requirements clear and implementable | DONE |
| User is asking a question (not an implementation task) | ANSWER |
| Requirements unclear, insufficient info | BLOCKED | | Requirements unclear, insufficient info | BLOCKED |
## Output Format ## Output Format
@ -46,6 +47,7 @@ steps:
| Situation | Tag | | Situation | Tag |
|-----------|-----| |-----------|-----|
| Analysis complete | `[PLANNER:DONE]` | | Analysis complete | `[PLANNER:DONE]` |
| Question answered | `[PLANNER:ANSWER]` |
| Insufficient info | `[PLANNER:BLOCKED]` | | Insufficient info | `[PLANNER:BLOCKED]` |
### Output Examples ### Output Examples
@ -55,6 +57,13 @@ steps:
[PLANNER:DONE] [PLANNER:DONE]
``` ```
**ANSWER case:**
```
{Answer to the question}
[PLANNER:ANSWER]
```
**BLOCKED case:** **BLOCKED case:**
``` ```
[PLANNER:BLOCKED] [PLANNER:BLOCKED]
@ -80,10 +89,15 @@ steps:
## Instructions ## Instructions
Analyze the task and create an implementation plan. 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), **Note:** If returned from implement step (Previous Response exists),
review and revise the plan based on that feedback (replan). review and revise the plan based on that feedback (replan).
**Tasks:** **Tasks (for implementation tasks):**
1. Understand the requirements 1. Understand the requirements
2. Identify impact scope 2. Identify impact scope
3. Decide implementation approach 3. Decide implementation approach
@ -117,6 +131,8 @@ steps:
transitions: transitions:
- condition: done - condition: done
next_step: implement next_step: implement
- condition: answer
next_step: COMPLETE
- condition: blocked - condition: blocked
next_step: ABORT next_step: ABORT
@ -131,6 +147,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits
status_rules_prompt: | status_rules_prompt: |
# ⚠️ REQUIRED: Status Output Rules ⚠️ # ⚠️ REQUIRED: Status Output Rules ⚠️
@ -360,6 +377,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits
status_rules_prompt: | status_rules_prompt: |
# ⚠️ REQUIRED: Status Output Rules ⚠️ # ⚠️ REQUIRED: Status Output Rules ⚠️
@ -545,6 +563,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits
status_rules_prompt: | status_rules_prompt: |
# ⚠️ REQUIRED: Status Output Rules ⚠️ # ⚠️ REQUIRED: Status Output Rules ⚠️
@ -727,6 +746,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits
status_rules_prompt: | status_rules_prompt: |
# ⚠️ REQUIRED: Status Output Rules ⚠️ # ⚠️ REQUIRED: Status Output Rules ⚠️
@ -796,6 +816,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits
status_rules_prompt: | status_rules_prompt: |
# ⚠️ REQUIRED: Status Output Rules ⚠️ # ⚠️ REQUIRED: Status Output Rules ⚠️

View File

@ -40,6 +40,7 @@ steps:
| Situation | Judgment | | Situation | Judgment |
|-----------|----------| |-----------|----------|
| Requirements clear and implementable | DONE | | Requirements clear and implementable | DONE |
| User is asking a question (not an implementation task) | ANSWER |
| Requirements unclear, insufficient info | BLOCKED | | Requirements unclear, insufficient info | BLOCKED |
## Output Format ## Output Format
@ -47,6 +48,7 @@ steps:
| Situation | Tag | | Situation | Tag |
|-----------|-----| |-----------|-----|
| Analysis complete | `[PLANNER:DONE]` | | Analysis complete | `[PLANNER:DONE]` |
| Question answered | `[PLANNER:ANSWER]` |
| Insufficient info | `[PLANNER:BLOCKED]` | | Insufficient info | `[PLANNER:BLOCKED]` |
### Output Examples ### Output Examples
@ -56,6 +58,13 @@ steps:
[PLANNER:DONE] [PLANNER:DONE]
``` ```
**ANSWER case:**
```
{Answer to the question}
[PLANNER:ANSWER]
```
**BLOCKED case:** **BLOCKED case:**
``` ```
[PLANNER:BLOCKED] [PLANNER:BLOCKED]
@ -81,10 +90,15 @@ steps:
## Instructions ## Instructions
Analyze the task and create an implementation plan. 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), **Note:** If returned from implement step (Previous Response exists),
review and revise the plan based on that feedback (replan). review and revise the plan based on that feedback (replan).
**Tasks:** **Tasks (for implementation tasks):**
1. Understand the requirements 1. Understand the requirements
2. Identify impact scope 2. Identify impact scope
3. Decide implementation approach 3. Decide implementation approach
@ -118,6 +132,8 @@ steps:
transitions: transitions:
- condition: done - condition: done
next_step: implement next_step: implement
- condition: answer
next_step: COMPLETE
- condition: blocked - condition: blocked
next_step: ABORT next_step: ABORT
@ -132,6 +148,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits
status_rules_prompt: | status_rules_prompt: |
# ⚠️ REQUIRED: Status Output Rules ⚠️ # ⚠️ REQUIRED: Status Output Rules ⚠️

View File

@ -39,6 +39,7 @@ steps:
| 状況 | 判定 | | 状況 | 判定 |
|------|------| |------|------|
| 要件が明確で実装可能 | DONE | | 要件が明確で実装可能 | DONE |
| ユーザーが質問をしている(実装タスクではない) | ANSWER |
| 要件が不明確、情報不足 | BLOCKED | | 要件が不明確、情報不足 | BLOCKED |
## 出力フォーマット ## 出力フォーマット
@ -46,6 +47,7 @@ steps:
| 状況 | タグ | | 状況 | タグ |
|------|------| |------|------|
| 分析完了 | `[PLANNER:DONE]` | | 分析完了 | `[PLANNER:DONE]` |
| 質問への回答 | `[PLANNER:ANSWER]` |
| 情報不足 | `[PLANNER:BLOCKED]` | | 情報不足 | `[PLANNER:BLOCKED]` |
### 出力例 ### 出力例
@ -55,6 +57,13 @@ steps:
[PLANNER:DONE] [PLANNER:DONE]
``` ```
**ANSWER の場合:**
```
{質問への回答}
[PLANNER:ANSWER]
```
**BLOCKED の場合:** **BLOCKED の場合:**
``` ```
[PLANNER:BLOCKED] [PLANNER:BLOCKED]
@ -80,10 +89,15 @@ steps:
## Instructions ## Instructions
タスクを分析し、実装方針を立ててください。 タスクを分析し、実装方針を立ててください。
**判断基準:**
- ユーザーの入力が実装タスクの場合 → 計画を立てて `[PLANNER:DONE]`
- ユーザーの入力が質問の場合 → 調査・回答して `[PLANNER:ANSWER]`
- 情報不足の場合 → `[PLANNER:BLOCKED]`
**注意:** Previous Responseがある場合は差し戻しのため、 **注意:** Previous Responseがある場合は差し戻しのため、
その内容を踏まえて計画を見直してくださいreplan その内容を踏まえて計画を見直してくださいreplan
**やること:** **やること(実装タスクの場合):**
1. タスクの要件を理解する 1. タスクの要件を理解する
2. 影響範囲を特定する 2. 影響範囲を特定する
3. 実装アプローチを決める 3. 実装アプローチを決める
@ -117,6 +131,8 @@ steps:
transitions: transitions:
- condition: done - condition: done
next_step: implement next_step: implement
- condition: answer
next_step: COMPLETE
- condition: blocked - condition: blocked
next_step: ABORT next_step: ABORT
@ -131,6 +147,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits
status_rules_prompt: | status_rules_prompt: |
# ⚠️ 必須: ステータス出力ルール ⚠️ # ⚠️ 必須: ステータス出力ルール ⚠️
@ -372,6 +389,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits
status_rules_prompt: | status_rules_prompt: |
# ⚠️ 必須: ステータス出力ルール ⚠️ # ⚠️ 必須: ステータス出力ルール ⚠️
@ -556,6 +574,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits
status_rules_prompt: | status_rules_prompt: |
# ⚠️ 必須: ステータス出力ルール ⚠️ # ⚠️ 必須: ステータス出力ルール ⚠️
@ -737,6 +756,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits
status_rules_prompt: | status_rules_prompt: |
# ⚠️ 必須: ステータス出力ルール ⚠️ # ⚠️ 必須: ステータス出力ルール ⚠️
@ -805,6 +825,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits
status_rules_prompt: | status_rules_prompt: |
# ⚠️ 必須: ステータス出力ルール ⚠️ # ⚠️ 必須: ステータス出力ルール ⚠️

View File

@ -40,6 +40,7 @@ steps:
| 状況 | 判定 | | 状況 | 判定 |
|------|------| |------|------|
| 要件が明確で実装可能 | DONE | | 要件が明確で実装可能 | DONE |
| ユーザーが質問をしている(実装タスクではない) | ANSWER |
| 要件が不明確、情報不足 | BLOCKED | | 要件が不明確、情報不足 | BLOCKED |
## 出力フォーマット ## 出力フォーマット
@ -47,6 +48,7 @@ steps:
| 状況 | タグ | | 状況 | タグ |
|------|------| |------|------|
| 分析完了 | `[PLANNER:DONE]` | | 分析完了 | `[PLANNER:DONE]` |
| 質問への回答 | `[PLANNER:ANSWER]` |
| 情報不足 | `[PLANNER:BLOCKED]` | | 情報不足 | `[PLANNER:BLOCKED]` |
### 出力例 ### 出力例
@ -56,6 +58,13 @@ steps:
[PLANNER:DONE] [PLANNER:DONE]
``` ```
**ANSWER の場合:**
```
{質問への回答}
[PLANNER:ANSWER]
```
**BLOCKED の場合:** **BLOCKED の場合:**
``` ```
[PLANNER:BLOCKED] [PLANNER:BLOCKED]
@ -81,10 +90,15 @@ steps:
## Instructions ## Instructions
タスクを分析し、実装方針を立ててください。 タスクを分析し、実装方針を立ててください。
**判断基準:**
- ユーザーの入力が実装タスクの場合 → 計画を立てて `[PLANNER:DONE]`
- ユーザーの入力が質問の場合 → 調査・回答して `[PLANNER:ANSWER]`
- 情報不足の場合 → `[PLANNER:BLOCKED]`
**注意:** Previous Responseがある場合は差し戻しのため、 **注意:** Previous Responseがある場合は差し戻しのため、
その内容を踏まえて計画を見直してくださいreplan その内容を踏まえて計画を見直してくださいreplan
**やること:** **やること(実装タスクの場合):**
1. タスクの要件を理解する 1. タスクの要件を理解する
2. 影響範囲を特定する 2. 影響範囲を特定する
3. 実装アプローチを決める 3. 実装アプローチを決める
@ -118,6 +132,8 @@ steps:
transitions: transitions:
- condition: done - condition: done
next_step: implement next_step: implement
- condition: answer
next_step: COMPLETE
- condition: blocked - condition: blocked
next_step: ABORT next_step: ABORT
@ -132,6 +148,7 @@ steps:
- Bash - Bash
- WebSearch - WebSearch
- WebFetch - WebFetch
permission_mode: acceptEdits
status_rules_prompt: | status_rules_prompt: |
# ⚠️ 必須: ステータス出力ルール ⚠️ # ⚠️ 必須: ステータス出力ルール ⚠️

View File

@ -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: 認証機能を追加する');
});
});

233
src/__tests__/debug.test.ts Normal file
View File

@ -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<typeof vi.spyOn>;
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();
}
});
});
});

View File

@ -7,6 +7,7 @@ import {
AgentTypeSchema, AgentTypeSchema,
StatusSchema, StatusSchema,
TransitionConditionSchema, TransitionConditionSchema,
PermissionModeSchema,
WorkflowConfigRawSchema, WorkflowConfigRawSchema,
CustomAgentConfigSchema, CustomAgentConfigSchema,
GlobalConfigSchema, GlobalConfigSchema,
@ -33,6 +34,7 @@ describe('StatusSchema', () => {
expect(StatusSchema.parse('approved')).toBe('approved'); expect(StatusSchema.parse('approved')).toBe('approved');
expect(StatusSchema.parse('rejected')).toBe('rejected'); expect(StatusSchema.parse('rejected')).toBe('rejected');
expect(StatusSchema.parse('blocked')).toBe('blocked'); expect(StatusSchema.parse('blocked')).toBe('blocked');
expect(StatusSchema.parse('answer')).toBe('answer');
}); });
it('should reject invalid statuses', () => { it('should reject invalid statuses', () => {
@ -47,6 +49,7 @@ describe('TransitionConditionSchema', () => {
expect(TransitionConditionSchema.parse('approved')).toBe('approved'); expect(TransitionConditionSchema.parse('approved')).toBe('approved');
expect(TransitionConditionSchema.parse('rejected')).toBe('rejected'); expect(TransitionConditionSchema.parse('rejected')).toBe('rejected');
expect(TransitionConditionSchema.parse('always')).toBe('always'); expect(TransitionConditionSchema.parse('always')).toBe('always');
expect(TransitionConditionSchema.parse('answer')).toBe('answer');
}); });
it('should reject invalid conditions', () => { 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', () => { describe('WorkflowConfigRawSchema', () => {
it('should parse valid workflow config', () => { it('should parse valid workflow config', () => {
const config = { const config = {
@ -80,6 +96,61 @@ describe('WorkflowConfigRawSchema', () => {
expect(result.max_iterations).toBe(10); 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', () => { it('should require at least one step', () => {
const config = { const config = {
name: 'empty-workflow', name: 'empty-workflow',
@ -172,6 +243,7 @@ describe('GENERIC_STATUS_PATTERNS', () => {
expect(GENERIC_STATUS_PATTERNS.done).toBeDefined(); expect(GENERIC_STATUS_PATTERNS.done).toBeDefined();
expect(GENERIC_STATUS_PATTERNS.blocked).toBeDefined(); expect(GENERIC_STATUS_PATTERNS.blocked).toBeDefined();
expect(GENERIC_STATUS_PATTERNS.improve).toBeDefined(); expect(GENERIC_STATUS_PATTERNS.improve).toBeDefined();
expect(GENERIC_STATUS_PATTERNS.answer).toBeDefined();
}); });
it('should have valid regex patterns', () => { 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('[CUSTOM:DONE]')).toBe(true);
expect(new RegExp(GENERIC_STATUS_PATTERNS.done).test('[CODER:FIXED]')).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.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);
}); });
}); });

View File

@ -15,7 +15,10 @@ import { loadCustomAgents, loadAgentPrompt } from '../config/loader.js';
import { loadGlobalConfig } from '../config/globalConfig.js'; import { loadGlobalConfig } from '../config/globalConfig.js';
import { loadProjectConfig } from '../config/projectConfig.js'; import { loadProjectConfig } from '../config/projectConfig.js';
import { getProvider, type ProviderType, type ProviderCallOptions } from '../providers/index.js'; import { getProvider, type ProviderType, type ProviderCallOptions } from '../providers/index.js';
import type { AgentResponse, CustomAgentConfig } 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 }; export type { StreamCallback };
@ -31,6 +34,8 @@ export interface RunAgentOptions {
allowedTools?: string[]; allowedTools?: string[];
/** Status output rules to inject into system prompt */ /** Status output rules to inject into system prompt */
statusRulesPrompt?: string; statusRulesPrompt?: string;
/** Permission mode for tool execution (from workflow step) */
permissionMode?: PermissionMode;
onStream?: StreamCallback; onStream?: StreamCallback;
onPermissionRequest?: PermissionHandler; onPermissionRequest?: PermissionHandler;
onAskUserQuestion?: AskUserQuestionHandler; onAskUserQuestion?: AskUserQuestionHandler;
@ -103,6 +108,7 @@ export async function runCustomAgent(
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools, allowedTools,
model: resolveModel(options.cwd, options, agentConfig), model: resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,
@ -118,6 +124,7 @@ export async function runCustomAgent(
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools, allowedTools,
model: resolveModel(options.cwd, options, agentConfig), model: resolveModel(options.cwd, options, agentConfig),
permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,
@ -143,6 +150,7 @@ export async function runCustomAgent(
allowedTools, allowedTools,
model: resolveModel(options.cwd, options, agentConfig), model: resolveModel(options.cwd, options, agentConfig),
statusPatterns: agentConfig.statusPatterns, statusPatterns: agentConfig.statusPatterns,
permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,
@ -194,6 +202,15 @@ export async function runAgent(
options: RunAgentOptions options: RunAgentOptions
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const agentName = extractAgentName(agentSpec); 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 agentPath is provided (from workflow), use it to load prompt
if (options.agentPath) { if (options.agentPath) {
@ -216,6 +233,7 @@ export async function runAgent(
allowedTools: options.allowedTools, allowedTools: options.allowedTools,
model: resolveModel(options.cwd, options), model: resolveModel(options.cwd, options),
systemPrompt, systemPrompt,
permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,

View File

@ -6,7 +6,7 @@
import { executeClaudeCli, type ClaudeSpawnOptions, type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from './process.js'; import { executeClaudeCli, type ClaudeSpawnOptions, type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from './process.js';
import type { AgentDefinition } from '@anthropic-ai/claude-agent-sdk'; 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'; import { GENERIC_STATUS_PATTERNS } from '../models/schemas.js';
/** Options for calling Claude */ /** Options for calling Claude */
@ -20,6 +20,8 @@ export interface ClaudeCallOptions {
statusPatterns?: Record<string, string>; statusPatterns?: Record<string, string>;
/** SDK agents to register for sub-agent execution */ /** SDK agents to register for sub-agent execution */
agents?: Record<string, AgentDefinition>; agents?: Record<string, AgentDefinition>;
/** Permission mode for tool execution (from workflow step) */
permissionMode?: PermissionMode;
/** Enable streaming mode with callback for real-time output */ /** Enable streaming mode with callback for real-time output */
onStream?: StreamCallback; onStream?: StreamCallback;
/** Custom permission handler for interactive permission prompts */ /** Custom permission handler for interactive permission prompts */
@ -112,6 +114,7 @@ export async function callClaude(
maxTurns: options.maxTurns, maxTurns: options.maxTurns,
systemPrompt: options.systemPrompt, systemPrompt: options.systemPrompt,
agents: options.agents, agents: options.agents,
permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,
@ -145,6 +148,7 @@ export async function callClaudeCustom(
model: options.model, model: options.model,
maxTurns: options.maxTurns, maxTurns: options.maxTurns,
systemPrompt, systemPrompt,
permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,
@ -193,6 +197,7 @@ export async function callClaudeSkill(
allowedTools: options.allowedTools, allowedTools: options.allowedTools,
model: options.model, model: options.model,
maxTurns: options.maxTurns, maxTurns: options.maxTurns,
permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,

View File

@ -22,7 +22,7 @@ import {
} from './config/index.js'; } from './config/index.js';
import { clearAgentSessions, getCurrentWorkflow, isVerboseMode } from './config/paths.js'; import { clearAgentSessions, getCurrentWorkflow, isVerboseMode } from './config/paths.js';
import { info, error, success, setLogLevel } from './utils/ui.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 { import {
executeTask, executeTask,
runAllTasks, runAllTasks,
@ -57,25 +57,33 @@ program
// Initialize project directories (.takt/) // Initialize project directories (.takt/)
initProjectDirs(cwd); initProjectDirs(cwd);
// Initialize debug logger from config // Determine verbose mode and initialize logging
const debugConfig = getEffectiveDebugConfig(cwd); 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); 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', { log.info('TAKT CLI starting', {
version: '0.1.0', version: '0.1.0',
cwd, cwd,
task: task || null, 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 // Handle slash commands
if (task?.startsWith('/')) { if (task?.startsWith('/')) {
const parts = task.slice(1).split(/\s+/); const parts = task.slice(1).split(/\s+/);

View File

@ -5,6 +5,7 @@
import { loadWorkflow } from '../config/index.js'; import { loadWorkflow } from '../config/index.js';
import { TaskRunner, type TaskInfo } from '../task/index.js'; import { TaskRunner, type TaskInfo } from '../task/index.js';
import { createWorktree } from '../task/worktree.js'; import { createWorktree } from '../task/worktree.js';
import { autoCommitWorktree } from '../task/autoCommit.js';
import { import {
header, header,
info, info,
@ -45,6 +46,71 @@ export async function executeTask(
return result.success; 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<boolean> {
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/ * Run all pending tasks from .takt/tasks/
* *
@ -76,46 +142,12 @@ export async function runAllTasks(
info(`=== Task: ${task.name} ===`); info(`=== Task: ${task.name} ===`);
console.log(); console.log();
const startedAt = new Date().toISOString(); const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName);
const executionLog: string[] = [];
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) { if (taskSuccess) {
successCount++; successCount++;
success(`Task "${task.name}" completed`);
} else { } else {
failCount++; failCount++;
error(`Task "${task.name}" failed`);
}
} catch (err) {
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, task: TaskInfo,
defaultCwd: string, defaultCwd: string,
defaultWorkflow: string defaultWorkflow: string
): { execCwd: string; execWorkflow: string } { ): { execCwd: string; execWorkflow: string; isWorktree: boolean } {
const data = task.data; const data = task.data;
// No structured data: use defaults // No structured data: use defaults
if (!data) { if (!data) {
return { execCwd: defaultCwd, execWorkflow: defaultWorkflow }; return { execCwd: defaultCwd, execWorkflow: defaultWorkflow, isWorktree: false };
} }
let execCwd = defaultCwd; let execCwd = defaultCwd;
let isWorktree = false;
// Handle worktree // Handle worktree
if (data.worktree) { if (data.worktree) {
@ -158,11 +191,12 @@ export function resolveTaskExecution(
taskSlug: task.name, taskSlug: task.name,
}); });
execCwd = result.path; execCwd = result.path;
isWorktree = true;
info(`Worktree created: ${result.path} (branch: ${result.branch})`); info(`Worktree created: ${result.path} (branch: ${result.branch})`);
} }
// Handle workflow override // Handle workflow override
const execWorkflow = data.workflow || defaultWorkflow; const execWorkflow = data.workflow || defaultWorkflow;
return { execCwd, execWorkflow }; return { execCwd, execWorkflow, isWorktree };
} }

View File

@ -11,12 +11,10 @@ import { getCurrentWorkflow } from '../config/paths.js';
import { import {
header, header,
info, info,
error,
success, success,
status, status,
} from '../utils/ui.js'; } from '../utils/ui.js';
import { getErrorMessage } from '../utils/error.js'; import { executeAndCompleteTask } from './taskExecution.js';
import { executeTask, resolveTaskExecution } from './taskExecution.js';
import { DEFAULT_WORKFLOW_NAME } from '../constants.js'; import { DEFAULT_WORKFLOW_NAME } from '../constants.js';
/** /**
@ -53,44 +51,12 @@ export async function watchTasks(cwd: string): Promise<void> {
info(`=== Task ${taskCount}: ${task.name} ===`); info(`=== Task ${taskCount}: ${task.name} ===`);
console.log(); console.log();
const startedAt = new Date().toISOString(); const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName);
const executionLog: string[] = [];
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) { if (taskSuccess) {
successCount++; successCount++;
success(`Task "${task.name}" completed`);
} else { } else {
failCount++; failCount++;
error(`Task "${task.name}" failed`);
}
} catch (err) {
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(); console.log();

View File

@ -69,6 +69,7 @@ function normalizeWorkflowConfig(raw: unknown, workflowDir: string): WorkflowCon
allowedTools: step.allowed_tools, allowedTools: step.allowed_tools,
provider: step.provider, provider: step.provider,
model: step.model, model: step.model,
permissionMode: step.permission_mode,
instructionTemplate: step.instruction_template || step.instruction || '{task}', instructionTemplate: step.instruction_template || step.instruction || '{task}',
statusRulesPrompt: step.status_rules_prompt, statusRulesPrompt: step.status_rules_prompt,
transitions: step.transitions.map((t) => ({ transitions: step.transitions.map((t) => ({

View File

@ -21,6 +21,7 @@ export const StatusSchema = z.enum([
'improve', 'improve',
'cancelled', 'cancelled',
'interrupted', 'interrupted',
'answer',
]); ]);
/** /**
@ -33,6 +34,7 @@ export const StatusSchema = z.enum([
* - approved: Review passed * - approved: Review passed
* - rejected: Review failed, needs major rework * - rejected: Review failed, needs major rework
* - improve: Needs improvement (security concerns, quality issues) * - improve: Needs improvement (security concerns, quality issues)
* - answer: Question answered (complete workflow as success)
* - always: Unconditional transition * - always: Unconditional transition
*/ */
export const TransitionConditionSchema = z.enum([ export const TransitionConditionSchema = z.enum([
@ -41,6 +43,7 @@ export const TransitionConditionSchema = z.enum([
'approved', 'approved',
'rejected', 'rejected',
'improve', 'improve',
'answer',
'always', 'always',
]); ]);
@ -53,6 +56,9 @@ export const WorkflowTransitionSchema = z.object({
nextStep: z.string().min(1), 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 */ /** Workflow step schema - raw YAML format */
export const WorkflowStepRawSchema = z.object({ export const WorkflowStepRawSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
@ -62,6 +68,8 @@ export const WorkflowStepRawSchema = z.object({
allowed_tools: z.array(z.string()).optional(), allowed_tools: z.array(z.string()).optional(),
provider: z.enum(['claude', 'codex', 'mock']).optional(), provider: z.enum(['claude', 'codex', 'mock']).optional(),
model: z.string().optional(), model: z.string().optional(),
/** Permission mode for tool execution in this step */
permission_mode: PermissionModeSchema.optional(),
instruction: z.string().optional(), instruction: z.string().optional(),
instruction_template: z.string().optional(), instruction_template: z.string().optional(),
status_rules_prompt: z.string().optional(), status_rules_prompt: z.string().optional(),
@ -141,4 +149,5 @@ export const GENERIC_STATUS_PATTERNS: Record<string, string> = {
improve: '\\[[\\w-]+:IMPROVE\\]', improve: '\\[[\\w-]+:IMPROVE\\]',
done: '\\[[\\w-]+:(DONE|FIXED)\\]', done: '\\[[\\w-]+:(DONE|FIXED)\\]',
blocked: '\\[[\\w-]+:BLOCKED\\]', blocked: '\\[[\\w-]+:BLOCKED\\]',
answer: '\\[[\\w-]+:ANSWER\\]',
}; };

View File

@ -15,7 +15,8 @@ export type Status =
| 'rejected' | 'rejected'
| 'improve' | 'improve'
| 'cancelled' | 'cancelled'
| 'interrupted'; | 'interrupted'
| 'answer';
/** Condition types for workflow transitions */ /** Condition types for workflow transitions */
export type TransitionCondition = export type TransitionCondition =
@ -24,6 +25,7 @@ export type TransitionCondition =
| 'approved' | 'approved'
| 'rejected' | 'rejected'
| 'improve' | 'improve'
| 'answer'
| 'always'; | 'always';
/** Response from an agent execution */ /** Response from an agent execution */
@ -53,6 +55,9 @@ export interface WorkflowTransition {
/** Behavior when no status marker is found in agent output */ /** Behavior when no status marker is found in agent output */
export type OnNoStatusBehavior = 'complete' | 'continue' | 'stay'; export type OnNoStatusBehavior = 'complete' | 'continue' | 'stay';
/** Permission mode for tool execution */
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
/** Single step in a workflow */ /** Single step in a workflow */
export interface WorkflowStep { export interface WorkflowStep {
name: string; name: string;
@ -68,6 +73,8 @@ export interface WorkflowStep {
provider?: 'claude' | 'codex' | 'mock'; provider?: 'claude' | 'codex' | 'mock';
/** Model override for this step */ /** Model override for this step */
model?: string; model?: string;
/** Permission mode for tool execution in this step */
permissionMode?: PermissionMode;
instructionTemplate: string; instructionTemplate: string;
/** Status output rules to be injected into system prompt */ /** Status output rules to be injected into system prompt */
statusRulesPrompt?: string; statusRulesPrompt?: string;

View File

@ -16,6 +16,7 @@ export class ClaudeProvider implements Provider {
model: options.model, model: options.model,
systemPrompt: options.systemPrompt, systemPrompt: options.systemPrompt,
statusPatterns: options.statusPatterns, statusPatterns: options.statusPatterns,
permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,
@ -32,6 +33,7 @@ export class ClaudeProvider implements Provider {
allowedTools: options.allowedTools, allowedTools: options.allowedTools,
model: options.model, model: options.model,
statusPatterns: options.statusPatterns, statusPatterns: options.statusPatterns,
permissionMode: options.permissionMode,
onStream: options.onStream, onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,

View File

@ -6,7 +6,7 @@
*/ */
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/process.js'; 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 { ClaudeProvider } from './claude.js';
import { CodexProvider } from './codex.js'; import { CodexProvider } from './codex.js';
import { MockProvider } from './mock.js'; import { MockProvider } from './mock.js';
@ -19,6 +19,8 @@ export interface ProviderCallOptions {
systemPrompt?: string; systemPrompt?: string;
allowedTools?: string[]; allowedTools?: string[];
statusPatterns?: Record<string, string>; statusPatterns?: Record<string, string>;
/** Permission mode for tool execution (from workflow step) */
permissionMode?: PermissionMode;
onStream?: StreamCallback; onStream?: StreamCallback;
onPermissionRequest?: PermissionHandler; onPermissionRequest?: PermissionHandler;
onAskUserQuestion?: AskUserQuestionHandler; onAskUserQuestion?: AskUserQuestionHandler;

86
src/task/autoCommit.ts Normal file
View File

@ -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}`,
};
}
}

View File

@ -13,4 +13,5 @@ export { showTaskList } from './display.js';
export { TaskFileSchema, type TaskFileData } from './schema.js'; export { TaskFileSchema, type TaskFileData } from './schema.js';
export { parseTaskFile, parseTaskFiles, type ParsedTask } from './parser.js'; export { parseTaskFile, parseTaskFiles, type ParsedTask } from './parser.js';
export { createWorktree, removeWorktree, type WorktreeOptions, type WorktreeResult } from './worktree.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'; export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';

View File

@ -1,6 +1,7 @@
/** /**
* Debug logging utilities for takt * 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'; import { existsSync, appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
@ -13,6 +14,9 @@ let debugEnabled = false;
let debugLogFile: string | null = null; let debugLogFile: string | null = null;
let initialized = false; let initialized = false;
/** Verbose console output state */
let verboseConsoleEnabled = false;
/** Get default debug log file path */ /** Get default debug log file path */
function getDefaultLogFile(): string { function getDefaultLogFile(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
@ -61,6 +65,17 @@ export function resetDebugLogger(): void {
debugEnabled = false; debugEnabled = false;
debugLogFile = null; debugLogFile = null;
initialized = false; 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 */ /** Check if debug is enabled */
@ -92,13 +107,23 @@ function formatLogMessage(level: string, component: string, message: string, dat
return logLine; return logLine;
} }
/** Write a debug log entry */ /** Format a compact console log line */
export function debugLog(component: string, message: string, data?: unknown): void { 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) { if (!debugEnabled || !debugLogFile) {
return; return;
} }
const logLine = formatLogMessage('DEBUG', component, message, data); const logLine = formatLogMessage(level, component, message, data);
try { try {
appendFileSync(debugLogFile, logLine + '\n', 'utf-8'); 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 */ /** Write an info log entry */
export function infoLog(component: string, message: string, data?: unknown): void { export function infoLog(component: string, message: string, data?: unknown): void {
if (!debugEnabled || !debugLogFile) { writeLog('INFO', component, message, data);
return;
}
const logLine = formatLogMessage('INFO', component, message, data);
try {
appendFileSync(debugLogFile, logLine + '\n', 'utf-8');
} catch {
// Silently fail
}
} }
/** Write an error log entry */ /** Write an error log entry */
export function errorLog(component: string, message: string, data?: unknown): void { export function errorLog(component: string, message: string, data?: unknown): void {
if (!debugEnabled || !debugLogFile) { writeLog('ERROR', component, message, data);
return;
}
const logLine = formatLogMessage('ERROR', component, message, data);
try {
appendFileSync(debugLogFile, logLine + '\n', 'utf-8');
} catch {
// Silently fail
}
} }
/** Log function entry with arguments */ /** Log function entry with arguments */

View File

@ -25,6 +25,9 @@ import {
incrementStepIteration, incrementStepIteration,
} from './state-manager.js'; } from './state-manager.js';
import { generateReportDir } from '../utils/session.js'; import { generateReportDir } from '../utils/session.js';
import { createLogger } from '../utils/debug.js';
const log = createLogger('engine');
// Re-export types for backward compatibility // Re-export types for backward compatibility
export type { export type {
@ -60,6 +63,12 @@ export class WorkflowEngine extends EventEmitter {
this.ensureReportDirExists(); this.ensureReportDirExists();
this.validateConfig(); this.validateConfig();
this.state = createInitialState(config, options); 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) */ /** 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 stepIteration = incrementStepIteration(this.state, step.name);
const instruction = this.buildInstruction(step, stepIteration); const instruction = this.buildInstruction(step, stepIteration);
const sessionId = this.state.agentSessions.get(step.agent); 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 = { const agentOptions: RunAgentOptions = {
cwd: this.cwd, cwd: this.cwd,
@ -155,6 +171,7 @@ export class WorkflowEngine extends EventEmitter {
statusRulesPrompt: step.statusRulesPrompt, statusRulesPrompt: step.statusRulesPrompt,
provider: step.provider, provider: step.provider,
model: step.model, model: step.model,
permissionMode: step.permissionMode,
onStream: this.options.onStream, onStream: this.options.onStream,
onPermissionRequest: this.options.onPermissionRequest, onPermissionRequest: this.options.onPermissionRequest,
onAskUserQuestion: this.options.onAskUserQuestion, onAskUserQuestion: this.options.onAskUserQuestion,
@ -239,6 +256,11 @@ export class WorkflowEngine extends EventEmitter {
} }
const nextStep = determineNextStep(step, response.status, this.config); const nextStep = determineNextStep(step, response.status, this.config);
log.debug('Step transition', {
from: step.name,
status: response.status,
nextStep,
});
if (nextStep === COMPLETE_STEP) { if (nextStep === COMPLETE_STEP) {
this.state.status = 'completed'; this.state.status = 'completed';

View File

@ -31,6 +31,7 @@ export function matchesCondition(
approved: ['approved'], approved: ['approved'],
rejected: ['rejected'], rejected: ['rejected'],
improve: ['improve'], improve: ['improve'],
answer: ['answer'],
pending: [], pending: [],
in_progress: [], in_progress: [],
cancelled: [], cancelled: [],