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:
parent
354e9c48a3
commit
d900ee8bc4
@ -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 ⚠️
|
||||
|
||||
|
||||
@ -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 ⚠️
|
||||
|
||||
|
||||
@ -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: |
|
||||
# ⚠️ 必須: ステータス出力ルール ⚠️
|
||||
|
||||
|
||||
@ -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: |
|
||||
# ⚠️ 必須: ステータス出力ルール ⚠️
|
||||
|
||||
|
||||
142
src/__tests__/autoCommit.test.ts
Normal file
142
src/__tests__/autoCommit.test.ts
Normal 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
233
src/__tests__/debug.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<AgentResponse> {
|
||||
const agentName = extractAgentName(agentSpec);
|
||||
log.debug('Running agent', {
|
||||
agentSpec,
|
||||
agentName,
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
hasAgentPath: !!options.agentPath,
|
||||
hasSession: !!options.sessionId,
|
||||
permissionMode: options.permissionMode,
|
||||
});
|
||||
|
||||
// If agentPath is provided (from workflow), use it to load prompt
|
||||
if (options.agentPath) {
|
||||
@ -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,
|
||||
|
||||
@ -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<string, string>;
|
||||
/** SDK agents to register for sub-agent execution */
|
||||
agents?: Record<string, AgentDefinition>;
|
||||
/** Permission mode for tool execution (from workflow step) */
|
||||
permissionMode?: PermissionMode;
|
||||
/** Enable streaming mode with callback for real-time output */
|
||||
onStream?: StreamCallback;
|
||||
/** Custom permission handler for interactive permission prompts */
|
||||
@ -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,
|
||||
|
||||
32
src/cli.ts
32
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+/);
|
||||
|
||||
@ -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<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/
|
||||
*
|
||||
@ -76,46 +142,12 @@ export async function runAllTasks(
|
||||
info(`=== Task: ${task.name} ===`);
|
||||
console.log();
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
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,
|
||||
});
|
||||
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName);
|
||||
|
||||
if (taskSuccess) {
|
||||
successCount++;
|
||||
success(`Task "${task.name}" completed`);
|
||||
} else {
|
||||
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,
|
||||
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 };
|
||||
}
|
||||
|
||||
@ -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<void> {
|
||||
info(`=== Task ${taskCount}: ${task.name} ===`);
|
||||
console.log();
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
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,
|
||||
});
|
||||
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName);
|
||||
|
||||
if (taskSuccess) {
|
||||
successCount++;
|
||||
success(`Task "${task.name}" completed`);
|
||||
} else {
|
||||
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();
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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<string, string> = {
|
||||
improve: '\\[[\\w-]+:IMPROVE\\]',
|
||||
done: '\\[[\\w-]+:(DONE|FIXED)\\]',
|
||||
blocked: '\\[[\\w-]+:BLOCKED\\]',
|
||||
answer: '\\[[\\w-]+:ANSWER\\]',
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string, string>;
|
||||
/** Permission mode for tool execution (from workflow step) */
|
||||
permissionMode?: PermissionMode;
|
||||
onStream?: StreamCallback;
|
||||
onPermissionRequest?: PermissionHandler;
|
||||
onAskUserQuestion?: AskUserQuestionHandler;
|
||||
|
||||
86
src/task/autoCommit.ts
Normal file
86
src/task/autoCommit.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -31,6 +31,7 @@ export function matchesCondition(
|
||||
approved: ['approved'],
|
||||
rejected: ['rejected'],
|
||||
improve: ['improve'],
|
||||
answer: ['answer'],
|
||||
pending: [],
|
||||
in_progress: [],
|
||||
cancelled: [],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user