This commit is contained in:
nrslib 2026-01-31 23:24:24 +09:00
parent 36b438e45a
commit 063b0e8d70
11 changed files with 262 additions and 57 deletions

View File

@ -1,44 +1,40 @@
# Repository Guidelines
このリポジトリに貢献する際の基本的な構成と期待値をまとめています。短い説明と例で各セクションを完結に示します。
## プロジェクト構成とモジュール整理
- 主要なソースは `src/`、CLI は `src/cli.ts`、公開 API は `src/index.ts` にある。
- テストは `src/__tests__/` に配置する。
- ビルド成果物は `dist/`、実行用スクリプトは `bin/`
- 既定のリソースは `resources/`、ドキュメントは `docs/`
- 実行時設定はユーザーディレクトリ `~/.takt/`、プロジェクト固有設定は `.takt/` に置く。
- 主要ソースは `src/` にあり、エントリポイントは `src/index.ts`、CLI は `src/cli.ts` です。
- テストは `src/__tests__/` に置き、ファイル名は対象機能が一目でわかるようにします(例: `client.test.ts`)。
- ビルド成果物は `dist/`、実行スクリプトは `bin/`、静的リソースは `resources/`、ドキュメントは `docs/` で管理します。
- 設定やキャッシュを使う際は `~/.takt/` 以下(実行時)や `.takt/`(プロジェクト固有)を参照します。
## ビルド・テスト・開発コマンド
```
npm run build # TypeScript コンパイルし dist/ を生成
npm run watch # 変更監視ビルド
npm run test # Vitest の全テスト実行
npm run test:watch # Vitest のウォッチモード
npm run lint # ESLint で静的解析
npm run build # TypeScript コンパイルを実行し dist/ を生成
npm run watch # ソース変更監視しつつ再ビルド
npm run lint # ESLint で src/ を解析
npm run test # Vitest で全テストを実行
npm run test:watch # テスト実行をウォッチ
```
単体実行例: `npx vitest run src/__tests__/client.test.ts`
- 単体テストを個別実行する例: `npx vitest run src/__tests__/client.test.ts`
## コーディング規約と命名
- TypeScript の strict 設定を前提にする
- ESM 形式のため、import の拡張子は `.js` を使う
- 既存ファイルは ESLint ルールに従い、読みやすさ優先で簡潔に書く
- 変更対象の命名や構成は既存パターンに合わせる
## コーディングスタイルと命名
- TypeScript + strict モードを前提にし、可読性や null 安全を優先します
- ESM 形式なので `import` の拡張子は `.js` に固定してください
- ESLint`eslint src/`)と prettier ルールを守り、命名は camelCase関数・変数および PascalCaseクラスを採用
- クロスファイルの共有型は `src/types/` 風に整理し、既存の命名パターンを踏襲します
## テスト指針
- テストフレームワークは Vitest。
- 追加・修正は関連テストの追加を推奨する
- テストファイルは `src/__tests__/` に置き、内容が分かる名前を付ける
- テストフレームワークは Vitest`vitest.config.ts` 参照)。全ての新機能・修正には関連テストを追加
- テストファイル名は `<対象>.test.ts`、あるいは `<対象>.spec.ts` で統一
- コンポーネント依存はモックやスタブを使い、状態を分離したシナリオを心がけます
## コミットとプルリク
- 直近の履歴は短い要約の一行コミットが中心(日本語・英語混在)。
- 変更内容が分かる簡潔な件名を推奨。
- PR は小さく集中した変更を基本とし、必要ならテストとドキュメントを更新。
- 事前に Issue を立てて相談する方針。
- 履歴は「短い要約 + 1 行」スタイル。英語・日本語混在可、目的が伝わるよう `feat:`, `fix:` 等のプレフィックスも可。
- PR には変更概要・テスト結果・関連 Issueあればを含め、小さな対象に絞ってレビュー負荷を抑えます。
- ドキュメントや設定変更を伴う場合は `CHANGELOG.md` への追記を検討し、スクリーンショットやログがあれば添付します。
## セキュリティと設定の注意
- 脆弱性は公開 Issue ではなく、メンテナへ非公開で報告する。
- `.takt/logs/` には機密情報が残る可能性があるため取り扱いに注意。
- `~/.takt/config.yaml` の trusted ディレクトリは必要最小限に絞る。
## エージェント向け補足
- ワークフローは `~/.takt/workflows/` の YAML を読み込む。
- 既存の遷移条件やスキーマは安易に拡張しない。
- 脆弱性は公開 Issue ではなくメンテナへ直接報告します。
- `.takt/logs/` など機密情報を含む可能性のあるファイルは共有しないでください。
- `~/.takt/config.yaml``trusted` ディレクトリは最小限にし、不要なパスは登録しないでください。
- 新しいワークフローを追加する場合は `~/.takt/workflows/` の既存スキーマを踏襲し、不要な拡張を避けます。

