diff --git a/CHANGELOG.md b/CHANGELOG.md index 7289ea7a..26fbb64e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.12.0] - 2026-02-11 + +### Added + +- **OpenCode プロバイダー**: 第3のプロバイダーとして OpenCode をネイティブサポート — `@opencode-ai/sdk/v2` による SDK 統合、権限マッピング(readonly/edit/full → reject/once/always)、SSE ストリーム処理、リトライ機構(最大3回)、10分タイムアウトによるハング検出 (#236, #238) +- **Arpeggio ムーブメント**: データ駆動バッチ処理の新ムーブメントタイプ — CSV データソースからバッチ分割、テンプレート展開(`{line:N}`, `{col:N:name}`, `{batch_index}`)、並行 LLM 呼び出し(Semaphore 制御)、concat/custom マージ戦略をサポート (#200) +- **`frontend` ビルトインピース**: フロントエンド開発特化のピースを新規追加 — React/Next.js 向けの knowledge 注入、coding/testing ポリシー適用、並列アーキテクチャレビュー対応 +- **Slack Webhook 通知**: ピース実行完了時に Slack へ自動通知 — `TAKT_NOTIFY_WEBHOOK` 環境変数で設定、10秒タイムアウト、失敗時も他処理をブロックしない (#234) +- **セッション選択 UI**: インタラクティブモード開始時に Claude Code の過去セッションから再開可能なセッションを選択可能に — 最新10セッションの一覧表示、初期入力・最終応答プレビュー付き (#180) +- **プロバイダーイベントログ**: Claude/Codex/OpenCode の実行中イベントを NDJSON 形式でファイル出力 — `.takt/logs/{sessionId}-provider-events.jsonl` に記録、長大テキストの自動圧縮 (#236) +- **プロバイダー・モデル名の出力表示**: 各ムーブメント実行時に使用中のプロバイダーとモデル名をコンソールに表示 + +### Changed + +- **`takt add` の刷新**: Issue 選択時にタスクへの自動追加、インタラクティブモードの廃止、Issue 作成時のタスク積み込み確認 (#193, #194) +- **`max_iteration` → `max_movement` 統一**: イテレーション上限の用語を統一し、無限実行指定として `ostinato` を追加 (#212) +- **`previous_response` 注入仕様の改善**: 長さ制御と Source Path 常時注入を実装 (#207) +- **タスク管理の改善**: `.takt/tasks/` を長文タスク仕様の置き場所として再定義、`completeTask()` で completed レコードを `tasks.yaml` から削除 (#201, #204) +- **レビュー出力の改善**: レビュー出力を最新化し、過去レポートは履歴ログへ分離 (#209) +- **ビルトインピース簡素化**: 全ビルトインピースのトップレベル宣言をさらに整理 + +### Fixed + +- **Report Phase blocked 時の動作修正**: Report Phase(Phase 2)で blocked 状態の際に新規セッションでリトライするよう修正 (#163) +- **OpenCode のハング・終了判定の修正**: プロンプトのエコー抑制、question の抑制、ハング問題の修正、終了判定の誤りを修正 (#238) +- **OpenCode の権限・ツール設定の修正**: edit 実行時の権限とツール配線を修正 +- **Worktree へのタスク指示書コピー**: Worktree 実行時にタスク指示書が正しくコピーされるよう修正 +- lint エラーの修正(merge/resolveTask/confirm) + +### Internal + +- OpenCode プロバイダーの包括的なテスト追加(client-cleanup, config, provider, stream-handler, types) +- Arpeggio の包括的なテスト追加(csv, data-source-factory, merge, schema, template, engine-arpeggio) +- E2E テストの大幅な拡充: cli-catalog, cli-clear, cli-config, cli-export-cc, cli-help, cli-prompt, cli-reset-categories, cli-switch, error-handling, piece-error-handling, provider-error, quiet-mode, run-multiple-tasks, task-content-file (#192, #198) +- `providerEventLogger.ts`, `providerModel.ts`, `slackWebhook.ts`, `session-reader.ts`, `sessionSelector.ts`, `provider-resolution.ts`, `run-paths.ts` の新規追加 +- `ArpeggioRunner.ts` の新規追加(データ駆動バッチ処理エンジン) +- AI Judge をプロバイダーシステム経由に変更(Codex/OpenCode 対応) +- テスト追加・拡充: report-phase-blocked, phase-runner-report-history, judgment-fallback, pieceExecution-session-loading, globalConfig-defaults, session-reader, sessionSelector, slackWebhook, providerEventLogger, provider-model, interactive, run-paths, engine-test-helpers + ## [0.11.1] - 2026-02-10 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index d695cf48..f26ee875 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -218,7 +218,7 @@ Builtin resources are embedded in the npm package (`builtins/`). User files in ` ```yaml name: piece-name description: Optional description -max_iterations: 10 +max_movements: 10 initial_step: plan # First step to execute steps: @@ -291,7 +291,7 @@ Key points about parallel steps: |----------|-------------| | `{task}` | Original user request (auto-injected if not in template) | | `{iteration}` | Piece-wide iteration count | -| `{max_iterations}` | Maximum iterations allowed | +| `{max_movements}` | Maximum movements allowed | | `{step_iteration}` | Per-step iteration count | | `{previous_response}` | Previous step output (auto-injected if not in template) | | `{user_inputs}` | Accumulated user inputs (auto-injected if not in template) | @@ -406,7 +406,7 @@ Key constraints: - **Ephemeral lifecycle**: Clone is created → task runs → auto-commit + push → clone is deleted. Branches are the single source of truth. - **Session isolation**: Claude Code sessions are stored per-cwd in `~/.claude/projects/{encoded-path}/`. Sessions from the main project cannot be resumed in a clone. The engine skips session resume when `cwd !== projectCwd`. - **No node_modules**: Clones only contain tracked files. `node_modules/` is absent. -- **Dual cwd**: `cwd` = clone path (where agents run), `projectCwd` = project root. Reports write to `cwd/.takt/reports/` (clone) to prevent agents from discovering the main repository. Logs and session data write to `projectCwd`. +- **Dual cwd**: `cwd` = clone path (where agents run), `projectCwd` = project root. Reports write to `cwd/.takt/runs/{slug}/reports/` (clone) to prevent agents from discovering the main repository. Logs and session data write to `projectCwd`. - **List**: Use `takt list` to list branches. Instruct action creates a temporary clone for the branch, executes, pushes, then removes the clone. ## Error Propagation @@ -455,10 +455,10 @@ Debug logs are written to `.takt/logs/debug.log` (ndjson format). Log levels: `d - If persona file doesn't exist, the persona string is used as inline system prompt **Report directory structure:** -- Report dirs are created at `.takt/reports/{timestamp}-{slug}/` +- Report dirs are created at `.takt/runs/{timestamp}-{slug}/reports/` - Report files specified in `step.report` are written relative to report dir - Report dir path is available as `{report_dir}` variable in instruction templates -- When `cwd !== projectCwd` (worktree execution), reports write to `cwd/.takt/reports/` (clone dir) to prevent agents from discovering the main repository path +- When `cwd !== projectCwd` (worktree execution), reports write to `cwd/.takt/runs/{slug}/reports/` (clone dir) to prevent agents from discovering the main repository path **Session continuity across phases:** - Agent sessions persist across Phase 1 → Phase 2 → Phase 3 for context continuity @@ -470,7 +470,7 @@ Debug logs are written to `.takt/logs/debug.log` (ndjson format). Log levels: `d - `git clone --shared` creates independent `.git` directory (not `git worktree`) - Clone cwd ≠ project cwd: agents work in clone, reports write to clone, logs write to project - Session resume is skipped when `cwd !== projectCwd` to avoid cross-directory contamination -- Reports write to `cwd/.takt/reports/` (clone) to prevent agents from discovering the main repository path via instruction +- Reports write to `cwd/.takt/runs/{slug}/reports/` (clone) to prevent agents from discovering the main repository path via instruction - Clones are ephemeral: created → task runs → auto-commit + push → deleted - Use `takt list` to manage task branches after clone deletion diff --git a/README.md b/README.md index 2964cca8..60a4501a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **T**ask **A**gent **K**oordination **T**ool - Define how AI agents coordinate, where humans intervene, and what gets recorded — in YAML -TAKT runs multiple AI agents (Claude Code, Codex) through YAML-defined workflows. Each step — who runs, what they see, what's allowed, what happens on failure — is declared in a piece file, not left to the agent. +TAKT runs multiple AI agents (Claude Code, Codex, OpenCode) through YAML-defined workflows. Each step — who runs, what they see, what's allowed, what happens on failure — is declared in a piece file, not left to the agent. TAKT is built with TAKT itself (dogfooding). @@ -49,14 +49,14 @@ Personas, policies, and knowledge are managed as independent files and freely co Choose one: -- **Use provider CLIs**: [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [Codex](https://github.com/openai/codex) installed -- **Use direct API**: **Anthropic API Key** or **OpenAI API Key** (no CLI required) +- **Use provider CLIs**: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), or [OpenCode](https://opencode.ai) installed +- **Use direct API**: **Anthropic API Key**, **OpenAI API Key**, or **OpenCode API Key** (no CLI required) Additionally required: - [GitHub CLI](https://cli.github.com/) (`gh`) — Only needed for `takt #N` (GitHub Issue execution) -**Pricing Note**: When using API Keys, TAKT directly calls the Claude API (Anthropic) or OpenAI API. The pricing structure is the same as using Claude Code or Codex. Be mindful of costs, especially when running automated tasks in CI/CD environments, as API usage can accumulate. +**Pricing Note**: When using API Keys, TAKT directly calls the Claude API (Anthropic), OpenAI API, or OpenCode API. The pricing structure is the same as using the respective CLI tools. Be mindful of costs, especially when running automated tasks in CI/CD environments, as API usage can accumulate. ## Installation @@ -186,7 +186,7 @@ takt #6 --auto-pr ### Task Management (add / run / watch / list) -Batch processing using task files (`.takt/tasks/`). Useful for accumulating multiple tasks and executing them together later. +Batch processing using `.takt/tasks.yaml` with task directories under `.takt/tasks/{slug}/`. Useful for accumulating multiple tasks and executing them together later. #### Add Task (`takt add`) @@ -201,14 +201,14 @@ takt add #28 #### Execute Tasks (`takt run`) ```bash -# Execute all pending tasks in .takt/tasks/ +# Execute all pending tasks in .takt/tasks.yaml takt run ``` #### Watch Tasks (`takt watch`) ```bash -# Monitor .takt/tasks/ and auto-execute tasks (resident process) +# Monitor .takt/tasks.yaml and auto-execute tasks (resident process) takt watch ``` @@ -225,6 +225,13 @@ takt list --non-interactive --action delete --branch takt/my-branch --yes takt list --non-interactive --format json ``` +#### Task Directory Workflow (Create / Run / Verify) + +1. Run `takt add` and confirm a pending record is created in `.takt/tasks.yaml`. +2. Open the generated `.takt/tasks/{slug}/order.md` and add detailed specifications/references as needed. +3. Run `takt run` (or `takt watch`) to execute pending tasks from `tasks.yaml`. +4. Verify outputs in `.takt/runs/{slug}/reports/` using the same slug as `task_dir`. + ### Pipeline Mode (for CI/Automation) Specifying `--pipeline` enables non-interactive pipeline mode. Automatically creates branch → runs piece → commits & pushes. Suitable for CI/CD automation. @@ -315,7 +322,7 @@ takt reset categories | `--repo ` | Specify repository (for PR creation) | | `--create-worktree ` | Skip worktree confirmation prompt | | `-q, --quiet` | Minimal output mode: suppress AI output (for CI) | -| `--provider ` | Override agent provider (claude\|codex\|mock) | +| `--provider ` | Override agent provider (claude\|codex\|opencode\|mock) | | `--model ` | Override agent model | ## Pieces @@ -328,7 +335,7 @@ TAKT uses YAML-based piece definitions and rule-based routing. Builtin pieces ar ```yaml name: default -max_iterations: 10 +max_movements: 10 initial_movement: plan # Section maps — key: file path (relative to this YAML) @@ -466,6 +473,7 @@ TAKT includes multiple builtin pieces: | `structural-reform` | Full project review and structural reform: iterative codebase restructuring with staged file splits. | | `unit-test` | Unit test focused piece: test analysis → test implementation → review → fix. | | `e2e-test` | E2E test focused piece: E2E analysis → E2E implementation → review → fix (Vitest-based E2E flow). | +| `frontend` | Frontend-specialized development piece with React/Next.js focused reviews and knowledge injection. | **Per-persona provider overrides:** Use `persona_providers` in config to route specific personas to different providers (e.g., coder on Codex, reviewers on Claude) without duplicating pieces. @@ -532,14 +540,14 @@ The model string is passed to the Codex SDK. If unspecified, defaults to `codex` .takt/ # Project-level configuration ├── config.yaml # Project config (current piece, etc.) -├── tasks/ # Pending task files (.yaml, .md) -├── completed/ # Completed tasks and reports -├── reports/ # Execution reports (auto-generated) -│ └── {timestamp}-{slug}/ -└── logs/ # NDJSON format session logs - ├── latest.json # Pointer to current/latest session - ├── previous.json # Pointer to previous session - └── {sessionId}.jsonl # NDJSON session log per piece execution +├── tasks/ # Task input directories (.takt/tasks/{slug}/order.md, etc.) +├── tasks.yaml # Pending tasks metadata (task_dir, piece, worktree, etc.) +└── runs/ # Run-scoped artifacts + └── {slug}/ + ├── reports/ # Execution reports (auto-generated) + ├── context/ # knowledge/policy/previous_response snapshots + ├── logs/ # NDJSON session logs for this run + └── meta.json # Run metadata ``` Builtin resources are embedded in the npm package (`builtins/`). User files in `~/.takt/` take priority. @@ -553,11 +561,17 @@ Configure default provider and model in `~/.takt/config.yaml`: language: en default_piece: default log_level: info -provider: claude # Default provider: claude or codex +provider: claude # Default provider: claude, codex, or opencode model: sonnet # Default model (optional) branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow) prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate) notification_sound: true # Enable/disable notification sounds +notification_sound_events: # Optional per-event toggles + iteration_limit: false + piece_complete: true + piece_abort: true + run_complete: true # Enabled by default; set false to disable + run_abort: true # Enabled by default; set false to disable concurrency: 1 # Parallel task count for takt run (1-10, default: 1 = sequential) task_poll_interval_ms: 500 # Polling interval for new tasks during takt run (100-5000, default: 500) interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, default: 3) @@ -569,9 +583,10 @@ interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, # ai-antipattern-reviewer: claude # Keep reviewers on Claude # API Key configuration (optional) -# Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY +# Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY anthropic_api_key: sk-ant-... # For Claude (Anthropic) # openai_api_key: sk-... # For Codex (OpenAI) +# opencode_api_key: ... # For OpenCode # Builtin piece filtering (optional) # builtin_pieces_enabled: true # Set false to disable all builtins @@ -595,17 +610,17 @@ anthropic_api_key: sk-ant-... # For Claude (Anthropic) 1. **Set via environment variables**: ```bash export TAKT_ANTHROPIC_API_KEY=sk-ant-... # For Claude - # or export TAKT_OPENAI_API_KEY=sk-... # For Codex + export TAKT_OPENCODE_API_KEY=... # For OpenCode ``` 2. **Set in config file**: - Write `anthropic_api_key` or `openai_api_key` in `~/.takt/config.yaml` as shown above + Write `anthropic_api_key`, `openai_api_key`, or `opencode_api_key` in `~/.takt/config.yaml` as shown above Priority: Environment variables > `config.yaml` settings **Notes:** -- If you set an API Key, installing Claude Code or Codex is not necessary. TAKT directly calls the Anthropic API or OpenAI API. +- If you set an API Key, installing Claude Code, Codex, or OpenCode is not necessary. TAKT directly calls the respective API. - **Security**: If you write API Keys in `config.yaml`, be careful not to commit this file to Git. Consider using environment variables or adding `~/.takt/config.yaml` to `.gitignore`. **Pipeline Template Variables:** @@ -621,37 +636,44 @@ Priority: Environment variables > `config.yaml` settings 1. Piece movement `model` (highest priority) 2. Custom agent `model` 3. Global config `model` -4. Provider default (Claude: sonnet, Codex: codex) +4. Provider default (Claude: sonnet, Codex: codex, OpenCode: provider default) ## Detailed Guides -### Task File Formats +### Task Directory Format -TAKT supports batch processing with task files in `.takt/tasks/`. Both `.yaml`/`.yml` and `.md` file formats are supported. +TAKT stores task metadata in `.takt/tasks.yaml`, and each task's long specification in `.takt/tasks/{slug}/`. -**YAML format** (recommended, supports worktree/branch/piece options): +**Recommended layout**: -```yaml -# .takt/tasks/add-auth.yaml -task: "Add authentication feature" -worktree: true # Execute in isolated shared clone -branch: "feat/add-auth" # Branch name (auto-generated if omitted) -piece: "default" # Piece specification (uses current if omitted) +```text +.takt/ + tasks/ + 20260201-015714-foptng/ + order.md + schema.sql + wireframe.png + tasks.yaml + runs/ + 20260201-015714-foptng/ + reports/ ``` -**Markdown format** (simple, backward compatible): - -```markdown -# .takt/tasks/add-login-feature.md - -Add login feature to the application. +**tasks.yaml record**: -Requirements: -- Username and password fields -- Form validation -- Error handling on failure +```yaml +tasks: + - name: add-auth-feature + status: pending + task_dir: .takt/tasks/20260201-015714-foptng + piece: default + created_at: "2026-02-01T01:57:14.000Z" + started_at: null + completed_at: null ``` +`takt add` creates `.takt/tasks/{slug}/order.md` automatically and saves `task_dir` to `tasks.yaml`. + #### Isolated Execution with Shared Clone Specifying `worktree` in YAML task files executes each task in an isolated clone created with `git clone --shared`, keeping your main working directory clean: @@ -667,15 +689,14 @@ Clones are ephemeral. After task completion, they auto-commit + push, then delet ### Session Logs -TAKT writes session logs in NDJSON (`.jsonl`) format to `.takt/logs/`. Each record is atomically appended, so partial logs are preserved even if the process crashes, and you can track in real-time with `tail -f`. +TAKT writes session logs in NDJSON (`.jsonl`) format to `.takt/runs/{slug}/logs/`. Each record is atomically appended, so partial logs are preserved even if the process crashes, and you can track in real-time with `tail -f`. -- `.takt/logs/latest.json` - Pointer to current (or latest) session -- `.takt/logs/previous.json` - Pointer to previous session -- `.takt/logs/{sessionId}.jsonl` - NDJSON session log per piece execution +- `.takt/runs/{slug}/logs/{sessionId}.jsonl` - NDJSON session log per piece execution +- `.takt/runs/{slug}/meta.json` - Run metadata (`task`, `piece`, `start/end`, `status`, etc.) Record types: `piece_start`, `step_start`, `step_complete`, `piece_complete`, `piece_abort` -Agents can read `previous.json` to inherit context from the previous execution. Session continuation is automatic — just run `takt "task"` to continue from the previous session. +The latest previous response is stored at `.takt/runs/{slug}/context/previous_responses/latest.md` and inherited automatically. ### Adding Custom Pieces @@ -690,7 +711,7 @@ takt eject default # ~/.takt/pieces/my-piece.yaml name: my-piece description: Custom piece -max_iterations: 5 +max_movements: 5 initial_movement: analyze personas: @@ -740,11 +761,11 @@ Variables available in `instruction_template`: |----------|-------------| | `{task}` | Original user request (auto-injected if not in template) | | `{iteration}` | Piece-wide turn count (total steps executed) | -| `{max_iterations}` | Maximum iteration count | +| `{max_movements}` | Maximum iteration count | | `{movement_iteration}` | Per-movement iteration count (times this movement has been executed) | | `{previous_response}` | Output from previous movement (auto-injected if not in template) | | `{user_inputs}` | Additional user inputs during piece (auto-injected if not in template) | -| `{report_dir}` | Report directory path (e.g., `.takt/reports/20250126-143052-task-summary`) | +| `{report_dir}` | Report directory path (e.g., `.takt/runs/20250126-143052-task-summary/reports`) | | `{report:filename}` | Expands to `{report_dir}/filename` (e.g., `{report:00-plan.md}`) | ### Piece Design @@ -777,7 +798,7 @@ Special `next` values: `COMPLETE` (success), `ABORT` (failure) | `edit` | - | Whether movement can edit project files (`true`/`false`) | | `pass_previous_response` | `true` | Pass previous movement output to `{previous_response}` | | `allowed_tools` | - | List of tools agent can use (Read, Glob, Grep, Edit, Write, Bash, etc.) | -| `provider` | - | Override provider for this movement (`claude` or `codex`) | +| `provider` | - | Override provider for this movement (`claude`, `codex`, or `opencode`) | | `model` | - | Override model for this movement | | `permission_mode` | - | Permission mode: `readonly`, `edit`, `full` (provider-independent) | | `output_contracts` | - | Output contract definitions for report files | @@ -855,7 +876,7 @@ npm install -g takt takt --pipeline --task "Fix bug" --auto-pr --repo owner/repo ``` -For authentication, set `TAKT_ANTHROPIC_API_KEY` or `TAKT_OPENAI_API_KEY` environment variables (TAKT-specific prefix). +For authentication, set `TAKT_ANTHROPIC_API_KEY`, `TAKT_OPENAI_API_KEY`, or `TAKT_OPENCODE_API_KEY` environment variables (TAKT-specific prefix). ```bash # For Claude (Anthropic) @@ -863,6 +884,9 @@ export TAKT_ANTHROPIC_API_KEY=sk-ant-... # For Codex (OpenAI) export TAKT_OPENAI_API_KEY=sk-... + +# For OpenCode +export TAKT_OPENCODE_API_KEY=... ``` ## Documentation diff --git a/builtins/en/piece-categories.yaml b/builtins/en/piece-categories.yaml index 69719ce6..27db8db3 100644 --- a/builtins/en/piece-categories.yaml +++ b/builtins/en/piece-categories.yaml @@ -6,6 +6,18 @@ piece_categories: - coding - minimal - compound-eye + 🎨 Frontend: + pieces: + - frontend + ⚙️ Backend: {} + 🔧 Expert: + Full Stack: + pieces: + - expert + - expert-cqrs + 🛠️ Refactoring: + pieces: + - structural-reform 🔍 Review: pieces: - review-fix-minimal @@ -14,16 +26,6 @@ piece_categories: pieces: - unit-test - e2e-test - 🎨 Frontend: {} - ⚙️ Backend: {} - 🔧 Expert: - Full Stack: - pieces: - - expert - - expert-cqrs - Refactoring: - pieces: - - structural-reform Others: pieces: - research diff --git a/builtins/en/pieces/coding.yaml b/builtins/en/pieces/coding.yaml index eea34623..ba288923 100644 --- a/builtins/en/pieces/coding.yaml +++ b/builtins/en/pieces/coding.yaml @@ -1,6 +1,6 @@ name: coding description: Lightweight development piece with planning and parallel reviews (plan -> implement -> parallel review -> complete) -max_iterations: 20 +max_movements: 20 initial_movement: plan movements: - name: plan diff --git a/builtins/en/pieces/compound-eye.yaml b/builtins/en/pieces/compound-eye.yaml index 9be6d4e3..8c37938a 100644 --- a/builtins/en/pieces/compound-eye.yaml +++ b/builtins/en/pieces/compound-eye.yaml @@ -1,6 +1,6 @@ name: compound-eye description: Multi-model review - send the same instruction to Claude and Codex simultaneously, synthesize both responses -max_iterations: 10 +max_movements: 10 initial_movement: evaluate movements: - name: evaluate diff --git a/builtins/en/pieces/default.yaml b/builtins/en/pieces/default.yaml index bd7626e0..05afa4fd 100644 --- a/builtins/en/pieces/default.yaml +++ b/builtins/en/pieces/default.yaml @@ -1,6 +1,6 @@ name: default description: Standard development piece with planning and specialized reviews -max_iterations: 30 +max_movements: 30 initial_movement: plan loop_monitors: - cycle: diff --git a/builtins/en/pieces/e2e-test.yaml b/builtins/en/pieces/e2e-test.yaml index eab791d2..ae582e95 100644 --- a/builtins/en/pieces/e2e-test.yaml +++ b/builtins/en/pieces/e2e-test.yaml @@ -1,6 +1,6 @@ name: e2e-test description: E2E test focused piece (E2E analysis → E2E implementation → review → fix) -max_iterations: 20 +max_movements: 20 initial_movement: plan_test loop_monitors: - cycle: diff --git a/builtins/en/pieces/expert-cqrs.yaml b/builtins/en/pieces/expert-cqrs.yaml index 217e713c..041f5d96 100644 --- a/builtins/en/pieces/expert-cqrs.yaml +++ b/builtins/en/pieces/expert-cqrs.yaml @@ -1,6 +1,6 @@ name: expert-cqrs description: CQRS+ES, Frontend, Security, QA Expert Review -max_iterations: 30 +max_movements: 30 initial_movement: plan movements: - name: plan @@ -26,7 +26,6 @@ movements: - name: implement edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -87,7 +86,6 @@ movements: - name: ai_fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -218,7 +216,6 @@ movements: - name: fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -267,7 +264,6 @@ movements: - name: fix_supervisor edit: true persona: coder - pass_previous_response: false policy: - coding - testing diff --git a/builtins/en/pieces/expert.yaml b/builtins/en/pieces/expert.yaml index a3830c55..65a006ee 100644 --- a/builtins/en/pieces/expert.yaml +++ b/builtins/en/pieces/expert.yaml @@ -1,6 +1,6 @@ name: expert description: Architecture, Frontend, Security, QA Expert Review -max_iterations: 30 +max_movements: 30 initial_movement: plan movements: - name: plan @@ -26,7 +26,6 @@ movements: - name: implement edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -86,7 +85,6 @@ movements: - name: ai_fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -216,7 +214,6 @@ movements: - name: fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -264,7 +261,6 @@ movements: - name: fix_supervisor edit: true persona: coder - pass_previous_response: false policy: - coding - testing diff --git a/builtins/en/pieces/frontend.yaml b/builtins/en/pieces/frontend.yaml new file mode 100644 index 00000000..31e3eb68 --- /dev/null +++ b/builtins/en/pieces/frontend.yaml @@ -0,0 +1,282 @@ +name: frontend +description: Frontend, Security, QA Expert Review +max_movements: 30 +initial_movement: plan +movements: + - name: plan + edit: false + persona: planner + allowed_tools: + - Read + - Glob + - Grep + - Bash + - WebSearch + - WebFetch + instruction: plan + rules: + - condition: Task analysis and planning is complete + next: implement + - condition: Requirements are unclear and planning cannot proceed + next: ABORT + output_contracts: + report: + - name: 00-plan.md + format: plan + - name: implement + edit: true + persona: coder + policy: + - coding + - testing + session: refresh + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + instruction: implement + rules: + - condition: Implementation is complete + next: ai_review + - condition: No implementation (report only) + next: ai_review + - condition: Cannot proceed with implementation + next: ai_review + - condition: User input required + next: implement + requires_user_input: true + interactive_only: true + output_contracts: + report: + - Scope: 01-coder-scope.md + - Decisions: 02-coder-decisions.md + - name: ai_review + edit: false + persona: ai-antipattern-reviewer + policy: + - review + - ai-antipattern + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + instruction: ai-review + rules: + - condition: No AI-specific issues found + next: reviewers + - condition: AI-specific issues detected + next: ai_fix + output_contracts: + report: + - name: 03-ai-review.md + format: ai-review + - name: ai_fix + edit: true + persona: coder + policy: + - coding + - testing + session: refresh + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + instruction: ai-fix + rules: + - condition: AI Reviewer's issues have been fixed + next: ai_review + - condition: No fix needed (verified target files/spec) + next: ai_no_fix + - condition: Unable to proceed with fixes + next: ai_no_fix + - name: ai_no_fix + edit: false + persona: architecture-reviewer + policy: review + allowed_tools: + - Read + - Glob + - Grep + rules: + - condition: ai_review's findings are valid (fix required) + next: ai_fix + - condition: ai_fix's judgment is valid (no fix needed) + next: reviewers + instruction: arbitrate + - name: reviewers + parallel: + - name: arch-review + edit: false + persona: architecture-reviewer + policy: review + knowledge: + - architecture + - frontend + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-arch + output_contracts: + report: + - name: 04-architect-review.md + format: architecture-review + - name: frontend-review + edit: false + persona: frontend-reviewer + policy: review + knowledge: frontend + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-frontend + output_contracts: + report: + - name: 05-frontend-review.md + format: frontend-review + - name: security-review + edit: false + persona: security-reviewer + policy: review + knowledge: security + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-security + output_contracts: + report: + - name: 06-security-review.md + format: security-review + - name: qa-review + edit: false + persona: qa-reviewer + policy: + - review + - qa + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-qa + output_contracts: + report: + - name: 07-qa-review.md + format: qa-review + rules: + - condition: all("approved") + next: supervise + - condition: any("needs_fix") + next: fix + - name: fix + edit: true + persona: coder + policy: + - coding + - testing + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + permission_mode: edit + rules: + - condition: Fix complete + next: reviewers + - condition: Cannot proceed, insufficient info + next: plan + instruction: fix + - name: supervise + edit: false + persona: expert-supervisor + policy: review + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + instruction: supervise + rules: + - condition: All validations pass and ready to merge + next: COMPLETE + - condition: Issues detected during final review + next: fix_supervisor + output_contracts: + report: + - Validation: 08-supervisor-validation.md + - Summary: summary.md + - name: fix_supervisor + edit: true + persona: coder + policy: + - coding + - testing + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + instruction: fix-supervisor + rules: + - condition: Supervisor's issues have been fixed + next: supervise + - condition: Unable to proceed with fixes + next: plan diff --git a/builtins/en/pieces/magi.yaml b/builtins/en/pieces/magi.yaml index f6ee7a6c..76631ed9 100644 --- a/builtins/en/pieces/magi.yaml +++ b/builtins/en/pieces/magi.yaml @@ -1,6 +1,6 @@ name: magi description: MAGI Deliberation System - Analyze from 3 perspectives and decide by majority -max_iterations: 5 +max_movements: 5 initial_movement: melchior movements: - name: melchior diff --git a/builtins/en/pieces/minimal.yaml b/builtins/en/pieces/minimal.yaml index 89017c35..344e95d4 100644 --- a/builtins/en/pieces/minimal.yaml +++ b/builtins/en/pieces/minimal.yaml @@ -1,6 +1,6 @@ name: minimal description: Minimal development piece (implement -> parallel review -> fix if needed -> complete) -max_iterations: 20 +max_movements: 20 initial_movement: implement movements: - name: implement diff --git a/builtins/en/pieces/passthrough.yaml b/builtins/en/pieces/passthrough.yaml index e9ae5e10..f4fbae57 100644 --- a/builtins/en/pieces/passthrough.yaml +++ b/builtins/en/pieces/passthrough.yaml @@ -1,6 +1,6 @@ name: passthrough description: Single-agent thin wrapper. Pass task directly to coder as-is. -max_iterations: 10 +max_movements: 10 initial_movement: execute movements: - name: execute diff --git a/builtins/en/pieces/research.yaml b/builtins/en/pieces/research.yaml index f88ac39b..75ec5c00 100644 --- a/builtins/en/pieces/research.yaml +++ b/builtins/en/pieces/research.yaml @@ -1,6 +1,6 @@ name: research description: Research piece - autonomously executes research without asking questions -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: - name: plan @@ -13,7 +13,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: plan @@ -48,7 +48,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: dig @@ -88,7 +88,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: supervise (research quality evaluation) diff --git a/builtins/en/pieces/review-fix-minimal.yaml b/builtins/en/pieces/review-fix-minimal.yaml index 79dbeab6..26051de5 100644 --- a/builtins/en/pieces/review-fix-minimal.yaml +++ b/builtins/en/pieces/review-fix-minimal.yaml @@ -1,6 +1,6 @@ name: review-fix-minimal description: Review and fix piece for existing code (starts with review, no implementation) -max_iterations: 20 +max_movements: 20 initial_movement: reviewers movements: - name: implement diff --git a/builtins/en/pieces/review-only.yaml b/builtins/en/pieces/review-only.yaml index eabf0313..da4fed89 100644 --- a/builtins/en/pieces/review-only.yaml +++ b/builtins/en/pieces/review-only.yaml @@ -1,6 +1,6 @@ name: review-only description: Review-only piece - reviews code without making edits -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: - name: plan diff --git a/builtins/en/pieces/structural-reform.yaml b/builtins/en/pieces/structural-reform.yaml index bbff4b9d..8a268f4f 100644 --- a/builtins/en/pieces/structural-reform.yaml +++ b/builtins/en/pieces/structural-reform.yaml @@ -1,6 +1,6 @@ name: structural-reform description: Full project review and structural reform - iterative codebase restructuring with staged file splits -max_iterations: 50 +max_movements: 50 initial_movement: review loop_monitors: - cycle: @@ -44,7 +44,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: review (full project review) @@ -126,7 +126,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: plan_reform (reform plan creation) @@ -323,7 +323,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: verify (build and test verification) @@ -378,7 +378,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: next_target (progress check and next target selection) diff --git a/builtins/en/pieces/unit-test.yaml b/builtins/en/pieces/unit-test.yaml index e8a1dfb3..819e8b42 100644 --- a/builtins/en/pieces/unit-test.yaml +++ b/builtins/en/pieces/unit-test.yaml @@ -1,6 +1,6 @@ name: unit-test description: Unit test focused piece (test analysis → test implementation → review → fix) -max_iterations: 20 +max_movements: 20 initial_movement: plan_test loop_monitors: - cycle: diff --git a/builtins/ja/INSTRUCTION_STYLE_GUIDE.md b/builtins/ja/INSTRUCTION_STYLE_GUIDE.md index 35a3eada..b7bb45f7 100644 --- a/builtins/ja/INSTRUCTION_STYLE_GUIDE.md +++ b/builtins/ja/INSTRUCTION_STYLE_GUIDE.md @@ -82,9 +82,9 @@ InstructionBuilder が instruction_template 内の `{変数名}` を展開する | 変数 | 内容 | |------|------| | `{iteration}` | ピース全体のイテレーション数 | -| `{max_iterations}` | 最大イテレーション数 | +| `{max_movements}` | 最大イテレーション数 | | `{movement_iteration}` | ムーブメント単位のイテレーション数 | -| `{report_dir}` | レポートディレクトリ名 | +| `{report_dir}` | レポートディレクトリ名(`.takt/runs/{slug}/reports`) | | `{report:filename}` | 指定レポートの内容展開(ファイルが存在する場合) | | `{cycle_count}` | ループモニターで検出されたサイクル回数(`loop_monitors` 専用) | @@ -222,7 +222,7 @@ InstructionBuilder が instruction_template 内の `{変数名}` を展開する # 非許容 **参照するレポート:** -- .takt/reports/20250101-task/ai-review.md ← パスのハードコード +- .takt/runs/20250101-task/reports/ai-review.md ← パスのハードコード ``` --- diff --git a/builtins/ja/PERSONA_STYLE_GUIDE.md b/builtins/ja/PERSONA_STYLE_GUIDE.md index fd06f785..f61f3553 100644 --- a/builtins/ja/PERSONA_STYLE_GUIDE.md +++ b/builtins/ja/PERSONA_STYLE_GUIDE.md @@ -157,7 +157,7 @@ 1. **ポリシーの詳細ルール**: コード例・判定基準・例外リスト等の詳細はポリシーの責務(1行の行動指針は行動姿勢に記載してよい) 2. **ピース固有の概念**: ムーブメント名、レポートファイル名、ステップ間ルーティング -3. **ツール固有の環境情報**: `.takt/reports/` 等のディレクトリパス、テンプレート変数(`{report_dir}` 等) +3. **ツール固有の環境情報**: `.takt/runs/` 等のディレクトリパス、テンプレート変数(`{report_dir}` 等) 4. **実行手順**: 「まず〜を読み、次に〜を実行」のような手順はinstruction_templateの責務 ### 例外: ドメイン知識としての重複 diff --git a/builtins/ja/POLICY_STYLE_GUIDE.md b/builtins/ja/POLICY_STYLE_GUIDE.md index 3468483f..655b8c80 100644 --- a/builtins/ja/POLICY_STYLE_GUIDE.md +++ b/builtins/ja/POLICY_STYLE_GUIDE.md @@ -100,7 +100,7 @@ 1. **特定エージェント固有の知識**: Architecture Reviewer だけが使う検出手法等 2. **ピース固有の概念**: ムーブメント名、レポートファイル名 -3. **ツール固有のパス**: `.takt/reports/` 等の具体的なディレクトリパス +3. **ツール固有のパス**: `.takt/runs/` 等の具体的なディレクトリパス 4. **実行手順**: どのファイルを読め、何を実行しろ等 --- diff --git a/builtins/ja/piece-categories.yaml b/builtins/ja/piece-categories.yaml index 41ca3e2a..5858f9b6 100644 --- a/builtins/ja/piece-categories.yaml +++ b/builtins/ja/piece-categories.yaml @@ -6,6 +6,18 @@ piece_categories: - coding - minimal - compound-eye + 🎨 フロントエンド: + pieces: + - frontend + ⚙️ バックエンド: {} + 🔧 エキスパート: + フルスタック: + pieces: + - expert + - expert-cqrs + 🛠️ リファクタリング: + pieces: + - structural-reform 🔍 レビュー: pieces: - review-fix-minimal @@ -14,16 +26,6 @@ piece_categories: pieces: - unit-test - e2e-test - 🎨 フロントエンド: {} - ⚙️ バックエンド: {} - 🔧 エキスパート: - フルスタック: - pieces: - - expert - - expert-cqrs - リファクタリング: - pieces: - - structural-reform その他: pieces: - research diff --git a/builtins/ja/pieces/coding.yaml b/builtins/ja/pieces/coding.yaml index 44908d07..990ff1b2 100644 --- a/builtins/ja/pieces/coding.yaml +++ b/builtins/ja/pieces/coding.yaml @@ -1,6 +1,6 @@ name: coding description: Lightweight development piece with planning and parallel reviews (plan -> implement -> parallel review -> complete) -max_iterations: 20 +max_movements: 20 initial_movement: plan movements: - name: plan diff --git a/builtins/ja/pieces/compound-eye.yaml b/builtins/ja/pieces/compound-eye.yaml index e4c41bb2..8a94f414 100644 --- a/builtins/ja/pieces/compound-eye.yaml +++ b/builtins/ja/pieces/compound-eye.yaml @@ -1,6 +1,6 @@ name: compound-eye description: 複眼レビュー - 同じ指示を Claude と Codex に同時に投げ、両者の回答を統合する -max_iterations: 10 +max_movements: 10 initial_movement: evaluate movements: diff --git a/builtins/ja/pieces/default.yaml b/builtins/ja/pieces/default.yaml index 3e262839..258cdb0f 100644 --- a/builtins/ja/pieces/default.yaml +++ b/builtins/ja/pieces/default.yaml @@ -1,6 +1,6 @@ name: default description: Standard development piece with planning and specialized reviews -max_iterations: 30 +max_movements: 30 initial_movement: plan loop_monitors: - cycle: diff --git a/builtins/ja/pieces/e2e-test.yaml b/builtins/ja/pieces/e2e-test.yaml index 1ce02622..6096e980 100644 --- a/builtins/ja/pieces/e2e-test.yaml +++ b/builtins/ja/pieces/e2e-test.yaml @@ -1,6 +1,6 @@ name: e2e-test description: E2Eテスト追加に特化したピース(E2E分析→E2E実装→レビュー→修正) -max_iterations: 20 +max_movements: 20 initial_movement: plan_test loop_monitors: - cycle: diff --git a/builtins/ja/pieces/expert-cqrs.yaml b/builtins/ja/pieces/expert-cqrs.yaml index 664375a4..8c2a2b67 100644 --- a/builtins/ja/pieces/expert-cqrs.yaml +++ b/builtins/ja/pieces/expert-cqrs.yaml @@ -1,6 +1,6 @@ name: expert-cqrs description: CQRS+ES・フロントエンド・セキュリティ・QA専門家レビュー -max_iterations: 30 +max_movements: 30 initial_movement: plan movements: - name: plan @@ -26,7 +26,6 @@ movements: - name: implement edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -87,7 +86,6 @@ movements: - name: ai_fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -218,7 +216,6 @@ movements: - name: fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -267,7 +264,6 @@ movements: - name: fix_supervisor edit: true persona: coder - pass_previous_response: false policy: - coding - testing diff --git a/builtins/ja/pieces/expert.yaml b/builtins/ja/pieces/expert.yaml index 0290e56d..9f36c606 100644 --- a/builtins/ja/pieces/expert.yaml +++ b/builtins/ja/pieces/expert.yaml @@ -1,6 +1,6 @@ name: expert description: アーキテクチャ・フロントエンド・セキュリティ・QA専門家レビュー -max_iterations: 30 +max_movements: 30 initial_movement: plan movements: - name: plan @@ -26,7 +26,6 @@ movements: - name: implement edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -86,7 +85,6 @@ movements: - name: ai_fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -216,7 +214,6 @@ movements: - name: fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -264,7 +261,6 @@ movements: - name: fix_supervisor edit: true persona: coder - pass_previous_response: false policy: - coding - testing diff --git a/builtins/ja/pieces/frontend.yaml b/builtins/ja/pieces/frontend.yaml new file mode 100644 index 00000000..6bcbcb29 --- /dev/null +++ b/builtins/ja/pieces/frontend.yaml @@ -0,0 +1,282 @@ +name: frontend +description: フロントエンド・セキュリティ・QA専門家レビュー +max_movements: 30 +initial_movement: plan +movements: + - name: plan + edit: false + persona: planner + allowed_tools: + - Read + - Glob + - Grep + - Bash + - WebSearch + - WebFetch + instruction: plan + rules: + - condition: タスク分析と計画が完了した + next: implement + - condition: 要件が不明確で計画を立てられない + next: ABORT + output_contracts: + report: + - name: 00-plan.md + format: plan + - name: implement + edit: true + persona: coder + policy: + - coding + - testing + session: refresh + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + instruction: implement + rules: + - condition: 実装が完了した + next: ai_review + - condition: 実装未着手(レポートのみ) + next: ai_review + - condition: 実装を進行できない + next: ai_review + - condition: ユーザー入力が必要 + next: implement + requires_user_input: true + interactive_only: true + output_contracts: + report: + - Scope: 01-coder-scope.md + - Decisions: 02-coder-decisions.md + - name: ai_review + edit: false + persona: ai-antipattern-reviewer + policy: + - review + - ai-antipattern + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + instruction: ai-review + rules: + - condition: AI特有の問題が見つからない + next: reviewers + - condition: AI特有の問題が検出された + next: ai_fix + output_contracts: + report: + - name: 03-ai-review.md + format: ai-review + - name: ai_fix + edit: true + persona: coder + policy: + - coding + - testing + session: refresh + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + instruction: ai-fix + rules: + - condition: AI Reviewerの指摘に対する修正が完了した + next: ai_review + - condition: 修正不要(指摘対象ファイル/仕様の確認済み) + next: ai_no_fix + - condition: 修正を進行できない + next: ai_no_fix + - name: ai_no_fix + edit: false + persona: architecture-reviewer + policy: review + allowed_tools: + - Read + - Glob + - Grep + rules: + - condition: ai_reviewの指摘が妥当(修正すべき) + next: ai_fix + - condition: ai_fixの判断が妥当(修正不要) + next: reviewers + instruction: arbitrate + - name: reviewers + parallel: + - name: arch-review + edit: false + persona: architecture-reviewer + policy: review + knowledge: + - architecture + - frontend + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-arch + output_contracts: + report: + - name: 04-architect-review.md + format: architecture-review + - name: frontend-review + edit: false + persona: frontend-reviewer + policy: review + knowledge: frontend + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-frontend + output_contracts: + report: + - name: 05-frontend-review.md + format: frontend-review + - name: security-review + edit: false + persona: security-reviewer + policy: review + knowledge: security + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-security + output_contracts: + report: + - name: 06-security-review.md + format: security-review + - name: qa-review + edit: false + persona: qa-reviewer + policy: + - review + - qa + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-qa + output_contracts: + report: + - name: 07-qa-review.md + format: qa-review + rules: + - condition: all("approved") + next: supervise + - condition: any("needs_fix") + next: fix + - name: fix + edit: true + persona: coder + policy: + - coding + - testing + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + permission_mode: edit + rules: + - condition: 修正が完了した + next: reviewers + - condition: 修正を進行できない + next: plan + instruction: fix + - name: supervise + edit: false + persona: expert-supervisor + policy: review + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + instruction: supervise + rules: + - condition: すべての検証が完了し、マージ可能な状態である + next: COMPLETE + - condition: 問題が検出された + next: fix_supervisor + output_contracts: + report: + - Validation: 08-supervisor-validation.md + - Summary: summary.md + - name: fix_supervisor + edit: true + persona: coder + policy: + - coding + - testing + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + instruction: fix-supervisor + rules: + - condition: 監督者の指摘に対する修正が完了した + next: supervise + - condition: 修正を進行できない + next: plan diff --git a/builtins/ja/pieces/magi.yaml b/builtins/ja/pieces/magi.yaml index 679329b9..83614cb1 100644 --- a/builtins/ja/pieces/magi.yaml +++ b/builtins/ja/pieces/magi.yaml @@ -1,6 +1,6 @@ name: magi description: MAGI合議システム - 3つの観点から分析し多数決で判定 -max_iterations: 5 +max_movements: 5 initial_movement: melchior movements: - name: melchior diff --git a/builtins/ja/pieces/minimal.yaml b/builtins/ja/pieces/minimal.yaml index 418f66a4..c190ac9f 100644 --- a/builtins/ja/pieces/minimal.yaml +++ b/builtins/ja/pieces/minimal.yaml @@ -1,6 +1,6 @@ name: minimal description: Minimal development piece (implement -> parallel review -> fix if needed -> complete) -max_iterations: 20 +max_movements: 20 initial_movement: implement movements: - name: implement diff --git a/builtins/ja/pieces/passthrough.yaml b/builtins/ja/pieces/passthrough.yaml index b2b9d486..ac4cb8b1 100644 --- a/builtins/ja/pieces/passthrough.yaml +++ b/builtins/ja/pieces/passthrough.yaml @@ -1,6 +1,6 @@ name: passthrough description: Single-agent thin wrapper. Pass task directly to coder as-is. -max_iterations: 10 +max_movements: 10 initial_movement: execute movements: - name: execute diff --git a/builtins/ja/pieces/research.yaml b/builtins/ja/pieces/research.yaml index 67dbb4c9..78f25216 100644 --- a/builtins/ja/pieces/research.yaml +++ b/builtins/ja/pieces/research.yaml @@ -1,6 +1,6 @@ name: research description: 調査ピース - 質問せずに自律的に調査を実行 -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: - name: plan @@ -13,7 +13,7 @@ movements: - WebFetch instruction_template: | ## ピース状況 - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメント実行回数: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: plan @@ -48,7 +48,7 @@ movements: - WebFetch instruction_template: | ## ピース状況 - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメント実行回数: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: dig @@ -88,7 +88,7 @@ movements: - WebFetch instruction_template: | ## ピース状況 - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメント実行回数: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: supervise (調査品質評価) diff --git a/builtins/ja/pieces/review-fix-minimal.yaml b/builtins/ja/pieces/review-fix-minimal.yaml index e9b0fc99..f9f33003 100644 --- a/builtins/ja/pieces/review-fix-minimal.yaml +++ b/builtins/ja/pieces/review-fix-minimal.yaml @@ -1,6 +1,6 @@ name: review-fix-minimal description: 既存コードのレビューと修正ピース(レビュー開始、実装なし) -max_iterations: 20 +max_movements: 20 initial_movement: reviewers movements: - name: implement diff --git a/builtins/ja/pieces/review-only.yaml b/builtins/ja/pieces/review-only.yaml index 75aea59c..6a8a0d93 100644 --- a/builtins/ja/pieces/review-only.yaml +++ b/builtins/ja/pieces/review-only.yaml @@ -1,6 +1,6 @@ name: review-only description: レビュー専用ピース - コードをレビューするだけで編集は行わない -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: - name: plan diff --git a/builtins/ja/pieces/structural-reform.yaml b/builtins/ja/pieces/structural-reform.yaml index a765026a..3cb04196 100644 --- a/builtins/ja/pieces/structural-reform.yaml +++ b/builtins/ja/pieces/structural-reform.yaml @@ -1,6 +1,6 @@ name: structural-reform description: プロジェクト全体レビューと構造改革 - 段階的なファイル分割による反復的コードベース再構築 -max_iterations: 50 +max_movements: 50 initial_movement: review loop_monitors: - cycle: @@ -44,7 +44,7 @@ movements: - WebFetch instruction_template: | ## ピースステータス - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメントイテレーション: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: review(プロジェクト全体レビュー) @@ -126,7 +126,7 @@ movements: - WebFetch instruction_template: | ## ピースステータス - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメントイテレーション: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: plan_reform(改革計画策定) @@ -323,7 +323,7 @@ movements: - WebFetch instruction_template: | ## ピースステータス - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメントイテレーション: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: verify(ビルド・テスト検証) @@ -378,7 +378,7 @@ movements: - WebFetch instruction_template: | ## ピースステータス - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメントイテレーション: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: next_target(進捗確認と次ターゲット選択) diff --git a/builtins/ja/pieces/unit-test.yaml b/builtins/ja/pieces/unit-test.yaml index 58598659..3c5e94c6 100644 --- a/builtins/ja/pieces/unit-test.yaml +++ b/builtins/ja/pieces/unit-test.yaml @@ -1,6 +1,6 @@ name: unit-test description: 単体テスト追加に特化したピース(テスト分析→テスト実装→レビュー→修正) -max_iterations: 20 +max_movements: 20 initial_movement: plan_test loop_monitors: - cycle: diff --git a/builtins/project/dotgitignore b/builtins/project/dotgitignore index 41d2f612..71aba952 100644 --- a/builtins/project/dotgitignore +++ b/builtins/project/dotgitignore @@ -1,6 +1,6 @@ # Temporary files logs/ -reports/ +runs/ completed/ tasks/ worktrees/ diff --git a/builtins/project/tasks/TASK-FORMAT b/builtins/project/tasks/TASK-FORMAT index fcf87400..f4638087 100644 --- a/builtins/project/tasks/TASK-FORMAT +++ b/builtins/project/tasks/TASK-FORMAT @@ -1,37 +1,48 @@ -TAKT Task File Format -===================== +TAKT Task Directory Format +========================== -Tasks placed in this directory (.takt/tasks/) will be processed by TAKT. +`.takt/tasks/` is the task input directory. Each task uses one subdirectory. -## YAML Format (Recommended) +## Directory Layout (Recommended) - # .takt/tasks/my-task.yaml - task: "Task description" - worktree: true # (optional) true | "/path/to/dir" - branch: "feat/my-feature" # (optional) branch name - piece: "default" # (optional) piece name + .takt/ + tasks/ + 20260201-015714-foptng/ + order.md + schema.sql + wireframe.png -Fields: - task (required) Task description (string) - worktree (optional) true: create shared clone, "/path": clone at path - branch (optional) Branch name (auto-generated if omitted: takt/{timestamp}-{slug}) - piece (optional) Piece name (uses current piece if omitted) +- Directory name should match the report directory slug. +- `order.md` is required. +- Other files are optional reference materials. + +## tasks.yaml Format -## Markdown Format (Simple) +Store task metadata in `.takt/tasks.yaml`, and point to the task directory with `task_dir`. - # .takt/tasks/my-task.md + tasks: + - name: add-auth-feature + status: pending + task_dir: .takt/tasks/20260201-015714-foptng + piece: default + created_at: "2026-02-01T01:57:14.000Z" + started_at: null + completed_at: null - Entire file content becomes the task description. - Supports multiline. No structured options available. +Fields: + task_dir (recommended) Path to task directory that contains `order.md` + content (legacy) Inline task text (kept for compatibility) + content_file (legacy) Path to task text file (kept for compatibility) -## Supported Extensions +## Command Behavior - .yaml, .yml -> YAML format (parsed and validated) - .md -> Markdown format (plain text, backward compatible) +- `takt add` creates `.takt/tasks/{slug}/order.md` automatically. +- `takt run` and `takt watch` read `.takt/tasks.yaml` and resolve `task_dir`. +- Report output is written to `.takt/runs/{slug}/reports/`. ## Commands - takt /add-task Add a task interactively - takt /run-tasks Run all pending tasks - takt /watch Watch and auto-run tasks - takt /list-tasks List task branches (merge/delete) + takt add Add a task and create task directory + takt run Run all pending tasks in tasks.yaml + takt watch Watch tasks.yaml and run pending tasks + takt list List task branches (merge/delete) diff --git a/builtins/skill/SKILL.md b/builtins/skill/SKILL.md index 28460f18..0072aadb 100644 --- a/builtins/skill/SKILL.md +++ b/builtins/skill/SKILL.md @@ -83,7 +83,7 @@ $ARGUMENTS を以下のように解析する: 3. 見つからない場合: 上記2ディレクトリを Glob で列挙し、AskUserQuestion で選択させる YAMLから以下を抽出する(→ references/yaml-schema.md 参照): -- `name`, `max_iterations`, `initial_movement`, `movements` 配列 +- `name`, `max_movements`, `initial_movement`, `movements` 配列 - セクションマップ: `personas`, `policies`, `instructions`, `output_contracts`, `knowledge` ### 手順 2: セクションリソースの事前読み込み @@ -116,13 +116,21 @@ TeamCreate tool を呼ぶ: - `permission_mode = コマンドで解析された権限モード("bypassPermissions" / "acceptEdits" / "default")` - `movement_history = []`(遷移履歴。Loop Monitor 用) -**レポートディレクトリ**: いずれかの movement に `report` フィールドがある場合、`.takt/reports/{YYYYMMDD-HHmmss}-{slug}/` を作成し、パスを `report_dir` 変数に保持する。 +**実行ディレクトリ**: いずれかの movement に `report` フィールドがある場合、`.takt/runs/{YYYYMMDD-HHmmss}-{slug}/` を作成し、以下を配置する。 +- `reports/`(レポート出力) +- `context/knowledge/`(Knowledge スナップショット) +- `context/policy/`(Policy スナップショット) +- `context/previous_responses/`(Previous Response 履歴 + `latest.md`) +- `logs/`(実行ログ) +- `meta.json`(run メタデータ) + +レポート出力先パスを `report_dir` 変数(`.takt/runs/{slug}/reports`)として保持する。 次に **手順 5** に進む。 ### 手順 5: チームメイト起動 -**iteration が max_iterations を超えていたら → 手順 8(ABORT: イテレーション上限)に進む。** +**iteration が max_movements を超えていたら → 手順 8(ABORT: イテレーション上限)に進む。** current_movement のプロンプトを構築する(→ references/engine.md のプロンプト構築を参照)。 diff --git a/builtins/skill/references/engine.md b/builtins/skill/references/engine.md index ef8e3f5e..2158db01 100644 --- a/builtins/skill/references/engine.md +++ b/builtins/skill/references/engine.md @@ -133,7 +133,7 @@ movement の `instruction:` キーから指示テンプレートファイルを - ワーキングディレクトリ: {cwd} - ピース: {piece_name} - Movement: {movement_name} -- イテレーション: {iteration} / {max_iterations} +- イテレーション: {iteration} / {max_movements} - Movement イテレーション: {movement_iteration} 回目 ``` @@ -146,9 +146,9 @@ movement の `instruction:` キーから指示テンプレートファイルを | `{task}` | ユーザーが入力したタスク内容 | | `{previous_response}` | 前の movement のチームメイト出力 | | `{iteration}` | ピース全体のイテレーション数(1始まり) | -| `{max_iterations}` | ピースの max_iterations 値 | +| `{max_movements}` | ピースの max_movements 値 | | `{movement_iteration}` | この movement が実行された回数(1始まり) | -| `{report_dir}` | レポートディレクトリパス | +| `{report_dir}` | レポートディレクトリパス(`.takt/runs/{slug}/reports`) | | `{report:ファイル名}` | 指定レポートファイルの内容(Read で取得) | ### {report:ファイル名} の処理 @@ -212,7 +212,10 @@ report: チームメイトの出力からレポート内容を抽出し、Write tool でレポートディレクトリに保存する。 **この作業は Team Lead(あなた)が行う。** チームメイトの出力を受け取った後に実施する。 -**レポートディレクトリ**: `.takt/reports/{timestamp}-{slug}/` に作成する。 +**実行ディレクトリ**: `.takt/runs/{timestamp}-{slug}/` に作成する。 +- レポートは `.takt/runs/{timestamp}-{slug}/reports/` に保存する。 +- `Knowledge` / `Policy` / `Previous Response` は `.takt/runs/{timestamp}-{slug}/context/` 配下に保存する。 +- 最新の previous response は `.takt/runs/{timestamp}-{slug}/context/previous_responses/latest.md` とする。 - `{timestamp}`: `YYYYMMDD-HHmmss` 形式 - `{slug}`: タスク内容の先頭30文字をスラグ化 @@ -314,7 +317,7 @@ parallel のサブステップにも同様にタグ出力指示を注入する ### 基本ルール - 同じ movement が連続3回以上実行されたら警告を表示する -- `max_iterations` に到達したら強制終了(ABORT)する +- `max_movements` に到達したら強制終了(ABORT)する ### カウンター管理 @@ -358,17 +361,24 @@ loop_monitors: d. judge の出力を judge の `rules` で評価する e. マッチした rule の `next` に遷移する(通常のルール評価をオーバーライドする) -## レポート管理 +## 実行アーティファクト管理 -### レポートディレクトリの作成 +### 実行ディレクトリの作成 -ピース実行開始時にレポートディレクトリを作成する: +ピース実行開始時に実行ディレクトリを作成する: ``` -.takt/reports/{YYYYMMDD-HHmmss}-{slug}/ +.takt/runs/{YYYYMMDD-HHmmss}-{slug}/ + reports/ + context/ + knowledge/ + policy/ + previous_responses/ + logs/ + meta.json ``` -このパスを `{report_dir}` 変数として全 movement から参照可能にする。 +このうち `reports/` のパスを `{report_dir}` 変数として全 movement から参照可能にする。 ### レポートの保存 @@ -392,7 +402,7 @@ loop_monitors: ↓ TeamCreate でチーム作成 ↓ -レポートディレクトリ作成 +実行ディレクトリ作成 ↓ initial_movement を取得 ↓ diff --git a/builtins/skill/references/yaml-schema.md b/builtins/skill/references/yaml-schema.md index 81094695..54e00d20 100644 --- a/builtins/skill/references/yaml-schema.md +++ b/builtins/skill/references/yaml-schema.md @@ -7,7 +7,7 @@ ```yaml name: piece-name # ピース名(必須) description: 説明テキスト # ピースの説明(任意) -max_iterations: 10 # 最大イテレーション数(必須) +max_movements: 10 # 最大イテレーション数(必須) initial_movement: plan # 最初に実行する movement 名(必須) # セクションマップ(キー → ファイルパスの対応表) @@ -192,7 +192,7 @@ quality_gates: | `{task}` | ユーザーのタスク入力(template に含まれない場合は自動追加) | | `{previous_response}` | 前の movement の出力(pass_previous_response: true 時、自動追加) | | `{iteration}` | ピース全体のイテレーション数 | -| `{max_iterations}` | 最大イテレーション数 | +| `{max_movements}` | 最大イテレーション数 | | `{movement_iteration}` | この movement の実行回数 | | `{report_dir}` | レポートディレクトリ名 | | `{report:ファイル名}` | 指定レポートファイルの内容を展開 | diff --git a/docs/README.ja.md b/docs/README.ja.md index c9b689c4..9ab545cb 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -2,7 +2,7 @@ **T**ask **A**gent **K**oordination **T**ool - AIエージェントの協調手順・人の介入ポイント・記録をYAMLで定義する -TAKTは複数のAIエージェント(Claude Code、Codex)をYAMLで定義されたワークフローに従って実行します。各ステップで誰が実行し、何を見て、何を許可し、失敗時にどうするかはピースファイルに宣言され、エージェント任せにしません。 +TAKTは複数のAIエージェント(Claude Code、Codex、OpenCode)をYAMLで定義されたワークフローに従って実行します。各ステップで誰が実行し、何を見て、何を許可し、失敗時にどうするかはピースファイルに宣言され、エージェント任せにしません。 TAKTはTAKT自身で開発されています(ドッグフーディング)。 @@ -45,14 +45,14 @@ TAKTはエージェントの実行を**制御**し、プロンプトの構成要 次のいずれかを選択してください。 -- **プロバイダーCLIを使用**: [Claude Code](https://docs.anthropic.com/en/docs/claude-code) または [Codex](https://github.com/openai/codex) をインストール -- **API直接利用**: **Anthropic API Key** または **OpenAI API Key**(CLI不要) +- **プロバイダーCLIを使用**: [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Codex](https://github.com/openai/codex)、または [OpenCode](https://opencode.ai) をインストール +- **API直接利用**: **Anthropic API Key**、**OpenAI API Key**、または **OpenCode API Key**(CLI不要) 追加で必要なもの: - [GitHub CLI](https://cli.github.com/) (`gh`) — `takt #N`(GitHub Issue実行)を使う場合のみ必要 -**料金について**: API Key を使用する場合、TAKT は Claude API(Anthropic)または OpenAI API を直接呼び出します。料金体系は Claude Code や Codex を使った場合と同じです。特に CI/CD で自動実行する場合、API 使用量が増えるため、コストに注意してください。 +**料金について**: API Key を使用する場合、TAKT は Claude API(Anthropic)、OpenAI API、または OpenCode API を直接呼び出します。料金体系は各 CLI ツールを使った場合と同じです。特に CI/CD で自動実行する場合、API 使用量が増えるため、コストに注意してください。 ## インストール @@ -186,7 +186,7 @@ takt #6 --auto-pr ### タスク管理(add / run / watch / list) -タスクファイル(`.takt/tasks/`)を使ったバッチ処理。複数のタスクを積んでおいて、後でまとめて実行する使い方に便利です。 +`.takt/tasks.yaml` と `.takt/tasks/{slug}/` を使ったバッチ処理。複数のタスクを積んでおいて、後でまとめて実行する使い方に便利です。 #### タスクを追加(`takt add`) @@ -201,14 +201,14 @@ takt add #28 #### タスクを実行(`takt run`) ```bash -# .takt/tasks/ の保留中タスクをすべて実行 +# .takt/tasks.yaml の保留中タスクをすべて実行 takt run ``` #### タスクを監視(`takt watch`) ```bash -# .takt/tasks/ を監視してタスクを自動実行(常駐プロセス) +# .takt/tasks.yaml を監視してタスクを自動実行(常駐プロセス) takt watch ``` @@ -225,6 +225,13 @@ takt list --non-interactive --action delete --branch takt/my-branch --yes takt list --non-interactive --format json ``` +#### タスクディレクトリ運用(作成・実行・確認) + +1. `takt add` を実行して `.takt/tasks.yaml` に pending レコードが作られることを確認する。 +2. 生成された `.takt/tasks/{slug}/order.md` を開き、必要なら仕様や参考資料を追記する。 +3. `takt run`(または `takt watch`)で `tasks.yaml` の pending タスクを実行する。 +4. `task_dir` と同じスラッグの `.takt/runs/{slug}/reports/` を確認する。 + ### パイプラインモード(CI/自動化向け) `--pipeline` を指定すると非対話のパイプラインモードに入ります。ブランチ作成 → ピース実行 → commit & push を自動で行います。CI/CD での自動化に適しています。 @@ -315,7 +322,7 @@ takt reset categories | `--repo ` | リポジトリ指定(PR作成時) | | `--create-worktree ` | worktree確認プロンプトをスキップ | | `-q, --quiet` | 最小限の出力モード: AIの出力を抑制(CI向け) | -| `--provider ` | エージェントプロバイダーを上書き(claude\|codex\|mock) | +| `--provider ` | エージェントプロバイダーを上書き(claude\|codex\|opencode\|mock) | | `--model ` | エージェントモデルを上書き | ## ピース @@ -328,7 +335,7 @@ TAKTはYAMLベースのピース定義とルールベースルーティングを ```yaml name: default -max_iterations: 10 +max_movements: 10 initial_movement: plan # セクションマップ — キー: ファイルパス(このYAMLからの相対パス) @@ -466,6 +473,7 @@ TAKTには複数のビルトインピースが同梱されています: | `structural-reform` | プロジェクト全体の構造改革: 段階的なファイル分割を伴う反復的なコードベース再構成。 | | `unit-test` | ユニットテスト重視ピース: テスト分析 → テスト実装 → レビュー → 修正。 | | `e2e-test` | E2Eテスト重視ピース: E2E分析 → E2E実装 → レビュー → 修正(VitestベースのE2Eフロー)。 | +| `frontend` | フロントエンド特化開発ピース: React/Next.js 向けのレビューとナレッジ注入。 | **ペルソナ別プロバイダー設定:** 設定ファイルの `persona_providers` で、特定のペルソナを異なるプロバイダーにルーティングできます(例: coder は Codex、レビュアーは Claude)。ピースを複製する必要はありません。 @@ -532,14 +540,14 @@ Claude Code はエイリアス(`opus`、`sonnet`、`haiku`、`opusplan`、`def .takt/ # プロジェクトレベルの設定 ├── config.yaml # プロジェクト設定(現在のピース等) -├── tasks/ # 保留中のタスクファイル(.yaml, .md) -├── completed/ # 完了したタスクとレポート -├── reports/ # 実行レポート(自動生成) -│ └── {timestamp}-{slug}/ -└── logs/ # NDJSON 形式のセッションログ - ├── latest.json # 現在/最新セッションへのポインタ - ├── previous.json # 前回セッションへのポインタ - └── {sessionId}.jsonl # ピース実行ごとの NDJSON セッションログ +├── tasks/ # タスク入力ディレクトリ(.takt/tasks/{slug}/order.md など) +├── tasks.yaml # 保留中タスクのメタデータ(task_dir, piece, worktree など) +└── runs/ # 実行単位の成果物 + └── {slug}/ + ├── reports/ # 実行レポート(自動生成) + ├── context/ # knowledge/policy/previous_response のスナップショット + ├── logs/ # この実行専用の NDJSON セッションログ + └── meta.json # run メタデータ ``` ビルトインリソースはnpmパッケージ(`builtins/`)に埋め込まれています。`~/.takt/` のユーザーファイルが優先されます。 @@ -553,11 +561,17 @@ Claude Code はエイリアス(`opus`、`sonnet`、`haiku`、`opusplan`、`def language: ja default_piece: default log_level: info -provider: claude # デフォルトプロバイダー: claude または codex +provider: claude # デフォルトプロバイダー: claude、codex、または opencode model: sonnet # デフォルトモデル(オプション) branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速) prevent_sleep: false # macOS の実行中スリープ防止(caffeinate) notification_sound: true # 通知音の有効/無効 +notification_sound_events: # タイミング別の通知音制御 + iteration_limit: false + piece_complete: true + piece_abort: true + run_complete: true # 未設定時は有効。false を指定すると無効 + run_abort: true # 未設定時は有効。false を指定すると無効 concurrency: 1 # takt run の並列タスク数(1-10、デフォルト: 1 = 逐次実行) task_poll_interval_ms: 500 # takt run 中の新タスク検出ポーリング間隔(100-5000、デフォルト: 500) interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数(0-10、デフォルト: 3) @@ -569,9 +583,10 @@ interactive_preview_movements: 3 # 対話モードでのムーブメントプ # ai-antipattern-reviewer: claude # レビュアーは Claude のまま # API Key 設定(オプション) -# 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY で上書き可能 +# 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY で上書き可能 anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合 # openai_api_key: sk-... # Codex (OpenAI) を使う場合 +# opencode_api_key: ... # OpenCode を使う場合 # ビルトインピースのフィルタリング(オプション) # builtin_pieces_enabled: true # false でビルトイン全体を無効化 @@ -595,17 +610,17 @@ anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合 1. **環境変数で設定**: ```bash export TAKT_ANTHROPIC_API_KEY=sk-ant-... # Claude の場合 - # または export TAKT_OPENAI_API_KEY=sk-... # Codex の場合 + export TAKT_OPENCODE_API_KEY=... # OpenCode の場合 ``` 2. **設定ファイルで設定**: - 上記の `~/.takt/config.yaml` に `anthropic_api_key` または `openai_api_key` を記述 + 上記の `~/.takt/config.yaml` に `anthropic_api_key`、`openai_api_key`、または `opencode_api_key` を記述 優先順位: 環境変数 > `config.yaml` の設定 **注意事項:** -- API Key を設定した場合、Claude Code や Codex のインストールは不要です。TAKT が直接 Anthropic API または OpenAI API を呼び出します。 +- API Key を設定した場合、Claude Code、Codex、OpenCode のインストールは不要です。TAKT が直接各 API を呼び出します。 - **セキュリティ**: `config.yaml` に API Key を記述した場合、このファイルを Git にコミットしないよう注意してください。環境変数での設定を使うか、`.gitignore` に `~/.takt/config.yaml` を追加することを検討してください。 **パイプラインテンプレート変数:** @@ -621,37 +636,44 @@ anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合 1. ピースのムーブメントの `model`(最優先) 2. カスタムエージェントの `model` 3. グローバル設定の `model` -4. プロバイダーデフォルト(Claude: sonnet、Codex: codex) +4. プロバイダーデフォルト(Claude: sonnet、Codex: codex、OpenCode: プロバイダーデフォルト) ## 詳細ガイド -### タスクファイルの形式 +### タスクディレクトリ形式 -TAKT は `.takt/tasks/` 内のタスクファイルによるバッチ処理をサポートしています。`.yaml`/`.yml` と `.md` の両方のファイル形式に対応しています。 +TAKT は `.takt/tasks.yaml` にタスクのメタデータを保存し、長文仕様は `.takt/tasks/{slug}/` に分離して管理します。 -**YAML形式**(推奨、worktree/branch/pieceオプション対応): +**推奨構成**: -```yaml -# .takt/tasks/add-auth.yaml -task: "認証機能を追加する" -worktree: true # 隔離された共有クローンで実行 -branch: "feat/add-auth" # ブランチ名(省略時は自動生成) -piece: "default" # ピース指定(省略時は現在のもの) +```text +.takt/ + tasks/ + 20260201-015714-foptng/ + order.md + schema.sql + wireframe.png + tasks.yaml + runs/ + 20260201-015714-foptng/ + reports/ ``` -**Markdown形式**(シンプル、後方互換): - -```markdown -# .takt/tasks/add-login-feature.md - -アプリケーションにログイン機能を追加する。 +**tasks.yaml レコード例**: -要件: -- ユーザー名とパスワードフィールド -- フォームバリデーション -- 失敗時のエラーハンドリング +```yaml +tasks: + - name: add-auth-feature + status: pending + task_dir: .takt/tasks/20260201-015714-foptng + piece: default + created_at: "2026-02-01T01:57:14.000Z" + started_at: null + completed_at: null ``` +`takt add` は `.takt/tasks/{slug}/order.md` を自動生成し、`tasks.yaml` には `task_dir` を保存します。 + #### 共有クローンによる隔離実行 YAMLタスクファイルで`worktree`を指定すると、各タスクを`git clone --shared`で作成した隔離クローンで実行し、メインの作業ディレクトリをクリーンに保てます: @@ -667,15 +689,14 @@ YAMLタスクファイルで`worktree`を指定すると、各タスクを`git c ### セッションログ -TAKTはセッションログをNDJSON(`.jsonl`)形式で`.takt/logs/`に書き込みます。各レコードはアトミックに追記されるため、プロセスが途中でクラッシュしても部分的なログが保持され、`tail -f`でリアルタイムに追跡できます。 +TAKTはセッションログをNDJSON(`.jsonl`)形式で`.takt/runs/{slug}/logs/`に書き込みます。各レコードはアトミックに追記されるため、プロセスが途中でクラッシュしても部分的なログが保持され、`tail -f`でリアルタイムに追跡できます。 -- `.takt/logs/latest.json` - 現在(または最新の)セッションへのポインタ -- `.takt/logs/previous.json` - 前回セッションへのポインタ -- `.takt/logs/{sessionId}.jsonl` - ピース実行ごとのNDJSONセッションログ +- `.takt/runs/{slug}/logs/{sessionId}.jsonl` - ピース実行ごとのNDJSONセッションログ +- `.takt/runs/{slug}/meta.json` - run メタデータ(`task`, `piece`, `start/end`, `status` など) レコード種別: `piece_start`, `step_start`, `step_complete`, `piece_complete`, `piece_abort` -エージェントは`previous.json`を読み取って前回の実行コンテキストを引き継ぐことができます。セッション継続は自動的に行われます — `takt "タスク"`を実行するだけで前回のセッションから続行されます。 +最新の previous response は `.takt/runs/{slug}/context/previous_responses/latest.md` に保存され、実行時に自動的に引き継がれます。 ### カスタムピースの追加 @@ -690,7 +711,7 @@ takt eject default # ~/.takt/pieces/my-piece.yaml name: my-piece description: カスタムピース -max_iterations: 5 +max_movements: 5 initial_movement: analyze personas: @@ -740,11 +761,11 @@ personas: |------|------| | `{task}` | 元のユーザーリクエスト(テンプレートになければ自動注入) | | `{iteration}` | ピース全体のターン数(実行された全ムーブメント数) | -| `{max_iterations}` | 最大イテレーション数 | +| `{max_movements}` | 最大イテレーション数 | | `{movement_iteration}` | ムーブメントごとのイテレーション数(このムーブメントが実行された回数) | | `{previous_response}` | 前のムーブメントの出力(テンプレートになければ自動注入) | | `{user_inputs}` | ピース中の追加ユーザー入力(テンプレートになければ自動注入) | -| `{report_dir}` | レポートディレクトリパス(例: `.takt/reports/20250126-143052-task-summary`) | +| `{report_dir}` | レポートディレクトリパス(例: `.takt/runs/20250126-143052-task-summary/reports`) | | `{report:filename}` | `{report_dir}/filename` に展開(例: `{report:00-plan.md}`) | ### ピースの設計 @@ -777,7 +798,7 @@ rules: | `edit` | - | ムーブメントがプロジェクトファイルを編集できるか(`true`/`false`) | | `pass_previous_response` | `true` | 前のムーブメントの出力を`{previous_response}`に渡す | | `allowed_tools` | - | エージェントが使用できるツール一覧(Read, Glob, Grep, Edit, Write, Bash等) | -| `provider` | - | このムーブメントのプロバイダーを上書き(`claude`または`codex`) | +| `provider` | - | このムーブメントのプロバイダーを上書き(`claude`、`codex`、または`opencode`) | | `model` | - | このムーブメントのモデルを上書き | | `permission_mode` | - | パーミッションモード: `readonly`、`edit`、`full`(プロバイダー非依存) | | `output_contracts` | - | レポートファイルの出力契約定義 | @@ -855,7 +876,7 @@ npm install -g takt takt --pipeline --task "バグ修正" --auto-pr --repo owner/repo ``` -認証には `TAKT_ANTHROPIC_API_KEY` または `TAKT_OPENAI_API_KEY` 環境変数を設定してください(TAKT 独自のプレフィックス付き)。 +認証には `TAKT_ANTHROPIC_API_KEY`、`TAKT_OPENAI_API_KEY`、または `TAKT_OPENCODE_API_KEY` 環境変数を設定してください(TAKT 独自のプレフィックス付き)。 ```bash # Claude (Anthropic) を使う場合 @@ -863,6 +884,9 @@ export TAKT_ANTHROPIC_API_KEY=sk-ant-... # Codex (OpenAI) を使う場合 export TAKT_OPENAI_API_KEY=sk-... + +# OpenCode を使う場合 +export TAKT_OPENCODE_API_KEY=... ``` ## ドキュメント diff --git a/docs/data-flow.md b/docs/data-flow.md index 9ed61c87..25fc1261 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -431,7 +431,7 @@ TAKTのデータフローは以下の7つの主要なレイヤーで構成され 2. **ログ初期化**: - `createSessionLog()`: セッションログオブジェクト作成 - `initNdjsonLog()`: NDJSON形式のログファイル初期化 - - `updateLatestPointer()`: `latest.json` ポインタ更新 + - `meta.json` 更新: 実行ステータス(running/completed/aborted)と時刻を保存 3. **PieceEngine初期化**: ```typescript @@ -498,7 +498,7 @@ TAKTのデータフローは以下の7つの主要なレイヤーで構成され while (state.status === 'running') { // 1. Abort & Iteration チェック if (abortRequested) { ... } - if (iteration >= maxIterations) { ... } + if (iteration >= maxMovements) { ... } // 2. ステップ取得 const step = getStep(state.currentStep); @@ -619,6 +619,7 @@ const match = await detectMatchedRule(step, response.content, tagContent, {...}) - Step Iteration (per-step) - Step name - Report Directory/File info + - Run Source Paths (`.takt/runs/{slug}/context/...`) 3. **User Request** (タスク本文): - `{task}` プレースホルダーがテンプレートにない場合のみ自動注入 @@ -626,6 +627,8 @@ const match = await detectMatchedRule(step, response.content, tagContent, {...}) 4. **Previous Response** (前ステップの出力): - `step.passPreviousResponse === true` かつ - `{previous_response}` プレースホルダーがテンプレートにない場合のみ自動注入 + - 長さ制御(2000 chars)と `...TRUNCATED...` を適用 + - Source Path を常時注入 5. **Additional User Inputs** (blocked時の追加入力): - `{user_inputs}` プレースホルダーがテンプレートにない場合のみ自動注入 @@ -643,7 +646,7 @@ const match = await detectMatchedRule(step, response.content, tagContent, {...}) - `{previous_response}`: 前ステップの出力 - `{user_inputs}`: 追加ユーザー入力 - `{iteration}`: ピース全体のイテレーション -- `{max_iterations}`: 最大イテレーション +- `{max_movements}`: 最大イテレーション - `{step_iteration}`: ステップのイテレーション - `{report_dir}`: レポートディレクトリ @@ -821,7 +824,7 @@ new PieceEngine(pieceConfig, cwd, task, { 1. **コンテキスト収集**: - `task`: 元のユーザーリクエスト - - `iteration`, `maxIterations`: イテレーション情報 + - `iteration`, `maxMovements`: イテレーション情報 - `stepIteration`: ステップごとの実行回数 - `cwd`, `projectCwd`: ディレクトリ情報 - `userInputs`: blocked時の追加入力 diff --git a/docs/faceted-prompting.ja.md b/docs/faceted-prompting.ja.md index c6fe0257..05daba9e 100644 --- a/docs/faceted-prompting.ja.md +++ b/docs/faceted-prompting.ja.md @@ -331,7 +331,7 @@ Faceted Promptingの中核メカニズムは**宣言的な合成**である。 ```yaml name: my-workflow -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: diff --git a/docs/faceted-prompting.md b/docs/faceted-prompting.md index 0336288e..f0100fef 100644 --- a/docs/faceted-prompting.md +++ b/docs/faceted-prompting.md @@ -331,7 +331,7 @@ Key properties: ```yaml name: my-workflow -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: diff --git a/docs/pieces.md b/docs/pieces.md index ea193e64..1663197b 100644 --- a/docs/pieces.md +++ b/docs/pieces.md @@ -25,7 +25,7 @@ A piece is a YAML file that defines a sequence of steps executed by AI agents. E ```yaml name: my-piece description: Optional description -max_iterations: 10 +max_movements: 10 initial_step: first-step # Optional, defaults to first step steps: @@ -55,11 +55,11 @@ steps: |----------|-------------| | `{task}` | Original user request (auto-injected if not in template) | | `{iteration}` | Piece-wide turn count (total steps executed) | -| `{max_iterations}` | Maximum iterations allowed | +| `{max_movements}` | Maximum movements allowed | | `{step_iteration}` | Per-step iteration count (how many times THIS step has run) | | `{previous_response}` | Previous step's output (auto-injected if not in template) | | `{user_inputs}` | Additional user inputs during piece (auto-injected if not in template) | -| `{report_dir}` | Report directory path (e.g., `.takt/reports/20250126-143052-task-summary`) | +| `{report_dir}` | Report directory path (e.g., `.takt/runs/20250126-143052-task-summary/reports`) | | `{report:filename}` | Resolves to `{report_dir}/filename` (e.g., `{report:00-plan.md}`) | > **Note**: `{task}`, `{previous_response}`, and `{user_inputs}` are auto-injected into instructions. You only need explicit placeholders if you want to control their position in the template. @@ -170,7 +170,7 @@ report: ```yaml name: simple-impl -max_iterations: 5 +max_movements: 5 steps: - name: implement @@ -191,7 +191,7 @@ steps: ```yaml name: with-review -max_iterations: 10 +max_movements: 10 steps: - name: implement diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index 331d1e23..cc996b3f 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -5,7 +5,8 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 ## 前提条件 - `gh` CLI が利用可能で、対象GitHubアカウントでログイン済みであること。 - `takt-testing` リポジトリが対象アカウントに存在すること(E2Eがクローンして使用)。 -- 必要に応じて `TAKT_E2E_PROVIDER` を設定すること(例: `claude` / `codex`)。 +- 必要に応じて `TAKT_E2E_PROVIDER` を設定すること(例: `claude` / `codex` / `opencode`)。 +- `TAKT_E2E_PROVIDER=opencode` の場合は `TAKT_E2E_MODEL` が必須(例: `opencode/big-pickle`)。 - 実行時間が長いテストがあるため、タイムアウトに注意すること。 - E2Eは `e2e/helpers/test-repo.ts` が `gh` でリポジトリをクローンし、テンポラリディレクトリで実行する。 - 対話UIを避けるため、E2E環境では `TAKT_NO_TTY=1` を設定してTTYを無効化する。 @@ -13,26 +14,35 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - リポジトリクローン: `$(os.tmpdir())/takt-e2e-repo-/` - 実行環境: `$(os.tmpdir())/takt-e2e--/` +## E2E用config.yaml +- E2Eのグローバル設定は `e2e/fixtures/config.e2e.yaml` を基準に生成する。 +- `createIsolatedEnv()` は毎回一時ディレクトリ配下(`$TAKT_CONFIG_DIR/config.yaml`)にこの基準設定を書き出す。 +- 通知音は `notification_sound_events` でタイミング別に制御し、E2E既定では道中(`iteration_limit` / `piece_complete` / `piece_abort`)をOFF、全体終了時(`run_complete` / `run_abort`)のみONにする。 +- 各スペックで `provider` や `concurrency` を変更する場合は、`updateIsolatedConfig()` を使って差分のみ上書きする。 +- `~/.takt/config.yaml` はE2Eでは参照されないため、通常実行の設定には影響しない。 + ## 実行コマンド - `npm run test:e2e`: E2E全体を実行。 - `npm run test:e2e:mock`: mock固定のE2Eのみ実行。 - `npm run test:e2e:provider`: `claude` と `codex` の両方で実行。 - `npm run test:e2e:provider:claude`: `TAKT_E2E_PROVIDER=claude` で実行。 - `npm run test:e2e:provider:codex`: `TAKT_E2E_PROVIDER=codex` で実行。 +- `npm run test:e2e:provider:opencode`: `TAKT_E2E_PROVIDER=opencode` で実行(`TAKT_E2E_MODEL` 必須)。 - `npm run test:e2e:all`: `mock` + `provider` を通しで実行。 - `npm run test:e2e:claude`: `test:e2e:provider:claude` の別名。 - `npm run test:e2e:codex`: `test:e2e:provider:codex` の別名。 +- `npm run test:e2e:opencode`: `test:e2e:provider:opencode` の別名。 - `npx vitest run e2e/specs/add-and-run.e2e.ts`: 単体実行の例。 ## シナリオ一覧 - Add task and run(`e2e/specs/add-and-run.e2e.ts`) - - 目的: `.takt/tasks/` にタスクYAMLを配置し、`takt run` が実行できることを確認。 + - 目的: `.takt/tasks.yaml` に pending タスクを配置し、`takt run` が実行できることを確認。 - LLM: 条件付き(`TAKT_E2E_PROVIDER` が `claude` / `codex` の場合に呼び出す) - 手順(ユーザー行動/コマンド): - - `.takt/tasks/e2e-test-task.yaml` にタスクを作成(`piece` は `e2e/fixtures/pieces/simple.yaml` を指定)。 + - `.takt/tasks.yaml` にタスクを作成(`piece` は `e2e/fixtures/pieces/simple.yaml` を指定)。 - `takt run` を実行する。 - `README.md` に行が追加されることを確認する。 - - タスクファイルが `tasks/` から移動されることを確認する。 + - 実行後にタスクが `tasks.yaml` から消えることを確認する。 - Worktree/Clone isolation(`e2e/specs/worktree.e2e.ts`) - 目的: `--create-worktree yes` 指定で隔離環境に実行されることを確認。 - LLM: 条件付き(`TAKT_E2E_PROVIDER` が `claude` / `codex` の場合に呼び出す) @@ -83,13 +93,13 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - `gh issue create ...` でIssueを作成する。 - `TAKT_MOCK_SCENARIO=e2e/fixtures/scenarios/add-task.json` を設定する。 - `takt add '#'` を実行し、`Create worktree?` に `n` で回答する。 - - `.takt/tasks/` にYAMLが生成されることを確認する。 + - `.takt/tasks.yaml` に `task_dir` が保存され、`.takt/tasks/{slug}/order.md` が生成されることを確認する。 - Watch tasks(`e2e/specs/watch.e2e.ts`) - 目的: `takt watch` が監視中に追加されたタスクを実行できることを確認。 - LLM: 呼び出さない(`--provider mock` 固定) - 手順(ユーザー行動/コマンド): - `takt watch --provider mock` を起動する。 - - `.takt/tasks/` にタスクYAMLを追加する(`piece` に `e2e/fixtures/pieces/mock-single-step.yaml` を指定)。 + - `.takt/tasks.yaml` に pending タスクを追加する(`piece` に `e2e/fixtures/pieces/mock-single-step.yaml` を指定)。 - 出力に `Task "watch-task" completed` が含まれることを確認する。 - `Ctrl+C` で終了する。 - Run tasks graceful shutdown on SIGINT(`e2e/specs/run-sigint-graceful.e2e.ts`) @@ -111,3 +121,27 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - `takt list --non-interactive --action diff --branch ` で差分統計が出力されることを確認する。 - `takt list --non-interactive --action try --branch ` で変更がステージされることを確認する。 - `takt list --non-interactive --action merge --branch ` でブランチがマージされ削除されることを確認する。 +- Config permission mode(`e2e/specs/cli-config.e2e.ts`) + - 目的: `takt config` でパーミッションモードの切り替えと永続化を確認。 + - LLM: 呼び出さない(LLM不使用の操作のみ) + - 手順(ユーザー行動/コマンド): + - `takt config default` を実行し、`Switched to: default` が出力されることを確認する。 + - `takt config sacrifice-my-pc` を実行し、`Switched to: sacrifice-my-pc` が出力されることを確認する。 + - `takt config sacrifice-my-pc` 実行後、`.takt/config.yaml` に `permissionMode: sacrifice-my-pc` が保存されていることを確認する。 + - `takt config invalid-mode` を実行し、`Invalid mode` が出力されることを確認する。 +- Reset categories(`e2e/specs/cli-reset-categories.e2e.ts`) + - 目的: `takt reset categories` でカテゴリオーバーレイのリセットを確認。 + - LLM: 呼び出さない(LLM不使用の操作のみ) + - 手順(ユーザー行動/コマンド): + - `takt reset categories` を実行する。 + - 出力に `reset` を含むことを確認する。 + - `$TAKT_CONFIG_DIR/preferences/piece-categories.yaml` が存在し `piece_categories: {}` を含むことを確認する。 +- Export Claude Code Skill(`e2e/specs/cli-export-cc.e2e.ts`) + - 目的: `takt export-cc` でClaude Code Skillのデプロイを確認。 + - LLM: 呼び出さない(LLM不使用の操作のみ) + - 手順(ユーザー行動/コマンド): + - `HOME` を一時ディレクトリに設定する。 + - `takt export-cc` を実行する。 + - 出力に `ファイルをデプロイしました` を含むことを確認する。 + - `$HOME/.claude/skills/takt/SKILL.md` が存在することを確認する。 + - `$HOME/.claude/skills/takt/pieces/` および `$HOME/.claude/skills/takt/personas/` ディレクトリが存在し、それぞれ少なくとも1ファイルを含むことを確認する。 diff --git a/e2e/fixtures/config.e2e.yaml b/e2e/fixtures/config.e2e.yaml new file mode 100644 index 00000000..6eea1b84 --- /dev/null +++ b/e2e/fixtures/config.e2e.yaml @@ -0,0 +1,11 @@ +provider: claude +language: en +log_level: info +default_piece: default +notification_sound: true +notification_sound_events: + iteration_limit: false + piece_complete: false + piece_abort: false + run_complete: true + run_abort: true diff --git a/e2e/fixtures/pieces/broken.yaml b/e2e/fixtures/pieces/broken.yaml new file mode 100644 index 00000000..3cc96424 --- /dev/null +++ b/e2e/fixtures/pieces/broken.yaml @@ -0,0 +1,5 @@ +name: broken + this is not valid YAML + - indentation: [wrong + movements: + broken: {{{ diff --git a/e2e/fixtures/pieces/mock-max-iter.yaml b/e2e/fixtures/pieces/mock-max-iter.yaml new file mode 100644 index 00000000..1912fde9 --- /dev/null +++ b/e2e/fixtures/pieces/mock-max-iter.yaml @@ -0,0 +1,27 @@ +name: e2e-mock-max-iter +description: Piece with max_movements=2 that loops between two steps + +max_movements: 2 + +initial_movement: step-a + +movements: + - name: step-a + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + {task} + rules: + - condition: Done + next: step-b + + - name: step-b + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + Continue the task. + rules: + - condition: Done + next: step-a diff --git a/e2e/fixtures/pieces/mock-no-match.yaml b/e2e/fixtures/pieces/mock-no-match.yaml new file mode 100644 index 00000000..493bd88b --- /dev/null +++ b/e2e/fixtures/pieces/mock-no-match.yaml @@ -0,0 +1,15 @@ +name: e2e-mock-no-match +description: Piece with a strict rule condition that will not match mock output + +max_movements: 3 + +movements: + - name: execute + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + {task} + rules: + - condition: SpecificMatchThatWillNotOccur + next: COMPLETE diff --git a/e2e/fixtures/pieces/mock-single-step.yaml b/e2e/fixtures/pieces/mock-single-step.yaml index 6ad42fbe..087869c7 100644 --- a/e2e/fixtures/pieces/mock-single-step.yaml +++ b/e2e/fixtures/pieces/mock-single-step.yaml @@ -1,7 +1,7 @@ name: e2e-mock-single description: Minimal mock-only piece for CLI E2E -max_iterations: 3 +max_movements: 3 movements: - name: execute diff --git a/e2e/fixtures/pieces/mock-slow-multi-step.yaml b/e2e/fixtures/pieces/mock-slow-multi-step.yaml index 5e4d8d06..8da8f3fc 100644 --- a/e2e/fixtures/pieces/mock-slow-multi-step.yaml +++ b/e2e/fixtures/pieces/mock-slow-multi-step.yaml @@ -1,7 +1,7 @@ name: e2e-mock-slow-multi-step description: Multi-step mock piece to keep tasks in-flight long enough for SIGINT E2E -max_iterations: 20 +max_movements: 20 initial_movement: step-1 diff --git a/e2e/fixtures/pieces/mock-two-step.yaml b/e2e/fixtures/pieces/mock-two-step.yaml new file mode 100644 index 00000000..8090cf46 --- /dev/null +++ b/e2e/fixtures/pieces/mock-two-step.yaml @@ -0,0 +1,27 @@ +name: e2e-mock-two-step +description: Two-step sequential piece for E2E testing + +max_movements: 5 + +initial_movement: step-1 + +movements: + - name: step-1 + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + {task} + rules: + - condition: Done + next: step-2 + + - name: step-2 + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + Continue the task. + rules: + - condition: Done + next: COMPLETE diff --git a/e2e/fixtures/pieces/multi-step-parallel.yaml b/e2e/fixtures/pieces/multi-step-parallel.yaml index d33354b2..2b25b3e1 100644 --- a/e2e/fixtures/pieces/multi-step-parallel.yaml +++ b/e2e/fixtures/pieces/multi-step-parallel.yaml @@ -1,7 +1,7 @@ name: e2e-multi-step-parallel description: Multi-step piece with parallel sub-movements for E2E testing -max_iterations: 10 +max_movements: 10 initial_movement: plan diff --git a/e2e/fixtures/pieces/report-judge.yaml b/e2e/fixtures/pieces/report-judge.yaml index 4e44c7d0..d8556098 100644 --- a/e2e/fixtures/pieces/report-judge.yaml +++ b/e2e/fixtures/pieces/report-judge.yaml @@ -1,7 +1,7 @@ name: e2e-report-judge description: E2E piece that exercises report + judge phases -max_iterations: 3 +max_movements: 3 movements: - name: execute diff --git a/e2e/fixtures/pieces/simple.yaml b/e2e/fixtures/pieces/simple.yaml index 9619c334..c8f813e0 100644 --- a/e2e/fixtures/pieces/simple.yaml +++ b/e2e/fixtures/pieces/simple.yaml @@ -1,7 +1,7 @@ name: e2e-simple description: Minimal E2E test piece -max_iterations: 5 +max_movements: 5 movements: - name: execute diff --git a/e2e/fixtures/scenarios/max-iter-loop.json b/e2e/fixtures/scenarios/max-iter-loop.json new file mode 100644 index 00000000..0befd974 --- /dev/null +++ b/e2e/fixtures/scenarios/max-iter-loop.json @@ -0,0 +1,18 @@ +[ + { + "status": "done", + "content": "Step A output." + }, + { + "status": "done", + "content": "Step B output." + }, + { + "status": "done", + "content": "Step A output again." + }, + { + "status": "done", + "content": "Step B output again." + } +] diff --git a/e2e/fixtures/scenarios/no-match.json b/e2e/fixtures/scenarios/no-match.json new file mode 100644 index 00000000..c70694c8 --- /dev/null +++ b/e2e/fixtures/scenarios/no-match.json @@ -0,0 +1,6 @@ +[ + { + "status": "error", + "content": "Simulated failure: API error during execution" + } +] diff --git a/e2e/fixtures/scenarios/one-entry-only.json b/e2e/fixtures/scenarios/one-entry-only.json new file mode 100644 index 00000000..c406d0f8 --- /dev/null +++ b/e2e/fixtures/scenarios/one-entry-only.json @@ -0,0 +1,6 @@ +[ + { + "status": "done", + "content": "Only entry in scenario." + } +] diff --git a/e2e/fixtures/scenarios/run-three-tasks.json b/e2e/fixtures/scenarios/run-three-tasks.json new file mode 100644 index 00000000..4ed3b69a --- /dev/null +++ b/e2e/fixtures/scenarios/run-three-tasks.json @@ -0,0 +1,14 @@ +[ + { + "status": "done", + "content": "Task 1 completed successfully." + }, + { + "status": "done", + "content": "Task 2 completed successfully." + }, + { + "status": "done", + "content": "Task 3 completed successfully." + } +] diff --git a/e2e/fixtures/scenarios/run-with-failure.json b/e2e/fixtures/scenarios/run-with-failure.json new file mode 100644 index 00000000..ab163164 --- /dev/null +++ b/e2e/fixtures/scenarios/run-with-failure.json @@ -0,0 +1,14 @@ +[ + { + "status": "done", + "content": "Task 1 completed successfully." + }, + { + "status": "error", + "content": "Task 2 encountered an error." + }, + { + "status": "done", + "content": "Task 3 completed successfully." + } +] diff --git a/e2e/fixtures/scenarios/two-step-done.json b/e2e/fixtures/scenarios/two-step-done.json new file mode 100644 index 00000000..7ced60d9 --- /dev/null +++ b/e2e/fixtures/scenarios/two-step-done.json @@ -0,0 +1,10 @@ +[ + { + "status": "done", + "content": "Step 1 output text completed." + }, + { + "status": "done", + "content": "Step 2 output text completed." + } +] diff --git a/e2e/helpers/isolated-env.ts b/e2e/helpers/isolated-env.ts index 5f08be48..8f1e24d3 100644 --- a/e2e/helpers/isolated-env.ts +++ b/e2e/helpers/isolated-env.ts @@ -1,6 +1,8 @@ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; export interface IsolatedEnv { runId: string; @@ -9,6 +11,73 @@ export interface IsolatedEnv { cleanup: () => void; } +type E2EConfig = Record; +type NotificationSoundEvents = Record; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const E2E_CONFIG_FIXTURE_PATH = resolve(__dirname, '../fixtures/config.e2e.yaml'); + +function readE2EFixtureConfig(): E2EConfig { + const raw = readFileSync(E2E_CONFIG_FIXTURE_PATH, 'utf-8'); + const parsed = parseYaml(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid E2E config fixture: ${E2E_CONFIG_FIXTURE_PATH}`); + } + return parsed as E2EConfig; +} + +function writeConfigFile(taktDir: string, config: E2EConfig): void { + writeFileSync(join(taktDir, 'config.yaml'), `${stringifyYaml(config)}`); +} + +function parseNotificationSoundEvents( + source: E2EConfig, + sourceName: string, +): NotificationSoundEvents | undefined { + const value = source.notification_sound_events; + if (value === undefined) { + return undefined; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error( + `Invalid notification_sound_events in ${sourceName}: expected object`, + ); + } + return value as NotificationSoundEvents; +} + +function mergeIsolatedConfig( + fixture: E2EConfig, + current: E2EConfig, + patch: E2EConfig, +): E2EConfig { + const merged: E2EConfig = { ...fixture, ...current, ...patch }; + const fixtureEvents = parseNotificationSoundEvents(fixture, 'fixture'); + const currentEvents = parseNotificationSoundEvents(current, 'current config'); + const patchEvents = parseNotificationSoundEvents(patch, 'patch'); + if (!fixtureEvents && !currentEvents && !patchEvents) { + return merged; + } + merged.notification_sound_events = { + ...(fixtureEvents ?? {}), + ...(currentEvents ?? {}), + ...(patchEvents ?? {}), + }; + return merged; +} + +export function updateIsolatedConfig(taktDir: string, patch: E2EConfig): void { + const current = readE2EFixtureConfig(); + const configPath = join(taktDir, 'config.yaml'); + const raw = readFileSync(configPath, 'utf-8'); + const parsed = parseYaml(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid isolated config: ${configPath}`); + } + writeConfigFile(taktDir, mergeIsolatedConfig(current, parsed as E2EConfig, patch)); +} + /** * Create an isolated environment for E2E testing. * @@ -24,18 +93,21 @@ export function createIsolatedEnv(): IsolatedEnv { const gitConfigPath = join(baseDir, '.gitconfig'); // Create TAKT config directory and config.yaml - // Use TAKT_E2E_PROVIDER to match config provider with the actual provider being tested - const configProvider = process.env.TAKT_E2E_PROVIDER ?? 'claude'; mkdirSync(taktDir, { recursive: true }); - writeFileSync( - join(taktDir, 'config.yaml'), - [ - `provider: ${configProvider}`, - 'language: en', - 'log_level: info', - 'default_piece: default', - ].join('\n'), - ); + const baseConfig = readE2EFixtureConfig(); + const provider = process.env.TAKT_E2E_PROVIDER; + const model = process.env.TAKT_E2E_MODEL; + if (provider === 'opencode' && !model) { + throw new Error('TAKT_E2E_PROVIDER=opencode requires TAKT_E2E_MODEL (e.g. opencode/big-pickle)'); + } + const config = provider + ? { + ...baseConfig, + provider, + ...(provider === 'opencode' && model ? { model } : {}), + } + : baseConfig; + writeConfigFile(taktDir, config); // Create isolated Git config file writeFileSync( @@ -58,11 +130,7 @@ export function createIsolatedEnv(): IsolatedEnv { taktDir, env, cleanup: () => { - try { - rmSync(baseDir, { recursive: true, force: true }); - } catch { - // Best-effort cleanup; ignore errors (e.g., already deleted) - } + rmSync(baseDir, { recursive: true, force: true }); }, }; } diff --git a/e2e/specs/add-and-run.e2e.ts b/e2e/specs/add-and-run.e2e.ts index 3b7c47bf..ac91c0ec 100644 --- a/e2e/specs/add-and-run.e2e.ts +++ b/e2e/specs/add-and-run.e2e.ts @@ -74,10 +74,10 @@ describe('E2E: Add task and run (takt add → takt run)', () => { const readme = readFileSync(readmePath, 'utf-8'); expect(readme).toContain('E2E test passed'); - // Verify task status became completed + // Verify completed task was removed from tasks.yaml const tasksRaw = readFileSync(tasksFile, 'utf-8'); const parsed = parseYaml(tasksRaw) as { tasks?: Array<{ name?: string; status?: string }> }; const executed = parsed.tasks?.find((task) => task.name === 'e2e-test-task'); - expect(executed?.status).toBe('completed'); + expect(executed).toBeUndefined(); }, 240_000); }); diff --git a/e2e/specs/add.e2e.ts b/e2e/specs/add.e2e.ts index a4bb0316..bc7979cb 100644 --- a/e2e/specs/add.e2e.ts +++ b/e2e/specs/add.e2e.ts @@ -1,10 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse as parseYaml } from 'yaml'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; import { createTestRepo, type TestRepo } from '../helpers/test-repo'; import { runTakt } from '../helpers/takt-runner'; @@ -22,16 +26,10 @@ describe('E2E: Add task from GitHub issue (takt add)', () => { testRepo = createTestRepo(); // Use mock provider to stabilize summarizer - writeFileSync( - join(isolatedEnv.taktDir, 'config.yaml'), - [ - 'provider: mock', - 'model: mock-model', - 'language: en', - 'log_level: info', - 'default_piece: default', - ].join('\n'), - ); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + model: 'mock-model', + }); const createOutput = execFileSync( 'gh', @@ -87,8 +85,12 @@ describe('E2E: Add task from GitHub issue (takt add)', () => { const tasksFile = join(testRepo.path, '.takt', 'tasks.yaml'); const content = readFileSync(tasksFile, 'utf-8'); - const parsed = parseYaml(content) as { tasks?: Array<{ issue?: number }> }; + const parsed = parseYaml(content) as { tasks?: Array<{ issue?: number; task_dir?: string }> }; expect(parsed.tasks?.length).toBe(1); expect(parsed.tasks?.[0]?.issue).toBe(Number(issueNumber)); + expect(parsed.tasks?.[0]?.task_dir).toBeTypeOf('string'); + const orderPath = join(testRepo.path, String(parsed.tasks?.[0]?.task_dir), 'order.md'); + expect(existsSync(orderPath)).toBe(true); + expect(readFileSync(orderPath, 'utf-8')).toContain('E2E Add Issue'); }, 240_000); }); diff --git a/e2e/specs/cli-catalog.e2e.ts b/e2e/specs/cli-catalog.e2e.ts new file mode 100644 index 00000000..881cde12 --- /dev/null +++ b/e2e/specs/cli-catalog.e2e.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-catalog-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Catalog command (takt catalog)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should list all facet types when no argument given', () => { + // Given: a local repo with isolated env + + // When: running takt catalog + const result = runTakt({ + args: ['catalog'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains facet type sections + expect(result.exitCode).toBe(0); + const output = result.stdout.toLowerCase(); + expect(output).toMatch(/persona/); + }); + + it('should list facets for a specific type', () => { + // Given: a local repo with isolated env + + // When: running takt catalog personas + const result = runTakt({ + args: ['catalog', 'personas'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains persona names + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/coder/i); + }); + + it('should error for an invalid facet type', () => { + // Given: a local repo with isolated env + + // When: running takt catalog with an invalid type + const result = runTakt({ + args: ['catalog', 'invalidtype'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains an error or lists valid types + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/invalid|not found|valid types|unknown/i); + }); +}); diff --git a/e2e/specs/cli-clear.e2e.ts b/e2e/specs/cli-clear.e2e.ts new file mode 100644 index 00000000..81ccad72 --- /dev/null +++ b/e2e/specs/cli-clear.e2e.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-clear-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Clear sessions command (takt clear)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should clear sessions without error', () => { + // Given: a local repo with isolated env + + // When: running takt clear + const result = runTakt({ + args: ['clear'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: exits cleanly + expect(result.exitCode).toBe(0); + const output = result.stdout.toLowerCase(); + expect(output).toMatch(/clear|session|removed|no session/); + }); +}); diff --git a/e2e/specs/cli-config.e2e.ts b/e2e/specs/cli-config.e2e.ts new file mode 100644 index 00000000..e51cfc4b --- /dev/null +++ b/e2e/specs/cli-config.e2e.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-config-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Config command (takt config)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should switch to default mode with explicit argument', () => { + // Given: a local repo with isolated env + + // When: running takt config default + const result = runTakt({ + args: ['config', 'default'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: exits successfully and outputs switched message + expect(result.exitCode).toBe(0); + const output = result.stdout; + expect(output).toMatch(/Switched to: default/); + }); + + it('should switch to sacrifice-my-pc mode with explicit argument', () => { + // Given: a local repo with isolated env + + // When: running takt config sacrifice-my-pc + const result = runTakt({ + args: ['config', 'sacrifice-my-pc'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: exits successfully and outputs switched message + expect(result.exitCode).toBe(0); + const output = result.stdout; + expect(output).toMatch(/Switched to: sacrifice-my-pc/); + }); + + it('should persist permission mode to project config', () => { + // Given: a local repo with isolated env + + // When: running takt config sacrifice-my-pc + runTakt({ + args: ['config', 'sacrifice-my-pc'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: .takt/config.yaml contains permissionMode: sacrifice-my-pc + const configPath = join(repo.path, '.takt', 'config.yaml'); + const content = readFileSync(configPath, 'utf-8'); + expect(content).toMatch(/permissionMode:\s*sacrifice-my-pc/); + }); + + it('should report error for invalid mode name', () => { + // Given: a local repo with isolated env + + // When: running takt config with an invalid mode + const result = runTakt({ + args: ['config', 'invalid-mode'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains invalid mode message + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/Invalid mode/); + }); +}); diff --git a/e2e/specs/cli-export-cc.e2e.ts b/e2e/specs/cli-export-cc.e2e.ts new file mode 100644 index 00000000..b1d771c8 --- /dev/null +++ b/e2e/specs/cli-export-cc.e2e.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, existsSync, readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-export-cc-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Export-cc command (takt export-cc)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + let fakeHome: string; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + fakeHome = mkdtempSync(join(tmpdir(), 'takt-e2e-export-cc-home-')); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + try { rmSync(fakeHome, { recursive: true, force: true }); } catch { /* best-effort */ } + }); + + it('should deploy skill files to isolated home directory', () => { + // Given: a local repo with isolated env and HOME redirected to fakeHome + const env: NodeJS.ProcessEnv = { ...isolatedEnv.env, HOME: fakeHome }; + + // When: running takt export-cc + const result = runTakt({ + args: ['export-cc'], + cwd: repo.path, + env, + }); + + // Then: exits successfully and outputs deploy message + expect(result.exitCode).toBe(0); + const output = result.stdout; + expect(output).toMatch(/ファイルをデプロイしました/); + + // Then: SKILL.md exists in the skill directory + const skillMdPath = join(fakeHome, '.claude', 'skills', 'takt', 'SKILL.md'); + expect(existsSync(skillMdPath)).toBe(true); + }); + + it('should deploy resource directories', () => { + // Given: a local repo with isolated env and HOME redirected to fakeHome + const env: NodeJS.ProcessEnv = { ...isolatedEnv.env, HOME: fakeHome }; + + // When: running takt export-cc + runTakt({ + args: ['export-cc'], + cwd: repo.path, + env, + }); + + // Then: pieces/ and personas/ directories exist with at least one file each + const skillDir = join(fakeHome, '.claude', 'skills', 'takt'); + + const piecesDir = join(skillDir, 'pieces'); + expect(existsSync(piecesDir)).toBe(true); + const pieceFiles = readdirSync(piecesDir); + expect(pieceFiles.length).toBeGreaterThan(0); + + const personasDir = join(skillDir, 'personas'); + expect(existsSync(personasDir)).toBe(true); + const personaFiles = readdirSync(personasDir); + expect(personaFiles.length).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/cli-help.e2e.ts b/e2e/specs/cli-help.e2e.ts new file mode 100644 index 00000000..c375f232 --- /dev/null +++ b/e2e/specs/cli-help.e2e.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-help-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Help command (takt --help)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should display subcommand list with --help', () => { + // Given: a local repo with isolated env + + // When: running takt --help + const result = runTakt({ + args: ['--help'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output lists subcommands + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/run/); + expect(result.stdout).toMatch(/add/); + expect(result.stdout).toMatch(/list/); + expect(result.stdout).toMatch(/eject/); + }); + + it('should display run subcommand help with takt run --help', () => { + // Given: a local repo with isolated env + + // When: running takt run --help + const result = runTakt({ + args: ['run', '--help'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains run command description + expect(result.exitCode).toBe(0); + const output = result.stdout.toLowerCase(); + expect(output).toMatch(/run|task|pending/); + }); +}); diff --git a/e2e/specs/cli-prompt.e2e.ts b/e2e/specs/cli-prompt.e2e.ts new file mode 100644 index 00000000..47b78fed --- /dev/null +++ b/e2e/specs/cli-prompt.e2e.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-prompt-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Prompt preview command (takt prompt)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should output prompt preview header and movement info for a piece', () => { + // Given: a piece file path + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + // When: running takt prompt with piece path + const result = runTakt({ + args: ['prompt', piecePath], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains "Prompt Preview" header and movement info + // (may fail on Phase 3 for pieces with tag-based rules, but header is still output) + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/Prompt Preview|Movement 1/i); + }); + + it('should report not found for a nonexistent piece name', () => { + // Given: a nonexistent piece name + + // When: running takt prompt with invalid piece + const result = runTakt({ + args: ['prompt', 'nonexistent-piece-xyz'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: reports piece not found + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found/i); + }); +}); diff --git a/e2e/specs/cli-reset-categories.e2e.ts b/e2e/specs/cli-reset-categories.e2e.ts new file mode 100644 index 00000000..f53131e5 --- /dev/null +++ b/e2e/specs/cli-reset-categories.e2e.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-reset-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Reset categories command (takt reset categories)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should reset categories and create overlay file', () => { + // Given: a local repo with isolated env + + // When: running takt reset categories + const result = runTakt({ + args: ['reset', 'categories'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: exits successfully and outputs reset message + expect(result.exitCode).toBe(0); + const output = result.stdout; + expect(output).toMatch(/reset/i); + + // Then: piece-categories.yaml exists with initial content + const categoriesPath = join(isolatedEnv.taktDir, 'preferences', 'piece-categories.yaml'); + expect(existsSync(categoriesPath)).toBe(true); + const content = readFileSync(categoriesPath, 'utf-8'); + expect(content).toContain('piece_categories: {}'); + }); +}); diff --git a/e2e/specs/cli-switch.e2e.ts b/e2e/specs/cli-switch.e2e.ts new file mode 100644 index 00000000..f9d05e85 --- /dev/null +++ b/e2e/specs/cli-switch.e2e.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-switch-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Switch piece command (takt switch)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should switch piece when a valid piece name is given', () => { + // Given: a local repo with isolated env + + // When: running takt switch default + const result = runTakt({ + args: ['switch', 'default'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: exits successfully + expect(result.exitCode).toBe(0); + const output = result.stdout.toLowerCase(); + expect(output).toMatch(/default|switched|piece/); + }); + + it('should error when a nonexistent piece name is given', () => { + // Given: a local repo with isolated env + + // When: running takt switch with a nonexistent piece name + const result = runTakt({ + args: ['switch', 'nonexistent-piece-xyz'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: error output + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found|error|does not exist/i); + }); +}); diff --git a/e2e/specs/error-handling.e2e.ts b/e2e/specs/error-handling.e2e.ts new file mode 100644 index 00000000..9c6cb0dd --- /dev/null +++ b/e2e/specs/error-handling.e2e.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-error-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Error handling edge cases (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should error when --piece points to a nonexistent file path', () => { + // Given: a nonexistent piece file path + + // When: running with a bad piece path + const result = runTakt({ + args: [ + '--task', 'test', + '--piece', '/nonexistent/path/to/piece.yaml', + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits with error + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found|does not exist|ENOENT/i); + }, 240_000); + + it('should report error when --piece specifies a nonexistent piece name', () => { + // Given: a nonexistent piece name + + // When: running with a bad piece name + const result = runTakt({ + args: [ + '--task', 'test', + '--piece', 'nonexistent-piece-name-xyz', + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: output contains error about piece not found + // Note: takt reports the error but currently exits with code 0 + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found/i); + }, 240_000); + + it('should error when --pipeline is used without --task or --issue', () => { + // Given: pipeline mode with no task or issue + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + // When: running in pipeline mode without a task + const result = runTakt({ + args: [ + '--pipeline', + '--piece', piecePath, + '--skip-git', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits with error (should not hang in interactive mode due to TAKT_NO_TTY=1) + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/task|issue|required/i); + }, 240_000); + + it('should error when --create-worktree receives an invalid value', () => { + // Given: invalid worktree value + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + // When: running with invalid worktree option + const result = runTakt({ + args: [ + '--task', 'test', + '--piece', piecePath, + '--create-worktree', 'invalid-value', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits with error or warning about invalid value + const combined = result.stdout + result.stderr; + const hasError = result.exitCode !== 0 || combined.match(/invalid|error|must be/i); + expect(hasError).toBeTruthy(); + }, 240_000); + + it('should error when piece file contains invalid YAML', () => { + // Given: a broken YAML piece file + const brokenPiecePath = resolve(__dirname, '../fixtures/pieces/broken.yaml'); + + // When: running with the broken piece + const result = runTakt({ + args: [ + '--task', 'test', + '--piece', brokenPiecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits with error about parsing + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/parse|invalid|error|validation/i); + }, 240_000); +}); diff --git a/e2e/specs/piece-error-handling.e2e.ts b/e2e/specs/piece-error-handling.e2e.ts new file mode 100644 index 00000000..5badea44 --- /dev/null +++ b/e2e/specs/piece-error-handling.e2e.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-piece-err-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Piece error handling (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should abort when agent returns error status', () => { + // Given: a piece and a scenario that returns error status + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-no-match.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/no-match.json'); + + // When: executing the piece + const result = runTakt({ + args: [ + '--task', 'Test error status abort', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: piece aborts with a non-zero exit code + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/failed|aborted|error/i); + }, 240_000); + + it('should abort when max_movements is reached', () => { + // Given: a piece with max_movements=2 that loops between step-a and step-b + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-max-iter.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/max-iter-loop.json'); + + // When: executing the piece + const result = runTakt({ + args: [ + '--task', 'Test max movements', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: piece aborts due to iteration limit + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/Max movements|iteration|aborted/i); + }, 240_000); + + it('should pass previous response between sequential steps', () => { + // Given: a two-step piece and a scenario with distinct step outputs + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-two-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/two-step-done.json'); + + // When: executing the piece + const result = runTakt({ + args: [ + '--task', 'Test previous response passing', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: piece completes successfully (both steps execute) + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Piece completed'); + }, 240_000); +}); diff --git a/e2e/specs/provider-error.e2e.ts b/e2e/specs/provider-error.e2e.ts new file mode 100644 index 00000000..e2e6978b --- /dev/null +++ b/e2e/specs/provider-error.e2e.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-provider-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Provider error handling (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should override config provider with --provider flag', () => { + // Given: config.yaml has provider: claude, but CLI flag specifies mock + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'claude', + }); + + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + + // When: running with --provider mock + const result = runTakt({ + args: [ + '--task', 'Test provider override', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: executes successfully with mock provider + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Piece completed'); + }, 240_000); + + it('should use default mock response when scenario entries are exhausted', () => { + // Given: a two-step piece with only 1 scenario entry + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-two-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/one-entry-only.json'); + + // When: executing the piece (step-2 will have no scenario entry) + const result = runTakt({ + args: [ + '--task', 'Test scenario exhaustion', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: does not crash; either completes or aborts gracefully + const combined = result.stdout + result.stderr; + expect(combined).not.toContain('UnhandledPromiseRejection'); + expect(combined).not.toContain('SIGTERM'); + }, 240_000); + + it('should error when scenario file does not exist', () => { + // Given: TAKT_MOCK_SCENARIO pointing to a non-existent file + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + // When: executing with a bad scenario path + const result = runTakt({ + args: [ + '--task', 'Test bad scenario', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: '/nonexistent/path/scenario.json', + }, + timeout: 240_000, + }); + + // Then: exits with error and clear message + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/[Ss]cenario file not found|ENOENT/); + }, 240_000); +}); diff --git a/e2e/specs/quiet-mode.e2e.ts b/e2e/specs/quiet-mode.e2e.ts new file mode 100644 index 00000000..085fb049 --- /dev/null +++ b/e2e/specs/quiet-mode.e2e.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-quiet-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Quiet mode (--quiet)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should suppress AI stream output in quiet mode', () => { + // Given: a simple piece and scenario + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + + // When: running with --quiet flag + const result = runTakt({ + args: [ + '--task', 'Test quiet mode', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + '--quiet', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: completes successfully; mock content should not appear in output + expect(result.exitCode).toBe(0); + // In quiet mode, the raw mock response text should be suppressed + expect(result.stdout).not.toContain('Mock response for persona'); + }, 240_000); +}); diff --git a/e2e/specs/run-multiple-tasks.e2e.ts b/e2e/specs/run-multiple-tasks.e2e.ts new file mode 100644 index 00000000..518db716 --- /dev/null +++ b/e2e/specs/run-multiple-tasks.e2e.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-run-multi-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Run multiple tasks (takt run)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + + // Override config to use mock provider + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + }); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should execute all pending tasks sequentially', () => { + // Given: 3 pending tasks in tasks.yaml + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/run-three-tasks.json'); + const now = new Date().toISOString(); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: task-1', + ' status: pending', + ' content: "E2E task 1"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' - name: task-2', + ' status: pending', + ' content: "E2E task 2"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' - name: task-3', + ' status: pending', + ' content: "E2E task 3"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: all 3 tasks complete + expect(result.exitCode).toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toContain('task-1'); + expect(combined).toContain('task-2'); + expect(combined).toContain('task-3'); + }, 240_000); + + it('should continue remaining tasks when one task fails', () => { + // Given: 3 tasks where the 2nd will fail (error status) + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/run-with-failure.json'); + const now = new Date().toISOString(); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: task-ok-1', + ' status: pending', + ' content: "Should succeed"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' - name: task-fail', + ' status: pending', + ' content: "Should fail"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' - name: task-ok-2', + ' status: pending', + ' content: "Should succeed after failure"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: exit code is non-zero (failure occurred), but task-ok-2 was still attempted + const combined = result.stdout + result.stderr; + expect(combined).toContain('task-ok-1'); + expect(combined).toContain('task-fail'); + expect(combined).toContain('task-ok-2'); + }, 240_000); + + it('should exit cleanly when no pending tasks exist', () => { + // Given: an empty tasks.yaml + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + 'tasks: []\n', + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits cleanly with code 0 + expect(result.exitCode).toBe(0); + }, 240_000); +}); diff --git a/e2e/specs/run-sigint-graceful.e2e.ts b/e2e/specs/run-sigint-graceful.e2e.ts index 941baea2..79c2d852 100644 --- a/e2e/specs/run-sigint-graceful.e2e.ts +++ b/e2e/specs/run-sigint-graceful.e2e.ts @@ -3,7 +3,11 @@ import { spawn } from 'node:child_process'; import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; import { createTestRepo, type TestRepo } from '../helpers/test-repo'; const __filename = fileURLToPath(import.meta.url); @@ -50,18 +54,12 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => { isolatedEnv = createIsolatedEnv(); testRepo = createTestRepo(); - writeFileSync( - join(isolatedEnv.taktDir, 'config.yaml'), - [ - 'provider: mock', - 'model: mock-model', - 'language: en', - 'log_level: info', - 'default_piece: default', - 'concurrency: 2', - 'task_poll_interval_ms: 100', - ].join('\n'), - ); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + model: 'mock-model', + concurrency: 2, + task_poll_interval_ms: 100, + }); }); afterEach(() => { diff --git a/e2e/specs/task-content-file.e2e.ts b/e2e/specs/task-content-file.e2e.ts new file mode 100644 index 00000000..4e79acbf --- /dev/null +++ b/e2e/specs/task-content-file.e2e.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-contentfile-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Task content_file reference (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + }); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should execute task using content_file reference', () => { + // Given: a task with content_file pointing to an existing file + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + const now = new Date().toISOString(); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + + // Create the content file + writeFileSync( + join(repo.path, 'task-content.txt'), + 'Create a noop file for E2E testing.', + 'utf-8', + ); + + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: content-file-task', + ' status: pending', + ' content_file: "./task-content.txt"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: task executes successfully + expect(result.exitCode).toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toContain('content-file-task'); + }, 240_000); + + it('should fail when content_file references a nonexistent file', () => { + // Given: a task with content_file pointing to a nonexistent file + const now = new Date().toISOString(); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: bad-content-file-task', + ' status: pending', + ' content_file: "./nonexistent-content.txt"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: task fails with a meaningful error + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found|ENOENT|missing|error/i); + }, 240_000); +}); diff --git a/e2e/specs/watch.e2e.ts b/e2e/specs/watch.e2e.ts index 29e0c180..f16318c2 100644 --- a/e2e/specs/watch.e2e.ts +++ b/e2e/specs/watch.e2e.ts @@ -96,6 +96,6 @@ describe('E2E: Watch tasks (takt watch)', () => { const tasksRaw = readFileSync(tasksFile, 'utf-8'); const parsed = parseYaml(tasksRaw) as { tasks?: Array<{ name?: string; status?: string }> }; const watchTask = parsed.tasks?.find((task) => task.name === 'watch-task'); - expect(watchTask?.status).toBe('completed'); + expect(watchTask).toBeUndefined(); }, 240_000); }); diff --git a/package-lock.json b/package-lock.json index c0719838..8d8d0c96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "takt", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.11.0", + "version": "0.12.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.37", "@openai/codex-sdk": "^0.98.0", + "@opencode-ai/sdk": "^1.1.53", "chalk": "^5.3.0", "commander": "^12.1.0", "update-notifier": "^7.3.1", @@ -936,6 +937,12 @@ "node": ">=18" } }, + "node_modules/@opencode-ai/sdk": { + "version": "1.1.53", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.53.tgz", + "integrity": "sha512-RUIVnPOP1CyyU32FrOOYuE7Ge51lOBuhaFp2NSX98ncApT7ffoNetmwzqrhOiJQgZB1KrbCHLYOCK6AZfacxag==", + "license": "MIT" + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", diff --git a/package.json b/package.json index a9b37634..7b36cdeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "takt", - "version": "0.11.1", + "version": "0.12.0", "description": "TAKT: Task Agent Koordination Tool - AI Agent Piece Orchestration", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -20,8 +20,10 @@ "test:e2e:provider": "npm run test:e2e:provider:claude && npm run test:e2e:provider:codex", "test:e2e:provider:claude": "TAKT_E2E_PROVIDER=claude vitest run --config vitest.config.e2e.provider.ts --reporter=verbose", "test:e2e:provider:codex": "TAKT_E2E_PROVIDER=codex vitest run --config vitest.config.e2e.provider.ts --reporter=verbose", + "test:e2e:provider:opencode": "TAKT_E2E_PROVIDER=opencode vitest run --config vitest.config.e2e.provider.ts --reporter=verbose", "test:e2e:claude": "npm run test:e2e:provider:claude", "test:e2e:codex": "npm run test:e2e:provider:codex", + "test:e2e:opencode": "npm run test:e2e:provider:opencode", "lint": "eslint src/", "prepublishOnly": "npm run lint && npm run build && npm run test" }, @@ -59,6 +61,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.37", "@openai/codex-sdk": "^0.98.0", + "@opencode-ai/sdk": "^1.1.53", "chalk": "^5.3.0", "commander": "^12.1.0", "update-notifier": "^7.3.1", diff --git a/src/__tests__/StreamDisplay.test.ts b/src/__tests__/StreamDisplay.test.ts index d0d7522e..488365a8 100644 --- a/src/__tests__/StreamDisplay.test.ts +++ b/src/__tests__/StreamDisplay.test.ts @@ -22,7 +22,7 @@ describe('StreamDisplay', () => { describe('progress info display', () => { const progressInfo: ProgressInfo = { iteration: 3, - maxIterations: 10, + maxMovements: 10, movementIndex: 1, totalMovements: 4, }; @@ -253,7 +253,7 @@ describe('StreamDisplay', () => { it('should format progress as (iteration/max) step index/total', () => { const progressInfo: ProgressInfo = { iteration: 5, - maxIterations: 20, + maxMovements: 20, movementIndex: 2, totalMovements: 6, }; @@ -267,7 +267,7 @@ describe('StreamDisplay', () => { it('should convert 0-indexed movementIndex to 1-indexed display', () => { const progressInfo: ProgressInfo = { iteration: 1, - maxIterations: 10, + maxMovements: 10, movementIndex: 0, // First movement (0-indexed) totalMovements: 4, }; diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 8c645a3b..ae79fbea 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -8,11 +8,6 @@ vi.mock('../features/interactive/index.js', () => ({ interactiveMode: vi.fn(), })); -vi.mock('../infra/config/global/globalConfig.js', () => ({ - loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })), - getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), -})); - vi.mock('../shared/prompt/index.js', () => ({ promptInput: vi.fn(), confirm: vi.fn(), @@ -23,6 +18,7 @@ vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), blankLine: vi.fn(), error: vi.fn(), + withProgress: vi.fn(async (_start, _done, operation) => operation()), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ @@ -38,15 +34,6 @@ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({ determinePiece: vi.fn(), })); -vi.mock('../infra/config/loaders/pieceResolver.js', () => ({ - getPieceDescription: vi.fn(() => ({ - name: 'default', - description: '', - pieceStructure: '1. implement\n2. review', - movementPreviews: [], - })), -})); - vi.mock('../infra/github/issue.js', () => ({ isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)), resolveIssueTask: vi.fn(), @@ -65,15 +52,17 @@ vi.mock('../infra/github/issue.js', () => ({ import { interactiveMode } from '../features/interactive/index.js'; import { promptInput, confirm } from '../shared/prompt/index.js'; +import { info } from '../shared/ui/index.js'; import { determinePiece } from '../features/tasks/execute/selectAndExecute.js'; import { resolveIssueTask } from '../infra/github/issue.js'; import { addTask } from '../features/tasks/index.js'; -const mockResolveIssueTask = vi.mocked(resolveIssueTask); const mockInteractiveMode = vi.mocked(interactiveMode); const mockPromptInput = vi.mocked(promptInput); const mockConfirm = vi.mocked(confirm); +const mockInfo = vi.mocked(info); const mockDeterminePiece = vi.mocked(determinePiece); +const mockResolveIssueTask = vi.mocked(resolveIssueTask); let testDir: string; @@ -96,23 +85,42 @@ afterEach(() => { }); describe('addTask', () => { - it('should create task entry from interactive result', async () => { - mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' }); + function readOrderContent(dir: string, taskDir: unknown): string { + return fs.readFileSync(path.join(dir, String(taskDir), 'order.md'), 'utf-8'); + } + it('should show usage and exit when task is missing', async () => { await addTask(testDir); - const tasks = loadTasks(testDir).tasks; - expect(tasks).toHaveLength(1); - expect(tasks[0]?.content).toContain('JWT認証を実装する'); - expect(tasks[0]?.piece).toBe('default'); + expect(mockInfo).toHaveBeenCalledWith('Usage: takt add '); + expect(mockDeterminePiece).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); + }); + + it('should show usage and exit when task is blank', async () => { + await addTask(testDir, ' '); + + expect(mockInfo).toHaveBeenCalledWith('Usage: takt add '); + expect(mockDeterminePiece).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); + }); + + it('should save plain text task without interactive mode', async () => { + await addTask(testDir, ' JWT認証を実装する '); + + expect(mockInteractiveMode).not.toHaveBeenCalled(); + const task = loadTasks(testDir).tasks[0]!; + expect(task.content).toBeUndefined(); + expect(task.task_dir).toBeTypeOf('string'); + expect(readOrderContent(testDir, task.task_dir)).toContain('JWT認証を実装する'); + expect(task.piece).toBe('default'); }); it('should include worktree settings when enabled', async () => { - mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'Task content' }); mockConfirm.mockResolvedValue(true); mockPromptInput.mockResolvedValueOnce('/custom/path').mockResolvedValueOnce('feat/branch'); - await addTask(testDir); + await addTask(testDir, 'Task content'); const task = loadTasks(testDir).tasks[0]!; expect(task.worktree).toBe('/custom/path'); @@ -121,20 +129,20 @@ describe('addTask', () => { it('should create task from issue reference without interactive mode', async () => { mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout'); - mockConfirm.mockResolvedValue(false); await addTask(testDir, '#99'); expect(mockInteractiveMode).not.toHaveBeenCalled(); const task = loadTasks(testDir).tasks[0]!; - expect(task.content).toContain('Fix login timeout'); + expect(task.content).toBeUndefined(); + expect(readOrderContent(testDir, task.task_dir)).toContain('Fix login timeout'); expect(task.issue).toBe(99); }); it('should not create task when piece selection is cancelled', async () => { mockDeterminePiece.mockResolvedValue(null); - await addTask(testDir); + await addTask(testDir, 'Task content'); expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); }); diff --git a/src/__tests__/apiKeyAuth.test.ts b/src/__tests__/apiKeyAuth.test.ts index dc418e2b..f21f5db9 100644 --- a/src/__tests__/apiKeyAuth.test.ts +++ b/src/__tests__/apiKeyAuth.test.ts @@ -32,7 +32,7 @@ vi.mock('../infra/config/paths.js', async (importOriginal) => { }); // Import after mocking -const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js'); +const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, resolveOpencodeApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js'); describe('GlobalConfigSchema API key fields', () => { it('should accept config without API keys', () => { @@ -280,3 +280,65 @@ describe('resolveOpenaiApiKey', () => { expect(key).toBeUndefined(); }); }); + +describe('resolveOpencodeApiKey', () => { + const originalEnv = process.env['TAKT_OPENCODE_API_KEY']; + + beforeEach(() => { + invalidateGlobalConfigCache(); + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['TAKT_OPENCODE_API_KEY'] = originalEnv; + } else { + delete process.env['TAKT_OPENCODE_API_KEY']; + } + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return env var when set', () => { + process.env['TAKT_OPENCODE_API_KEY'] = 'sk-opencode-from-env'; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + 'opencode_api_key: sk-opencode-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpencodeApiKey(); + expect(key).toBe('sk-opencode-from-env'); + }); + + it('should fall back to config when env var is not set', () => { + delete process.env['TAKT_OPENCODE_API_KEY']; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + 'opencode_api_key: sk-opencode-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpencodeApiKey(); + expect(key).toBe('sk-opencode-from-yaml'); + }); + + it('should return undefined when neither env var nor config is set', () => { + delete process.env['TAKT_OPENCODE_API_KEY']; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpencodeApiKey(); + expect(key).toBeUndefined(); + }); +}); diff --git a/src/__tests__/arpeggio-csv.test.ts b/src/__tests__/arpeggio-csv.test.ts new file mode 100644 index 00000000..9d517937 --- /dev/null +++ b/src/__tests__/arpeggio-csv.test.ts @@ -0,0 +1,136 @@ +/** + * Tests for CSV data source parsing and batch reading. + */ + +import { describe, it, expect } from 'vitest'; +import { parseCsv, CsvDataSource } from '../core/piece/arpeggio/csv-data-source.js'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; + +describe('parseCsv', () => { + it('should parse simple CSV content', () => { + const csv = 'name,age\nAlice,30\nBob,25'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['name', 'age'], + ['Alice', '30'], + ['Bob', '25'], + ]); + }); + + it('should handle quoted fields', () => { + const csv = 'name,description\nAlice,"Hello, World"\nBob,"Line1"'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['name', 'description'], + ['Alice', 'Hello, World'], + ['Bob', 'Line1'], + ]); + }); + + it('should handle escaped quotes (double quotes)', () => { + const csv = 'name,value\nAlice,"He said ""hello"""\nBob,simple'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['name', 'value'], + ['Alice', 'He said "hello"'], + ['Bob', 'simple'], + ]); + }); + + it('should handle CRLF line endings', () => { + const csv = 'name,age\r\nAlice,30\r\nBob,25'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['name', 'age'], + ['Alice', '30'], + ['Bob', '25'], + ]); + }); + + it('should handle bare CR line endings', () => { + const csv = 'name,age\rAlice,30\rBob,25'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['name', 'age'], + ['Alice', '30'], + ['Bob', '25'], + ]); + }); + + it('should handle empty fields', () => { + const csv = 'a,b,c\n1,,3\n,,'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['a', 'b', 'c'], + ['1', '', '3'], + ['', '', ''], + ]); + }); + + it('should handle newlines within quoted fields', () => { + const csv = 'name,bio\nAlice,"Line1\nLine2"\nBob,simple'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['name', 'bio'], + ['Alice', 'Line1\nLine2'], + ['Bob', 'simple'], + ]); + }); +}); + +describe('CsvDataSource', () => { + function createTempCsv(content: string): string { + const dir = join(tmpdir(), `takt-csv-test-${randomUUID()}`); + mkdirSync(dir, { recursive: true }); + const filePath = join(dir, 'test.csv'); + writeFileSync(filePath, content, 'utf-8'); + return filePath; + } + + it('should read batches with batch_size 1', async () => { + const filePath = createTempCsv('name,age\nAlice,30\nBob,25\nCharlie,35'); + const source = new CsvDataSource(filePath); + const batches = await source.readBatches(1); + + expect(batches).toHaveLength(3); + expect(batches[0]!.rows).toEqual([{ name: 'Alice', age: '30' }]); + expect(batches[0]!.batchIndex).toBe(0); + expect(batches[0]!.totalBatches).toBe(3); + expect(batches[1]!.rows).toEqual([{ name: 'Bob', age: '25' }]); + expect(batches[2]!.rows).toEqual([{ name: 'Charlie', age: '35' }]); + }); + + it('should read batches with batch_size 2', async () => { + const filePath = createTempCsv('name,age\nAlice,30\nBob,25\nCharlie,35'); + const source = new CsvDataSource(filePath); + const batches = await source.readBatches(2); + + expect(batches).toHaveLength(2); + expect(batches[0]!.rows).toEqual([ + { name: 'Alice', age: '30' }, + { name: 'Bob', age: '25' }, + ]); + expect(batches[0]!.totalBatches).toBe(2); + expect(batches[1]!.rows).toEqual([ + { name: 'Charlie', age: '35' }, + ]); + }); + + it('should throw when CSV has no data rows', async () => { + const filePath = createTempCsv('name,age'); + const source = new CsvDataSource(filePath); + await expect(source.readBatches(1)).rejects.toThrow('CSV file has no data rows'); + }); + + it('should handle missing columns by returning empty string', async () => { + const filePath = createTempCsv('a,b,c\n1,2\n3'); + const source = new CsvDataSource(filePath); + const batches = await source.readBatches(1); + + expect(batches[0]!.rows).toEqual([{ a: '1', b: '2', c: '' }]); + expect(batches[1]!.rows).toEqual([{ a: '3', b: '', c: '' }]); + }); +}); diff --git a/src/__tests__/arpeggio-data-source-factory.test.ts b/src/__tests__/arpeggio-data-source-factory.test.ts new file mode 100644 index 00000000..b3bc896c --- /dev/null +++ b/src/__tests__/arpeggio-data-source-factory.test.ts @@ -0,0 +1,50 @@ +/** + * Tests for the arpeggio data source factory. + * + * Covers: + * - Built-in 'csv' source type returns CsvDataSource + * - Custom module: valid default export returns a data source + * - Custom module: non-function default export throws + * - Custom module: missing default export throws + */ + +import { describe, it, expect } from 'vitest'; +import { createDataSource } from '../core/piece/arpeggio/data-source-factory.js'; +import { CsvDataSource } from '../core/piece/arpeggio/csv-data-source.js'; + +describe('createDataSource', () => { + it('should return a CsvDataSource for built-in "csv" type', async () => { + const source = await createDataSource('csv', '/path/to/data.csv'); + expect(source).toBeInstanceOf(CsvDataSource); + }); + + it('should return a valid data source from a custom module with correct default export', async () => { + const tempModulePath = new URL( + 'data:text/javascript,export default function(path) { return { readBatches: async () => [] }; }' + ).href; + + const source = await createDataSource(tempModulePath, '/some/path'); + expect(source).toBeDefined(); + expect(typeof source.readBatches).toBe('function'); + }); + + it('should throw when custom module does not export a default function', async () => { + const tempModulePath = new URL( + 'data:text/javascript,export default "not-a-function"' + ).href; + + await expect(createDataSource(tempModulePath, '/some/path')).rejects.toThrow( + /must export a default factory function/ + ); + }); + + it('should throw when custom module has no default export', async () => { + const tempModulePath = new URL( + 'data:text/javascript,export const foo = 42' + ).href; + + await expect(createDataSource(tempModulePath, '/some/path')).rejects.toThrow( + /must export a default factory function/ + ); + }); +}); diff --git a/src/__tests__/arpeggio-merge.test.ts b/src/__tests__/arpeggio-merge.test.ts new file mode 100644 index 00000000..a27f8c56 --- /dev/null +++ b/src/__tests__/arpeggio-merge.test.ts @@ -0,0 +1,108 @@ +/** + * Tests for arpeggio merge processing. + */ + +import { describe, it, expect } from 'vitest'; +import { buildMergeFn } from '../core/piece/arpeggio/merge.js'; +import type { ArpeggioMergeMovementConfig } from '../core/piece/arpeggio/types.js'; +import type { BatchResult } from '../core/piece/arpeggio/types.js'; + +function makeResult(batchIndex: number, content: string, success = true): BatchResult { + return { batchIndex, content, success }; +} + +function makeFailedResult(batchIndex: number, error: string): BatchResult { + return { batchIndex, content: '', success: false, error }; +} + +describe('buildMergeFn', () => { + describe('concat strategy', () => { + it('should concatenate results with default separator (newline)', async () => { + const config: ArpeggioMergeMovementConfig = { strategy: 'concat' }; + const mergeFn = await buildMergeFn(config); + const results = [ + makeResult(0, 'Result A'), + makeResult(1, 'Result B'), + makeResult(2, 'Result C'), + ]; + expect(mergeFn(results)).toBe('Result A\nResult B\nResult C'); + }); + + it('should concatenate results with custom separator', async () => { + const config: ArpeggioMergeMovementConfig = { strategy: 'concat', separator: '\n---\n' }; + const mergeFn = await buildMergeFn(config); + const results = [ + makeResult(0, 'A'), + makeResult(1, 'B'), + ]; + expect(mergeFn(results)).toBe('A\n---\nB'); + }); + + it('should sort results by batch index', async () => { + const config: ArpeggioMergeMovementConfig = { strategy: 'concat' }; + const mergeFn = await buildMergeFn(config); + const results = [ + makeResult(2, 'C'), + makeResult(0, 'A'), + makeResult(1, 'B'), + ]; + expect(mergeFn(results)).toBe('A\nB\nC'); + }); + + it('should filter out failed results', async () => { + const config: ArpeggioMergeMovementConfig = { strategy: 'concat' }; + const mergeFn = await buildMergeFn(config); + const results = [ + makeResult(0, 'A'), + makeFailedResult(1, 'oops'), + makeResult(2, 'C'), + ]; + expect(mergeFn(results)).toBe('A\nC'); + }); + + it('should return empty string when all results failed', async () => { + const config: ArpeggioMergeMovementConfig = { strategy: 'concat' }; + const mergeFn = await buildMergeFn(config); + const results = [ + makeFailedResult(0, 'error1'), + makeFailedResult(1, 'error2'), + ]; + expect(mergeFn(results)).toBe(''); + }); + }); + + describe('custom strategy with inline_js', () => { + it('should execute inline JS merge function', async () => { + const config: ArpeggioMergeMovementConfig = { + strategy: 'custom', + inlineJs: 'return results.filter(r => r.success).map(r => r.content.toUpperCase()).join(", ");', + }; + const mergeFn = await buildMergeFn(config); + const results = [ + makeResult(0, 'hello'), + makeResult(1, 'world'), + ]; + expect(mergeFn(results)).toBe('HELLO, WORLD'); + }); + + it('should throw when inline JS returns non-string', async () => { + const config: ArpeggioMergeMovementConfig = { + strategy: 'custom', + inlineJs: 'return 42;', + }; + const mergeFn = await buildMergeFn(config); + expect(() => mergeFn([makeResult(0, 'test')])).toThrow( + 'Inline JS merge function must return a string, got number' + ); + }); + }); + + describe('custom strategy validation', () => { + it('should throw when custom strategy has neither inline_js nor file', async () => { + const config: ArpeggioMergeMovementConfig = { strategy: 'custom' }; + await expect(buildMergeFn(config)).rejects.toThrow( + 'Custom merge strategy requires either inline_js or file path' + ); + }); + }); +}); diff --git a/src/__tests__/arpeggio-schema.test.ts b/src/__tests__/arpeggio-schema.test.ts new file mode 100644 index 00000000..e121945b --- /dev/null +++ b/src/__tests__/arpeggio-schema.test.ts @@ -0,0 +1,332 @@ +/** + * Tests for Arpeggio-related Zod schemas. + * + * Covers: + * - ArpeggioMergeRawSchema cross-validation (.refine()) + * - ArpeggioConfigRawSchema required fields and defaults + * - PieceMovementRawSchema with arpeggio field + */ + +import { describe, it, expect } from 'vitest'; +import { + ArpeggioMergeRawSchema, + ArpeggioConfigRawSchema, + PieceMovementRawSchema, +} from '../core/models/index.js'; + +describe('ArpeggioMergeRawSchema', () => { + it('should accept concat strategy without inline_js or file', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'concat', + }); + expect(result.success).toBe(true); + }); + + it('should accept concat strategy with separator', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'concat', + separator: '\n---\n', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.separator).toBe('\n---\n'); + } + }); + + it('should default strategy to concat when omitted', () => { + const result = ArpeggioMergeRawSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.strategy).toBe('concat'); + } + }); + + it('should accept custom strategy with inline_js', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'custom', + inline_js: 'return results.map(r => r.content).join(",");', + }); + expect(result.success).toBe(true); + }); + + it('should accept custom strategy with file', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'custom', + file: './merge.js', + }); + expect(result.success).toBe(true); + }); + + it('should reject custom strategy without inline_js or file', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'custom', + }); + expect(result.success).toBe(false); + }); + + it('should reject concat strategy with inline_js', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'concat', + inline_js: 'return "hello";', + }); + expect(result.success).toBe(false); + }); + + it('should reject concat strategy with file', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'concat', + file: './merge.js', + }); + expect(result.success).toBe(false); + }); + + it('should reject invalid strategy value', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'invalid', + }); + expect(result.success).toBe(false); + }); +}); + +describe('ArpeggioConfigRawSchema', () => { + const validConfig = { + source: 'csv', + source_path: './data.csv', + template: './template.md', + }; + + it('should accept a valid minimal config', () => { + const result = ArpeggioConfigRawSchema.safeParse(validConfig); + expect(result.success).toBe(true); + }); + + it('should apply default values for optional fields', () => { + const result = ArpeggioConfigRawSchema.safeParse(validConfig); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.batch_size).toBe(1); + expect(result.data.concurrency).toBe(1); + expect(result.data.max_retries).toBe(2); + expect(result.data.retry_delay_ms).toBe(1000); + } + }); + + it('should accept explicit values overriding defaults', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + batch_size: 5, + concurrency: 3, + max_retries: 4, + retry_delay_ms: 2000, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.batch_size).toBe(5); + expect(result.data.concurrency).toBe(3); + expect(result.data.max_retries).toBe(4); + expect(result.data.retry_delay_ms).toBe(2000); + } + }); + + it('should accept config with merge field', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + merge: { strategy: 'concat', separator: '---' }, + }); + expect(result.success).toBe(true); + }); + + it('should accept config with output_path', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + output_path: './output.txt', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.output_path).toBe('./output.txt'); + } + }); + + it('should reject when source is empty', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + source: '', + }); + expect(result.success).toBe(false); + }); + + it('should reject when source is missing', () => { + const { source: _, ...noSource } = validConfig; + const result = ArpeggioConfigRawSchema.safeParse(noSource); + expect(result.success).toBe(false); + }); + + it('should reject when source_path is empty', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + source_path: '', + }); + expect(result.success).toBe(false); + }); + + it('should reject when source_path is missing', () => { + const { source_path: _, ...noSourcePath } = validConfig; + const result = ArpeggioConfigRawSchema.safeParse(noSourcePath); + expect(result.success).toBe(false); + }); + + it('should reject when template is empty', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + template: '', + }); + expect(result.success).toBe(false); + }); + + it('should reject when template is missing', () => { + const { template: _, ...noTemplate } = validConfig; + const result = ArpeggioConfigRawSchema.safeParse(noTemplate); + expect(result.success).toBe(false); + }); + + it('should reject batch_size of 0', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + batch_size: 0, + }); + expect(result.success).toBe(false); + }); + + it('should reject negative batch_size', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + batch_size: -1, + }); + expect(result.success).toBe(false); + }); + + it('should reject concurrency of 0', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + concurrency: 0, + }); + expect(result.success).toBe(false); + }); + + it('should reject negative concurrency', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + concurrency: -1, + }); + expect(result.success).toBe(false); + }); + + it('should reject negative max_retries', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + max_retries: -1, + }); + expect(result.success).toBe(false); + }); + + it('should accept max_retries of 0', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + max_retries: 0, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.max_retries).toBe(0); + } + }); + + it('should reject non-integer batch_size', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + batch_size: 1.5, + }); + expect(result.success).toBe(false); + }); +}); + +describe('PieceMovementRawSchema with arpeggio', () => { + it('should accept a movement with arpeggio config', () => { + const raw = { + name: 'batch-process', + arpeggio: { + source: 'csv', + source_path: './data.csv', + template: './prompt.md', + }, + }; + + const result = PieceMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.arpeggio).toBeDefined(); + expect(result.data.arpeggio!.source).toBe('csv'); + } + }); + + it('should accept a movement with arpeggio and rules', () => { + const raw = { + name: 'batch-process', + arpeggio: { + source: 'csv', + source_path: './data.csv', + template: './prompt.md', + batch_size: 2, + concurrency: 3, + }, + rules: [ + { condition: 'All processed', next: 'COMPLETE' }, + { condition: 'Errors found', next: 'fix' }, + ], + }; + + const result = PieceMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.arpeggio!.batch_size).toBe(2); + expect(result.data.arpeggio!.concurrency).toBe(3); + expect(result.data.rules).toHaveLength(2); + } + }); + + it('should accept a movement without arpeggio (normal movement)', () => { + const raw = { + name: 'normal-step', + persona: 'coder.md', + instruction_template: 'Do work', + }; + + const result = PieceMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.arpeggio).toBeUndefined(); + } + }); + + it('should accept a movement with arpeggio including custom merge', () => { + const raw = { + name: 'custom-merge-step', + arpeggio: { + source: 'csv', + source_path: './data.csv', + template: './prompt.md', + merge: { + strategy: 'custom', + inline_js: 'return results.map(r => r.content).join(", ");', + }, + output_path: './output.txt', + }, + }; + + const result = PieceMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.arpeggio!.merge).toBeDefined(); + expect(result.data.arpeggio!.output_path).toBe('./output.txt'); + } + }); +}); diff --git a/src/__tests__/arpeggio-template.test.ts b/src/__tests__/arpeggio-template.test.ts new file mode 100644 index 00000000..1fcc8d38 --- /dev/null +++ b/src/__tests__/arpeggio-template.test.ts @@ -0,0 +1,83 @@ +/** + * Tests for arpeggio template expansion. + */ + +import { describe, it, expect } from 'vitest'; +import { expandTemplate } from '../core/piece/arpeggio/template.js'; +import type { DataBatch } from '../core/piece/arpeggio/types.js'; + +function makeBatch(rows: Record[], batchIndex = 0, totalBatches = 1): DataBatch { + return { rows, batchIndex, totalBatches }; +} + +describe('expandTemplate', () => { + it('should expand {line:1} with formatted row data', () => { + const batch = makeBatch([{ name: 'Alice', age: '30' }]); + const result = expandTemplate('Process this: {line:1}', batch); + expect(result).toBe('Process this: name: Alice\nage: 30'); + }); + + it('should expand {line:1} and {line:2} for multi-row batches', () => { + const batch = makeBatch([ + { name: 'Alice', age: '30' }, + { name: 'Bob', age: '25' }, + ]); + const result = expandTemplate('Row 1: {line:1}\nRow 2: {line:2}', batch); + expect(result).toBe('Row 1: name: Alice\nage: 30\nRow 2: name: Bob\nage: 25'); + }); + + it('should expand {col:N:name} with specific column values', () => { + const batch = makeBatch([{ name: 'Alice', age: '30', city: 'Tokyo' }]); + const result = expandTemplate('Name: {col:1:name}, City: {col:1:city}', batch); + expect(result).toBe('Name: Alice, City: Tokyo'); + }); + + it('should expand {batch_index} and {total_batches}', () => { + const batch = makeBatch([{ name: 'Alice' }], 2, 5); + const result = expandTemplate('Batch {batch_index} of {total_batches}', batch); + expect(result).toBe('Batch 2 of 5'); + }); + + it('should expand all placeholder types in a single template', () => { + const batch = makeBatch([ + { name: 'Alice', role: 'dev' }, + { name: 'Bob', role: 'pm' }, + ], 0, 3); + const template = 'Batch {batch_index}/{total_batches}\nFirst: {col:1:name}\nSecond: {line:2}'; + const result = expandTemplate(template, batch); + expect(result).toBe('Batch 0/3\nFirst: Alice\nSecond: name: Bob\nrole: pm'); + }); + + it('should throw when {line:N} references out-of-range row', () => { + const batch = makeBatch([{ name: 'Alice' }]); + expect(() => expandTemplate('{line:2}', batch)).toThrow( + 'Template placeholder {line:2} references row 2 but batch has 1 rows' + ); + }); + + it('should throw when {col:N:name} references out-of-range row', () => { + const batch = makeBatch([{ name: 'Alice' }]); + expect(() => expandTemplate('{col:2:name}', batch)).toThrow( + 'Template placeholder {col:2:name} references row 2 but batch has 1 rows' + ); + }); + + it('should throw when {col:N:name} references unknown column', () => { + const batch = makeBatch([{ name: 'Alice' }]); + expect(() => expandTemplate('{col:1:missing}', batch)).toThrow( + 'Template placeholder {col:1:missing} references unknown column "missing"' + ); + }); + + it('should handle templates with no placeholders', () => { + const batch = makeBatch([{ name: 'Alice' }]); + const result = expandTemplate('No placeholders here', batch); + expect(result).toBe('No placeholders here'); + }); + + it('should handle multiple occurrences of the same placeholder', () => { + const batch = makeBatch([{ name: 'Alice' }], 1, 3); + const result = expandTemplate('{batch_index} and {batch_index}', batch); + expect(result).toBe('1 and 1'); + }); +}); diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index 41c53b18..1ba690b6 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -11,6 +11,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), error: vi.fn(), + withProgress: vi.fn(async (_start, _done, operation) => operation()), +})); + +vi.mock('../shared/prompt/index.js', () => ({ + confirm: vi.fn(() => true), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ @@ -46,6 +51,7 @@ vi.mock('../features/pipeline/index.js', () => ({ vi.mock('../features/interactive/index.js', () => ({ interactiveMode: vi.fn(), selectInteractiveMode: vi.fn(() => 'assistant'), + selectRecentSession: vi.fn(() => null), passthroughMode: vi.fn(), quietMode: vi.fn(), personaMode: vi.fn(), @@ -83,8 +89,10 @@ vi.mock('../app/cli/helpers.js', () => ({ })); import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; -import { selectAndExecuteTask, determinePiece } from '../features/tasks/index.js'; -import { interactiveMode } from '../features/interactive/index.js'; +import { selectAndExecuteTask, determinePiece, createIssueFromTask, saveTaskFromInteractive } from '../features/tasks/index.js'; +import { interactiveMode, selectRecentSession } from '../features/interactive/index.js'; +import { loadGlobalConfig } from '../infra/config/index.js'; +import { confirm } from '../shared/prompt/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; import { executeDefaultAction } from '../app/cli/routing.js'; import type { GitHubIssue } from '../infra/github/types.js'; @@ -95,7 +103,12 @@ const mockFormatIssueAsTask = vi.mocked(formatIssueAsTask); const mockParseIssueNumbers = vi.mocked(parseIssueNumbers); const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask); const mockDeterminePiece = vi.mocked(determinePiece); +const mockCreateIssueFromTask = vi.mocked(createIssueFromTask); +const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive); const mockInteractiveMode = vi.mocked(interactiveMode); +const mockSelectRecentSession = vi.mocked(selectRecentSession); +const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); +const mockConfirm = vi.mocked(confirm); const mockIsDirectTask = vi.mocked(isDirectTask); function createMockIssue(number: number): GitHubIssue { @@ -117,6 +130,7 @@ beforeEach(() => { // Default setup mockDeterminePiece.mockResolvedValue('default'); mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' }); + mockConfirm.mockResolvedValue(true); mockIsDirectTask.mockReturnValue(false); mockParseIssueNumbers.mockReturnValue([]); }); @@ -142,6 +156,7 @@ describe('Issue resolution in routing', () => { '/test/cwd', '## GitHub Issue #131: Issue #131', expect.anything(), + undefined, ); // Then: selectAndExecuteTask should receive issues in options @@ -194,6 +209,7 @@ describe('Issue resolution in routing', () => { '/test/cwd', '## GitHub Issue #131: Issue #131', expect.anything(), + undefined, ); // Then: selectAndExecuteTask should receive issues @@ -218,6 +234,7 @@ describe('Issue resolution in routing', () => { '/test/cwd', 'refactor the code', expect.anything(), + undefined, ); // Then: no issue fetching should occur @@ -237,6 +254,7 @@ describe('Issue resolution in routing', () => { '/test/cwd', undefined, expect.anything(), + undefined, ); // Then: no issue fetching should occur @@ -261,4 +279,112 @@ describe('Issue resolution in routing', () => { expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); }); }); + + describe('create_issue action', () => { + it('should create issue first, then delegate final confirmation to saveTaskFromInteractive', async () => { + // Given + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); + mockCreateIssueFromTask.mockReturnValue(226); + + // When + await executeDefaultAction(); + + // Then: issue is created first + expect(mockCreateIssueFromTask).toHaveBeenCalledWith('New feature request'); + // Then: saveTaskFromInteractive receives final confirmation message + expect(mockSaveTaskFromInteractive).toHaveBeenCalledWith( + '/test/cwd', + 'New feature request', + 'default', + { issue: 226, confirmAtEndMessage: 'Add this issue to tasks?' }, + ); + }); + + it('should skip confirmation and task save when issue creation fails', async () => { + // Given + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); + mockCreateIssueFromTask.mockReturnValue(undefined); + + // When + await executeDefaultAction(); + + // Then + expect(mockCreateIssueFromTask).toHaveBeenCalledWith('New feature request'); + expect(mockSaveTaskFromInteractive).not.toHaveBeenCalled(); + }); + + it('should not call selectAndExecuteTask when create_issue action is chosen', async () => { + // Given + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); + + // When + await executeDefaultAction(); + + // Then: selectAndExecuteTask should NOT be called + expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); + }); + }); + + describe('session selection with provider=claude', () => { + it('should pass selected session ID to interactiveMode when provider is claude', async () => { + // Given + mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); + mockConfirm.mockResolvedValue(true); + mockSelectRecentSession.mockResolvedValue('session-xyz'); + + // When + await executeDefaultAction(); + + // Then: selectRecentSession should be called + expect(mockSelectRecentSession).toHaveBeenCalledWith('/test/cwd', 'en'); + + // Then: interactiveMode should receive the session ID as 4th argument + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + undefined, + expect.anything(), + 'session-xyz', + ); + + expect(mockConfirm).toHaveBeenCalledWith('Choose a previous session?', false); + }); + + it('should not call selectRecentSession when user selects no in confirmation', async () => { + // Given + mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); + mockConfirm.mockResolvedValue(false); + + // When + await executeDefaultAction(); + + // Then + expect(mockConfirm).toHaveBeenCalledWith('Choose a previous session?', false); + expect(mockSelectRecentSession).not.toHaveBeenCalled(); + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + undefined, + expect.anything(), + undefined, + ); + }); + + it('should not call selectRecentSession when provider is not claude', async () => { + // Given + mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'openai' }); + + // When + await executeDefaultAction(); + + // Then: selectRecentSession should NOT be called + expect(mockSelectRecentSession).not.toHaveBeenCalled(); + + // Then: interactiveMode should be called with undefined session ID + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + undefined, + expect.anything(), + undefined, + ); + }); + }); }); diff --git a/src/__tests__/cli-worktree.test.ts b/src/__tests__/cli-worktree.test.ts index cd09d5eb..a8fbfa5e 100644 --- a/src/__tests__/cli-worktree.test.ts +++ b/src/__tests__/cli-worktree.test.ts @@ -28,14 +28,23 @@ vi.mock('../infra/task/summarize.js', () => ({ summarizeTaskName: vi.fn(), })); -vi.mock('../shared/ui/index.js', () => ({ - info: vi.fn(), - error: vi.fn(), - success: vi.fn(), - header: vi.fn(), - status: vi.fn(), - setLogLevel: vi.fn(), -})); +vi.mock('../shared/ui/index.js', () => { + const info = vi.fn(); + return { + info, + error: vi.fn(), + success: vi.fn(), + header: vi.fn(), + status: vi.fn(), + setLogLevel: vi.fn(), + withProgress: vi.fn(async (start, done, operation) => { + info(start); + const result = await operation(); + info(typeof done === 'function' ? done(result) : done); + return result; + }), + }; +}); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), @@ -199,6 +208,7 @@ describe('confirmAndCreateWorktree', () => { // Then expect(mockInfo).toHaveBeenCalledWith('Generating branch name...'); + expect(mockInfo).toHaveBeenCalledWith('Branch name generated: test-task'); }); it('should skip prompt when override is false', async () => { diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index e628d92f..5c2f910c 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -188,7 +188,7 @@ describe('loadAllPieces', () => { const samplePiece = ` name: test-piece description: Test piece -max_iterations: 10 +max_movements: 10 movements: - name: step1 persona: coder diff --git a/src/__tests__/createIssueFromTask.test.ts b/src/__tests__/createIssueFromTask.test.ts index 2d15a875..e31d3ed8 100644 --- a/src/__tests__/createIssueFromTask.test.ts +++ b/src/__tests__/createIssueFromTask.test.ts @@ -114,6 +114,42 @@ describe('createIssueFromTask', () => { expect(mockSuccess).not.toHaveBeenCalled(); }); + describe('return value', () => { + it('should return issue number when creation succeeds', () => { + // Given + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/42' }); + + // When + const result = createIssueFromTask('Test task'); + + // Then + expect(result).toBe(42); + }); + + it('should return undefined when creation fails', () => { + // Given + mockCreateIssue.mockReturnValue({ success: false, error: 'auth failed' }); + + // When + const result = createIssueFromTask('Test task'); + + // Then + expect(result).toBeUndefined(); + }); + + it('should return undefined and display error when URL has non-numeric suffix', () => { + // Given + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/abc' }); + + // When + const result = createIssueFromTask('Test task'); + + // Then + expect(result).toBeUndefined(); + expect(mockError).toHaveBeenCalledWith('Failed to extract issue number from URL'); + }); + }); + it('should use first line as title and full text as body for multi-line task', () => { // Given: multi-line task const task = 'First line title\nSecond line details\nThird line more info'; diff --git a/src/__tests__/debug.test.ts b/src/__tests__/debug.test.ts index 05aea164..7e80bdf0 100644 --- a/src/__tests__/debug.test.ts +++ b/src/__tests__/debug.test.ts @@ -63,7 +63,7 @@ describe('debug logging', () => { } }); - it('should write debug log to project .takt/logs/ directory', () => { + it('should write debug log to project .takt/runs/*/logs/ directory', () => { const projectDir = join(tmpdir(), 'takt-test-debug-project-' + Date.now()); mkdirSync(projectDir, { recursive: true }); @@ -71,7 +71,9 @@ describe('debug logging', () => { initDebugLogger({ enabled: true }, projectDir); const logFile = getDebugLogFile(); expect(logFile).not.toBeNull(); - expect(logFile!).toContain(join(projectDir, '.takt', 'logs')); + expect(logFile!).toContain(join(projectDir, '.takt', 'runs')); + expect(logFile!).toContain(`${join(projectDir, '.takt', 'runs')}/`); + expect(logFile!).toContain('/logs/'); expect(logFile!).toMatch(/debug-.*\.log$/); expect(existsSync(logFile!)).toBe(true); } finally { @@ -86,7 +88,8 @@ describe('debug logging', () => { try { initDebugLogger({ enabled: true }, projectDir); const promptsLogFile = resolvePromptsLogFilePath(); - expect(promptsLogFile).toContain(join(projectDir, '.takt', 'logs')); + expect(promptsLogFile).toContain(join(projectDir, '.takt', 'runs')); + expect(promptsLogFile).toContain('/logs/'); expect(promptsLogFile).toMatch(/debug-.*-prompts\.jsonl$/); expect(existsSync(promptsLogFile)).toBe(true); } finally { diff --git a/src/__tests__/e2e-helpers.test.ts b/src/__tests__/e2e-helpers.test.ts index c94124e5..63b395d2 100644 --- a/src/__tests__/e2e-helpers.test.ts +++ b/src/__tests__/e2e-helpers.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, afterEach } from 'vitest'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { parse as parseYaml } from 'yaml'; import { injectProviderArgs } from '../../e2e/helpers/takt-runner.js'; -import { createIsolatedEnv } from '../../e2e/helpers/isolated-env.js'; +import { + createIsolatedEnv, + updateIsolatedConfig, +} from '../../e2e/helpers/isolated-env.js'; describe('injectProviderArgs', () => { it('should prepend --provider when provider is specified', () => { @@ -70,4 +75,112 @@ describe('createIsolatedEnv', () => { expect(isolated.env.GIT_CONFIG_GLOBAL).toBeDefined(); expect(isolated.env.GIT_CONFIG_GLOBAL).toContain('takt-e2e-'); }); + + it('should create config.yaml from E2E fixture with notification_sound timing controls', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8'); + const config = parseYaml(configRaw) as Record; + + expect(config.language).toBe('en'); + expect(config.log_level).toBe('info'); + expect(config.default_piece).toBe('default'); + expect(config.notification_sound).toBe(true); + expect(config.notification_sound_events).toEqual({ + iteration_limit: false, + piece_complete: false, + piece_abort: false, + run_complete: true, + run_abort: true, + }); + }); + + it('should override provider in config.yaml when TAKT_E2E_PROVIDER is set', () => { + process.env = { ...originalEnv, TAKT_E2E_PROVIDER: 'mock' }; + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8'); + const config = parseYaml(configRaw) as Record; + expect(config.provider).toBe('mock'); + }); + + it('should preserve base settings when updateIsolatedConfig applies patch', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + updateIsolatedConfig(isolated.taktDir, { + provider: 'mock', + concurrency: 2, + }); + + const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8'); + const config = parseYaml(configRaw) as Record; + + expect(config.provider).toBe('mock'); + expect(config.concurrency).toBe(2); + expect(config.notification_sound).toBe(true); + expect(config.notification_sound_events).toEqual({ + iteration_limit: false, + piece_complete: false, + piece_abort: false, + run_complete: true, + run_abort: true, + }); + expect(config.language).toBe('en'); + }); + + it('should deep-merge notification_sound_events patch and preserve unspecified keys', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + updateIsolatedConfig(isolated.taktDir, { + notification_sound_events: { + run_complete: false, + }, + }); + + const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8'); + const config = parseYaml(configRaw) as Record; + + expect(config.notification_sound_events).toEqual({ + iteration_limit: false, + piece_complete: false, + piece_abort: false, + run_complete: false, + run_abort: true, + }); + }); + + it('should throw when patch.notification_sound_events is not an object', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + expect(() => { + updateIsolatedConfig(isolated.taktDir, { + notification_sound_events: true, + }); + }).toThrow('Invalid notification_sound_events in patch: expected object'); + }); + + it('should throw when current config notification_sound_events is invalid', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + writeFileSync( + `${isolated.taktDir}/config.yaml`, + [ + 'language: en', + 'log_level: info', + 'default_piece: default', + 'notification_sound: true', + 'notification_sound_events: true', + ].join('\n'), + ); + + expect(() => { + updateIsolatedConfig(isolated.taktDir, { provider: 'mock' }); + }).toThrow('Invalid notification_sound_events in current config: expected object'); + }); }); diff --git a/src/__tests__/engine-abort.test.ts b/src/__tests__/engine-abort.test.ts index 04cb66bf..dae845ca 100644 --- a/src/__tests__/engine-abort.test.ts +++ b/src/__tests__/engine-abort.test.ts @@ -65,7 +65,7 @@ describe('PieceEngine: Abort (SIGINT)', () => { function makeSimpleConfig(): PieceConfig { return { name: 'test', - maxIterations: 10, + maxMovements: 10, initialMovement: 'step1', movements: [ makeMovement('step1', { diff --git a/src/__tests__/engine-agent-overrides.test.ts b/src/__tests__/engine-agent-overrides.test.ts index 6ba737fd..4ba823cb 100644 --- a/src/__tests__/engine-agent-overrides.test.ts +++ b/src/__tests__/engine-agent-overrides.test.ts @@ -54,7 +54,7 @@ describe('PieceEngine agent overrides', () => { name: 'override-test', movements: [movement], initialMovement: 'plan', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ @@ -83,7 +83,7 @@ describe('PieceEngine agent overrides', () => { name: 'override-fallback', movements: [movement], initialMovement: 'plan', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ @@ -114,7 +114,7 @@ describe('PieceEngine agent overrides', () => { name: 'movement-defaults', movements: [movement], initialMovement: 'plan', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ diff --git a/src/__tests__/engine-arpeggio.test.ts b/src/__tests__/engine-arpeggio.test.ts new file mode 100644 index 00000000..3523c60f --- /dev/null +++ b/src/__tests__/engine-arpeggio.test.ts @@ -0,0 +1,282 @@ +/** + * Integration tests for arpeggio movement execution via PieceEngine. + * + * Tests the full pipeline: CSV → template expansion → LLM → merge → rule evaluation. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +// Mock external dependencies before importing +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../core/piece/evaluation/index.js', () => ({ + detectMatchedRule: vi.fn(), + evaluateAggregateConditions: vi.fn(), +})); + +vi.mock('../core/piece/phase-runner.js', () => ({ + needsStatusJudgmentPhase: vi.fn().mockReturnValue(false), + runReportPhase: vi.fn().mockResolvedValue(undefined), + runStatusJudgmentPhase: vi.fn().mockResolvedValue(''), +})); + +vi.mock('../shared/utils/index.js', async () => { + const actual = await vi.importActual('../shared/utils/index.js'); + return { + ...actual, + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), + }; +}); + +import { runAgent } from '../agents/runner.js'; +import { detectMatchedRule } from '../core/piece/evaluation/index.js'; +import { PieceEngine } from '../core/piece/engine/PieceEngine.js'; +import type { PieceConfig, PieceMovement, AgentResponse, ArpeggioMovementConfig } from '../core/models/index.js'; +import type { PieceEngineOptions } from '../core/piece/types.js'; +import { + makeResponse, + makeMovement, + makeRule, + createTestTmpDir, + cleanupPieceEngine, +} from './engine-test-helpers.js'; +import type { RuleMatch } from '../core/piece/index.js'; + +function createArpeggioTestDir(): { tmpDir: string; csvPath: string; templatePath: string } { + const tmpDir = createTestTmpDir(); + const csvPath = join(tmpDir, 'data.csv'); + const templatePath = join(tmpDir, 'template.md'); + + writeFileSync(csvPath, 'name,task\nAlice,review\nBob,implement\nCharlie,test', 'utf-8'); + writeFileSync(templatePath, 'Process {line:1}', 'utf-8'); + + return { tmpDir, csvPath, templatePath }; +} + +function createArpeggioConfig(csvPath: string, templatePath: string, overrides: Partial = {}): ArpeggioMovementConfig { + return { + source: 'csv', + sourcePath: csvPath, + batchSize: 1, + concurrency: 1, + templatePath, + merge: { strategy: 'concat' }, + maxRetries: 0, + retryDelayMs: 0, + ...overrides, + }; +} + +function buildArpeggioPieceConfig(arpeggioConfig: ArpeggioMovementConfig, tmpDir: string): PieceConfig { + return { + name: 'test-arpeggio', + description: 'Test arpeggio piece', + maxMovements: 10, + initialMovement: 'process', + movements: [ + { + ...makeMovement('process', { + rules: [ + makeRule('Processing complete', 'COMPLETE'), + makeRule('Processing failed', 'ABORT'), + ], + }), + arpeggio: arpeggioConfig, + }, + ], + }; +} + +function createEngineOptions(tmpDir: string): PieceEngineOptions { + return { + projectCwd: tmpDir, + reportDirName: 'test-report-dir', + detectRuleIndex: () => 0, + callAiJudge: async () => 0, + }; +} + +describe('ArpeggioRunner integration', () => { + let engine: PieceEngine | undefined; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(detectMatchedRule).mockResolvedValue(undefined); + }); + + afterEach(() => { + if (engine) { + cleanupPieceEngine(engine); + engine = undefined; + } + }); + + it('should process CSV data and merge results', async () => { + const { tmpDir, csvPath, templatePath } = createArpeggioTestDir(); + const arpeggioConfig = createArpeggioConfig(csvPath, templatePath); + const config = buildArpeggioPieceConfig(arpeggioConfig, tmpDir); + + // Mock agent to return batch-specific responses + const mockAgent = vi.mocked(runAgent); + mockAgent + .mockResolvedValueOnce(makeResponse({ content: 'Processed Alice' })) + .mockResolvedValueOnce(makeResponse({ content: 'Processed Bob' })) + .mockResolvedValueOnce(makeResponse({ content: 'Processed Charlie' })); + + // Mock rule detection for the merged result + vi.mocked(detectMatchedRule).mockResolvedValueOnce({ + index: 0, + method: 'phase1_tag', + }); + + engine = new PieceEngine(config, tmpDir, 'test task', createEngineOptions(tmpDir)); + const state = await engine.run(); + + expect(state.status).toBe('completed'); + expect(mockAgent).toHaveBeenCalledTimes(3); + + // Verify merged content in movement output + const output = state.movementOutputs.get('process'); + expect(output).toBeDefined(); + expect(output!.content).toBe('Processed Alice\nProcessed Bob\nProcessed Charlie'); + + const previousDir = join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'previous_responses'); + const previousFiles = readdirSync(previousDir); + expect(state.previousResponseSourcePath).toMatch(/^\.takt\/runs\/test-report-dir\/context\/previous_responses\/process\.1\.\d{8}T\d{6}Z\.md$/); + expect(previousFiles).toContain('latest.md'); + expect(readFileSync(join(previousDir, 'latest.md'), 'utf-8')).toBe('Processed Alice\nProcessed Bob\nProcessed Charlie'); + }); + + it('should handle batch_size > 1', async () => { + const tmpDir = createTestTmpDir(); + const csvPath = join(tmpDir, 'data.csv'); + const templatePath = join(tmpDir, 'batch-template.md'); + // 4 rows so batch_size=2 gives exactly 2 batches with 2 rows each + writeFileSync(csvPath, 'name,task\nAlice,review\nBob,implement\nCharlie,test\nDave,deploy', 'utf-8'); + writeFileSync(templatePath, 'Row1: {line:1}\nRow2: {line:2}', 'utf-8'); + + const arpeggioConfig = createArpeggioConfig(csvPath, templatePath, { batchSize: 2 }); + const config = buildArpeggioPieceConfig(arpeggioConfig, tmpDir); + + const mockAgent = vi.mocked(runAgent); + mockAgent + .mockResolvedValueOnce(makeResponse({ content: 'Batch 0 result' })) + .mockResolvedValueOnce(makeResponse({ content: 'Batch 1 result' })); + + vi.mocked(detectMatchedRule).mockResolvedValueOnce({ + index: 0, + method: 'phase1_tag', + }); + + engine = new PieceEngine(config, tmpDir, 'test task', createEngineOptions(tmpDir)); + const state = await engine.run(); + + expect(state.status).toBe('completed'); + // 4 rows / batch_size 2 = 2 batches + expect(mockAgent).toHaveBeenCalledTimes(2); + }); + + it('should abort when a batch fails and retries are exhausted', async () => { + const { tmpDir, csvPath, templatePath } = createArpeggioTestDir(); + const arpeggioConfig = createArpeggioConfig(csvPath, templatePath, { + maxRetries: 1, + retryDelayMs: 0, + }); + const config = buildArpeggioPieceConfig(arpeggioConfig, tmpDir); + + const mockAgent = vi.mocked(runAgent); + // First batch succeeds + mockAgent.mockResolvedValueOnce(makeResponse({ content: 'OK' })); + // Second batch fails twice (initial + 1 retry) + mockAgent.mockResolvedValueOnce(makeResponse({ status: 'error', error: 'fail1' })); + mockAgent.mockResolvedValueOnce(makeResponse({ status: 'error', error: 'fail2' })); + // Third batch succeeds + mockAgent.mockResolvedValueOnce(makeResponse({ content: 'OK' })); + + engine = new PieceEngine(config, tmpDir, 'test task', createEngineOptions(tmpDir)); + const state = await engine.run(); + + expect(state.status).toBe('aborted'); + }); + + it('should write output file when output_path is configured', async () => { + const { tmpDir, csvPath, templatePath } = createArpeggioTestDir(); + const outputPath = join(tmpDir, 'output.txt'); + const arpeggioConfig = createArpeggioConfig(csvPath, templatePath, { outputPath }); + const config = buildArpeggioPieceConfig(arpeggioConfig, tmpDir); + + const mockAgent = vi.mocked(runAgent); + mockAgent + .mockResolvedValueOnce(makeResponse({ content: 'Result A' })) + .mockResolvedValueOnce(makeResponse({ content: 'Result B' })) + .mockResolvedValueOnce(makeResponse({ content: 'Result C' })); + + vi.mocked(detectMatchedRule).mockResolvedValueOnce({ + index: 0, + method: 'phase1_tag', + }); + + engine = new PieceEngine(config, tmpDir, 'test task', createEngineOptions(tmpDir)); + await engine.run(); + + const { readFileSync } = await import('node:fs'); + const outputContent = readFileSync(outputPath, 'utf-8'); + expect(outputContent).toBe('Result A\nResult B\nResult C'); + }); + + it('should handle concurrency > 1', async () => { + const { tmpDir, csvPath, templatePath } = createArpeggioTestDir(); + const arpeggioConfig = createArpeggioConfig(csvPath, templatePath, { concurrency: 3 }); + const config = buildArpeggioPieceConfig(arpeggioConfig, tmpDir); + + const mockAgent = vi.mocked(runAgent); + mockAgent + .mockResolvedValueOnce(makeResponse({ content: 'A' })) + .mockResolvedValueOnce(makeResponse({ content: 'B' })) + .mockResolvedValueOnce(makeResponse({ content: 'C' })); + + vi.mocked(detectMatchedRule).mockResolvedValueOnce({ + index: 0, + method: 'phase1_tag', + }); + + engine = new PieceEngine(config, tmpDir, 'test task', createEngineOptions(tmpDir)); + const state = await engine.run(); + + expect(state.status).toBe('completed'); + expect(mockAgent).toHaveBeenCalledTimes(3); + }); + + it('should use custom merge function when configured', async () => { + const { tmpDir, csvPath, templatePath } = createArpeggioTestDir(); + const arpeggioConfig = createArpeggioConfig(csvPath, templatePath, { + merge: { + strategy: 'custom', + inlineJs: 'return results.filter(r => r.success).map(r => r.content).join(" | ");', + }, + }); + const config = buildArpeggioPieceConfig(arpeggioConfig, tmpDir); + + const mockAgent = vi.mocked(runAgent); + mockAgent + .mockResolvedValueOnce(makeResponse({ content: 'X' })) + .mockResolvedValueOnce(makeResponse({ content: 'Y' })) + .mockResolvedValueOnce(makeResponse({ content: 'Z' })); + + vi.mocked(detectMatchedRule).mockResolvedValueOnce({ + index: 0, + method: 'phase1_tag', + }); + + engine = new PieceEngine(config, tmpDir, 'test task', createEngineOptions(tmpDir)); + const state = await engine.run(); + + expect(state.status).toBe('completed'); + const output = state.movementOutputs.get('process'); + expect(output!.content).toBe('X | Y | Z'); + }); +}); diff --git a/src/__tests__/engine-error.test.ts b/src/__tests__/engine-error.test.ts index 0c040ba8..bcc9ca2b 100644 --- a/src/__tests__/engine-error.test.ts +++ b/src/__tests__/engine-error.test.ts @@ -109,6 +109,7 @@ describe('PieceEngine Integration: Error Handling', () => { const reason = abortFn.mock.calls[0]![1] as string; expect(reason).toContain('API connection failed'); }); + }); // ===================================================== @@ -117,7 +118,7 @@ describe('PieceEngine Integration: Error Handling', () => { describe('Loop detection', () => { it('should abort when loop detected with action: abort', async () => { const config = buildDefaultPieceConfig({ - maxIterations: 100, + maxMovements: 100, loopDetection: { maxConsecutiveSameStep: 3, action: 'abort' }, initialMovement: 'loop-step', movements: [ @@ -156,7 +157,7 @@ describe('PieceEngine Integration: Error Handling', () => { // ===================================================== describe('Iteration limit', () => { it('should abort when max iterations reached without onIterationLimit callback', async () => { - const config = buildDefaultPieceConfig({ maxIterations: 2 }); + const config = buildDefaultPieceConfig({ maxMovements: 2 }); const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ @@ -182,11 +183,11 @@ describe('PieceEngine Integration: Error Handling', () => { expect(limitFn).toHaveBeenCalledWith(2, 2); expect(abortFn).toHaveBeenCalledOnce(); const reason = abortFn.mock.calls[0]![1] as string; - expect(reason).toContain('Max iterations'); + expect(reason).toContain('Max movements'); }); it('should extend iterations when onIterationLimit provides additional iterations', async () => { - const config = buildDefaultPieceConfig({ maxIterations: 2 }); + const config = buildDefaultPieceConfig({ maxMovements: 2 }); const onIterationLimit = vi.fn().mockResolvedValueOnce(10); diff --git a/src/__tests__/engine-happy-path.test.ts b/src/__tests__/engine-happy-path.test.ts index 89a20fa4..d067fa4d 100644 --- a/src/__tests__/engine-happy-path.test.ts +++ b/src/__tests__/engine-happy-path.test.ts @@ -388,7 +388,7 @@ describe('PieceEngine Integration: Happy Path', () => { it('should pass instruction to movement:start for normal movements', async () => { const simpleConfig: PieceConfig = { name: 'test', - maxIterations: 10, + maxMovements: 10, initialMovement: 'plan', movements: [ makeMovement('plan', { @@ -456,7 +456,7 @@ describe('PieceEngine Integration: Happy Path', () => { }); it('should emit iteration:limit when max iterations reached', async () => { - const config = buildDefaultPieceConfig({ maxIterations: 1 }); + const config = buildDefaultPieceConfig({ maxMovements: 1 }); engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ @@ -518,7 +518,7 @@ describe('PieceEngine Integration: Happy Path', () => { it('should emit phase:start and phase:complete events for Phase 1', async () => { const simpleConfig: PieceConfig = { name: 'test', - maxIterations: 10, + maxMovements: 10, initialMovement: 'plan', movements: [ makeMovement('plan', { @@ -609,7 +609,7 @@ describe('PieceEngine Integration: Happy Path', () => { it('should throw when rule references nonexistent movement', () => { const config: PieceConfig = { name: 'test', - maxIterations: 10, + maxMovements: 10, initialMovement: 'step1', movements: [ makeMovement('step1', { diff --git a/src/__tests__/engine-loop-monitors.test.ts b/src/__tests__/engine-loop-monitors.test.ts index 7f9cfa29..e363264b 100644 --- a/src/__tests__/engine-loop-monitors.test.ts +++ b/src/__tests__/engine-loop-monitors.test.ts @@ -60,7 +60,7 @@ function buildConfigWithLoopMonitor( return { name: 'test-loop-monitor', description: 'Test piece with loop monitors', - maxIterations: 30, + maxMovements: 30, initialMovement: 'implement', loopMonitors: [ { diff --git a/src/__tests__/engine-parallel-failure.test.ts b/src/__tests__/engine-parallel-failure.test.ts index 3d3d00e4..a48d6c11 100644 --- a/src/__tests__/engine-parallel-failure.test.ts +++ b/src/__tests__/engine-parallel-failure.test.ts @@ -54,7 +54,7 @@ function buildParallelOnlyConfig(): PieceConfig { return { name: 'test-parallel-failure', description: 'Test parallel failure handling', - maxIterations: 10, + maxMovements: 10, initialMovement: 'reviewers', movements: [ makeMovement('reviewers', { diff --git a/src/__tests__/engine-parallel.test.ts b/src/__tests__/engine-parallel.test.ts index 11b88cff..bb5cf774 100644 --- a/src/__tests__/engine-parallel.test.ts +++ b/src/__tests__/engine-parallel.test.ts @@ -8,7 +8,8 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { existsSync, rmSync } from 'node:fs'; +import { existsSync, rmSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; // --- Mock setup (must be before imports that use these modules) --- @@ -128,6 +129,46 @@ describe('PieceEngine Integration: Parallel Movement Aggregation', () => { expect(state.movementOutputs.get('security-review')!.content).toBe('Sec content'); }); + it('should persist aggregated previous_response snapshot for parallel parent movement', async () => { + const config = buildDefaultPieceConfig(); + const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan' }), + makeResponse({ persona: 'implement', content: 'Impl' }), + makeResponse({ persona: 'ai_review', content: 'OK' }), + makeResponse({ persona: 'arch-review', content: 'Arch content' }), + makeResponse({ persona: 'security-review', content: 'Sec content' }), + makeResponse({ persona: 'supervise', content: 'Pass' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'aggregate' }, + { index: 0, method: 'phase1_tag' }, + ]); + + const state = await engine.run(); + const reviewersOutput = state.movementOutputs.get('reviewers')!.content; + const previousDir = join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'previous_responses'); + const previousFiles = readdirSync(previousDir); + + expect(state.previousResponseSourcePath).toMatch(/^\.takt\/runs\/test-report-dir\/context\/previous_responses\/supervise\.1\.\d{8}T\d{6}Z\.md$/); + expect(previousFiles).toContain('latest.md'); + expect(previousFiles.some((name) => /^reviewers\.1\.\d{8}T\d{6}Z\.md$/.test(name))).toBe(true); + expect(readFileSync(join(previousDir, 'latest.md'), 'utf-8')).toBe('Pass'); + expect( + previousFiles.some((name) => { + if (!/^reviewers\.1\.\d{8}T\d{6}Z\.md$/.test(name)) return false; + return readFileSync(join(previousDir, name), 'utf-8') === reviewersOutput; + }) + ).toBe(true); + }); + it('should execute sub-movements concurrently (both runAgent calls happen)', async () => { const config = buildDefaultPieceConfig(); const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); diff --git a/src/__tests__/engine-persona-providers.test.ts b/src/__tests__/engine-persona-providers.test.ts index afb371ca..4dc533c5 100644 --- a/src/__tests__/engine-persona-providers.test.ts +++ b/src/__tests__/engine-persona-providers.test.ts @@ -55,7 +55,7 @@ describe('PieceEngine persona_providers override', () => { name: 'persona-provider-test', movements: [movement], initialMovement: 'implement', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ @@ -84,7 +84,7 @@ describe('PieceEngine persona_providers override', () => { name: 'persona-provider-nomatch', movements: [movement], initialMovement: 'plan', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ @@ -114,7 +114,7 @@ describe('PieceEngine persona_providers override', () => { name: 'movement-over-persona', movements: [movement], initialMovement: 'implement', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ @@ -143,7 +143,7 @@ describe('PieceEngine persona_providers override', () => { name: 'no-persona-providers', movements: [movement], initialMovement: 'plan', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ @@ -175,7 +175,7 @@ describe('PieceEngine persona_providers override', () => { name: 'multi-persona-providers', movements: [planMovement, implementMovement], initialMovement: 'plan', - maxIterations: 3, + maxMovements: 3, }; mockRunAgentSequence([ diff --git a/src/__tests__/engine-report.test.ts b/src/__tests__/engine-report.test.ts index 46339999..5b813443 100644 --- a/src/__tests__/engine-report.test.ts +++ b/src/__tests__/engine-report.test.ts @@ -15,7 +15,7 @@ import type { PieceMovement, OutputContractItem, OutputContractLabelPath, Output * Extracted emitMovementReports logic for unit testing. * Mirrors engine.ts emitMovementReports + emitIfReportExists. * - * reportDir already includes the `.takt/reports/` prefix (set by engine constructor). + * reportDir already includes the `.takt/runs/{slug}/reports` path (set by engine constructor). */ function emitMovementReports( emitter: EventEmitter, @@ -59,8 +59,8 @@ function createMovement(overrides: Partial = {}): PieceMovement { describe('emitMovementReports', () => { let tmpDir: string; let reportBaseDir: string; - // reportDir now includes .takt/reports/ prefix (matches engine constructor behavior) - const reportDirName = '.takt/reports/test-report-dir'; + // reportDir now includes .takt/runs/{slug}/reports path (matches engine constructor behavior) + const reportDirName = '.takt/runs/test-report-dir/reports'; beforeEach(() => { tmpDir = join(tmpdir(), `takt-report-test-${Date.now()}`); diff --git a/src/__tests__/engine-test-helpers.test.ts b/src/__tests__/engine-test-helpers.test.ts new file mode 100644 index 00000000..f917be1a --- /dev/null +++ b/src/__tests__/engine-test-helpers.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { cleanupPieceEngine } from './engine-test-helpers.js'; + +describe('cleanupPieceEngine', () => { + it('should remove all listeners when engine has removeAllListeners function', () => { + const removeAllListeners = vi.fn(); + const engine = { removeAllListeners }; + + cleanupPieceEngine(engine); + + expect(removeAllListeners).toHaveBeenCalledOnce(); + }); + + it('should not throw when engine does not have removeAllListeners function', () => { + expect(() => cleanupPieceEngine({})).not.toThrow(); + expect(() => cleanupPieceEngine(null)).not.toThrow(); + expect(() => cleanupPieceEngine(undefined)).not.toThrow(); + expect(() => cleanupPieceEngine({ removeAllListeners: 'no-op' })).not.toThrow(); + }); +}); diff --git a/src/__tests__/engine-test-helpers.ts b/src/__tests__/engine-test-helpers.ts index 7112df8a..d8c893fb 100644 --- a/src/__tests__/engine-test-helpers.ts +++ b/src/__tests__/engine-test-helpers.ts @@ -70,7 +70,7 @@ export function buildDefaultPieceConfig(overrides: Partial = {}): P return { name: 'test-default', description: 'Test piece', - maxIterations: 30, + maxMovements: 30, initialMovement: 'plan', movements: [ makeMovement('plan', { @@ -154,13 +154,17 @@ export function mockDetectMatchedRuleSequence(matches: (RuleMatch | undefined)[] // --- Test environment setup --- /** - * Create a temporary directory with the required .takt/reports structure. + * Create a temporary directory with the required .takt/runs structure. * Returns the tmpDir path. Caller is responsible for cleanup. */ export function createTestTmpDir(): string { const tmpDir = join(tmpdir(), `takt-engine-test-${randomUUID()}`); mkdirSync(tmpDir, { recursive: true }); - mkdirSync(join(tmpDir, '.takt', 'reports', 'test-report-dir'), { recursive: true }); + mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'reports'), { recursive: true }); + mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'knowledge'), { recursive: true }); + mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'policy'), { recursive: true }); + mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'previous_responses'), { recursive: true }); + mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'logs'), { recursive: true }); return tmpDir; } @@ -174,12 +178,27 @@ export function applyDefaultMocks(): void { vi.mocked(generateReportDir).mockReturnValue('test-report-dir'); } +type RemovableListeners = { + removeAllListeners: () => void; +}; + +function hasRemovableListeners(value: unknown): value is RemovableListeners { + if (!value || typeof value !== 'object') { + return false; + } + if (!('removeAllListeners' in value)) { + return false; + } + const candidate = value as { removeAllListeners: unknown }; + return typeof candidate.removeAllListeners === 'function'; +} + /** * Clean up PieceEngine instances to prevent EventEmitter memory leaks. * Call this in afterEach to ensure all event listeners are removed. */ -export function cleanupPieceEngine(engine: any): void { - if (engine && typeof engine.removeAllListeners === 'function') { +export function cleanupPieceEngine(engine: unknown): void { + if (hasRemovableListeners(engine)) { engine.removeAllListeners(); } } diff --git a/src/__tests__/engine-worktree-report.test.ts b/src/__tests__/engine-worktree-report.test.ts index 52b43781..1021c0a6 100644 --- a/src/__tests__/engine-worktree-report.test.ts +++ b/src/__tests__/engine-worktree-report.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { existsSync, rmSync, mkdirSync } from 'node:fs'; +import { existsSync, rmSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; @@ -51,11 +51,11 @@ function createWorktreeDirs(): { projectCwd: string; cloneCwd: string } { const projectCwd = join(base, 'project'); const cloneCwd = join(base, 'clone'); - // Project side: real .takt/reports directory (for non-worktree tests) - mkdirSync(join(projectCwd, '.takt', 'reports', 'test-report-dir'), { recursive: true }); + // Project side: real .takt/runs directory (for non-worktree tests) + mkdirSync(join(projectCwd, '.takt', 'runs', 'test-report-dir', 'reports'), { recursive: true }); - // Clone side: .takt/reports directory (reports now written directly to clone) - mkdirSync(join(cloneCwd, '.takt', 'reports', 'test-report-dir'), { recursive: true }); + // Clone side: .takt/runs directory (reports now written directly to clone) + mkdirSync(join(cloneCwd, '.takt', 'runs', 'test-report-dir', 'reports'), { recursive: true }); return { projectCwd, cloneCwd }; } @@ -64,7 +64,7 @@ function buildSimpleConfig(): PieceConfig { return { name: 'worktree-test', description: 'Test piece for worktree', - maxIterations: 10, + maxMovements: 10, initialMovement: 'review', movements: [ makeMovement('review', { @@ -121,8 +121,8 @@ describe('PieceEngine: worktree reportDir resolution', () => { // reportDir should be resolved from cloneCwd (cwd), not projectCwd // This prevents agents from discovering the main repository path via instruction - const expectedPath = join(cloneCwd, '.takt/reports/test-report-dir'); - const unexpectedPath = join(projectCwd, '.takt/reports/test-report-dir'); + const expectedPath = join(cloneCwd, '.takt/runs/test-report-dir/reports'); + const unexpectedPath = join(projectCwd, '.takt/runs/test-report-dir/reports'); expect(phaseCtx.reportDir).toBe(expectedPath); expect(phaseCtx.reportDir).not.toBe(unexpectedPath); @@ -133,7 +133,7 @@ describe('PieceEngine: worktree reportDir resolution', () => { const config: PieceConfig = { name: 'worktree-test', description: 'Test', - maxIterations: 10, + maxMovements: 10, initialMovement: 'review', movements: [ makeMovement('review', { @@ -166,10 +166,10 @@ describe('PieceEngine: worktree reportDir resolution', () => { expect(runAgentMock).toHaveBeenCalled(); const instruction = runAgentMock.mock.calls[0][1] as string; - const expectedPath = join(cloneCwd, '.takt/reports/test-report-dir'); + const expectedPath = join(cloneCwd, '.takt/runs/test-report-dir/reports'); expect(instruction).toContain(expectedPath); // In worktree mode, projectCwd path should NOT appear in instruction - expect(instruction).not.toContain(join(projectCwd, '.takt/reports/test-report-dir')); + expect(instruction).not.toContain(join(projectCwd, '.takt/runs/test-report-dir/reports')); }); it('should use same path in non-worktree mode (cwd === projectCwd)', async () => { @@ -195,7 +195,100 @@ describe('PieceEngine: worktree reportDir resolution', () => { expect(reportPhaseMock).toHaveBeenCalled(); const phaseCtx = reportPhaseMock.mock.calls[0][2] as { reportDir: string }; - const expectedPath = join(normalDir, '.takt/reports/test-report-dir'); + const expectedPath = join(normalDir, '.takt/runs/test-report-dir/reports'); expect(phaseCtx.reportDir).toBe(expectedPath); }); + + it('should use explicit reportDirName when provided', async () => { + const normalDir = projectCwd; + const config = buildSimpleConfig(); + const engine = new PieceEngine(config, normalDir, 'test task', { + projectCwd: normalDir, + reportDirName: '20260201-015714-foptng', + }); + + mockRunAgentSequence([ + makeResponse({ persona: 'review', content: 'Review done' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'tag' as const }, + ]); + + await engine.run(); + + const reportPhaseMock = vi.mocked(runReportPhase); + expect(reportPhaseMock).toHaveBeenCalled(); + const phaseCtx = reportPhaseMock.mock.calls[0][2] as { reportDir: string }; + expect(phaseCtx.reportDir).toBe(join(normalDir, '.takt/runs/20260201-015714-foptng/reports')); + }); + + it('should reject invalid explicit reportDirName', () => { + const normalDir = projectCwd; + const config = buildSimpleConfig(); + + expect(() => new PieceEngine(config, normalDir, 'test task', { + projectCwd: normalDir, + reportDirName: '..', + })).toThrow('Invalid reportDirName: ..'); + + expect(() => new PieceEngine(config, normalDir, 'test task', { + projectCwd: normalDir, + reportDirName: '.', + })).toThrow('Invalid reportDirName: .'); + + expect(() => new PieceEngine(config, normalDir, 'test task', { + projectCwd: normalDir, + reportDirName: '', + })).toThrow('Invalid reportDirName: '); + }); + + it('should persist context snapshots and update latest previous response', async () => { + const normalDir = projectCwd; + const config: PieceConfig = { + name: 'snapshot-test', + description: 'Test', + maxMovements: 10, + initialMovement: 'implement', + movements: [ + makeMovement('implement', { + policyContents: ['Policy content'], + knowledgeContents: ['Knowledge content'], + rules: [makeRule('go-review', 'review')], + }), + makeMovement('review', { + rules: [makeRule('approved', 'COMPLETE')], + }), + ], + }; + const engine = new PieceEngine(config, normalDir, 'test task', { + projectCwd: normalDir, + reportDirName: 'test-report-dir', + }); + + mockRunAgentSequence([ + makeResponse({ persona: 'implement', content: 'implement output' }), + makeResponse({ persona: 'review', content: 'review output' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'tag' as const }, + { index: 0, method: 'tag' as const }, + ]); + + await engine.run(); + + const base = join(normalDir, '.takt', 'runs', 'test-report-dir', 'context'); + const knowledgeDir = join(base, 'knowledge'); + const policyDir = join(base, 'policy'); + const previousResponsesDir = join(base, 'previous_responses'); + + const knowledgeFiles = readdirSync(knowledgeDir); + const policyFiles = readdirSync(policyDir); + const previousResponseFiles = readdirSync(previousResponsesDir); + + expect(knowledgeFiles.some((name) => name.endsWith('.md'))).toBe(true); + expect(policyFiles.some((name) => name.endsWith('.md'))).toBe(true); + expect(previousResponseFiles).toContain('latest.md'); + expect(previousResponseFiles.filter((name) => name.endsWith('.md')).length).toBe(3); + expect(readFileSync(join(previousResponsesDir, 'latest.md'), 'utf-8')).toBe('review output'); + }); }); diff --git a/src/__tests__/escape.test.ts b/src/__tests__/escape.test.ts index e850fa3e..081c643c 100644 --- a/src/__tests__/escape.test.ts +++ b/src/__tests__/escape.test.ts @@ -26,7 +26,7 @@ function makeContext(overrides: Partial = {}): InstructionCo return { task: 'test task', iteration: 1, - maxIterations: 10, + maxMovements: 10, movementIteration: 1, cwd: '/tmp/test', projectCwd: '/tmp/project', @@ -78,10 +78,10 @@ describe('replaceTemplatePlaceholders', () => { expect(result).toBe('fix {bug} in code'); }); - it('should replace {iteration} and {max_iterations}', () => { + it('should replace {iteration} and {max_movements}', () => { const step = makeMovement(); - const ctx = makeContext({ iteration: 3, maxIterations: 20 }); - const template = 'Iteration {iteration}/{max_iterations}'; + const ctx = makeContext({ iteration: 3, maxMovements: 20 }); + const template = 'Iteration {iteration}/{max_movements}'; const result = replaceTemplatePlaceholders(template, step, ctx); expect(result).toBe('Iteration 3/20'); @@ -112,6 +112,23 @@ describe('replaceTemplatePlaceholders', () => { expect(result).toBe('Previous: previous output text'); }); + it('should prefer preprocessed previous response text when provided', () => { + const step = makeMovement({ passPreviousResponse: true }); + const ctx = makeContext({ + previousOutput: { + persona: 'coder', + status: 'done', + content: 'raw previous output', + timestamp: new Date(), + }, + previousResponseText: 'processed previous output', + }); + const template = 'Previous: {previous_response}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('Previous: processed previous output'); + }); + it('should replace {previous_response} with empty string when no previous output', () => { const step = makeMovement({ passPreviousResponse: true }); const ctx = makeContext(); @@ -169,11 +186,11 @@ describe('replaceTemplatePlaceholders', () => { const ctx = makeContext({ task: 'test task', iteration: 2, - maxIterations: 5, + maxMovements: 5, movementIteration: 1, reportDir: '/reports', }); - const template = '{task} - iter {iteration}/{max_iterations} - mv {movement_iteration} - dir {report_dir}'; + const template = '{task} - iter {iteration}/{max_movements} - mv {movement_iteration} - dir {report_dir}'; const result = replaceTemplatePlaceholders(template, step, ctx); expect(result).toBe('test task - iter 2/5 - mv 1 - dir /reports'); diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 3c62adf5..d8a5cccc 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -287,6 +287,96 @@ describe('loadGlobalConfig', () => { expect(config.notificationSound).toBeUndefined(); }); + it('should load notification_sound_events config from config.yaml', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + [ + 'language: en', + 'notification_sound_events:', + ' iteration_limit: false', + ' piece_complete: true', + ' piece_abort: true', + ' run_complete: true', + ' run_abort: false', + ].join('\n'), + 'utf-8', + ); + + const config = loadGlobalConfig(); + expect(config.notificationSoundEvents).toEqual({ + iterationLimit: false, + pieceComplete: true, + pieceAbort: true, + runComplete: true, + runAbort: false, + }); + }); + + it('should load observability.provider_events config from config.yaml', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + [ + 'language: en', + 'observability:', + ' provider_events: false', + ].join('\n'), + 'utf-8', + ); + + const config = loadGlobalConfig(); + expect(config.observability).toEqual({ + providerEvents: false, + }); + }); + + it('should save and reload observability.provider_events config', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.observability = { + providerEvents: false, + }; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.observability).toEqual({ + providerEvents: false, + }); + }); + + it('should save and reload notification_sound_events config', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.notificationSoundEvents = { + iterationLimit: false, + pieceComplete: true, + pieceAbort: false, + runComplete: true, + runAbort: true, + }; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.notificationSoundEvents).toEqual({ + iterationLimit: false, + pieceComplete: true, + pieceAbort: false, + runComplete: true, + runAbort: true, + }); + }); + it('should load interactive_preview_movements config from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); @@ -465,5 +555,77 @@ describe('loadGlobalConfig', () => { expect(() => loadGlobalConfig()).not.toThrow(); }); + + it('should throw when provider is opencode but model is a Claude alias (opus)', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'provider: opencode\nmodel: opus\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).toThrow(/model 'opus' is a Claude model alias but provider is 'opencode'/); + }); + + it('should throw when provider is opencode but model is sonnet', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'provider: opencode\nmodel: sonnet\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).toThrow(/model 'sonnet' is a Claude model alias but provider is 'opencode'/); + }); + + it('should throw when provider is opencode but model is haiku', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'provider: opencode\nmodel: haiku\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).toThrow(/model 'haiku' is a Claude model alias but provider is 'opencode'/); + }); + + it('should not throw when provider is opencode with a compatible model', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'provider: opencode\nmodel: opencode/big-pickle\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).not.toThrow(); + }); + + it('should throw when provider is opencode without a model', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'provider: opencode\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).toThrow(/provider 'opencode' requires model in 'provider\/model' format/i); + }); + + it('should throw when provider is opencode and model is not provider/model format', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'provider: opencode\nmodel: big-pickle\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).toThrow(/must be in 'provider\/model' format/i); + }); }); }); diff --git a/src/__tests__/i18n.test.ts b/src/__tests__/i18n.test.ts index 149c5306..bed0d128 100644 --- a/src/__tests__/i18n.test.ts +++ b/src/__tests__/i18n.test.ts @@ -37,7 +37,7 @@ describe('getLabel', () => { it('replaces {variableName} placeholders with provided values', () => { const result = getLabel('piece.iterationLimit.maxReached', undefined, { currentIteration: '5', - maxIterations: '10', + maxMovements: '10', }); expect(result).toContain('(5/10)'); }); diff --git a/src/__tests__/instruction-helpers.test.ts b/src/__tests__/instruction-helpers.test.ts index 0b9adffe..ee75ba90 100644 --- a/src/__tests__/instruction-helpers.test.ts +++ b/src/__tests__/instruction-helpers.test.ts @@ -27,7 +27,7 @@ function makeContext(overrides: Partial = {}): InstructionCo return { task: 'test task', iteration: 1, - maxIterations: 10, + maxMovements: 10, movementIteration: 1, cwd: '/tmp/test', projectCwd: '/tmp/project', @@ -101,7 +101,7 @@ describe('renderReportOutputInstruction', () => { const result = renderReportOutputInstruction(step, ctx, 'en'); expect(result).toContain('Report output'); expect(result).toContain('Report File'); - expect(result).toContain('Iteration 2'); + expect(result).toContain('Move current content to `logs/reports-history/`'); }); it('should render English multi-file instruction', () => { @@ -121,6 +121,7 @@ describe('renderReportOutputInstruction', () => { const result = renderReportOutputInstruction(step, ctx, 'ja'); expect(result).toContain('レポート出力'); expect(result).toContain('Report File'); + expect(result).toContain('`logs/reports-history/`'); }); it('should render Japanese multi-file instruction', () => { diff --git a/src/__tests__/instructionBuilder.test.ts b/src/__tests__/instructionBuilder.test.ts index 6740e877..d5e0296e 100644 --- a/src/__tests__/instructionBuilder.test.ts +++ b/src/__tests__/instructionBuilder.test.ts @@ -41,7 +41,7 @@ function createMinimalContext(overrides: Partial = {}): Inst return { task: 'Test task', iteration: 1, - maxIterations: 10, + maxMovements: 10, movementIteration: 1, cwd: '/project', projectCwd: '/project', @@ -128,13 +128,13 @@ describe('instruction-builder', () => { ); const context = createMinimalContext({ cwd: '/project', - reportDir: '/project/.takt/reports/20260128-test-report', + reportDir: '/project/.takt/runs/20260128-test-report/reports', }); const result = buildInstruction(step, context); expect(result).toContain( - '- Report Directory: /project/.takt/reports/20260128-test-report/' + '- Report Directory: /project/.takt/runs/20260128-test-report/reports/' ); }); @@ -145,14 +145,14 @@ describe('instruction-builder', () => { const context = createMinimalContext({ cwd: '/clone/my-task', projectCwd: '/project', - reportDir: '/project/.takt/reports/20260128-worktree-report', + reportDir: '/project/.takt/runs/20260128-worktree-report/reports', }); const result = buildInstruction(step, context); // reportDir is now absolute, pointing to projectCwd expect(result).toContain( - '- Report: /project/.takt/reports/20260128-worktree-report/00-plan.md' + '- Report: /project/.takt/runs/20260128-worktree-report/reports/00-plan.md' ); expect(result).toContain('Working Directory: /clone/my-task'); }); @@ -164,13 +164,13 @@ describe('instruction-builder', () => { const context = createMinimalContext({ projectCwd: '/project', cwd: '/worktree', - reportDir: '/project/.takt/reports/20260128-multi', + reportDir: '/project/.takt/runs/20260128-multi/reports', }); const result = buildInstruction(step, context); - expect(result).toContain('/project/.takt/reports/20260128-multi/01-scope.md'); - expect(result).toContain('/project/.takt/reports/20260128-multi/02-decisions.md'); + expect(result).toContain('/project/.takt/runs/20260128-multi/reports/01-scope.md'); + expect(result).toContain('/project/.takt/runs/20260128-multi/reports/02-decisions.md'); }); it('should replace standalone {report_dir} with absolute path', () => { @@ -178,12 +178,108 @@ describe('instruction-builder', () => { 'Report dir name: {report_dir}' ); const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260128-standalone', + reportDir: '/project/.takt/runs/20260128-standalone/reports', }); const result = buildInstruction(step, context); - expect(result).toContain('Report dir name: /project/.takt/reports/20260128-standalone'); + expect(result).toContain('Report dir name: /project/.takt/runs/20260128-standalone/reports'); + }); + }); + + describe('context length control and source path injection', () => { + it('should truncate previous response and inject source path with conflict notice', () => { + const step = createMinimalStep('Continue work'); + step.passPreviousResponse = true; + const longResponse = 'x'.repeat(2100); + const context = createMinimalContext({ + previousOutput: { + persona: 'coder', + status: 'done', + content: longResponse, + timestamp: new Date(), + }, + previousResponseSourcePath: '.takt/runs/test/context/previous_responses/latest.md', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('...TRUNCATED...'); + expect(result).toContain('Source: .takt/runs/test/context/previous_responses/latest.md'); + expect(result).toContain('If prompt content conflicts with source files, source files take precedence.'); + }); + + it('should always inject source paths when content is not truncated', () => { + const step = createMinimalStep('Do work'); + step.passPreviousResponse = true; + const context = createMinimalContext({ + previousOutput: { + persona: 'reviewer', + status: 'done', + content: 'short previous response', + timestamp: new Date(), + }, + previousResponseSourcePath: '.takt/runs/test/context/previous_responses/latest.md', + knowledgeContents: ['short knowledge'], + knowledgeSourcePath: '.takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md', + policyContents: ['short policy'], + policySourcePath: '.takt/runs/test/context/policy/implement.1.20260210T010203Z.md', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('Knowledge Source: .takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md'); + expect(result).toContain('Policy Source: .takt/runs/test/context/policy/implement.1.20260210T010203Z.md'); + expect(result).toContain('Source: .takt/runs/test/context/previous_responses/latest.md'); + expect(result).not.toContain('...TRUNCATED...'); + expect(result).not.toContain('Knowledge is truncated.'); + expect(result).not.toContain('Policy is authoritative. If truncated'); + expect(result).not.toContain('Previous Response is truncated.'); + }); + + it('should not truncate when content length is exactly 2000 chars', () => { + const step = createMinimalStep('Do work'); + step.passPreviousResponse = true; + const exactBoundary = 'x'.repeat(2000); + const context = createMinimalContext({ + previousOutput: { + persona: 'reviewer', + status: 'done', + content: exactBoundary, + timestamp: new Date(), + }, + previousResponseSourcePath: '.takt/runs/test/context/previous_responses/latest.md', + knowledgeContents: [exactBoundary], + knowledgeSourcePath: '.takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md', + policyContents: [exactBoundary], + policySourcePath: '.takt/runs/test/context/policy/implement.1.20260210T010203Z.md', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('Knowledge Source: .takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md'); + expect(result).toContain('Policy Source: .takt/runs/test/context/policy/implement.1.20260210T010203Z.md'); + expect(result).toContain('Source: .takt/runs/test/context/previous_responses/latest.md'); + expect(result).not.toContain('...TRUNCATED...'); + }); + + it('should inject required truncated warning and source path for knowledge/policy', () => { + const step = createMinimalStep('Do work'); + const longKnowledge = 'k'.repeat(2200); + const longPolicy = 'p'.repeat(2200); + const context = createMinimalContext({ + knowledgeContents: [longKnowledge], + knowledgeSourcePath: '.takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md', + policyContents: [longPolicy], + policySourcePath: '.takt/runs/test/context/policy/implement.1.20260210T010203Z.md', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('Knowledge is truncated. You MUST consult the source files before making decisions.'); + expect(result).toContain('Policy is authoritative. If truncated, you MUST read the full policy file and follow it strictly.'); + expect(result).toContain('Knowledge Source: .takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md'); + expect(result).toContain('Policy Source: .takt/runs/test/context/policy/implement.1.20260210T010203Z.md'); }); }); @@ -362,7 +458,7 @@ describe('instruction-builder', () => { step.name = 'implement'; const context = createMinimalContext({ iteration: 3, - maxIterations: 20, + maxMovements: 20, movementIteration: 2, language: 'en', }); @@ -380,7 +476,7 @@ describe('instruction-builder', () => { step.name = 'plan'; step.outputContracts = [{ name: '00-plan.md' }]; const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); @@ -399,7 +495,7 @@ describe('instruction-builder', () => { { label: 'Decisions', path: '02-decisions.md' }, ]; const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); @@ -414,7 +510,7 @@ describe('instruction-builder', () => { const step = createMinimalStep('Do work'); step.outputContracts = [{ name: '00-plan.md' }]; const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); @@ -559,7 +655,7 @@ describe('instruction-builder', () => { const step = createMinimalStep('Do work'); step.outputContracts = [{ name: '00-plan.md' }]; const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); @@ -579,7 +675,7 @@ describe('instruction-builder', () => { const step = createMinimalStep('Do work'); step.outputContracts = [{ name: '00-plan.md', format: '**Format:**\n# Plan' }]; const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); @@ -595,7 +691,7 @@ describe('instruction-builder', () => { order: 'Custom order instruction', }]; const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); @@ -607,13 +703,13 @@ describe('instruction-builder', () => { it('should still replace {report:filename} in instruction_template', () => { const step = createMinimalStep('Write to {report:00-plan.md}'); const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); const result = buildInstruction(step, context); - expect(result).toContain('Write to /project/.takt/reports/20260129-test/00-plan.md'); + expect(result).toContain('Write to /project/.takt/runs/20260129-test/reports/00-plan.md'); expect(result).not.toContain('{report:00-plan.md}'); }); }); @@ -622,7 +718,7 @@ describe('instruction-builder', () => { function createReportContext(overrides: Partial = {}): ReportInstructionContext { return { cwd: '/project', - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', movementIteration: 1, language: 'en', ...overrides, @@ -663,12 +759,12 @@ describe('instruction-builder', () => { it('should include report directory and file for string report', () => { const step = createMinimalStep('Do work'); step.outputContracts = [{ name: '00-plan.md' }]; - const ctx = createReportContext({ reportDir: '/project/.takt/reports/20260130-test' }); + const ctx = createReportContext({ reportDir: '/project/.takt/runs/20260130-test/reports' }); const result = buildReportInstruction(step, ctx); - expect(result).toContain('- Report Directory: /project/.takt/reports/20260130-test/'); - expect(result).toContain('- Report File: /project/.takt/reports/20260130-test/00-plan.md'); + expect(result).toContain('- Report Directory: /project/.takt/runs/20260130-test/reports/'); + expect(result).toContain('- Report File: /project/.takt/runs/20260130-test/reports/00-plan.md'); }); it('should include report files for OutputContractEntry[] report', () => { @@ -681,10 +777,10 @@ describe('instruction-builder', () => { const result = buildReportInstruction(step, ctx); - expect(result).toContain('- Report Directory: /project/.takt/reports/20260129-test/'); + expect(result).toContain('- Report Directory: /project/.takt/runs/20260129-test/reports/'); expect(result).toContain('- Report Files:'); - expect(result).toContain(' - Scope: /project/.takt/reports/20260129-test/01-scope.md'); - expect(result).toContain(' - Decisions: /project/.takt/reports/20260129-test/02-decisions.md'); + expect(result).toContain(' - Scope: /project/.takt/runs/20260129-test/reports/01-scope.md'); + expect(result).toContain(' - Decisions: /project/.takt/runs/20260129-test/reports/02-decisions.md'); }); it('should include report file for OutputContractItem report', () => { @@ -694,7 +790,7 @@ describe('instruction-builder', () => { const result = buildReportInstruction(step, ctx); - expect(result).toContain('- Report File: /project/.takt/reports/20260129-test/00-plan.md'); + expect(result).toContain('- Report File: /project/.takt/runs/20260129-test/reports/00-plan.md'); }); it('should include auto-generated report output instruction', () => { @@ -706,7 +802,7 @@ describe('instruction-builder', () => { expect(result).toContain('**Report output:** Output to the `Report File` specified above.'); expect(result).toContain('- If file does not exist: Create new file'); - expect(result).toContain('Append with `## Iteration 1` section'); + expect(result).toContain('- If file exists: Move current content to `logs/reports-history/` and overwrite with latest report'); }); it('should include explicit order instead of auto-generated', () => { @@ -719,7 +815,7 @@ describe('instruction-builder', () => { const result = buildReportInstruction(step, ctx); - expect(result).toContain('Output to /project/.takt/reports/20260129-test/00-plan.md file.'); + expect(result).toContain('Output to /project/.takt/runs/20260129-test/reports/00-plan.md file.'); expect(result).not.toContain('**Report output:**'); }); @@ -737,14 +833,14 @@ describe('instruction-builder', () => { expect(result).toContain('# Plan'); }); - it('should replace {movement_iteration} in report output instruction', () => { + it('should include overwrite-and-archive rule in report output instruction', () => { const step = createMinimalStep('Do work'); step.outputContracts = [{ name: '00-plan.md' }]; const ctx = createReportContext({ movementIteration: 5 }); const result = buildReportInstruction(step, ctx); - expect(result).toContain('Append with `## Iteration 5` section'); + expect(result).toContain('Move current content to `logs/reports-history/` and overwrite with latest report'); }); it('should include instruction body text', () => { @@ -895,6 +991,24 @@ describe('instruction-builder', () => { expect(result).toContain('## Feedback\nReview feedback here'); }); + it('should apply truncation and source path when {previous_response} placeholder is used', () => { + const step = createMinimalStep('## Feedback\n{previous_response}\n\nFix the issues.'); + step.passPreviousResponse = true; + const context = createMinimalContext({ + previousOutput: { content: 'x'.repeat(2100), tag: '[TEST:1]' }, + previousResponseSourcePath: '.takt/runs/test/context/previous_responses/latest.md', + language: 'en', + }); + + const result = buildInstruction(step, context); + + expect(result).not.toContain('## Previous Response\n'); + expect(result).toContain('## Feedback'); + expect(result).toContain('...TRUNCATED...'); + expect(result).toContain('Source: .takt/runs/test/context/previous_responses/latest.md'); + expect(result).toContain('If prompt content conflicts with source files, source files take precedence.'); + }); + it('should skip auto-injected Additional User Inputs when template contains {user_inputs}', () => { const step = createMinimalStep('Inputs: {user_inputs}'); const context = createMinimalContext({ @@ -921,9 +1035,9 @@ describe('instruction-builder', () => { expect(result).toContain('Build the app'); }); - it('should replace {iteration} and {max_iterations}', () => { - const step = createMinimalStep('Step {iteration}/{max_iterations}'); - const context = createMinimalContext({ iteration: 3, maxIterations: 20 }); + it('should replace {iteration} and {max_movements}', () => { + const step = createMinimalStep('Step {iteration}/{max_movements}'); + const context = createMinimalContext({ iteration: 3, maxMovements: 20 }); const result = buildInstruction(step, context); diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index d5310424..76e29e5f 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -369,6 +369,70 @@ describe('interactiveMode', () => { expect(result.task).toBe('Fix login page with clarified scope.'); }); + it('should pass sessionId to provider when sessionId parameter is given', async () => { + // Given + setupRawStdin(toRawInputs(['hello', '/cancel'])); + setupMockProvider(['AI response']); + + // When + await interactiveMode('/project', undefined, undefined, 'test-session-id'); + + // Then: provider call should include the overridden sessionId + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + sessionId: 'test-session-id', + }), + ); + }); + + it('should abort in-flight provider call on SIGINT during initial input', async () => { + mockGetProvider.mockReturnValue({ + setup: () => ({ + call: vi.fn((_prompt: string, options: { abortSignal?: AbortSignal }) => { + return new Promise((resolve) => { + options.abortSignal?.addEventListener('abort', () => { + resolve({ + persona: 'interactive', + status: 'error', + content: 'aborted', + timestamp: new Date(), + }); + }, { once: true }); + }); + }), + }), + } as unknown as ReturnType); + + const promise = interactiveMode('/project', 'trigger'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const listeners = process.rawListeners('SIGINT') as Array<() => void>; + listeners[listeners.length - 1]?.(); + + const result = await promise; + expect(result.action).toBe('cancel'); + }); + + it('should use saved sessionId from initializeSession when no sessionId parameter is given', async () => { + // Given + setupRawStdin(toRawInputs(['hello', '/cancel'])); + setupMockProvider(['AI response']); + + // When: no sessionId parameter + await interactiveMode('/project'); + + // Then: provider call should include sessionId from initializeSession (undefined in mock) + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + sessionId: undefined, + }), + ); + }); + describe('/play command', () => { it('should return action=execute with task on /play command', async () => { // Given diff --git a/src/__tests__/it-error-recovery.test.ts b/src/__tests__/it-error-recovery.test.ts index 5b49af33..0635fc07 100644 --- a/src/__tests__/it-error-recovery.test.ts +++ b/src/__tests__/it-error-recovery.test.ts @@ -99,11 +99,11 @@ function buildEngineOptions(projectCwd: string) { }; } -function buildPiece(agentPaths: Record, maxIterations: number): PieceConfig { +function buildPiece(agentPaths: Record, maxMovements: number): PieceConfig { return { name: 'it-error', description: 'IT error recovery piece', - maxIterations, + maxMovements, initialMovement: 'plan', movements: [ makeMovement('plan', agentPaths.plan, [ diff --git a/src/__tests__/it-instruction-builder.test.ts b/src/__tests__/it-instruction-builder.test.ts index db161019..3753b10a 100644 --- a/src/__tests__/it-instruction-builder.test.ts +++ b/src/__tests__/it-instruction-builder.test.ts @@ -57,7 +57,7 @@ function makeContext(overrides: Partial = {}): InstructionCo return { task: 'Test task description', iteration: 3, - maxIterations: 30, + maxMovements: 30, movementIteration: 2, cwd: '/tmp/test-project', projectCwd: '/tmp/test-project', @@ -176,11 +176,11 @@ describe('Instruction Builder IT: user_inputs auto-injection', () => { }); describe('Instruction Builder IT: iteration variables', () => { - it('should replace {iteration}, {max_iterations}, {movement_iteration} in template', () => { + it('should replace {iteration}, {max_movements}, {movement_iteration} in template', () => { const step = makeMovement({ - instructionTemplate: 'Iter: {iteration}/{max_iterations}, movement iter: {movement_iteration}', + instructionTemplate: 'Iter: {iteration}/{max_movements}, movement iter: {movement_iteration}', }); - const ctx = makeContext({ iteration: 5, maxIterations: 30, movementIteration: 2 }); + const ctx = makeContext({ iteration: 5, maxMovements: 30, movementIteration: 2 }); const result = buildInstruction(step, ctx); @@ -189,7 +189,7 @@ describe('Instruction Builder IT: iteration variables', () => { it('should include iteration in Piece Context section', () => { const step = makeMovement(); - const ctx = makeContext({ iteration: 7, maxIterations: 20, movementIteration: 3 }); + const ctx = makeContext({ iteration: 7, maxMovements: 20, movementIteration: 3 }); const result = buildInstruction(step, ctx); @@ -203,11 +203,11 @@ describe('Instruction Builder IT: report_dir expansion', () => { const step = makeMovement({ instructionTemplate: 'Read the plan from {report_dir}/00-plan.md', }); - const ctx = makeContext({ reportDir: '/tmp/test-project/.takt/reports/20250126-task' }); + const ctx = makeContext({ reportDir: '/tmp/test-project/.takt/runs/20250126-task/reports' }); const result = buildInstruction(step, ctx); - expect(result).toContain('Read the plan from /tmp/test-project/.takt/reports/20250126-task/00-plan.md'); + expect(result).toContain('Read the plan from /tmp/test-project/.takt/runs/20250126-task/reports/00-plan.md'); }); it('should replace {report:filename} with full path', () => { @@ -289,13 +289,13 @@ describe('Instruction Builder IT: buildReportInstruction', () => { const result = buildReportInstruction(step, { cwd: '/tmp/test', - reportDir: '/tmp/test/.takt/reports/test-dir', + reportDir: '/tmp/test/.takt/runs/test-dir/reports', movementIteration: 1, language: 'en', }); expect(result).toContain('00-plan.md'); - expect(result).toContain('/tmp/test/.takt/reports/test-dir'); + expect(result).toContain('/tmp/test/.takt/runs/test-dir/reports'); expect(result).toContain('report'); }); diff --git a/src/__tests__/it-notification-sound.test.ts b/src/__tests__/it-notification-sound.test.ts index 497125f0..ce54a0f8 100644 --- a/src/__tests__/it-notification-sound.test.ts +++ b/src/__tests__/it-notification-sound.test.ts @@ -74,7 +74,7 @@ const { if (this.onIterationLimit) { await this.onIterationLimit({ currentIteration: 10, - maxIterations: 10, + maxMovements: 10, currentMovement: 'step1', }); } @@ -120,6 +120,8 @@ vi.mock('../infra/config/index.js', () => ({ updateWorktreeSession: vi.fn(), loadGlobalConfig: mockLoadGlobalConfig, saveSessionState: vi.fn(), + ensureDir: vi.fn(), + writeFileAtomic: vi.fn(), })); vi.mock('../shared/context.js', () => ({ @@ -151,23 +153,30 @@ vi.mock('../infra/fs/index.js', () => ({ status: _status, endTime: new Date().toISOString(), })), - updateLatestPointer: vi.fn(), initNdjsonLog: vi.fn().mockReturnValue('/tmp/test-log.jsonl'), appendNdjsonLine: vi.fn(), })); -vi.mock('../shared/utils/index.js', () => ({ - createLogger: vi.fn().mockReturnValue({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - notifySuccess: mockNotifySuccess, - notifyError: mockNotifyError, - playWarningSound: mockPlayWarningSound, - preventSleep: vi.fn(), -})); +vi.mock('../shared/utils/index.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createLogger: vi.fn().mockReturnValue({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + notifySuccess: mockNotifySuccess, + notifyError: mockNotifyError, + playWarningSound: mockPlayWarningSound, + preventSleep: vi.fn(), + isDebugEnabled: vi.fn().mockReturnValue(false), + writePromptLog: vi.fn(), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), + isValidReportDirName: vi.fn().mockImplementation((value: string) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)), + }; +}); vi.mock('../shared/prompt/index.js', () => ({ selectOption: mockSelectOption, @@ -192,7 +201,7 @@ import type { PieceConfig } from '../core/models/index.js'; function makeConfig(): PieceConfig { return { name: 'test-notify', - maxIterations: 10, + maxMovements: 10, initialMovement: 'step1', movements: [ { @@ -273,6 +282,22 @@ describe('executePiece: notification sound behavior', () => { expect(mockNotifySuccess).not.toHaveBeenCalled(); }); + + it('should NOT call notifySuccess when piece_complete event is disabled', async () => { + mockLoadGlobalConfig.mockReturnValue({ + provider: 'claude', + notificationSound: true, + notificationSoundEvents: { pieceComplete: false }, + }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + MockPieceEngine.latestInstance!.complete(); + await resultPromise; + + expect(mockNotifySuccess).not.toHaveBeenCalled(); + }); }); describe('notifyError on piece:abort', () => { @@ -311,6 +336,22 @@ describe('executePiece: notification sound behavior', () => { expect(mockNotifyError).not.toHaveBeenCalled(); }); + + it('should NOT call notifyError when piece_abort event is disabled', async () => { + mockLoadGlobalConfig.mockReturnValue({ + provider: 'claude', + notificationSound: true, + notificationSoundEvents: { pieceAbort: false }, + }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + MockPieceEngine.latestInstance!.abort(); + await resultPromise; + + expect(mockNotifyError).not.toHaveBeenCalled(); + }); }); describe('playWarningSound on iteration limit', () => { @@ -352,5 +393,22 @@ describe('executePiece: notification sound behavior', () => { expect(mockPlayWarningSound).not.toHaveBeenCalled(); }); + + it('should NOT call playWarningSound when iteration_limit event is disabled', async () => { + mockLoadGlobalConfig.mockReturnValue({ + provider: 'claude', + notificationSound: true, + notificationSoundEvents: { iterationLimit: false }, + }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + await MockPieceEngine.latestInstance!.triggerIterationLimit(); + MockPieceEngine.latestInstance!.abort(); + await resultPromise; + + expect(mockPlayWarningSound).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/__tests__/it-piece-execution.test.ts b/src/__tests__/it-piece-execution.test.ts index a836d505..3539bb74 100644 --- a/src/__tests__/it-piece-execution.test.ts +++ b/src/__tests__/it-piece-execution.test.ts @@ -105,7 +105,7 @@ function buildSimplePiece(agentPaths: Record): PieceConfig { return { name: 'it-simple', description: 'IT simple piece', - maxIterations: 15, + maxMovements: 15, initialMovement: 'plan', movements: [ makeMovement('plan', agentPaths.planner, [ @@ -128,7 +128,7 @@ function buildLoopPiece(agentPaths: Record): PieceConfig { return { name: 'it-loop', description: 'IT piece with fix loop', - maxIterations: 20, + maxMovements: 20, initialMovement: 'plan', movements: [ makeMovement('plan', agentPaths.planner, [ @@ -286,7 +286,7 @@ describe('Piece Engine IT: Max Iterations', () => { rmSync(testDir, { recursive: true, force: true }); }); - it('should abort when maxIterations exceeded in infinite loop', async () => { + it('should abort when maxMovements exceeded in infinite loop', async () => { // Create an infinite loop: plan always goes to implement, implement always goes back to plan const infiniteScenario = Array.from({ length: 10 }, (_, i) => ({ status: 'done' as const, @@ -295,7 +295,7 @@ describe('Piece Engine IT: Max Iterations', () => { setMockScenario(infiniteScenario); const config = buildSimplePiece(agentPaths); - config.maxIterations = 5; + config.maxMovements = 5; const engine = new PieceEngine(config, testDir, 'Looping task', { ...buildEngineOptions(testDir), diff --git a/src/__tests__/it-piece-loader.test.ts b/src/__tests__/it-piece-loader.test.ts index de34adbc..3ab53ac6 100644 --- a/src/__tests__/it-piece-loader.test.ts +++ b/src/__tests__/it-piece-loader.test.ts @@ -58,7 +58,7 @@ describe('Piece Loader IT: builtin piece loading', () => { expect(config!.name).toBe(name); expect(config!.movements.length).toBeGreaterThan(0); expect(config!.initialMovement).toBeDefined(); - expect(config!.maxIterations).toBeGreaterThan(0); + expect(config!.maxMovements).toBeGreaterThan(0); }); } @@ -123,7 +123,7 @@ describe('Piece Loader IT: project-local piece override', () => { writeFileSync(join(piecesDir, 'custom-wf.yaml'), ` name: custom-wf description: Custom project piece -max_iterations: 5 +max_movements: 5 initial_movement: start movements: @@ -250,11 +250,11 @@ describe('Piece Loader IT: piece config validation', () => { rmSync(testDir, { recursive: true, force: true }); }); - it('should set max_iterations from YAML', () => { + it('should set max_movements from YAML', () => { const config = loadPiece('minimal', testDir); expect(config).not.toBeNull(); - expect(typeof config!.maxIterations).toBe('number'); - expect(config!.maxIterations).toBeGreaterThan(0); + expect(typeof config!.maxMovements).toBe('number'); + expect(config!.maxMovements).toBeGreaterThan(0); }); it('should set initial_movement from YAML', () => { @@ -397,7 +397,7 @@ describe('Piece Loader IT: quality_gates loading', () => { writeFileSync(join(piecesDir, 'with-gates.yaml'), ` name: with-gates description: Piece with quality gates -max_iterations: 5 +max_movements: 5 initial_movement: implement movements: @@ -434,7 +434,7 @@ movements: writeFileSync(join(piecesDir, 'no-gates.yaml'), ` name: no-gates description: Piece without quality gates -max_iterations: 5 +max_movements: 5 initial_movement: implement movements: @@ -461,7 +461,7 @@ movements: writeFileSync(join(piecesDir, 'empty-gates.yaml'), ` name: empty-gates description: Piece with empty quality gates -max_iterations: 5 +max_movements: 5 initial_movement: implement movements: @@ -501,7 +501,7 @@ describe('Piece Loader IT: mcp_servers parsing', () => { writeFileSync(join(piecesDir, 'with-mcp.yaml'), ` name: with-mcp description: Piece with MCP servers -max_iterations: 5 +max_movements: 5 initial_movement: e2e-test movements: @@ -541,7 +541,7 @@ movements: writeFileSync(join(piecesDir, 'no-mcp.yaml'), ` name: no-mcp description: Piece without MCP servers -max_iterations: 5 +max_movements: 5 initial_movement: implement movements: @@ -568,7 +568,7 @@ movements: writeFileSync(join(piecesDir, 'multi-mcp.yaml'), ` name: multi-mcp description: Piece with multiple MCP servers -max_iterations: 5 +max_movements: 5 initial_movement: test movements: @@ -625,7 +625,7 @@ describe('Piece Loader IT: structural-reform piece', () => { expect(config).not.toBeNull(); expect(config!.name).toBe('structural-reform'); expect(config!.movements.length).toBe(7); - expect(config!.maxIterations).toBe(50); + expect(config!.maxMovements).toBe(50); expect(config!.initialMovement).toBe('review'); }); diff --git a/src/__tests__/it-pipeline-modes.test.ts b/src/__tests__/it-pipeline-modes.test.ts index 241cac1e..0916befd 100644 --- a/src/__tests__/it-pipeline-modes.test.ts +++ b/src/__tests__/it-pipeline-modes.test.ts @@ -96,7 +96,6 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ iterations: 0, }), finalizeSessionLog: vi.fn().mockImplementation((log, status) => ({ ...log, status })), - updateLatestPointer: vi.fn(), initNdjsonLog: vi.fn().mockReturnValue('/tmp/test.ndjson'), appendNdjsonLine: vi.fn(), generateReportDir: vi.fn().mockReturnValue('test-report-dir'), @@ -172,7 +171,7 @@ function createTestPieceDir(): { dir: string; piecePath: string } { const pieceYaml = ` name: it-pipeline description: Pipeline test piece -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: diff --git a/src/__tests__/it-pipeline.test.ts b/src/__tests__/it-pipeline.test.ts index a796e594..ad027238 100644 --- a/src/__tests__/it-pipeline.test.ts +++ b/src/__tests__/it-pipeline.test.ts @@ -78,7 +78,6 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ iterations: 0, }), finalizeSessionLog: vi.fn().mockImplementation((log, status) => ({ ...log, status })), - updateLatestPointer: vi.fn(), initNdjsonLog: vi.fn().mockReturnValue('/tmp/test.ndjson'), appendNdjsonLine: vi.fn(), generateReportDir: vi.fn().mockReturnValue('test-report-dir'), @@ -139,8 +138,8 @@ import { executePipeline } from '../features/pipeline/index.js'; function createTestPieceDir(): { dir: string; piecePath: string } { const dir = mkdtempSync(join(tmpdir(), 'takt-it-pipeline-')); - // Create .takt/reports structure - mkdirSync(join(dir, '.takt', 'reports', 'test-report-dir'), { recursive: true }); + // Create .takt/runs structure + mkdirSync(join(dir, '.takt', 'runs', 'test-report-dir', 'reports'), { recursive: true }); // Create persona prompt files const personasDir = join(dir, 'personas'); @@ -153,7 +152,7 @@ function createTestPieceDir(): { dir: string; piecePath: string } { const pieceYaml = ` name: it-simple description: Integration test piece -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: diff --git a/src/__tests__/it-sigint-interrupt.test.ts b/src/__tests__/it-sigint-interrupt.test.ts index f1e0e52d..28abafec 100644 --- a/src/__tests__/it-sigint-interrupt.test.ts +++ b/src/__tests__/it-sigint-interrupt.test.ts @@ -27,14 +27,18 @@ const { mockInterruptAllQueries, MockPieceEngine } = vi.hoisted(() => { class MockPieceEngine extends EE { private abortRequested = false; private runResolve: ((value: { status: string; iteration: number }) => void) | null = null; + static lastOptions: { abortSignal?: AbortSignal } | null = null; constructor( _config: unknown, _cwd: string, _task: string, - _options: unknown, + options: unknown, ) { super(); + if (options && typeof options === 'object') { + MockPieceEngine.lastOptions = options as { abortSignal?: AbortSignal }; + } } abort(): void { @@ -86,6 +90,8 @@ vi.mock('../infra/config/index.js', () => ({ updateWorktreeSession: vi.fn(), loadGlobalConfig: vi.fn().mockReturnValue({ provider: 'claude' }), saveSessionState: vi.fn(), + ensureDir: vi.fn(), + writeFileAtomic: vi.fn(), })); vi.mock('../shared/context.js', () => ({ @@ -117,25 +123,30 @@ vi.mock('../infra/fs/index.js', () => ({ status: _status, endTime: new Date().toISOString(), })), - updateLatestPointer: vi.fn(), initNdjsonLog: vi.fn().mockReturnValue('/tmp/test-log.jsonl'), appendNdjsonLine: vi.fn(), })); -vi.mock('../shared/utils/index.js', () => ({ - createLogger: vi.fn().mockReturnValue({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - notifySuccess: vi.fn(), - notifyError: vi.fn(), - playWarningSound: vi.fn(), - preventSleep: vi.fn(), - isDebugEnabled: vi.fn().mockReturnValue(false), - writePromptLog: vi.fn(), -})); +vi.mock('../shared/utils/index.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createLogger: vi.fn().mockReturnValue({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + notifySuccess: vi.fn(), + notifyError: vi.fn(), + playWarningSound: vi.fn(), + preventSleep: vi.fn(), + isDebugEnabled: vi.fn().mockReturnValue(false), + writePromptLog: vi.fn(), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), + isValidReportDirName: vi.fn().mockImplementation((value: string) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)), + }; +}); vi.mock('../shared/prompt/index.js', () => ({ selectOption: vi.fn(), @@ -163,6 +174,7 @@ describe('executePiece: SIGINT handler integration', () => { beforeEach(() => { vi.clearAllMocks(); + MockPieceEngine.lastOptions = null; tmpDir = join(tmpdir(), `takt-sigint-it-${randomUUID()}`); mkdirSync(tmpDir, { recursive: true }); mkdirSync(join(tmpDir, '.takt', 'reports'), { recursive: true }); @@ -189,7 +201,7 @@ describe('executePiece: SIGINT handler integration', () => { function makeConfig(): PieceConfig { return { name: 'test-sigint', - maxIterations: 10, + maxMovements: 10, initialMovement: 'step1', movements: [ { @@ -236,6 +248,30 @@ describe('executePiece: SIGINT handler integration', () => { expect(result.success).toBe(false); }); + it('should abort provider signal on first SIGINT', async () => { + const config = makeConfig(); + + const resultPromise = executePiece(config, 'test task', tmpDir, { + projectCwd: tmpDir, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const signal = MockPieceEngine.lastOptions?.abortSignal; + expect(signal).toBeDefined(); + expect(signal!.aborted).toBe(false); + + const allListeners = process.rawListeners('SIGINT') as ((...args: unknown[]) => void)[]; + const newListener = allListeners.find((l) => !savedSigintListeners.includes(l)); + expect(newListener).toBeDefined(); + newListener!(); + + expect(signal!.aborted).toBe(true); + + const result = await resultPromise; + expect(result.success).toBe(false); + }); + it('should register EPIPE handler before calling interruptAllQueries', async () => { const config = makeConfig(); diff --git a/src/__tests__/it-stage-and-commit.test.ts b/src/__tests__/it-stage-and-commit.test.ts index 496b7bd4..4648dc1d 100644 --- a/src/__tests__/it-stage-and-commit.test.ts +++ b/src/__tests__/it-stage-and-commit.test.ts @@ -2,7 +2,7 @@ * Integration test for stageAndCommit * * Tests that gitignored files are NOT included in commits. - * Regression test for c89ac4c where `git add -f .takt/reports/` caused + * Regression test for c89ac4c where `git add -f .takt/runs/` caused * gitignored report files to be committed. */ @@ -36,15 +36,15 @@ describe('stageAndCommit', () => { } }); - it('should not commit gitignored .takt/reports/ files', () => { + it('should not commit gitignored .takt/runs/ files', () => { // Setup: .takt/ is gitignored writeFileSync(join(testDir, '.gitignore'), '.takt/\n'); execFileSync('git', ['add', '.gitignore'], { cwd: testDir }); execFileSync('git', ['commit', '-m', 'Add gitignore'], { cwd: testDir }); - // Create .takt/reports/ with a report file - mkdirSync(join(testDir, '.takt', 'reports', 'test-report'), { recursive: true }); - writeFileSync(join(testDir, '.takt', 'reports', 'test-report', '00-plan.md'), '# Plan'); + // Create .takt/runs/ with a report file + mkdirSync(join(testDir, '.takt', 'runs', 'test-report', 'reports'), { recursive: true }); + writeFileSync(join(testDir, '.takt', 'runs', 'test-report', 'reports', '00-plan.md'), '# Plan'); // Also create a tracked file change to ensure commit happens writeFileSync(join(testDir, 'src.ts'), 'export const x = 1;'); @@ -52,7 +52,7 @@ describe('stageAndCommit', () => { const hash = stageAndCommit(testDir, 'test commit'); expect(hash).toBeDefined(); - // Verify .takt/reports/ is NOT in the commit + // Verify .takt/runs/ is NOT in the commit const committedFiles = execFileSync('git', ['diff-tree', '--no-commit-id', '-r', '--name-only', 'HEAD'], { cwd: testDir, encoding: 'utf-8', @@ -60,7 +60,7 @@ describe('stageAndCommit', () => { }).trim(); expect(committedFiles).toContain('src.ts'); - expect(committedFiles).not.toContain('.takt/reports/'); + expect(committedFiles).not.toContain('.takt/runs/'); }); it('should commit normally when no gitignored files exist', () => { diff --git a/src/__tests__/it-three-phase-execution.test.ts b/src/__tests__/it-three-phase-execution.test.ts index c87380b3..a7e250a7 100644 --- a/src/__tests__/it-three-phase-execution.test.ts +++ b/src/__tests__/it-three-phase-execution.test.ts @@ -133,7 +133,7 @@ describe('Three-Phase Execution IT: phase1 only (no report, no tag rules)', () = const config: PieceConfig = { name: 'it-phase1-only', description: 'Test', - maxIterations: 5, + maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ @@ -185,7 +185,7 @@ describe('Three-Phase Execution IT: phase1 + phase2 (report defined)', () => { const config: PieceConfig = { name: 'it-phase1-2', description: 'Test', - maxIterations: 5, + maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ @@ -215,7 +215,7 @@ describe('Three-Phase Execution IT: phase1 + phase2 (report defined)', () => { const config: PieceConfig = { name: 'it-phase1-2-multi', description: 'Test', - maxIterations: 5, + maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ @@ -266,7 +266,7 @@ describe('Three-Phase Execution IT: phase1 + phase3 (tag rules defined)', () => const config: PieceConfig = { name: 'it-phase1-3', description: 'Test', - maxIterations: 5, + maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ @@ -317,7 +317,7 @@ describe('Three-Phase Execution IT: all three phases', () => { const config: PieceConfig = { name: 'it-all-phases', description: 'Test', - maxIterations: 5, + maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ @@ -377,7 +377,7 @@ describe('Three-Phase Execution IT: phase3 tag → rule match', () => { const config: PieceConfig = { name: 'it-phase3-tag', description: 'Test', - maxIterations: 5, + maxMovements: 5, initialMovement: 'step1', movements: [ makeMovement('step1', agentPath, [ diff --git a/src/__tests__/judgment-fallback.test.ts b/src/__tests__/judgment-fallback.test.ts index e7d80bf2..0d7d5606 100644 --- a/src/__tests__/judgment-fallback.test.ts +++ b/src/__tests__/judgment-fallback.test.ts @@ -3,8 +3,12 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import type { PieceMovement } from '../core/models/types.js'; import type { JudgmentContext } from '../core/piece/judgment/FallbackStrategy.js'; +import { runAgent } from '../agents/runner.js'; import { AutoSelectStrategy, ReportBasedStrategy, @@ -88,6 +92,48 @@ describe('JudgmentStrategies', () => { // mockStep has no outputContracts field → getReportFiles returns [] expect(strategy.canApply(mockContext)).toBe(false); }); + + it('should use only latest report file from reports directory', async () => { + const tmpRoot = mkdtempSync(join(tmpdir(), 'takt-judgment-report-')); + try { + const reportDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'reports'); + const historyDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'logs', 'reports-history'); + mkdirSync(reportDir, { recursive: true }); + mkdirSync(historyDir, { recursive: true }); + + const latestFile = '05-architect-review.md'; + writeFileSync(join(reportDir, latestFile), 'LATEST-ONLY-CONTENT'); + writeFileSync(join(historyDir, '05-architect-review.20260210T061143Z.md'), 'OLD-HISTORY-CONTENT'); + + const stepWithOutputContracts: PieceMovement = { + ...mockStep, + outputContracts: [{ name: latestFile }], + }; + + const runAgentMock = vi.mocked(runAgent); + runAgentMock.mockResolvedValue({ + persona: 'conductor', + status: 'done', + content: '[TEST-MOVEMENT:1]', + timestamp: new Date('2026-02-10T07:11:43Z'), + }); + + const strategy = new ReportBasedStrategy(); + const result = await strategy.execute({ + ...mockContext, + step: stepWithOutputContracts, + reportDir, + }); + + expect(result.success).toBe(true); + expect(runAgentMock).toHaveBeenCalledTimes(1); + const instruction = runAgentMock.mock.calls[0]?.[1]; + expect(instruction).toContain('LATEST-ONLY-CONTENT'); + expect(instruction).not.toContain('OLD-HISTORY-CONTENT'); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + } + }); }); describe('ResponseBasedStrategy', () => { diff --git a/src/__tests__/knowledge.test.ts b/src/__tests__/knowledge.test.ts index 45266aa5..baa8839f 100644 --- a/src/__tests__/knowledge.test.ts +++ b/src/__tests__/knowledge.test.ts @@ -330,7 +330,7 @@ function createMinimalContext(overrides: Partial = {}): Inst return { task: 'Test task', iteration: 1, - maxIterations: 10, + maxMovements: 10, movementIteration: 1, cwd: '/tmp/test', projectCwd: '/tmp/test', diff --git a/src/__tests__/models.test.ts b/src/__tests__/models.test.ts index db67628f..0709d022 100644 --- a/src/__tests__/models.test.ts +++ b/src/__tests__/models.test.ts @@ -81,7 +81,7 @@ describe('PieceConfigRawSchema', () => { expect(result.name).toBe('test-piece'); expect(result.movements).toHaveLength(1); expect(result.movements![0]?.allowed_tools).toEqual(['Read', 'Grep']); - expect(result.max_iterations).toBe(10); + expect(result.max_movements).toBe(10); }); it('should parse movement with permission_mode', () => { @@ -410,15 +410,20 @@ describe('GlobalConfigSchema', () => { expect(result.default_piece).toBe('default'); expect(result.log_level).toBe('info'); expect(result.provider).toBe('claude'); + expect(result.observability).toBeUndefined(); }); it('should accept valid config', () => { const config = { default_piece: 'custom', log_level: 'debug' as const, + observability: { + provider_events: false, + }, }; const result = GlobalConfigSchema.parse(config); expect(result.log_level).toBe('debug'); + expect(result.observability?.provider_events).toBe(false); }); }); diff --git a/src/__tests__/opencode-client-cleanup.test.ts b/src/__tests__/opencode-client-cleanup.test.ts new file mode 100644 index 00000000..af74d2cf --- /dev/null +++ b/src/__tests__/opencode-client-cleanup.test.ts @@ -0,0 +1,535 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +class MockEventStream implements AsyncGenerator { + private index = 0; + private readonly events: unknown[]; + readonly returnSpy = vi.fn(async () => ({ done: true as const, value: undefined })); + + constructor(events: unknown[]) { + this.events = events; + } + + [Symbol.asyncIterator](): AsyncGenerator { + return this; + } + + async next(): Promise> { + if (this.index >= this.events.length) { + return { done: true, value: undefined }; + } + const value = this.events[this.index]; + this.index += 1; + return { done: false, value }; + } + + async return(): Promise> { + return this.returnSpy(); + } + + async throw(e?: unknown): Promise> { + throw e; + } +} + +const { createOpencodeMock } = vi.hoisted(() => ({ + createOpencodeMock: vi.fn(), +})); + +vi.mock('node:net', () => ({ + createServer: () => { + const handlers = new Map void>(); + return { + unref: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(event, handler); + }), + listen: vi.fn((_port: number, _host: string, cb: () => void) => { + cb(); + }), + address: vi.fn(() => ({ port: 62000 })), + close: vi.fn((cb?: (err?: Error) => void) => cb?.()), + }; + }, +})); + +vi.mock('@opencode-ai/sdk/v2', () => ({ + createOpencode: createOpencodeMock, +})); + +describe('OpenCodeClient stream cleanup', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should close SSE stream when session.idle is received', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'session.idle', + properties: { sessionID: 'session-1' }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-1' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + + const subscribe = vi.fn().mockResolvedValue({ stream }); + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('interactive', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + }); + + expect(result.status).toBe('done'); + expect(stream.returnSpy).toHaveBeenCalled(); + expect(disposeInstance).toHaveBeenCalledWith( + { directory: '/tmp' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(subscribe).toHaveBeenCalledWith( + { directory: '/tmp' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('should close SSE stream when session.error is received', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'session.error', + properties: { + sessionID: 'session-2', + error: { name: 'Error', data: { message: 'boom' } }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-2' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + + const subscribe = vi.fn().mockResolvedValue({ stream }); + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('interactive', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + }); + + expect(result.status).toBe('error'); + expect(result.content).toContain('boom'); + expect(stream.returnSpy).toHaveBeenCalled(); + expect(disposeInstance).toHaveBeenCalledWith( + { directory: '/tmp' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(subscribe).toHaveBeenCalledWith( + { directory: '/tmp' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('should continue after assistant message completed and finish on session.idle', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'message.part.updated', + properties: { + part: { id: 'p-1', type: 'text', text: 'done' }, + delta: 'done', + }, + }, + { + type: 'message.updated', + properties: { + info: { + sessionID: 'session-3', + role: 'assistant', + time: { created: Date.now(), completed: Date.now() + 1 }, + }, + }, + }, + { + type: 'message.part.updated', + properties: { + part: { id: 'p-1', type: 'text', text: 'done more' }, + delta: ' more', + }, + }, + { + type: 'session.idle', + properties: { sessionID: 'session-3' }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-3' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + + const subscribe = vi.fn().mockResolvedValue({ stream }); + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await Promise.race([ + client.call('interactive', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), 500)), + ]); + + expect(result.status).toBe('done'); + expect(result.content).toBe('done more'); + expect(disposeInstance).toHaveBeenCalledWith( + { directory: '/tmp' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(subscribe).toHaveBeenCalledWith( + { directory: '/tmp' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('should reject question.asked without handler and continue processing', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'question.asked', + properties: { + id: 'q-1', + sessionID: 'session-4', + questions: [ + { + question: 'Select one', + header: 'Question', + options: [{ label: 'A', description: 'A desc' }], + }, + ], + }, + }, + { + type: 'message.part.updated', + properties: { + part: { id: 'p-q1', type: 'text', text: 'continued response' }, + delta: 'continued response', + }, + }, + { + type: 'session.idle', + properties: { sessionID: 'session-4' }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-4' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const questionReject = vi.fn().mockResolvedValue({ data: true }); + + const subscribe = vi.fn().mockResolvedValue({ stream }); + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + question: { reject: questionReject, reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('interactive', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + }); + + expect(result.status).toBe('done'); + expect(result.content).toBe('continued response'); + expect(questionReject).toHaveBeenCalledWith( + { + requestID: 'q-1', + directory: '/tmp', + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('should answer question.asked when handler is configured', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'question.asked', + properties: { + id: 'q-2', + sessionID: 'session-5', + questions: [ + { + question: 'Select one', + header: 'Question', + options: [{ label: 'A', description: 'A desc' }], + }, + ], + }, + }, + { + type: 'message.updated', + properties: { + info: { + sessionID: 'session-5', + role: 'assistant', + time: { created: Date.now(), completed: Date.now() + 1 }, + }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-5' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const questionReply = vi.fn().mockResolvedValue({ data: true }); + + const subscribe = vi.fn().mockResolvedValue({ stream }); + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + question: { reject: vi.fn(), reply: questionReply }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('interactive', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + onAskUserQuestion: async () => ({ Question: 'A' }), + }); + + expect(result.status).toBe('done'); + expect(questionReply).toHaveBeenCalledWith( + { + requestID: 'q-2', + directory: '/tmp', + answers: [['A']], + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('should pass mapped tools to promptAsync when allowedTools is set', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'message.updated', + properties: { + info: { + sessionID: 'session-tools', + role: 'assistant', + time: { created: Date.now(), completed: Date.now() + 1 }, + }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-tools' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const subscribe = vi.fn().mockResolvedValue({ stream }); + + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('coder', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + allowedTools: ['Read', 'Edit', 'Bash', 'WebSearch', 'WebFetch', 'mcp__github__search'], + }); + + expect(result.status).toBe('done'); + expect(promptAsync).toHaveBeenCalledWith( + expect.objectContaining({ + tools: { + read: true, + edit: true, + bash: true, + websearch: true, + webfetch: true, + mcp__github__search: true, + }, + }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('should configure allow permissions for edit mode', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'message.updated', + properties: { + info: { + sessionID: 'session-perm', + role: 'assistant', + time: { created: Date.now(), completed: Date.now() + 1 }, + }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-perm' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const subscribe = vi.fn().mockResolvedValue({ stream }); + + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + await client.call('coder', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + permissionMode: 'edit', + }); + + const createCallArgs = createOpencodeMock.mock.calls[0]?.[0] as { config?: Record }; + const permission = createCallArgs.config?.permission as Record; + expect(permission.read).toBe('allow'); + expect(permission.edit).toBe('allow'); + expect(permission.write).toBe('allow'); + expect(permission.bash).toBe('allow'); + expect(permission.question).toBe('deny'); + }); + + it('should pass permission ruleset to session.create', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'message.updated', + properties: { + info: { + sessionID: 'session-ruleset', + role: 'assistant', + time: { created: Date.now(), completed: Date.now() + 1 }, + }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-ruleset' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const subscribe = vi.fn().mockResolvedValue({ stream }); + + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + await client.call('coder', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + permissionMode: 'edit', + }); + + expect(sessionCreate).toHaveBeenCalledWith(expect.objectContaining({ + directory: '/tmp', + permission: expect.arrayContaining([ + expect.objectContaining({ permission: 'edit', action: 'allow' }), + expect.objectContaining({ permission: 'question', action: 'deny' }), + ]), + })); + }); + + it('should fail fast when permission reply times out', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'permission.asked', + properties: { + id: 'perm-1', + sessionID: 'session-perm-timeout', + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-perm-timeout' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const subscribe = vi.fn().mockResolvedValue({ stream }); + const permissionReply = vi.fn().mockImplementation(() => new Promise(() => {})); + + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: permissionReply }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await Promise.race([ + client.call('coder', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + permissionMode: 'edit', + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), 8000)), + ]); + + expect(result.status).toBe('error'); + expect(result.content).toContain('permission reply timed out'); + }); +}); diff --git a/src/__tests__/opencode-config.test.ts b/src/__tests__/opencode-config.test.ts new file mode 100644 index 00000000..6e181f64 --- /dev/null +++ b/src/__tests__/opencode-config.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for OpenCode integration in schemas and global config + */ + +import { describe, it, expect } from 'vitest'; +import { + GlobalConfigSchema, + ProjectConfigSchema, + CustomAgentConfigSchema, + PieceMovementRawSchema, + ParallelSubMovementRawSchema, +} from '../core/models/index.js'; + +describe('Schemas accept opencode provider', () => { + it('should accept opencode in GlobalConfigSchema provider field', () => { + const result = GlobalConfigSchema.parse({ provider: 'opencode' }); + expect(result.provider).toBe('opencode'); + }); + + it('should accept opencode in GlobalConfigSchema persona_providers field', () => { + const result = GlobalConfigSchema.parse({ + persona_providers: { coder: 'opencode' }, + }); + expect(result.persona_providers).toEqual({ coder: 'opencode' }); + }); + + it('should accept opencode_api_key in GlobalConfigSchema', () => { + const result = GlobalConfigSchema.parse({ + opencode_api_key: 'test-key-123', + }); + expect(result.opencode_api_key).toBe('test-key-123'); + }); + + it('should accept opencode in ProjectConfigSchema', () => { + const result = ProjectConfigSchema.parse({ provider: 'opencode' }); + expect(result.provider).toBe('opencode'); + }); + + it('should accept opencode in CustomAgentConfigSchema', () => { + const result = CustomAgentConfigSchema.parse({ + name: 'test', + prompt: 'You are a test agent', + provider: 'opencode', + }); + expect(result.provider).toBe('opencode'); + }); + + it('should accept opencode in PieceMovementRawSchema', () => { + const result = PieceMovementRawSchema.parse({ + name: 'test-movement', + provider: 'opencode', + }); + expect(result.provider).toBe('opencode'); + }); + + it('should accept opencode in ParallelSubMovementRawSchema', () => { + const result = ParallelSubMovementRawSchema.parse({ + name: 'sub-1', + provider: 'opencode', + }); + expect(result.provider).toBe('opencode'); + }); + + it('should still accept existing providers (claude, codex, mock)', () => { + for (const provider of ['claude', 'codex', 'mock']) { + const result = GlobalConfigSchema.parse({ provider }); + expect(result.provider).toBe(provider); + } + }); +}); diff --git a/src/__tests__/opencode-provider.test.ts b/src/__tests__/opencode-provider.test.ts new file mode 100644 index 00000000..d7cbe745 --- /dev/null +++ b/src/__tests__/opencode-provider.test.ts @@ -0,0 +1,67 @@ +/** + * Tests for OpenCode provider implementation + */ + +import { describe, it, expect } from 'vitest'; +import { OpenCodeProvider } from '../infra/providers/opencode.js'; +import { ProviderRegistry } from '../infra/providers/index.js'; + +describe('OpenCodeProvider', () => { + it('should throw when claudeAgent is specified', () => { + const provider = new OpenCodeProvider(); + + expect(() => provider.setup({ + name: 'test', + claudeAgent: 'some-agent', + })).toThrow('Claude Code agent calls are not supported by the OpenCode provider'); + }); + + it('should throw when claudeSkill is specified', () => { + const provider = new OpenCodeProvider(); + + expect(() => provider.setup({ + name: 'test', + claudeSkill: 'some-skill', + })).toThrow('Claude Code skill calls are not supported by the OpenCode provider'); + }); + + it('should return a ProviderAgent when setup with name only', () => { + const provider = new OpenCodeProvider(); + const agent = provider.setup({ name: 'test' }); + + expect(agent).toBeDefined(); + expect(typeof agent.call).toBe('function'); + }); + + it('should return a ProviderAgent when setup with systemPrompt', () => { + const provider = new OpenCodeProvider(); + const agent = provider.setup({ + name: 'test', + systemPrompt: 'You are a helpful assistant.', + }); + + expect(agent).toBeDefined(); + expect(typeof agent.call).toBe('function'); + }); +}); + +describe('ProviderRegistry with OpenCode', () => { + it('should return OpenCode provider from registry', () => { + ProviderRegistry.resetInstance(); + const registry = ProviderRegistry.getInstance(); + const provider = registry.get('opencode'); + + expect(provider).toBeDefined(); + expect(provider).toBeInstanceOf(OpenCodeProvider); + }); + + it('should setup an agent through the registry', () => { + ProviderRegistry.resetInstance(); + const registry = ProviderRegistry.getInstance(); + const provider = registry.get('opencode'); + const agent = provider.setup({ name: 'test' }); + + expect(agent).toBeDefined(); + expect(typeof agent.call).toBe('function'); + }); +}); diff --git a/src/__tests__/opencode-stream-handler.test.ts b/src/__tests__/opencode-stream-handler.test.ts new file mode 100644 index 00000000..108fc100 --- /dev/null +++ b/src/__tests__/opencode-stream-handler.test.ts @@ -0,0 +1,386 @@ +/** + * Tests for OpenCode stream event handling + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + createStreamTrackingState, + emitInit, + emitText, + emitThinking, + emitToolUse, + emitToolResult, + emitResult, + handlePartUpdated, + type OpenCodeStreamEvent, + type OpenCodeTextPart, + type OpenCodeReasoningPart, + type OpenCodeToolPart, +} from '../infra/opencode/OpenCodeStreamHandler.js'; +import type { StreamCallback } from '../core/piece/types.js'; + +describe('createStreamTrackingState', () => { + it('should create fresh state with empty collections', () => { + const state = createStreamTrackingState(); + + expect(state.textOffsets.size).toBe(0); + expect(state.thinkingOffsets.size).toBe(0); + expect(state.startedTools.size).toBe(0); + }); +}); + +describe('emitInit', () => { + it('should emit init event with model and sessionId', () => { + const onStream = vi.fn(); + + emitInit(onStream, 'opencode/big-pickle', 'session-123'); + + expect(onStream).toHaveBeenCalledOnce(); + expect(onStream).toHaveBeenCalledWith({ + type: 'init', + data: { model: 'opencode/big-pickle', sessionId: 'session-123' }, + }); + }); + + it('should not emit when onStream is undefined', () => { + emitInit(undefined, 'opencode/big-pickle', 'session-123'); + }); +}); + +describe('emitText', () => { + it('should emit text event', () => { + const onStream = vi.fn(); + + emitText(onStream, 'Hello world'); + + expect(onStream).toHaveBeenCalledWith({ + type: 'text', + data: { text: 'Hello world' }, + }); + }); + + it('should not emit when text is empty', () => { + const onStream = vi.fn(); + + emitText(onStream, ''); + + expect(onStream).not.toHaveBeenCalled(); + }); + + it('should not emit when onStream is undefined', () => { + emitText(undefined, 'Hello'); + }); +}); + +describe('emitThinking', () => { + it('should emit thinking event', () => { + const onStream = vi.fn(); + + emitThinking(onStream, 'Reasoning...'); + + expect(onStream).toHaveBeenCalledWith({ + type: 'thinking', + data: { thinking: 'Reasoning...' }, + }); + }); + + it('should not emit when thinking is empty', () => { + const onStream = vi.fn(); + + emitThinking(onStream, ''); + + expect(onStream).not.toHaveBeenCalled(); + }); +}); + +describe('emitToolUse', () => { + it('should emit tool_use event', () => { + const onStream = vi.fn(); + + emitToolUse(onStream, 'Bash', { command: 'ls' }, 'tool-1'); + + expect(onStream).toHaveBeenCalledWith({ + type: 'tool_use', + data: { tool: 'Bash', input: { command: 'ls' }, id: 'tool-1' }, + }); + }); +}); + +describe('emitToolResult', () => { + it('should emit tool_result event for success', () => { + const onStream = vi.fn(); + + emitToolResult(onStream, 'file.txt', false); + + expect(onStream).toHaveBeenCalledWith({ + type: 'tool_result', + data: { content: 'file.txt', isError: false }, + }); + }); + + it('should emit tool_result event for error', () => { + const onStream = vi.fn(); + + emitToolResult(onStream, 'command not found', true); + + expect(onStream).toHaveBeenCalledWith({ + type: 'tool_result', + data: { content: 'command not found', isError: true }, + }); + }); +}); + +describe('emitResult', () => { + it('should emit result event for success', () => { + const onStream = vi.fn(); + + emitResult(onStream, true, 'Completed', 'session-1'); + + expect(onStream).toHaveBeenCalledWith({ + type: 'result', + data: { + result: 'Completed', + sessionId: 'session-1', + success: true, + error: undefined, + }, + }); + }); + + it('should emit result event for failure', () => { + const onStream = vi.fn(); + + emitResult(onStream, false, 'Network error', 'session-1'); + + expect(onStream).toHaveBeenCalledWith({ + type: 'result', + data: { + result: 'Network error', + sessionId: 'session-1', + success: false, + error: 'Network error', + }, + }); + }); +}); + +describe('handlePartUpdated', () => { + it('should handle text part with delta', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello world' }; + + handlePartUpdated(part, 'Hello', onStream, state); + + expect(onStream).toHaveBeenCalledWith({ + type: 'text', + data: { text: 'Hello' }, + }); + }); + + it('should handle text part without delta using offset tracking', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + const part1: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello' }; + handlePartUpdated(part1, undefined, onStream, state); + + expect(onStream).toHaveBeenCalledWith({ + type: 'text', + data: { text: 'Hello' }, + }); + + onStream.mockClear(); + + const part2: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello world' }; + handlePartUpdated(part2, undefined, onStream, state); + + expect(onStream).toHaveBeenCalledWith({ + type: 'text', + data: { text: ' world' }, + }); + }); + + it('should not emit duplicate text when offset has not changed', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello' }; + handlePartUpdated(part, undefined, onStream, state); + onStream.mockClear(); + + handlePartUpdated(part, undefined, onStream, state); + + expect(onStream).not.toHaveBeenCalled(); + }); + + it('should handle reasoning part with delta', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeReasoningPart = { id: 'r1', type: 'reasoning', text: 'Thinking...' }; + + handlePartUpdated(part, 'Thinking', onStream, state); + + expect(onStream).toHaveBeenCalledWith({ + type: 'thinking', + data: { thinking: 'Thinking' }, + }); + }); + + it('should handle reasoning part without delta using offset tracking', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeReasoningPart = { id: 'r1', type: 'reasoning', text: 'Step 1' }; + handlePartUpdated(part, undefined, onStream, state); + + expect(onStream).toHaveBeenCalledWith({ + type: 'thinking', + data: { thinking: 'Step 1' }, + }); + }); + + it('should handle tool part in running state', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeToolPart = { + id: 't1', + type: 'tool', + callID: 'call-1', + tool: 'Bash', + state: { status: 'running', input: { command: 'ls' } }, + }; + + handlePartUpdated(part, undefined, onStream, state); + + expect(onStream).toHaveBeenCalledWith({ + type: 'tool_use', + data: { tool: 'Bash', input: { command: 'ls' }, id: 'call-1' }, + }); + expect(state.startedTools.has('call-1')).toBe(true); + }); + + it('should handle tool part in completed state', () => { + const onStream: StreamCallback = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeToolPart = { + id: 't1', + type: 'tool', + callID: 'call-1', + tool: 'Bash', + state: { + status: 'completed', + input: { command: 'ls' }, + output: 'file.txt', + title: 'List files', + }, + }; + + handlePartUpdated(part, undefined, onStream, state); + + expect(onStream).toHaveBeenCalledTimes(2); + expect(onStream).toHaveBeenNthCalledWith(1, { + type: 'tool_use', + data: { tool: 'Bash', input: { command: 'ls' }, id: 'call-1' }, + }); + expect(onStream).toHaveBeenNthCalledWith(2, { + type: 'tool_result', + data: { content: 'file.txt', isError: false }, + }); + }); + + it('should handle tool part in error state', () => { + const onStream: StreamCallback = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeToolPart = { + id: 't1', + type: 'tool', + callID: 'call-1', + tool: 'Bash', + state: { + status: 'error', + input: { command: 'rm -rf /' }, + error: 'Permission denied', + }, + }; + + handlePartUpdated(part, undefined, onStream, state); + + expect(onStream).toHaveBeenCalledTimes(2); + expect(onStream).toHaveBeenNthCalledWith(2, { + type: 'tool_result', + data: { content: 'Permission denied', isError: true }, + }); + }); + + it('should not emit duplicate tool_use for already-started tool', () => { + const onStream: StreamCallback = vi.fn(); + const state = createStreamTrackingState(); + state.startedTools.add('call-1'); + + const part: OpenCodeToolPart = { + id: 't1', + type: 'tool', + callID: 'call-1', + tool: 'Bash', + state: { status: 'running', input: { command: 'ls' } }, + }; + + handlePartUpdated(part, undefined, onStream, state); + + expect(onStream).not.toHaveBeenCalled(); + }); + + it('should ignore unknown part types', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + handlePartUpdated({ id: 'x1', type: 'unknown' }, undefined, onStream, state); + + expect(onStream).not.toHaveBeenCalled(); + }); + + it('should not emit when onStream is undefined', () => { + const state = createStreamTrackingState(); + + const part: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello' }; + handlePartUpdated(part, 'Hello', undefined, state); + }); +}); + +describe('OpenCodeStreamEvent typing', () => { + it('should accept message.completed event shape', () => { + const event: OpenCodeStreamEvent = { + type: 'message.completed', + properties: { + info: { + sessionID: 'session-1', + role: 'assistant', + error: undefined, + }, + }, + }; + + expect(event.type).toBe('message.completed'); + }); + + it('should accept message.failed event shape', () => { + const event: OpenCodeStreamEvent = { + type: 'message.failed', + properties: { + info: { + sessionID: 'session-2', + role: 'assistant', + error: { message: 'failed' }, + }, + }, + }; + + expect(event.type).toBe('message.failed'); + }); +}); diff --git a/src/__tests__/opencode-types.test.ts b/src/__tests__/opencode-types.test.ts new file mode 100644 index 00000000..e8ad9b3f --- /dev/null +++ b/src/__tests__/opencode-types.test.ts @@ -0,0 +1,84 @@ +/** + * Tests for OpenCode type definitions and permission mapping + */ + +import { describe, it, expect } from 'vitest'; +import { + buildOpenCodePermissionConfig, + buildOpenCodePermissionRuleset, + mapToOpenCodePermissionReply, + mapToOpenCodeTools, +} from '../infra/opencode/types.js'; +import type { PermissionMode } from '../core/models/index.js'; + +describe('mapToOpenCodePermissionReply', () => { + it('should map readonly to reject', () => { + expect(mapToOpenCodePermissionReply('readonly')).toBe('reject'); + }); + + it('should map edit to once', () => { + expect(mapToOpenCodePermissionReply('edit')).toBe('once'); + }); + + it('should map full to always', () => { + expect(mapToOpenCodePermissionReply('full')).toBe('always'); + }); + + it('should handle all PermissionMode values', () => { + const modes: PermissionMode[] = ['readonly', 'edit', 'full']; + const expectedReplies = ['reject', 'once', 'always']; + + modes.forEach((mode, index) => { + expect(mapToOpenCodePermissionReply(mode)).toBe(expectedReplies[index]); + }); + }); +}); + +describe('mapToOpenCodeTools', () => { + it('should map built-in tool names to OpenCode tool IDs', () => { + expect(mapToOpenCodeTools(['Read', 'Edit', 'Bash', 'WebSearch', 'WebFetch'])).toEqual({ + read: true, + edit: true, + bash: true, + websearch: true, + webfetch: true, + }); + }); + + it('should keep unknown tool names as-is', () => { + expect(mapToOpenCodeTools(['mcp__github__search', 'custom_tool'])).toEqual({ + mcp__github__search: true, + custom_tool: true, + }); + }); + + it('should return undefined when tools are not provided', () => { + expect(mapToOpenCodeTools(undefined)).toBeUndefined(); + expect(mapToOpenCodeTools([])).toBeUndefined(); + }); +}); + +describe('OpenCode permissions', () => { + it('should build allow config for full mode', () => { + expect(buildOpenCodePermissionConfig('full')).toBe('allow'); + }); + + it('should build deny config for readonly mode', () => { + expect(buildOpenCodePermissionConfig('readonly')).toBe('deny'); + }); + + it('should build ruleset for edit mode', () => { + const ruleset = buildOpenCodePermissionRuleset('edit'); + expect(ruleset.length).toBeGreaterThan(0); + expect(ruleset.find((rule) => rule.permission === 'edit')).toEqual({ + permission: 'edit', + pattern: '**', + action: 'allow', + }); + expect(ruleset.find((rule) => rule.permission === 'question')).toEqual({ + permission: 'question', + pattern: '**', + action: 'deny', + }); + }); +}); diff --git a/src/__tests__/parallel-and-loader.test.ts b/src/__tests__/parallel-and-loader.test.ts index 961d4280..706d2ae5 100644 --- a/src/__tests__/parallel-and-loader.test.ts +++ b/src/__tests__/parallel-and-loader.test.ts @@ -145,7 +145,7 @@ describe('PieceConfigRawSchema with parallel movements', () => { }, ], initial_movement: 'plan', - max_iterations: 10, + max_movements: 10, }; const result = PieceConfigRawSchema.safeParse(raw); diff --git a/src/__tests__/parallel-logger.test.ts b/src/__tests__/parallel-logger.test.ts index 5a4113c7..67681b73 100644 --- a/src/__tests__/parallel-logger.test.ts +++ b/src/__tests__/parallel-logger.test.ts @@ -74,7 +74,7 @@ describe('ParallelLogger', () => { writeFn, progressInfo: { iteration: 4, - maxIterations: 30, + maxMovements: 30, }, taskLabel: 'override-persona-provider', taskColorIndex: 0, @@ -393,6 +393,63 @@ describe('ParallelLogger', () => { expect(doneIndex0).toBe(doneIndex1); }); + it('should prepend task prefix to all summary lines in rich mode', () => { + const logger = new ParallelLogger({ + subMovementNames: ['arch-review', 'security-review'], + writeFn, + progressInfo: { iteration: 5, maxMovements: 30 }, + taskLabel: 'override-persona-provider', + taskColorIndex: 0, + parentMovementName: 'reviewers', + movementIteration: 1, + }); + + logger.printSummary('reviewers', [ + { name: 'arch-review', condition: 'approved' }, + { name: 'security-review', condition: 'needs_fix' }, + ]); + + // Every output line should have the task prefix + for (const line of output) { + expect(line).toContain('[over]'); + expect(line).toContain('[reviewers]'); + expect(line).toContain('(5/30)(1)'); + } + + // Verify task color (cyan for index 0) + expect(output[0]).toContain('\x1b[36m'); + + // Verify summary content is still present + const fullOutput = output.join(''); + expect(fullOutput).toContain('reviewers results'); + expect(fullOutput).toContain('arch-review:'); + expect(fullOutput).toContain('approved'); + expect(fullOutput).toContain('security-review:'); + expect(fullOutput).toContain('needs_fix'); + }); + + it('should not prepend task prefix to summary lines in non-rich mode', () => { + const logger = new ParallelLogger({ + subMovementNames: ['arch-review'], + writeFn, + }); + + logger.printSummary('reviewers', [ + { name: 'arch-review', condition: 'approved' }, + ]); + + // No task prefix should appear + for (const line of output) { + expect(line).not.toContain('[over]'); + } + + // Summary content is present + const fullOutput = output.join(''); + expect(fullOutput).toContain('reviewers results'); + expect(fullOutput).toContain('arch-review:'); + expect(fullOutput).toContain('approved'); + }); + it('should flush remaining buffers before printing summary', () => { const logger = new ParallelLogger({ subMovementNames: ['step-a'], @@ -529,7 +586,7 @@ describe('ParallelLogger', () => { writeFn, progressInfo: { iteration: 3, - maxIterations: 10, + maxMovements: 10, }, }); @@ -545,7 +602,7 @@ describe('ParallelLogger', () => { writeFn, progressInfo: { iteration: 5, - maxIterations: 20, + maxMovements: 20, }, }); @@ -576,7 +633,7 @@ describe('ParallelLogger', () => { writeFn, progressInfo: { iteration: 2, - maxIterations: 5, + maxMovements: 5, }, }); const handler = logger.createStreamHandler('step-a', 0); diff --git a/src/__tests__/phase-runner-report-history.test.ts b/src/__tests__/phase-runner-report-history.test.ts new file mode 100644 index 00000000..f2960669 --- /dev/null +++ b/src/__tests__/phase-runner-report-history.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { runReportPhase, type PhaseRunnerContext } from '../core/piece/phase-runner.js'; +import type { PieceMovement } from '../core/models/types.js'; + +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +import { runAgent } from '../agents/runner.js'; + +function createStep(fileName: string): PieceMovement { + return { + name: 'reviewers', + personaDisplayName: 'Reviewers', + instructionTemplate: 'review', + passPreviousResponse: false, + outputContracts: [{ name: fileName }], + }; +} + +function createContext(reportDir: string): PhaseRunnerContext { + let currentSessionId = 'session-1'; + return { + cwd: reportDir, + reportDir, + getSessionId: (_persona: string) => currentSessionId, + buildResumeOptions: ( + _step, + _sessionId, + _overrides, + ) => ({ cwd: reportDir }), + updatePersonaSession: (_persona, sessionId) => { + if (sessionId) { + currentSessionId = sessionId; + } + }, + }; +} + +describe('runReportPhase report history behavior', () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = mkdtempSync(join(tmpdir(), 'takt-report-history-')); + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + if (existsSync(tmpRoot)) { + rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('should overwrite report file and archive previous content to reports-history', async () => { + // Given + const reportDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'reports'); + const step = createStep('05-architect-review.md'); + const ctx = createContext(reportDir); + const runAgentMock = vi.mocked(runAgent); + runAgentMock + .mockResolvedValueOnce({ + persona: 'reviewers', + status: 'done', + content: 'First review result', + timestamp: new Date('2026-02-10T06:11:43Z'), + sessionId: 'session-2', + }) + .mockResolvedValueOnce({ + persona: 'reviewers', + status: 'done', + content: 'Second review result', + timestamp: new Date('2026-02-10T06:14:37Z'), + sessionId: 'session-3', + }); + + // When + await runReportPhase(step, 1, ctx); + await runReportPhase(step, 2, ctx); + + // Then + const latestPath = join(reportDir, '05-architect-review.md'); + const latestContent = readFileSync(latestPath, 'utf-8'); + expect(latestContent).toBe('Second review result'); + + const historyDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'logs', 'reports-history'); + const historyFiles = readdirSync(historyDir); + expect(historyFiles).toHaveLength(1); + expect(historyFiles[0]).toMatch(/^05-architect-review\.\d{8}T\d{6}Z\.md$/); + + const archivedContent = readFileSync(join(historyDir, historyFiles[0]!), 'utf-8'); + expect(archivedContent).toBe('First review result'); + }); + + it('should add sequence suffix when history file name collides in the same second', async () => { + // Given + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-02-10T06:11:43Z')); + + const reportDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'reports'); + const step = createStep('06-qa-review.md'); + const ctx = createContext(reportDir); + const runAgentMock = vi.mocked(runAgent); + runAgentMock + .mockResolvedValueOnce({ + persona: 'reviewers', + status: 'done', + content: 'v1', + timestamp: new Date('2026-02-10T06:11:43Z'), + sessionId: 'session-2', + }) + .mockResolvedValueOnce({ + persona: 'reviewers', + status: 'done', + content: 'v2', + timestamp: new Date('2026-02-10T06:11:43Z'), + sessionId: 'session-3', + }) + .mockResolvedValueOnce({ + persona: 'reviewers', + status: 'done', + content: 'v3', + timestamp: new Date('2026-02-10T06:11:43Z'), + sessionId: 'session-4', + }); + + // When + await runReportPhase(step, 1, ctx); + await runReportPhase(step, 2, ctx); + await runReportPhase(step, 3, ctx); + + // Then + const historyDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'logs', 'reports-history'); + const historyFiles = readdirSync(historyDir).sort(); + expect(historyFiles).toEqual([ + '06-qa-review.20260210T061143Z.1.md', + '06-qa-review.20260210T061143Z.md', + ]); + }); +}); diff --git a/src/__tests__/piece-categories.test.ts b/src/__tests__/piece-categories.test.ts index c4ad8046..b025782c 100644 --- a/src/__tests__/piece-categories.test.ts +++ b/src/__tests__/piece-categories.test.ts @@ -24,7 +24,7 @@ import { const SAMPLE_PIECE = `name: test-piece description: Test piece initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 diff --git a/src/__tests__/piece-category-config.test.ts b/src/__tests__/piece-category-config.test.ts index e89edc4e..1a164fff 100644 --- a/src/__tests__/piece-category-config.test.ts +++ b/src/__tests__/piece-category-config.test.ts @@ -65,7 +65,7 @@ function createPieceMap(entries: { name: string; source: 'builtin' | 'user' | 'p name: entry.name, movements: [], initialMovement: 'start', - maxIterations: 1, + maxMovements: 1, }, }); } diff --git a/src/__tests__/piece-selection.test.ts b/src/__tests__/piece-selection.test.ts index ae250dfc..a05088a0 100644 --- a/src/__tests__/piece-selection.test.ts +++ b/src/__tests__/piece-selection.test.ts @@ -78,7 +78,7 @@ function createPieceMap(entries: { name: string; source: 'user' | 'builtin' }[]) name: e.name, movements: [], initialMovement: 'start', - maxIterations: 1, + maxMovements: 1, }, }); } diff --git a/src/__tests__/pieceExecution-debug-prompts.test.ts b/src/__tests__/pieceExecution-debug-prompts.test.ts index 073ec9b8..75aa4e5c 100644 --- a/src/__tests__/pieceExecution-debug-prompts.test.ts +++ b/src/__tests__/pieceExecution-debug-prompts.test.ts @@ -18,6 +18,9 @@ const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted(( constructor(config: PieceConfig, _cwd: string, task: string, _options: unknown) { super(); + if (task === 'constructor-throw-task') { + throw new Error('mock constructor failure'); + } this.config = config; this.task = task; } @@ -27,6 +30,7 @@ const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted(( async run(): Promise<{ status: string; iteration: number }> { const step = this.config.movements[0]!; const timestamp = new Date('2026-02-07T00:00:00.000Z'); + const shouldAbort = this.task === 'abort-task'; const shouldRepeatMovement = this.task === 'repeat-movement-task'; this.emit('movement:start', step, 1, 'movement instruction'); @@ -57,8 +61,11 @@ const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted(( 'movement instruction repeat' ); } + if (shouldAbort) { + this.emit('piece:abort', { status: 'aborted', iteration: 1 }, 'user_interrupted'); + return { status: 'aborted', iteration: shouldRepeatMovement ? 2 : 1 }; + } this.emit('piece:complete', { status: 'completed', iteration: 1 }); - return { status: 'completed', iteration: shouldRepeatMovement ? 2 : 1 }; } } @@ -86,6 +93,8 @@ vi.mock('../infra/config/index.js', () => ({ updateWorktreeSession: vi.fn(), loadGlobalConfig: vi.fn().mockReturnValue({ provider: 'claude' }), saveSessionState: vi.fn(), + ensureDir: vi.fn(), + writeFileAtomic: vi.fn(), })); vi.mock('../shared/context.js', () => ({ @@ -117,7 +126,6 @@ vi.mock('../infra/fs/index.js', () => ({ status, endTime: new Date().toISOString(), })), - updateLatestPointer: vi.fn(), initNdjsonLog: vi.fn().mockReturnValue('/tmp/test-log.jsonl'), appendNdjsonLine: vi.fn(), })); @@ -134,6 +142,8 @@ vi.mock('../shared/utils/index.js', () => ({ preventSleep: vi.fn(), isDebugEnabled: mockIsDebugEnabled, writePromptLog: mockWritePromptLog, + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), + isValidReportDirName: vi.fn().mockImplementation((value: string) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)), })); vi.mock('../shared/prompt/index.js', () => ({ @@ -150,6 +160,7 @@ vi.mock('../shared/exitCodes.js', () => ({ })); import { executePiece } from '../features/tasks/execute/pieceExecution.js'; +import { ensureDir, writeFileAtomic } from '../infra/config/index.js'; describe('executePiece debug prompts logging', () => { beforeEach(() => { @@ -159,7 +170,7 @@ describe('executePiece debug prompts logging', () => { function makeConfig(): PieceConfig { return { name: 'test-piece', - maxIterations: 5, + maxMovements: 5, initialMovement: 'implement', movements: [ { @@ -235,4 +246,69 @@ describe('executePiece debug prompts logging', () => { }) ).rejects.toThrow('taskPrefix and taskColorIndex must be provided together'); }); + + it('should fail fast for invalid reportDirName before run directory writes', async () => { + await expect( + executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + reportDirName: '..', + }) + ).rejects.toThrow('Invalid reportDirName: ..'); + + expect(vi.mocked(ensureDir)).not.toHaveBeenCalled(); + expect(vi.mocked(writeFileAtomic)).not.toHaveBeenCalled(); + }); + + it('should update meta status from running to completed', async () => { + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + reportDirName: 'test-report-dir', + }); + + const calls = vi.mocked(writeFileAtomic).mock.calls; + expect(calls).toHaveLength(2); + + const firstMeta = JSON.parse(String(calls[0]![1])) as { status: string; endTime?: string }; + const secondMeta = JSON.parse(String(calls[1]![1])) as { status: string; endTime?: string }; + expect(firstMeta.status).toBe('running'); + expect(firstMeta.endTime).toBeUndefined(); + expect(secondMeta.status).toBe('completed'); + expect(secondMeta.endTime).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('should update meta status from running to aborted', async () => { + await executePiece(makeConfig(), 'abort-task', '/tmp/project', { + projectCwd: '/tmp/project', + reportDirName: 'test-report-dir', + }); + + const calls = vi.mocked(writeFileAtomic).mock.calls; + expect(calls).toHaveLength(2); + + const firstMeta = JSON.parse(String(calls[0]![1])) as { status: string; endTime?: string }; + const secondMeta = JSON.parse(String(calls[1]![1])) as { status: string; endTime?: string }; + expect(firstMeta.status).toBe('running'); + expect(firstMeta.endTime).toBeUndefined(); + expect(secondMeta.status).toBe('aborted'); + expect(secondMeta.endTime).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('should finalize meta as aborted when PieceEngine constructor throws', async () => { + await expect( + executePiece(makeConfig(), 'constructor-throw-task', '/tmp/project', { + projectCwd: '/tmp/project', + reportDirName: 'test-report-dir', + }) + ).rejects.toThrow('mock constructor failure'); + + const calls = vi.mocked(writeFileAtomic).mock.calls; + expect(calls).toHaveLength(2); + + const firstMeta = JSON.parse(String(calls[0]![1])) as { status: string; endTime?: string }; + const secondMeta = JSON.parse(String(calls[1]![1])) as { status: string; endTime?: string }; + expect(firstMeta.status).toBe('running'); + expect(firstMeta.endTime).toBeUndefined(); + expect(secondMeta.status).toBe('aborted'); + expect(secondMeta.endTime).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); }); diff --git a/src/__tests__/pieceExecution-session-loading.test.ts b/src/__tests__/pieceExecution-session-loading.test.ts new file mode 100644 index 00000000..92ff51e5 --- /dev/null +++ b/src/__tests__/pieceExecution-session-loading.test.ts @@ -0,0 +1,251 @@ +/** + * Tests: session loading behavior in executePiece(). + * + * Normal runs pass empty sessions to PieceEngine; + * retry runs (startMovement / retryNote) load persisted sessions. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { PieceConfig } from '../core/models/index.js'; + +const { MockPieceEngine, mockLoadPersonaSessions, mockLoadWorktreeSessions } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { EventEmitter: EE } = require('node:events') as typeof import('node:events'); + + const mockLoadPersonaSessions = vi.fn().mockReturnValue({ coder: 'saved-session-id' }); + const mockLoadWorktreeSessions = vi.fn().mockReturnValue({ coder: 'worktree-session-id' }); + + class MockPieceEngine extends EE { + static lastInstance: MockPieceEngine; + readonly receivedOptions: Record; + private readonly config: PieceConfig; + + constructor(config: PieceConfig, _cwd: string, _task: string, options: Record) { + super(); + this.config = config; + this.receivedOptions = options; + MockPieceEngine.lastInstance = this; + } + + abort(): void {} + + async run(): Promise<{ status: string; iteration: number }> { + const firstStep = this.config.movements[0]; + if (firstStep) { + this.emit('movement:start', firstStep, 1, firstStep.instructionTemplate); + } + this.emit('piece:complete', { status: 'completed', iteration: 1 }); + return { status: 'completed', iteration: 1 }; + } + } + + return { MockPieceEngine, mockLoadPersonaSessions, mockLoadWorktreeSessions }; +}); + +vi.mock('../core/piece/index.js', () => ({ + PieceEngine: MockPieceEngine, +})); + +vi.mock('../infra/claude/index.js', () => ({ + detectRuleIndex: vi.fn(), + interruptAllQueries: vi.fn(), +})); + +vi.mock('../agents/ai-judge.js', () => ({ + callAiJudge: vi.fn(), +})); + +vi.mock('../infra/config/index.js', () => ({ + loadPersonaSessions: mockLoadPersonaSessions, + updatePersonaSession: vi.fn(), + loadWorktreeSessions: mockLoadWorktreeSessions, + updateWorktreeSession: vi.fn(), + loadGlobalConfig: vi.fn().mockReturnValue({ provider: 'claude' }), + saveSessionState: vi.fn(), + ensureDir: vi.fn(), + writeFileAtomic: vi.fn(), +})); + +vi.mock('../shared/context.js', () => ({ + isQuietMode: vi.fn().mockReturnValue(true), +})); + +vi.mock('../shared/ui/index.js', () => ({ + header: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + status: vi.fn(), + blankLine: vi.fn(), + StreamDisplay: vi.fn().mockImplementation(() => ({ + createHandler: vi.fn().mockReturnValue(vi.fn()), + flush: vi.fn(), + })), +})); + +vi.mock('../infra/fs/index.js', () => ({ + generateSessionId: vi.fn().mockReturnValue('test-session-id'), + createSessionLog: vi.fn().mockReturnValue({ + startTime: new Date().toISOString(), + iterations: 0, + }), + finalizeSessionLog: vi.fn().mockImplementation((log, status) => ({ + ...log, + status, + endTime: new Date().toISOString(), + })), + initNdjsonLog: vi.fn().mockReturnValue('/tmp/test-log.jsonl'), + appendNdjsonLine: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', () => ({ + createLogger: vi.fn().mockReturnValue({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + notifySuccess: vi.fn(), + notifyError: vi.fn(), + preventSleep: vi.fn(), + isDebugEnabled: vi.fn().mockReturnValue(false), + writePromptLog: vi.fn(), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), + isValidReportDirName: vi.fn().mockReturnValue(true), + playWarningSound: vi.fn(), +})); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: vi.fn(), + promptInput: vi.fn(), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn().mockImplementation((key: string) => key), +})); + +vi.mock('../shared/exitCodes.js', () => ({ + EXIT_SIGINT: 130, +})); + +import { executePiece } from '../features/tasks/execute/pieceExecution.js'; +import { info } from '../shared/ui/index.js'; + +function makeConfig(): PieceConfig { + return { + name: 'test-piece', + maxMovements: 5, + initialMovement: 'implement', + movements: [ + { + name: 'implement', + persona: '../agents/coder.md', + personaDisplayName: 'coder', + instructionTemplate: 'Implement task', + passPreviousResponse: true, + rules: [{ condition: 'done', next: 'COMPLETE' }], + }, + ], + }; +} + +describe('executePiece session loading', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadPersonaSessions.mockReturnValue({ coder: 'saved-session-id' }); + mockLoadWorktreeSessions.mockReturnValue({ coder: 'worktree-session-id' }); + }); + + it('should pass empty initialSessions on normal run', async () => { + // Given: normal execution (no startMovement, no retryNote) + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + }); + + // Then: PieceEngine receives empty sessions + expect(mockLoadPersonaSessions).not.toHaveBeenCalled(); + expect(mockLoadWorktreeSessions).not.toHaveBeenCalled(); + expect(MockPieceEngine.lastInstance.receivedOptions.initialSessions).toEqual({}); + }); + + it('should load persisted sessions when startMovement is set (retry)', async () => { + // Given: retry execution with startMovement + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + startMovement: 'implement', + }); + + // Then: loadPersonaSessions is called to load saved sessions + expect(mockLoadPersonaSessions).toHaveBeenCalledWith('/tmp/project', 'claude'); + }); + + it('should load persisted sessions when retryNote is set (retry)', async () => { + // Given: retry execution with retryNote + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + retryNote: 'Fix the failing test', + }); + + // Then: loadPersonaSessions is called to load saved sessions + expect(mockLoadPersonaSessions).toHaveBeenCalledWith('/tmp/project', 'claude'); + }); + + it('should load worktree sessions on retry when cwd differs from projectCwd', async () => { + // Given: retry execution in a worktree (cwd !== projectCwd) + await executePiece(makeConfig(), 'task', '/tmp/worktree', { + projectCwd: '/tmp/project', + startMovement: 'implement', + }); + + // Then: loadWorktreeSessions is called instead of loadPersonaSessions + expect(mockLoadWorktreeSessions).toHaveBeenCalledWith('/tmp/project', '/tmp/worktree', 'claude'); + expect(mockLoadPersonaSessions).not.toHaveBeenCalled(); + }); + + it('should not load sessions for worktree normal run', async () => { + // Given: normal execution in a worktree (no retry) + await executePiece(makeConfig(), 'task', '/tmp/worktree', { + projectCwd: '/tmp/project', + }); + + // Then: neither session loader is called + expect(mockLoadPersonaSessions).not.toHaveBeenCalled(); + expect(mockLoadWorktreeSessions).not.toHaveBeenCalled(); + }); + + it('should load sessions when both startMovement and retryNote are set', async () => { + // Given: retry with both flags + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + startMovement: 'implement', + retryNote: 'Fix issue', + }); + + // Then: sessions are loaded + expect(mockLoadPersonaSessions).toHaveBeenCalledWith('/tmp/project', 'claude'); + }); + + it('should log provider and model per movement with global defaults', async () => { + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + }); + + const mockInfo = vi.mocked(info); + expect(mockInfo).toHaveBeenCalledWith('Provider: claude'); + expect(mockInfo).toHaveBeenCalledWith('Model: (default)'); + }); + + it('should log provider and model per movement with overrides', async () => { + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + provider: 'codex', + model: 'gpt-5', + personaProviders: { coder: 'opencode' }, + }); + + const mockInfo = vi.mocked(info); + expect(mockInfo).toHaveBeenCalledWith('Provider: opencode'); + expect(mockInfo).toHaveBeenCalledWith('Model: gpt-5'); + }); +}); diff --git a/src/__tests__/pieceLoader.test.ts b/src/__tests__/pieceLoader.test.ts index 3b50cf5a..71ca03f6 100644 --- a/src/__tests__/pieceLoader.test.ts +++ b/src/__tests__/pieceLoader.test.ts @@ -16,7 +16,7 @@ import { const SAMPLE_PIECE = `name: test-piece description: Test piece initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -173,7 +173,7 @@ describe('loadAllPieces with project-local', () => { const overridePiece = `name: project-override description: Project override initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 diff --git a/src/__tests__/pieceResolver.test.ts b/src/__tests__/pieceResolver.test.ts index a6b1c4af..ca6583e8 100644 --- a/src/__tests__/pieceResolver.test.ts +++ b/src/__tests__/pieceResolver.test.ts @@ -23,7 +23,7 @@ describe('getPieceDescription', () => { const pieceYaml = `name: test-piece description: Test piece for workflow initial_movement: plan -max_iterations: 3 +max_movements: 3 movements: - name: plan @@ -56,7 +56,7 @@ movements: const pieceYaml = `name: coding description: Full coding workflow initial_movement: plan -max_iterations: 10 +max_movements: 10 movements: - name: plan @@ -98,7 +98,7 @@ movements: it('should handle movements without descriptions', () => { const pieceYaml = `name: minimal initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -132,7 +132,7 @@ movements: it('should handle parallel movements without descriptions', () => { const pieceYaml = `name: test-parallel initial_movement: parent -max_iterations: 1 +max_movements: 1 movements: - name: parent @@ -174,7 +174,7 @@ describe('getPieceDescription with movementPreviews', () => { const pieceYaml = `name: preview-test description: Test piece initial_movement: plan -max_iterations: 5 +max_movements: 5 movements: - name: plan @@ -237,7 +237,7 @@ movements: it('should return empty previews when previewCount is 0', () => { const pieceYaml = `name: test initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -256,7 +256,7 @@ movements: it('should return empty previews when previewCount is not specified', () => { const pieceYaml = `name: test initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -275,7 +275,7 @@ movements: it('should stop at COMPLETE movement', () => { const pieceYaml = `name: test-complete initial_movement: step1 -max_iterations: 3 +max_movements: 3 movements: - name: step1 @@ -301,7 +301,7 @@ movements: it('should stop at ABORT movement', () => { const pieceYaml = `name: test-abort initial_movement: step1 -max_iterations: 3 +max_movements: 3 movements: - name: step1 @@ -331,7 +331,7 @@ movements: const pieceYaml = `name: test-persona-file initial_movement: plan -max_iterations: 1 +max_movements: 1 personas: planner: ./planner.md @@ -355,7 +355,7 @@ movements: it('should limit previews to maxCount', () => { const pieceYaml = `name: test-limit initial_movement: step1 -max_iterations: 5 +max_movements: 5 movements: - name: step1 @@ -388,7 +388,7 @@ movements: it('should handle movements without rules (stop after first)', () => { const pieceYaml = `name: test-no-rules initial_movement: step1 -max_iterations: 3 +max_movements: 3 movements: - name: step1 @@ -411,7 +411,7 @@ movements: it('should return empty previews when initial movement not found in list', () => { const pieceYaml = `name: test-missing-initial initial_movement: nonexistent -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -430,7 +430,7 @@ movements: it('should handle self-referencing rule (prevent infinite loop)', () => { const pieceYaml = `name: test-self-ref initial_movement: step1 -max_iterations: 5 +max_movements: 5 movements: - name: step1 @@ -453,7 +453,7 @@ movements: it('should handle multi-node cycle A→B→A (prevent duplicate previews)', () => { const pieceYaml = `name: test-cycle initial_movement: stepA -max_iterations: 10 +max_movements: 10 movements: - name: stepA @@ -489,7 +489,7 @@ movements: it('should use inline persona content when no personaPath', () => { const pieceYaml = `name: test-inline initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -515,7 +515,7 @@ movements: const pieceYaml = `name: test-unreadable-persona initial_movement: plan -max_iterations: 1 +max_movements: 1 personas: planner: ./unreadable-persona.md @@ -545,7 +545,7 @@ movements: it('should include personaDisplayName in previews', () => { const pieceYaml = `name: test-display initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -578,7 +578,7 @@ describe('getPieceDescription interactiveMode field', () => { it('should return interactiveMode when piece defines interactive_mode', () => { const pieceYaml = `name: test-mode initial_movement: step1 -max_iterations: 1 +max_movements: 1 interactive_mode: quiet movements: @@ -598,7 +598,7 @@ movements: it('should return undefined interactiveMode when piece omits interactive_mode', () => { const pieceYaml = `name: test-no-mode initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -618,7 +618,7 @@ movements: for (const mode of ['assistant', 'persona', 'quiet', 'passthrough'] as const) { const pieceYaml = `name: test-${mode} initial_movement: step1 -max_iterations: 1 +max_movements: 1 interactive_mode: ${mode} movements: @@ -651,7 +651,7 @@ describe('getPieceDescription firstMovement field', () => { it('should return firstMovement with inline persona content', () => { const pieceYaml = `name: test-first initial_movement: plan -max_iterations: 1 +max_movements: 1 movements: - name: plan @@ -681,7 +681,7 @@ movements: const pieceYaml = `name: test-persona-file initial_movement: plan -max_iterations: 1 +max_movements: 1 personas: planner: ./planner-persona.md @@ -705,7 +705,7 @@ movements: it('should return undefined firstMovement when initialMovement not found', () => { const pieceYaml = `name: test-missing initial_movement: nonexistent -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -724,7 +724,7 @@ movements: it('should return empty allowedTools array when movement has no tools', () => { const pieceYaml = `name: test-no-tools initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -749,7 +749,7 @@ movements: const pieceYaml = `name: test-fallback initial_movement: step1 -max_iterations: 1 +max_movements: 1 personas: myagent: ./unreadable.md diff --git a/src/__tests__/policy-persona.test.ts b/src/__tests__/policy-persona.test.ts index 33a62365..7fd2cf2e 100644 --- a/src/__tests__/policy-persona.test.ts +++ b/src/__tests__/policy-persona.test.ts @@ -27,7 +27,7 @@ function makeContext(overrides: Partial = {}): InstructionCo return { task: 'Test task', iteration: 1, - maxIterations: 10, + maxMovements: 10, movementIteration: 1, cwd: '/tmp/test', projectCwd: '/tmp/test', diff --git a/src/__tests__/prompts.test.ts b/src/__tests__/prompts.test.ts index f6af374e..55fb8bf0 100644 --- a/src/__tests__/prompts.test.ts +++ b/src/__tests__/prompts.test.ts @@ -130,6 +130,7 @@ describe('template file existence', () => { 'score_interactive_policy', 'score_summary_system_prompt', 'score_slug_system_prompt', + 'score_slug_user_prompt', 'perform_phase1_message', 'perform_phase2_message', 'perform_phase3_message', diff --git a/src/__tests__/provider-model.test.ts b/src/__tests__/provider-model.test.ts new file mode 100644 index 00000000..74b29be1 --- /dev/null +++ b/src/__tests__/provider-model.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { parseProviderModel } from '../shared/utils/providerModel.js'; + +describe('parseProviderModel', () => { + it('should parse provider/model format', () => { + expect(parseProviderModel('opencode/big-pickle', 'model')).toEqual({ + providerID: 'opencode', + modelID: 'big-pickle', + }); + }); + + it('should reject empty string', () => { + expect(() => parseProviderModel('', 'model')).toThrow(/must not be empty/i); + }); + + it('should reject missing slash', () => { + expect(() => parseProviderModel('big-pickle', 'model')).toThrow(/provider\/model/i); + }); + + it('should reject multiple slashes', () => { + expect(() => parseProviderModel('a/b/c', 'model')).toThrow(/provider\/model/i); + }); +}); diff --git a/src/__tests__/providerEventLogger.test.ts b/src/__tests__/providerEventLogger.test.ts new file mode 100644 index 00000000..d2ac74ac --- /dev/null +++ b/src/__tests__/providerEventLogger.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + createProviderEventLogger, + isProviderEventsEnabled, +} from '../shared/utils/providerEventLogger.js'; +import type { ProviderType } from '../core/piece/index.js'; + +describe('providerEventLogger', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `takt-provider-events-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should disable provider events by default', () => { + expect(isProviderEventsEnabled()).toBe(false); + expect(isProviderEventsEnabled({})).toBe(false); + expect(isProviderEventsEnabled({ observability: {} })).toBe(false); + }); + + it('should enable provider events only when explicitly true', () => { + expect(isProviderEventsEnabled({ observability: { providerEvents: true } })).toBe(true); + }); + + it('should disable provider events only when explicitly false', () => { + expect(isProviderEventsEnabled({ observability: { providerEvents: false } })).toBe(false); + }); + + it('should write normalized JSONL records when enabled', () => { + const logger = createProviderEventLogger({ + logsDir: tempDir, + sessionId: 'session-1', + runId: 'run-1', + provider: 'opencode', + movement: 'implement', + enabled: true, + }); + + const original = vi.fn(); + const wrapped = logger.wrapCallback(original); + + wrapped({ + type: 'tool_use', + data: { + tool: 'Read', + id: 'call-123', + messageId: 'msg-123', + requestId: 'req-123', + sessionID: 'session-abc', + }, + }); + + expect(original).toHaveBeenCalledTimes(1); + expect(existsSync(logger.filepath)).toBe(true); + + const lines = readFileSync(logger.filepath, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(1); + + const parsed = JSON.parse(lines[0]!) as { + provider: ProviderType; + event_type: string; + run_id: string; + movement: string; + session_id?: string; + call_id?: string; + message_id?: string; + request_id?: string; + data: Record; + }; + + expect(parsed.provider).toBe('opencode'); + expect(parsed.event_type).toBe('tool_use'); + expect(parsed.run_id).toBe('run-1'); + expect(parsed.movement).toBe('implement'); + expect(parsed.session_id).toBe('session-abc'); + expect(parsed.call_id).toBe('call-123'); + expect(parsed.message_id).toBe('msg-123'); + expect(parsed.request_id).toBe('req-123'); + expect(parsed.data['tool']).toBe('Read'); + }); + + it('should update movement and provider for subsequent events', () => { + const logger = createProviderEventLogger({ + logsDir: tempDir, + sessionId: 'session-2', + runId: 'run-2', + provider: 'claude', + movement: 'plan', + enabled: true, + }); + + const wrapped = logger.wrapCallback(); + + wrapped({ type: 'init', data: { model: 'sonnet', sessionId: 's-1' } }); + logger.setMovement('implement'); + logger.setProvider('codex'); + wrapped({ type: 'result', data: { result: 'ok', sessionId: 's-1', success: true } }); + + const lines = readFileSync(logger.filepath, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + + const first = JSON.parse(lines[0]!) as { provider: ProviderType; movement: string }; + const second = JSON.parse(lines[1]!) as { provider: ProviderType; movement: string }; + + expect(first.provider).toBe('claude'); + expect(first.movement).toBe('plan'); + expect(second.provider).toBe('codex'); + expect(second.movement).toBe('implement'); + }); + + it('should not write records when disabled', () => { + const logger = createProviderEventLogger({ + logsDir: tempDir, + sessionId: 'session-3', + runId: 'run-3', + provider: 'claude', + movement: 'plan', + enabled: false, + }); + + const original = vi.fn(); + const wrapped = logger.wrapCallback(original); + wrapped({ type: 'text', data: { text: 'hello' } }); + + expect(original).toHaveBeenCalledTimes(1); + expect(existsSync(logger.filepath)).toBe(false); + }); + + it('should truncate long text fields', () => { + const logger = createProviderEventLogger({ + logsDir: tempDir, + sessionId: 'session-4', + runId: 'run-4', + provider: 'claude', + movement: 'plan', + enabled: true, + }); + + const wrapped = logger.wrapCallback(); + const longText = 'a'.repeat(11_000); + wrapped({ type: 'text', data: { text: longText } }); + + const line = readFileSync(logger.filepath, 'utf-8').trim(); + const parsed = JSON.parse(line) as { data: { text: string } }; + + expect(parsed.data.text.length).toBeLessThan(longText.length); + expect(parsed.data.text).toContain('...[truncated]'); + }); + + it('should write init event records with typed data objects', () => { + const logger = createProviderEventLogger({ + logsDir: tempDir, + sessionId: 'session-5', + runId: 'run-5', + provider: 'codex', + movement: 'implement', + enabled: true, + }); + + const wrapped = logger.wrapCallback(); + wrapped({ + type: 'init', + data: { + model: 'gpt-5-codex', + sessionId: 'thread-1', + }, + }); + + const line = readFileSync(logger.filepath, 'utf-8').trim(); + const parsed = JSON.parse(line) as { + provider: ProviderType; + event_type: string; + session_id?: string; + data: { model: string; sessionId: string }; + }; + + expect(parsed.provider).toBe('codex'); + expect(parsed.event_type).toBe('init'); + expect(parsed.session_id).toBe('thread-1'); + expect(parsed.data.model).toBe('gpt-5-codex'); + expect(parsed.data.sessionId).toBe('thread-1'); + }); +}); diff --git a/src/__tests__/report-phase-blocked.test.ts b/src/__tests__/report-phase-blocked.test.ts new file mode 100644 index 00000000..3afad149 --- /dev/null +++ b/src/__tests__/report-phase-blocked.test.ts @@ -0,0 +1,224 @@ +/** + * PieceEngine integration tests: Report phase (Phase 2) blocked handling. + * + * Covers: + * - Report phase blocked propagates to PieceEngine's handleBlocked flow + * - User input triggers full movement retry (Phase 1 → 2 → 3) + * - Null user input aborts the piece + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, rmSync } from 'node:fs'; + +// --- Mock setup (must be before imports that use these modules) --- + +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../core/piece/evaluation/index.js', () => ({ + detectMatchedRule: vi.fn(), +})); + +vi.mock('../core/piece/phase-runner.js', () => ({ + needsStatusJudgmentPhase: vi.fn().mockReturnValue(false), + runReportPhase: vi.fn().mockResolvedValue(undefined), + runStatusJudgmentPhase: vi.fn().mockResolvedValue(''), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), +})); + +// --- Imports (after mocks) --- + +import { PieceEngine } from '../core/piece/index.js'; +import { runReportPhase } from '../core/piece/index.js'; +import { + makeResponse, + makeMovement, + buildDefaultPieceConfig, + mockRunAgentSequence, + mockDetectMatchedRuleSequence, + createTestTmpDir, + applyDefaultMocks, +} from './engine-test-helpers.js'; +import type { PieceConfig, OutputContractItem } from '../core/models/index.js'; + +/** + * Build a piece config where a movement has outputContracts (triggering report phase). + * plan → implement (with report) → supervise + */ +function buildConfigWithReport(): PieceConfig { + const reportContract: OutputContractItem = { + name: '02-coder-scope.md', + label: 'Scope', + description: 'Scope report', + }; + + return buildDefaultPieceConfig({ + movements: [ + makeMovement('plan', { + rules: [ + { condition: 'Requirements are clear', next: 'implement' }, + { condition: 'Requirements unclear', next: 'ABORT' }, + ], + }), + makeMovement('implement', { + outputContracts: [reportContract], + rules: [ + { condition: 'Implementation complete', next: 'supervise' }, + { condition: 'Cannot proceed', next: 'plan' }, + ], + }), + makeMovement('supervise', { + rules: [ + { condition: 'All checks passed', next: 'COMPLETE' }, + { condition: 'Requirements unmet', next: 'plan' }, + ], + }), + ], + }); +} + +describe('PieceEngine Integration: Report Phase Blocked Handling', () => { + let tmpDir: string; + + beforeEach(() => { + vi.resetAllMocks(); + applyDefaultMocks(); + tmpDir = createTestTmpDir(); + }); + + afterEach(() => { + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should abort when report phase is blocked and no onUserInput callback', async () => { + const config = buildConfigWithReport(); + const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + + // Phase 1 succeeds for plan, then implement + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan done' }), + makeResponse({ persona: 'implement', content: 'Impl done' }), + ]); + + // plan → implement, then implement's report phase blocks + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + ]); + + // Report phase returns blocked (only implement has outputContracts, so only one call) + const blockedResponse = makeResponse({ persona: 'implement', status: 'blocked', content: 'Need clarification for report' }); + vi.mocked(runReportPhase).mockResolvedValueOnce({ blocked: true, response: blockedResponse }); + + const blockedFn = vi.fn(); + const abortFn = vi.fn(); + engine.on('movement:blocked', blockedFn); + engine.on('piece:abort', abortFn); + + const state = await engine.run(); + + expect(state.status).toBe('aborted'); + expect(blockedFn).toHaveBeenCalledOnce(); + expect(abortFn).toHaveBeenCalledOnce(); + }); + + it('should abort when report phase is blocked and onUserInput returns null', async () => { + const config = buildConfigWithReport(); + const onUserInput = vi.fn().mockResolvedValue(null); + const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, onUserInput }); + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan done' }), + makeResponse({ persona: 'implement', content: 'Impl done' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + ]); + + const blockedResponse = makeResponse({ persona: 'implement', status: 'blocked', content: 'Need info for report' }); + vi.mocked(runReportPhase).mockResolvedValueOnce({ blocked: true, response: blockedResponse }); + + const state = await engine.run(); + + expect(state.status).toBe('aborted'); + expect(onUserInput).toHaveBeenCalledOnce(); + }); + + it('should retry full movement when report phase is blocked and user provides input', async () => { + const config = buildConfigWithReport(); + const onUserInput = vi.fn().mockResolvedValueOnce('User provided report clarification'); + const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, onUserInput }); + + mockRunAgentSequence([ + // First: plan succeeds + makeResponse({ persona: 'plan', content: 'Plan done' }), + // Second: implement Phase 1 succeeds, but Phase 2 will block + makeResponse({ persona: 'implement', content: 'Impl done' }), + // Third: implement retried after user input (Phase 1 re-executes) + makeResponse({ persona: 'implement', content: 'Impl done with clarification' }), + // Fourth: supervise + makeResponse({ persona: 'supervise', content: 'All passed' }), + ]); + + mockDetectMatchedRuleSequence([ + // plan → implement + { index: 0, method: 'phase1_tag' }, + // implement (blocked, no rule eval happens) + // implement retry → supervise + { index: 0, method: 'phase1_tag' }, + // supervise → COMPLETE + { index: 0, method: 'phase1_tag' }, + ]); + + // Report phase: only implement has outputContracts; blocks first, succeeds on retry + const blockedResponse = makeResponse({ persona: 'implement', status: 'blocked', content: 'Need report clarification' }); + vi.mocked(runReportPhase).mockResolvedValueOnce({ blocked: true, response: blockedResponse }); // implement (first attempt) + vi.mocked(runReportPhase).mockResolvedValueOnce(undefined); // implement (retry, succeeds) + + const userInputFn = vi.fn(); + engine.on('movement:user_input', userInputFn); + + const state = await engine.run(); + + expect(state.status).toBe('completed'); + expect(onUserInput).toHaveBeenCalledOnce(); + expect(userInputFn).toHaveBeenCalledOnce(); + expect(state.userInputs).toContain('User provided report clarification'); + }); + + it('should propagate blocked content from report phase to engine response', async () => { + const config = buildConfigWithReport(); + const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan done' }), + makeResponse({ persona: 'implement', content: 'Original impl content' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + ]); + + const blockedContent = 'Blocked: need specific file path for report'; + const blockedResponse = makeResponse({ persona: 'implement', status: 'blocked', content: blockedContent }); + vi.mocked(runReportPhase).mockResolvedValueOnce({ blocked: true, response: blockedResponse }); + + const blockedFn = vi.fn(); + engine.on('movement:blocked', blockedFn); + + const state = await engine.run(); + + expect(state.status).toBe('aborted'); + expect(blockedFn).toHaveBeenCalledWith( + expect.objectContaining({ name: 'implement' }), + expect.objectContaining({ status: 'blocked', content: blockedContent }), + ); + }); +}); diff --git a/src/__tests__/review-only-piece.test.ts b/src/__tests__/review-only-piece.test.ts index 4ca0b305..74e891b2 100644 --- a/src/__tests__/review-only-piece.test.ts +++ b/src/__tests__/review-only-piece.test.ts @@ -36,8 +36,8 @@ describe('review-only piece (EN)', () => { expect(raw.initial_movement).toBe('plan'); }); - it('should have max_iterations of 10', () => { - expect(raw.max_iterations).toBe(10); + it('should have max_movements of 10', () => { + expect(raw.max_movements).toBe(10); }); it('should have 4 movements: plan, reviewers, supervise, pr-comment', () => { diff --git a/src/__tests__/run-paths.test.ts b/src/__tests__/run-paths.test.ts new file mode 100644 index 00000000..ba1965f1 --- /dev/null +++ b/src/__tests__/run-paths.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { buildRunPaths } from '../core/piece/run/run-paths.js'; + +describe('buildRunPaths', () => { + it('should build run-scoped relative and absolute paths', () => { + const paths = buildRunPaths('/tmp/project', '20260210-demo-task'); + + expect(paths.runRootRel).toBe('.takt/runs/20260210-demo-task'); + expect(paths.reportsRel).toBe('.takt/runs/20260210-demo-task/reports'); + expect(paths.contextKnowledgeRel).toBe('.takt/runs/20260210-demo-task/context/knowledge'); + expect(paths.contextPolicyRel).toBe('.takt/runs/20260210-demo-task/context/policy'); + expect(paths.contextPreviousResponsesRel).toBe('.takt/runs/20260210-demo-task/context/previous_responses'); + expect(paths.logsRel).toBe('.takt/runs/20260210-demo-task/logs'); + expect(paths.metaRel).toBe('.takt/runs/20260210-demo-task/meta.json'); + + expect(paths.reportsAbs).toBe('/tmp/project/.takt/runs/20260210-demo-task/reports'); + expect(paths.metaAbs).toBe('/tmp/project/.takt/runs/20260210-demo-task/meta.json'); + }); +}); diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index e7ec26c9..9bab686c 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -21,10 +21,25 @@ vi.mock('../infra/config/index.js', () => ({ import { loadGlobalConfig } from '../infra/config/index.js'; const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); -const mockClaimNextTasks = vi.fn(); -const mockCompleteTask = vi.fn(); -const mockFailTask = vi.fn(); -const mockRecoverInterruptedRunningTasks = vi.fn(); +const { + mockClaimNextTasks, + mockCompleteTask, + mockFailTask, + mockRecoverInterruptedRunningTasks, + mockNotifySuccess, + mockNotifyError, + mockSendSlackNotification, + mockGetSlackWebhookUrl, +} = vi.hoisted(() => ({ + mockClaimNextTasks: vi.fn(), + mockCompleteTask: vi.fn(), + mockFailTask: vi.fn(), + mockRecoverInterruptedRunningTasks: vi.fn(), + mockNotifySuccess: vi.fn(), + mockNotifyError: vi.fn(), + mockSendSlackNotification: vi.fn(), + mockGetSlackWebhookUrl: vi.fn(), +})); vi.mock('../infra/task/index.js', async (importOriginal) => ({ ...(await importOriginal>()), @@ -75,6 +90,10 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ error: vi.fn(), }), getErrorMessage: vi.fn((e) => e.message), + notifySuccess: mockNotifySuccess, + notifyError: mockNotifyError, + sendSlackNotification: mockSendSlackNotification, + getSlackWebhookUrl: mockGetSlackWebhookUrl, })); vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ @@ -149,6 +168,8 @@ describe('runAllTasks concurrency', () => { language: 'en', defaultPiece: 'default', logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: true, runAbort: true }, concurrency: 1, taskPollIntervalMs: 500, }); @@ -190,6 +211,8 @@ describe('runAllTasks concurrency', () => { language: 'en', defaultPiece: 'default', logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: true, runAbort: true }, concurrency: 3, taskPollIntervalMs: 500, }); @@ -266,6 +289,7 @@ describe('runAllTasks concurrency', () => { language: 'en', defaultPiece: 'default', logLevel: 'info', + notificationSound: false, concurrency: 1, taskPollIntervalMs: 500, }); @@ -283,6 +307,8 @@ describe('runAllTasks concurrency', () => { (call) => typeof call[0] === 'string' && call[0].startsWith('Concurrency:') ); expect(concurrencyInfoCalls).toHaveLength(0); + expect(mockNotifySuccess).not.toHaveBeenCalled(); + expect(mockNotifyError).not.toHaveBeenCalled(); }); }); @@ -291,7 +317,7 @@ describe('runAllTasks concurrency', () => { name: 'default', movements: [{ name: 'implement', personaDisplayName: 'coder' }], initialMovement: 'implement', - maxIterations: 10, + maxMovements: 10, }; beforeEach(() => { @@ -384,6 +410,16 @@ describe('runAllTasks concurrency', () => { it('should count partial failures correctly', async () => { // Given: 3 tasks, 1 fails, 2 succeed + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runAbort: true }, + concurrency: 3, + taskPollIntervalMs: 500, + }); + const task1 = createTask('pass-1'); const task2 = createTask('fail-1'); const task3 = createTask('pass-2'); @@ -406,6 +442,8 @@ describe('runAllTasks concurrency', () => { expect(mockStatus).toHaveBeenCalledWith('Total', '3'); expect(mockStatus).toHaveBeenCalledWith('Success', '2', undefined); expect(mockStatus).toHaveBeenCalledWith('Failed', '1', 'red'); + expect(mockNotifySuccess).not.toHaveBeenCalled(); + expect(mockNotifyError).toHaveBeenCalledTimes(1); }); it('should persist failure reason and movement when piece aborts', async () => { @@ -458,6 +496,8 @@ describe('runAllTasks concurrency', () => { language: 'en', defaultPiece: 'default', logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: true, runAbort: true }, concurrency: 1, taskPollIntervalMs: 500, }); @@ -480,5 +520,240 @@ describe('runAllTasks concurrency', () => { expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal); expect(pieceOptions?.taskPrefix).toBeUndefined(); }); + + it('should only notify once at run completion when multiple tasks succeed', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: true }, + concurrency: 3, + taskPollIntervalMs: 500, + }); + + const task1 = createTask('task-1'); + const task2 = createTask('task-2'); + const task3 = createTask('task-3'); + + mockClaimNextTasks + .mockReturnValueOnce([task1, task2, task3]) + .mockReturnValueOnce([]); + + await runAllTasks('/project'); + + expect(mockNotifySuccess).toHaveBeenCalledTimes(1); + expect(mockNotifyError).not.toHaveBeenCalled(); + }); + + it('should not notify run completion when runComplete is explicitly false', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: false }, + concurrency: 1, + taskPollIntervalMs: 500, + }); + + const task1 = createTask('task-1'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + await runAllTasks('/project'); + + expect(mockNotifySuccess).not.toHaveBeenCalled(); + expect(mockNotifyError).not.toHaveBeenCalled(); + }); + + it('should notify run completion by default when notification_sound_events is not set', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + concurrency: 1, + taskPollIntervalMs: 500, + }); + + const task1 = createTask('task-1'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + await runAllTasks('/project'); + + expect(mockNotifySuccess).toHaveBeenCalledTimes(1); + expect(mockNotifySuccess).toHaveBeenCalledWith('TAKT', 'run.notifyComplete'); + expect(mockNotifyError).not.toHaveBeenCalled(); + }); + + it('should notify run abort by default when notification_sound_events is not set', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + concurrency: 1, + taskPollIntervalMs: 500, + }); + + const task1 = createTask('task-1'); + mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' }); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + await runAllTasks('/project'); + + expect(mockNotifySuccess).not.toHaveBeenCalled(); + expect(mockNotifyError).toHaveBeenCalledTimes(1); + expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort'); + }); + + it('should not notify run abort when runAbort is explicitly false', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runAbort: false }, + concurrency: 1, + taskPollIntervalMs: 500, + }); + + const task1 = createTask('task-1'); + mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' }); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + await runAllTasks('/project'); + + expect(mockNotifySuccess).not.toHaveBeenCalled(); + expect(mockNotifyError).not.toHaveBeenCalled(); + }); + + it('should notify run abort and rethrow when worker pool throws', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runAbort: true }, + concurrency: 1, + taskPollIntervalMs: 500, + }); + + const task1 = createTask('task-1'); + const poolError = new Error('worker pool crashed'); + + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockImplementationOnce(() => { + throw poolError; + }); + + await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed'); + expect(mockNotifyError).toHaveBeenCalledTimes(1); + expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort'); + }); + }); + + describe('Slack webhook notification', () => { + const webhookUrl = 'https://hooks.slack.com/services/T00/B00/xxx'; + const fakePieceConfig = { + name: 'default', + movements: [{ name: 'implement', personaDisplayName: 'coder' }], + initialMovement: 'implement', + maxMovements: 10, + }; + + beforeEach(() => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + concurrency: 1, + taskPollIntervalMs: 500, + }); + mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never); + }); + + it('should send Slack notification on success when webhook URL is set', async () => { + // Given + mockGetSlackWebhookUrl.mockReturnValue(webhookUrl); + const task1 = createTask('task-1'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + // When + await runAllTasks('/project'); + + // Then + expect(mockSendSlackNotification).toHaveBeenCalledOnce(); + expect(mockSendSlackNotification).toHaveBeenCalledWith( + webhookUrl, + 'TAKT Run complete: 1 tasks succeeded', + ); + }); + + it('should send Slack notification on failure when webhook URL is set', async () => { + // Given + mockGetSlackWebhookUrl.mockReturnValue(webhookUrl); + const task1 = createTask('task-1'); + mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' }); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + // When + await runAllTasks('/project'); + + // Then + expect(mockSendSlackNotification).toHaveBeenCalledOnce(); + expect(mockSendSlackNotification).toHaveBeenCalledWith( + webhookUrl, + 'TAKT Run finished with errors: 1 failed out of 1 tasks', + ); + }); + + it('should send Slack notification on exception when webhook URL is set', async () => { + // Given + mockGetSlackWebhookUrl.mockReturnValue(webhookUrl); + const task1 = createTask('task-1'); + const poolError = new Error('worker pool crashed'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockImplementationOnce(() => { + throw poolError; + }); + + // When / Then + await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed'); + expect(mockSendSlackNotification).toHaveBeenCalledOnce(); + expect(mockSendSlackNotification).toHaveBeenCalledWith( + webhookUrl, + 'TAKT Run error: worker pool crashed', + ); + }); + + it('should not send Slack notification when webhook URL is not set', async () => { + // Given + mockGetSlackWebhookUrl.mockReturnValue(undefined); + const task1 = createTask('task-1'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + // When + await runAllTasks('/project'); + + // Then + expect(mockSendSlackNotification).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 51078ae8..f4edfb10 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -42,10 +42,13 @@ function loadTasks(testDir: string): { tasks: Array> } { beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-02-10T04:40:00.000Z')); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-')); }); afterEach(() => { + vi.useRealTimers(); if (testDir && fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true }); } @@ -61,7 +64,11 @@ describe('saveTaskFile', () => { const tasks = loadTasks(testDir).tasks; expect(tasks).toHaveLength(1); - expect(tasks[0]?.content).toContain('Implement feature X'); + expect(tasks[0]?.content).toBeUndefined(); + expect(tasks[0]?.task_dir).toBeTypeOf('string'); + const taskDir = path.join(testDir, String(tasks[0]?.task_dir)); + expect(fs.existsSync(path.join(taskDir, 'order.md'))).toBe(true); + expect(fs.readFileSync(path.join(taskDir, 'order.md'), 'utf-8')).toContain('Implement feature X'); }); it('should include optional fields', async () => { @@ -79,6 +86,7 @@ describe('saveTaskFile', () => { expect(task.worktree).toBe(true); expect(task.branch).toBe('feat/my-branch'); expect(task.auto_pr).toBe(false); + expect(task.task_dir).toBeTypeOf('string'); }); it('should generate unique names on duplicates', async () => { @@ -86,6 +94,13 @@ describe('saveTaskFile', () => { const second = await saveTaskFile(testDir, 'Same title'); expect(first.taskName).not.toBe(second.taskName); + + const tasks = loadTasks(testDir).tasks; + expect(tasks).toHaveLength(2); + expect(tasks[0]?.task_dir).toBe('.takt/tasks/20260210-044000-same-title'); + expect(tasks[1]?.task_dir).toBe('.takt/tasks/20260210-044000-same-title-2'); + expect(fs.readFileSync(path.join(testDir, String(tasks[0]?.task_dir), 'order.md'), 'utf-8')).toContain('Same title'); + expect(fs.readFileSync(path.join(testDir, String(tasks[1]?.task_dir), 'order.md'), 'utf-8')).toContain('Same title'); }); }); @@ -122,4 +137,16 @@ describe('saveTaskFromInteractive', () => { expect(mockInfo).toHaveBeenCalledWith(' Piece: review'); }); + + it('should record issue number in tasks.yaml when issue option is provided', async () => { + // Given: user declines worktree + mockConfirm.mockResolvedValueOnce(false); + + // When + await saveTaskFromInteractive(testDir, 'Fix login bug', 'default', { issue: 42 }); + + // Then + const task = loadTasks(testDir).tasks[0]!; + expect(task.issue).toBe(42); + }); }); diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index f4eeb2f4..2aad3566 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -30,6 +30,11 @@ vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), error: vi.fn(), success: vi.fn(), + withProgress: async ( + _startMessage: string, + _completionMessage: string | ((result: T) => string), + operation: () => Promise, + ): Promise => operation(), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ @@ -126,7 +131,7 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { name: 'default', movements: [], initialMovement: 'start', - maxIterations: 1, + maxMovements: 1, }, }], ])); diff --git a/src/__tests__/session-reader.test.ts b/src/__tests__/session-reader.test.ts new file mode 100644 index 00000000..b5baed0d --- /dev/null +++ b/src/__tests__/session-reader.test.ts @@ -0,0 +1,261 @@ +/** + * Tests for Claude Code session reader + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Mock getClaudeProjectSessionsDir to point to our temp directory +let mockSessionsDir: string; + +vi.mock('../infra/config/project/sessionStore.js', () => ({ + getClaudeProjectSessionsDir: vi.fn(() => mockSessionsDir), +})); + +import { loadSessionIndex, extractLastAssistantResponse } from '../infra/claude/session-reader.js'; + +describe('loadSessionIndex', () => { + beforeEach(() => { + mockSessionsDir = mkdtempSync(join(tmpdir(), 'session-reader-test-')); + }); + + it('returns empty array when sessions-index.json does not exist', () => { + const result = loadSessionIndex('/nonexistent'); + expect(result).toEqual([]); + }); + + it('reads and parses sessions-index.json correctly', () => { + const indexData = { + version: 1, + entries: [ + { + sessionId: 'aaa', + firstPrompt: 'First session', + modified: '2026-01-28T10:00:00.000Z', + messageCount: 5, + gitBranch: 'main', + isSidechain: false, + fullPath: '/path/to/aaa.jsonl', + }, + { + sessionId: 'bbb', + firstPrompt: 'Second session', + modified: '2026-01-29T10:00:00.000Z', + messageCount: 10, + gitBranch: '', + isSidechain: false, + fullPath: '/path/to/bbb.jsonl', + }, + ], + }; + + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), JSON.stringify(indexData)); + + const result = loadSessionIndex('/any'); + expect(result).toHaveLength(2); + // Sorted by modified descending: bbb (Jan 29) first, then aaa (Jan 28) + expect(result[0]!.sessionId).toBe('bbb'); + expect(result[1]!.sessionId).toBe('aaa'); + }); + + it('filters out sidechain sessions', () => { + const indexData = { + version: 1, + entries: [ + { + sessionId: 'main-session', + firstPrompt: 'User conversation', + modified: '2026-01-28T10:00:00.000Z', + messageCount: 5, + gitBranch: '', + isSidechain: false, + fullPath: '/path/to/main.jsonl', + }, + { + sessionId: 'sidechain-session', + firstPrompt: 'Sub-agent work', + modified: '2026-01-29T10:00:00.000Z', + messageCount: 20, + gitBranch: '', + isSidechain: true, + fullPath: '/path/to/sidechain.jsonl', + }, + ], + }; + + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), JSON.stringify(indexData)); + + const result = loadSessionIndex('/any'); + expect(result).toHaveLength(1); + expect(result[0]!.sessionId).toBe('main-session'); + }); + + it('returns empty array when entries is missing', () => { + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), JSON.stringify({ version: 1 })); + + const result = loadSessionIndex('/any'); + expect(result).toEqual([]); + }); + + it('returns empty array when sessions-index.json contains corrupted JSON', () => { + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), '{corrupted json content'); + + const result = loadSessionIndex('/any'); + expect(result).toEqual([]); + }); +}); + +describe('extractLastAssistantResponse', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'session-reader-extract-')); + }); + + it('returns null when file does not exist', () => { + const result = extractLastAssistantResponse('/nonexistent/file.jsonl', 200); + expect(result).toBeNull(); + }); + + it('extracts text from last assistant message', () => { + const lines = [ + JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'First response' }] }, + }), + JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'Follow up' }] }, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Last response here' }] }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Last response here'); + }); + + it('skips assistant messages with only tool_use content', () => { + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Text response' }] }, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'tool_use', id: 'tool1', name: 'Read', input: {} }] }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Text response'); + }); + + it('returns null when no assistant messages have text', () => { + const lines = [ + JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'tool_use', id: 'tool1', name: 'Read', input: {} }] }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBeNull(); + }); + + it('truncates long responses', () => { + const longText = 'A'.repeat(300); + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: longText }] }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toHaveLength(201); // 200 chars + '…' + expect(result!.endsWith('…')).toBe(true); + }); + + it('concatenates multiple text blocks in a single message', () => { + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'Part one' }, + { type: 'tool_use', id: 'tool1', name: 'Read', input: {} }, + { type: 'text', text: 'Part two' }, + ], + }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Part one\nPart two'); + }); + + it('handles malformed JSON lines gracefully', () => { + const lines = [ + 'not valid json', + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Valid response' }] }, + }), + '{also broken', + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Valid response'); + }); + + it('handles progress and other non-assistant record types', () => { + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Response' }] }, + }), + JSON.stringify({ + type: 'progress', + data: { type: 'hook_progress' }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Response'); + }); +}); diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index c4e123b3..cd3093d1 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -7,14 +7,11 @@ import { existsSync, readFileSync, mkdirSync, rmSync, writeFileSync } from 'node import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { - createSessionLog, - updateLatestPointer, initNdjsonLog, appendNdjsonLine, loadNdjsonLog, loadSessionLog, extractFailureInfo, - type LatestLogPointer, type SessionLog, type NdjsonRecord, type NdjsonStepComplete, @@ -26,121 +23,18 @@ import { type NdjsonInteractiveEnd, } from '../infra/fs/session.js'; -/** Create a temp project directory with .takt/logs structure */ +/** Create a temp project directory for each test */ function createTempProject(): string { const dir = join(tmpdir(), `takt-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); mkdirSync(dir, { recursive: true }); return dir; } -describe('updateLatestPointer', () => { - let projectDir: string; - - beforeEach(() => { - projectDir = createTempProject(); - }); - - afterEach(() => { - rmSync(projectDir, { recursive: true, force: true }); - }); - - it('should create latest.json with pointer data', () => { - const log = createSessionLog('my task', projectDir, 'default'); - const sessionId = 'abc-123'; - - updateLatestPointer(log, sessionId, projectDir); - - const latestPath = join(projectDir, '.takt', 'logs', 'latest.json'); - expect(existsSync(latestPath)).toBe(true); - - const pointer = JSON.parse(readFileSync(latestPath, 'utf-8')) as LatestLogPointer; - expect(pointer.sessionId).toBe('abc-123'); - expect(pointer.logFile).toBe('abc-123.jsonl'); - expect(pointer.task).toBe('my task'); - expect(pointer.pieceName).toBe('default'); - expect(pointer.status).toBe('running'); - expect(pointer.iterations).toBe(0); - expect(pointer.startTime).toBeDefined(); - expect(pointer.updatedAt).toBeDefined(); - }); - - it('should not create previous.json when copyToPrevious is false', () => { - const log = createSessionLog('task', projectDir, 'wf'); - updateLatestPointer(log, 'sid-1', projectDir); - - const previousPath = join(projectDir, '.takt', 'logs', 'previous.json'); - expect(existsSync(previousPath)).toBe(false); - }); - - it('should not create previous.json when copyToPrevious is true but latest.json does not exist', () => { - const log = createSessionLog('task', projectDir, 'wf'); - updateLatestPointer(log, 'sid-1', projectDir, { copyToPrevious: true }); - - const previousPath = join(projectDir, '.takt', 'logs', 'previous.json'); - // latest.json didn't exist before this call, so previous.json should not be created - expect(existsSync(previousPath)).toBe(false); - }); - - it('should copy latest.json to previous.json when copyToPrevious is true and latest exists', () => { - const log1 = createSessionLog('first task', projectDir, 'wf1'); - updateLatestPointer(log1, 'sid-first', projectDir); - - // Simulate a second piece starting - const log2 = createSessionLog('second task', projectDir, 'wf2'); - updateLatestPointer(log2, 'sid-second', projectDir, { copyToPrevious: true }); - - const logsDir = join(projectDir, '.takt', 'logs'); - const latest = JSON.parse(readFileSync(join(logsDir, 'latest.json'), 'utf-8')) as LatestLogPointer; - const previous = JSON.parse(readFileSync(join(logsDir, 'previous.json'), 'utf-8')) as LatestLogPointer; - - // latest should point to second session - expect(latest.sessionId).toBe('sid-second'); - expect(latest.task).toBe('second task'); - - // previous should point to first session - expect(previous.sessionId).toBe('sid-first'); - expect(previous.task).toBe('first task'); - }); - - it('should not update previous.json on step-complete calls (no copyToPrevious)', () => { - // Piece 1 creates latest - const log1 = createSessionLog('first', projectDir, 'wf'); - updateLatestPointer(log1, 'sid-1', projectDir); - - // Piece 2 starts → copies latest to previous - const log2 = createSessionLog('second', projectDir, 'wf'); - updateLatestPointer(log2, 'sid-2', projectDir, { copyToPrevious: true }); - - // Step completes → updates only latest (no copyToPrevious) - log2.iterations = 1; - updateLatestPointer(log2, 'sid-2', projectDir); - - const logsDir = join(projectDir, '.takt', 'logs'); - const previous = JSON.parse(readFileSync(join(logsDir, 'previous.json'), 'utf-8')) as LatestLogPointer; - - // previous should still point to first session - expect(previous.sessionId).toBe('sid-1'); - }); - - it('should update iterations and status in latest.json on subsequent calls', () => { - const log = createSessionLog('task', projectDir, 'wf'); - updateLatestPointer(log, 'sid-1', projectDir, { copyToPrevious: true }); - - // Simulate step completion - log.iterations = 2; - updateLatestPointer(log, 'sid-1', projectDir); - - // Simulate piece completion - log.status = 'completed'; - log.iterations = 3; - updateLatestPointer(log, 'sid-1', projectDir); - - const latestPath = join(projectDir, '.takt', 'logs', 'latest.json'); - const pointer = JSON.parse(readFileSync(latestPath, 'utf-8')) as LatestLogPointer; - expect(pointer.status).toBe('completed'); - expect(pointer.iterations).toBe(3); - }); -}); +function initTestNdjsonLog(sessionId: string, task: string, pieceName: string, projectDir: string): string { + const logsDir = join(projectDir, '.takt', 'runs', 'test-run', 'logs'); + mkdirSync(logsDir, { recursive: true }); + return initNdjsonLog(sessionId, task, pieceName, { logsDir }); +} describe('NDJSON log', () => { let projectDir: string; @@ -155,7 +49,7 @@ describe('NDJSON log', () => { describe('initNdjsonLog', () => { it('should create a .jsonl file with piece_start record', () => { - const filepath = initNdjsonLog('sess-001', 'my task', 'default', projectDir); + const filepath = initTestNdjsonLog('sess-001', 'my task', 'default', projectDir); expect(filepath).toContain('sess-001.jsonl'); expect(existsSync(filepath)).toBe(true); @@ -176,7 +70,7 @@ describe('NDJSON log', () => { describe('appendNdjsonLine', () => { it('should append records as individual lines', () => { - const filepath = initNdjsonLog('sess-002', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-002', 'task', 'wf', projectDir); const stepStart: NdjsonRecord = { type: 'step_start', @@ -224,7 +118,7 @@ describe('NDJSON log', () => { describe('loadNdjsonLog', () => { it('should reconstruct SessionLog from NDJSON file', () => { - const filepath = initNdjsonLog('sess-003', 'build app', 'default', projectDir); + const filepath = initTestNdjsonLog('sess-003', 'build app', 'default', projectDir); // Add step_start + step_complete appendNdjsonLine(filepath, { @@ -270,7 +164,7 @@ describe('NDJSON log', () => { }); it('should handle aborted piece', () => { - const filepath = initNdjsonLog('sess-004', 'failing task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-004', 'failing task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'step_start', @@ -294,7 +188,7 @@ describe('NDJSON log', () => { const abort: NdjsonPieceAbort = { type: 'piece_abort', iterations: 1, - reason: 'Max iterations reached', + reason: 'Max movements reached', endTime: '2025-01-01T00:00:03.000Z', }; appendNdjsonLine(filepath, abort); @@ -321,7 +215,7 @@ describe('NDJSON log', () => { }); it('should skip step_start records when reconstructing SessionLog', () => { - const filepath = initNdjsonLog('sess-005', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-005', 'task', 'wf', projectDir); // Add various records appendNdjsonLine(filepath, { @@ -358,7 +252,7 @@ describe('NDJSON log', () => { describe('loadSessionLog with .jsonl extension', () => { it('should delegate to loadNdjsonLog for .jsonl files', () => { - const filepath = initNdjsonLog('sess-006', 'jsonl task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-006', 'jsonl task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'step_complete', @@ -406,7 +300,7 @@ describe('NDJSON log', () => { describe('appendNdjsonLine real-time characteristics', () => { it('should append without overwriting previous content', () => { - const filepath = initNdjsonLog('sess-007', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-007', 'task', 'wf', projectDir); // Read after init const after1 = readFileSync(filepath, 'utf-8').trim().split('\n'); @@ -428,7 +322,7 @@ describe('NDJSON log', () => { }); it('should produce valid JSON on each line', () => { - const filepath = initNdjsonLog('sess-008', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-008', 'task', 'wf', projectDir); for (let i = 0; i < 5; i++) { appendNdjsonLine(filepath, { @@ -453,7 +347,7 @@ describe('NDJSON log', () => { describe('phase NDJSON records', () => { it('should serialize and append phase_start records', () => { - const filepath = initNdjsonLog('sess-phase-001', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-phase-001', 'task', 'wf', projectDir); const record: NdjsonPhaseStart = { type: 'phase_start', @@ -480,7 +374,7 @@ describe('NDJSON log', () => { }); it('should serialize and append phase_complete records', () => { - const filepath = initNdjsonLog('sess-phase-002', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-phase-002', 'task', 'wf', projectDir); const record: NdjsonPhaseComplete = { type: 'phase_complete', @@ -509,7 +403,7 @@ describe('NDJSON log', () => { }); it('should serialize phase_complete with error', () => { - const filepath = initNdjsonLog('sess-phase-003', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-phase-003', 'task', 'wf', projectDir); const record: NdjsonPhaseComplete = { type: 'phase_complete', @@ -534,7 +428,7 @@ describe('NDJSON log', () => { }); it('should be skipped by loadNdjsonLog (default case)', () => { - const filepath = initNdjsonLog('sess-phase-004', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-phase-004', 'task', 'wf', projectDir); // Add phase records appendNdjsonLine(filepath, { @@ -577,7 +471,7 @@ describe('NDJSON log', () => { describe('interactive NDJSON records', () => { it('should serialize and append interactive_start records', () => { - const filepath = initNdjsonLog('sess-interactive-001', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-interactive-001', 'task', 'wf', projectDir); const record: NdjsonInteractiveStart = { type: 'interactive_start', @@ -597,7 +491,7 @@ describe('NDJSON log', () => { }); it('should serialize and append interactive_end records', () => { - const filepath = initNdjsonLog('sess-interactive-002', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-interactive-002', 'task', 'wf', projectDir); const record: NdjsonInteractiveEnd = { type: 'interactive_end', @@ -620,7 +514,7 @@ describe('NDJSON log', () => { }); it('should be skipped by loadNdjsonLog (default case)', () => { - const filepath = initNdjsonLog('sess-interactive-003', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-interactive-003', 'task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'interactive_start', @@ -647,7 +541,7 @@ describe('NDJSON log', () => { }); it('should extract failure info from aborted piece log', () => { - const filepath = initNdjsonLog('20260205-120000-abc123', 'failing task', 'wf', projectDir); + const filepath = initTestNdjsonLog('20260205-120000-abc123', 'failing task', 'wf', projectDir); // Add step_start for plan appendNdjsonLine(filepath, { @@ -696,7 +590,7 @@ describe('NDJSON log', () => { }); it('should handle log with only completed movements (no abort)', () => { - const filepath = initNdjsonLog('sess-success-001', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-success-001', 'task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'step_start', @@ -731,7 +625,7 @@ describe('NDJSON log', () => { }); it('should handle log with no step_complete records', () => { - const filepath = initNdjsonLog('sess-fail-early-001', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-fail-early-001', 'task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'step_start', diff --git a/src/__tests__/sessionSelector.test.ts b/src/__tests__/sessionSelector.test.ts new file mode 100644 index 00000000..9bb7941d --- /dev/null +++ b/src/__tests__/sessionSelector.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for session selector + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SessionIndexEntry } from '../infra/claude/session-reader.js'; + +const mockLoadSessionIndex = vi.fn<(dir: string) => SessionIndexEntry[]>(); +const mockExtractLastAssistantResponse = vi.fn<(path: string, maxLen: number) => string | null>(); + +vi.mock('../infra/claude/session-reader.js', () => ({ + loadSessionIndex: (...args: [string]) => mockLoadSessionIndex(...args), + extractLastAssistantResponse: (...args: [string, number]) => mockExtractLastAssistantResponse(...args), +})); + +const mockSelectOption = vi.fn<(prompt: string, options: unknown[]) => Promise>(); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: (...args: [string, unknown[]]) => mockSelectOption(...args), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: (key: string, _lang: string, params?: Record) => { + if (key === 'interactive.sessionSelector.newSession') return 'New session'; + if (key === 'interactive.sessionSelector.newSessionDescription') return 'Start a new conversation'; + if (key === 'interactive.sessionSelector.messages') return `${params?.count} messages`; + if (key === 'interactive.sessionSelector.lastResponse') return `Last: ${params?.response}`; + if (key === 'interactive.sessionSelector.prompt') return 'Select a session'; + return key; + }, +})); + +import { selectRecentSession } from '../features/interactive/sessionSelector.js'; + +describe('selectRecentSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return null when no sessions exist', async () => { + mockLoadSessionIndex.mockReturnValue([]); + + const result = await selectRecentSession('/project', 'en'); + + expect(result).toBeNull(); + expect(mockSelectOption).not.toHaveBeenCalled(); + }); + + it('should return null when user selects __new__', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('session-1', 'Hello world', '2026-01-28T10:00:00.000Z'), + ]); + mockExtractLastAssistantResponse.mockReturnValue(null); + mockSelectOption.mockResolvedValue('__new__'); + + const result = await selectRecentSession('/project', 'en'); + + expect(result).toBeNull(); + }); + + it('should return null when user cancels selection', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('session-1', 'Hello world', '2026-01-28T10:00:00.000Z'), + ]); + mockExtractLastAssistantResponse.mockReturnValue(null); + mockSelectOption.mockResolvedValue(null); + + const result = await selectRecentSession('/project', 'en'); + + expect(result).toBeNull(); + }); + + it('should return sessionId when user selects a session', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('session-abc', 'Fix the bug', '2026-01-28T10:00:00.000Z'), + ]); + mockExtractLastAssistantResponse.mockReturnValue(null); + mockSelectOption.mockResolvedValue('session-abc'); + + const result = await selectRecentSession('/project', 'en'); + + expect(result).toBe('session-abc'); + }); + + it('should pass correct options to selectOption with new session first', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('s1', 'First prompt', '2026-01-28T10:00:00.000Z', 5), + ]); + mockExtractLastAssistantResponse.mockReturnValue('Some response'); + mockSelectOption.mockResolvedValue('s1'); + + await selectRecentSession('/project', 'en'); + + expect(mockSelectOption).toHaveBeenCalledWith( + 'Select a session', + expect.arrayContaining([ + expect.objectContaining({ value: '__new__', label: 'New session' }), + expect.objectContaining({ value: 's1' }), + ]), + ); + + const options = mockSelectOption.mock.calls[0]![1] as Array<{ value: string }>; + expect(options[0]!.value).toBe('__new__'); + expect(options[1]!.value).toBe('s1'); + }); + + it('should limit display to MAX_DISPLAY_SESSIONS (10)', async () => { + const sessions = Array.from({ length: 15 }, (_, i) => + createSession(`s${i}`, `Prompt ${i}`, `2026-01-${String(i + 10).padStart(2, '0')}T10:00:00.000Z`), + ); + mockLoadSessionIndex.mockReturnValue(sessions); + mockExtractLastAssistantResponse.mockReturnValue(null); + mockSelectOption.mockResolvedValue(null); + + await selectRecentSession('/project', 'en'); + + const options = mockSelectOption.mock.calls[0]![1] as Array<{ value: string }>; + // 1 new session + 10 display sessions = 11 total + expect(options).toHaveLength(11); + }); + + it('should include last response details when available', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('s1', 'Hello', '2026-01-28T10:00:00.000Z', 3, '/path/to/s1.jsonl'), + ]); + mockExtractLastAssistantResponse.mockReturnValue('AI response text'); + mockSelectOption.mockResolvedValue('s1'); + + await selectRecentSession('/project', 'en'); + + expect(mockExtractLastAssistantResponse).toHaveBeenCalledWith('/path/to/s1.jsonl', 200); + + const options = mockSelectOption.mock.calls[0]![1] as Array<{ value: string; details?: string[] }>; + const sessionOption = options[1]!; + expect(sessionOption.details).toBeDefined(); + expect(sessionOption.details![0]).toContain('AI response text'); + }); +}); + +function createSession( + sessionId: string, + firstPrompt: string, + modified: string, + messageCount = 5, + fullPath = `/path/to/${sessionId}.jsonl`, +): SessionIndexEntry { + return { + sessionId, + firstPrompt, + modified, + messageCount, + gitBranch: 'main', + isSidechain: false, + fullPath, + }; +} diff --git a/src/__tests__/slackWebhook.test.ts b/src/__tests__/slackWebhook.test.ts new file mode 100644 index 00000000..5946eb39 --- /dev/null +++ b/src/__tests__/slackWebhook.test.ts @@ -0,0 +1,135 @@ +/** + * Unit tests for Slack Incoming Webhook notification + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { sendSlackNotification, getSlackWebhookUrl } from '../shared/utils/slackWebhook.js'; + +describe('sendSlackNotification', () => { + const webhookUrl = 'https://hooks.slack.com/services/T00/B00/xxx'; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should send POST request with correct payload', async () => { + // Given + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', mockFetch); + + // When + await sendSlackNotification(webhookUrl, 'Hello from TAKT'); + + // Then + expect(mockFetch).toHaveBeenCalledOnce(); + expect(mockFetch).toHaveBeenCalledWith( + webhookUrl, + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'Hello from TAKT' }), + }), + ); + }); + + it('should include AbortSignal for timeout', async () => { + // Given + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', mockFetch); + + // When + await sendSlackNotification(webhookUrl, 'test'); + + // Then + const callArgs = mockFetch.mock.calls[0]![1] as RequestInit; + expect(callArgs.signal).toBeInstanceOf(AbortSignal); + }); + + it('should write to stderr on non-ok response', async () => { + // Given + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + }); + vi.stubGlobal('fetch', mockFetch); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + // When + await sendSlackNotification(webhookUrl, 'test'); + + // Then: no exception thrown, error written to stderr + expect(stderrSpy).toHaveBeenCalledWith( + 'Slack webhook failed: HTTP 403 Forbidden\n', + ); + }); + + it('should write to stderr on fetch error without throwing', async () => { + // Given + const mockFetch = vi.fn().mockRejectedValue(new Error('network timeout')); + vi.stubGlobal('fetch', mockFetch); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + // When + await sendSlackNotification(webhookUrl, 'test'); + + // Then: no exception thrown, error written to stderr + expect(stderrSpy).toHaveBeenCalledWith( + 'Slack webhook error: network timeout\n', + ); + }); + + it('should handle non-Error thrown values', async () => { + // Given + const mockFetch = vi.fn().mockRejectedValue('string error'); + vi.stubGlobal('fetch', mockFetch); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + // When + await sendSlackNotification(webhookUrl, 'test'); + + // Then + expect(stderrSpy).toHaveBeenCalledWith( + 'Slack webhook error: string error\n', + ); + }); +}); + +describe('getSlackWebhookUrl', () => { + const envKey = 'TAKT_NOTIFY_WEBHOOK'; + let originalValue: string | undefined; + + beforeEach(() => { + originalValue = process.env[envKey]; + }); + + afterEach(() => { + if (originalValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = originalValue; + } + }); + + it('should return the webhook URL when environment variable is set', () => { + // Given + process.env[envKey] = 'https://hooks.slack.com/services/T00/B00/xxx'; + + // When + const url = getSlackWebhookUrl(); + + // Then + expect(url).toBe('https://hooks.slack.com/services/T00/B00/xxx'); + }); + + it('should return undefined when environment variable is not set', () => { + // Given + delete process.env[envKey]; + + // When + const url = getSlackWebhookUrl(); + + // Then + expect(url).toBeUndefined(); + }); +}); diff --git a/src/__tests__/state-manager.test.ts b/src/__tests__/state-manager.test.ts index 3da87a98..15580d59 100644 --- a/src/__tests__/state-manager.test.ts +++ b/src/__tests__/state-manager.test.ts @@ -22,7 +22,7 @@ function makeConfig(overrides: Partial = {}): PieceConfig { name: 'test-piece', movements: [], initialMovement: 'start', - maxIterations: 10, + maxMovements: 10, ...overrides, }; } diff --git a/src/__tests__/summarize.test.ts b/src/__tests__/summarize.test.ts index 20ba8652..db393829 100644 --- a/src/__tests__/summarize.test.ts +++ b/src/__tests__/summarize.test.ts @@ -57,20 +57,25 @@ describe('summarizeTaskName', () => { timestamp: new Date(), }); - // When - const result = await summarizeTaskName('long task name for testing', { cwd: '/project' }); - - // Then - expect(result).toBe('add-auth'); - expect(mockGetProvider).toHaveBeenCalledWith('claude'); - expect(mockProviderCall).toHaveBeenCalledWith( - 'long task name for testing', - expect.objectContaining({ - cwd: '/project', - allowedTools: [], - }) - ); - }); + // When + const result = await summarizeTaskName('long task name for testing', { cwd: '/project' }); + + // Then + expect(result).toBe('add-auth'); + expect(mockGetProvider).toHaveBeenCalledWith('claude'); + const callPrompt = mockProviderCall.mock.calls[0]?.[0]; + expect(callPrompt).toContain('Generate a slug from the task description below.'); + expect(callPrompt).toContain(''); + expect(callPrompt).toContain('long task name for testing'); + expect(callPrompt).toContain(''); + expect(mockProviderCall).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + cwd: '/project', + permissionMode: 'readonly', + }) + ); + }); it('should return AI-generated slug for English task name', async () => { // Given diff --git a/src/__tests__/switchPiece.test.ts b/src/__tests__/switchPiece.test.ts index 5a865242..44ab51a1 100644 --- a/src/__tests__/switchPiece.test.ts +++ b/src/__tests__/switchPiece.test.ts @@ -57,7 +57,7 @@ describe('switchPiece', () => { name: 'default', movements: [], initialMovement: 'start', - maxIterations: 1, + maxMovements: 1, }, }], ])); diff --git a/src/__tests__/task-prefix-writer.test.ts b/src/__tests__/task-prefix-writer.test.ts index 8cc4fdb2..cf038656 100644 --- a/src/__tests__/task-prefix-writer.test.ts +++ b/src/__tests__/task-prefix-writer.test.ts @@ -188,7 +188,7 @@ describe('TaskPrefixWriter', () => { writer.setMovementContext({ movementName: 'implement', iteration: 4, - maxIterations: 30, + maxMovements: 30, movementIteration: 2, }); writer.writeLine('content'); diff --git a/src/__tests__/task-schema.test.ts b/src/__tests__/task-schema.test.ts index c0971f2c..dc2550cc 100644 --- a/src/__tests__/task-schema.test.ts +++ b/src/__tests__/task-schema.test.ts @@ -216,9 +216,39 @@ describe('TaskRecordSchema', () => { expect(() => TaskRecordSchema.parse(record)).not.toThrow(); }); - it('should reject record with neither content nor content_file', () => { + it('should accept record with task_dir', () => { + const record = { ...makePendingRecord(), content: undefined, task_dir: '.takt/tasks/20260201-000000-task' }; + expect(() => TaskRecordSchema.parse(record)).not.toThrow(); + }); + + it('should reject record with neither content, content_file, nor task_dir', () => { const record = { ...makePendingRecord(), content: undefined }; expect(() => TaskRecordSchema.parse(record)).toThrow(); }); + + it('should reject record with both content and task_dir', () => { + const record = { ...makePendingRecord(), task_dir: '.takt/tasks/20260201-000000-task' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject record with invalid task_dir format', () => { + const record = { ...makePendingRecord(), content: undefined, task_dir: '.takt/reports/invalid' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject record with parent-directory task_dir', () => { + const record = { ...makePendingRecord(), content: undefined, task_dir: '.takt/tasks/..' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject record with empty content', () => { + const record = { ...makePendingRecord(), content: '' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject record with empty content_file', () => { + const record = { ...makePendingRecord(), content: undefined, content_file: '' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); }); }); diff --git a/src/__tests__/task.test.ts b/src/__tests__/task.test.ts index d5dfe14f..d7153905 100644 --- a/src/__tests__/task.test.ts +++ b/src/__tests__/task.test.ts @@ -161,14 +161,49 @@ describe('TaskRunner (tasks.yaml)', () => { expect(tasks[0]?.content).toBe('Absolute task content'); }); - it('should prefer inline content over content_file', () => { + it('should build task instruction from task_dir and expose taskDir on TaskInfo', () => { + mkdirSync(join(testDir, '.takt', 'tasks', '20260201-000000-demo'), { recursive: true }); + writeFileSync( + join(testDir, '.takt', 'tasks', '20260201-000000-demo', 'order.md'), + 'Detailed long spec', + 'utf-8', + ); writeTasksFile(testDir, [createPendingRecord({ - content: 'Inline content', - content_file: 'missing-content-file.txt', + content: undefined, + task_dir: '.takt/tasks/20260201-000000-demo', })]); const tasks = runner.listTasks(); - expect(tasks[0]?.content).toBe('Inline content'); + expect(tasks[0]?.taskDir).toBe('.takt/tasks/20260201-000000-demo'); + expect(tasks[0]?.content).toContain('Implement using only the files'); + expect(tasks[0]?.content).toContain('.takt/tasks/20260201-000000-demo'); + expect(tasks[0]?.content).toContain('.takt/tasks/20260201-000000-demo/order.md'); + }); + + it('should throw when task_dir order.md is missing', () => { + mkdirSync(join(testDir, '.takt', 'tasks', '20260201-000000-missing'), { recursive: true }); + writeTasksFile(testDir, [createPendingRecord({ + content: undefined, + task_dir: '.takt/tasks/20260201-000000-missing', + })]); + + expect(() => runner.listTasks()).toThrow(/Task spec file is missing/i); + }); + + it('should reset tasks file when both content and content_file are set', () => { + writeTasksFile(testDir, [{ + name: 'task-a', + status: 'pending', + content: 'Inline content', + content_file: 'missing-content-file.txt', + created_at: '2026-02-09T00:00:00.000Z', + started_at: null, + completed_at: null, + owner_pid: null, + }]); + + expect(runner.listTasks()).toEqual([]); + expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(false); }); it('should throw when content_file target is missing', () => { @@ -180,7 +215,7 @@ describe('TaskRunner (tasks.yaml)', () => { expect(() => runner.listTasks()).toThrow(/ENOENT|no such file/i); }); - it('should mark claimed task as completed', () => { + it('should remove completed task record from tasks.yaml', () => { runner.addTask('Task A'); const task = runner.claimNextTasks(1)[0]!; @@ -194,7 +229,27 @@ describe('TaskRunner (tasks.yaml)', () => { }); const file = loadTasksFile(testDir); - expect(file.tasks[0]?.status).toBe('completed'); + expect(file.tasks).toHaveLength(0); + }); + + it('should remove only the completed task when multiple tasks exist', () => { + runner.addTask('Task A'); + runner.addTask('Task B'); + const task = runner.claimNextTasks(1)[0]!; + + runner.completeTask({ + task, + success: true, + response: 'Done', + executionLog: [], + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + }); + + const file = loadTasksFile(testDir); + expect(file.tasks).toHaveLength(1); + expect(file.tasks[0]?.name).toContain('task-b'); + expect(file.tasks[0]?.status).toBe('pending'); }); it('should mark claimed task as failed with failure detail', () => { diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 5d5eb249..382e00ca 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -2,6 +2,9 @@ * Tests for resolveTaskExecution */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock dependencies before importing the module under test @@ -40,14 +43,23 @@ vi.mock('../infra/task/summarize.js', async (importOriginal) => ({ summarizeTaskName: vi.fn(), })); -vi.mock('../shared/ui/index.js', () => ({ - header: vi.fn(), - info: vi.fn(), - error: vi.fn(), - success: vi.fn(), - status: vi.fn(), - blankLine: vi.fn(), -})); +vi.mock('../shared/ui/index.js', () => { + const info = vi.fn(); + return { + header: vi.fn(), + info, + error: vi.fn(), + success: vi.fn(), + status: vi.fn(), + blankLine: vi.fn(), + withProgress: vi.fn(async (start, done, operation) => { + info(start); + const result = await operation(); + info(typeof done === 'function' ? done(result) : done); + return result; + }), + }; +}); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), @@ -193,6 +205,7 @@ describe('resolveTaskExecution', () => { // Then expect(mockInfo).toHaveBeenCalledWith('Generating branch name...'); + expect(mockInfo).toHaveBeenCalledWith('Branch name generated: test-task'); }); it('should use task content (not name) for AI summarization', async () => { @@ -511,4 +524,101 @@ describe('resolveTaskExecution', () => { expect(mockSummarizeTaskName).not.toHaveBeenCalled(); expect(mockCreateSharedClone).not.toHaveBeenCalled(); }); + + it('should stage task_dir spec into run context and return reportDirName', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-taskdir-normal-')); + const projectDir = path.join(tmpRoot, 'project'); + fs.mkdirSync(path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng'), { recursive: true }); + const sourceOrder = path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng', 'order.md'); + fs.writeFileSync(sourceOrder, '# normal task spec\n', 'utf-8'); + + const task: TaskInfo = { + name: 'task-with-dir', + content: 'Task content', + taskDir: '.takt/tasks/20260201-015714-foptng', + filePath: '/tasks/task.yaml', + data: { + task: 'Task content', + }, + }; + + const result = await resolveTaskExecution(task, projectDir, 'default'); + + expect(result.reportDirName).toBe('20260201-015714-foptng'); + expect(result.execCwd).toBe(projectDir); + const stagedOrder = path.join(projectDir, '.takt', 'runs', '20260201-015714-foptng', 'context', 'task', 'order.md'); + expect(fs.existsSync(stagedOrder)).toBe(true); + expect(fs.readFileSync(stagedOrder, 'utf-8')).toContain('normal task spec'); + expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260201-015714-foptng/context/task/order.md`.'); + expect(result.taskPrompt).not.toContain(projectDir); + }); + + it('should throw when taskDir format is invalid', async () => { + const task: TaskInfo = { + name: 'task-with-invalid-dir', + content: 'Task content', + taskDir: '.takt/reports/20260201-015714-foptng', + filePath: '/tasks/task.yaml', + data: { + task: 'Task content', + }, + }; + + await expect(resolveTaskExecution(task, '/project', 'default')).rejects.toThrow( + 'Invalid task_dir format: .takt/reports/20260201-015714-foptng', + ); + }); + + it('should throw when taskDir contains parent directory segment', async () => { + const task: TaskInfo = { + name: 'task-with-parent-dir', + content: 'Task content', + taskDir: '.takt/tasks/..', + filePath: '/tasks/task.yaml', + data: { + task: 'Task content', + }, + }; + + await expect(resolveTaskExecution(task, '/project', 'default')).rejects.toThrow( + 'Invalid task_dir format: .takt/tasks/..', + ); + }); + + it('should stage task_dir spec into worktree run context and return run-scoped task prompt', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-taskdir-')); + const projectDir = path.join(tmpRoot, 'project'); + const cloneDir = path.join(tmpRoot, 'clone'); + fs.mkdirSync(path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng'), { recursive: true }); + fs.mkdirSync(cloneDir, { recursive: true }); + const sourceOrder = path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng', 'order.md'); + fs.writeFileSync(sourceOrder, '# webhook task\n', 'utf-8'); + + const task: TaskInfo = { + name: 'task-with-taskdir-worktree', + content: 'Task content', + taskDir: '.takt/tasks/20260201-015714-foptng', + filePath: '/tasks/task.yaml', + data: { + task: 'Task content', + worktree: true, + }, + }; + + mockSummarizeTaskName.mockResolvedValue('webhook-task'); + mockCreateSharedClone.mockReturnValue({ + path: cloneDir, + branch: 'takt/webhook-task', + }); + + const result = await resolveTaskExecution(task, projectDir, 'default'); + + const stagedOrder = path.join(cloneDir, '.takt', 'runs', '20260201-015714-foptng', 'context', 'task', 'order.md'); + expect(fs.existsSync(stagedOrder)).toBe(true); + expect(fs.readFileSync(stagedOrder, 'utf-8')).toContain('webhook task'); + + expect(result.taskPrompt).toContain('Implement using only the files in `.takt/runs/20260201-015714-foptng/context/task`.'); + expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260201-015714-foptng/context/task/order.md`.'); + expect(result.taskPrompt).not.toContain(projectDir); + }); }); diff --git a/src/__tests__/taskRetryActions.test.ts b/src/__tests__/taskRetryActions.test.ts index 5035dde6..981261d9 100644 --- a/src/__tests__/taskRetryActions.test.ts +++ b/src/__tests__/taskRetryActions.test.ts @@ -51,7 +51,7 @@ const defaultPieceConfig: PieceConfig = { name: 'default', description: 'Default piece', initialMovement: 'plan', - maxIterations: 30, + maxMovements: 30, movements: [ { name: 'plan', persona: 'planner', instruction: '' }, { name: 'implement', persona: 'coder', instruction: '' }, diff --git a/src/agents/ai-judge.ts b/src/agents/ai-judge.ts index 1adc4398..178d0724 100644 --- a/src/agents/ai-judge.ts +++ b/src/agents/ai-judge.ts @@ -55,7 +55,7 @@ export const callAiJudge: AiJudgeCaller = async ( const response = await runAgent(undefined, prompt, { cwd: options.cwd, maxTurns: 1, - allowedTools: [], + permissionMode: 'readonly', }); if (response.status !== 'done') { diff --git a/src/agents/types.ts b/src/agents/types.ts index cdfd2a68..d27882a7 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -13,7 +13,7 @@ export interface RunAgentOptions { abortSignal?: AbortSignal; sessionId?: string; model?: string; - provider?: 'claude' | 'codex' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'mock'; /** Resolved path to persona prompt file */ personaPath?: string; /** Allowed tools for this agent run */ diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 0b6ad5d0..4c12eb10 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -15,7 +15,7 @@ import { resolveAgentOverrides } from './helpers.js'; program .command('run') - .description('Run all pending tasks from .takt/tasks/') + .description('Run all pending tasks from .takt/tasks.yaml') .action(async () => { const piece = getCurrentPiece(resolvedCwd); await runAllTasks(resolvedCwd, piece, resolveAgentOverrides(program)); @@ -30,7 +30,7 @@ program program .command('add') - .description('Add a new task (interactive AI conversation)') + .description('Add a new task') .argument('[task]', 'Task description or GitHub issue reference (e.g. "#28")') .action(async (task?: string) => { await addTask(resolvedCwd, task); diff --git a/src/app/cli/index.ts b/src/app/cli/index.ts index 05653b59..76394a99 100644 --- a/src/app/cli/index.ts +++ b/src/app/cli/index.ts @@ -35,6 +35,11 @@ import { executeDefaultAction } from './routing.js'; // Normal parsing for all other cases (including '#' prefixed inputs) await program.parseAsync(); + + const rootArg = process.argv.slice(2)[0]; + if (rootArg !== 'watch') { + process.exit(0); + } })().catch((err) => { console.error(err); process.exit(1); diff --git a/src/app/cli/program.ts b/src/app/cli/program.ts index 75b88ae7..f6057327 100644 --- a/src/app/cli/program.ts +++ b/src/app/cli/program.ts @@ -46,7 +46,7 @@ program .option('-b, --branch ', 'Branch name (auto-generated if omitted)') .option('--auto-pr', 'Create PR after successful execution') .option('--repo ', 'Repository (defaults to current)') - .option('--provider ', 'Override agent provider (claude|codex|mock)') + .option('--provider ', 'Override agent provider (claude|codex|opencode|mock)') .option('--model ', 'Override agent model') .option('-t, --task ', 'Task content (as alternative to GitHub issue)') .option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation') diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 53dff764..140b16e8 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -5,7 +5,8 @@ * pipeline mode, or interactive mode. */ -import { info, error } from '../../shared/ui/index.js'; +import { info, error, withProgress } from '../../shared/ui/index.js'; +import { confirm } from '../../shared/prompt/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { getLabel } from '../../shared/i18n/index.js'; import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js'; @@ -14,6 +15,7 @@ import { executePipeline } from '../../features/pipeline/index.js'; import { interactiveMode, selectInteractiveMode, + selectRecentSession, passthroughMode, quietMode, personaMode, @@ -35,22 +37,24 @@ import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from ' * Returns resolved issues and the formatted task text for interactive mode. * Throws on gh CLI unavailability or fetch failure. */ -function resolveIssueInput( +async function resolveIssueInput( issueOption: number | undefined, task: string | undefined, -): { issues: GitHubIssue[]; initialInput: string } | null { +): Promise<{ issues: GitHubIssue[]; initialInput: string } | null> { if (issueOption) { - info('Fetching GitHub Issue...'); const ghStatus = checkGhCli(); if (!ghStatus.available) { throw new Error(ghStatus.error); } - const issue = fetchIssue(issueOption); + const issue = await withProgress( + 'Fetching GitHub Issue...', + (fetchedIssue) => `GitHub Issue fetched: #${fetchedIssue.number} ${fetchedIssue.title}`, + async () => fetchIssue(issueOption), + ); return { issues: [issue], initialInput: formatIssueAsTask(issue) }; } if (task && isDirectTask(task)) { - info('Fetching GitHub Issue...'); const ghStatus = checkGhCli(); if (!ghStatus.available) { throw new Error(ghStatus.error); @@ -60,7 +64,11 @@ function resolveIssueInput( if (issueNumbers.length === 0) { throw new Error(`Invalid issue reference: ${task}`); } - const issues = issueNumbers.map((n) => fetchIssue(n)); + const issues = await withProgress( + 'Fetching GitHub Issue...', + (fetchedIssues) => `GitHub Issues fetched: ${fetchedIssues.map((issue) => `#${issue.number}`).join(', ')}`, + async () => issueNumbers.map((n) => fetchIssue(n)), + ); return { issues, initialInput: issues.map(formatIssueAsTask).join('\n\n---\n\n') }; } @@ -116,7 +124,7 @@ export async function executeDefaultAction(task?: string): Promise { let initialInput: string | undefined = task; try { - const issueResult = resolveIssueInput(opts.issue as number | undefined, task); + const issueResult = await resolveIssueInput(opts.issue as number | undefined, task); if (issueResult) { selectOptions.issues = issueResult.issues; initialInput = issueResult.initialInput; @@ -156,9 +164,24 @@ export async function executeDefaultAction(task?: string): Promise { let result: InteractiveModeResult; switch (selectedMode) { - case 'assistant': - result = await interactiveMode(resolvedCwd, initialInput, pieceContext); + case 'assistant': { + let selectedSessionId: string | undefined; + const provider = globalConfig.provider; + if (provider === 'claude') { + const shouldSelectSession = await confirm( + getLabel('interactive.sessionSelector.confirm', lang), + false, + ); + if (shouldSelectSession) { + const sessionId = await selectRecentSession(resolvedCwd, lang); + if (sessionId) { + selectedSessionId = sessionId; + } + } + } + result = await interactiveMode(resolvedCwd, initialInput, pieceContext, selectedSessionId); break; + } case 'passthrough': result = await passthroughMode(lang, initialInput); @@ -188,7 +211,15 @@ export async function executeDefaultAction(task?: string): Promise { break; case 'create_issue': - createIssueFromTask(result.task); + { + const issueNumber = createIssueFromTask(result.task); + if (issueNumber !== undefined) { + await saveTaskFromInteractive(resolvedCwd, result.task, pieceId, { + issue: issueNumber, + confirmAtEndMessage: 'Add this issue to tasks?', + }); + } + } break; case 'save_task': diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 26a7b439..7ab9db56 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -10,7 +10,7 @@ export interface CustomAgentConfig { allowedTools?: string[]; claudeAgent?: string; claudeSkill?: string; - provider?: 'claude' | 'codex' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'mock'; model?: string; } @@ -20,6 +20,12 @@ export interface DebugConfig { logFile?: string; } +/** Observability configuration for runtime event logs */ +export interface ObservabilityConfig { + /** Enable provider stream event logging (default: false when undefined) */ + providerEvents?: boolean; +} + /** Language setting for takt */ export type Language = 'en' | 'ja'; @@ -33,14 +39,29 @@ export interface PipelineConfig { prBodyTemplate?: string; } +/** Notification sound toggles per event timing */ +export interface NotificationSoundEventsConfig { + /** Warning when iteration limit is reached */ + iterationLimit?: boolean; + /** Success notification when piece execution completes */ + pieceComplete?: boolean; + /** Error notification when piece execution aborts */ + pieceAbort?: boolean; + /** Success notification when runAllTasks finishes without failures */ + runComplete?: boolean; + /** Error notification when runAllTasks finishes with failures or aborts */ + runAbort?: boolean; +} + /** Global configuration for takt */ export interface GlobalConfig { language: Language; defaultPiece: string; logLevel: 'debug' | 'info' | 'warn' | 'error'; - provider?: 'claude' | 'codex' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'mock'; model?: string; debug?: DebugConfig; + observability?: ObservabilityConfig; /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ worktreeDir?: string; /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ @@ -53,6 +74,8 @@ export interface GlobalConfig { anthropicApiKey?: string; /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ openaiApiKey?: string; + /** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */ + opencodeApiKey?: string; /** Pipeline execution settings */ pipeline?: PipelineConfig; /** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */ @@ -62,13 +85,15 @@ export interface GlobalConfig { /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ pieceCategoriesFile?: string; /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ - personaProviders?: Record; + personaProviders?: Record; /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branchNameStrategy?: 'romaji' | 'ai'; /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ preventSleep?: boolean; /** Enable notification sounds (default: true when undefined) */ notificationSound?: boolean; + /** Notification sound toggles per event timing */ + notificationSoundEvents?: NotificationSoundEventsConfig; /** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ interactivePreviewMovements?: number; /** Number of tasks to run concurrently in takt run (default: 1 = sequential) */ @@ -81,5 +106,5 @@ export interface GlobalConfig { export interface ProjectConfig { piece?: string; agents?: CustomAgentConfig[]; - provider?: 'claude' | 'codex' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'mock'; } diff --git a/src/core/models/index.ts b/src/core/models/index.ts index 166becde..8221700c 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -12,6 +12,8 @@ export type { SessionState, PieceRule, PieceMovement, + ArpeggioMovementConfig, + ArpeggioMergeMovementConfig, LoopDetectionConfig, LoopMonitorConfig, LoopMonitorJudge, @@ -20,6 +22,7 @@ export type { PieceState, CustomAgentConfig, DebugConfig, + ObservabilityConfig, Language, PipelineConfig, GlobalConfig, diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index aec81474..7c029cc5 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -97,7 +97,7 @@ export interface PieceMovement { /** Resolved absolute path to persona prompt file (set by loader) */ personaPath?: string; /** Provider override for this movement */ - provider?: 'claude' | 'codex' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'mock'; /** Model override for this movement */ model?: string; /** Permission mode for tool execution in this movement */ @@ -114,12 +114,48 @@ export interface PieceMovement { passPreviousResponse: boolean; /** Sub-movements to execute in parallel. When set, this movement runs all sub-movements concurrently. */ parallel?: PieceMovement[]; + /** Arpeggio configuration for data-driven batch processing. When set, this movement reads from a data source, expands templates, and calls LLM per batch. */ + arpeggio?: ArpeggioMovementConfig; /** Resolved policy content strings (from piece-level policies map, resolved at parse time) */ policyContents?: string[]; /** Resolved knowledge content strings (from piece-level knowledge map, resolved at parse time) */ knowledgeContents?: string[]; } +/** Merge configuration for arpeggio results */ +export interface ArpeggioMergeMovementConfig { + /** Merge strategy: 'concat' (default), 'custom' */ + readonly strategy: 'concat' | 'custom'; + /** Inline JS merge function body (for custom strategy) */ + readonly inlineJs?: string; + /** Path to external JS merge file (for custom strategy, resolved to absolute) */ + readonly filePath?: string; + /** Separator for concat strategy (default: '\n') */ + readonly separator?: string; +} + +/** Arpeggio configuration for data-driven batch processing movements */ +export interface ArpeggioMovementConfig { + /** Data source type (e.g., 'csv') */ + readonly source: string; + /** Path to the data source file (resolved to absolute) */ + readonly sourcePath: string; + /** Number of rows per batch (default: 1) */ + readonly batchSize: number; + /** Number of concurrent LLM calls (default: 1) */ + readonly concurrency: number; + /** Path to prompt template file (resolved to absolute) */ + readonly templatePath: string; + /** Merge configuration */ + readonly merge: ArpeggioMergeMovementConfig; + /** Maximum retry attempts per batch (default: 2) */ + readonly maxRetries: number; + /** Delay between retries in ms (default: 1000) */ + readonly retryDelayMs: number; + /** Optional output file path (resolved to absolute) */ + readonly outputPath?: string; +} + /** Loop detection configuration */ export interface LoopDetectionConfig { /** Maximum consecutive runs of the same step before triggering (default: 10) */ @@ -174,7 +210,7 @@ export interface PieceConfig { reportFormats?: Record; movements: PieceMovement[]; initialMovement: string; - maxIterations: number; + maxMovements: number; /** Loop detection settings */ loopDetection?: LoopDetectionConfig; /** Loop monitors for detecting cyclic patterns between movements */ @@ -197,6 +233,8 @@ export interface PieceState { movementOutputs: Map; /** Most recent movement output (used for Previous Response injection) */ lastOutput?: AgentResponse; + /** Source path of the latest previous response snapshot */ + previousResponseSourcePath?: string; userInputs: string[]; personaSessions: Map; /** Per-movement iteration counters (how many times each movement has been executed) */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index df0d6b8c..6ab743d3 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -130,6 +130,46 @@ export const PieceRuleSchema = z.object({ interactive_only: z.boolean().optional(), }); +/** Arpeggio merge configuration schema */ +export const ArpeggioMergeRawSchema = z.object({ + /** Merge strategy: 'concat' or 'custom' */ + strategy: z.enum(['concat', 'custom']).optional().default('concat'), + /** Inline JS function body for custom merge */ + inline_js: z.string().optional(), + /** External JS file path for custom merge */ + file: z.string().optional(), + /** Separator for concat strategy */ + separator: z.string().optional(), +}).refine( + (data) => data.strategy !== 'custom' || data.inline_js != null || data.file != null, + { message: "Custom merge strategy requires either 'inline_js' or 'file'" } +).refine( + (data) => data.strategy !== 'concat' || (data.inline_js == null && data.file == null), + { message: "Concat merge strategy does not accept 'inline_js' or 'file'" } +); + +/** Arpeggio configuration schema for data-driven batch processing */ +export const ArpeggioConfigRawSchema = z.object({ + /** Data source type (e.g., 'csv') */ + source: z.string().min(1), + /** Path to the data source file */ + source_path: z.string().min(1), + /** Number of rows per batch (default: 1) */ + batch_size: z.number().int().positive().optional().default(1), + /** Number of concurrent LLM calls (default: 1) */ + concurrency: z.number().int().positive().optional().default(1), + /** Path to prompt template file */ + template: z.string().min(1), + /** Merge configuration */ + merge: ArpeggioMergeRawSchema.optional(), + /** Maximum retry attempts per batch (default: 2) */ + max_retries: z.number().int().min(0).optional().default(2), + /** Delay between retries in ms (default: 1000) */ + retry_delay_ms: z.number().int().min(0).optional().default(1000), + /** Optional output file path */ + output_path: z.string().optional(), +}); + /** Sub-movement schema for parallel execution */ export const ParallelSubMovementRawSchema = z.object({ name: z.string().min(1), @@ -143,7 +183,7 @@ export const ParallelSubMovementRawSchema = z.object({ knowledge: z.union([z.string(), z.array(z.string())]).optional(), allowed_tools: z.array(z.string()).optional(), mcp_servers: McpServersSchema, - provider: z.enum(['claude', 'codex', 'mock']).optional(), + provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), model: z.string().optional(), permission_mode: PermissionModeSchema.optional(), edit: z.boolean().optional(), @@ -173,7 +213,7 @@ export const PieceMovementRawSchema = z.object({ knowledge: z.union([z.string(), z.array(z.string())]).optional(), allowed_tools: z.array(z.string()).optional(), mcp_servers: McpServersSchema, - provider: z.enum(['claude', 'codex', 'mock']).optional(), + provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), model: z.string().optional(), /** Permission mode for tool execution in this movement */ permission_mode: PermissionModeSchema.optional(), @@ -190,6 +230,8 @@ export const PieceMovementRawSchema = z.object({ pass_previous_response: z.boolean().optional().default(true), /** Sub-movements to execute in parallel */ parallel: z.array(ParallelSubMovementRawSchema).optional(), + /** Arpeggio configuration for data-driven batch processing */ + arpeggio: ArpeggioConfigRawSchema.optional(), }); /** Loop monitor rule schema */ @@ -239,7 +281,7 @@ export const PieceConfigRawSchema = z.object({ report_formats: z.record(z.string(), z.string()).optional(), movements: z.array(PieceMovementRawSchema).min(1), initial_movement: z.string().optional(), - max_iterations: z.number().int().positive().optional().default(10), + max_movements: z.number().int().positive().optional().default(10), loop_monitors: z.array(LoopMonitorSchema).optional(), answer_agent: z.string().optional(), /** Default interactive mode for this piece (overrides user default) */ @@ -254,7 +296,7 @@ export const CustomAgentConfigSchema = z.object({ allowed_tools: z.array(z.string()).optional(), claude_agent: z.string().optional(), claude_skill: z.string().optional(), - provider: z.enum(['claude', 'codex', 'mock']).optional(), + provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), model: z.string().optional(), }).refine( (data) => data.prompt_file || data.prompt || data.claude_agent || data.claude_skill, @@ -267,6 +309,10 @@ export const DebugConfigSchema = z.object({ log_file: z.string().optional(), }); +export const ObservabilityConfigSchema = z.object({ + provider_events: z.boolean().optional(), +}); + /** Language setting schema */ export const LanguageSchema = z.enum(['en', 'ja']); @@ -296,9 +342,10 @@ export const GlobalConfigSchema = z.object({ language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), default_piece: z.string().optional().default('default'), log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'), - provider: z.enum(['claude', 'codex', 'mock']).optional().default('claude'), + provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'), model: z.string().optional(), debug: DebugConfigSchema.optional(), + observability: ObservabilityConfigSchema.optional(), /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ worktree_dir: z.string().optional(), /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ @@ -311,6 +358,8 @@ export const GlobalConfigSchema = z.object({ anthropic_api_key: z.string().optional(), /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ openai_api_key: z.string().optional(), + /** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */ + opencode_api_key: z.string().optional(), /** Pipeline execution settings */ pipeline: PipelineConfigSchema.optional(), /** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */ @@ -320,13 +369,21 @@ export const GlobalConfigSchema = z.object({ /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ piece_categories_file: z.string().optional(), /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ - persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'mock'])).optional(), + persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(), /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branch_name_strategy: z.enum(['romaji', 'ai']).optional(), /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ prevent_sleep: z.boolean().optional(), /** Enable notification sounds (default: true when undefined) */ notification_sound: z.boolean().optional(), + /** Notification sound toggles per event timing */ + notification_sound_events: z.object({ + iteration_limit: z.boolean().optional(), + piece_complete: z.boolean().optional(), + piece_abort: z.boolean().optional(), + run_complete: z.boolean().optional(), + run_abort: z.boolean().optional(), + }).optional(), /** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3), /** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */ @@ -339,5 +396,5 @@ export const GlobalConfigSchema = z.object({ export const ProjectConfigSchema = z.object({ piece: z.string().optional(), agents: z.array(CustomAgentConfigSchema).optional(), - provider: z.enum(['claude', 'codex', 'mock']).optional(), + provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), }); diff --git a/src/core/models/session.ts b/src/core/models/session.ts index 5bf2401a..d503e5f2 100644 --- a/src/core/models/session.ts +++ b/src/core/models/session.ts @@ -12,7 +12,7 @@ export interface SessionState { task: string; projectDir: string; iteration: number; - maxIterations: number; + maxMovements: number; coderStatus: Status; architectStatus: Status; supervisorStatus: Status; @@ -32,7 +32,7 @@ export function createSessionState( task, projectDir, iteration: 0, - maxIterations: 10, + maxMovements: 10, coderStatus: 'pending', architectStatus: 'pending', supervisorStatus: 'pending', diff --git a/src/core/models/types.ts b/src/core/models/types.ts index b13a24a6..42e49e9c 100644 --- a/src/core/models/types.ts +++ b/src/core/models/types.ts @@ -31,6 +31,8 @@ export type { OutputContractEntry, McpServerConfig, PieceMovement, + ArpeggioMovementConfig, + ArpeggioMergeMovementConfig, LoopDetectionConfig, LoopMonitorConfig, LoopMonitorJudge, @@ -43,6 +45,7 @@ export type { export type { CustomAgentConfig, DebugConfig, + ObservabilityConfig, Language, PipelineConfig, GlobalConfig, diff --git a/src/core/piece/arpeggio/csv-data-source.ts b/src/core/piece/arpeggio/csv-data-source.ts new file mode 100644 index 00000000..ca17755b --- /dev/null +++ b/src/core/piece/arpeggio/csv-data-source.ts @@ -0,0 +1,133 @@ +/** + * CSV data source for arpeggio movements. + * + * Reads CSV files and returns data in batches for template expansion. + * Handles quoted fields, escaped quotes, and various line endings. + */ + +import { readFileSync } from 'node:fs'; +import type { ArpeggioDataSource, DataBatch, DataRow } from './types.js'; + +/** Parse a CSV string into an array of string arrays (rows of fields) */ +export function parseCsv(content: string): string[][] { + const rows: string[][] = []; + let currentRow: string[] = []; + let currentField = ''; + let inQuotes = false; + let i = 0; + + while (i < content.length) { + const char = content[i]!; + + if (inQuotes) { + if (char === '"') { + // Check for escaped quote ("") + if (i + 1 < content.length && content[i + 1] === '"') { + currentField += '"'; + i += 2; + continue; + } + // End of quoted field + inQuotes = false; + i++; + continue; + } + currentField += char; + i++; + continue; + } + + if (char === '"' && currentField.length === 0) { + inQuotes = true; + i++; + continue; + } + + if (char === ',') { + currentRow.push(currentField); + currentField = ''; + i++; + continue; + } + + if (char === '\r') { + // Handle \r\n and bare \r + currentRow.push(currentField); + currentField = ''; + rows.push(currentRow); + currentRow = []; + if (i + 1 < content.length && content[i + 1] === '\n') { + i += 2; + } else { + i++; + } + continue; + } + + if (char === '\n') { + currentRow.push(currentField); + currentField = ''; + rows.push(currentRow); + currentRow = []; + i++; + continue; + } + + currentField += char; + i++; + } + + // Handle last field/row + if (currentField.length > 0 || currentRow.length > 0) { + currentRow.push(currentField); + rows.push(currentRow); + } + + return rows; +} + +/** Convert parsed CSV rows into DataRow objects using the header row */ +function rowsToDataRows(headers: readonly string[], dataRows: readonly string[][]): DataRow[] { + return dataRows.map((row) => { + const dataRow: DataRow = {}; + for (let col = 0; col < headers.length; col++) { + const header = headers[col]!; + dataRow[header] = row[col] ?? ''; + } + return dataRow; + }); +} + +/** Split an array into chunks of the given size */ +function chunk(array: readonly T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; +} + +export class CsvDataSource implements ArpeggioDataSource { + constructor(private readonly filePath: string) {} + + async readBatches(batchSize: number): Promise { + const content = readFileSync(this.filePath, 'utf-8'); + const parsed = parseCsv(content); + + if (parsed.length < 2) { + throw new Error(`CSV file has no data rows: ${this.filePath}`); + } + + const headers = parsed[0]!; + const dataRowArrays = parsed.slice(1); + const dataRows = rowsToDataRows(headers, dataRowArrays); + const chunks = chunk(dataRows, batchSize); + const totalBatches = chunks.length; + + return chunks.map((rows, index) => ({ + rows, + batchIndex: index, + totalBatches, + })); + } +} diff --git a/src/core/piece/arpeggio/data-source-factory.ts b/src/core/piece/arpeggio/data-source-factory.ts new file mode 100644 index 00000000..32161614 --- /dev/null +++ b/src/core/piece/arpeggio/data-source-factory.ts @@ -0,0 +1,41 @@ +/** + * Factory for creating data source instances. + * + * Maps source type names to their implementations. + * Built-in: 'csv'. Users can extend with custom JS modules. + */ + +import type { ArpeggioDataSource } from './types.js'; +import { CsvDataSource } from './csv-data-source.js'; + +/** Built-in data source type mapping */ +const BUILTIN_SOURCES: Record ArpeggioDataSource> = { + csv: (path) => new CsvDataSource(path), +}; + +/** + * Create a data source instance by type and path. + * + * For built-in types ('csv'), uses the registered factory. + * For custom types, loads from the source type as a JS module path. + */ +export async function createDataSource( + sourceType: string, + sourcePath: string, +): Promise { + const builtinFactory = BUILTIN_SOURCES[sourceType]; + if (builtinFactory) { + return builtinFactory(sourcePath); + } + + // Custom data source: sourceType is a path to a JS module that exports a factory + const module = await import(sourceType) as { + default?: (path: string) => ArpeggioDataSource; + }; + if (typeof module.default !== 'function') { + throw new Error( + `Custom data source module "${sourceType}" must export a default factory function` + ); + } + return module.default(sourcePath); +} diff --git a/src/core/piece/arpeggio/merge.ts b/src/core/piece/arpeggio/merge.ts new file mode 100644 index 00000000..7ea25d34 --- /dev/null +++ b/src/core/piece/arpeggio/merge.ts @@ -0,0 +1,78 @@ +/** + * Merge processing for arpeggio batch results. + * + * Supports two merge strategies: + * - 'concat': Simple concatenation with configurable separator + * - 'custom': User-provided merge function (inline JS or external file) + */ + +import { writeFileSync } from 'node:fs'; +import type { ArpeggioMergeMovementConfig, MergeFn } from './types.js'; + +/** Create a concat merge function with the given separator */ +function createConcatMerge(separator: string): MergeFn { + return (results) => + results + .filter((r) => r.success) + .sort((a, b) => a.batchIndex - b.batchIndex) + .map((r) => r.content) + .join(separator); +} + +/** + * Create a merge function from inline JavaScript. + * + * The inline JS receives `results` as the function parameter (readonly BatchResult[]). + * It must return a string. + */ +function createInlineJsMerge(jsBody: string): MergeFn { + const fn = new Function('results', jsBody) as MergeFn; + return (results) => { + const output = fn(results); + if (typeof output !== 'string') { + throw new Error(`Inline JS merge function must return a string, got ${typeof output}`); + } + return output; + }; +} + +/** + * Create a merge function from an external JS file. + * + * The file must export a default function: (results: BatchResult[]) => string + */ +async function createFileMerge(filePath: string): Promise { + const module = await import(filePath) as { default?: MergeFn }; + if (typeof module.default !== 'function') { + throw new Error(`Merge file "${filePath}" must export a default function`); + } + return module.default; +} + +/** + * Build a merge function from the arpeggio merge configuration. + * + * For 'concat' strategy: returns a simple join function. + * For 'custom' strategy: loads from inline JS or external file. + */ +export async function buildMergeFn(config: ArpeggioMergeMovementConfig): Promise { + if (config.strategy === 'concat') { + return createConcatMerge(config.separator ?? '\n'); + } + + // Custom strategy + if (config.inlineJs) { + return createInlineJsMerge(config.inlineJs); + } + + if (config.filePath) { + return createFileMerge(config.filePath); + } + + throw new Error('Custom merge strategy requires either inline_js or file path'); +} + +/** Write merged output to a file if output_path is configured */ +export function writeMergedOutput(outputPath: string, content: string): void { + writeFileSync(outputPath, content, 'utf-8'); +} diff --git a/src/core/piece/arpeggio/template.ts b/src/core/piece/arpeggio/template.ts new file mode 100644 index 00000000..7e407a2f --- /dev/null +++ b/src/core/piece/arpeggio/template.ts @@ -0,0 +1,72 @@ +/** + * Template expansion for arpeggio movements. + * + * Expands placeholders in prompt templates using data from batches: + * - {line:N} — entire row N as "key: value" pairs (1-based) + * - {col:N:name} — specific column value from row N (1-based) + * - {batch_index} — 0-based batch index + * - {total_batches} — total number of batches + */ + +import { readFileSync } from 'node:fs'; +import type { DataBatch, DataRow } from './types.js'; + +/** Format a single data row as "key: value" lines */ +function formatRow(row: DataRow): string { + return Object.entries(row) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); +} + +/** + * Expand placeholders in a template string using batch data. + * + * Supported placeholders: + * - {line:N} — Row N (1-based) formatted as "key: value" lines + * - {col:N:name} — Column "name" from row N (1-based) + * - {batch_index} — 0-based batch index + * - {total_batches} — Total number of batches + */ +export function expandTemplate(template: string, batch: DataBatch): string { + let result = template; + + // Replace {batch_index} and {total_batches} + result = result.replace(/\{batch_index\}/g, String(batch.batchIndex)); + result = result.replace(/\{total_batches\}/g, String(batch.totalBatches)); + + // Replace {col:N:name} — must be done before {line:N} to avoid partial matches + result = result.replace(/\{col:(\d+):(\w+)\}/g, (_match, indexStr: string, colName: string) => { + const rowIndex = parseInt(indexStr, 10) - 1; + if (rowIndex < 0 || rowIndex >= batch.rows.length) { + throw new Error( + `Template placeholder {col:${indexStr}:${colName}} references row ${indexStr} but batch has ${batch.rows.length} rows` + ); + } + const row = batch.rows[rowIndex]!; + const value = row[colName]; + if (value === undefined) { + throw new Error( + `Template placeholder {col:${indexStr}:${colName}} references unknown column "${colName}"` + ); + } + return value; + }); + + // Replace {line:N} + result = result.replace(/\{line:(\d+)\}/g, (_match, indexStr: string) => { + const rowIndex = parseInt(indexStr, 10) - 1; + if (rowIndex < 0 || rowIndex >= batch.rows.length) { + throw new Error( + `Template placeholder {line:${indexStr}} references row ${indexStr} but batch has ${batch.rows.length} rows` + ); + } + return formatRow(batch.rows[rowIndex]!); + }); + + return result; +} + +/** Load a template file and return its content */ +export function loadTemplate(templatePath: string): string { + return readFileSync(templatePath, 'utf-8'); +} diff --git a/src/core/piece/arpeggio/types.ts b/src/core/piece/arpeggio/types.ts new file mode 100644 index 00000000..63bb0006 --- /dev/null +++ b/src/core/piece/arpeggio/types.ts @@ -0,0 +1,46 @@ +/** + * Arpeggio movement internal type definitions. + * + * Configuration types (ArpeggioMovementConfig, ArpeggioMergeMovementConfig) + * live in models/piece-types.ts as part of PieceMovement. + * This file defines runtime types used internally by the arpeggio module. + */ + +export type { + ArpeggioMovementConfig, + ArpeggioMergeMovementConfig, +} from '../../models/piece-types.js'; + +/** A single row of data from a data source (column name → value) */ +export type DataRow = Record; + +/** A batch of rows read from a data source */ +export interface DataBatch { + /** The rows in this batch */ + readonly rows: readonly DataRow[]; + /** 0-based index of this batch in the overall data set */ + readonly batchIndex: number; + /** Total number of batches (known after full read) */ + readonly totalBatches: number; +} + +/** Interface for data source implementations */ +export interface ArpeggioDataSource { + /** Read all batches from the data source. Returns an array of DataBatch. */ + readBatches(batchSize: number): Promise; +} + +/** Result of a single LLM call for one batch */ +export interface BatchResult { + /** 0-based index of the batch */ + readonly batchIndex: number; + /** LLM response content */ + readonly content: string; + /** Whether this result was successful */ + readonly success: boolean; + /** Error message if failed */ + readonly error?: string; +} + +/** Merge function signature: takes all batch results, returns merged string */ +export type MergeFn = (results: readonly BatchResult[]) => string; diff --git a/src/core/piece/constants.ts b/src/core/piece/constants.ts index 4874e25c..ee87d471 100644 --- a/src/core/piece/constants.ts +++ b/src/core/piece/constants.ts @@ -19,5 +19,5 @@ export const ERROR_MESSAGES = { `Loop detected: movement "${movementName}" ran ${count} times consecutively without progress.`, UNKNOWN_MOVEMENT: (movementName: string) => `Unknown movement: ${movementName}`, MOVEMENT_EXECUTION_FAILED: (message: string) => `Movement execution failed: ${message}`, - MAX_ITERATIONS_REACHED: 'Max iterations reached', + MAX_MOVEMENTS_REACHED: 'Max movements reached', }; diff --git a/src/core/piece/engine/ArpeggioRunner.ts b/src/core/piece/engine/ArpeggioRunner.ts new file mode 100644 index 00000000..017c247a --- /dev/null +++ b/src/core/piece/engine/ArpeggioRunner.ts @@ -0,0 +1,276 @@ +/** + * Executes arpeggio piece movements: data-driven batch processing. + * + * Reads data from a source, expands templates with batch data, + * calls LLM for each batch (with concurrency control), + * merges results, and returns an aggregated response. + */ + +import type { + PieceMovement, + PieceState, + AgentResponse, +} from '../../models/types.js'; +import type { ArpeggioMovementConfig, BatchResult, DataBatch } from '../arpeggio/types.js'; +import { createDataSource } from '../arpeggio/data-source-factory.js'; +import { loadTemplate, expandTemplate } from '../arpeggio/template.js'; +import { buildMergeFn, writeMergedOutput } from '../arpeggio/merge.js'; +import { runAgent, type RunAgentOptions } from '../../../agents/runner.js'; +import { detectMatchedRule } from '../evaluation/index.js'; +import { incrementMovementIteration } from './state-manager.js'; +import { createLogger } from '../../../shared/utils/index.js'; +import type { OptionsBuilder } from './OptionsBuilder.js'; +import type { MovementExecutor } from './MovementExecutor.js'; +import type { PhaseName } from '../types.js'; + +const log = createLogger('arpeggio-runner'); + +export interface ArpeggioRunnerDeps { + readonly optionsBuilder: OptionsBuilder; + readonly movementExecutor: MovementExecutor; + readonly getCwd: () => string; + readonly getInteractive: () => boolean; + readonly detectRuleIndex: (content: string, movementName: string) => number; + readonly callAiJudge: ( + agentOutput: string, + conditions: Array<{ index: number; text: string }>, + options: { cwd: string } + ) => Promise; + readonly onPhaseStart?: (step: PieceMovement, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void; + readonly onPhaseComplete?: (step: PieceMovement, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void; +} + +/** + * Simple semaphore for controlling concurrency. + * Limits the number of concurrent async operations. + */ +class Semaphore { + private running = 0; + private readonly waiting: Array<() => void> = []; + + constructor(private readonly maxConcurrency: number) {} + + async acquire(): Promise { + if (this.running < this.maxConcurrency) { + this.running++; + return; + } + return new Promise((resolve) => { + this.waiting.push(resolve); + }); + } + + release(): void { + if (this.waiting.length > 0) { + const next = this.waiting.shift()!; + next(); + } else { + this.running--; + } + } +} + +/** Execute a single batch with retry logic */ +async function executeBatchWithRetry( + batch: DataBatch, + template: string, + persona: string | undefined, + agentOptions: RunAgentOptions, + maxRetries: number, + retryDelayMs: number, +): Promise { + const prompt = expandTemplate(template, batch); + let lastError: string | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await runAgent(persona, prompt, agentOptions); + if (response.status === 'error') { + lastError = response.error ?? response.content ?? 'Agent returned error status'; + log.info('Batch execution failed, retrying', { + batchIndex: batch.batchIndex, + attempt: attempt + 1, + maxRetries, + error: lastError, + }); + if (attempt < maxRetries) { + await delay(retryDelayMs); + continue; + } + return { + batchIndex: batch.batchIndex, + content: '', + success: false, + error: lastError, + }; + } + return { + batchIndex: batch.batchIndex, + content: response.content, + success: true, + }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + log.info('Batch execution threw, retrying', { + batchIndex: batch.batchIndex, + attempt: attempt + 1, + maxRetries, + error: lastError, + }); + if (attempt < maxRetries) { + await delay(retryDelayMs); + continue; + } + } + } + + return { + batchIndex: batch.batchIndex, + content: '', + success: false, + error: lastError, + }; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class ArpeggioRunner { + constructor( + private readonly deps: ArpeggioRunnerDeps, + ) {} + + /** + * Run an arpeggio movement: read data, expand templates, call LLM, + * merge results, and return an aggregated response. + */ + async runArpeggioMovement( + step: PieceMovement, + state: PieceState, + ): Promise<{ response: AgentResponse; instruction: string }> { + const arpeggioConfig = step.arpeggio; + if (!arpeggioConfig) { + throw new Error(`Movement "${step.name}" has no arpeggio configuration`); + } + + const movementIteration = incrementMovementIteration(state, step.name); + log.debug('Running arpeggio movement', { + movement: step.name, + source: arpeggioConfig.source, + batchSize: arpeggioConfig.batchSize, + concurrency: arpeggioConfig.concurrency, + movementIteration, + }); + + const dataSource = await createDataSource(arpeggioConfig.source, arpeggioConfig.sourcePath); + const batches = await dataSource.readBatches(arpeggioConfig.batchSize); + + if (batches.length === 0) { + throw new Error(`Data source returned no batches for movement "${step.name}"`); + } + + log.info('Arpeggio data loaded', { + movement: step.name, + batchCount: batches.length, + batchSize: arpeggioConfig.batchSize, + }); + + const template = loadTemplate(arpeggioConfig.templatePath); + + const agentOptions = this.deps.optionsBuilder.buildAgentOptions(step); + const semaphore = new Semaphore(arpeggioConfig.concurrency); + const results = await this.executeBatches( + batches, + template, + step, + agentOptions, + arpeggioConfig, + semaphore, + ); + + const failedBatches = results.filter((r) => !r.success); + if (failedBatches.length > 0) { + const errorDetails = failedBatches + .map((r) => `batch ${r.batchIndex}: ${r.error}`) + .join('; '); + throw new Error( + `Arpeggio movement "${step.name}" failed: ${failedBatches.length}/${results.length} batches failed (${errorDetails})` + ); + } + + const mergeFn = await buildMergeFn(arpeggioConfig.merge); + const mergedContent = mergeFn(results); + + if (arpeggioConfig.outputPath) { + writeMergedOutput(arpeggioConfig.outputPath, mergedContent); + log.info('Arpeggio output written', { outputPath: arpeggioConfig.outputPath }); + } + + const ruleCtx = { + state, + cwd: this.deps.getCwd(), + interactive: this.deps.getInteractive(), + detectRuleIndex: this.deps.detectRuleIndex, + callAiJudge: this.deps.callAiJudge, + }; + const match = await detectMatchedRule(step, mergedContent, '', ruleCtx); + + const aggregatedResponse: AgentResponse = { + persona: step.name, + status: 'done', + content: mergedContent, + timestamp: new Date(), + ...(match && { matchedRuleIndex: match.index, matchedRuleMethod: match.method }), + }; + + state.movementOutputs.set(step.name, aggregatedResponse); + state.lastOutput = aggregatedResponse; + this.deps.movementExecutor.persistPreviousResponseSnapshot( + state, + step.name, + movementIteration, + aggregatedResponse.content, + ); + + const instruction = `[Arpeggio] ${step.name}: ${batches.length} batches, source=${arpeggioConfig.source}`; + + return { response: aggregatedResponse, instruction }; + } + + /** Execute all batches with concurrency control */ + private async executeBatches( + batches: readonly DataBatch[], + template: string, + step: PieceMovement, + agentOptions: RunAgentOptions, + config: ArpeggioMovementConfig, + semaphore: Semaphore, + ): Promise { + const promises = batches.map(async (batch) => { + await semaphore.acquire(); + try { + this.deps.onPhaseStart?.(step, 1, 'execute', `[Arpeggio batch ${batch.batchIndex + 1}/${batch.totalBatches}]`); + const result = await executeBatchWithRetry( + batch, + template, + step.persona, + agentOptions, + config.maxRetries, + config.retryDelayMs, + ); + this.deps.onPhaseComplete?.( + step, 1, 'execute', + result.content, + result.success ? 'done' : 'error', + result.error, + ); + return result; + } finally { + semaphore.release(); + } + }); + + return Promise.all(promises); + } +} diff --git a/src/core/piece/engine/MovementExecutor.ts b/src/core/piece/engine/MovementExecutor.ts index f8c1b668..9f0d9942 100644 --- a/src/core/piece/engine/MovementExecutor.ts +++ b/src/core/piece/engine/MovementExecutor.ts @@ -6,7 +6,7 @@ * Phase 3: Status judgment (no tools, optional) */ -import { existsSync } from 'node:fs'; +import { existsSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { PieceMovement, @@ -23,6 +23,7 @@ import { buildSessionKey } from '../session-key.js'; import { incrementMovementIteration, getPreviousOutput } from './state-manager.js'; import { createLogger } from '../../../shared/utils/index.js'; import type { OptionsBuilder } from './OptionsBuilder.js'; +import type { RunPaths } from '../run/run-paths.js'; const log = createLogger('movement-executor'); @@ -31,6 +32,7 @@ export interface MovementExecutorDeps { readonly getCwd: () => string; readonly getProjectCwd: () => string; readonly getReportDir: () => string; + readonly getRunPaths: () => RunPaths; readonly getLanguage: () => Language | undefined; readonly getInteractive: () => boolean; readonly getPieceMovements: () => ReadonlyArray<{ name: string; description?: string }>; @@ -52,19 +54,103 @@ export class MovementExecutor { private readonly deps: MovementExecutorDeps, ) {} + private static buildTimestamp(): string { + return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); + } + + private writeSnapshot( + content: string, + directoryRel: string, + filename: string, + ): string { + const absPath = join(this.deps.getCwd(), directoryRel, filename); + writeFileSync(absPath, content, 'utf-8'); + return `${directoryRel}/${filename}`; + } + + private writeFacetSnapshot( + facet: 'knowledge' | 'policy', + movementName: string, + movementIteration: number, + contents: string[] | undefined, + ): { content: string[]; sourcePath: string } | undefined { + if (!contents || contents.length === 0) return undefined; + const merged = contents.join('\n\n---\n\n'); + const timestamp = MovementExecutor.buildTimestamp(); + const runPaths = this.deps.getRunPaths(); + const directoryRel = facet === 'knowledge' + ? runPaths.contextKnowledgeRel + : runPaths.contextPolicyRel; + const sourcePath = this.writeSnapshot( + merged, + directoryRel, + `${movementName}.${movementIteration}.${timestamp}.md`, + ); + return { content: [merged], sourcePath }; + } + + private ensurePreviousResponseSnapshot( + state: PieceState, + movementName: string, + movementIteration: number, + ): void { + if (!state.lastOutput || state.previousResponseSourcePath) return; + const timestamp = MovementExecutor.buildTimestamp(); + const runPaths = this.deps.getRunPaths(); + const fileName = `${movementName}.${movementIteration}.${timestamp}.md`; + const sourcePath = this.writeSnapshot( + state.lastOutput.content, + runPaths.contextPreviousResponsesRel, + fileName, + ); + this.writeSnapshot( + state.lastOutput.content, + runPaths.contextPreviousResponsesRel, + 'latest.md', + ); + state.previousResponseSourcePath = sourcePath; + } + + persistPreviousResponseSnapshot( + state: PieceState, + movementName: string, + movementIteration: number, + content: string, + ): void { + const timestamp = MovementExecutor.buildTimestamp(); + const runPaths = this.deps.getRunPaths(); + const fileName = `${movementName}.${movementIteration}.${timestamp}.md`; + const sourcePath = this.writeSnapshot(content, runPaths.contextPreviousResponsesRel, fileName); + this.writeSnapshot(content, runPaths.contextPreviousResponsesRel, 'latest.md'); + state.previousResponseSourcePath = sourcePath; + } + /** Build Phase 1 instruction from template */ buildInstruction( step: PieceMovement, movementIteration: number, state: PieceState, task: string, - maxIterations: number, + maxMovements: number, ): string { + this.ensurePreviousResponseSnapshot(state, step.name, movementIteration); + const policySnapshot = this.writeFacetSnapshot( + 'policy', + step.name, + movementIteration, + step.policyContents, + ); + const knowledgeSnapshot = this.writeFacetSnapshot( + 'knowledge', + step.name, + movementIteration, + step.knowledgeContents, + ); const pieceMovements = this.deps.getPieceMovements(); return new InstructionBuilder(step, { task, iteration: state.iteration, - maxIterations, + maxMovements, movementIteration, cwd: this.deps.getCwd(), projectCwd: this.deps.getProjectCwd(), @@ -78,8 +164,11 @@ export class MovementExecutor { pieceName: this.deps.getPieceName(), pieceDescription: this.deps.getPieceDescription(), retryNote: this.deps.getRetryNote(), - policyContents: step.policyContents, - knowledgeContents: step.knowledgeContents, + policyContents: policySnapshot?.content ?? step.policyContents, + policySourcePath: policySnapshot?.sourcePath, + knowledgeContents: knowledgeSnapshot?.content ?? step.knowledgeContents, + knowledgeSourcePath: knowledgeSnapshot?.sourcePath, + previousResponseSourcePath: state.previousResponseSourcePath, }).build(); } @@ -93,14 +182,14 @@ export class MovementExecutor { step: PieceMovement, state: PieceState, task: string, - maxIterations: number, + maxMovements: number, updatePersonaSession: (persona: string, sessionId: string | undefined) => void, prebuiltInstruction?: string, ): Promise<{ response: AgentResponse; instruction: string }> { const movementIteration = prebuiltInstruction ? state.movementIterations.get(step.name) ?? 1 : incrementMovementIteration(state, step.name); - const instruction = prebuiltInstruction ?? this.buildInstruction(step, movementIteration, state, task, maxIterations); + const instruction = prebuiltInstruction ?? this.buildInstruction(step, movementIteration, state, task, maxMovements); const sessionKey = buildSessionKey(step); log.debug('Running movement', { movement: step.name, @@ -120,8 +209,15 @@ export class MovementExecutor { const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(state, response.content, updatePersonaSession, this.deps.onPhaseStart, this.deps.onPhaseComplete); // Phase 2: report output (resume same session, Write only) + // When report phase returns blocked, propagate to PieceEngine's handleBlocked flow if (step.outputContracts && step.outputContracts.length > 0) { - await runReportPhase(step, movementIteration, phaseCtx); + const reportResult = await runReportPhase(step, movementIteration, phaseCtx); + if (reportResult?.blocked) { + response = { ...response, status: 'blocked', content: reportResult.response.content }; + state.movementOutputs.set(step.name, response); + state.lastOutput = response; + return { response, instruction }; + } } // Phase 3: status judgment (resume session, no tools, output status tag) @@ -144,6 +240,7 @@ export class MovementExecutor { state.movementOutputs.set(step.name, response); state.lastOutput = response; + this.persistPreviousResponseSnapshot(state, step.name, movementIteration, response.content); this.emitMovementReports(step); return { response, instruction }; } diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index 9114ca04..8fe68c31 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -11,6 +11,7 @@ import type { RunAgentOptions } from '../../../agents/runner.js'; import type { PhaseRunnerContext } from '../phase-runner.js'; import type { PieceEngineOptions, PhaseName } from '../types.js'; import { buildSessionKey } from '../session-key.js'; +import { resolveMovementProviderModel } from '../provider-resolution.js'; export class OptionsBuilder { constructor( @@ -30,13 +31,19 @@ export class OptionsBuilder { const movements = this.getPieceMovements(); const currentIndex = movements.findIndex((m) => m.name === step.name); const currentPosition = currentIndex >= 0 ? `${currentIndex + 1}/${movements.length}` : '?/?'; + const resolved = resolveMovementProviderModel({ + step, + provider: this.engineOptions.provider, + model: this.engineOptions.model, + personaProviders: this.engineOptions.personaProviders, + }); return { cwd: this.getCwd(), abortSignal: this.engineOptions.abortSignal, personaPath: step.personaPath, - provider: step.provider ?? this.engineOptions.personaProviders?.[step.personaDisplayName] ?? this.engineOptions.provider, - model: step.model ?? this.engineOptions.model, + provider: resolved.provider, + model: resolved.model, permissionMode: step.permissionMode, language: this.getLanguage(), onStream: this.engineOptions.onStream, @@ -82,8 +89,8 @@ export class OptionsBuilder { ): RunAgentOptions { return { ...this.buildBaseOptions(step), - // Do not pass permission mode in report/status phases. - permissionMode: undefined, + // Report/status phases are read-only regardless of movement settings. + permissionMode: 'readonly', sessionId, allowedTools: overrides.allowedTools, maxTurns: overrides.maxTurns, diff --git a/src/core/piece/engine/ParallelRunner.ts b/src/core/piece/engine/ParallelRunner.ts index de0ca707..a72a04eb 100644 --- a/src/core/piece/engine/ParallelRunner.ts +++ b/src/core/piece/engine/ParallelRunner.ts @@ -54,7 +54,7 @@ export class ParallelRunner { step: PieceMovement, state: PieceState, task: string, - maxIterations: number, + maxMovements: number, updatePersonaSession: (persona: string, sessionId: string | undefined) => void, ): Promise<{ response: AgentResponse; instruction: string }> { if (!step.parallel) { @@ -70,7 +70,7 @@ export class ParallelRunner { // Create parallel logger for prefixed output (only when streaming is enabled) const parallelLogger = this.deps.engineOptions.onStream - ? new ParallelLogger(this.buildParallelLoggerOptions(step.name, movementIteration, subMovements.map((s) => s.name), state.iteration, maxIterations)) + ? new ParallelLogger(this.buildParallelLoggerOptions(step.name, movementIteration, subMovements.map((s) => s.name), state.iteration, maxMovements)) : undefined; const ruleCtx = { @@ -85,7 +85,7 @@ export class ParallelRunner { const settled = await Promise.allSettled( subMovements.map(async (subMovement, index) => { const subIteration = incrementMovementIteration(state, subMovement.name); - const subInstruction = this.deps.movementExecutor.buildInstruction(subMovement, subIteration, state, task, maxIterations); + const subInstruction = this.deps.movementExecutor.buildInstruction(subMovement, subIteration, state, task, maxMovements); // Session key uses buildSessionKey (persona:provider) — same as normal movements. // This ensures sessions are shared across movements with the same persona+provider, @@ -192,6 +192,12 @@ export class ParallelRunner { state.movementOutputs.set(step.name, aggregatedResponse); state.lastOutput = aggregatedResponse; + this.deps.movementExecutor.persistPreviousResponseSnapshot( + state, + step.name, + movementIteration, + aggregatedResponse.content, + ); this.deps.movementExecutor.emitMovementReports(step); return { response: aggregatedResponse, instruction: aggregatedInstruction }; } @@ -201,14 +207,14 @@ export class ParallelRunner { movementIteration: number, subMovementNames: string[], iteration: number, - maxIterations: number, + maxMovements: number, ): ParallelLoggerOptions { const options: ParallelLoggerOptions = { subMovementNames, parentOnStream: this.deps.engineOptions.onStream, progressInfo: { iteration, - maxIterations, + maxMovements, }, }; diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index d7f5071f..cfde2397 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -8,7 +8,6 @@ import { EventEmitter } from 'node:events'; import { mkdirSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; import type { PieceConfig, PieceState, @@ -27,10 +26,12 @@ import { addUserInput as addUserInputToState, incrementMovementIteration, } from './state-manager.js'; -import { generateReportDir, getErrorMessage, createLogger } from '../../../shared/utils/index.js'; +import { generateReportDir, getErrorMessage, createLogger, isValidReportDirName } from '../../../shared/utils/index.js'; import { OptionsBuilder } from './OptionsBuilder.js'; import { MovementExecutor } from './MovementExecutor.js'; import { ParallelRunner } from './ParallelRunner.js'; +import { ArpeggioRunner } from './ArpeggioRunner.js'; +import { buildRunPaths, type RunPaths } from '../run/run-paths.js'; const log = createLogger('engine'); @@ -55,11 +56,13 @@ export class PieceEngine extends EventEmitter { private loopDetector: LoopDetector; private cycleDetector: CycleDetector; private reportDir: string; + private runPaths: RunPaths; private abortRequested = false; private readonly optionsBuilder: OptionsBuilder; private readonly movementExecutor: MovementExecutor; private readonly parallelRunner: ParallelRunner; + private readonly arpeggioRunner: ArpeggioRunner; private readonly detectRuleIndex: (content: string, movementName: string) => number; private readonly callAiJudge: ( agentOutput: string, @@ -77,8 +80,13 @@ export class PieceEngine extends EventEmitter { this.options = options; this.loopDetector = new LoopDetector(config.loopDetection); this.cycleDetector = new CycleDetector(config.loopMonitors ?? []); - this.reportDir = `.takt/reports/${generateReportDir(task)}`; - this.ensureReportDirExists(); + if (options.reportDirName !== undefined && !isValidReportDirName(options.reportDirName)) { + throw new Error(`Invalid reportDirName: ${options.reportDirName}`); + } + const reportDirName = options.reportDirName ?? generateReportDir(task); + this.runPaths = buildRunPaths(this.cwd, reportDirName); + this.reportDir = this.runPaths.reportsRel; + this.ensureRunDirsExist(); this.validateConfig(); this.state = createInitialState(config, options); this.detectRuleIndex = options.detectRuleIndex ?? (() => { @@ -106,6 +114,7 @@ export class PieceEngine extends EventEmitter { getCwd: () => this.cwd, getProjectCwd: () => this.projectCwd, getReportDir: () => this.reportDir, + getRunPaths: () => this.runPaths, getLanguage: () => this.options.language, getInteractive: () => this.options.interactive === true, getPieceMovements: () => this.config.movements.map(s => ({ name: s.name, description: s.description })), @@ -139,11 +148,26 @@ export class PieceEngine extends EventEmitter { }, }); + this.arpeggioRunner = new ArpeggioRunner({ + optionsBuilder: this.optionsBuilder, + movementExecutor: this.movementExecutor, + getCwd: () => this.cwd, + getInteractive: () => this.options.interactive === true, + detectRuleIndex: this.detectRuleIndex, + callAiJudge: this.callAiJudge, + onPhaseStart: (step, phase, phaseName, instruction) => { + this.emit('phase:start', step, phase, phaseName, instruction); + }, + onPhaseComplete: (step, phase, phaseName, content, phaseStatus, error) => { + this.emit('phase:complete', step, phase, phaseName, content, phaseStatus, error); + }, + }); + log.debug('PieceEngine initialized', { piece: config.name, movements: config.movements.map(s => s.name), initialMovement: config.initialMovement, - maxIterations: config.maxIterations, + maxMovements: config.maxMovements, }); } @@ -155,11 +179,21 @@ export class PieceEngine extends EventEmitter { } } - /** Ensure report directory exists (in cwd, which is clone dir in worktree mode) */ - private ensureReportDirExists(): void { - const reportDirPath = join(this.cwd, this.reportDir); - if (!existsSync(reportDirPath)) { - mkdirSync(reportDirPath, { recursive: true }); + /** Ensure run directories exist (in cwd, which is clone dir in worktree mode) */ + private ensureRunDirsExist(): void { + const requiredDirs = [ + this.runPaths.runRootAbs, + this.runPaths.reportsAbs, + this.runPaths.contextAbs, + this.runPaths.contextKnowledgeAbs, + this.runPaths.contextPolicyAbs, + this.runPaths.contextPreviousResponsesAbs, + this.runPaths.logsAbs, + ]; + for (const dir of requiredDirs) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } } } @@ -290,18 +324,22 @@ export class PieceEngine extends EventEmitter { } } - /** Run a single movement (delegates to ParallelRunner if movement has parallel sub-movements) */ + /** Run a single movement (delegates to ParallelRunner, ArpeggioRunner, or MovementExecutor) */ private async runMovement(step: PieceMovement, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> { const updateSession = this.updatePersonaSession.bind(this); let result: { response: AgentResponse; instruction: string }; if (step.parallel && step.parallel.length > 0) { result = await this.parallelRunner.runParallelMovement( - step, this.state, this.task, this.config.maxIterations, updateSession, + step, this.state, this.task, this.config.maxMovements, updateSession, + ); + } else if (step.arpeggio) { + result = await this.arpeggioRunner.runArpeggioMovement( + step, this.state, ); } else { result = await this.movementExecutor.runNormalMovement( - step, this.state, this.task, this.config.maxIterations, updateSession, prebuiltInstruction, + step, this.state, this.task, this.config.maxMovements, updateSession, prebuiltInstruction, ); } @@ -326,7 +364,7 @@ export class PieceEngine extends EventEmitter { /** Build instruction (public, used by pieceExecution.ts for logging) */ buildInstruction(step: PieceMovement, movementIteration: number): string { return this.movementExecutor.buildInstruction( - step, movementIteration, this.state, this.task, this.config.maxIterations, + step, movementIteration, this.state, this.task, this.config.maxMovements, ); } @@ -413,7 +451,7 @@ export class PieceEngine extends EventEmitter { this.state.iteration++; const movementIteration = incrementMovementIteration(this.state, judgeMovement.name); const prebuiltInstruction = this.movementExecutor.buildInstruction( - judgeMovement, movementIteration, this.state, this.task, this.config.maxIterations, + judgeMovement, movementIteration, this.state, this.task, this.config.maxMovements, ); this.emit('movement:start', judgeMovement, this.state.iteration, prebuiltInstruction); @@ -422,7 +460,7 @@ export class PieceEngine extends EventEmitter { judgeMovement, this.state, this.task, - this.config.maxIterations, + this.config.maxMovements, this.updatePersonaSession.bind(this), prebuiltInstruction, ); @@ -453,27 +491,27 @@ export class PieceEngine extends EventEmitter { break; } - if (this.state.iteration >= this.config.maxIterations) { - this.emit('iteration:limit', this.state.iteration, this.config.maxIterations); + if (this.state.iteration >= this.config.maxMovements) { + this.emit('iteration:limit', this.state.iteration, this.config.maxMovements); if (this.options.onIterationLimit) { const additionalIterations = await this.options.onIterationLimit({ currentIteration: this.state.iteration, - maxIterations: this.config.maxIterations, + maxMovements: this.config.maxMovements, currentMovement: this.state.currentMovement, }); if (additionalIterations !== null && additionalIterations > 0) { this.config = { ...this.config, - maxIterations: this.config.maxIterations + additionalIterations, + maxMovements: this.config.maxMovements + additionalIterations, }; continue; } } this.state.status = 'aborted'; - this.emit('piece:abort', this.state, ERROR_MESSAGES.MAX_ITERATIONS_REACHED); + this.emit('piece:abort', this.state, ERROR_MESSAGES.MAX_MOVEMENTS_REACHED); break; } @@ -492,13 +530,14 @@ export class PieceEngine extends EventEmitter { this.state.iteration++; - // Build instruction before emitting movement:start so listeners can log it - const isParallel = movement.parallel && movement.parallel.length > 0; + // Build instruction before emitting movement:start so listeners can log it. + // Parallel and arpeggio movements handle iteration incrementing internally. + const isDelegated = (movement.parallel && movement.parallel.length > 0) || !!movement.arpeggio; let prebuiltInstruction: string | undefined; - if (!isParallel) { + if (!isDelegated) { const movementIteration = incrementMovementIteration(this.state, movement.name); prebuiltInstruction = this.movementExecutor.buildInstruction( - movement, movementIteration, this.state, this.task, this.config.maxIterations, + movement, movementIteration, this.state, this.task, this.config.maxMovements, ); } this.emit('movement:start', movement, this.state.iteration, prebuiltInstruction ?? ''); diff --git a/src/core/piece/engine/index.ts b/src/core/piece/engine/index.ts index f94c0b43..505be712 100644 --- a/src/core/piece/engine/index.ts +++ b/src/core/piece/engine/index.ts @@ -8,6 +8,7 @@ export { PieceEngine } from './PieceEngine.js'; export { MovementExecutor } from './MovementExecutor.js'; export type { MovementExecutorDeps } from './MovementExecutor.js'; export { ParallelRunner } from './ParallelRunner.js'; +export { ArpeggioRunner } from './ArpeggioRunner.js'; export { OptionsBuilder } from './OptionsBuilder.js'; export { CycleDetector } from './cycle-detector.js'; export type { CycleCheckResult } from './cycle-detector.js'; diff --git a/src/core/piece/engine/parallel-logger.ts b/src/core/piece/engine/parallel-logger.ts index 9994ef5e..d44a6fff 100644 --- a/src/core/piece/engine/parallel-logger.ts +++ b/src/core/piece/engine/parallel-logger.ts @@ -17,8 +17,8 @@ const RESET = '\x1b[0m'; export interface ParallelProgressInfo { /** Current iteration (1-indexed) */ iteration: number; - /** Maximum iterations allowed */ - maxIterations: number; + /** Maximum movements allowed */ + maxMovements: number; } export interface ParallelLoggerOptions { @@ -83,8 +83,8 @@ export class ParallelLogger { buildPrefix(name: string, index: number): string { if (this.taskLabel && this.parentMovementName && this.progressInfo && this.movementIteration != null && this.taskColorIndex != null) { const taskColor = COLORS[this.taskColorIndex % COLORS.length]; - const { iteration, maxIterations } = this.progressInfo; - return `${taskColor}[${this.taskLabel}]${RESET}[${this.parentMovementName}][${name}](${iteration}/${maxIterations})(${this.movementIteration}) `; + const { iteration, maxMovements } = this.progressInfo; + return `${taskColor}[${this.taskLabel}]${RESET}[${this.parentMovementName}][${name}](${iteration}/${maxMovements})(${this.movementIteration}) `; } const color = COLORS[index % COLORS.length]; @@ -92,9 +92,9 @@ export class ParallelLogger { let progressPart = ''; if (this.progressInfo) { - const { iteration, maxIterations } = this.progressInfo; + const { iteration, maxMovements } = this.progressInfo; // index is 0-indexed, display as 1-indexed for step number - progressPart = `(${iteration}/${maxIterations}) step ${index + 1}/${this.totalSubMovements} `; + progressPart = `(${iteration}/${maxMovements}) step ${index + 1}/${this.totalSubMovements} `; } return `${color}[${name}]${RESET}${padding} ${progressPart}`; @@ -189,6 +189,19 @@ export class ParallelLogger { } } + /** + * Build the prefix string for summary lines (no sub-movement name). + * Returns empty string in non-rich mode (no task-level prefix needed). + */ + private buildSummaryPrefix(): string { + if (this.taskLabel && this.parentMovementName && this.progressInfo && this.movementIteration != null && this.taskColorIndex != null) { + const taskColor = COLORS[this.taskColorIndex % COLORS.length]; + const { iteration, maxMovements } = this.progressInfo; + return `${taskColor}[${this.taskLabel}]${RESET}[${this.parentMovementName}](${iteration}/${maxMovements})(${this.movementIteration}) `; + } + return ''; + } + /** * Flush remaining line buffers for all sub-movements. * Call after all sub-movements complete to output any trailing partial lines. @@ -243,10 +256,11 @@ export class ParallelLogger { const headerLine = `${'─'.repeat(sideWidth)}${headerText}${'─'.repeat(sideWidth)}`; const footerLine = '─'.repeat(headerLine.length); - this.writeFn(`${headerLine}\n`); + const summaryPrefix = this.buildSummaryPrefix(); + this.writeFn(`${summaryPrefix}${headerLine}\n`); for (const line of resultLines) { - this.writeFn(`${line}\n`); + this.writeFn(`${summaryPrefix}${line}\n`); } - this.writeFn(`${footerLine}\n`); + this.writeFn(`${summaryPrefix}${footerLine}\n`); } } diff --git a/src/core/piece/engine/state-manager.ts b/src/core/piece/engine/state-manager.ts index 2d23aafb..fc593557 100644 --- a/src/core/piece/engine/state-manager.ts +++ b/src/core/piece/engine/state-manager.ts @@ -40,6 +40,7 @@ export class StateManager { iteration: 0, movementOutputs: new Map(), lastOutput: undefined, + previousResponseSourcePath: undefined, userInputs, personaSessions, movementIterations: new Map(), diff --git a/src/core/piece/index.ts b/src/core/piece/index.ts index a2991e2e..776c8104 100644 --- a/src/core/piece/index.ts +++ b/src/core/piece/index.ts @@ -64,4 +64,4 @@ export { RuleEvaluator, type RuleMatch, type RuleEvaluatorContext, detectMatched export { AggregateEvaluator } from './evaluation/AggregateEvaluator.js'; // Phase runner -export { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from './phase-runner.js'; +export { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase, type ReportPhaseBlockedResult } from './phase-runner.js'; diff --git a/src/core/piece/instruction/InstructionBuilder.ts b/src/core/piece/instruction/InstructionBuilder.ts index 0e73c00f..316ebd0a 100644 --- a/src/core/piece/instruction/InstructionBuilder.ts +++ b/src/core/piece/instruction/InstructionBuilder.ts @@ -11,6 +11,72 @@ import { buildEditRule } from './instruction-context.js'; import { escapeTemplateChars, replaceTemplatePlaceholders } from './escape.js'; import { loadTemplate } from '../../../shared/prompts/index.js'; +const CONTEXT_MAX_CHARS = 2000; + +interface PreparedContextBlock { + readonly content: string; + readonly truncated: boolean; +} + +function trimContextContent(content: string): PreparedContextBlock { + if (content.length <= CONTEXT_MAX_CHARS) { + return { content, truncated: false }; + } + return { + content: `${content.slice(0, CONTEXT_MAX_CHARS)}\n...TRUNCATED...`, + truncated: true, + }; +} + +function renderConflictNotice(): string { + return 'If prompt content conflicts with source files, source files take precedence.'; +} + +function prepareKnowledgeContent(content: string, sourcePath?: string): string { + const prepared = trimContextContent(content); + const lines: string[] = [prepared.content]; + if (prepared.truncated && sourcePath) { + lines.push( + '', + `Knowledge is truncated. You MUST consult the source files before making decisions. Source: ${sourcePath}`, + ); + } + if (sourcePath) { + lines.push('', `Knowledge Source: ${sourcePath}`); + } + lines.push('', renderConflictNotice()); + return lines.join('\n'); +} + +function preparePolicyContent(content: string, sourcePath?: string): string { + const prepared = trimContextContent(content); + const lines: string[] = [prepared.content]; + if (prepared.truncated && sourcePath) { + lines.push( + '', + `Policy is authoritative. If truncated, you MUST read the full policy file and follow it strictly. Source: ${sourcePath}`, + ); + } + if (sourcePath) { + lines.push('', `Policy Source: ${sourcePath}`); + } + lines.push('', renderConflictNotice()); + return lines.join('\n'); +} + +function preparePreviousResponseContent(content: string, sourcePath?: string): string { + const prepared = trimContextContent(content); + const lines: string[] = [prepared.content]; + if (prepared.truncated && sourcePath) { + lines.push('', `Previous Response is truncated. Source: ${sourcePath}`); + } + if (sourcePath) { + lines.push('', `Source: ${sourcePath}`); + } + lines.push('', renderConflictNotice()); + return lines.join('\n'); +} + /** * Check if an output contract entry is the item form (OutputContractItem). */ @@ -72,8 +138,14 @@ export class InstructionBuilder { this.context.previousOutput && !hasPreviousResponsePlaceholder ); - const previousResponse = hasPreviousResponse && this.context.previousOutput - ? escapeTemplateChars(this.context.previousOutput.content) + const previousResponsePrepared = this.step.passPreviousResponse && this.context.previousOutput + ? preparePreviousResponseContent( + this.context.previousOutput.content, + this.context.previousResponseSourcePath, + ) + : ''; + const previousResponse = hasPreviousResponse + ? escapeTemplateChars(previousResponsePrepared) : ''; // User Inputs @@ -86,7 +158,10 @@ export class InstructionBuilder { const instructions = replaceTemplatePlaceholders( this.step.instructionTemplate, this.step, - this.context, + { + ...this.context, + previousResponseText: previousResponsePrepared || undefined, + }, ); // Piece name and description @@ -101,12 +176,18 @@ export class InstructionBuilder { // Policy injection (top + bottom reminder per "Lost in the Middle" research) const policyContents = this.context.policyContents ?? this.step.policyContents; const hasPolicy = !!(policyContents && policyContents.length > 0); - const policyContent = hasPolicy ? policyContents!.join('\n\n---\n\n') : ''; + const policyJoined = hasPolicy ? policyContents!.join('\n\n---\n\n') : ''; + const policyContent = hasPolicy + ? preparePolicyContent(policyJoined, this.context.policySourcePath) + : ''; // Knowledge injection (domain-specific knowledge, no reminder needed) const knowledgeContents = this.context.knowledgeContents ?? this.step.knowledgeContents; const hasKnowledge = !!(knowledgeContents && knowledgeContents.length > 0); - const knowledgeContent = hasKnowledge ? knowledgeContents!.join('\n\n---\n\n') : ''; + const knowledgeJoined = hasKnowledge ? knowledgeContents!.join('\n\n---\n\n') : ''; + const knowledgeContent = hasKnowledge + ? prepareKnowledgeContent(knowledgeJoined, this.context.knowledgeSourcePath) + : ''; // Quality gates injection (AI directives for movement completion) const hasQualityGates = !!(this.step.qualityGates && this.step.qualityGates.length > 0); @@ -121,7 +202,7 @@ export class InstructionBuilder { pieceDescription, hasPieceDescription, pieceStructure, - iteration: `${this.context.iteration}/${this.context.maxIterations}`, + iteration: `${this.context.iteration}/${this.context.maxMovements}`, movementIteration: String(this.context.movementIteration), movement: this.step.name, hasReport, @@ -217,21 +298,21 @@ export function renderReportOutputInstruction( let heading: string; let createRule: string; - let appendRule: string; + let overwriteRule: string; if (language === 'ja') { heading = isMulti ? '**レポート出力:** Report Files に出力してください。' : '**レポート出力:** `Report File` に出力してください。'; createRule = '- ファイルが存在しない場合: 新規作成'; - appendRule = `- ファイルが存在する場合: \`## Iteration ${context.movementIteration}\` セクションを追記`; + overwriteRule = '- ファイルが存在する場合: 既存内容を `logs/reports-history/` に退避し、最新内容で上書き'; } else { heading = isMulti ? '**Report output:** Output to the `Report Files` specified above.' : '**Report output:** Output to the `Report File` specified above.'; createRule = '- If file does not exist: Create new file'; - appendRule = `- If file exists: Append with \`## Iteration ${context.movementIteration}\` section`; + overwriteRule = '- If file exists: Move current content to `logs/reports-history/` and overwrite with latest report'; } - return `${heading}\n${createRule}\n${appendRule}`; + return `${heading}\n${createRule}\n${overwriteRule}`; } diff --git a/src/core/piece/instruction/ReportInstructionBuilder.ts b/src/core/piece/instruction/ReportInstructionBuilder.ts index 2c21aa92..6ed90d4b 100644 --- a/src/core/piece/instruction/ReportInstructionBuilder.ts +++ b/src/core/piece/instruction/ReportInstructionBuilder.ts @@ -59,7 +59,7 @@ export class ReportInstructionBuilder { const instrContext: InstructionContext = { task: '', iteration: 0, - maxIterations: 0, + maxMovements: 0, movementIteration: this.context.movementIteration, cwd: this.context.cwd, projectCwd: this.context.cwd, diff --git a/src/core/piece/instruction/escape.ts b/src/core/piece/instruction/escape.ts index fbf67186..9b4fcdd6 100644 --- a/src/core/piece/instruction/escape.ts +++ b/src/core/piece/instruction/escape.ts @@ -30,14 +30,19 @@ export function replaceTemplatePlaceholders( // Replace {task} result = result.replace(/\{task\}/g, escapeTemplateChars(context.task)); - // Replace {iteration}, {max_iterations}, and {movement_iteration} + // Replace {iteration}, {max_movements}, and {movement_iteration} result = result.replace(/\{iteration\}/g, String(context.iteration)); - result = result.replace(/\{max_iterations\}/g, String(context.maxIterations)); + result = result.replace(/\{max_movements\}/g, String(context.maxMovements)); result = result.replace(/\{movement_iteration\}/g, String(context.movementIteration)); // Replace {previous_response} if (step.passPreviousResponse) { - if (context.previousOutput) { + if (context.previousResponseText !== undefined) { + result = result.replace( + /\{previous_response\}/g, + escapeTemplateChars(context.previousResponseText), + ); + } else if (context.previousOutput) { result = result.replace( /\{previous_response\}/g, escapeTemplateChars(context.previousOutput.content), diff --git a/src/core/piece/instruction/instruction-context.ts b/src/core/piece/instruction/instruction-context.ts index 0814c94f..ee8bf645 100644 --- a/src/core/piece/instruction/instruction-context.ts +++ b/src/core/piece/instruction/instruction-context.ts @@ -14,8 +14,8 @@ export interface InstructionContext { task: string; /** Current iteration number (piece-wide turn count) */ iteration: number; - /** Maximum iterations allowed */ - maxIterations: number; + /** Maximum movements allowed */ + maxMovements: number; /** Current movement's iteration number (how many times this movement has been executed) */ movementIteration: number; /** Working directory (agent work dir, may be a clone) */ @@ -26,6 +26,10 @@ export interface InstructionContext { userInputs: string[]; /** Previous movement output if available */ previousOutput?: AgentResponse; + /** Source path for previous response snapshot */ + previousResponseSourcePath?: string; + /** Preprocessed previous response text for template placeholder replacement */ + previousResponseText?: string; /** Report directory path */ reportDir?: string; /** Language for metadata rendering. Defaults to 'en'. */ @@ -44,8 +48,12 @@ export interface InstructionContext { retryNote?: string; /** Resolved policy content strings for injection into instruction */ policyContents?: string[]; + /** Source path for policy snapshot */ + policySourcePath?: string; /** Resolved knowledge content strings for injection into instruction */ knowledgeContents?: string[]; + /** Source path for knowledge snapshot */ + knowledgeSourcePath?: string; } /** diff --git a/src/core/piece/judgment/FallbackStrategy.ts b/src/core/piece/judgment/FallbackStrategy.ts index 85f1225c..f3007c88 100644 --- a/src/core/piece/judgment/FallbackStrategy.ts +++ b/src/core/piece/judgment/FallbackStrategy.ts @@ -69,8 +69,8 @@ abstract class JudgmentStrategyBase implements JudgmentStrategy { protected async runConductor(instruction: string, context: JudgmentContext): Promise { const response = await runAgent('conductor', instruction, { cwd: context.cwd, - allowedTools: [], maxTurns: 3, + permissionMode: 'readonly', language: context.language, }); diff --git a/src/core/piece/phase-runner.ts b/src/core/piece/phase-runner.ts index 21b2e62a..51b7a93a 100644 --- a/src/core/piece/phase-runner.ts +++ b/src/core/piece/phase-runner.ts @@ -5,9 +5,9 @@ * as session-resume operations. */ -import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, resolve, sep } from 'node:path'; -import type { PieceMovement, Language } from '../models/types.js'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, parse, resolve, sep } from 'node:path'; +import type { PieceMovement, Language, AgentResponse } from '../models/types.js'; import type { PhaseName } from './types.js'; import { runAgent, type RunAgentOptions } from '../../agents/runner.js'; import { ReportInstructionBuilder } from './instruction/ReportInstructionBuilder.js'; @@ -18,6 +18,9 @@ import { buildSessionKey } from './session-key.js'; const log = createLogger('phase-runner'); +/** Result when Phase 2 encounters a blocked status */ +export type ReportPhaseBlockedResult = { blocked: true; response: AgentResponse }; + export interface PhaseRunnerContext { /** Working directory (agent work dir, may be a clone) */ cwd: string; @@ -49,6 +52,41 @@ export function needsStatusJudgmentPhase(step: PieceMovement): boolean { return hasTagBasedRules(step); } +function formatHistoryTimestamp(date: Date): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hour = String(date.getUTCHours()).padStart(2, '0'); + const minute = String(date.getUTCMinutes()).padStart(2, '0'); + const second = String(date.getUTCSeconds()).padStart(2, '0'); + return `${year}${month}${day}T${hour}${minute}${second}Z`; +} + +function buildHistoryFileName(fileName: string, timestamp: string, sequence: number): string { + const parsed = parse(fileName); + const duplicateSuffix = sequence === 0 ? '' : `.${sequence}`; + return `${parsed.name}.${timestamp}${duplicateSuffix}${parsed.ext}`; +} + +function backupExistingReport(reportDir: string, fileName: string, targetPath: string): void { + if (!existsSync(targetPath)) { + return; + } + + const currentContent = readFileSync(targetPath, 'utf-8'); + const historyDir = resolve(reportDir, '..', 'logs', 'reports-history'); + mkdirSync(historyDir, { recursive: true }); + + const timestamp = formatHistoryTimestamp(new Date()); + let sequence = 0; + let historyPath = resolve(historyDir, buildHistoryFileName(fileName, timestamp, sequence)); + while (existsSync(historyPath)) { + sequence += 1; + historyPath = resolve(historyDir, buildHistoryFileName(fileName, timestamp, sequence)); + } + + writeFileSync(historyPath, currentContent); +} function writeReportFile(reportDir: string, fileName: string, content: string): void { const baseDir = resolve(reportDir); @@ -58,11 +96,8 @@ function writeReportFile(reportDir: string, fileName: string, content: string): throw new Error(`Report file path escapes report directory: ${fileName}`); } mkdirSync(dirname(targetPath), { recursive: true }); - if (existsSync(targetPath)) { - appendFileSync(targetPath, `\n\n${content}`); - } else { - writeFileSync(targetPath, content); - } + backupExistingReport(baseDir, fileName, targetPath); + writeFileSync(targetPath, content); } /** @@ -75,7 +110,7 @@ export async function runReportPhase( step: PieceMovement, movementIteration: number, ctx: PhaseRunnerContext, -): Promise { +): Promise { const sessionKey = buildSessionKey(step); let currentSessionId = ctx.getSessionId(sessionKey); if (!currentSessionId) { @@ -121,6 +156,11 @@ export async function runReportPhase( throw error; } + if (reportResponse.status === 'blocked') { + ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status); + return { blocked: true, response: reportResponse }; + } + if (reportResponse.status !== 'done') { const errorMsg = reportResponse.error || reportResponse.content || 'Unknown error'; ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status, errorMsg); diff --git a/src/core/piece/provider-resolution.ts b/src/core/piece/provider-resolution.ts new file mode 100644 index 00000000..6561c800 --- /dev/null +++ b/src/core/piece/provider-resolution.ts @@ -0,0 +1,24 @@ +import type { PieceMovement } from '../models/types.js'; + +export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; + +export interface MovementProviderModelInput { + step: Pick; + provider?: ProviderType; + model?: string; + personaProviders?: Record; +} + +export interface MovementProviderModelOutput { + provider?: ProviderType; + model?: string; +} + +export function resolveMovementProviderModel(input: MovementProviderModelInput): MovementProviderModelOutput { + return { + provider: input.step.provider + ?? input.personaProviders?.[input.step.personaDisplayName] + ?? input.provider, + model: input.step.model ?? input.model, + }; +} diff --git a/src/core/piece/run/run-paths.ts b/src/core/piece/run/run-paths.ts new file mode 100644 index 00000000..b8e5ac21 --- /dev/null +++ b/src/core/piece/run/run-paths.ts @@ -0,0 +1,52 @@ +import { join } from 'node:path'; + +export interface RunPaths { + readonly slug: string; + readonly runRootRel: string; + readonly reportsRel: string; + readonly contextRel: string; + readonly contextKnowledgeRel: string; + readonly contextPolicyRel: string; + readonly contextPreviousResponsesRel: string; + readonly logsRel: string; + readonly metaRel: string; + readonly runRootAbs: string; + readonly reportsAbs: string; + readonly contextAbs: string; + readonly contextKnowledgeAbs: string; + readonly contextPolicyAbs: string; + readonly contextPreviousResponsesAbs: string; + readonly logsAbs: string; + readonly metaAbs: string; +} + +export function buildRunPaths(cwd: string, slug: string): RunPaths { + const runRootRel = `.takt/runs/${slug}`; + const reportsRel = `${runRootRel}/reports`; + const contextRel = `${runRootRel}/context`; + const contextKnowledgeRel = `${contextRel}/knowledge`; + const contextPolicyRel = `${contextRel}/policy`; + const contextPreviousResponsesRel = `${contextRel}/previous_responses`; + const logsRel = `${runRootRel}/logs`; + const metaRel = `${runRootRel}/meta.json`; + + return { + slug, + runRootRel, + reportsRel, + contextRel, + contextKnowledgeRel, + contextPolicyRel, + contextPreviousResponsesRel, + logsRel, + metaRel, + runRootAbs: join(cwd, runRootRel), + reportsAbs: join(cwd, reportsRel), + contextAbs: join(cwd, contextRel), + contextKnowledgeAbs: join(cwd, contextKnowledgeRel), + contextPolicyAbs: join(cwd, contextPolicyRel), + contextPreviousResponsesAbs: join(cwd, contextPreviousResponsesRel), + logsAbs: join(cwd, logsRel), + metaAbs: join(cwd, metaRel), + }; +} diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index 82b681c7..73b940d5 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -8,7 +8,7 @@ import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js'; -export type ProviderType = 'claude' | 'codex' | 'mock'; +export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; export interface StreamInitEventData { model: string; @@ -117,7 +117,7 @@ export interface PieceEvents { 'phase:complete': (step: PieceMovement, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void; 'piece:complete': (state: PieceState) => void; 'piece:abort': (state: PieceState, reason: string) => void; - 'iteration:limit': (iteration: number, maxIterations: number) => void; + 'iteration:limit': (iteration: number, maxMovements: number) => void; 'movement:loop_detected': (step: PieceMovement, consecutiveCount: number) => void; 'movement:cycle_detected': (monitor: LoopMonitorConfig, cycleCount: number) => void; } @@ -136,8 +136,8 @@ export interface UserInputRequest { export interface IterationLimitRequest { /** Current iteration count */ currentIteration: number; - /** Current max iterations */ - maxIterations: number; + /** Current max movements */ + maxMovements: number; /** Current movement name */ currentMovement: string; } @@ -190,6 +190,8 @@ export interface PieceEngineOptions { startMovement?: string; /** Retry note explaining why task is being retried */ retryNote?: string; + /** Override report directory name (without parent path). */ + reportDirName?: string; /** Task name prefix for parallel task execution output */ taskPrefix?: string; /** Color index for task prefix (cycled across tasks) */ diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index 1819ceb4..156862fc 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -22,6 +22,7 @@ import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; import { readMultilineInput } from './lineEditor.js'; +import { EXIT_SIGINT } from '../../shared/exitCodes.js'; import { type PieceContext, type InteractiveModeResult, @@ -97,6 +98,21 @@ export async function callAIWithRetry( ctx: SessionContext, ): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> { const display = new StreamDisplay('assistant', isQuietMode()); + const abortController = new AbortController(); + let sigintCount = 0; + const onSigInt = (): void => { + sigintCount += 1; + if (sigintCount === 1) { + blankLine(); + info(getLabel('piece.sigintGraceful', ctx.lang)); + abortController.abort(); + return; + } + blankLine(); + error(getLabel('piece.sigintForce', ctx.lang)); + process.exit(EXIT_SIGINT); + }; + process.on('SIGINT', onSigInt); let { sessionId } = ctx; try { @@ -106,6 +122,7 @@ export async function callAIWithRetry( model: ctx.model, sessionId, allowedTools, + abortSignal: abortController.signal, onStream: display.createHandler(), }); display.flush(); @@ -121,6 +138,7 @@ export async function callAIWithRetry( model: ctx.model, sessionId: undefined, allowedTools, + abortSignal: abortController.signal, onStream: retryDisplay.createHandler(), }); retryDisplay.flush(); @@ -148,6 +166,8 @@ export async function callAIWithRetry( error(msg); blankLine(); return { result: null, sessionId }; + } finally { + process.removeListener('SIGINT', onSigInt); } } diff --git a/src/features/interactive/index.ts b/src/features/interactive/index.ts index 66b5e9d1..56bda3f3 100644 --- a/src/features/interactive/index.ts +++ b/src/features/interactive/index.ts @@ -15,6 +15,7 @@ export { } from './interactive.js'; export { selectInteractiveMode } from './modeSelection.js'; +export { selectRecentSession } from './sessionSelector.js'; export { passthroughMode } from './passthroughMode.js'; export { quietMode } from './quietMode.js'; export { personaMode } from './personaMode.js'; diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 0e677377..36b9104b 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -221,8 +221,10 @@ export async function interactiveMode( cwd: string, initialInput?: string, pieceContext?: PieceContext, + sessionId?: string, ): Promise { - const ctx = initializeSession(cwd, 'interactive'); + const baseCtx = initializeSession(cwd, 'interactive'); + const ctx = sessionId ? { ...baseCtx, sessionId } : baseCtx; displayAndClearSessionState(cwd, ctx.lang); diff --git a/src/features/interactive/sessionSelector.ts b/src/features/interactive/sessionSelector.ts new file mode 100644 index 00000000..c7fd6116 --- /dev/null +++ b/src/features/interactive/sessionSelector.ts @@ -0,0 +1,103 @@ +/** + * Session selector for interactive mode + * + * Presents recent Claude Code sessions for the user to choose from, + * allowing them to resume a previous conversation as the assistant. + */ + +import { loadSessionIndex, extractLastAssistantResponse } from '../../infra/claude/session-reader.js'; +import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js'; +import { getLabel } from '../../shared/i18n/index.js'; + +/** Maximum number of sessions to display */ +const MAX_DISPLAY_SESSIONS = 10; + +/** Maximum length for last response preview */ +const MAX_RESPONSE_PREVIEW_LENGTH = 200; + +/** + * Format a modified date for display. + */ +function formatModifiedDate(modified: string, lang: 'en' | 'ja'): string { + const date = new Date(modified); + return date.toLocaleString(lang === 'ja' ? 'ja-JP' : 'en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * Truncate a single-line string for use as a label. + */ +function truncateForLabel(text: string, maxLength: number): string { + const singleLine = text.replace(/\n/g, ' ').trim(); + if (singleLine.length <= maxLength) { + return singleLine; + } + return singleLine.slice(0, maxLength) + '…'; +} + +/** + * Prompt user to select from recent Claude Code sessions. + * + * @param cwd - Current working directory (project directory) + * @param lang - Display language + * @returns Selected session ID, or null for new session / no sessions + */ +export async function selectRecentSession( + cwd: string, + lang: 'en' | 'ja', +): Promise { + const sessions = loadSessionIndex(cwd); + + if (sessions.length === 0) { + return null; + } + + const displaySessions = sessions.slice(0, MAX_DISPLAY_SESSIONS); + + const options: SelectOptionItem[] = [ + { + label: getLabel('interactive.sessionSelector.newSession', lang), + value: '__new__', + description: getLabel('interactive.sessionSelector.newSessionDescription', lang), + }, + ]; + + for (const session of displaySessions) { + const label = truncateForLabel(session.firstPrompt, 60); + const dateStr = formatModifiedDate(session.modified, lang); + const messagesStr = getLabel('interactive.sessionSelector.messages', lang, { + count: String(session.messageCount), + }); + const description = `${dateStr} | ${messagesStr}`; + + const details: string[] = []; + const lastResponse = extractLastAssistantResponse(session.fullPath, MAX_RESPONSE_PREVIEW_LENGTH); + if (lastResponse) { + const previewLine = lastResponse.replace(/\n/g, ' ').trim(); + const preview = getLabel('interactive.sessionSelector.lastResponse', lang, { + response: previewLine, + }); + details.push(preview); + } + + options.push({ + label, + value: session.sessionId, + description, + details: details.length > 0 ? details : undefined, + }); + } + + const prompt = getLabel('interactive.sessionSelector.prompt', lang); + const selected = await selectOption(prompt, options); + + if (selected === null || selected === '__new__') { + return null; + } + + return selected; +} diff --git a/src/features/prompt/preview.ts b/src/features/prompt/preview.ts index 5d62a5b1..27d5bc89 100644 --- a/src/features/prompt/preview.ts +++ b/src/features/prompt/preview.ts @@ -48,14 +48,14 @@ export async function previewPrompts(cwd: string, pieceIdentifier?: string): Pro const context: InstructionContext = { task: '', iteration: 1, - maxIterations: config.maxIterations, + maxMovements: config.maxMovements, movementIteration: 1, cwd, projectCwd: cwd, userInputs: [], pieceMovements: config.movements, currentMovementIndex: i, - reportDir: movement.outputContracts && movement.outputContracts.length > 0 ? '.takt/reports/preview' : undefined, + reportDir: movement.outputContracts && movement.outputContracts.length > 0 ? '.takt/runs/preview/reports' : undefined, language, }; @@ -67,7 +67,7 @@ export async function previewPrompts(cwd: string, pieceIdentifier?: string): Pro if (movement.outputContracts && movement.outputContracts.length > 0) { const reportBuilder = new ReportInstructionBuilder(movement, { cwd, - reportDir: '.takt/reports/preview', + reportDir: '.takt/runs/preview/reports', movementIteration: 1, language, }); diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index e7a4c944..cd48f402 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -1,22 +1,32 @@ /** * add command implementation * - * Starts an AI conversation to refine task requirements, - * then appends a task record to .takt/tasks.yaml. + * Appends a task record to .takt/tasks.yaml. */ import * as path from 'node:path'; +import * as fs from 'node:fs'; import { promptInput, confirm } from '../../../shared/prompt/index.js'; -import { success, info, error } from '../../../shared/ui/index.js'; +import { success, info, error, withProgress } from '../../../shared/ui/index.js'; import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js'; -import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.js'; import { determinePiece } from '../execute/selectAndExecute.js'; -import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js'; import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js'; -import { interactiveMode } from '../../interactive/index.js'; const log = createLogger('add-task'); +function resolveUniqueTaskSlug(cwd: string, baseSlug: string): string { + let sequence = 1; + let slug = baseSlug; + let taskDir = path.join(cwd, '.takt', 'tasks', slug); + while (fs.existsSync(taskDir)) { + sequence += 1; + slug = `${baseSlug}-${sequence}`; + taskDir = path.join(cwd, '.takt', 'tasks', slug); + } + return slug; +} + /** * Save a task entry to .takt/tasks.yaml. * @@ -29,6 +39,12 @@ export async function saveTaskFile( options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean }, ): Promise<{ taskName: string; tasksFile: string }> { const runner = new TaskRunner(cwd); + const taskSlug = resolveUniqueTaskSlug(cwd, generateReportDir(taskContent)); + const taskDir = path.join(cwd, '.takt', 'tasks', taskSlug); + const taskDirRelative = `.takt/tasks/${taskSlug}`; + const orderPath = path.join(taskDir, 'order.md'); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync(orderPath, taskContent, 'utf-8'); const config: Omit = { ...(options?.worktree !== undefined && { worktree: options.worktree }), ...(options?.branch && { branch: options.branch }), @@ -36,7 +52,10 @@ export async function saveTaskFile( ...(options?.issue !== undefined && { issue: options.issue }), ...(options?.autoPr !== undefined && { auto_pr: options.autoPr }), }; - const created = runner.addTask(taskContent, config); + const created = runner.addTask(taskContent, { + ...config, + task_dir: taskDirRelative, + }); const tasksFile = path.join(cwd, '.takt', 'tasks.yaml'); log.info('Task created', { taskName: created.name, tasksFile, config }); return { taskName: created.name, tasksFile }; @@ -48,15 +67,22 @@ export async function saveTaskFile( * Extracts the first line as the issue title (truncated to 100 chars), * uses the full task as the body, and displays success/error messages. */ -export function createIssueFromTask(task: string): void { +export function createIssueFromTask(task: string): number | undefined { info('Creating GitHub Issue...'); const firstLine = task.split('\n')[0] || task; const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine; const issueResult = createIssue({ title, body: task }); if (issueResult.success) { success(`Issue created: ${issueResult.url}`); + const num = Number(issueResult.url!.split('/').pop()); + if (Number.isNaN(num)) { + error('Failed to extract issue number from URL'); + return undefined; + } + return num; } else { error(`Failed to create issue: ${issueResult.error}`); + return undefined; } } @@ -66,6 +92,38 @@ interface WorktreeSettings { autoPr?: boolean; } +function displayTaskCreationResult( + created: { taskName: string; tasksFile: string }, + settings: WorktreeSettings, + piece?: string, +): void { + success(`Task created: ${created.taskName}`); + info(` File: ${created.tasksFile}`); + if (settings.worktree) { + info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); + } + if (settings.branch) { + info(` Branch: ${settings.branch}`); + } + if (settings.autoPr) { + info(` Auto-PR: yes`); + } + if (piece) info(` Piece: ${piece}`); +} + +/** + * Create a GitHub Issue and save the task to .takt/tasks.yaml. + * + * Combines issue creation and task saving into a single workflow. + * If issue creation fails, no task is saved. + */ +export async function createIssueAndSaveTask(cwd: string, task: string, piece?: string): Promise { + const issueNumber = createIssueFromTask(task); + if (issueNumber !== undefined) { + await saveTaskFromInteractive(cwd, task, piece, { issue: issueNumber }); + } +} + async function promptWorktreeSettings(): Promise { const useWorktree = await confirm('Create worktree?', true); if (!useWorktree) { @@ -91,87 +149,65 @@ export async function saveTaskFromInteractive( cwd: string, task: string, piece?: string, + options?: { issue?: number; confirmAtEndMessage?: string }, ): Promise { const settings = await promptWorktreeSettings(); - const created = await saveTaskFile(cwd, task, { piece, ...settings }); - success(`Task created: ${created.taskName}`); - info(` File: ${created.tasksFile}`); - if (settings.worktree) { - info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); - } - if (settings.branch) { - info(` Branch: ${settings.branch}`); - } - if (settings.autoPr) { - info(` Auto-PR: yes`); + if (options?.confirmAtEndMessage) { + const approved = await confirm(options.confirmAtEndMessage, true); + if (!approved) { + return; + } } - if (piece) info(` Piece: ${piece}`); + const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings }); + displayTaskCreationResult(created, settings, piece); } /** * add command handler * * Flow: - * A) Issue参照の場合: issue取得 → ピース選択 → ワークツリー設定 → YAML作成 - * B) それ以外: ピース選択 → AI対話モード → ワークツリー設定 → YAML作成 + * A) 引数なし: Usage表示して終了 + * B) Issue参照の場合: issue取得 → ピース選択 → ワークツリー設定 → YAML作成 + * C) 通常入力: 引数をそのまま保存 */ export async function addTask(cwd: string, task?: string): Promise { - // ピース選択とタスク内容の決定 + const rawTask = task ?? ''; + const trimmedTask = rawTask.trim(); + if (!trimmedTask) { + info('Usage: takt add '); + return; + } + let taskContent: string; let issueNumber: number | undefined; - let piece: string | undefined; - if (task && isIssueReference(task)) { + if (isIssueReference(trimmedTask)) { // Issue reference: fetch issue and use directly as task content - info('Fetching GitHub Issue...'); try { - taskContent = resolveIssueTask(task); - const numbers = parseIssueNumbers([task]); + const numbers = parseIssueNumbers([trimmedTask]); + const primaryIssueNumber = numbers[0]; + taskContent = await withProgress( + 'Fetching GitHub Issue...', + primaryIssueNumber ? `GitHub Issue fetched: #${primaryIssueNumber}` : 'GitHub Issue fetched', + async () => resolveIssueTask(trimmedTask), + ); if (numbers.length > 0) { issueNumber = numbers[0]; } } catch (e) { const msg = getErrorMessage(e); - log.error('Failed to fetch GitHub Issue', { task, error: msg }); - info(`Failed to fetch issue ${task}: ${msg}`); + log.error('Failed to fetch GitHub Issue', { task: trimmedTask, error: msg }); + info(`Failed to fetch issue ${trimmedTask}: ${msg}`); return; } - - // ピース選択(issue取得成功後) - const pieceId = await determinePiece(cwd); - if (pieceId === null) { - info('Cancelled.'); - return; - } - piece = pieceId; } else { - // ピース選択を先に行い、結果を対話モードに渡す - const pieceId = await determinePiece(cwd); - if (pieceId === null) { - info('Cancelled.'); - return; - } - piece = pieceId; - - const globalConfig = loadGlobalConfig(); - const previewCount = globalConfig.interactivePreviewMovements; - const pieceContext = getPieceDescription(pieceId, cwd, previewCount); - - // Interactive mode: AI conversation to refine task - const result = await interactiveMode(cwd, undefined, pieceContext); - - if (result.action === 'create_issue') { - createIssueFromTask(result.task); - return; - } - - if (result.action !== 'execute' && result.action !== 'save_task') { - info('Cancelled.'); - return; - } + taskContent = rawTask; + } - // interactiveMode already returns a summarized task from conversation - taskContent = result.task; + const piece = await determinePiece(cwd); + if (piece === null) { + info('Cancelled.'); + return; } // 3. ワークツリー/ブランチ/PR設定 @@ -184,18 +220,5 @@ export async function addTask(cwd: string, task?: string): Promise { ...settings, }); - success(`Task created: ${created.taskName}`); - info(` File: ${created.tasksFile}`); - if (settings.worktree) { - info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); - } - if (settings.branch) { - info(` Branch: ${settings.branch}`); - } - if (settings.autoPr) { - info(` Auto-PR: yes`); - } - if (piece) { - info(` Piece: ${piece}`); - } + displayTaskCreationResult(created, settings, piece); } diff --git a/src/features/tasks/execute/parallelExecution.ts b/src/features/tasks/execute/parallelExecution.ts index ee65819c..2967c568 100644 --- a/src/features/tasks/execute/parallelExecution.ts +++ b/src/features/tasks/execute/parallelExecution.ts @@ -7,7 +7,7 @@ * (concurrency>1) execution through the same code path. * * Polls for newly added tasks at a configurable interval so that tasks - * added to .takt/tasks/ during execution are picked up without waiting + * added to .takt/tasks.yaml during execution are picked up without waiting * for an active task to complete. */ diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 0a10d7f4..01bd8056 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -36,7 +36,6 @@ import { generateSessionId, createSessionLog, finalizeSessionLog, - updateLatestPointer, initNdjsonLog, appendNdjsonLine, type NdjsonStepStart, @@ -56,11 +55,20 @@ import { playWarningSound, isDebugEnabled, writePromptLog, + generateReportDir, + isValidReportDirName, } from '../../../shared/utils/index.js'; import type { PromptLogRecord } from '../../../shared/utils/index.js'; +import { + createProviderEventLogger, + isProviderEventsEnabled, +} from '../../../shared/utils/providerEventLogger.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { getLabel } from '../../../shared/i18n/index.js'; import { installSigIntHandler } from './sigintHandler.js'; +import { buildRunPaths } from '../../../core/piece/run/run-paths.js'; +import { resolveMovementProviderModel } from '../../../core/piece/provider-resolution.js'; +import { writeFileAtomic, ensureDir } from '../../../infra/config/index.js'; const log = createLogger('piece'); @@ -79,6 +87,20 @@ interface OutputFns { logLine: (text: string) => void; } +interface RunMeta { + task: string; + piece: string; + runSlug: string; + runRoot: string; + reportDirectory: string; + contextDirectory: string; + logsDirectory: string; + status: 'running' | 'completed' | 'aborted'; + startTime: string; + endTime?: string; + iterations?: number; +} + function assertTaskPrefixPair( taskPrefix: string | undefined, taskColorIndex: number | undefined @@ -201,17 +223,49 @@ export async function executePiece( : undefined; const out = createOutputFns(prefixWriter); - // Always continue from previous sessions (use /clear to reset) - log.debug('Continuing session (use /clear to reset)'); + // Retry reuses saved sessions; normal runs start fresh + const isRetry = Boolean(options.startMovement || options.retryNote); + log.debug('Session mode', { isRetry, isWorktree: cwd !== projectCwd }); out.header(`${headerPrefix} ${pieceConfig.name}`); const pieceSessionId = generateSessionId(); + const runSlug = options.reportDirName ?? generateReportDir(task); + if (!isValidReportDirName(runSlug)) { + throw new Error(`Invalid reportDirName: ${runSlug}`); + } + const runPaths = buildRunPaths(cwd, runSlug); + + const runMeta: RunMeta = { + task, + piece: pieceConfig.name, + runSlug: runPaths.slug, + runRoot: runPaths.runRootRel, + reportDirectory: runPaths.reportsRel, + contextDirectory: runPaths.contextRel, + logsDirectory: runPaths.logsRel, + status: 'running', + startTime: new Date().toISOString(), + }; + ensureDir(runPaths.runRootAbs); + writeFileAtomic(runPaths.metaAbs, JSON.stringify(runMeta, null, 2)); + let isMetaFinalized = false; + const finalizeRunMeta = (status: 'completed' | 'aborted', iterations?: number): void => { + writeFileAtomic(runPaths.metaAbs, JSON.stringify({ + ...runMeta, + status, + endTime: new Date().toISOString(), + ...(iterations != null ? { iterations } : {}), + } satisfies RunMeta, null, 2)); + isMetaFinalized = true; + }; + let sessionLog = createSessionLog(task, projectCwd, pieceConfig.name); - // Initialize NDJSON log file + pointer at piece start - const ndjsonLogPath = initNdjsonLog(pieceSessionId, task, pieceConfig.name, projectCwd); - updateLatestPointer(sessionLog, pieceSessionId, projectCwd, { copyToPrevious: true }); + // Initialize NDJSON log file at run-scoped logs directory + const ndjsonLogPath = initNdjsonLog(pieceSessionId, task, pieceConfig.name, { + logsDir: runPaths.logsAbs, + }); // Write interactive mode records if interactive mode was used before this piece if (options.interactiveMetadata) { @@ -244,19 +298,33 @@ export async function executePiece( displayRef.current.createHandler()(event); }; - // Load saved agent sessions for continuity (from project root or clone-specific storage) + // Load saved agent sessions only on retry; normal runs start with empty sessions const isWorktree = cwd !== projectCwd; const globalConfig = loadGlobalConfig(); const shouldNotify = globalConfig.notificationSound !== false; + const notificationSoundEvents = globalConfig.notificationSoundEvents; + const shouldNotifyIterationLimit = shouldNotify && notificationSoundEvents?.iterationLimit !== false; + const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false; + const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false; const currentProvider = globalConfig.provider ?? 'claude'; + const providerEventLogger = createProviderEventLogger({ + logsDir: runPaths.logsAbs, + sessionId: pieceSessionId, + runId: runSlug, + provider: currentProvider, + movement: options.startMovement ?? pieceConfig.initialMovement, + enabled: isProviderEventsEnabled(globalConfig), + }); // Prevent macOS idle sleep if configured if (globalConfig.preventSleep) { preventSleep(); } - const savedSessions = isWorktree - ? loadWorktreeSessions(projectCwd, cwd, currentProvider) - : loadPersonaSessions(projectCwd, currentProvider); + const savedSessions = isRetry + ? (isWorktree + ? loadWorktreeSessions(projectCwd, cwd, currentProvider) + : loadPersonaSessions(projectCwd, currentProvider)) + : {}; // Session update handler - persist session IDs when they change // Clone sessions are stored separately per clone path @@ -280,12 +348,12 @@ export async function executePiece( out.warn( getLabel('piece.iterationLimit.maxReached', undefined, { currentIteration: String(request.currentIteration), - maxIterations: String(request.maxIterations), + maxMovements: String(request.maxMovements), }) ); out.info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement })); - if (shouldNotify) { + if (shouldNotifyIterationLimit) { playWarningSound(); } @@ -310,7 +378,7 @@ export async function executePiece( const additionalIterations = Number.parseInt(input, 10); if (Number.isInteger(additionalIterations) && additionalIterations > 0) { - pieceConfig.maxIterations = request.maxIterations + additionalIterations; + pieceConfig.maxMovements = request.maxMovements + additionalIterations; return additionalIterations; } @@ -331,35 +399,42 @@ export async function executePiece( } : undefined; - const engine = new PieceEngine(pieceConfig, cwd, task, { - abortSignal: options.abortSignal, - onStream: streamHandler, - onUserInput, - initialSessions: savedSessions, - onSessionUpdate: sessionUpdateHandler, - onIterationLimit: iterationLimitHandler, - projectCwd, - language: options.language, - provider: options.provider, - model: options.model, - personaProviders: options.personaProviders, - interactive: interactiveUserInput, - detectRuleIndex, - callAiJudge, - startMovement: options.startMovement, - retryNote: options.retryNote, - taskPrefix: options.taskPrefix, - taskColorIndex: options.taskColorIndex, - }); - let abortReason: string | undefined; let lastMovementContent: string | undefined; let lastMovementName: string | undefined; let currentIteration = 0; const phasePrompts = new Map(); const movementIterations = new Map(); + let engine: PieceEngine | null = null; + let onAbortSignal: (() => void) | undefined; + let sigintCleanup: (() => void) | undefined; + let onEpipe: ((err: NodeJS.ErrnoException) => void) | undefined; + const runAbortController = new AbortController(); - engine.on('phase:start', (step, phase, phaseName, instruction) => { + try { + engine = new PieceEngine(pieceConfig, cwd, task, { + abortSignal: runAbortController.signal, + onStream: providerEventLogger.wrapCallback(streamHandler), + onUserInput, + initialSessions: savedSessions, + onSessionUpdate: sessionUpdateHandler, + onIterationLimit: iterationLimitHandler, + projectCwd, + language: options.language, + provider: options.provider, + model: options.model, + personaProviders: options.personaProviders, + interactive: interactiveUserInput, + detectRuleIndex, + callAiJudge, + startMovement: options.startMovement, + retryNote: options.retryNote, + reportDirName: runSlug, + taskPrefix: options.taskPrefix, + taskColorIndex: options.taskColorIndex, + }); + + engine.on('phase:start', (step, phase, phaseName, instruction) => { log.debug('Phase starting', { step: step.name, phase, phaseName }); const record: NdjsonPhaseStart = { type: 'phase_start', @@ -376,7 +451,7 @@ export async function executePiece( } }); - engine.on('phase:complete', (step, phase, phaseName, content, phaseStatus, phaseError) => { + engine.on('phase:complete', (step, phase, phaseName, content, phaseStatus, phaseError) => { log.debug('Phase completed', { step: step.name, phase, phaseName, status: phaseStatus }); const record: NdjsonPhaseComplete = { type: 'phase_complete', @@ -409,7 +484,7 @@ export async function executePiece( } }); - engine.on('movement:start', (step, iteration, instruction) => { + engine.on('movement:start', (step, iteration, instruction) => { log.debug('Movement starting', { step: step.name, persona: step.personaDisplayName, iteration }); currentIteration = iteration; const movementIteration = (movementIterations.get(step.name) ?? 0) + 1; @@ -417,10 +492,22 @@ export async function executePiece( prefixWriter?.setMovementContext({ movementName: step.name, iteration, - maxIterations: pieceConfig.maxIterations, + maxMovements: pieceConfig.maxMovements, movementIteration, }); - out.info(`[${iteration}/${pieceConfig.maxIterations}] ${step.name} (${step.personaDisplayName})`); + out.info(`[${iteration}/${pieceConfig.maxMovements}] ${step.name} (${step.personaDisplayName})`); + const resolved = resolveMovementProviderModel({ + step, + provider: options.provider, + model: options.model, + personaProviders: options.personaProviders, + }); + const movementProvider = resolved.provider ?? currentProvider; + const movementModel = resolved.model ?? globalConfig.model ?? '(default)'; + providerEventLogger.setMovement(step.name); + providerEventLogger.setProvider(movementProvider); + out.info(`Provider: ${movementProvider}`); + out.info(`Model: ${movementModel}`); // Log prompt content for debugging if (instruction) { @@ -438,7 +525,7 @@ export async function executePiece( const agentLabel = step.personaDisplayName; displayRef.current = new StreamDisplay(agentLabel, quiet, { iteration, - maxIterations: pieceConfig.maxIterations, + maxMovements: pieceConfig.maxMovements, movementIndex: movementIndex >= 0 ? movementIndex : 0, totalMovements, }); @@ -457,7 +544,7 @@ export async function executePiece( }); - engine.on('movement:complete', (step, response, instruction) => { + engine.on('movement:complete', (step, response, instruction) => { log.debug('Movement completed', { step: step.name, status: response.status, @@ -516,16 +603,15 @@ export async function executePiece( // Update in-memory log for pointer metadata (immutable) sessionLog = { ...sessionLog, iterations: sessionLog.iterations + 1 }; - updateLatestPointer(sessionLog, pieceSessionId, projectCwd); }); - engine.on('movement:report', (_step, filePath, fileName) => { + engine.on('movement:report', (_step, filePath, fileName) => { const content = readFileSync(filePath, 'utf-8'); out.logLine(`\n📄 Report: ${fileName}\n`); out.logLine(content); }); - engine.on('piece:complete', (state) => { + engine.on('piece:complete', (state) => { log.info('Piece completed successfully', { iterations: state.iteration }); sessionLog = finalizeSessionLog(sessionLog, 'completed'); @@ -536,7 +622,7 @@ export async function executePiece( endTime: new Date().toISOString(), }; appendNdjsonLine(ndjsonLogPath, record); - updateLatestPointer(sessionLog, pieceSessionId, projectCwd); + finalizeRunMeta('completed', state.iteration); // Save session state for next interactive mode try { @@ -560,12 +646,12 @@ export async function executePiece( out.success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`); out.info(`Session log: ${ndjsonLogPath}`); - if (shouldNotify) { + if (shouldNotifyPieceComplete) { notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) })); } }); - engine.on('piece:abort', (state, reason) => { + engine.on('piece:abort', (state, reason) => { interruptAllQueries(); log.error('Piece aborted', { reason, iterations: state.iteration }); if (displayRef.current) { @@ -584,7 +670,7 @@ export async function executePiece( endTime: new Date().toISOString(), }; appendNdjsonLine(ndjsonLogPath, record); - updateLatestPointer(sessionLog, pieceSessionId, projectCwd); + finalizeRunMeta('aborted', state.iteration); // Save session state for next interactive mode try { @@ -608,41 +694,46 @@ export async function executePiece( out.error(`Piece aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`); out.info(`Session log: ${ndjsonLogPath}`); - if (shouldNotify) { + if (shouldNotifyPieceAbort) { notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason })); } }); - // Suppress EPIPE errors from SDK child process stdin after interrupt. - // When interruptAllQueries() kills the child process, the SDK may still - // try to write to the dead process's stdin pipe, causing an unhandled - // EPIPE error on the Socket. This handler catches it gracefully. - const onEpipe = (err: NodeJS.ErrnoException) => { - if (err.code === 'EPIPE') return; - throw err; - }; - - const abortEngine = () => { - process.on('uncaughtException', onEpipe); - interruptAllQueries(); - engine.abort(); - }; - - // SIGINT handling: when abortSignal is provided (parallel mode), delegate to caller - const useExternalAbort = Boolean(options.abortSignal); + // Suppress EPIPE errors from SDK child process stdin after interrupt. + // When interruptAllQueries() kills the child process, the SDK may still + // try to write to the dead process's stdin pipe, causing an unhandled + // EPIPE error on the Socket. This handler catches it gracefully. + onEpipe = (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') return; + throw err; + }; - let onAbortSignal: (() => void) | undefined; - let sigintCleanup: (() => void) | undefined; + const abortEngine = () => { + if (!engine || !onEpipe) { + throw new Error('Abort handler invoked before PieceEngine initialization'); + } + if (!runAbortController.signal.aborted) { + runAbortController.abort(); + } + process.on('uncaughtException', onEpipe); + interruptAllQueries(); + engine.abort(); + }; - if (useExternalAbort) { - onAbortSignal = abortEngine; - options.abortSignal!.addEventListener('abort', onAbortSignal, { once: true }); - } else { - const handler = installSigIntHandler(abortEngine); - sigintCleanup = handler.cleanup; - } + // SIGINT handling: when abortSignal is provided (parallel mode), delegate to caller + const useExternalAbort = Boolean(options.abortSignal); + if (useExternalAbort) { + onAbortSignal = abortEngine; + if (options.abortSignal!.aborted) { + abortEngine(); + } else { + options.abortSignal!.addEventListener('abort', onAbortSignal, { once: true }); + } + } else { + const handler = installSigIntHandler(abortEngine); + sigintCleanup = handler.cleanup; + } - try { const finalState = await engine.run(); return { @@ -651,12 +742,19 @@ export async function executePiece( lastMovement: lastMovementName, lastMessage: lastMovementContent, }; + } catch (error) { + if (!isMetaFinalized) { + finalizeRunMeta('aborted'); + } + throw error; } finally { prefixWriter?.flush(); sigintCleanup?.(); if (onAbortSignal && options.abortSignal) { options.abortSignal.removeEventListener('abort', onAbortSignal); } - process.removeListener('uncaughtException', onEpipe); + if (onEpipe) { + process.removeListener('uncaughtException', onEpipe); + } } } diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 8a74c934..43d1e11d 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -2,14 +2,19 @@ * Resolve execution directory and piece from task data. */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { loadGlobalConfig } from '../../../infra/config/index.js'; import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; -import { info } from '../../../shared/ui/index.js'; +import { withProgress } from '../../../shared/ui/index.js'; +import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js'; export interface ResolvedTaskExecution { execCwd: string; execPiece: string; isWorktree: boolean; + taskPrompt?: string; + reportDirName?: string; branch?: string; baseBranch?: string; startMovement?: string; @@ -18,6 +23,36 @@ export interface ResolvedTaskExecution { issueNumber?: number; } +function buildRunTaskDirInstruction(reportDirName: string): string { + const runTaskDir = `.takt/runs/${reportDirName}/context/task`; + const orderFile = `${runTaskDir}/order.md`; + return [ + `Implement using only the files in \`${runTaskDir}\`.`, + `Primary spec: \`${orderFile}\`.`, + 'Use report files in Report Directory as primary execution history.', + 'Do not rely on previous response or conversation summary.', + ].join('\n'); +} + +function stageTaskSpecForExecution( + projectCwd: string, + execCwd: string, + taskDir: string, + reportDirName: string, +): string { + const sourceOrderPath = path.join(projectCwd, taskDir, 'order.md'); + if (!fs.existsSync(sourceOrderPath)) { + throw new Error(`Task spec file is missing: ${sourceOrderPath}`); + } + + const targetTaskDir = path.join(execCwd, '.takt', 'runs', reportDirName, 'context', 'task'); + const targetOrderPath = path.join(targetTaskDir, 'order.md'); + fs.mkdirSync(targetTaskDir, { recursive: true }); + fs.copyFileSync(sourceOrderPath, targetOrderPath); + + return buildRunTaskDirInstruction(reportDirName); +} + function throwIfAborted(signal?: AbortSignal): void { if (signal?.aborted) { throw new Error('Task execution aborted'); @@ -44,28 +79,47 @@ export async function resolveTaskExecution( let execCwd = defaultCwd; let isWorktree = false; + let reportDirName: string | undefined; + let taskPrompt: string | undefined; let branch: string | undefined; let baseBranch: string | undefined; + if (task.taskDir) { + const taskSlug = getTaskSlugFromTaskDir(task.taskDir); + if (!taskSlug) { + throw new Error(`Invalid task_dir format: ${task.taskDir}`); + } + reportDirName = taskSlug; + } if (data.worktree) { throwIfAborted(abortSignal); baseBranch = getCurrentBranch(defaultCwd); - info('Generating branch name...'); - const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd }); + const taskSlug = await withProgress( + 'Generating branch name...', + (slug) => `Branch name generated: ${slug}`, + () => summarizeTaskName(task.content, { cwd: defaultCwd }), + ); throwIfAborted(abortSignal); - info('Creating clone...'); - const result = createSharedClone(defaultCwd, { - worktree: data.worktree, - branch: data.branch, - taskSlug, - issueNumber: data.issue, - }); + const result = await withProgress( + 'Creating clone...', + (cloneResult) => `Clone created: ${cloneResult.path} (branch: ${cloneResult.branch})`, + async () => createSharedClone(defaultCwd, { + worktree: data.worktree!, + branch: data.branch, + taskSlug, + issueNumber: data.issue, + }), + ); throwIfAborted(abortSignal); execCwd = result.path; branch = result.branch; isWorktree = true; - info(`Clone created: ${result.path} (branch: ${result.branch})`); + + } + + if (task.taskDir && reportDirName) { + taskPrompt = stageTaskSpecForExecution(defaultCwd, execCwd, task.taskDir, reportDirName); } const execPiece = data.piece || defaultPiece; @@ -80,5 +134,17 @@ export async function resolveTaskExecution( autoPr = globalConfig.autoPr; } - return { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber: data.issue }; + return { + execCwd, + execPiece, + isWorktree, + ...(taskPrompt ? { taskPrompt } : {}), + ...(reportDirName ? { reportDirName } : {}), + ...(branch ? { branch } : {}), + ...(baseBranch ? { baseBranch } : {}), + ...(startMovement ? { startMovement } : {}), + ...(retryNote ? { retryNote } : {}), + ...(autoPr !== undefined ? { autoPr } : {}), + ...(data.issue !== undefined ? { issueNumber: data.issue } : {}), + }; } diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 94a7c52f..5816921b 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -19,7 +19,7 @@ import { import { confirm } from '../../../shared/prompt/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; -import { info, error, success } from '../../../shared/ui/index.js'; +import { info, error, success, withProgress } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.js'; import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js'; import { executeTask } from './taskExecution.js'; @@ -113,15 +113,20 @@ export async function confirmAndCreateWorktree( const baseBranch = getCurrentBranch(cwd); - info('Generating branch name...'); - const taskSlug = await summarizeTaskName(task, { cwd }); + const taskSlug = await withProgress( + 'Generating branch name...', + (slug) => `Branch name generated: ${slug}`, + () => summarizeTaskName(task, { cwd }), + ); - info('Creating clone...'); - const result = createSharedClone(cwd, { - worktree: true, - taskSlug, - }); - info(`Clone created: ${result.path} (branch: ${result.branch})`); + const result = await withProgress( + 'Creating clone...', + (cloneResult) => `Clone created: ${cloneResult.path} (branch: ${cloneResult.branch})`, + async () => createSharedClone(cwd, { + worktree: true, + taskSlug, + }), + ); return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch }; } diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 876652cf..14c4a0de 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -12,7 +12,8 @@ import { status, blankLine, } from '../../../shared/ui/index.js'; -import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { createLogger, getErrorMessage, getSlackWebhookUrl, notifyError, notifySuccess, sendSlackNotification } from '../../../shared/utils/index.js'; +import { getLabel } from '../../../shared/i18n/index.js'; import { executePiece } from './pieceExecution.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js'; @@ -49,7 +50,7 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType { - const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, abortSignal, taskPrefix, taskColorIndex } = options; + const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, reportDirName, abortSignal, taskPrefix, taskColorIndex } = options; const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd); if (!pieceConfig) { @@ -80,6 +81,7 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise1) execution through the same code path. @@ -239,7 +254,12 @@ export async function runAllTasks( ): Promise { const taskRunner = new TaskRunner(cwd); const globalConfig = loadGlobalConfig(); + const shouldNotifyRunComplete = globalConfig.notificationSound !== false + && globalConfig.notificationSoundEvents?.runComplete !== false; + const shouldNotifyRunAbort = globalConfig.notificationSound !== false + && globalConfig.notificationSoundEvents?.runAbort !== false; const concurrency = globalConfig.concurrency; + const slackWebhookUrl = getSlackWebhookUrl(); const recovered = taskRunner.recoverInterruptedRunningTasks(); if (recovered > 0) { info(`Recovered ${recovered} interrupted running task(s) to pending.`); @@ -258,15 +278,39 @@ export async function runAllTasks( info(`Concurrency: ${concurrency}`); } - const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs); + try { + const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs); + + const totalCount = result.success + result.fail; + blankLine(); + header('Tasks Summary'); + status('Total', String(totalCount)); + status('Success', String(result.success), result.success === totalCount ? 'green' : undefined); + if (result.fail > 0) { + status('Failed', String(result.fail), 'red'); + if (shouldNotifyRunAbort) { + notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: String(result.fail) })); + } + if (slackWebhookUrl) { + await sendSlackNotification(slackWebhookUrl, `TAKT Run finished with errors: ${String(result.fail)} failed out of ${String(totalCount)} tasks`); + } + return; + } - const totalCount = result.success + result.fail; - blankLine(); - header('Tasks Summary'); - status('Total', String(totalCount)); - status('Success', String(result.success), result.success === totalCount ? 'green' : undefined); - if (result.fail > 0) { - status('Failed', String(result.fail), 'red'); + if (shouldNotifyRunComplete) { + notifySuccess('TAKT', getLabel('run.notifyComplete', undefined, { total: String(totalCount) })); + } + if (slackWebhookUrl) { + await sendSlackNotification(slackWebhookUrl, `TAKT Run complete: ${String(totalCount)} tasks succeeded`); + } + } catch (e) { + if (shouldNotifyRunAbort) { + notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: getErrorMessage(e) })); + } + if (slackWebhookUrl) { + await sendSlackNotification(slackWebhookUrl, `TAKT Run error: ${getErrorMessage(e)}`); + } + throw e; } } diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index 5a8d946c..37896cd4 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -42,6 +42,8 @@ export interface PieceExecutionOptions { startMovement?: string; /** Retry note explaining why task is being retried */ retryNote?: string; + /** Override report directory name (e.g. "20260201-015714-foptng") */ + reportDirName?: string; /** External abort signal for parallel execution — when provided, SIGINT handling is delegated to caller */ abortSignal?: AbortSignal; /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ @@ -74,6 +76,8 @@ export interface ExecuteTaskOptions { startMovement?: string; /** Retry note explaining why task is being retried */ retryNote?: string; + /** Override report directory name (e.g. "20260201-015714-foptng") */ + reportDirName?: string; /** External abort signal for parallel execution — when provided, SIGINT handling is delegated to caller */ abortSignal?: AbortSignal; /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ diff --git a/src/features/tasks/index.ts b/src/features/tasks/index.ts index e276807a..f413860e 100644 --- a/src/features/tasks/index.ts +++ b/src/features/tasks/index.ts @@ -14,7 +14,7 @@ export { type SelectAndExecuteOptions, type WorktreeConfirmationResult, } from './execute/selectAndExecute.js'; -export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask } from './add/index.js'; +export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask, createIssueAndSaveTask } from './add/index.js'; export { watchTasks } from './watch/index.js'; export { listTasks, diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 15756458..4d267320 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -2,7 +2,7 @@ * List tasks command — main entry point. * * Interactive UI for reviewing branch-based task results, - * pending tasks (.takt/tasks/), and failed tasks (.takt/failed/). + * pending tasks (.takt/tasks.yaml), and failed tasks. * Individual actions (merge, delete, instruct, diff) are in taskActions.ts. * Task delete actions are in taskDeleteActions.ts. * Non-interactive mode is in listNonInteractive.ts. diff --git a/src/infra/claude/index.ts b/src/infra/claude/index.ts index 8e8060db..5e2cb5c2 100644 --- a/src/infra/claude/index.ts +++ b/src/infra/claude/index.ts @@ -70,3 +70,4 @@ export { detectRuleIndex, isRegexSafe, } from './client.js'; + diff --git a/src/infra/claude/session-reader.ts b/src/infra/claude/session-reader.ts new file mode 100644 index 00000000..82e6b1ea --- /dev/null +++ b/src/infra/claude/session-reader.ts @@ -0,0 +1,123 @@ +/** + * Claude Code session reader + * + * Reads Claude Code's sessions-index.json and individual .jsonl session files + * to extract session metadata and last assistant responses. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getClaudeProjectSessionsDir } from '../config/project/sessionStore.js'; + +/** Entry in Claude Code's sessions-index.json */ +export interface SessionIndexEntry { + sessionId: string; + firstPrompt: string; + modified: string; + messageCount: number; + gitBranch: string; + isSidechain: boolean; + fullPath: string; +} + +/** Shape of sessions-index.json */ +interface SessionsIndex { + version: number; + entries: SessionIndexEntry[]; +} + +/** + * Load the session index for a project directory. + * + * Reads ~/.claude/projects/{encoded-path}/sessions-index.json, + * filters out sidechain sessions, and sorts by modified descending. + */ +export function loadSessionIndex(projectDir: string): SessionIndexEntry[] { + const sessionsDir = getClaudeProjectSessionsDir(projectDir); + const indexPath = join(sessionsDir, 'sessions-index.json'); + + if (!existsSync(indexPath)) { + return []; + } + + const content = readFileSync(indexPath, 'utf-8'); + + let index: SessionsIndex; + try { + index = JSON.parse(content) as SessionsIndex; + } catch { + return []; + } + + if (!index.entries || !Array.isArray(index.entries)) { + return []; + } + + return index.entries + .filter((entry) => !entry.isSidechain) + .sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()); +} + +/** Content block with text type from Claude API */ +interface TextContentBlock { + type: 'text'; + text: string; +} + +/** Message structure in JSONL records */ +interface AssistantMessage { + content: Array; +} + +/** JSONL record for assistant messages */ +interface SessionRecord { + type: string; + message?: AssistantMessage; +} + +/** + * Extract the last assistant text response from a session JSONL file. + * + * Reads the file and scans from the end to find the last `type: "assistant"` + * record with a text content block. Returns the truncated text. + */ +export function extractLastAssistantResponse(sessionFilePath: string, maxLength: number): string | null { + if (!existsSync(sessionFilePath)) { + return null; + } + + const content = readFileSync(sessionFilePath, 'utf-8'); + const lines = content.split('\n').filter((line) => line.trim()); + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line) continue; + + let record: SessionRecord; + try { + record = JSON.parse(line) as SessionRecord; + } catch { + continue; + } + + if (record.type !== 'assistant' || !record.message?.content) { + continue; + } + + const textBlocks = record.message.content.filter( + (block): block is TextContentBlock => block.type === 'text', + ); + + if (textBlocks.length === 0) { + continue; + } + + const fullText = textBlocks.map((b) => b.text).join('\n'); + if (fullText.length <= maxLength) { + return fullText; + } + return fullText.slice(0, maxLength) + '…'; + } + + return null; +} diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 32f4280e..763f138a 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -11,20 +11,33 @@ import { GlobalConfigSchema } from '../../../core/models/index.js'; import type { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js'; import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js'; import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; +import { parseProviderModel } from '../../../shared/utils/providerModel.js'; /** Claude-specific model aliases that are not valid for other providers */ const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']); /** Validate that provider and model are compatible */ function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void { - if (!provider || !model) return; + if (!provider) return; - if (provider === 'codex' && CLAUDE_MODEL_ALIASES.has(model)) { + if (provider === 'opencode' && !model) { + throw new Error( + "Configuration error: provider 'opencode' requires model in 'provider/model' format (e.g. 'opencode/big-pickle')." + ); + } + + if (!model) return; + + if ((provider === 'codex' || provider === 'opencode') && CLAUDE_MODEL_ALIASES.has(model)) { throw new Error( `Configuration error: model '${model}' is a Claude model alias but provider is '${provider}'. ` + - `Either change the provider to 'claude' or specify a Codex-compatible model.` + `Either change the provider to 'claude' or specify a ${provider}-compatible model.` ); } + + if (provider === 'opencode') { + parseProviderModel(model, "Configuration error: model"); + } } /** Create default global configuration (fresh instance each call) */ @@ -92,12 +105,16 @@ export class GlobalConfigManager { enabled: parsed.debug.enabled, logFile: parsed.debug.log_file, } : undefined, + observability: parsed.observability ? { + providerEvents: parsed.observability.provider_events, + } : undefined, worktreeDir: parsed.worktree_dir, autoPr: parsed.auto_pr, disabledBuiltins: parsed.disabled_builtins, enableBuiltinPieces: parsed.enable_builtin_pieces, anthropicApiKey: parsed.anthropic_api_key, openaiApiKey: parsed.openai_api_key, + opencodeApiKey: parsed.opencode_api_key, pipeline: parsed.pipeline ? { defaultBranchPrefix: parsed.pipeline.default_branch_prefix, commitMessageTemplate: parsed.pipeline.commit_message_template, @@ -110,6 +127,13 @@ export class GlobalConfigManager { branchNameStrategy: parsed.branch_name_strategy, preventSleep: parsed.prevent_sleep, notificationSound: parsed.notification_sound, + notificationSoundEvents: parsed.notification_sound_events ? { + iterationLimit: parsed.notification_sound_events.iteration_limit, + pieceComplete: parsed.notification_sound_events.piece_complete, + pieceAbort: parsed.notification_sound_events.piece_abort, + runComplete: parsed.notification_sound_events.run_complete, + runAbort: parsed.notification_sound_events.run_abort, + } : undefined, interactivePreviewMovements: parsed.interactive_preview_movements, concurrency: parsed.concurrency, taskPollIntervalMs: parsed.task_poll_interval_ms, @@ -137,6 +161,11 @@ export class GlobalConfigManager { log_file: config.debug.logFile, }; } + if (config.observability && config.observability.providerEvents !== undefined) { + raw.observability = { + provider_events: config.observability.providerEvents, + }; + } if (config.worktreeDir) { raw.worktree_dir = config.worktreeDir; } @@ -155,6 +184,9 @@ export class GlobalConfigManager { if (config.openaiApiKey) { raw.openai_api_key = config.openaiApiKey; } + if (config.opencodeApiKey) { + raw.opencode_api_key = config.opencodeApiKey; + } if (config.pipeline) { const pipelineRaw: Record = {}; if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix; @@ -185,6 +217,27 @@ export class GlobalConfigManager { if (config.notificationSound !== undefined) { raw.notification_sound = config.notificationSound; } + if (config.notificationSoundEvents) { + const eventRaw: Record = {}; + if (config.notificationSoundEvents.iterationLimit !== undefined) { + eventRaw.iteration_limit = config.notificationSoundEvents.iterationLimit; + } + if (config.notificationSoundEvents.pieceComplete !== undefined) { + eventRaw.piece_complete = config.notificationSoundEvents.pieceComplete; + } + if (config.notificationSoundEvents.pieceAbort !== undefined) { + eventRaw.piece_abort = config.notificationSoundEvents.pieceAbort; + } + if (config.notificationSoundEvents.runComplete !== undefined) { + eventRaw.run_complete = config.notificationSoundEvents.runComplete; + } + if (config.notificationSoundEvents.runAbort !== undefined) { + eventRaw.run_abort = config.notificationSoundEvents.runAbort; + } + if (Object.keys(eventRaw).length > 0) { + raw.notification_sound_events = eventRaw; + } + } if (config.interactivePreviewMovements !== undefined) { raw.interactive_preview_movements = config.interactivePreviewMovements; } @@ -244,7 +297,7 @@ export function setLanguage(language: Language): void { saveGlobalConfig(config); } -export function setProvider(provider: 'claude' | 'codex'): void { +export function setProvider(provider: 'claude' | 'codex' | 'opencode'): void { const config = loadGlobalConfig(); config.provider = provider; saveGlobalConfig(config); @@ -282,6 +335,22 @@ export function resolveOpenaiApiKey(): string | undefined { } } +/** + * Resolve the OpenCode API key. + * Priority: TAKT_OPENCODE_API_KEY env var > config.yaml > undefined + */ +export function resolveOpencodeApiKey(): string | undefined { + const envKey = process.env['TAKT_OPENCODE_API_KEY']; + if (envKey) return envKey; + + try { + const config = loadGlobalConfig(); + return config.opencodeApiKey; + } catch { + return undefined; + } +} + /** Load project-level debug configuration (from .takt/config.yaml) */ export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined { const configPath = getProjectConfigPath(projectDir); diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index 30b0b937..7f442b09 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -14,6 +14,7 @@ export { setProvider, resolveAnthropicApiKey, resolveOpenaiApiKey, + resolveOpencodeApiKey, loadProjectDebugConfig, getEffectiveDebugConfig, } from './globalConfig.js'; diff --git a/src/infra/config/global/initialization.ts b/src/infra/config/global/initialization.ts index 1caf1c58..ec5656a9 100644 --- a/src/infra/config/global/initialization.ts +++ b/src/infra/config/global/initialization.ts @@ -56,14 +56,15 @@ export async function promptLanguageSelection(): Promise { * Prompt user to select provider for resources. * Exits process if cancelled (initial setup is required). */ -export async function promptProviderSelection(): Promise<'claude' | 'codex'> { - const options: { label: string; value: 'claude' | 'codex' }[] = [ +export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode'> { + const options: { label: string; value: 'claude' | 'codex' | 'opencode' }[] = [ { label: 'Claude Code', value: 'claude' }, { label: 'Codex', value: 'codex' }, + { label: 'OpenCode', value: 'opencode' }, ]; const result = await selectOptionWithDefault( - 'Select provider (Claude Code or Codex) / プロバイダーを選択してください:', + 'Select provider / プロバイダーを選択してください:', options, 'claude' ); diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 87d5039b..bd5f6df5 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -6,11 +6,11 @@ */ import { readFileSync, existsSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; import type { z } from 'zod'; import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js'; -import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractLabelPath, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge } from '../../../core/models/index.js'; +import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractLabelPath, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge, ArpeggioMovementConfig, ArpeggioMergeMovementConfig } from '../../../core/models/index.js'; import { getLanguage } from '../global/globalConfig.js'; import { type PieceSections, @@ -150,6 +150,35 @@ function normalizeRule(r: { }; } +/** Normalize raw arpeggio config from YAML into internal format. */ +function normalizeArpeggio( + raw: RawStep['arpeggio'], + pieceDir: string, +): ArpeggioMovementConfig | undefined { + if (!raw) return undefined; + + const merge: ArpeggioMergeMovementConfig = raw.merge + ? { + strategy: raw.merge.strategy, + inlineJs: raw.merge.inline_js, + filePath: raw.merge.file ? resolve(pieceDir, raw.merge.file) : undefined, + separator: raw.merge.separator, + } + : { strategy: 'concat' }; + + return { + source: raw.source, + sourcePath: resolve(pieceDir, raw.source_path), + batchSize: raw.batch_size, + concurrency: raw.concurrency, + templatePath: resolve(pieceDir, raw.template), + merge, + maxRetries: raw.max_retries, + retryDelayMs: raw.retry_delay_ms, + outputPath: raw.output_path ? resolve(pieceDir, raw.output_path) : undefined, + }; +} + /** Normalize a raw step into internal PieceMovement format. */ function normalizeStepFromRaw( step: RawStep, @@ -203,6 +232,11 @@ function normalizeStepFromRaw( result.parallel = step.parallel.map((sub: RawStep) => normalizeStepFromRaw(sub, pieceDir, sections, context)); } + const arpeggioConfig = normalizeArpeggio(step.arpeggio, pieceDir); + if (arpeggioConfig) { + result.arpeggio = arpeggioConfig; + } + return result; } @@ -280,7 +314,7 @@ export function normalizePieceConfig( reportFormats: resolvedReportFormats, movements, initialMovement, - maxIterations: parsed.max_iterations, + maxMovements: parsed.max_movements, loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir, sections, context), answerAgent: parsed.answer_agent, interactiveMode: parsed.interactive_mode, diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index 4d227f98..f29d5374 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -17,7 +17,7 @@ export interface ProjectLocalConfig { /** Current piece name */ piece?: string; /** Provider selection for agent runtime */ - provider?: 'claude' | 'codex'; + provider?: 'claude' | 'codex' | 'opencode'; /** Permission mode setting */ permissionMode?: PermissionMode; /** Verbose output mode */ diff --git a/src/infra/fs/index.ts b/src/infra/fs/index.ts index ee350db0..1b143ae2 100644 --- a/src/infra/fs/index.ts +++ b/src/infra/fs/index.ts @@ -14,7 +14,6 @@ export type { NdjsonInteractiveStart, NdjsonInteractiveEnd, NdjsonRecord, - LatestLogPointer, } from './session.js'; export { @@ -28,5 +27,4 @@ export { finalizeSessionLog, loadSessionLog, loadProjectContext, - updateLatestPointer, } from './session.js'; diff --git a/src/infra/fs/session.ts b/src/infra/fs/session.ts index ca1ed833..2e4660ed 100644 --- a/src/infra/fs/session.ts +++ b/src/infra/fs/session.ts @@ -2,15 +2,14 @@ * Session management utilities */ -import { existsSync, readFileSync, copyFileSync, appendFileSync } from 'node:fs'; +import { existsSync, readFileSync, appendFileSync } from 'node:fs'; import { join } from 'node:path'; -import { getProjectLogsDir, getGlobalLogsDir, ensureDir, writeFileAtomic } from '../config/index.js'; +import { ensureDir } from '../config/index.js'; import { generateReportDir as buildReportDir } from '../../shared/utils/index.js'; import type { SessionLog, NdjsonRecord, NdjsonPieceStart, - LatestLogPointer, } from '../../shared/utils/index.js'; export type { @@ -25,7 +24,6 @@ export type { NdjsonInteractiveStart, NdjsonInteractiveEnd, NdjsonRecord, - LatestLogPointer, } from '../../shared/utils/index.js'; /** Failure information extracted from session log */ @@ -44,7 +42,7 @@ export interface FailureInfo { /** * Manages session lifecycle: ID generation, NDJSON logging, - * session log creation/loading, and latest pointer maintenance. + * and session log creation/loading. */ export class SessionManager { /** Append a single NDJSON line to a log file */ @@ -58,11 +56,9 @@ export class SessionManager { sessionId: string, task: string, pieceName: string, - projectDir?: string, + options: { logsDir: string }, ): string { - const logsDir = projectDir - ? getProjectLogsDir(projectDir) - : getGlobalLogsDir(); + const { logsDir } = options; ensureDir(logsDir); const filepath = join(logsDir, `${sessionId}.jsonl`); @@ -218,38 +214,6 @@ export class SessionManager { return contextParts.join('\n\n---\n\n'); } - /** Update latest.json pointer file */ - updateLatestPointer( - log: SessionLog, - sessionId: string, - projectDir?: string, - options?: { copyToPrevious?: boolean }, - ): void { - const logsDir = projectDir - ? getProjectLogsDir(projectDir) - : getGlobalLogsDir(); - ensureDir(logsDir); - - const latestPath = join(logsDir, 'latest.json'); - const previousPath = join(logsDir, 'previous.json'); - - if (options?.copyToPrevious && existsSync(latestPath)) { - copyFileSync(latestPath, previousPath); - } - - const pointer: LatestLogPointer = { - sessionId, - logFile: `${sessionId}.jsonl`, - task: log.task, - pieceName: log.pieceName, - status: log.status, - startTime: log.startTime, - updatedAt: new Date().toISOString(), - iterations: log.iterations, - }; - - writeFileAtomic(latestPath, JSON.stringify(pointer, null, 2)); - } } const defaultManager = new SessionManager(); @@ -262,9 +226,9 @@ export function initNdjsonLog( sessionId: string, task: string, pieceName: string, - projectDir?: string, + options: { logsDir: string }, ): string { - return defaultManager.initNdjsonLog(sessionId, task, pieceName, projectDir); + return defaultManager.initNdjsonLog(sessionId, task, pieceName, options); } @@ -304,15 +268,6 @@ export function loadProjectContext(projectDir: string): string { return defaultManager.loadProjectContext(projectDir); } -export function updateLatestPointer( - log: SessionLog, - sessionId: string, - projectDir?: string, - options?: { copyToPrevious?: boolean }, -): void { - defaultManager.updateLatestPointer(log, sessionId, projectDir, options); -} - /** * Extract failure information from an NDJSON session log file. * diff --git a/src/infra/opencode/OpenCodeStreamHandler.ts b/src/infra/opencode/OpenCodeStreamHandler.ts new file mode 100644 index 00000000..6b312edf --- /dev/null +++ b/src/infra/opencode/OpenCodeStreamHandler.ts @@ -0,0 +1,286 @@ +/** + * OpenCode stream event handling. + * + * Converts OpenCode SDK SSE events into the unified StreamCallback format + * used throughout the takt codebase. + */ + +import type { StreamCallback } from '../claude/index.js'; + +/** Subset of OpenCode Part types relevant for stream handling */ +export interface OpenCodeTextPart { + id: string; + type: 'text'; + text: string; +} + +export interface OpenCodeReasoningPart { + id: string; + type: 'reasoning'; + text: string; +} + +export interface OpenCodeToolPart { + id: string; + type: 'tool'; + callID: string; + tool: string; + state: OpenCodeToolState; +} + +export type OpenCodeToolState = + | { status: 'pending'; input: Record } + | { status: 'running'; input: Record; title?: string } + | { status: 'completed'; input: Record; output: string; title: string } + | { status: 'error'; input: Record; error: string }; + +export type OpenCodePart = OpenCodeTextPart | OpenCodeReasoningPart | OpenCodeToolPart | { id: string; type: string }; + +/** OpenCode SSE event types relevant for stream handling */ +export interface OpenCodeMessagePartUpdatedEvent { + type: 'message.part.updated'; + properties: { part: OpenCodePart; delta?: string }; +} + +export interface OpenCodeSessionIdleEvent { + type: 'session.idle'; + properties: { sessionID: string }; +} + +export interface OpenCodeSessionStatusEvent { + type: 'session.status'; + properties: { + sessionID: string; + status: { type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number }; + }; +} + +export interface OpenCodeSessionErrorEvent { + type: 'session.error'; + properties: { + sessionID?: string; + error?: { name: string; data: { message: string } }; + }; +} + +export interface OpenCodeMessageUpdatedEvent { + type: 'message.updated'; + properties: { + info: { + sessionID: string; + role: 'assistant' | 'user'; + time?: { created?: number; completed?: number }; + error?: unknown; + }; + }; +} + +export interface OpenCodeMessageCompletedEvent { + type: 'message.completed'; + properties: { + info: { + sessionID: string; + role: 'assistant' | 'user'; + error?: unknown; + }; + }; +} + +export interface OpenCodeMessageFailedEvent { + type: 'message.failed'; + properties: { + info: { + sessionID: string; + role: 'assistant' | 'user'; + error?: unknown; + }; + }; +} + +export interface OpenCodePermissionAskedEvent { + type: 'permission.asked'; + properties: { + id: string; + sessionID: string; + permission: string; + patterns: string[]; + metadata: Record; + always: string[]; + }; +} + +export interface OpenCodeQuestionAskedEvent { + type: 'question.asked'; + properties: { + id: string; + sessionID: string; + questions: Array<{ + question: string; + header: string; + options: Array<{ + label: string; + description: string; + }>; + multiple?: boolean; + }>; + }; +} + +export type OpenCodeStreamEvent = + | OpenCodeMessagePartUpdatedEvent + | OpenCodeMessageUpdatedEvent + | OpenCodeMessageCompletedEvent + | OpenCodeMessageFailedEvent + | OpenCodeSessionStatusEvent + | OpenCodeSessionIdleEvent + | OpenCodeSessionErrorEvent + | OpenCodePermissionAskedEvent + | OpenCodeQuestionAskedEvent + | { type: string; properties: Record }; + +/** Tracking state for stream offsets during a single OpenCode session */ +export interface StreamTrackingState { + textOffsets: Map; + thinkingOffsets: Map; + startedTools: Set; +} + +export function createStreamTrackingState(): StreamTrackingState { + return { + textOffsets: new Map(), + thinkingOffsets: new Map(), + startedTools: new Set(), + }; +} + +// ---- Stream emission helpers ---- + +export function emitInit( + onStream: StreamCallback | undefined, + model: string, + sessionId: string, +): void { + if (!onStream) return; + onStream({ + type: 'init', + data: { + model, + sessionId, + }, + }); +} + +export function emitText(onStream: StreamCallback | undefined, text: string): void { + if (!onStream || !text) return; + onStream({ type: 'text', data: { text } }); +} + +export function emitThinking(onStream: StreamCallback | undefined, thinking: string): void { + if (!onStream || !thinking) return; + onStream({ type: 'thinking', data: { thinking } }); +} + +export function emitToolUse( + onStream: StreamCallback | undefined, + tool: string, + input: Record, + id: string, +): void { + if (!onStream) return; + onStream({ type: 'tool_use', data: { tool, input, id } }); +} + +export function emitToolResult( + onStream: StreamCallback | undefined, + content: string, + isError: boolean, +): void { + if (!onStream) return; + onStream({ type: 'tool_result', data: { content, isError } }); +} + +export function emitResult( + onStream: StreamCallback | undefined, + success: boolean, + result: string, + sessionId: string, +): void { + if (!onStream) return; + onStream({ + type: 'result', + data: { + result, + sessionId, + success, + error: success ? undefined : result || undefined, + }, + }); +} + +/** Process a message.part.updated event and emit appropriate stream events */ +export function handlePartUpdated( + part: OpenCodePart, + delta: string | undefined, + onStream: StreamCallback | undefined, + state: StreamTrackingState, +): void { + if (!onStream) return; + + switch (part.type) { + case 'text': { + const textPart = part as OpenCodeTextPart; + if (delta) { + emitText(onStream, delta); + } else { + const prev = state.textOffsets.get(textPart.id) ?? 0; + if (textPart.text.length > prev) { + emitText(onStream, textPart.text.slice(prev)); + state.textOffsets.set(textPart.id, textPart.text.length); + } + } + break; + } + case 'reasoning': { + const reasoningPart = part as OpenCodeReasoningPart; + if (delta) { + emitThinking(onStream, delta); + } else { + const prev = state.thinkingOffsets.get(reasoningPart.id) ?? 0; + if (reasoningPart.text.length > prev) { + emitThinking(onStream, reasoningPart.text.slice(prev)); + state.thinkingOffsets.set(reasoningPart.id, reasoningPart.text.length); + } + } + break; + } + case 'tool': { + const toolPart = part as OpenCodeToolPart; + handleToolPartUpdated(toolPart, onStream, state); + break; + } + default: + break; + } +} + +function handleToolPartUpdated( + toolPart: OpenCodeToolPart, + onStream: StreamCallback, + state: StreamTrackingState, +): void { + const toolId = toolPart.callID || toolPart.id; + + if (!state.startedTools.has(toolId)) { + emitToolUse(onStream, toolPart.tool, toolPart.state.input, toolId); + state.startedTools.add(toolId); + } + + switch (toolPart.state.status) { + case 'completed': + emitToolResult(onStream, toolPart.state.output, false); + break; + case 'error': + emitToolResult(onStream, toolPart.state.error, true); + break; + } +} diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts new file mode 100644 index 00000000..d8537979 --- /dev/null +++ b/src/infra/opencode/client.ts @@ -0,0 +1,662 @@ +/** + * OpenCode SDK integration for agent interactions + * + * Uses @opencode-ai/sdk/v2 for native TypeScript integration. + * Follows the same patterns as the Codex client. + */ + +import { createOpencode } from '@opencode-ai/sdk/v2'; +import { createServer } from 'node:net'; +import type { AgentResponse } from '../../core/models/index.js'; +import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; +import { parseProviderModel } from '../../shared/utils/providerModel.js'; +import { + buildOpenCodePermissionConfig, + buildOpenCodePermissionRuleset, + mapToOpenCodePermissionReply, + mapToOpenCodeTools, + type OpenCodeCallOptions, +} from './types.js'; +import { + type OpenCodeStreamEvent, + type OpenCodePart, + type OpenCodeTextPart, + createStreamTrackingState, + emitInit, + emitText, + emitResult, + handlePartUpdated, +} from './OpenCodeStreamHandler.js'; + +export type { OpenCodeCallOptions } from './types.js'; + +const log = createLogger('opencode-sdk'); +const OPENCODE_STREAM_IDLE_TIMEOUT_MS = 10 * 60 * 1000; +const OPENCODE_STREAM_ABORTED_MESSAGE = 'OpenCode execution aborted'; +const OPENCODE_RETRY_MAX_ATTEMPTS = 3; +const OPENCODE_RETRY_BASE_DELAY_MS = 250; +const OPENCODE_INTERACTION_TIMEOUT_MS = 5000; +const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ + 'stream disconnected before completion', + 'transport error', + 'network error', + 'error decoding response body', + 'econnreset', + 'etimedout', + 'eai_again', + 'fetch failed', + 'failed to start server on port', +]; + +async function withTimeout( + operation: (signal: AbortSignal) => Promise, + timeoutMs: number, + timeoutErrorMessage: string, +): Promise { + const controller = new AbortController(); + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + controller.abort(); + reject(new Error(timeoutErrorMessage)); + }, timeoutMs); + }); + try { + return await Promise.race([ + operation(controller.signal), + timeoutPromise, + ]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } +} + +function extractOpenCodeErrorMessage(error: unknown): string | undefined { + if (!error || typeof error !== 'object') { + return undefined; + } + const value = error as { message?: unknown; data?: { message?: unknown }; name?: unknown }; + if (typeof value.message === 'string' && value.message.length > 0) { + return value.message; + } + if (typeof value.data?.message === 'string' && value.data.message.length > 0) { + return value.data.message; + } + if (typeof value.name === 'string' && value.name.length > 0) { + return value.name; + } + return undefined; +} + +function getCommonPrefixLength(a: string, b: string): number { + const max = Math.min(a.length, b.length); + let i = 0; + while (i < max && a[i] === b[i]) { + i += 1; + } + return i; +} + +function stripPromptEcho( + chunk: string, + echoState: { remainingPrompt: string }, +): string { + if (!chunk) return ''; + if (!echoState.remainingPrompt) return chunk; + + const consumeLength = getCommonPrefixLength(chunk, echoState.remainingPrompt); + if (consumeLength > 0) { + echoState.remainingPrompt = echoState.remainingPrompt.slice(consumeLength); + return chunk.slice(consumeLength); + } + + return chunk; +} + +type OpenCodeQuestionOption = { + label: string; + description: string; +}; + +type OpenCodeQuestionInfo = { + question: string; + header: string; + options: OpenCodeQuestionOption[]; + multiple?: boolean; +}; + +type OpenCodeQuestionAskedProperties = { + id: string; + sessionID: string; + questions: OpenCodeQuestionInfo[]; +}; + +function toQuestionInput(props: OpenCodeQuestionAskedProperties): { + questions: Array<{ + question: string; + header?: string; + options?: Array<{ + label: string; + description?: string; + }>; + multiSelect?: boolean; + }>; +} { + return { + questions: props.questions.map((item) => ({ + question: item.question, + header: item.header, + options: item.options.map((opt) => ({ + label: opt.label, + description: opt.description, + })), + multiSelect: item.multiple, + })), + }; +} + +function toQuestionAnswers( + props: OpenCodeQuestionAskedProperties, + answers: Record, +): Array> { + return props.questions.map((item) => { + const key = item.header || item.question; + const value = answers[key]; + if (!value) return []; + return [value]; + }); +} + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') { + server.close(() => reject(new Error('Failed to allocate free TCP port'))); + return; + } + const port = addr.port; + server.close((err) => { + if (err) { + reject(err); + return; + } + resolve(port); + }); + }); + }); +} + +/** + * Client for OpenCode SDK agent interactions. + * + * Handles session management, streaming event conversion, + * permission auto-reply, and response processing. + */ +export class OpenCodeClient { + private isRetriableError(message: string, aborted: boolean, abortCause?: 'timeout' | 'external'): boolean { + if (aborted || abortCause) { + return false; + } + + const lower = message.toLowerCase(); + return OPENCODE_RETRYABLE_ERROR_PATTERNS.some((pattern) => lower.includes(pattern)); + } + + private async waitForRetryDelay(attempt: number, signal?: AbortSignal): Promise { + const delayMs = OPENCODE_RETRY_BASE_DELAY_MS * (2 ** Math.max(0, attempt - 1)); + await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + if (signal) { + signal.removeEventListener('abort', onAbort); + } + resolve(); + }, delayMs); + + const onAbort = (): void => { + clearTimeout(timeoutId); + if (signal) { + signal.removeEventListener('abort', onAbort); + } + reject(new Error(OPENCODE_STREAM_ABORTED_MESSAGE)); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + }); + } + + /** Call OpenCode with an agent prompt */ + async call( + agentType: string, + prompt: string, + options: OpenCodeCallOptions, + ): Promise { + const fullPrompt = options.systemPrompt + ? `${options.systemPrompt}\n\n${prompt}` + : prompt; + + for (let attempt = 1; attempt <= OPENCODE_RETRY_MAX_ATTEMPTS; attempt++) { + let idleTimeoutId: ReturnType | undefined; + const streamAbortController = new AbortController(); + const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; + let abortCause: 'timeout' | 'external' | undefined; + let serverClose: (() => void) | undefined; + let opencodeApiClient: Awaited>['client'] | undefined; + + const resetIdleTimeout = (): void => { + if (idleTimeoutId !== undefined) { + clearTimeout(idleTimeoutId); + } + idleTimeoutId = setTimeout(() => { + abortCause = 'timeout'; + streamAbortController.abort(); + }, OPENCODE_STREAM_IDLE_TIMEOUT_MS); + }; + + const onExternalAbort = (): void => { + abortCause = 'external'; + streamAbortController.abort(); + }; + + if (options.abortSignal) { + if (options.abortSignal.aborted) { + streamAbortController.abort(); + } else { + options.abortSignal.addEventListener('abort', onExternalAbort, { once: true }); + } + } + + try { + log.debug('Starting OpenCode session', { + agentType, + model: options.model, + hasSystemPrompt: !!options.systemPrompt, + attempt, + }); + + const parsedModel = parseProviderModel(options.model, 'OpenCode model'); + const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`; + const port = await getFreePort(); + const permission = buildOpenCodePermissionConfig(options.permissionMode); + const config = { + model: fullModel, + small_model: fullModel, + permission, + ...(options.opencodeApiKey + ? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } + : {}), + }; + const { client, server } = await createOpencode({ + port, + signal: streamAbortController.signal, + config, + }); + opencodeApiClient = client; + serverClose = server.close; + + const sessionResult = options.sessionId + ? { data: { id: options.sessionId } } + : await client.session.create({ + directory: options.cwd, + permission: buildOpenCodePermissionRuleset(options.permissionMode), + }); + + const sessionId = sessionResult.data?.id; + if (!sessionId) { + throw new Error('Failed to create OpenCode session'); + } + const { stream } = await client.event.subscribe( + { directory: options.cwd }, + { signal: streamAbortController.signal }, + ); + resetIdleTimeout(); + + const tools = mapToOpenCodeTools(options.allowedTools); + await client.session.promptAsync( + { + sessionID: sessionId, + directory: options.cwd, + model: parsedModel, + ...(tools ? { tools } : {}), + parts: [{ type: 'text' as const, text: fullPrompt }], + }, + { signal: streamAbortController.signal }, + ); + + emitInit(options.onStream, options.model, sessionId); + + let content = ''; + let success = true; + let failureMessage = ''; + const state = createStreamTrackingState(); + const echoState = { remainingPrompt: fullPrompt }; + const textOffsets = new Map(); + const textContentParts = new Map(); + + for await (const event of stream) { + if (streamAbortController.signal.aborted) break; + resetIdleTimeout(); + + const sseEvent = event as OpenCodeStreamEvent; + if (sseEvent.type === 'message.part.updated') { + const props = sseEvent.properties as { part: OpenCodePart; delta?: string }; + const part = props.part; + const delta = props.delta; + + if (part.type === 'text') { + const textPart = part as OpenCodeTextPart; + const prev = textOffsets.get(textPart.id) ?? 0; + const rawDelta = delta + ?? (textPart.text.length > prev ? textPart.text.slice(prev) : ''); + + textOffsets.set(textPart.id, textPart.text.length); + + if (rawDelta) { + const visibleDelta = stripPromptEcho(rawDelta, echoState); + if (visibleDelta) { + emitText(options.onStream, visibleDelta); + const previous = textContentParts.get(textPart.id) ?? ''; + textContentParts.set(textPart.id, `${previous}${visibleDelta}`); + } + } + continue; + } + + handlePartUpdated(part, delta, options.onStream, state); + continue; + } + + if (sseEvent.type === 'permission.asked') { + const permProps = sseEvent.properties as { + id: string; + sessionID: string; + }; + if (permProps.sessionID === sessionId) { + const reply = options.permissionMode + ? mapToOpenCodePermissionReply(options.permissionMode) + : 'once'; + await withTimeout( + (signal) => client.permission.reply({ + requestID: permProps.id, + directory: options.cwd, + reply, + }, { signal }), + OPENCODE_INTERACTION_TIMEOUT_MS, + 'OpenCode permission reply timed out', + ); + } + continue; + } + + if (sseEvent.type === 'question.asked') { + const questionProps = sseEvent.properties as OpenCodeQuestionAskedProperties; + if (questionProps.sessionID === sessionId) { + if (!options.onAskUserQuestion) { + await withTimeout( + (signal) => client.question.reject({ + requestID: questionProps.id, + directory: options.cwd, + }, { signal }), + OPENCODE_INTERACTION_TIMEOUT_MS, + 'OpenCode question reject timed out', + ); + continue; + } + + try { + const answers = await options.onAskUserQuestion(toQuestionInput(questionProps)); + await withTimeout( + (signal) => client.question.reply({ + requestID: questionProps.id, + directory: options.cwd, + answers: toQuestionAnswers(questionProps, answers), + }, { signal }), + OPENCODE_INTERACTION_TIMEOUT_MS, + 'OpenCode question reply timed out', + ); + } catch { + await withTimeout( + (signal) => client.question.reject({ + requestID: questionProps.id, + directory: options.cwd, + }, { signal }), + OPENCODE_INTERACTION_TIMEOUT_MS, + 'OpenCode question reject timed out', + ); + success = false; + failureMessage = 'OpenCode question handling failed'; + break; + } + } + continue; + } + + if (sseEvent.type === 'message.updated') { + const messageProps = sseEvent.properties as { + info?: { + sessionID?: string; + role?: 'assistant' | 'user'; + time?: { completed?: number }; + error?: unknown; + }; + }; + const info = messageProps.info; + const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; + if (isCurrentAssistantMessage) { + const streamError = extractOpenCodeErrorMessage(info?.error); + if (streamError) { + success = false; + failureMessage = streamError; + break; + } + } + continue; + } + + if (sseEvent.type === 'message.completed') { + const completedProps = sseEvent.properties as { + info?: { + sessionID?: string; + role?: 'assistant' | 'user'; + error?: unknown; + }; + }; + const info = completedProps.info; + const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; + if (isCurrentAssistantMessage) { + const streamError = extractOpenCodeErrorMessage(info?.error); + if (streamError) { + success = false; + failureMessage = streamError; + break; + } + } + continue; + } + + if (sseEvent.type === 'message.failed') { + const failedProps = sseEvent.properties as { + info?: { + sessionID?: string; + role?: 'assistant' | 'user'; + error?: unknown; + }; + }; + const info = failedProps.info; + const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; + if (isCurrentAssistantMessage) { + success = false; + failureMessage = extractOpenCodeErrorMessage(info?.error) ?? 'OpenCode message failed'; + break; + } + continue; + } + + if (sseEvent.type === 'session.status') { + const statusProps = sseEvent.properties as { + sessionID?: string; + status?: { type?: string }; + }; + if (statusProps.sessionID === sessionId && statusProps.status?.type === 'idle') { + break; + } + continue; + } + + if (sseEvent.type === 'session.idle') { + const idleProps = sseEvent.properties as { sessionID: string }; + if (idleProps.sessionID === sessionId) { + break; + } + continue; + } + + if (sseEvent.type === 'session.error') { + const errorProps = sseEvent.properties as { + sessionID?: string; + error?: { name: string; data: { message: string } }; + }; + if (!errorProps.sessionID || errorProps.sessionID === sessionId) { + success = false; + failureMessage = errorProps.error?.data?.message ?? 'OpenCode session error'; + break; + } + continue; + } + } + + content = [...textContentParts.values()].join('\n'); + + if (!success) { + const message = failureMessage || 'OpenCode execution failed'; + const retriable = this.isRetriableError(message, streamAbortController.signal.aborted, abortCause); + if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) { + log.info('Retrying OpenCode call after transient failure', { agentType, attempt, message }); + await this.waitForRetryDelay(attempt, options.abortSignal); + continue; + } + + emitResult(options.onStream, false, message, sessionId); + return { + persona: agentType, + status: 'error', + content: message, + timestamp: new Date(), + sessionId, + }; + } + + const trimmed = content.trim(); + emitResult(options.onStream, true, trimmed, sessionId); + + return { + persona: agentType, + status: 'done', + content: trimmed, + timestamp: new Date(), + sessionId, + }; + } catch (error) { + const message = getErrorMessage(error); + const errorMessage = streamAbortController.signal.aborted + ? abortCause === 'timeout' + ? timeoutMessage + : OPENCODE_STREAM_ABORTED_MESSAGE + : message; + + const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause); + if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) { + log.info('Retrying OpenCode call after transient exception', { agentType, attempt, errorMessage }); + await this.waitForRetryDelay(attempt, options.abortSignal); + continue; + } + + if (options.sessionId) { + emitResult(options.onStream, false, errorMessage, options.sessionId); + } + + return { + persona: agentType, + status: 'error', + content: errorMessage, + timestamp: new Date(), + sessionId: options.sessionId, + }; + } finally { + if (idleTimeoutId !== undefined) { + clearTimeout(idleTimeoutId); + } + if (options.abortSignal) { + options.abortSignal.removeEventListener('abort', onExternalAbort); + } + if (opencodeApiClient) { + const disposeAbortController = new AbortController(); + const disposeTimeoutId = setTimeout(() => { + disposeAbortController.abort(); + }, 3000); + try { + await opencodeApiClient.instance.dispose( + { directory: options.cwd }, + { signal: disposeAbortController.signal }, + ); + } catch { + // Ignore dispose errors during cleanup. + } finally { + clearTimeout(disposeTimeoutId); + } + } + if (serverClose) { + serverClose(); + } + if (!streamAbortController.signal.aborted) { + streamAbortController.abort(); + } + } + } + + throw new Error('Unreachable: OpenCode retry loop exhausted without returning'); + } + + /** Call OpenCode with a custom agent configuration (system prompt + prompt) */ + async callCustom( + agentName: string, + prompt: string, + systemPrompt: string, + options: OpenCodeCallOptions, + ): Promise { + return this.call(agentName, prompt, { + ...options, + systemPrompt, + }); + } +} + +const defaultClient = new OpenCodeClient(); + +export async function callOpenCode( + agentType: string, + prompt: string, + options: OpenCodeCallOptions, +): Promise { + return defaultClient.call(agentType, prompt, options); +} + +export async function callOpenCodeCustom( + agentName: string, + prompt: string, + systemPrompt: string, + options: OpenCodeCallOptions, +): Promise { + return defaultClient.callCustom(agentName, prompt, systemPrompt, options); +} diff --git a/src/infra/opencode/index.ts b/src/infra/opencode/index.ts new file mode 100644 index 00000000..1d36e84f --- /dev/null +++ b/src/infra/opencode/index.ts @@ -0,0 +1,7 @@ +/** + * OpenCode integration exports + */ + +export { OpenCodeClient, callOpenCode, callOpenCodeCustom } from './client.js'; +export { mapToOpenCodePermissionReply } from './types.js'; +export type { OpenCodeCallOptions, OpenCodePermissionReply } from './types.js'; diff --git a/src/infra/opencode/types.ts b/src/infra/opencode/types.ts new file mode 100644 index 00000000..490561d7 --- /dev/null +++ b/src/infra/opencode/types.ts @@ -0,0 +1,170 @@ +/** + * Type definitions for OpenCode SDK integration + */ + +import type { StreamCallback } from '../claude/index.js'; +import type { AskUserQuestionHandler } from '../../core/piece/types.js'; +import type { PermissionMode } from '../../core/models/index.js'; + +/** OpenCode permission reply values */ +export type OpenCodePermissionReply = 'once' | 'always' | 'reject'; +export type OpenCodePermissionAction = 'ask' | 'allow' | 'deny'; + +/** Map TAKT PermissionMode to OpenCode permission reply */ +export function mapToOpenCodePermissionReply(mode: PermissionMode): OpenCodePermissionReply { + const mapping: Record = { + readonly: 'reject', + edit: 'once', + full: 'always', + }; + return mapping[mode]; +} + +const OPEN_CODE_PERMISSION_KEYS = [ + 'read', + 'glob', + 'grep', + 'edit', + 'write', + 'bash', + 'task', + 'websearch', + 'webfetch', + 'question', +] as const; + +export type OpenCodePermissionKey = typeof OPEN_CODE_PERMISSION_KEYS[number]; + +export type OpenCodePermissionMap = Record; + +function buildPermissionMap(mode?: PermissionMode): OpenCodePermissionMap { + const allDeny: OpenCodePermissionMap = { + read: 'deny', + glob: 'deny', + grep: 'deny', + edit: 'deny', + write: 'deny', + bash: 'deny', + task: 'deny', + websearch: 'deny', + webfetch: 'deny', + question: 'deny', + }; + + if (mode === 'readonly') return allDeny; + + if (mode === 'full') { + return { + ...allDeny, + read: 'allow', + glob: 'allow', + grep: 'allow', + edit: 'allow', + write: 'allow', + bash: 'allow', + task: 'allow', + websearch: 'allow', + webfetch: 'allow', + question: 'allow', + }; + } + + if (mode === 'edit') { + return { + ...allDeny, + read: 'allow', + glob: 'allow', + grep: 'allow', + edit: 'allow', + write: 'allow', + bash: 'allow', + task: 'allow', + websearch: 'allow', + webfetch: 'allow', + question: 'deny', + }; + } + + return { + ...allDeny, + read: 'ask', + glob: 'ask', + grep: 'ask', + edit: 'ask', + write: 'ask', + bash: 'ask', + task: 'ask', + websearch: 'ask', + webfetch: 'ask', + question: 'deny', + }; +} + +export function buildOpenCodePermissionConfig(mode?: PermissionMode): OpenCodePermissionAction | Record { + if (mode === 'readonly') return 'deny'; + if (mode === 'full') return 'allow'; + return buildPermissionMap(mode); +} + +export function buildOpenCodePermissionRuleset(mode?: PermissionMode): Array<{ permission: string; pattern: string; action: OpenCodePermissionAction }> { + const permissionMap = buildPermissionMap(mode); + return OPEN_CODE_PERMISSION_KEYS.map((permission) => ({ + permission, + pattern: '**', + action: permissionMap[permission], + })); +} + +const BUILTIN_TOOL_MAP: Record = { + Read: 'read', + Glob: 'glob', + Grep: 'grep', + Edit: 'edit', + Write: 'write', + Bash: 'bash', + WebSearch: 'websearch', + WebFetch: 'webfetch', +}; + +export function mapToOpenCodeTools(allowedTools?: string[]): Record | undefined { + if (!allowedTools || allowedTools.length === 0) { + return undefined; + } + + const mapped = new Set(); + for (const tool of allowedTools) { + const normalized = tool.trim(); + if (!normalized) { + continue; + } + const mappedTool = BUILTIN_TOOL_MAP[normalized] ?? normalized; + mapped.add(mappedTool); + } + + if (mapped.size === 0) { + return undefined; + } + + const tools: Record = {}; + for (const tool of mapped) { + tools[tool] = true; + } + return tools; +} + +/** Options for calling OpenCode */ +export interface OpenCodeCallOptions { + cwd: string; + abortSignal?: AbortSignal; + sessionId?: string; + model: string; + systemPrompt?: string; + allowedTools?: string[]; + /** Permission mode for automatic permission handling */ + permissionMode?: PermissionMode; + /** Enable streaming mode with callback (best-effort) */ + onStream?: StreamCallback; + onAskUserQuestion?: AskUserQuestionHandler; + /** OpenCode API key */ + opencodeApiKey?: string; +} diff --git a/src/infra/providers/index.ts b/src/infra/providers/index.ts index 576744b8..88659c88 100644 --- a/src/infra/providers/index.ts +++ b/src/infra/providers/index.ts @@ -1,12 +1,13 @@ /** * Provider abstraction layer * - * Provides a unified interface for different agent providers (Claude, Codex, Mock). + * Provides a unified interface for different agent providers (Claude, Codex, OpenCode, Mock). * This enables adding new providers without modifying the runner logic. */ import { ClaudeProvider } from './claude.js'; import { CodexProvider } from './codex.js'; +import { OpenCodeProvider } from './opencode.js'; import { MockProvider } from './mock.js'; import type { Provider, ProviderType } from './types.js'; @@ -24,6 +25,7 @@ export class ProviderRegistry { this.providers = { claude: new ClaudeProvider(), codex: new CodexProvider(), + opencode: new OpenCodeProvider(), mock: new MockProvider(), }; } diff --git a/src/infra/providers/opencode.ts b/src/infra/providers/opencode.ts new file mode 100644 index 00000000..19e97989 --- /dev/null +++ b/src/infra/providers/opencode.ts @@ -0,0 +1,53 @@ +/** + * OpenCode provider implementation + */ + +import { callOpenCode, callOpenCodeCustom, type OpenCodeCallOptions } from '../opencode/index.js'; +import { resolveOpencodeApiKey } from '../config/index.js'; +import type { AgentResponse } from '../../core/models/index.js'; +import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; + +function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions { + if (!options.model) { + throw new Error("OpenCode provider requires model in 'provider/model' format (e.g. 'opencode/big-pickle')."); + } + + return { + cwd: options.cwd, + abortSignal: options.abortSignal, + sessionId: options.sessionId, + model: options.model, + allowedTools: options.allowedTools, + permissionMode: options.permissionMode, + onStream: options.onStream, + onAskUserQuestion: options.onAskUserQuestion, + opencodeApiKey: options.opencodeApiKey ?? resolveOpencodeApiKey(), + }; +} + +/** OpenCode provider — delegates to OpenCode SDK */ +export class OpenCodeProvider implements Provider { + setup(config: AgentSetup): ProviderAgent { + if (config.claudeAgent) { + throw new Error('Claude Code agent calls are not supported by the OpenCode provider'); + } + if (config.claudeSkill) { + throw new Error('Claude Code skill calls are not supported by the OpenCode provider'); + } + + const { name, systemPrompt } = config; + if (systemPrompt) { + return { + call: async (prompt: string, options: ProviderCallOptions): Promise => { + return callOpenCodeCustom(name, prompt, systemPrompt, toOpenCodeOptions(options)); + }, + }; + } + + return { + call: async (prompt: string, options: ProviderCallOptions): Promise => { + return callOpenCode(name, prompt, toOpenCodeOptions(options)); + }, + }; + } +} diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index df688d99..d2bc48d1 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -38,6 +38,8 @@ export interface ProviderCallOptions { anthropicApiKey?: string; /** OpenAI API key for Codex provider */ openaiApiKey?: string; + /** OpenCode API key for OpenCode provider */ + opencodeApiKey?: string; } /** A configured agent ready to be called */ @@ -51,4 +53,4 @@ export interface Provider { } /** Provider type */ -export type ProviderType = 'claude' | 'codex' | 'mock'; +export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts index ded75cf6..87762b60 100644 --- a/src/infra/task/mapper.ts +++ b/src/infra/task/mapper.ts @@ -7,10 +7,37 @@ function firstLine(content: string): string { return content.trim().split('\n')[0]?.slice(0, 80) ?? ''; } +function toDisplayPath(projectDir: string, targetPath: string): string { + const relativePath = path.relative(projectDir, targetPath); + if (!relativePath || relativePath.startsWith('..')) { + return targetPath; + } + return relativePath; +} + +function buildTaskDirInstruction(projectDir: string, taskDirPath: string, orderFilePath: string): string { + const displayTaskDir = toDisplayPath(projectDir, taskDirPath); + const displayOrderFile = toDisplayPath(projectDir, orderFilePath); + return [ + `Implement using only the files in \`${displayTaskDir}\`.`, + `Primary spec: \`${displayOrderFile}\`.`, + 'Use report files in Report Directory as primary execution history.', + 'Do not rely on previous response or conversation summary.', + ].join('\n'); +} + export function resolveTaskContent(projectDir: string, task: TaskRecord): string { if (task.content) { return task.content; } + if (task.task_dir) { + const taskDirPath = path.join(projectDir, task.task_dir); + const orderFilePath = path.join(taskDirPath, 'order.md'); + if (!fs.existsSync(orderFilePath)) { + throw new Error(`Task spec file is missing: ${orderFilePath}`); + } + return buildTaskDirInstruction(projectDir, taskDirPath, orderFilePath); + } if (!task.content_file) { throw new Error(`Task content is missing: ${task.name}`); } @@ -40,6 +67,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco filePath: tasksFile, name: task.name, content, + taskDir: task.task_dir, createdAt: task.created_at, status: task.status, data: TaskFileSchema.parse({ diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index 6c049964..e7e9957d 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -29,13 +29,17 @@ export class TaskRunner { return this.tasksFile; } - addTask(content: string, options?: Omit): TaskInfo { + addTask( + content: string, + options?: Omit & { content_file?: string; task_dir?: string }, + ): TaskInfo { const state = this.store.update((current) => { const name = this.generateTaskName(content, current.tasks.map((task) => task.name)); + const contentValue = options?.task_dir ? undefined : content; const record: TaskRecord = TaskRecordSchema.parse({ name, status: 'pending', - content, + content: contentValue, created_at: nowIso(), started_at: null, completed_at: null, @@ -119,17 +123,9 @@ export class TaskRunner { throw new Error(`Task not found: ${result.task.name}`); } - const target = current.tasks[index]!; - const updated: TaskRecord = { - ...target, - status: 'completed', - completed_at: result.completedAt, - owner_pid: null, - failure: undefined, + return { + tasks: current.tasks.filter((_, i) => i !== index), }; - const tasks = [...current.tasks]; - tasks[index] = updated; - return { tasks }; }); return this.tasksFile; diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index a884f0f8..f84df168 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -3,6 +3,7 @@ */ import { z } from 'zod/v4'; +import { isValidTaskDir } from '../../shared/utils/taskPaths.js'; /** * Per-task execution config schema. @@ -40,19 +41,35 @@ export type TaskFailure = z.infer; export const TaskRecordSchema = TaskExecutionConfigSchema.extend({ name: z.string().min(1), status: TaskStatusSchema, - content: z.string().optional(), - content_file: z.string().optional(), + content: z.string().min(1).optional(), + content_file: z.string().min(1).optional(), + task_dir: z.string().optional(), created_at: z.string().min(1), started_at: z.string().nullable(), completed_at: z.string().nullable(), owner_pid: z.number().int().positive().nullable().optional(), failure: TaskFailureSchema.optional(), }).superRefine((value, ctx) => { - if (!value.content && !value.content_file) { + const sourceFields = [value.content, value.content_file, value.task_dir].filter((field) => field !== undefined); + if (sourceFields.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['content'], - message: 'Either content or content_file is required.', + message: 'Either content, content_file, or task_dir is required.', + }); + } + if (sourceFields.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['content'], + message: 'Exactly one of content, content_file, or task_dir must be set.', + }); + } + if (value.task_dir !== undefined && !isValidTaskDir(value.task_dir)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['task_dir'], + message: 'task_dir must match .takt/tasks/ format.', }); } diff --git a/src/infra/task/summarize.ts b/src/infra/task/summarize.ts index 185bd130..a8c80415 100644 --- a/src/infra/task/summarize.ts +++ b/src/infra/task/summarize.ts @@ -70,10 +70,11 @@ export class TaskSummarizer { name: 'summarizer', systemPrompt: loadTemplate('score_slug_system_prompt', 'en'), }); - const response = await agent.call(taskName, { + const prompt = loadTemplate('score_slug_user_prompt', 'en', { taskDescription: taskName }); + const response = await agent.call(prompt, { cwd: options.cwd, model, - allowedTools: [], + permissionMode: 'readonly', }); const slug = sanitizeSlug(response.content); diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index 2968a7cf..303ccf60 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -10,6 +10,7 @@ export interface TaskInfo { filePath: string; name: string; content: string; + taskDir?: string; createdAt: string; status: TaskStatus; data: TaskFileData | null; diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 1fdbba17..5826eb6c 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -35,6 +35,13 @@ interactive: quietDescription: "Generate instructions without asking questions" passthrough: "Passthrough" passthroughDescription: "Pass your input directly as task text" + sessionSelector: + confirm: "Choose a previous session?" + prompt: "Resume from a recent session?" + newSession: "New session" + newSessionDescription: "Start a fresh conversation" + lastResponse: "Last: {response}" + messages: "{count} messages" previousTask: success: "✅ Previous task completed successfully" error: "❌ Previous task failed: {error}" @@ -45,7 +52,7 @@ interactive: # ===== Piece Execution UI ===== piece: iterationLimit: - maxReached: "Reached max iterations ({currentIteration}/{maxIterations})" + maxReached: "Reached max iterations ({currentIteration}/{maxMovements})" currentMovement: "Current movement: {currentMovement}" continueQuestion: "Continue?" continueLabel: "Continue (enter additional iterations)" @@ -58,3 +65,7 @@ piece: notifyAbort: "Aborted: {reason}" sigintGraceful: "Ctrl+C: Aborting piece..." sigintForce: "Ctrl+C: Force exit" + +run: + notifyComplete: "Run complete ({total} tasks)" + notifyAbort: "Run finished with errors ({failed})" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 21af472a..8239e7d1 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -35,6 +35,13 @@ interactive: quietDescription: "質問なしでベストエフォートの指示書を生成" passthrough: "パススルー" passthroughDescription: "入力をそのままタスクとして渡す" + sessionSelector: + confirm: "前回セッションを選択しますか?" + prompt: "直近のセッションを引き継ぎますか?" + newSession: "新しいセッション" + newSessionDescription: "新しい会話を始める" + lastResponse: "最後: {response}" + messages: "{count}メッセージ" previousTask: success: "✅ 前回のタスクは正常に完了しました" error: "❌ 前回のタスクはエラーで終了しました: {error}" @@ -45,7 +52,7 @@ interactive: # ===== Piece Execution UI ===== piece: iterationLimit: - maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxIterations})" + maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxMovements})" currentMovement: "現在のムーブメント: {currentMovement}" continueQuestion: "続行しますか?" continueLabel: "続行する(追加イテレーション数を入力)" @@ -58,3 +65,7 @@ piece: notifyAbort: "中断: {reason}" sigintGraceful: "Ctrl+C: ピースを中断しています..." sigintForce: "Ctrl+C: 強制終了します" + +run: + notifyComplete: "run完了 ({total} tasks)" + notifyAbort: "runはエラー終了 ({failed})" diff --git a/src/shared/prompt/confirm.ts b/src/shared/prompt/confirm.ts index bb823343..8f516682 100644 --- a/src/shared/prompt/confirm.ts +++ b/src/shared/prompt/confirm.ts @@ -9,6 +9,16 @@ import * as readline from 'node:readline'; import chalk from 'chalk'; import { resolveTtyPolicy, assertTtyIfForced } from './tty.js'; +function pauseStdinSafely(): void { + try { + if (process.stdin.readable && !process.stdin.destroyed) { + process.stdin.pause(); + } + } catch { + return; + } +} + /** * Prompt user for simple text input * @returns User input or null if cancelled @@ -27,6 +37,7 @@ export async function promptInput(message: string): Promise { return new Promise((resolve) => { rl.question(chalk.green(message + ': '), (answer) => { rl.close(); + pauseStdinSafely(); const trimmed = answer.trim(); if (!trimmed) { @@ -98,6 +109,7 @@ export async function confirm(message: string, defaultYes = true): Promise { rl.question(chalk.green(`${message} ${hint}: `), (answer) => { rl.close(); + pauseStdinSafely(); const trimmed = answer.trim().toLowerCase(); diff --git a/src/shared/prompts/en/perform_phase1_message.md b/src/shared/prompts/en/perform_phase1_message.md index 121d124f..9a64a397 100644 --- a/src/shared/prompts/en/perform_phase1_message.md +++ b/src/shared/prompts/en/perform_phase1_message.md @@ -22,6 +22,7 @@ Note: This section is metadata. Follow the language used in the rest of the prom ## Knowledge The following knowledge is domain-specific information for this movement. Use it as reference. +Knowledge may be truncated. Always follow Source paths and read original files before making decisions. {{knowledgeContent}} {{/if}} @@ -72,6 +73,7 @@ Before completing this movement, ensure the following requirements are met: ## Policy The following policies are behavioral standards applied to this movement. You MUST comply with them. +Policy is authoritative. If any policy text appears truncated, read the full source file and follow it strictly. {{policyContent}} {{/if}} diff --git a/src/shared/prompts/en/score_slug_user_prompt.md b/src/shared/prompts/en/score_slug_user_prompt.md new file mode 100644 index 00000000..bcdd39da --- /dev/null +++ b/src/shared/prompts/en/score_slug_user_prompt.md @@ -0,0 +1,12 @@ + +Generate a slug from the task description below. +Output ONLY the slug text. + + +{{taskDescription}} + diff --git a/src/shared/prompts/ja/perform_phase1_message.md b/src/shared/prompts/ja/perform_phase1_message.md index 52bef048..47118404 100644 --- a/src/shared/prompts/ja/perform_phase1_message.md +++ b/src/shared/prompts/ja/perform_phase1_message.md @@ -21,6 +21,7 @@ ## Knowledge 以下のナレッジはこのムーブメントに適用されるドメイン固有の知識です。参考にしてください。 +Knowledge はトリミングされる場合があります。Source Path に従い、判断前に必ず元ファイルを確認してください。 {{knowledgeContent}} {{/if}} @@ -71,6 +72,7 @@ ## Policy 以下のポリシーはこのムーブメントに適用される行動規範です。必ず遵守してください。 +Policy は最優先です。トリミングされている場合は必ず Source Path の全文を確認して厳密に従ってください。 {{policyContent}} {{/if}} diff --git a/src/shared/prompts/ja/score_slug_user_prompt.md b/src/shared/prompts/ja/score_slug_user_prompt.md new file mode 100644 index 00000000..bcdd39da --- /dev/null +++ b/src/shared/prompts/ja/score_slug_user_prompt.md @@ -0,0 +1,12 @@ + +Generate a slug from the task description below. +Output ONLY the slug text. + + +{{taskDescription}} + diff --git a/src/shared/ui/Progress.ts b/src/shared/ui/Progress.ts new file mode 100644 index 00000000..bbb90a06 --- /dev/null +++ b/src/shared/ui/Progress.ts @@ -0,0 +1,17 @@ +import { info } from './LogManager.js'; + +export type ProgressCompletionMessage = string | ((result: T) => string); + +export async function withProgress( + startMessage: string, + completionMessage: ProgressCompletionMessage, + operation: () => Promise, +): Promise { + info(startMessage); + const result = await operation(); + const message = typeof completionMessage === 'function' + ? completionMessage(result) + : completionMessage; + info(message); + return result; +} diff --git a/src/shared/ui/StreamDisplay.ts b/src/shared/ui/StreamDisplay.ts index 581a79fc..8be57656 100644 --- a/src/shared/ui/StreamDisplay.ts +++ b/src/shared/ui/StreamDisplay.ts @@ -18,8 +18,8 @@ import { stripAnsi } from '../utils/text.js'; export interface ProgressInfo { /** Current iteration (1-indexed) */ iteration: number; - /** Maximum iterations allowed */ - maxIterations: number; + /** Maximum movements allowed */ + maxMovements: number; /** Current movement index within piece (0-indexed) */ movementIndex: number; /** Total number of movements in piece */ @@ -52,16 +52,16 @@ export class StreamDisplay { /** * Build progress prefix string for display. - * Format: `(iteration/maxIterations) step movementIndex/totalMovements` + * Format: `(iteration/maxMovements) step movementIndex/totalMovements` * Example: `(3/10) step 2/4` */ private buildProgressPrefix(): string { if (!this.progressInfo) { return ''; } - const { iteration, maxIterations, movementIndex, totalMovements } = this.progressInfo; + const { iteration, maxMovements, movementIndex, totalMovements } = this.progressInfo; // movementIndex is 0-indexed, display as 1-indexed - return `(${iteration}/${maxIterations}) step ${movementIndex + 1}/${totalMovements}`; + return `(${iteration}/${maxMovements}) step ${movementIndex + 1}/${totalMovements}`; } showInit(model: string): void { diff --git a/src/shared/ui/TaskPrefixWriter.ts b/src/shared/ui/TaskPrefixWriter.ts index 188ea88f..7cf508b9 100644 --- a/src/shared/ui/TaskPrefixWriter.ts +++ b/src/shared/ui/TaskPrefixWriter.ts @@ -30,7 +30,7 @@ export interface TaskPrefixWriterOptions { export interface MovementPrefixContext { movementName: string; iteration: number; - maxIterations: number; + maxMovements: number; movementIteration: number; } @@ -63,8 +63,8 @@ export class TaskPrefixWriter { return `${this.taskPrefix} `; } - const { movementName, iteration, maxIterations, movementIteration } = this.movementContext; - return `${this.taskPrefix}[${movementName}](${iteration}/${maxIterations})(${movementIteration}) `; + const { movementName, iteration, maxMovements, movementIteration } = this.movementContext; + return `${this.taskPrefix}[${movementName}](${iteration}/${maxMovements})(${movementIteration}) `; } /** diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index faec0266..79689964 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -31,3 +31,5 @@ export { Spinner } from './Spinner.js'; export { StreamDisplay, type ProgressInfo } from './StreamDisplay.js'; export { TaskPrefixWriter } from './TaskPrefixWriter.js'; + +export { withProgress, type ProgressCompletionMessage } from './Progress.js'; diff --git a/src/shared/utils/debug.ts b/src/shared/utils/debug.ts index c3f8b7cd..c64c1c49 100644 --- a/src/shared/utils/debug.ts +++ b/src/shared/utils/debug.ts @@ -43,7 +43,8 @@ export class DebugLogger { /** Get default debug log file prefix */ private static getDefaultLogPrefix(projectDir: string): string { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - return join(projectDir, '.takt', 'logs', `debug-${timestamp}`); + const runSlug = `debug-${timestamp}`; + return join(projectDir, '.takt', 'runs', runSlug, 'logs', runSlug); } /** Initialize debug logger from config */ diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 1eb7ab44..340d55ca 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -5,9 +5,12 @@ export * from './debug.js'; export * from './error.js'; export * from './notification.js'; +export * from './providerEventLogger.js'; export * from './reportDir.js'; +export * from './slackWebhook.js'; export * from './sleep.js'; export * from './slug.js'; +export * from './taskPaths.js'; export * from './text.js'; export * from './types.js'; export * from './updateNotifier.js'; diff --git a/src/shared/utils/providerEventLogger.ts b/src/shared/utils/providerEventLogger.ts new file mode 100644 index 00000000..0789e90e --- /dev/null +++ b/src/shared/utils/providerEventLogger.ts @@ -0,0 +1,137 @@ +import { appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ProviderType, StreamCallback, StreamEvent } from '../../core/piece/index.js'; + +export interface ProviderEventLoggerConfig { + logsDir: string; + sessionId: string; + runId: string; + provider: ProviderType; + movement: string; + enabled: boolean; +} + +export interface ProviderEventLogger { + readonly filepath: string; + setMovement(movement: string): void; + setProvider(provider: ProviderType): void; + wrapCallback(original?: StreamCallback): StreamCallback; +} + +interface ProviderEventLogRecord { + timestamp: string; + provider: ProviderType; + event_type: string; + run_id: string; + movement: string; + session_id?: string; + message_id?: string; + call_id?: string; + request_id?: string; + data: Record; +} + +const MAX_TEXT_LENGTH = 10_000; +const HEAD_LENGTH = 5_000; +const TAIL_LENGTH = 2_000; +const TRUNCATED_MARKER = '...[truncated]'; + +function truncateString(value: string): string { + if (value.length <= MAX_TEXT_LENGTH) { + return value; + } + return value.slice(0, HEAD_LENGTH) + TRUNCATED_MARKER + value.slice(-TAIL_LENGTH); +} + +function sanitizeData(data: Record): Record { + return Object.fromEntries( + Object.entries(data).map(([key, value]) => { + if (typeof value === 'string') { + return [key, truncateString(value)]; + } + return [key, value]; + }) + ); +} + +function pickString(source: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = source[key]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + return undefined; +} + +function buildLogRecord( + event: StreamEvent, + provider: ProviderType, + movement: string, + runId: string, +): ProviderEventLogRecord { + const data = sanitizeData(event.data as unknown as Record); + const sessionId = pickString(data, ['session_id', 'sessionId', 'sessionID', 'thread_id', 'threadId']); + const messageId = pickString(data, ['message_id', 'messageId', 'item_id', 'itemId']); + const callId = pickString(data, ['call_id', 'callId', 'id']); + const requestId = pickString(data, ['request_id', 'requestId']); + + return { + timestamp: new Date().toISOString(), + provider, + event_type: event.type, + run_id: runId, + movement, + ...(sessionId ? { session_id: sessionId } : {}), + ...(messageId ? { message_id: messageId } : {}), + ...(callId ? { call_id: callId } : {}), + ...(requestId ? { request_id: requestId } : {}), + data, + }; +} + +export function createProviderEventLogger(config: ProviderEventLoggerConfig): ProviderEventLogger { + const filepath = join(config.logsDir, `${config.sessionId}-provider-events.jsonl`); + let movement = config.movement; + let provider = config.provider; + + const write = (event: StreamEvent): void => { + try { + const record = buildLogRecord(event, provider, movement, config.runId); + appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8'); + } catch { + // Silently fail - observability logging should not interrupt main flow. + } + }; + + return { + filepath, + setMovement(nextMovement: string): void { + movement = nextMovement; + }, + setProvider(nextProvider: ProviderType): void { + provider = nextProvider; + }, + wrapCallback(original?: StreamCallback): StreamCallback { + if (!config.enabled && original) { + return original; + } + if (!config.enabled) { + return () => {}; + } + + return (event: StreamEvent): void => { + write(event); + original?.(event); + }; + }, + }; +} + +export function isProviderEventsEnabled(config?: { + observability?: { + providerEvents?: boolean; + }; +}): boolean { + return config?.observability?.providerEvents === true; +} diff --git a/src/shared/utils/providerModel.ts b/src/shared/utils/providerModel.ts new file mode 100644 index 00000000..a0947658 --- /dev/null +++ b/src/shared/utils/providerModel.ts @@ -0,0 +1,21 @@ +/** + * Parse provider/model identifier. + * + * Expected format: "/" with both segments non-empty. + */ +export function parseProviderModel(value: string, fieldName: string): { providerID: string; modelID: string } { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${fieldName} must not be empty`); + } + + const slashIndex = trimmed.indexOf('/'); + if (slashIndex <= 0 || slashIndex === trimmed.length - 1 || trimmed.indexOf('/', slashIndex + 1) !== -1) { + throw new Error(`${fieldName} must be in 'provider/model' format: received '${value}'`); + } + + return { + providerID: trimmed.slice(0, slashIndex), + modelID: trimmed.slice(slashIndex + 1), + }; +} diff --git a/src/shared/utils/slackWebhook.ts b/src/shared/utils/slackWebhook.ts new file mode 100644 index 00000000..6d208f92 --- /dev/null +++ b/src/shared/utils/slackWebhook.ts @@ -0,0 +1,43 @@ +/** + * Slack Incoming Webhook notification + * + * Sends a text message to a Slack channel via Incoming Webhook. + * Activated only when TAKT_NOTIFY_WEBHOOK environment variable is set. + */ + +const WEBHOOK_ENV_KEY = 'TAKT_NOTIFY_WEBHOOK'; +const TIMEOUT_MS = 10_000; + +/** + * Send a notification message to Slack via Incoming Webhook. + * + * Never throws: errors are written to stderr so the caller's flow is not disrupted. + */ +export async function sendSlackNotification(webhookUrl: string, message: string): Promise { + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: message }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + if (!response.ok) { + process.stderr.write( + `Slack webhook failed: HTTP ${String(response.status)} ${response.statusText}\n`, + ); + } + } catch (err: unknown) { + const detail = err instanceof Error ? err.message : String(err); + process.stderr.write(`Slack webhook error: ${detail}\n`); + } +} + +/** + * Read the Slack webhook URL from the environment. + * + * @returns The webhook URL, or undefined if the environment variable is not set. + */ +export function getSlackWebhookUrl(): string | undefined { + return process.env[WEBHOOK_ENV_KEY]; +} diff --git a/src/shared/utils/taskPaths.ts b/src/shared/utils/taskPaths.ts new file mode 100644 index 00000000..b12d5826 --- /dev/null +++ b/src/shared/utils/taskPaths.ts @@ -0,0 +1,20 @@ +const TASK_SLUG_PATTERN = + '[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf](?:[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf-]*[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf])?'; +const TASK_DIR_PREFIX = '.takt/tasks/'; +const TASK_DIR_PATTERN = new RegExp(`^\\.takt/tasks/${TASK_SLUG_PATTERN}$`); +const REPORT_DIR_NAME_PATTERN = new RegExp(`^${TASK_SLUG_PATTERN}$`); + +export function isValidTaskDir(taskDir: string): boolean { + return TASK_DIR_PATTERN.test(taskDir); +} + +export function getTaskSlugFromTaskDir(taskDir: string): string | undefined { + if (!isValidTaskDir(taskDir)) { + return undefined; + } + return taskDir.slice(TASK_DIR_PREFIX.length); +} + +export function isValidReportDirName(reportDirName: string): boolean { + return REPORT_DIR_NAME_PATTERN.test(reportDirName); +} diff --git a/src/shared/utils/types.ts b/src/shared/utils/types.ts index 3e4b24fb..2f33f52f 100644 --- a/src/shared/utils/types.ts +++ b/src/shared/utils/types.ts @@ -116,20 +116,6 @@ export type NdjsonRecord = | NdjsonInteractiveStart | NdjsonInteractiveEnd; -// --- Conversation log types --- - -/** Pointer metadata for latest/previous log files */ -export interface LatestLogPointer { - sessionId: string; - logFile: string; - task: string; - pieceName: string; - status: SessionLog['status']; - startTime: string; - updatedAt: string; - iterations: number; -} - /** Record for debug prompt/response log (debug-*-prompts.jsonl) */ export interface PromptLogRecord { movement: string; diff --git a/vitest.config.e2e.mock.ts b/vitest.config.e2e.mock.ts index 246ed68b..12bc9fc1 100644 --- a/vitest.config.e2e.mock.ts +++ b/vitest.config.e2e.mock.ts @@ -11,6 +11,20 @@ export default defineConfig({ 'e2e/specs/list-non-interactive.e2e.ts', 'e2e/specs/multi-step-parallel.e2e.ts', 'e2e/specs/run-sigint-graceful.e2e.ts', + 'e2e/specs/piece-error-handling.e2e.ts', + 'e2e/specs/run-multiple-tasks.e2e.ts', + 'e2e/specs/provider-error.e2e.ts', + 'e2e/specs/error-handling.e2e.ts', + 'e2e/specs/cli-catalog.e2e.ts', + 'e2e/specs/cli-prompt.e2e.ts', + 'e2e/specs/cli-switch.e2e.ts', + 'e2e/specs/cli-help.e2e.ts', + 'e2e/specs/cli-clear.e2e.ts', + 'e2e/specs/cli-config.e2e.ts', + 'e2e/specs/cli-reset-categories.e2e.ts', + 'e2e/specs/cli-export-cc.e2e.ts', + 'e2e/specs/quiet-mode.e2e.ts', + 'e2e/specs/task-content-file.e2e.ts', ], environment: 'node', globals: false,