View File

@ -0,0 +1,129 @@
/**
* Tests for WorkflowEngine provider/model overrides.
*
* Verifies that CLI-specified overrides take precedence over workflow step defaults,
* and that step-specific values are used when no overrides are present.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
vi.mock('../workflow/rule-evaluator.js', () => ({
detectMatchedRule: vi.fn(),
}));
vi.mock('../workflow/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn(),
runReportPhase: vi.fn(),
runStatusJudgmentPhase: vi.fn(),
}));
vi.mock('../utils/session.js', () => ({
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
}));
import { WorkflowEngine } from '../workflow/engine.js';
import { runAgent } from '../agents/runner.js';
import type { WorkflowConfig } from '../models/types.js';
import {
makeResponse,
makeRule,
makeStep,
mockRunAgentSequence,
mockDetectMatchedRuleSequence,
applyDefaultMocks,
} from './engine-test-helpers.js';
describe('WorkflowEngine agent overrides', () => {
beforeEach(() => {
vi.resetAllMocks();
applyDefaultMocks();
});
it('respects workflow step provider/model even when CLI overrides are provided', async () => {
const step = makeStep('plan', {
provider: 'claude',
model: 'claude-step',
rules: [makeRule('done', 'COMPLETE')],
});
const config: WorkflowConfig = {
name: 'override-test',
steps: [step],
initialStep: 'plan',
maxIterations: 1,
};
mockRunAgentSequence([
makeResponse({ agent: step.agent, content: 'done' }),
]);
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
const engine = new WorkflowEngine(config, '/tmp/project', 'override task', {
provider: 'codex',
model: 'cli-model',
});
await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('claude');
expect(options.model).toBe('claude-step');
});
it('allows CLI overrides when workflow step leaves provider/model undefined', async () => {
const step = makeStep('plan', {
rules: [makeRule('done', 'COMPLETE')],
});
const config: WorkflowConfig = {
name: 'override-fallback',
steps: [step],
initialStep: 'plan',
maxIterations: 1,
};
mockRunAgentSequence([
makeResponse({ agent: step.agent, content: 'done' }),
]);
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
const engine = new WorkflowEngine(config, '/tmp/project', 'override task', {
provider: 'codex',
model: 'cli-model',
});
await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('codex');
expect(options.model).toBe('cli-model');
});
it('falls back to workflow step provider/model when no overrides supplied', async () => {
const step = makeStep('plan', {
provider: 'claude',
model: 'step-model',
rules: [makeRule('done', 'COMPLETE')],
});
const config: WorkflowConfig = {
name: 'step-defaults',
steps: [step],
initialStep: 'plan',
maxIterations: 1,
};
mockRunAgentSequence([
makeResponse({ agent: step.agent, content: 'done' }),
]);
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
const engine = new WorkflowEngine(config, '/tmp/project', 'step task');
await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('claude');
expect(options.model).toBe('step-model');
});
});

View File

@ -150,6 +150,30 @@ describe('executePipeline', () => {
'Fix the bug',
'/tmp/test',
'default',
undefined,
undefined,
);
});
it('passes provider/model overrides to task execution', async () => {
mockExecuteTask.mockResolvedValueOnce(true);
const exitCode = await executePipeline({
task: 'Fix the bug',
workflow: 'default',
autoPr: false,
cwd: '/tmp/test',
provider: 'codex',
model: 'codex-model',
});
expect(exitCode).toBe(0);
expect(mockExecuteTask).toHaveBeenCalledWith(
'Fix the bug',
'/tmp/test',
'default',
undefined,
{ provider: 'codex', model: 'codex-model' },
);
});
@ -205,6 +229,8 @@ describe('executePipeline', () => {
'From --task flag',
'/tmp/test',
'magi',
undefined,
undefined,
);
});
@ -359,7 +385,13 @@ describe('executePipeline', () => {
});
expect(exitCode).toBe(0);
expect(mockExecuteTask).toHaveBeenCalledWith('Fix the bug', '/tmp/test', 'default');
expect(mockExecuteTask).toHaveBeenCalledWith(
'Fix the bug',
'/tmp/test',
'default',
undefined,
undefined,
);
// No git operations should have been called
const gitCalls = mockExecFileSync.mock.calls.filter(

View File

@ -50,6 +50,8 @@ import { DEFAULT_WORKFLOW_NAME } from './constants.js';
import { checkForUpdates } from './utils/updateNotifier.js';
import { resolveIssueTask, isIssueReference } from './github/issue.js';
import { createPullRequest, buildPrBody } from './github/pr.js';
import type { TaskExecutionOptions } from './commands/taskExecution.js';
import type { ProviderType } from './providers/index.js';
const require = createRequire(import.meta.url);
const { version: cliVersion } = require('../package.json') as { version: string };
@ -109,6 +111,7 @@ async function selectAndExecuteTask(
cwd: string,
task: string,
options?: { autoPr?: boolean; repo?: string },
agentOverrides?: TaskExecutionOptions,
): Promise<void> {
const selectedWorkflow = await selectWorkflow(cwd);
@ -120,7 +123,7 @@ async function selectAndExecuteTask(
const { execCwd, isWorktree, branch } = await confirmAndCreateWorktree(cwd, task);
log.info('Starting task execution', { workflow: selectedWorkflow, worktree: isWorktree });
const taskSuccess = await executeTask(task, execCwd, selectedWorkflow, cwd);
const taskSuccess = await executeTask(task, execCwd, selectedWorkflow, cwd, agentOverrides);
if (taskSuccess && isWorktree) {
const commitResult = autoCommitAndPush(execCwd, task, cwd);
@ -186,6 +189,18 @@ export async function confirmAndCreateWorktree(
const program = new Command();
function resolveAgentOverrides(): TaskExecutionOptions | undefined {
const opts = program.opts();
const provider = opts.provider as ProviderType | undefined;
const model = opts.model as string | undefined;
if (!provider && !model) {
return undefined;
}
return { provider, model };
}
program
.name('takt')
.description('TAKT: Task Agent Koordination Tool')
@ -198,6 +213,8 @@ program
.option('-b, --branch <name>', 'Branch name (auto-generated if omitted)')
.option('--auto-pr', 'Create PR after successful execution')
.option('--repo <owner/repo>', 'Repository (defaults to current)')
.option('--provider <name>', 'Override agent provider (claude|codex|mock)')
.option('--model <name>', 'Override agent model')
.option('-t, --task <string>', 'Task content (triggers pipeline/non-interactive mode)')
.option('--skip-git', 'Skip branch creation, commit, and push (pipeline mode)');
@ -239,14 +256,14 @@ program
.description('Run all pending tasks from .takt/tasks/')
.action(async () => {
const workflow = getCurrentWorkflow(resolvedCwd);
await runAllTasks(resolvedCwd, workflow);
await runAllTasks(resolvedCwd, workflow, resolveAgentOverrides());
});
program
.command('watch')
.description('Watch for tasks and auto-execute')
.action(async () => {
await watchTasks(resolvedCwd);
await watchTasks(resolvedCwd, resolveAgentOverrides());
});
program
@ -261,7 +278,7 @@ program
.command('list')
.description('List task branches (merge/delete)')
.action(async () => {
await listTasks(resolvedCwd);
await listTasks(resolvedCwd, resolveAgentOverrides());
});
program
@ -318,6 +335,7 @@ program
.argument('[task]', 'Task to execute (or GitHub issue reference like "#6")')
.action(async (task?: string) => {
const opts = program.opts();
const agentOverrides = resolveAgentOverrides();
// --- Pipeline mode (non-interactive): triggered by --task ---
if (pipelineMode) {
@ -330,6 +348,8 @@ program
repo: opts.repo as string | undefined,
skipGit: opts.skipGit === true,
cwd: resolvedCwd,
provider: agentOverrides?.provider,
model: agentOverrides?.model,
});
if (exitCode !== 0) {
@ -350,7 +370,7 @@ program
if (issueFromOption) {
try {
const resolvedTask = resolveIssueTask(`#${issueFromOption}`);
await selectAndExecuteTask(resolvedCwd, resolvedTask, prOptions);
await selectAndExecuteTask(resolvedCwd, resolvedTask, prOptions, agentOverrides);
} catch (e) {
error(e instanceof Error ? e.message : String(e));
process.exit(1);
@ -371,7 +391,7 @@ program
}
}
await selectAndExecuteTask(resolvedCwd, resolvedTask, prOptions);
await selectAndExecuteTask(resolvedCwd, resolvedTask, prOptions, agentOverrides);
return;
}
@ -382,7 +402,7 @@ program
return;
}
await selectAndExecuteTask(resolvedCwd, result.task, prOptions);
await selectAndExecuteTask(resolvedCwd, result.task, prOptions, agentOverrides);
});
program.parse();

View File

@ -24,7 +24,7 @@ import { autoCommitAndPush } from '../task/autoCommit.js';
import { selectOption, confirm, promptInput } from '../prompt/index.js';
import { info, success, error as logError, warn } from '../utils/ui.js';
import { createLogger } from '../utils/debug.js';
import { executeTask } from './taskExecution.js';
import { executeTask, type TaskExecutionOptions } from './taskExecution.js';
import { listWorkflows } from '../config/workflowLoader.js';
import { getCurrentWorkflow } from '../config/paths.js';
import { DEFAULT_WORKFLOW_NAME } from '../constants.js';
@ -292,6 +292,7 @@ function getBranchContext(projectDir: string, branch: string): string {
export async function instructBranch(
projectDir: string,
item: BranchListItem,
options?: TaskExecutionOptions,
): Promise<boolean> {
const { branch } = item.info;
@ -323,7 +324,7 @@ export async function instructBranch(
: instruction;
// 5. Execute task on temp clone
const taskSuccess = await executeTask(fullInstruction, clone.path, selectedWorkflow, projectDir);
const taskSuccess = await executeTask(fullInstruction, clone.path, selectedWorkflow, projectDir, options);
// 6. Auto-commit+push if successful
if (taskSuccess) {
@ -351,7 +352,7 @@ export async function instructBranch(
/**
* Main entry point: list branch-based tasks interactively.
*/
export async function listTasks(cwd: string): Promise<void> {
export async function listTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> {
log.info('Starting list-tasks');
const defaultBranch = detectDefaultBranch(cwd);
@ -367,7 +368,7 @@ export async function listTasks(cwd: string): Promise<void> {
const items = buildListItems(cwd, branches, defaultBranch);
// Build selection options
const options = items.map((item, idx) => {
const menuOptions = items.map((item, idx) => {
const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`;
const description = item.originalInstruction
? `${filesSummary} | ${item.originalInstruction}`
@ -381,7 +382,7 @@ export async function listTasks(cwd: string): Promise<void> {
const selected = await selectOption<string>(
'List Tasks (Branches)',
options,
menuOptions,
);
if (selected === null) {
@ -406,7 +407,7 @@ export async function listTasks(cwd: string): Promise<void> {
switch (action) {
case 'instruct':
await instructBranch(cwd, item);
await instructBranch(cwd, item, options);
break;
case 'try':
tryMergeBranch(cwd, item);

View File

@ -12,7 +12,7 @@
import { execFileSync } from 'node:child_process';
import { fetchIssue, formatIssueAsTask, checkGhCli, type GitHubIssue } from '../github/issue.js';
import { createPullRequest, pushBranch, buildPrBody } from '../github/pr.js';
import { executeTask } from './taskExecution.js';
import { executeTask, type TaskExecutionOptions } from './taskExecution.js';
import { loadGlobalConfig } from '../config/globalConfig.js';
import { info, error, success, status } from '../utils/ui.js';
import { createLogger } from '../utils/debug.js';
@ -23,6 +23,7 @@ import {
EXIT_GIT_OPERATION_FAILED,
EXIT_PR_CREATION_FAILED,
} from '../exitCodes.js';
import type { ProviderType } from '../providers/index.js';
const log = createLogger('pipeline');
@ -43,6 +44,8 @@ export interface PipelineExecutionOptions {
skipGit?: boolean;
/** Working directory */
cwd: string;
provider?: ProviderType;
model?: string;
}
/**
@ -184,7 +187,11 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis
info(`Running workflow: ${workflow}`);
log.info('Pipeline workflow execution starting', { workflow, branch, skipGit, issueNumber: options.issueNumber });
const taskSuccess = await executeTask(task, cwd, workflow);
const agentOverrides: TaskExecutionOptions | undefined = (options.provider || options.model)
? { provider: options.provider, model: options.model }
: undefined;
const taskSuccess = await executeTask(task, cwd, workflow, undefined, agentOverrides);
if (!taskSuccess) {
error(`Workflow '${workflow}' failed`);

View File

@ -18,9 +18,15 @@ import { createLogger } from '../utils/debug.js';
import { getErrorMessage } from '../utils/error.js';
import { executeWorkflow } from './workflowExecution.js';
import { DEFAULT_WORKFLOW_NAME } from '../constants.js';
import type { ProviderType } from '../providers/index.js';
const log = createLogger('task');
export interface TaskExecutionOptions {
provider?: ProviderType;
model?: string;
}
/**
* Execute a single task with workflow
* @param task - Task content
@ -32,7 +38,8 @@ export async function executeTask(
task: string,
cwd: string,
workflowName: string = DEFAULT_WORKFLOW_NAME,
projectCwd?: string
projectCwd?: string,
options?: TaskExecutionOptions
): Promise<boolean> {
const workflowConfig = loadWorkflow(workflowName);
@ -52,6 +59,8 @@ export async function executeTask(
const result = await executeWorkflow(workflowConfig, task, cwd, {
projectCwd,
language: globalConfig.language,
provider: options?.provider,
model: options?.model,
});
return result.success;
}
@ -69,6 +78,7 @@ export async function executeAndCompleteTask(
taskRunner: TaskRunner,
cwd: string,
workflowName: string,
options?: TaskExecutionOptions,
): Promise<boolean> {
const startedAt = new Date().toISOString();
const executionLog: string[] = [];
@ -77,7 +87,7 @@ export async function executeAndCompleteTask(
const { execCwd, execWorkflow, isWorktree } = await resolveTaskExecution(task, cwd, workflowName);
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
const taskSuccess = await executeTask(task.content, execCwd, execWorkflow, cwd);
const taskSuccess = await executeTask(task.content, execCwd, execWorkflow, cwd, options);
const completedAt = new Date().toISOString();
if (taskSuccess && isWorktree) {
@ -132,7 +142,8 @@ export async function executeAndCompleteTask(
*/
export async function runAllTasks(
cwd: string,
workflowName: string = DEFAULT_WORKFLOW_NAME
workflowName: string = DEFAULT_WORKFLOW_NAME,
options?: TaskExecutionOptions,
): Promise<void> {
const taskRunner = new TaskRunner(cwd);
@ -155,7 +166,7 @@ export async function runAllTasks(
info(`=== Task: ${task.name} ===`);
console.log();
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName);
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName, options);
if (taskSuccess) {
successCount++;

View File

@ -16,12 +16,13 @@ import {
} from '../utils/ui.js';
import { executeAndCompleteTask } from './taskExecution.js';
import { DEFAULT_WORKFLOW_NAME } from '../constants.js';
import type { TaskExecutionOptions } from './taskExecution.js';
/**
* Watch for tasks and execute them as they appear.
* Runs until Ctrl+C.
*/
export async function watchTasks(cwd: string): Promise<void> {
export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> {
const workflowName = getCurrentWorkflow(cwd) || DEFAULT_WORKFLOW_NAME;
const taskRunner = new TaskRunner(cwd);
const watcher = new TaskWatcher(cwd);
@ -51,7 +52,7 @@ export async function watchTasks(cwd: string): Promise<void> {
info(`=== Task ${taskCount}: ${task.name} ===`);
console.log();
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName);
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName, options);
if (taskSuccess) {
successCount++;

View File

@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs';
import { WorkflowEngine } from '../workflow/engine.js';
import type { WorkflowConfig, Language } from '../models/types.js';
import type { IterationLimitRequest } from '../workflow/types.js';
import type { ProviderType } from '../providers/index.js';
import {
loadAgentSessions,
updateAgentSession,
@ -73,6 +74,8 @@ export interface WorkflowExecutionOptions {
projectCwd?: string;
/** Language for instruction metadata */
language?: Language;
provider?: ProviderType;
model?: string;
}
/**
@ -182,6 +185,8 @@ export async function executeWorkflow(
onIterationLimit: iterationLimitHandler,
projectCwd,
language: options.language,
provider: options.provider,
model: options.model,
});
let abortReason: string | undefined;

View File

@ -226,8 +226,8 @@ export class WorkflowEngine extends EventEmitter {
return {
cwd: this.cwd,
agentPath: step.agentPath,
provider: step.provider,
model: step.model,
provider: step.provider ?? this.options.provider,
model: step.model ?? this.options.model,
permissionMode: step.permissionMode,
onStream: this.options.onStream,
onPermissionRequest: this.options.onPermissionRequest,

View File

@ -8,6 +8,7 @@
import type { WorkflowStep, AgentResponse, WorkflowState, Language } from '../models/types.js';
import type { StreamCallback } from '../agents/runner.js';
import type { PermissionHandler, AskUserQuestionHandler } from '../claude/process.js';
import type { ProviderType } from '../providers/index.js';
/** Events emitted by workflow engine */
export interface WorkflowEvents {
@ -75,6 +76,8 @@ export interface WorkflowEngineOptions {
projectCwd?: string;
/** Language for instruction metadata. Defaults to 'en'. */
language?: Language;
provider?: ProviderType;
model?: string;
}
/** Loop detection result */