diff --git a/CLAUDE.md b/CLAUDE.md index 1cae284..60db9f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,18 +11,20 @@ TAKT (Task Agent Koordination Tool) is a multi-agent orchestration system for Cl | Command | Description | |---------|-------------| | `npm run build` | TypeScript build | +| `npm run watch` | TypeScript build in watch mode | | `npm run test` | Run all tests | -| `npm run test:watch` | Watch mode | +| `npm run test:watch` | Run tests in watch mode (alias: `npm run test -- --watch`) | | `npm run lint` | ESLint | | `npx vitest run src/__tests__/client.test.ts` | Run single test file | | `npx vitest run -t "pattern"` | Run tests matching pattern | +| `npm run prepublishOnly` | Lint, build, and test before publishing | ## CLI Subcommands | Command | Description | |---------|-------------| | `takt {task}` | Execute task with current workflow | -| `takt` | Interactive task input mode | +| `takt` | Interactive task input mode (chat with AI to refine requirements) | | `takt run` | Execute all pending tasks from `.takt/tasks/` once | | `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (resident process) | | `takt add` | Add a new task via AI conversation | @@ -33,7 +35,11 @@ TAKT (Task Agent Koordination Tool) is a multi-agent orchestration system for Cl | `takt config` | Configure settings (permission mode) | | `takt --help` | Show help message | -GitHub issue references: `takt #6` fetches issue #6 and executes it as a task. +**Interactive mode:** Running `takt` (without arguments) or `takt {initial message}` starts an interactive planning session. The AI helps refine task requirements through conversation. Type `/go` to execute the task with the selected workflow, or `/cancel` to abort. Implemented in `src/features/interactive/`. + +**Pipeline mode:** Specifying `--pipeline` enables non-interactive mode suitable for CI/CD. Automatically creates a branch, runs the workflow, commits, and pushes. Use `--auto-pr` to also create a pull request. Use `--skip-git` to run workflow only (no git operations). Implemented in `src/features/pipeline/`. + +**GitHub issue references:** `takt #6` fetches issue #6 and executes it as a task. ## Architecture @@ -83,7 +89,24 @@ Implemented in `src/core/workflow/evaluation/RuleEvaluator.ts`. The matched meth - Emits events: `step:start`, `step:complete`, `step:blocked`, `step:loop_detected`, `workflow:complete`, `workflow:abort`, `iteration:limit` - Supports loop detection (`LoopDetector`) and iteration limits - Maintains agent sessions per step for conversation continuity -- Parallel step execution via `runParallelStep()` with `Promise.all()` +- Delegates to `StepExecutor` (normal steps) and `ParallelRunner` (parallel steps) + +**StepExecutor** (`src/core/workflow/engine/StepExecutor.ts`) +- Executes a single workflow step through the 3-phase model +- Phase 1: Main agent execution (with tools) +- Phase 2: Report output (Write-only, optional) +- Phase 3: Status judgment (no tools, optional) +- Builds instructions via `InstructionBuilder`, detects matched rules via `RuleEvaluator` + +**ParallelRunner** (`src/core/workflow/engine/ParallelRunner.ts`) +- Executes parallel sub-steps concurrently via `Promise.all()` +- Aggregates sub-step results for parent rule evaluation +- Supports `all()` / `any()` aggregate conditions + +**RuleEvaluator** (`src/core/workflow/evaluation/RuleEvaluator.ts`) +- 5-stage fallback evaluation: aggregate → Phase 3 tag → Phase 1 tag → ai() judge → all-conditions AI judge +- Returns `RuleMatch` with index and detection method (`aggregate`, `phase3_tag`, `phase1_tag`, `ai_judge`, `ai_fallback`) +- Fail-fast: throws if rules exist but no rule matched **Instruction Builder** (`src/core/workflow/instruction/InstructionBuilder.ts`) - Auto-injects standard sections into every instruction (no need for `{task}` or `{previous_response}` placeholders in templates): @@ -95,6 +118,7 @@ Implemented in `src/core/workflow/evaluation/RuleEvaluator.ts`. The matched meth 6. `instruction_template` content 7. Status output rules (auto-injected for tag-based rules) - Localized for `en` and `ja` +- Related: `ReportInstructionBuilder` (Phase 2), `StatusJudgmentBuilder` (Phase 3) **Agent Runner** (`src/agents/runner.ts`) - Resolves agent specs (name or path) to agent configurations @@ -105,25 +129,35 @@ Implemented in `src/core/workflow/evaluation/RuleEvaluator.ts`. The matched meth - `planner`: Read/Glob/Grep/Bash/WebSearch/WebFetch - Custom agents via `.takt/agents.yaml` or prompt files (.md) -**Claude Integration** (`src/claude/`) -- `client.ts` - High-level API: `callClaude()`, `callClaudeCustom()`, `callClaudeAgent()`, `callClaudeSkill()` -- `process.ts` - SDK wrapper with `ClaudeProcess` class -- `executor.ts` - Query execution using `@anthropic-ai/claude-agent-sdk` -- `query-manager.ts` - Concurrent query tracking with query IDs +**Provider Integration** (`src/infra/claude/`, `src/infra/codex/`) +- **Claude** - Uses `@anthropic-ai/claude-agent-sdk` + - `client.ts` - High-level API: `callClaude()`, `callClaudeCustom()`, `callClaudeAgent()`, `callClaudeSkill()` + - `process.ts` - SDK wrapper with `ClaudeProcess` class + - `executor.ts` - Query execution + - `query-manager.ts` - Concurrent query tracking with query IDs +- **Codex** - Direct OpenAI SDK integration + - `CodexStreamHandler.ts` - Stream handling and tool execution **Configuration** (`src/infra/config/`) -- `loader.ts` - Custom agent loading from `.takt/agents.yaml` -- `workflowLoader.ts` - YAML workflow parsing with Zod validation; resolves user workflows (`~/.takt/workflows/`) with builtin fallback (`resources/global/{lang}/workflows/`) -- `agentLoader.ts` - Agent prompt file loading +- `loaders/loader.ts` - Custom agent loading from `.takt/agents.yaml` +- `loaders/workflowParser.ts` - YAML parsing, step/rule normalization with Zod validation +- `loaders/workflowResolver.ts` - 3-layer resolution (builtin → user → project-local) +- `loaders/workflowCategories.ts` - Workflow categorization and filtering +- `loaders/agentLoader.ts` - Agent prompt file loading - `paths.ts` - Directory structure (`.takt/`, `~/.takt/`), session management +- `global/globalConfig.ts` - Global configuration (provider, model, trusted dirs) +- `project/projectConfig.ts` - Project-level configuration -**Task Management** (`src/infra/task/`) -- `runner.ts` - TaskRunner class for managing task files (`.takt/tasks/`) -- `watcher.ts` - TaskWatcher class for polling and auto-executing tasks (used by `/watch`) -- `index.ts` - Task operations (getNextTask, completeTask, addTask) +**Task Management** (`src/features/tasks/`) +- `execute/taskExecution.ts` - Main task execution orchestration +- `execute/workflowExecution.ts` - Workflow execution wrapper +- `add/index.ts` - Interactive task addition via AI conversation +- `list/index.ts` - List task branches with merge/delete actions +- `watch/index.ts` - Watch for task files and auto-execute -**GitHub Integration** (`src/infra/github/issue.ts`) -- Fetches issues via `gh` CLI, formats as task text with title/body/labels/comments +**GitHub Integration** (`src/infra/github/`) +- `issue.ts` - Fetches issues via `gh` CLI, formats as task text with title/body/labels/comments +- `pr.ts` - Creates pull requests via `gh` CLI ### Data Flow @@ -240,6 +274,36 @@ Key points about parallel steps: | `{user_inputs}` | Accumulated user inputs (auto-injected if not in template) | | `{report_dir}` | Report directory name | +### Workflow Categories + +Workflows can be organized into categories for better UI presentation. Categories are configured in: +- `resources/global/{lang}/default-categories.yaml` - Default builtin categories +- `~/.takt/config.yaml` - User-defined categories (via `workflow_categories` field) + +Category configuration supports: +- Nested categories (unlimited depth) +- Per-category workflow lists +- "Others" category for uncategorized workflows (can be disabled via `show_others_category: false`) +- Builtin workflow filtering (disable via `builtin_workflows_enabled: false`, or selectively via `disabled_builtins: [name1, name2]`) + +Example category config: +```yaml +workflow_categories: + Development: + workflows: [default, simple] + children: + Backend: + workflows: [expert-cqrs] + Frontend: + workflows: [expert] + Research: + workflows: [research, magi] +show_others_category: true +others_category_name: "Other Workflows" +``` + +Implemented in `src/infra/config/loaders/workflowCategories.ts`. + ### Model Resolution Model is resolved in the following priority order: @@ -280,7 +344,7 @@ Files: `.takt/logs/{sessionId}.jsonl`, with `latest.json` pointer. Legacy `.json **Keep commands minimal.** One command per concept. Use arguments/modes instead of multiple similar commands. Before adding a new command, consider if existing commands can be extended. -**Do NOT expand schemas carelessly.** Rule conditions are free-form text (not enum-restricted). However, the engine's behavior depends on specific patterns (`ai()`, `all()`, `any()`). Do not add new special syntax without updating the loader's regex parsing in `workflowLoader.ts`. +**Do NOT expand schemas carelessly.** Rule conditions are free-form text (not enum-restricted). However, the engine's behavior depends on specific patterns (`ai()`, `all()`, `any()`). Do not add new special syntax without updating the loader's regex parsing in `workflowParser.ts`. **Instruction auto-injection over explicit placeholders.** The instruction builder auto-injects `{task}`, `{previous_response}`, `{user_inputs}`, and status rules. Templates should contain only step-specific instructions, not boilerplate. @@ -298,6 +362,15 @@ What belongs in workflow `instruction_template`: - Specific report file names or formats - Comment/output templates with hardcoded review type names +**Separation of concerns in workflow engine:** +- `WorkflowEngine` - Orchestration, state management, event emission +- `StepExecutor` - Single step execution (3-phase model) +- `ParallelRunner` - Parallel step execution +- `RuleEvaluator` - Rule matching and evaluation +- `InstructionBuilder` - Instruction template processing + +**Session management:** Agent sessions are stored per-cwd in `~/.claude/projects/{encoded-path}/` (Claude Code) or in-memory (Codex). Sessions are resumed across phases (Phase 1 → Phase 2 → Phase 3) to maintain context. When `cwd !== projectCwd` (worktree/clone execution), session resume is skipped to avoid cross-directory contamination. + ## Isolated Execution (Shared Clone) When tasks specify `worktree: true` or `worktree: "path"`, code runs in a `git clone --shared` (lightweight clone with independent `.git` directory). Clones are ephemeral: created before task execution, auto-committed + pushed after success, then deleted. @@ -317,6 +390,28 @@ Key constraints: `ClaudeResult` (from SDK) has an `error` field. This must be propagated through `AgentResponse.error` → session log history → console output. Without this, SDK failures (exit code 1, rate limits, auth errors) appear as empty `blocked` status with no diagnostic info. +**Error handling flow:** +1. Provider error (Claude SDK / Codex) → `AgentResponse.error` +2. `StepExecutor` captures error → `WorkflowEngine` emits `step:complete` with error +3. Error logged to session log (`.takt/logs/{sessionId}.jsonl`) +4. Console output shows error details +5. Workflow transitions to `ABORT` step if error is unrecoverable + +## Debugging + +**Debug logging:** Set `debug_enabled: true` in `~/.takt/config.yaml` or create a `.takt/debug.yaml` file: +```yaml +enabled: true +``` + +Debug logs are written to `.takt/logs/debug.log` (ndjson format). Log levels: `debug`, `info`, `warn`, `error`. + +**Verbose mode:** Create `.takt/verbose` file (empty file) to enable verbose console output. This automatically enables debug logging and sets log level to `debug`. + +**Session logs:** All workflow executions are logged to `.takt/logs/{sessionId}.jsonl`. Use `tail -f .takt/logs/{sessionId}.jsonl` to monitor in real-time. + +**Testing with mocks:** Use `--provider mock` to test workflows without calling real AI APIs. Mock responses are deterministic and configurable via test fixtures. + ## Testing Notes - Vitest for testing framework @@ -324,3 +419,54 @@ Key constraints: - Mock workflows and agent configs for integration tests - Test single files: `npx vitest run src/__tests__/filename.test.ts` - Pattern matching: `npx vitest run -t "test pattern"` +- Integration tests: Tests with `it-` prefix are integration tests that simulate full workflow execution +- Engine tests: Tests with `engine-` prefix test specific WorkflowEngine scenarios (happy path, error handling, parallel execution, etc.) + +## Important Implementation Notes + +**Agent prompt resolution:** +- Agent paths in workflow YAML are resolved relative to the workflow file's directory +- `../agents/default/coder.md` resolves from workflow file location +- Built-in agents are loaded from `dist/resources/global/{lang}/agents/` +- User agents are loaded from `~/.takt/agents/` or `.takt/agents.yaml` +- If agent file doesn't exist, the agent string is used as inline system prompt + +**Report directory structure:** +- Report dirs are created at `.takt/reports/{timestamp}-{slug}/` +- 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 still write to `projectCwd/.takt/reports/` + +**Session continuity across phases:** +- Agent sessions persist across Phase 1 → Phase 2 → Phase 3 for context continuity +- Session ID is passed via `resumeFrom` in `RunAgentOptions` +- Sessions are stored per-cwd, so worktree executions create new sessions +- Use `takt clear` to reset all agent sessions + +**Worktree execution gotchas:** +- `git clone --shared` creates independent `.git` directory (not `git worktree`) +- Clone cwd ≠ project cwd: agents work in clone, but reports/logs write to project +- Session resume is skipped when `cwd !== projectCwd` to avoid cross-directory contamination +- Clones are ephemeral: created → task runs → auto-commit + push → deleted +- Use `takt list` to manage task branches after clone deletion + +**Rule evaluation quirks:** +- Tag-based rules match by array index (0-based), not by exact condition text +- `ai()` conditions are evaluated by Claude/Codex, not by string matching +- Aggregate conditions (`all()`, `any()`) only work in parallel parent steps +- Fail-fast: if rules exist but no rule matches, workflow aborts +- Interactive-only rules are skipped in pipeline mode (`rule.interactiveOnly === true`) + +**Provider-specific behavior:** +- Claude: Uses session files in `~/.claude/projects/`, supports skill/agent calls +- Codex: In-memory sessions, no skill/agent calls +- Model names are passed directly to provider (no alias resolution in TAKT) +- Claude supports aliases: `opus`, `sonnet`, `haiku` +- Codex defaults to `codex` if model not specified + +**Permission modes:** +- `default`: Claude Code default behavior (prompts for file writes) +- `acceptEdits`: Auto-accept file edits without prompts +- `bypassPermissions`: Bypass all permission checks +- Specified at step level (`permission_mode` field) or global config +- Implemented via `--sandbox-mode` and `--accept-edits` flags passed to Claude Code CLI diff --git a/resources/global/en/agents/default/coder.md b/resources/global/en/agents/default/coder.md index 2594873..b530686 100644 --- a/resources/global/en/agents/default/coder.md +++ b/resources/global/en/agents/default/coder.md @@ -23,7 +23,7 @@ You are the implementer. **Focus on implementation, not design decisions.** - Writing unused code "just in case" → Prohibited (will be flagged in review) - Making design decisions arbitrarily → Report and ask for guidance - Dismissing reviewer feedback → Prohibited (your understanding is wrong) -- **Adding legacy compatibility without being asked → Prohibited (unnecessary unless explicitly instructed)** +- **Adding backward compatibility or legacy support without being asked → Absolutely prohibited (fallbacks, old API maintenance, migration code, etc. are unnecessary unless explicitly instructed)** ## Most Important Rule diff --git a/resources/global/en/config.yaml b/resources/global/en/config.yaml index 3e8a12e..b9a5427 100644 --- a/resources/global/en/config.yaml +++ b/resources/global/en/config.yaml @@ -16,6 +16,9 @@ log_level: info # Provider runtime: claude or codex provider: claude +# Builtin workflows (resources/global/{lang}/workflows) +# enable_builtin_workflows: true + # Default model (optional) # Claude: opus, sonnet, haiku, opusplan, default, or full model name # Codex: gpt-5.2-codex, gpt-5.1-codex, etc. diff --git a/resources/global/en/default-categories.yaml b/resources/global/en/default-categories.yaml index 2193277..2f19296 100644 --- a/resources/global/en/default-categories.yaml +++ b/resources/global/en/default-categories.yaml @@ -1,25 +1,30 @@ workflow_categories: "🚀 Quick Start": - - minimal - - default + workflows: + - minimal + - default "🔍 Review & Fix": - - review-fix-minimal + workflows: + - review-fix-minimal "🎨 Frontend": - [] + {} "⚙️ Backend": - [] + {} - "🔧 Full Stack": - - expert - - expert-cqrs + "🔧 Expert": + "Full Stack": + workflows: + - expert + - expert-cqrs "Others": - - research - - magi - - review-only + workflows: + - research + - magi + - review-only show_others_category: true others_category_name: "Others" diff --git a/resources/global/en/workflows/default.yaml b/resources/global/en/workflows/default.yaml index b20a98a..e2685b8 100644 --- a/resources/global/en/workflows/default.yaml +++ b/resources/global/en/workflows/default.yaml @@ -1,5 +1,5 @@ # Default TAKT Workflow -# Plan -> Coder -> AI Review -> Reviewers (parallel: Architect + Security) -> Supervisor Approval +# Plan -> Architect -> Implement -> AI Review -> Reviewers (parallel: Architect + Security) -> Supervisor Approval # # Boilerplate sections (Workflow Context, User Request, Previous Response, # Additional User Inputs, Instructions heading) are auto-injected by buildInstruction(). @@ -63,7 +63,7 @@ steps: - WebFetch rules: - condition: Requirements are clear and implementable - next: implement + next: architect - condition: User is asking a question (not an implementation task) next: COMPLETE - condition: Requirements unclear, insufficient info @@ -84,13 +84,79 @@ steps: 2. Identify impact scope 3. Decide implementation approach + - name: architect + edit: false + agent: ../agents/default/architect.md + report: + name: 01-architecture.md + format: | + ```markdown + # Architecture Design + + ## Task Size + Small / Medium / Large + + ## Design Decisions + + ### File Structure + | File | Role | + |------|------| + | `src/example.ts` | Summary | + + ### Technology Selection + - {Selected technologies/libraries and reasoning} + + ### Design Patterns + - {Patterns to adopt and where to apply} + + ## Implementation Guidelines + - {Guidelines for Coder to follow during implementation} + ``` + allowed_tools: + - Read + - Glob + - Grep + - Write + - WebSearch + - WebFetch + rules: + - condition: Small task (no design needed) + next: implement + - condition: Design complete + next: implement + - condition: Insufficient info, cannot proceed + next: ABORT + pass_previous_response: true + instruction_template: | + Read the plan report ({report:00-plan.md}) and perform architecture design. + + **Small task criteria:** + - Only 1-2 files to modify + - Can follow existing patterns + - No technology selection needed + + For small tasks, skip the design report and use the "Small task (no design needed)" rule. + + **Tasks requiring design:** + - 3+ files to modify + - Adding new modules/features + - Technology selection needed + - Architecture pattern decisions needed + + **Tasks:** + 1. Evaluate task size + 2. Decide file structure + 3. Select technology (if needed) + 4. Choose design patterns + 5. Create implementation guidelines for Coder + - name: implement edit: true agent: ../agents/default/coder.md session: refresh report: - - Scope: 01-coder-scope.md - - Decisions: 02-coder-decisions.md + - Scope: 02-coder-scope.md + - Decisions: 03-coder-decisions.md allowed_tools: - Read - Glob @@ -113,10 +179,17 @@ steps: requires_user_input: true interactive_only: true instruction_template: | - Follow the plan from the plan step and implement. - Refer to the plan report ({report:00-plan.md}) and proceed with implementation. + Follow the plan from the plan step and the design from the architect step. + + **Reports to reference:** + - Plan: {report:00-plan.md} + - Design: {report:01-architecture.md} (if exists) + Use only the Report Directory files shown in Workflow Context. Do not search or open reports outside that directory. + **Important:** Do not make design decisions; follow the design determined in the architect step. + Report if you encounter unclear points or need design changes. + **Scope report format (create at implementation start):** ```markdown # Change Scope Declaration @@ -162,7 +235,7 @@ steps: agent: ../agents/default/ai-antipattern-reviewer.md pass_previous_response: true report: - name: 03-ai-review.md + name: 04-ai-review.md format: | ```markdown # AI-Generated Code Review @@ -278,7 +351,7 @@ steps: edit: false agent: ../agents/default/architecture-reviewer.md report: - name: 04-architect-review.md + name: 05-architect-review.md format: | ```markdown # Architecture Review @@ -318,15 +391,28 @@ steps: - condition: approved - condition: needs_fix instruction_template: | - Focus on **architecture and design** review. Do NOT review AI-specific issues (that's the ai_review step). + **Verify that the implementation follows the design from the architect step.** + Do NOT review AI-specific issues (that's the ai_review step). - Review the changes and provide feedback. + **Reports to reference:** + - Design: {report:01-architecture.md} (if exists) + - Implementation scope: {report:02-coder-scope.md} + + **Review perspectives:** + - Design consistency (does it follow the file structure and patterns defined by architect?) + - Code quality + - Change scope appropriateness + - Test coverage + - Dead code + - Call chain verification + + **Note:** For small tasks that skipped the architect step, review design validity as usual. - name: security-review edit: false agent: ../agents/default/security-reviewer.md report: - name: 05-security-review.md + name: 06-security-review.md format: | ```markdown # Security Review @@ -417,7 +503,7 @@ steps: edit: false agent: ../agents/default/supervisor.md report: - - Validation: 06-supervisor-validation.md + - Validation: 07-supervisor-validation.md - Summary: summary.md allowed_tools: - Read @@ -436,7 +522,7 @@ steps: Run tests, verify the build, and perform final approval. **Workflow Overall Review:** - 1. Does the implementation match the plan ({report:00-plan.md})? + 1. Does the implementation match the plan ({report:00-plan.md}) and design ({report:01-architecture.md}, if exists)? 2. Were all review step issues addressed? 3. Was the original task objective achieved? @@ -485,8 +571,9 @@ steps: ## Review Results | Review | Result | |--------|--------| - | Architect | ✅ APPROVE | + | Architecture Design | ✅ Complete | | AI Review | ✅ APPROVE | + | Architect Review | ✅ APPROVE | | Security | ✅ APPROVE | | Supervisor | ✅ APPROVE | diff --git a/resources/global/ja/agents/default/coder.md b/resources/global/ja/agents/default/coder.md index 0da40f8..35214bb 100644 --- a/resources/global/ja/agents/default/coder.md +++ b/resources/global/ja/agents/default/coder.md @@ -23,7 +23,7 @@ - 「念のため」で未使用コードを書く → 禁止(レビューで指摘される) - 設計判断を勝手にする → 報告して判断を仰ぐ - レビュワーの指摘を軽視する → 禁止(あなたの認識が間違っている) -- **Legacy対応を勝手に追加する → 禁止(明示的な指示がない限り不要)** +- **後方互換・Legacy対応を勝手に追加する → 絶対禁止(フォールバック、古いAPI維持、移行期コードなど、明示的な指示がない限り不要)** ## 最重要ルール diff --git a/resources/global/ja/config.yaml b/resources/global/ja/config.yaml index d017451..d5da6aa 100644 --- a/resources/global/ja/config.yaml +++ b/resources/global/ja/config.yaml @@ -16,6 +16,9 @@ log_level: info # プロバイダー: claude または codex provider: claude +# ビルトインワークフローの読み込み (resources/global/{lang}/workflows) +# enable_builtin_workflows: true + # デフォルトモデル (オプション) # Claude: opus, sonnet, haiku, opusplan, default, またはフルモデル名 # Codex: gpt-5.2-codex, gpt-5.1-codex など diff --git a/resources/global/ja/default-categories.yaml b/resources/global/ja/default-categories.yaml index 81b71a0..41b7a9f 100644 --- a/resources/global/ja/default-categories.yaml +++ b/resources/global/ja/default-categories.yaml @@ -1,25 +1,29 @@ workflow_categories: "🚀 クイックスタート": - - minimal - - default + workflows: + - default + - minimal "🔍 レビュー&修正": - - review-fix-minimal + workflows: + - review-fix-minimal "🎨 フロントエンド": - [] + {} "⚙️ バックエンド": - [] + {} "🔧 フルスタック": - - expert - - expert-cqrs + workflows: + - expert + - expert-cqrs "その他": - - research - - magi - - review-only + workflows: + - research + - magi + - review-only show_others_category: true others_category_name: "その他" diff --git a/resources/global/ja/workflows/default.yaml b/resources/global/ja/workflows/default.yaml index 14f9a4a..40ec305 100644 --- a/resources/global/ja/workflows/default.yaml +++ b/resources/global/ja/workflows/default.yaml @@ -1,5 +1,5 @@ # Default TAKT Workflow -# Plan -> Implement -> AI Review -> Reviewers (parallel: Architect + Security) -> Supervisor Approval +# Plan -> Architect -> Implement -> AI Review -> Reviewers (parallel: Architect + Security) -> Supervisor Approval # # Template Variables (auto-injected by buildInstruction): # {iteration} - Workflow-wide turn count (total steps executed across all agents) @@ -54,7 +54,7 @@ steps: - WebFetch rules: - condition: 要件が明確で実装可能 - next: implement + next: architect - condition: ユーザーが質問をしている(実装タスクではない) next: COMPLETE - condition: 要件が不明確、情報不足 @@ -75,13 +75,79 @@ steps: 2. 影響範囲を特定する 3. 実装アプローチを決める + - name: architect + edit: false + agent: ../agents/default/architect.md + report: + name: 01-architecture.md + format: | + ```markdown + # アーキテクチャ設計 + + ## タスク規模 + Small / Medium / Large + + ## 設計判断 + + ### ファイル構成 + | ファイル | 役割 | + |---------|------| + | `src/example.ts` | 概要 | + + ### 技術選定 + - {選定した技術・ライブラリとその理由} + + ### 設計パターン + - {採用するパターンと適用箇所} + + ## 実装ガイドライン + - {Coderが実装時に従うべき指針} + ``` + allowed_tools: + - Read + - Glob + - Grep + - Write + - WebSearch + - WebFetch + rules: + - condition: 小規模タスク(設計不要) + next: implement + - condition: 設計完了 + next: implement + - condition: 情報不足、判断できない + next: ABORT + pass_previous_response: true + instruction_template: | + 計画レポート({report:00-plan.md})を読み、アーキテクチャ設計を行ってください。 + + **小規模タスクの判断基準:** + - 1-2ファイルの変更のみ + - 既存パターンの踏襲で済む + - 技術選定が不要 + + 小規模タスクの場合は設計レポートを作成せず、「小規模タスク(設計不要)」のルールに対応してください。 + + **設計が必要なタスク:** + - 3ファイル以上の変更 + - 新しいモジュール・機能の追加 + - 技術選定が必要 + - アーキテクチャパターンの決定が必要 + + **やること:** + 1. タスクの規模を評価 + 2. ファイル構成を決定 + 3. 技術選定(必要な場合) + 4. 設計パターンの選択 + 5. Coderへの実装ガイドライン作成 + - name: implement edit: true agent: ../agents/default/coder.md session: refresh report: - - Scope: 01-coder-scope.md - - Decisions: 02-coder-decisions.md + - Scope: 02-coder-scope.md + - Decisions: 03-coder-decisions.md allowed_tools: - Read - Glob @@ -104,10 +170,17 @@ steps: requires_user_input: true interactive_only: true instruction_template: | - planステップで立てた計画に従って実装してください。 - 計画レポート({report:00-plan.md})を参照し、実装を進めてください。 + planステップで立てた計画と、architectステップで決定した設計に従って実装してください。 + + **参照するレポート:** + - 計画: {report:00-plan.md} + - 設計: {report:01-architecture.md}(存在する場合) + Workflow Contextに示されたReport Directory内のファイルのみ参照してください。他のレポートディレクトリは検索/参照しないでください。 + **重要:** 設計判断はせず、architectステップで決定された設計に従ってください。 + 不明点や設計の変更が必要な場合は報告してください。 + **重要**: 実装と同時に単体テストを追加してください。 - 新規作成したクラス・関数には単体テストを追加 - 既存コードを変更した場合は該当するテストを更新 @@ -157,7 +230,7 @@ steps: agent: ../agents/default/ai-antipattern-reviewer.md pass_previous_response: true report: - name: 03-ai-review.md + name: 04-ai-review.md format: | ```markdown # AI生成コードレビュー @@ -273,7 +346,7 @@ steps: edit: false agent: ../agents/default/architecture-reviewer.md report: - name: 04-architect-review.md + name: 05-architect-review.md format: | ```markdown # アーキテクチャレビュー @@ -316,23 +389,28 @@ steps: - condition: approved - condition: needs_fix instruction_template: | - **アーキテクチャと設計**のレビューに集中してください。AI特有の問題はレビューしないでください(ai_reviewステップで行います)。 + **実装がarchitectステップの設計に従っているか**を確認してください。 + AI特有の問題はレビューしないでください(ai_reviewステップで行います)。 - 変更をレビューしてフィードバックを提供してください。 + **参照するレポート:** + - 設計: {report:01-architecture.md}(存在する場合) + - 実装スコープ: {report:02-coder-scope.md} **レビュー観点:** - - 構造・設計の妥当性 + - 設計との整合性(architectが定めたファイル構成・パターンに従っているか) - コード品質 - 変更スコープの適切性 - テストカバレッジ - デッドコード - 呼び出しチェーン検証 + **注意:** architectステップをスキップした小規模タスクの場合は、従来通り設計の妥当性も確認してください。 + - name: security-review edit: false agent: ../agents/default/security-reviewer.md report: - name: 05-security-review.md + name: 06-security-review.md format: | ```markdown # セキュリティレビュー @@ -422,7 +500,7 @@ steps: edit: false agent: ../agents/default/supervisor.md report: - - Validation: 06-supervisor-validation.md + - Validation: 07-supervisor-validation.md - Summary: summary.md allowed_tools: - Read @@ -441,7 +519,7 @@ steps: テスト実行、ビルド確認、最終承認を行ってください。 **ワークフロー全体の確認:** - 1. 計画({report:00-plan.md})と実装結果が一致しているか + 1. 計画({report:00-plan.md})と設計({report:01-architecture.md}、存在する場合)に従った実装か 2. 各レビューステップの指摘が対応されているか 3. 元のタスク目的が達成されているか @@ -490,8 +568,9 @@ steps: ## レビュー結果 | レビュー | 結果 | |---------|------| - | Architect | ✅ APPROVE | + | Architecture Design | ✅ 完了 | | AI Review | ✅ APPROVE | + | Architect Review | ✅ APPROVE | | Security | ✅ APPROVE | | Supervisor | ✅ APPROVE | diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 7bbfacd..ef7b524 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -18,6 +18,7 @@ vi.mock('../infra/providers/index.js', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })), + getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true), })); vi.mock('../shared/prompt/index.js', () => ({ diff --git a/src/__tests__/bookmark.test.ts b/src/__tests__/bookmark.test.ts index af45afc..df65879 100644 --- a/src/__tests__/bookmark.test.ts +++ b/src/__tests__/bookmark.test.ts @@ -45,27 +45,25 @@ describe('applyBookmarks', () => { { label: 'delta', value: 'delta' }, ]; - it('should move bookmarked items to the top with ★ prefix', () => { + it('should add [*] suffix to bookmarked items without changing order', () => { const result = applyBookmarks(options, ['gamma']); - expect(result[0]!.label).toBe('★ gamma'); - expect(result[0]!.value).toBe('gamma'); + expect(result[2]!.label).toBe('gamma [*]'); + expect(result[2]!.value).toBe('gamma'); expect(result).toHaveLength(4); }); - it('should preserve order of non-bookmarked items', () => { + it('should preserve original order of all items', () => { const result = applyBookmarks(options, ['gamma']); - const rest = result.slice(1); - expect(rest.map((o) => o.value)).toEqual(['alpha', 'beta', 'delta']); + expect(result.map((o) => o.value)).toEqual(['alpha', 'beta', 'gamma', 'delta']); }); - it('should handle multiple bookmarks preserving their relative order', () => { + it('should handle multiple bookmarks preserving original order', () => { const result = applyBookmarks(options, ['delta', 'alpha']); - // Bookmarked items appear first, in the order they appear in options (not in bookmarks array) expect(result[0]!.value).toBe('alpha'); - expect(result[0]!.label).toBe('★ alpha'); - expect(result[1]!.value).toBe('delta'); - expect(result[1]!.label).toBe('★ delta'); - expect(result.slice(2).map((o) => o.value)).toEqual(['beta', 'gamma']); + expect(result[0]!.label).toBe('alpha [*]'); + expect(result[3]!.value).toBe('delta'); + expect(result[3]!.label).toBe('delta [*]'); + expect(result.map((o) => o.value)).toEqual(['alpha', 'beta', 'gamma', 'delta']); }); it('should return unchanged options when no bookmarks', () => { @@ -92,13 +90,13 @@ describe('applyBookmarks', () => { ]; // Only workflow values should match; categories are not bookmarkable const result = applyBookmarks(categoryOptions, ['simple']); - expect(result[0]!.label).toBe('★ simple'); - expect(result.slice(1).map((o) => o.value)).toEqual(['__category__:frontend', '__category__:backend']); + expect(result[0]!.label).toBe('simple [*]'); + expect(result.map((o) => o.value)).toEqual(['simple', '__category__:frontend', '__category__:backend']); }); it('should handle all items bookmarked', () => { const result = applyBookmarks(options, ['alpha', 'beta', 'gamma', 'delta']); - expect(result.every((o) => o.label.startsWith('★ '))).toBe(true); + expect(result.every((o) => o.label.endsWith(' [*]'))).toBe(true); expect(result.map((o) => o.value)).toEqual(['alpha', 'beta', 'gamma', 'delta']); }); }); diff --git a/src/__tests__/clone.test.ts b/src/__tests__/clone.test.ts index 2853d45..ef3df3e 100644 --- a/src/__tests__/clone.test.ts +++ b/src/__tests__/clone.test.ts @@ -32,6 +32,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(() => ({})), + getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true), })); import { execFileSync } from 'node:child_process'; diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index d44540e..5da7571 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })), + getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true), })); vi.mock('../infra/providers/index.js', () => ({ diff --git a/src/__tests__/it-error-recovery.test.ts b/src/__tests__/it-error-recovery.test.ts index 4c9c376..302f8e6 100644 --- a/src/__tests__/it-error-recovery.test.ts +++ b/src/__tests__/it-error-recovery.test.ts @@ -42,6 +42,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), getDisabledBuiltins: vi.fn().mockReturnValue([]), + getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true), })); vi.mock('../infra/config/project/projectConfig.js', () => ({ diff --git a/src/__tests__/it-instruction-builder.test.ts b/src/__tests__/it-instruction-builder.test.ts index a5a7fe0..5041cf1 100644 --- a/src/__tests__/it-instruction-builder.test.ts +++ b/src/__tests__/it-instruction-builder.test.ts @@ -13,6 +13,7 @@ import type { WorkflowStep, WorkflowRule, AgentResponse } from '../core/models/i vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), + getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true), })); import { InstructionBuilder } from '../core/workflow/index.js'; diff --git a/src/__tests__/it-rule-evaluation.test.ts b/src/__tests__/it-rule-evaluation.test.ts index 9b6cc8c..d1a7963 100644 --- a/src/__tests__/it-rule-evaluation.test.ts +++ b/src/__tests__/it-rule-evaluation.test.ts @@ -24,6 +24,7 @@ const mockCallAiJudge = vi.fn(); vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), + getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true), })); vi.mock('../infra/config/project/projectConfig.js', () => ({ diff --git a/src/__tests__/it-three-phase-execution.test.ts b/src/__tests__/it-three-phase-execution.test.ts index cfa6805..ece1f53 100644 --- a/src/__tests__/it-three-phase-execution.test.ts +++ b/src/__tests__/it-three-phase-execution.test.ts @@ -47,6 +47,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), getDisabledBuiltins: vi.fn().mockReturnValue([]), + getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true), })); vi.mock('../infra/config/project/projectConfig.js', () => ({ diff --git a/src/__tests__/it-workflow-execution.test.ts b/src/__tests__/it-workflow-execution.test.ts index b682e02..5bf4782 100644 --- a/src/__tests__/it-workflow-execution.test.ts +++ b/src/__tests__/it-workflow-execution.test.ts @@ -45,6 +45,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), + getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true), })); vi.mock('../infra/config/project/projectConfig.js', () => ({ diff --git a/src/__tests__/it-workflow-loader.test.ts b/src/__tests__/it-workflow-loader.test.ts index 82365d1..acb2ed9 100644 --- a/src/__tests__/it-workflow-loader.test.ts +++ b/src/__tests__/it-workflow-loader.test.ts @@ -19,6 +19,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), getDisabledBuiltins: vi.fn().mockReturnValue([]), + getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true), })); // --- Imports (after mocks) --- @@ -189,13 +190,13 @@ describe('Workflow Loader IT: rule syntax parsing', () => { const config = loadWorkflow('minimal', testDir); expect(config).not.toBeNull(); - const planStep = config!.steps.find((s) => s.name === 'plan'); - expect(planStep).toBeDefined(); - expect(planStep!.rules).toBeDefined(); - expect(planStep!.rules!.length).toBeGreaterThan(0); + const implementStep = config!.steps.find((s) => s.name === 'implement'); + expect(implementStep).toBeDefined(); + expect(implementStep!.rules).toBeDefined(); + expect(implementStep!.rules!.length).toBeGreaterThan(0); // Each rule should have condition and next - for (const rule of planStep!.rules!) { + for (const rule of implementStep!.rules!) { expect(typeof rule.condition).toBe('string'); expect(rule.condition.length).toBeGreaterThan(0); } @@ -320,10 +321,10 @@ describe('Workflow Loader IT: report config loading', () => { }); it('should load single report config', () => { - const config = loadWorkflow('minimal', testDir); + const config = loadWorkflow('default', testDir); expect(config).not.toBeNull(); - // simple workflow: plan step has a report config + // default workflow: plan step has a report config const planStep = config!.steps.find((s) => s.name === 'plan'); expect(planStep).toBeDefined(); expect(planStep!.report).toBeDefined(); diff --git a/src/__tests__/it-workflow-patterns.test.ts b/src/__tests__/it-workflow-patterns.test.ts index a725bec..f52c965 100644 --- a/src/__tests__/it-workflow-patterns.test.ts +++ b/src/__tests__/it-workflow-patterns.test.ts @@ -21,7 +21,15 @@ vi.mock('../infra/claude/client.js', async (importOriginal) => { const original = await importOriginal(); return { ...original, - callAiJudge: vi.fn().mockResolvedValue(-1), + callAiJudge: vi.fn().mockImplementation(async (rules: { condition: string }[], content: string) => { + // Simple text matching: return index of first rule whose condition appears in content + for (let i = 0; i < rules.length; i++) { + if (content.includes(rules[i]!.condition)) { + return i; + } + } + return -1; + }), }; }); @@ -41,6 +49,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), getLanguage: vi.fn().mockReturnValue('en'), getDisabledBuiltins: vi.fn().mockReturnValue([]), + getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true), })); vi.mock('../infra/config/project/projectConfig.js', () => ({ @@ -88,9 +97,9 @@ describe('Workflow Patterns IT: minimal workflow', () => { expect(config).not.toBeNull(); setMockScenario([ - { agent: 'coder', status: 'done', content: '[IMPLEMENT:0]\n\nImplementation complete.' }, - { agent: 'ai-antipattern-reviewer', status: 'done', content: '[AI_REVIEW:0]\n\nNo AI-specific issues.' }, - { agent: 'supervisor', status: 'done', content: '[SUPERVISE:0]\n\nAll checks passed.' }, + { agent: 'coder', status: 'done', content: 'Implementation complete.' }, + { agent: 'ai-antipattern-reviewer', status: 'done', content: 'No AI-specific issues.' }, + { agent: 'supervisor', status: 'done', content: 'All checks passed.' }, ]); const engine = createEngine(config!, testDir, 'Test task'); @@ -104,7 +113,7 @@ describe('Workflow Patterns IT: minimal workflow', () => { const config = loadWorkflow('minimal', testDir); setMockScenario([ - { agent: 'coder', status: 'done', content: '[IMPLEMENT:1]\n\nCannot proceed, insufficient info.' }, + { agent: 'coder', status: 'done', content: 'Cannot proceed, insufficient info.' }, ]); const engine = createEngine(config!, testDir, 'Vague task'); diff --git a/src/__tests__/summarize.test.ts b/src/__tests__/summarize.test.ts index e4c1c76..52940c0 100644 --- a/src/__tests__/summarize.test.ts +++ b/src/__tests__/summarize.test.ts @@ -10,6 +10,7 @@ vi.mock('../infra/providers/index.js', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(), + getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ diff --git a/src/__tests__/workflow-builtin-toggle.test.ts b/src/__tests__/workflow-builtin-toggle.test.ts new file mode 100644 index 0000000..c872109 --- /dev/null +++ b/src/__tests__/workflow-builtin-toggle.test.ts @@ -0,0 +1,49 @@ +/** + * Tests for builtin workflow enable/disable flag + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { + const original = await importOriginal() as Record; + return { + ...original, + getLanguage: () => 'en', + getDisabledBuiltins: () => [], + getBuiltinWorkflowsEnabled: () => false, + }; +}); + +const { listWorkflows } = await import('../infra/config/loaders/workflowLoader.js'); + +const SAMPLE_WORKFLOW = `name: test-workflow +steps: + - name: step1 + agent: coder + instruction: "{task}" +`; + +describe('builtin workflow toggle', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-test-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should exclude builtin workflows when disabled', () => { + const projectWorkflowsDir = join(tempDir, '.takt', 'workflows'); + mkdirSync(projectWorkflowsDir, { recursive: true }); + writeFileSync(join(projectWorkflowsDir, 'project-custom.yaml'), SAMPLE_WORKFLOW); + + const workflows = listWorkflows(tempDir); + expect(workflows).toContain('project-custom'); + expect(workflows).not.toContain('default'); + }); +}); diff --git a/src/__tests__/workflow-categories.test.ts b/src/__tests__/workflow-categories.test.ts index 4e922f7..9cbc6be 100644 --- a/src/__tests__/workflow-categories.test.ts +++ b/src/__tests__/workflow-categories.test.ts @@ -127,9 +127,11 @@ describe('workflow categories - listWorkflowEntries', () => { expect(simpleEntry).toBeDefined(); expect(simpleEntry!.category).toBeUndefined(); + expect(simpleEntry!.source).toBe('project'); expect(reactEntry).toBeDefined(); expect(reactEntry!.category).toBe('frontend'); + expect(reactEntry!.source).toBe('project'); }); }); @@ -199,10 +201,10 @@ describe('workflow categories - loadWorkflow', () => { describe('buildWorkflowSelectionItems', () => { it('should separate root workflows and categories', () => { const entries: WorkflowDirEntry[] = [ - { name: 'simple', path: '/tmp/simple.yaml' }, - { name: 'frontend/react', path: '/tmp/frontend/react.yaml', category: 'frontend' }, - { name: 'frontend/vue', path: '/tmp/frontend/vue.yaml', category: 'frontend' }, - { name: 'backend/api', path: '/tmp/backend/api.yaml', category: 'backend' }, + { name: 'simple', path: '/tmp/simple.yaml', source: 'project' }, + { name: 'frontend/react', path: '/tmp/frontend/react.yaml', category: 'frontend', source: 'project' }, + { name: 'frontend/vue', path: '/tmp/frontend/vue.yaml', category: 'frontend', source: 'project' }, + { name: 'backend/api', path: '/tmp/backend/api.yaml', category: 'backend', source: 'project' }, ]; const items = buildWorkflowSelectionItems(entries); @@ -225,9 +227,9 @@ describe('buildWorkflowSelectionItems', () => { it('should sort items alphabetically', () => { const entries: WorkflowDirEntry[] = [ - { name: 'zebra', path: '/tmp/zebra.yaml' }, - { name: 'alpha', path: '/tmp/alpha.yaml' }, - { name: 'misc/playground', path: '/tmp/misc/playground.yaml', category: 'misc' }, + { name: 'zebra', path: '/tmp/zebra.yaml', source: 'project' }, + { name: 'alpha', path: '/tmp/alpha.yaml', source: 'project' }, + { name: 'misc/playground', path: '/tmp/misc/playground.yaml', category: 'misc', source: 'project' }, ]; const items = buildWorkflowSelectionItems(entries); diff --git a/src/__tests__/workflow-category-config.test.ts b/src/__tests__/workflow-category-config.test.ts index ffc6459..2fb7a8b 100644 --- a/src/__tests__/workflow-category-config.test.ts +++ b/src/__tests__/workflow-category-config.test.ts @@ -7,7 +7,7 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; -import type { WorkflowConfig } from '../core/models/index.js'; +import type { WorkflowWithSource } from '../infra/config/index.js'; const pathsState = vi.hoisted(() => ({ globalConfigPath: '', @@ -32,6 +32,12 @@ vi.mock('../infra/resources/index.js', async (importOriginal) => { }; }); +const workflowCategoriesState = vi.hoisted(() => ({ + categories: undefined as any, + showOthersCategory: undefined as boolean | undefined, + othersCategoryName: undefined as string | undefined, +})); + vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { const original = await importOriginal() as Record; return { @@ -40,6 +46,16 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { }; }); +vi.mock('../infra/config/global/workflowCategories.js', async (importOriginal) => { + const original = await importOriginal() as Record; + return { + ...original, + getWorkflowCategoriesConfig: () => workflowCategoriesState.categories, + getShowOthersCategory: () => workflowCategoriesState.showOthersCategory, + getOthersCategoryName: () => workflowCategoriesState.othersCategoryName, + }; +}); + const { getWorkflowCategories, loadDefaultCategories, @@ -51,14 +67,18 @@ function writeYaml(path: string, content: string): void { writeFileSync(path, content.trim() + '\n', 'utf-8'); } -function createWorkflowMap(names: string[]): Map { - const workflows = new Map(); - for (const name of names) { - workflows.set(name, { - name, - steps: [], - initialStep: 'start', - maxIterations: 1, +function createWorkflowMap(entries: { name: string; source: 'builtin' | 'user' | 'project' }[]): + Map { + const workflows = new Map(); + for (const entry of entries) { + workflows.set(entry.name, { + source: entry.source, + config: { + name: entry.name, + steps: [], + initialStep: 'start', + maxIterations: 1, + }, }); } return workflows; @@ -81,6 +101,10 @@ describe('workflow category config loading', () => { pathsState.projectConfigPath = projectConfigPath; pathsState.resourcesDir = resourcesDir; + // Reset workflow categories state + workflowCategoriesState.categories = undefined; + workflowCategoriesState.showOthersCategory = undefined; + workflowCategoriesState.othersCategoryName = undefined; }); afterEach(() => { @@ -91,33 +115,40 @@ describe('workflow category config loading', () => { writeYaml(join(resourcesDir, 'default-categories.yaml'), ` workflow_categories: Default: - - simple + workflows: + - simple show_others_category: true others_category_name: "Others" `); const config = getWorkflowCategories(testDir); expect(config).not.toBeNull(); - expect(config!.workflowCategories).toEqual({ Default: ['simple'] }); + expect(config!.workflowCategories).toEqual([ + { name: 'Default', workflows: ['simple'], children: [] }, + ]); }); it('should prefer project config over default when workflow_categories is defined', () => { writeYaml(join(resourcesDir, 'default-categories.yaml'), ` workflow_categories: Default: - - simple + workflows: + - simple `); writeYaml(projectConfigPath, ` workflow_categories: Project: - - custom + workflows: + - custom show_others_category: false `); const config = getWorkflowCategories(testDir); expect(config).not.toBeNull(); - expect(config!.workflowCategories).toEqual({ Project: ['custom'] }); + expect(config!.workflowCategories).toEqual([ + { name: 'Project', workflows: ['custom'], children: [] }, + ]); expect(config!.showOthersCategory).toBe(false); }); @@ -125,31 +156,37 @@ show_others_category: false writeYaml(join(resourcesDir, 'default-categories.yaml'), ` workflow_categories: Default: - - simple + workflows: + - simple `); writeYaml(projectConfigPath, ` workflow_categories: Project: - - custom + workflows: + - custom `); - writeYaml(globalConfigPath, ` -workflow_categories: - User: - - preferred -`); + // Simulate user config from separate file + workflowCategoriesState.categories = { + User: { + workflows: ['preferred'], + }, + }; const config = getWorkflowCategories(testDir); expect(config).not.toBeNull(); - expect(config!.workflowCategories).toEqual({ User: ['preferred'] }); + expect(config!.workflowCategories).toEqual([ + { name: 'User', workflows: ['preferred'], children: [] }, + ]); }); it('should ignore configs without workflow_categories and fall back to default', () => { writeYaml(join(resourcesDir, 'default-categories.yaml'), ` workflow_categories: Default: - - simple + workflows: + - simple `); writeYaml(globalConfigPath, ` @@ -158,7 +195,9 @@ show_others_category: false const config = getWorkflowCategories(testDir); expect(config).not.toBeNull(); - expect(config!.workflowCategories).toEqual({ Default: ['simple'] }); + expect(config!.workflowCategories).toEqual([ + { name: 'Default', workflows: ['simple'], children: [] }, + ]); }); it('should return null when default categories file is missing', () => { @@ -168,47 +207,76 @@ show_others_category: false }); describe('buildCategorizedWorkflows', () => { - beforeEach(() => { - }); - it('should warn for missing workflows and generate Others', () => { - const allWorkflows = createWorkflowMap(['a', 'b', 'c']); + const allWorkflows = createWorkflowMap([ + { name: 'a', source: 'user' }, + { name: 'b', source: 'user' }, + { name: 'c', source: 'builtin' }, + ]); const config = { - workflowCategories: { Cat: ['a', 'missing'] }, + workflowCategories: [ + { + name: 'Cat', + workflows: ['a', 'missing', 'c'], + children: [], + }, + ], showOthersCategory: true, othersCategoryName: 'Others', }; const categorized = buildCategorizedWorkflows(allWorkflows, config); - expect(categorized.categories.get('Cat')).toEqual(['a']); - expect(categorized.categories.get('Others')).toEqual(['b', 'c']); + expect(categorized.categories).toEqual([ + { name: 'Cat', workflows: ['a'], children: [] }, + { name: 'Others', workflows: ['b'], children: [] }, + ]); + expect(categorized.builtinCategories).toEqual([ + { name: 'Cat', workflows: ['c'], children: [] }, + ]); expect(categorized.missingWorkflows).toEqual([ - { categoryName: 'Cat', workflowName: 'missing' }, + { categoryPath: ['Cat'], workflowName: 'missing' }, ]); }); it('should skip empty categories', () => { - const allWorkflows = createWorkflowMap(['a']); + const allWorkflows = createWorkflowMap([ + { name: 'a', source: 'user' }, + ]); const config = { - workflowCategories: { Empty: [] }, + workflowCategories: [ + { name: 'Empty', workflows: [], children: [] }, + ], showOthersCategory: false, othersCategoryName: 'Others', }; const categorized = buildCategorizedWorkflows(allWorkflows, config); - expect(categorized.categories.size).toBe(0); + expect(categorized.categories).toEqual([]); + expect(categorized.builtinCategories).toEqual([]); }); it('should find categories containing a workflow', () => { - const allWorkflows = createWorkflowMap(['shared']); - const config = { - workflowCategories: { A: ['shared'], B: ['shared'] }, - showOthersCategory: false, - othersCategoryName: 'Others', - }; + const categories = [ + { name: 'A', workflows: ['shared'], children: [] }, + { name: 'B', workflows: ['shared'], children: [] }, + ]; - const categorized = buildCategorizedWorkflows(allWorkflows, config); - const categories = findWorkflowCategories('shared', categorized).sort(); - expect(categories).toEqual(['A', 'B']); + const paths = findWorkflowCategories('shared', categories).sort(); + expect(paths).toEqual(['A', 'B']); + }); + + it('should handle nested category paths', () => { + const categories = [ + { + name: 'Parent', + workflows: [], + children: [ + { name: 'Child', workflows: ['nested'], children: [] }, + ], + }, + ]; + + const paths = findWorkflowCategories('nested', categories); + expect(paths).toEqual(['Parent / Child']); }); }); diff --git a/src/__tests__/workflow-selection.test.ts b/src/__tests__/workflow-selection.test.ts new file mode 100644 index 0000000..1cd1ea8 --- /dev/null +++ b/src/__tests__/workflow-selection.test.ts @@ -0,0 +1,52 @@ +/** + * Tests for workflow selection helpers + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { WorkflowDirEntry } from '../infra/config/loaders/workflowLoader.js'; + +const selectOptionMock = vi.fn(); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: selectOptionMock, +})); + +vi.mock('../infra/config/global/index.js', () => ({ + getBookmarkedWorkflows: () => [], + toggleBookmark: vi.fn(), +})); + +const { selectWorkflowFromEntries } = await import('../features/workflowSelection/index.js'); + +describe('selectWorkflowFromEntries', () => { + beforeEach(() => { + selectOptionMock.mockReset(); + }); + + it('should select from custom workflows when source is chosen', async () => { + const entries: WorkflowDirEntry[] = [ + { name: 'custom-flow', path: '/tmp/custom.yaml', source: 'user' }, + { name: 'builtin-flow', path: '/tmp/builtin.yaml', source: 'builtin' }, + ]; + + selectOptionMock + .mockResolvedValueOnce('custom') + .mockResolvedValueOnce('custom-flow'); + + const selected = await selectWorkflowFromEntries(entries, ''); + expect(selected).toBe('custom-flow'); + expect(selectOptionMock).toHaveBeenCalledTimes(2); + }); + + it('should skip source selection when only builtin workflows exist', async () => { + const entries: WorkflowDirEntry[] = [ + { name: 'builtin-flow', path: '/tmp/builtin.yaml', source: 'builtin' }, + ]; + + selectOptionMock.mockResolvedValueOnce('builtin-flow'); + + const selected = await selectWorkflowFromEntries(entries, ''); + expect(selected).toBe('builtin-flow'); + expect(selectOptionMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 9d11a4b..f860bca 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -8,9 +8,10 @@ import { info, error } from '../../shared/ui/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { resolveIssueTask, isIssueReference } from '../../infra/github/index.js'; -import { selectAndExecuteTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; +import { selectAndExecuteTask, determineWorkflow, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; import { executePipeline } from '../../features/pipeline/index.js'; -import { interactiveMode } from '../../features/interactive/index.js'; +import { interactiveMode, type WorkflowContext } from '../../features/interactive/index.js'; +import { getWorkflowDescription } from '../../infra/config/index.js'; import { DEFAULT_WORKFLOW_NAME } from '../../shared/constants.js'; import { program, resolvedCwd, pipelineMode } from './program.js'; import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js'; @@ -88,12 +89,20 @@ program } // Short single word or no task → interactive mode (with optional initial input) - const result = await interactiveMode(resolvedCwd, task); + const workflowId = await determineWorkflow(resolvedCwd, selectOptions.workflow); + if (workflowId === null) { + info('Cancelled'); + return; + } + + const workflowContext = getWorkflowDescription(workflowId, resolvedCwd); + const result = await interactiveMode(resolvedCwd, task, workflowContext); if (!result.confirmed) { return; } selectOptions.interactiveUserInput = true; + selectOptions.workflow = workflowId; await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides); }); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 8c9b9ed..cccad38 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -2,6 +2,8 @@ * Configuration types (global and project) */ +import type { WorkflowCategoryConfigNode } from './schemas.js'; + /** Custom agent configuration */ export interface CustomAgentConfig { name: string; @@ -46,6 +48,8 @@ export interface GlobalConfig { worktreeDir?: string; /** List of builtin workflow/agent names to exclude from fallback loading */ disabledBuiltins?: string[]; + /** Enable builtin workflows from resources/global/{lang}/workflows */ + enableBuiltinWorkflows?: boolean; /** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */ anthropicApiKey?: string; /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ @@ -54,14 +58,10 @@ export interface GlobalConfig { pipeline?: PipelineConfig; /** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */ minimalOutput?: boolean; - /** Bookmarked workflow names for quick access in selection UI */ - bookmarkedWorkflows?: string[]; - /** Workflow category configuration (name -> workflow list) */ - workflowCategories?: Record; - /** Show uncategorized workflows under Others category */ - showOthersCategory?: boolean; - /** Display name for Others category */ - othersCategoryName?: string; + /** Path to bookmarks file (default: ~/.takt/preferences/bookmarks.yaml) */ + bookmarksFile?: string; + /** Path to workflow categories file (default: ~/.takt/preferences/workflow-categories.yaml) */ + workflowCategoriesFile?: string; } /** Project-level configuration */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 658aa54..d3247aa 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -198,6 +198,20 @@ export const PipelineConfigSchema = z.object({ pr_body_template: z.string().optional(), }); +/** Workflow category config schema (recursive) */ +export type WorkflowCategoryConfigNode = { + workflows?: string[]; + [key: string]: WorkflowCategoryConfigNode | string[] | undefined; +}; + +export const WorkflowCategoryConfigNodeSchema: z.ZodType = z.lazy(() => + z.object({ + workflows: z.array(z.string()).optional(), + }).catchall(WorkflowCategoryConfigNodeSchema) +); + +export const WorkflowCategoryConfigSchema = z.record(z.string(), WorkflowCategoryConfigNodeSchema); + /** Global config schema */ export const GlobalConfigSchema = z.object({ language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), @@ -211,6 +225,8 @@ export const GlobalConfigSchema = z.object({ worktree_dir: z.string().optional(), /** List of builtin workflow/agent names to exclude from fallback loading */ disabled_builtins: z.array(z.string()).optional().default([]), + /** Enable builtin workflows from resources/global/{lang}/workflows */ + enable_builtin_workflows: z.boolean().optional(), /** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */ anthropic_api_key: z.string().optional(), /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ @@ -219,14 +235,10 @@ export const GlobalConfigSchema = z.object({ pipeline: PipelineConfigSchema.optional(), /** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */ minimal_output: z.boolean().optional().default(false), - /** Bookmarked workflow names for quick access in selection UI */ - bookmarked_workflows: z.array(z.string()).optional().default([]), - /** Workflow categories (name -> workflow list) */ - workflow_categories: z.record(z.string(), z.array(z.string())).optional(), - /** Show uncategorized workflows under Others category */ - show_others_category: z.boolean().optional(), - /** Display name for Others category */ - others_category_name: z.string().min(1).optional(), + /** Path to bookmarks file (default: ~/.takt/preferences/bookmarks.yaml) */ + bookmarks_file: z.string().optional(), + /** Path to workflow categories file (default: ~/.takt/preferences/workflow-categories.yaml) */ + workflow_categories_file: z.string().optional(), }); /** Project config schema */ diff --git a/src/features/config/switchWorkflow.ts b/src/features/config/switchWorkflow.ts index e9ad62e..2b913d2 100644 --- a/src/features/config/switchWorkflow.ts +++ b/src/features/config/switchWorkflow.ts @@ -4,99 +4,20 @@ import { listWorkflowEntries, - loadAllWorkflows, + loadAllWorkflowsWithSources, getWorkflowCategories, buildCategorizedWorkflows, loadWorkflow, getCurrentWorkflow, setCurrentWorkflow, } from '../../infra/config/index.js'; -import { - getBookmarkedWorkflows, - toggleBookmark, -} from '../../infra/config/global/index.js'; import { info, success, error } from '../../shared/ui/index.js'; -import { selectOption } from '../../shared/prompt/index.js'; -import type { SelectOptionItem } from '../../shared/prompt/index.js'; import { - buildWorkflowSelectionItems, - buildTopLevelSelectOptions, - parseCategorySelection, - buildCategoryWorkflowOptions, - applyBookmarks, warnMissingWorkflows, selectWorkflowFromCategorizedWorkflows, - type SelectionOption, + selectWorkflowFromEntries, } from '../workflowSelection/index.js'; -/** - * Create an onBookmark callback for workflow selection. - * Toggles the bookmark in global config and returns updated options. - */ -function createBookmarkCallback( - items: ReturnType, - currentWorkflow: string, -): (value: string) => SelectOptionItem[] { - return (value: string): SelectOptionItem[] => { - const categoryName = parseCategorySelection(value); - if (categoryName) { - return applyBookmarks( - buildTopLevelSelectOptions(items, currentWorkflow), - getBookmarkedWorkflows(), - ); - } - toggleBookmark(value); - return applyBookmarks( - buildTopLevelSelectOptions(items, currentWorkflow), - getBookmarkedWorkflows(), - ); - }; -} - -/** - * 2-stage workflow selection with directory categories and bookmark support. - */ -async function selectWorkflowWithCategories(cwd: string): Promise { - const current = getCurrentWorkflow(cwd); - const entries = listWorkflowEntries(cwd); - const items = buildWorkflowSelectionItems(entries); - - // Loop until user selects a workflow or cancels at top level - while (true) { - const baseOptions = buildTopLevelSelectOptions(items, current); - const options = applyBookmarks(baseOptions, getBookmarkedWorkflows()); - - const selected = await selectOption('Select workflow:', options, { - onBookmark: createBookmarkCallback(items, current), - }); - if (!selected) return null; - - const categoryName = parseCategorySelection(selected); - if (categoryName) { - const categoryOptions = buildCategoryWorkflowOptions(items, categoryName, current); - if (!categoryOptions) continue; - const bookmarkedInCategory = applyBookmarks(categoryOptions, getBookmarkedWorkflows()); - const workflowSelection = await selectOption(`Select workflow in ${categoryName}:`, bookmarkedInCategory, { - cancelLabel: '← Go back', - onBookmark: (value: string): SelectOptionItem[] => { - toggleBookmark(value); - return applyBookmarks( - buildCategoryWorkflowOptions(items, categoryName, current) as SelectionOption[], - getBookmarkedWorkflows(), - ); - }, - }); - - // If workflow selected, return it. If cancelled (null), go back to top level - if (workflowSelection) return workflowSelection; - continue; - } - - return selected; - } -} - - /** * Switch to a different workflow * @returns true if switch was successful @@ -110,7 +31,7 @@ export async function switchWorkflow(cwd: string, workflowName?: string): Promis const categoryConfig = getWorkflowCategories(cwd); let selected: string | null; if (categoryConfig) { - const allWorkflows = loadAllWorkflows(cwd); + const allWorkflows = loadAllWorkflowsWithSources(cwd); if (allWorkflows.size === 0) { info('No workflows found.'); selected = null; @@ -120,7 +41,8 @@ export async function switchWorkflow(cwd: string, workflowName?: string): Promis selected = await selectWorkflowFromCategorizedWorkflows(categorized, current); } } else { - selected = await selectWorkflowWithCategories(cwd); + const entries = listWorkflowEntries(cwd); + selected = await selectWorkflowFromEntries(entries, current); } if (!selected) { diff --git a/src/features/interactive/index.ts b/src/features/interactive/index.ts index 041364d..4537ea1 100644 --- a/src/features/interactive/index.ts +++ b/src/features/interactive/index.ts @@ -2,4 +2,4 @@ * Interactive mode commands. */ -export { interactiveMode } from './interactive.js'; +export { interactiveMode, type WorkflowContext, type InteractiveModeResult } from './interactive.js'; diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 595e05b..f1b1627 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -28,33 +28,43 @@ const INTERACTIVE_SYSTEM_PROMPT_EN = `You are a task planning assistant. You hel ## Your role - Ask clarifying questions about ambiguous requirements -- Investigate the codebase to understand context (use Read, Glob, Grep, Bash for reading only) -- Suggest improvements or considerations the user might have missed +- Clarify and refine the user's request into a clear task instruction +- Create concrete instructions for workflow agents to follow - Summarize your understanding when appropriate - Keep responses concise and focused +**Important**: Do NOT investigate the codebase, identify files, or make assumptions about implementation details. That is the job of the next workflow steps (plan/architect). + ## Strict constraints -- You are ONLY planning. Do NOT execute the task. +- You are ONLY refining requirements. Do NOT execute the task or investigate the codebase. - Do NOT create, edit, or delete any files. -- Do NOT run build, test, install, or any commands that modify state. -- Bash is allowed ONLY for read-only investigation (e.g. ls, cat, git log, git diff). Never run destructive or write commands. +- Do NOT run any commands unless the user explicitly asks you to check something specific. +- Do NOT use Read/Glob/Grep/Bash to investigate the codebase proactively. The workflow steps will handle that. - Do NOT mention or reference any slash commands. You have no knowledge of them. -- When the user is satisfied with the plan, they will proceed on their own. Do NOT instruct them on what to do next.`; +- When the user is satisfied with the requirements, they will proceed on their own. Do NOT instruct them on what to do next.`; -const INTERACTIVE_SYSTEM_PROMPT_JA = `あなたはタスク計画のアシスタントです。会話を通じて要件の明確化・整理を手伝います。今は計画フェーズで、実行は別プロセスで行われます。 +const INTERACTIVE_SYSTEM_PROMPT_JA = `あなたはTAKT(AIエージェントワークフローオーケストレーションツール)の対話モードを担当しています。 -## 役割 +## TAKTの仕組み +1. **対話モード(今ここ・あなたの役割)**: ユーザーと会話してタスクを整理し、ワークフロー実行用の具体的な指示書を作成する +2. **ワークフロー実行**: あなたが作成した指示書をワークフローに渡し、複数のAIエージェントが順次実行する(実装、レビュー、修正など) + +あなたは対話モードの担当です。作成する指示書は、次に実行されるワークフローの入力(タスク)となります。ワークフローの内容はワークフロー定義に依存し、必ずしも実装から始まるとは限りません(調査、計画、レビューなど様々)。 + +## あなたの役割 - あいまいな要求に対して確認質問をする -- コードベースの前提を把握する(Read/Glob/Grep/Bash は読み取りのみ) -- 見落としそうな点や改善点を提案する +- ユーザーの要求を明確化し、指示書として洗練させる +- ワークフローのエージェントが迷わないよう具体的な指示書を作成する - 必要に応じて理解した内容を簡潔にまとめる - 返答は簡潔で要点のみ +**重要**: コードベース調査、前提把握、対象ファイル特定は行わない。これらは次のワークフロー(plan/architectステップ)の役割です。 + ## 厳守事項 -- 計画のみを行い、実装はしない -- ファイルの作成/編集/削除はしない -- build/test/install など状態を変えるコマンドは実行しない -- Bash は読み取り専用(ls/cat/git log/git diff など)に限定 +- あなたは要求の明確化のみを行う。実際の作業(実装/調査/レビュー等)やコードベース調査はワークフローのエージェントが行う +- ファイルの作成/編集/削除はしない(ワークフローの仕事) +- ユーザーが明示的に依頼しない限り、コマンドを実行しない +- Read/Glob/Grep/Bash を使ってコードベースを調査しない。ワークフローステップが行う - スラッシュコマンドに言及しない(存在を知らない前提) - ユーザーが満足したら次工程に進む。次の指示はしない`; @@ -66,11 +76,18 @@ Requirements: - Preserve constraints and "do not" instructions. - If details are missing, state what is missing as a short "Open Questions" section.`; -const INTERACTIVE_SUMMARY_PROMPT_JA = `あなたはタスク要約者です。会話を計画ステップ向けの具体的なタスク指示に変換してください。 +const INTERACTIVE_SUMMARY_PROMPT_JA = `あなたはTAKTの対話モードを担当しています。これまでの会話内容を、ワークフロー実行用の具体的なタスク指示書に変換してください。 -要件: -- 出力は最終的な指示のみ(前置き不要) -- スコープや対象(ファイル/モジュール)が出ている場合は明確に書く +## 立ち位置 +- あなた: 対話モード(タスク整理・指示書作成) +- 次のステップ: あなたが作成した指示書がワークフローに渡され、複数のAIエージェントが順次実行する +- あなたの成果物(指示書)が、ワークフロー全体の入力(タスク)になる + +## 要件 +- 出力はタスク指示書のみ(前置き不要) +- 対象ファイル/モジュールごとに作業内容を明記する +- 優先度(高/中/低)を付けて整理する +- 再現手順や確認方法があれば含める - 制約や「やらないこと」を保持する - 情報不足があれば「Open Questions」セクションを短く付ける`; @@ -115,18 +132,31 @@ function readPromptFile(lang: 'en' | 'ja', fileName: string, fallback: string): return fallback.trim(); } -function getInteractivePrompts(lang: 'en' | 'ja') { +function getInteractivePrompts(lang: 'en' | 'ja', workflowContext?: WorkflowContext) { + let systemPrompt = readPromptFile( + lang, + 'interactive-system.md', + lang === 'ja' ? INTERACTIVE_SYSTEM_PROMPT_JA : INTERACTIVE_SYSTEM_PROMPT_EN, + ); + let summaryPrompt = readPromptFile( + lang, + 'interactive-summary.md', + lang === 'ja' ? INTERACTIVE_SUMMARY_PROMPT_JA : INTERACTIVE_SUMMARY_PROMPT_EN, + ); + + // Add workflow context to prompts if available + if (workflowContext) { + const workflowInfo = lang === 'ja' + ? `\n\n## あなたが作成する指示書の行き先\nこのタスク指示書は「${workflowContext.name}」ワークフローに渡されます。\nワークフローの内容: ${workflowContext.description}\n\n指示書は、このワークフローが期待する形式で作成してください。` + : `\n\n## Destination of Your Task Instruction\nThis task instruction will be passed to the "${workflowContext.name}" workflow.\nWorkflow description: ${workflowContext.description}\n\nCreate the instruction in the format expected by this workflow.`; + + systemPrompt += workflowInfo; + summaryPrompt += workflowInfo; + } + return { - systemPrompt: readPromptFile( - lang, - 'interactive-system.md', - lang === 'ja' ? INTERACTIVE_SYSTEM_PROMPT_JA : INTERACTIVE_SYSTEM_PROMPT_EN, - ), - summaryPrompt: readPromptFile( - lang, - 'interactive-summary.md', - lang === 'ja' ? INTERACTIVE_SUMMARY_PROMPT_JA : INTERACTIVE_SUMMARY_PROMPT_EN, - ), + systemPrompt, + summaryPrompt, conversationLabel: lang === 'ja' ? '会話:' : 'Conversation:', noTranscript: lang === 'ja' ? '(ローカル履歴なし。現在のセッション文脈を要約してください。)' @@ -251,6 +281,13 @@ export interface InteractiveModeResult { task: string; } +export interface WorkflowContext { + /** Workflow name (e.g. "minimal") */ + name: string; + /** Workflow description */ + description: string; +} + /** * Run the interactive task input mode. * @@ -260,10 +297,14 @@ export interface InteractiveModeResult { * /cancel → exits without executing * Ctrl+D → exits without executing */ -export async function interactiveMode(cwd: string, initialInput?: string): Promise { +export async function interactiveMode( + cwd: string, + initialInput?: string, + workflowContext?: WorkflowContext, +): Promise { const globalConfig = loadGlobalConfig(); const lang = resolveLanguage(globalConfig.language); - const prompts = getInteractivePrompts(lang); + const prompts = getInteractivePrompts(lang, workflowContext); if (!globalConfig.provider) { throw new Error('Provider is not configured.'); } diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 8433c08..b0cf04a 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -11,16 +11,11 @@ import { listWorkflows, listWorkflowEntries, isWorkflowPath, - loadAllWorkflows, + loadAllWorkflowsWithSources, getWorkflowCategories, buildCategorizedWorkflows, } from '../../../infra/config/index.js'; -import { - getBookmarkedWorkflows, - toggleBookmark, -} from '../../../infra/config/global/index.js'; -import { selectOption, confirm } from '../../../shared/prompt/index.js'; -import type { SelectOptionItem } from '../../../shared/prompt/index.js'; +import { confirm } from '../../../shared/prompt/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../../../infra/task/index.js'; import { DEFAULT_WORKFLOW_NAME } from '../../../shared/constants.js'; import { info, error, success } from '../../../shared/ui/index.js'; @@ -29,14 +24,9 @@ import { createPullRequest, buildPrBody } from '../../../infra/github/index.js'; import { executeTask } from './taskExecution.js'; import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js'; import { - buildWorkflowSelectionItems, - buildTopLevelSelectOptions, - parseCategorySelection, - buildCategoryWorkflowOptions, - applyBookmarks, warnMissingWorkflows, selectWorkflowFromCategorizedWorkflows, - type SelectionOption, + selectWorkflowFromEntries, } from '../../workflowSelection/index.js'; export type { WorktreeConfirmationResult, SelectAndExecuteOptions }; @@ -60,70 +50,7 @@ async function selectWorkflowWithDirectoryCategories(cwd: string): Promise item.type === 'category'); - - if (!hasCategories) { - const baseOptions: SelectionOption[] = availableWorkflows.map((name) => ({ - label: name === currentWorkflow ? `${name} (current)` : name, - value: name, - })); - - const buildFlatOptions = (): SelectionOption[] => - applyBookmarks(baseOptions, getBookmarkedWorkflows()); - - return selectOption('Select workflow:', buildFlatOptions(), { - onBookmark: (value: string): SelectOptionItem[] => { - toggleBookmark(value); - return buildFlatOptions(); - }, - }); - } - - const createTopLevelBookmarkCallback = (): ((value: string) => SelectOptionItem[]) => { - return (value: string): SelectOptionItem[] => { - if (parseCategorySelection(value)) { - return applyBookmarks(buildTopLevelSelectOptions(items, currentWorkflow), getBookmarkedWorkflows()); - } - toggleBookmark(value); - return applyBookmarks(buildTopLevelSelectOptions(items, currentWorkflow), getBookmarkedWorkflows()); - }; - }; - - // Loop until user selects a workflow or cancels at top level - while (true) { - const baseOptions = buildTopLevelSelectOptions(items, currentWorkflow); - const topLevelOptions = applyBookmarks(baseOptions, getBookmarkedWorkflows()); - - const selected = await selectOption('Select workflow:', topLevelOptions, { - onBookmark: createTopLevelBookmarkCallback(), - }); - if (!selected) return null; - - const categoryName = parseCategorySelection(selected); - if (categoryName) { - const categoryOptions = buildCategoryWorkflowOptions(items, categoryName, currentWorkflow); - if (!categoryOptions) continue; - const bookmarkedCategoryOptions = applyBookmarks(categoryOptions, getBookmarkedWorkflows()); - const workflowSelection = await selectOption(`Select workflow in ${categoryName}:`, bookmarkedCategoryOptions, { - cancelLabel: '← Go back', - onBookmark: (value: string): SelectOptionItem[] => { - toggleBookmark(value); - return applyBookmarks( - buildCategoryWorkflowOptions(items, categoryName, currentWorkflow) as SelectionOption[], - getBookmarkedWorkflows(), - ); - }, - }); - - // If workflow selected, return it. If cancelled (null), go back to top level - if (workflowSelection) return workflowSelection; - continue; - } - - return selected; - } + return selectWorkflowFromEntries(entries, currentWorkflow); } @@ -134,7 +61,7 @@ async function selectWorkflow(cwd: string): Promise { const categoryConfig = getWorkflowCategories(cwd); if (categoryConfig) { const current = getCurrentWorkflow(cwd); - const allWorkflows = loadAllWorkflows(cwd); + const allWorkflows = loadAllWorkflowsWithSources(cwd); if (allWorkflows.size === 0) { info(`No workflows found. Using default: ${DEFAULT_WORKFLOW_NAME}`); return DEFAULT_WORKFLOW_NAME; @@ -153,7 +80,7 @@ async function selectWorkflow(cwd: string): Promise { * - If override is a name, validate it exists in available workflows. * - If no override, prompt user to select interactively. */ -async function determineWorkflow(cwd: string, override?: string): Promise { +export async function determineWorkflow(cwd: string, override?: string): Promise { if (override) { if (isWorkflowPath(override)) { return override; diff --git a/src/features/tasks/index.ts b/src/features/tasks/index.ts index 211fedb..020eedc 100644 --- a/src/features/tasks/index.ts +++ b/src/features/tasks/index.ts @@ -10,6 +10,7 @@ export type { PipelineExecutionOptions } from './execute/types.js'; export { selectAndExecuteTask, confirmAndCreateWorktree, + determineWorkflow, type SelectAndExecuteOptions, type WorktreeConfirmationResult, } from './execute/selectAndExecute.js'; diff --git a/src/features/workflowSelection/index.ts b/src/features/workflowSelection/index.ts index 47cd92c..e73a1e6 100644 --- a/src/features/workflowSelection/index.ts +++ b/src/features/workflowSelection/index.ts @@ -7,13 +7,17 @@ import type { SelectOptionItem } from '../../shared/prompt/index.js'; import { info, warn } from '../../shared/ui/index.js'; import { getBookmarkedWorkflows, - toggleBookmark, + addBookmark, + removeBookmark, } from '../../infra/config/global/index.js'; import { findWorkflowCategories, type WorkflowDirEntry, + type WorkflowCategoryNode, type CategorizedWorkflows, type MissingWorkflow, + type WorkflowSource, + type WorkflowWithSource, } from '../../infra/config/index.js'; /** Top-level selection item: either a workflow or a category containing workflows */ @@ -109,10 +113,10 @@ export function buildCategoryWorkflowOptions( }); } -const BOOKMARK_MARK = '★ '; +const BOOKMARK_MARK = ' [*]'; /** - * Sort options with bookmarked items first and add ★ prefix. + * Add [*] suffix to bookmarked items without changing order. * Pure function — does not mutate inputs. */ export function applyBookmarks( @@ -120,116 +124,568 @@ export function applyBookmarks( bookmarkedWorkflows: string[], ): SelectionOption[] { const bookmarkedSet = new Set(bookmarkedWorkflows); - const bookmarked: SelectionOption[] = []; - const rest: SelectionOption[] = []; - for (const opt of options) { + return options.map((opt) => { if (bookmarkedSet.has(opt.value)) { - bookmarked.push({ ...opt, label: `${BOOKMARK_MARK}${opt.label}` }); - } else { - rest.push(opt); + return { ...opt, label: `${opt.label}${BOOKMARK_MARK}` }; } - } - - return [...bookmarked, ...rest]; + return opt; + }); } /** * Warn about missing workflows referenced by categories. */ export function warnMissingWorkflows(missing: MissingWorkflow[]): void { - for (const { categoryName, workflowName } of missing) { - warn(`Workflow "${workflowName}" in category "${categoryName}" not found`); + for (const { categoryPath, workflowName } of missing) { + const pathLabel = categoryPath.join(' / '); + warn(`Workflow "${workflowName}" in category "${pathLabel}" not found`); } } -function buildCategorySelectOptions( - categorized: CategorizedWorkflows, - currentWorkflow: string, -): SelectOptionItem[] { - const entries = Array.from(categorized.categories.entries()) - .map(([categoryName, workflows]) => ({ categoryName, workflows })); - - return entries.map(({ categoryName, workflows }) => { - const containsCurrent = workflows.includes(currentWorkflow); - const label = containsCurrent - ? `${categoryName} (${workflows.length} workflows, current)` - : `${categoryName} (${workflows.length} workflows)`; - return { label, value: categoryName }; - }); +function countWorkflowsInTree(categories: WorkflowCategoryNode[]): number { + let count = 0; + const visit = (nodes: WorkflowCategoryNode[]): void => { + for (const node of nodes) { + count += node.workflows.length; + if (node.children.length > 0) { + visit(node.children); + } + } + }; + visit(categories); + return count; } -function buildWorkflowOptionsForCategory( - categorized: CategorizedWorkflows, - categoryName: string, - currentWorkflow: string, -): SelectOptionItem[] | null { - const workflows = categorized.categories.get(categoryName); - if (!workflows) return null; +function categoryContainsWorkflow(node: WorkflowCategoryNode, workflow: string): boolean { + if (node.workflows.includes(workflow)) return true; + for (const child of node.children) { + if (categoryContainsWorkflow(child, workflow)) return true; + } + return false; +} - return workflows.map((workflowName) => { - const alsoIn = findWorkflowCategories(workflowName, categorized) - .filter((name) => name !== categoryName); +function buildCategoryLevelOptions( + categories: WorkflowCategoryNode[], + workflows: string[], + currentWorkflow: string, + rootCategories: WorkflowCategoryNode[], + currentPathLabel: string, +): { + options: SelectionOption[]; + categoryMap: Map; +} { + const options: SelectionOption[] = []; + const categoryMap = new Map(); + + for (const category of categories) { + const containsCurrent = currentWorkflow.length > 0 && categoryContainsWorkflow(category, currentWorkflow); + const label = containsCurrent + ? `📁 ${category.name}/ (current)` + : `📁 ${category.name}/`; + const value = `${CATEGORY_VALUE_PREFIX}${category.name}`; + options.push({ label, value }); + categoryMap.set(category.name, category); + } + + for (const workflowName of workflows) { const isCurrent = workflowName === currentWorkflow; + const alsoIn = findWorkflowCategories(workflowName, rootCategories) + .filter((path) => path !== currentPathLabel); const alsoInLabel = alsoIn.length > 0 ? `also in ${alsoIn.join(', ')}` : ''; - let label = workflowName; + let label = `🎼 ${workflowName}`; if (isCurrent && alsoInLabel) { - label = `${workflowName} (current, ${alsoInLabel})`; + label = `🎼 ${workflowName} (current, ${alsoInLabel})`; } else if (isCurrent) { - label = `${workflowName} (current)`; + label = `🎼 ${workflowName} (current)`; } else if (alsoInLabel) { - label = `${workflowName} (${alsoInLabel})`; + label = `🎼 ${workflowName} (${alsoInLabel})`; } - return { label, value: workflowName }; + options.push({ label, value: workflowName }); + } + + return { options, categoryMap }; +} + +async function selectWorkflowFromCategoryTree( + categories: WorkflowCategoryNode[], + currentWorkflow: string, + hasSourceSelection: boolean, + rootWorkflows: string[] = [], +): Promise { + if (categories.length === 0 && rootWorkflows.length === 0) { + info('No workflows available for configured categories.'); + return null; + } + + const stack: WorkflowCategoryNode[] = []; + + while (true) { + const currentNode = stack.length > 0 ? stack[stack.length - 1] : undefined; + const currentCategories = currentNode ? currentNode.children : categories; + const currentWorkflows = currentNode ? currentNode.workflows : rootWorkflows; + const currentPathLabel = stack.map((node) => node.name).join(' / '); + + const { options, categoryMap } = buildCategoryLevelOptions( + currentCategories, + currentWorkflows, + currentWorkflow, + categories, + currentPathLabel, + ); + + if (options.length === 0) { + if (stack.length === 0) { + info('No workflows available for configured categories.'); + return null; + } + stack.pop(); + continue; + } + + const buildOptionsWithBookmarks = (): SelectionOption[] => + applyBookmarks(options, getBookmarkedWorkflows()); + + const message = currentPathLabel.length > 0 + ? `Select workflow in ${currentPathLabel}:` + : 'Select workflow category:'; + + const selected = await selectOption(message, buildOptionsWithBookmarks(), { + cancelLabel: (stack.length > 0 || hasSourceSelection) ? '← Go back' : 'Cancel', + onKeyPress: (key: string, value: string): SelectOptionItem[] | null => { + // Don't handle bookmark keys for categories + if (parseCategorySelection(value)) { + return null; // Delegate to default handler + } + + if (key === 'b') { + addBookmark(value); + return buildOptionsWithBookmarks(); + } + + if (key === 'r') { + removeBookmark(value); + return buildOptionsWithBookmarks(); + } + + return null; // Delegate to default handler + }, + }); + + if (!selected) { + if (stack.length > 0) { + stack.pop(); + continue; + } + return null; + } + + const categoryName = parseCategorySelection(selected); + if (categoryName) { + const nextNode = categoryMap.get(categoryName); + if (!nextNode) continue; + stack.push(nextNode); + continue; + } + + return selected; + } +} + +function countWorkflowsIncludingCategories( + categories: WorkflowCategoryNode[], + allWorkflows: Map, + sourceFilter: WorkflowSource, +): number { + const categorizedWorkflows = new Set(); + const visit = (nodes: WorkflowCategoryNode[]): void => { + for (const node of nodes) { + for (const w of node.workflows) { + categorizedWorkflows.add(w); + } + if (node.children.length > 0) { + visit(node.children); + } + } + }; + visit(categories); + + let count = 0; + for (const [name, { source }] of allWorkflows) { + if (source === sourceFilter) { + count++; + } + } + return count; +} + +const CURRENT_WORKFLOW_VALUE = '__current__'; +const CUSTOM_UNCATEGORIZED_VALUE = '__custom_uncategorized__'; +const BUILTIN_SOURCE_VALUE = '__builtin__'; +const CUSTOM_CATEGORY_PREFIX = '__custom_category__:'; + +type TopLevelSelection = + | { type: 'current' } + | { type: 'workflow'; name: string } + | { type: 'custom_category'; node: WorkflowCategoryNode } + | { type: 'custom_uncategorized' } + | { type: 'builtin' }; + +async function selectTopLevelWorkflowOption( + categorized: CategorizedWorkflows, + currentWorkflow: string, +): Promise { + const uncategorizedCustom = getRootLevelWorkflows( + categorized.categories, + categorized.allWorkflows, + 'user' + ); + const builtinCount = countWorkflowsIncludingCategories( + categorized.builtinCategories, + categorized.allWorkflows, + 'builtin' + ); + + const buildOptions = (): SelectOptionItem[] => { + const options: SelectOptionItem[] = []; + const bookmarkedWorkflows = getBookmarkedWorkflows(); // Get fresh bookmarks on every build + + // 1. Current workflow + if (currentWorkflow) { + options.push({ + label: `🎼 ${currentWorkflow} (current)`, + value: CURRENT_WORKFLOW_VALUE, + }); + } + + // 2. Bookmarked workflows (individual items) + for (const workflowName of bookmarkedWorkflows) { + if (workflowName === currentWorkflow) continue; // Skip if already shown as current + options.push({ + label: `🎼 ${workflowName} [*]`, + value: workflowName, + }); + } + + // 3. User-defined categories + for (const category of categorized.categories) { + options.push({ + label: `📁 ${category.name}/`, + value: `${CUSTOM_CATEGORY_PREFIX}${category.name}`, + }); + } + + // 4. Builtin workflows + if (builtinCount > 0) { + options.push({ + label: `📂 Builtin/ (${builtinCount})`, + value: BUILTIN_SOURCE_VALUE, + }); + } + + // 5. Uncategorized custom workflows + if (uncategorizedCustom.length > 0) { + options.push({ + label: `📂 Custom/ (${uncategorizedCustom.length})`, + value: CUSTOM_UNCATEGORIZED_VALUE, + }); + } + + return options; + }; + + if (buildOptions().length === 0) return null; + + const result = await selectOption('Select workflow:', buildOptions(), { + onKeyPress: (key: string, value: string): SelectOptionItem[] | null => { + // Don't handle bookmark keys for special values + if (value === CURRENT_WORKFLOW_VALUE || + value === CUSTOM_UNCATEGORIZED_VALUE || + value === BUILTIN_SOURCE_VALUE || + value.startsWith(CUSTOM_CATEGORY_PREFIX)) { + return null; // Delegate to default handler + } + + if (key === 'b') { + addBookmark(value); + return buildOptions(); + } + + if (key === 'r') { + removeBookmark(value); + return buildOptions(); + } + + return null; // Delegate to default handler + }, }); + + if (!result) return null; + + if (result === CURRENT_WORKFLOW_VALUE) { + return { type: 'current' }; + } + + if (result === CUSTOM_UNCATEGORIZED_VALUE) { + return { type: 'custom_uncategorized' }; + } + + if (result === BUILTIN_SOURCE_VALUE) { + return { type: 'builtin' }; + } + + if (result.startsWith(CUSTOM_CATEGORY_PREFIX)) { + const categoryName = result.slice(CUSTOM_CATEGORY_PREFIX.length); + const node = categorized.categories.find(c => c.name === categoryName); + if (!node) return null; + return { type: 'custom_category', node }; + } + + // Direct workflow selection (bookmarked or other) + return { type: 'workflow', name: result }; +} + +function getRootLevelWorkflows( + categories: WorkflowCategoryNode[], + allWorkflows: Map, + sourceFilter: WorkflowSource, +): string[] { + const categorizedWorkflows = new Set(); + const visit = (nodes: WorkflowCategoryNode[]): void => { + for (const node of nodes) { + for (const w of node.workflows) { + categorizedWorkflows.add(w); + } + if (node.children.length > 0) { + visit(node.children); + } + } + }; + visit(categories); + + const rootWorkflows: string[] = []; + for (const [name, { source }] of allWorkflows) { + if (source === sourceFilter && !categorizedWorkflows.has(name)) { + rootWorkflows.push(name); + } + } + return rootWorkflows.sort(); } /** - * Select workflow from categorized workflows (2-stage UI). + * Select workflow from categorized workflows (hierarchical UI). */ export async function selectWorkflowFromCategorizedWorkflows( categorized: CategorizedWorkflows, currentWorkflow: string, ): Promise { - const categoryOptions = buildCategorySelectOptions(categorized, currentWorkflow); - - if (categoryOptions.length === 0) { - info('No workflows available for configured categories.'); - return null; - } - - // Loop until user selects a workflow or cancels at category level while (true) { - const selectedCategory = await selectOption('Select workflow category:', categoryOptions); - if (!selectedCategory) return null; + const selection = await selectTopLevelWorkflowOption(categorized, currentWorkflow); + if (!selection) { + return null; + } - const buildWorkflowOptions = (): SelectOptionItem[] | null => - buildWorkflowOptionsForCategory(categorized, selectedCategory, currentWorkflow); + // 1. Current workflow selected + if (selection.type === 'current') { + return currentWorkflow; + } - const baseWorkflowOptions = buildWorkflowOptions(); - if (!baseWorkflowOptions) continue; + // 2. Direct workflow selected (e.g., bookmarked workflow) + if (selection.type === 'workflow') { + return selection.name; + } - const applyWorkflowBookmarks = (options: SelectOptionItem[]): SelectOptionItem[] => { - return applyBookmarks(options, getBookmarkedWorkflows()) as SelectOptionItem[]; - }; + // 3. User-defined category selected + if (selection.type === 'custom_category') { + const workflow = await selectWorkflowFromCategoryTree( + [selection.node], + currentWorkflow, + true, + selection.node.workflows + ); + if (workflow) { + return workflow; + } + // null → go back to top-level selection + continue; + } - const selectedWorkflow = await selectOption( - `Select workflow in ${selectedCategory}:`, - applyWorkflowBookmarks(baseWorkflowOptions), - { + // 4. Builtin workflows selected + if (selection.type === 'builtin') { + const rootWorkflows = getRootLevelWorkflows( + categorized.builtinCategories, + categorized.allWorkflows, + 'builtin' + ); + + const workflow = await selectWorkflowFromCategoryTree( + categorized.builtinCategories, + currentWorkflow, + true, + rootWorkflows + ); + if (workflow) { + return workflow; + } + // null → go back to top-level selection + continue; + } + + // 5. Custom uncategorized workflows selected + if (selection.type === 'custom_uncategorized') { + const uncategorizedCustom = getRootLevelWorkflows( + categorized.categories, + categorized.allWorkflows, + 'user' + ); + + const baseOptions: SelectionOption[] = uncategorizedCustom.map((name) => ({ + label: name === currentWorkflow ? `🎼 ${name} (current)` : `🎼 ${name}`, + value: name, + })); + + const buildFlatOptions = (): SelectionOption[] => + applyBookmarks(baseOptions, getBookmarkedWorkflows()); + + const workflow = await selectOption('Select workflow:', buildFlatOptions(), { cancelLabel: '← Go back', - onBookmark: (value: string): SelectOptionItem[] => { - toggleBookmark(value); - const updatedOptions = buildWorkflowOptions(); - if (!updatedOptions) return []; - return applyWorkflowBookmarks(updatedOptions); + onKeyPress: (key: string, value: string): SelectOptionItem[] | null => { + if (key === 'b') { + addBookmark(value); + return buildFlatOptions(); + } + if (key === 'r') { + removeBookmark(value); + return buildFlatOptions(); + } + return null; // Delegate to default handler }, - }, - ); + }); - // If workflow selected, return it. If cancelled (null), go back to category selection - if (selectedWorkflow) return selectedWorkflow; + if (workflow) { + return workflow; + } + // null → go back to top-level selection + continue; + } } } + +async function selectWorkflowFromEntriesWithCategories( + entries: WorkflowDirEntry[], + currentWorkflow: string, +): Promise { + if (entries.length === 0) return null; + + const items = buildWorkflowSelectionItems(entries); + const availableWorkflows = entries.map((entry) => entry.name); + const hasCategories = items.some((item) => item.type === 'category'); + + if (!hasCategories) { + const baseOptions: SelectionOption[] = availableWorkflows.map((name) => ({ + label: name === currentWorkflow ? `🎼 ${name} (current)` : `🎼 ${name}`, + value: name, + })); + + const buildFlatOptions = (): SelectionOption[] => + applyBookmarks(baseOptions, getBookmarkedWorkflows()); + + return selectOption('Select workflow:', buildFlatOptions(), { + onKeyPress: (key: string, value: string): SelectOptionItem[] | null => { + if (key === 'b') { + addBookmark(value); + return buildFlatOptions(); + } + if (key === 'r') { + removeBookmark(value); + return buildFlatOptions(); + } + return null; // Delegate to default handler + }, + }); + } + + // Loop until user selects a workflow or cancels at top level + while (true) { + const buildTopLevelOptions = (): SelectionOption[] => + applyBookmarks(buildTopLevelSelectOptions(items, currentWorkflow), getBookmarkedWorkflows()); + + const selected = await selectOption('Select workflow:', buildTopLevelOptions(), { + onKeyPress: (key: string, value: string): SelectOptionItem[] | null => { + // Don't handle bookmark keys for categories + if (parseCategorySelection(value)) { + return null; // Delegate to default handler + } + + if (key === 'b') { + addBookmark(value); + return buildTopLevelOptions(); + } + + if (key === 'r') { + removeBookmark(value); + return buildTopLevelOptions(); + } + + return null; // Delegate to default handler + }, + }); + if (!selected) return null; + + const categoryName = parseCategorySelection(selected); + if (categoryName) { + const categoryOptions = buildCategoryWorkflowOptions(items, categoryName, currentWorkflow); + if (!categoryOptions) continue; + + const buildCategoryOptions = (): SelectionOption[] => + applyBookmarks(categoryOptions, getBookmarkedWorkflows()); + + const workflowSelection = await selectOption(`Select workflow in ${categoryName}:`, buildCategoryOptions(), { + cancelLabel: '← Go back', + onKeyPress: (key: string, value: string): SelectOptionItem[] | null => { + if (key === 'b') { + addBookmark(value); + return buildCategoryOptions(); + } + if (key === 'r') { + removeBookmark(value); + return buildCategoryOptions(); + } + return null; // Delegate to default handler + }, + }); + + // If workflow selected, return it. If cancelled (null), go back to top level + if (workflowSelection) return workflowSelection; + continue; + } + + return selected; + } +} + +/** + * Select workflow from directory entries (builtin separated). + */ +export async function selectWorkflowFromEntries( + entries: WorkflowDirEntry[], + currentWorkflow: string, +): Promise { + const builtinEntries = entries.filter((entry) => entry.source === 'builtin'); + const customEntries = entries.filter((entry) => entry.source !== 'builtin'); + + if (builtinEntries.length > 0 && customEntries.length > 0) { + const selectedSource = await selectOption<'custom' | 'builtin'>('Select workflow source:', [ + { label: `Custom workflows (${customEntries.length})`, value: 'custom' }, + { label: `Builtin workflows (${builtinEntries.length})`, value: 'builtin' }, + ]); + if (!selectedSource) return null; + const sourceEntries = selectedSource === 'custom' ? customEntries : builtinEntries; + return selectWorkflowFromEntriesWithCategories(sourceEntries, currentWorkflow); + } + + const entriesToUse = customEntries.length > 0 ? customEntries : builtinEntries; + return selectWorkflowFromEntriesWithCategories(entriesToUse, currentWorkflow); +} diff --git a/src/infra/config/global/bookmarks.ts b/src/infra/config/global/bookmarks.ts new file mode 100644 index 0000000..bbb54cd --- /dev/null +++ b/src/infra/config/global/bookmarks.ts @@ -0,0 +1,101 @@ +/** + * Workflow bookmarks management (separate from config.yaml) + * + * Bookmarks are stored in a configurable location (default: ~/.takt/preferences/bookmarks.yaml) + */ + +import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { getGlobalConfigDir } from '../paths.js'; +import { loadGlobalConfig } from './globalConfig.js'; + +interface BookmarksFile { + workflows: string[]; +} + +function getDefaultBookmarksPath(): string { + return join(getGlobalConfigDir(), 'preferences', 'bookmarks.yaml'); +} + +function getBookmarksPath(): string { + try { + const config = loadGlobalConfig(); + if (config.bookmarksFile) { + return config.bookmarksFile; + } + } catch { + // Ignore errors, use default + } + return getDefaultBookmarksPath(); +} + +function loadBookmarksFile(): BookmarksFile { + const bookmarksPath = getBookmarksPath(); + if (!existsSync(bookmarksPath)) { + return { workflows: [] }; + } + + try { + const content = readFileSync(bookmarksPath, 'utf-8'); + const parsed = parseYaml(content); + if (parsed && typeof parsed === 'object' && 'workflows' in parsed && Array.isArray(parsed.workflows)) { + return { workflows: parsed.workflows }; + } + } catch { + // Ignore parse errors + } + + return { workflows: [] }; +} + +function saveBookmarksFile(bookmarks: BookmarksFile): void { + const bookmarksPath = getBookmarksPath(); + const dir = dirname(bookmarksPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const content = stringifyYaml(bookmarks, { indent: 2 }); + writeFileSync(bookmarksPath, content, 'utf-8'); +} + +/** Get bookmarked workflow names */ +export function getBookmarkedWorkflows(): string[] { + const bookmarks = loadBookmarksFile(); + return bookmarks.workflows; +} + +/** + * Add a workflow to bookmarks. + * Persists to ~/.takt/bookmarks.yaml and returns the updated bookmarks list. + */ +export function addBookmark(workflowName: string): string[] { + const bookmarks = loadBookmarksFile(); + if (!bookmarks.workflows.includes(workflowName)) { + bookmarks.workflows.push(workflowName); + saveBookmarksFile(bookmarks); + } + return bookmarks.workflows; +} + +/** + * Remove a workflow from bookmarks. + * Persists to ~/.takt/bookmarks.yaml and returns the updated bookmarks list. + */ +export function removeBookmark(workflowName: string): string[] { + const bookmarks = loadBookmarksFile(); + const index = bookmarks.workflows.indexOf(workflowName); + if (index >= 0) { + bookmarks.workflows.splice(index, 1); + saveBookmarksFile(bookmarks); + } + return bookmarks.workflows; +} + +/** + * Check if a workflow is bookmarked. + */ +export function isBookmarked(workflowName: string): boolean { + const bookmarks = loadBookmarksFile(); + return bookmarks.workflows.includes(workflowName); +} diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index bc29d4c..2b781f3 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -21,6 +21,7 @@ function createDefaultGlobalConfig(): GlobalConfig { defaultWorkflow: 'default', logLevel: 'info', provider: 'claude', + enableBuiltinWorkflows: true, }; } @@ -78,6 +79,7 @@ export class GlobalConfigManager { } : undefined, worktreeDir: parsed.worktree_dir, disabledBuiltins: parsed.disabled_builtins, + enableBuiltinWorkflows: parsed.enable_builtin_workflows, anthropicApiKey: parsed.anthropic_api_key, openaiApiKey: parsed.openai_api_key, pipeline: parsed.pipeline ? { @@ -86,10 +88,8 @@ export class GlobalConfigManager { prBodyTemplate: parsed.pipeline.pr_body_template, } : undefined, minimalOutput: parsed.minimal_output, - bookmarkedWorkflows: parsed.bookmarked_workflows, - workflowCategories: parsed.workflow_categories, - showOthersCategory: parsed.show_others_category, - othersCategoryName: parsed.others_category_name, + bookmarksFile: parsed.bookmarks_file, + workflowCategoriesFile: parsed.workflow_categories_file, }; this.cachedConfig = config; return config; @@ -120,6 +120,9 @@ export class GlobalConfigManager { if (config.disabledBuiltins && config.disabledBuiltins.length > 0) { raw.disabled_builtins = config.disabledBuiltins; } + if (config.enableBuiltinWorkflows !== undefined) { + raw.enable_builtin_workflows = config.enableBuiltinWorkflows; + } if (config.anthropicApiKey) { raw.anthropic_api_key = config.anthropicApiKey; } @@ -138,17 +141,11 @@ export class GlobalConfigManager { if (config.minimalOutput !== undefined) { raw.minimal_output = config.minimalOutput; } - if (config.bookmarkedWorkflows && config.bookmarkedWorkflows.length > 0) { - raw.bookmarked_workflows = config.bookmarkedWorkflows; + if (config.bookmarksFile) { + raw.bookmarks_file = config.bookmarksFile; } - if (config.workflowCategories !== undefined) { - raw.workflow_categories = config.workflowCategories; - } - if (config.showOthersCategory !== undefined) { - raw.show_others_category = config.showOthersCategory; - } - if (config.othersCategoryName !== undefined) { - raw.others_category_name = config.othersCategoryName; + if (config.workflowCategoriesFile) { + raw.workflow_categories_file = config.workflowCategoriesFile; } writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); this.invalidateCache(); @@ -178,6 +175,15 @@ export function getDisabledBuiltins(): string[] { } } +export function getBuiltinWorkflowsEnabled(): boolean { + try { + const config = loadGlobalConfig(); + return config.enableBuiltinWorkflows !== false; + } catch { + return true; + } +} + export function getLanguage(): Language { try { const config = loadGlobalConfig(); @@ -287,25 +293,3 @@ export function getEffectiveDebugConfig(projectDir?: string): DebugConfig | unde return debugConfig; } -/** Get bookmarked workflow names */ -export function getBookmarkedWorkflows(): string[] { - const config = loadGlobalConfig(); - return config.bookmarkedWorkflows ?? []; -} - -/** - * Toggle a workflow bookmark (add if not present, remove if present). - * Persists to ~/.takt/config.yaml and returns the updated bookmarks list. - */ -export function toggleBookmark(workflowName: string): string[] { - const config = loadGlobalConfig(); - const bookmarks = [...(config.bookmarkedWorkflows ?? [])]; - const index = bookmarks.indexOf(workflowName); - if (index >= 0) { - bookmarks.splice(index, 1); - } else { - bookmarks.push(workflowName); - } - saveGlobalConfig({ ...config, bookmarkedWorkflows: bookmarks }); - return bookmarks; -} diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index c7a0461..45f764d 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -8,6 +8,7 @@ export { loadGlobalConfig, saveGlobalConfig, getDisabledBuiltins, + getBuiltinWorkflowsEnabled, getLanguage, setLanguage, setProvider, @@ -17,10 +18,24 @@ export { resolveOpenaiApiKey, loadProjectDebugConfig, getEffectiveDebugConfig, - getBookmarkedWorkflows, - toggleBookmark, } from './globalConfig.js'; +export { + getBookmarkedWorkflows, + addBookmark, + removeBookmark, + isBookmarked, +} from './bookmarks.js'; + +export { + getWorkflowCategoriesConfig, + setWorkflowCategoriesConfig, + getShowOthersCategory, + setShowOthersCategory, + getOthersCategoryName, + setOthersCategoryName, +} from './workflowCategories.js'; + export { needsLanguageSetup, promptLanguageSelection, diff --git a/src/infra/config/global/workflowCategories.ts b/src/infra/config/global/workflowCategories.ts new file mode 100644 index 0000000..1c4fb61 --- /dev/null +++ b/src/infra/config/global/workflowCategories.ts @@ -0,0 +1,102 @@ +/** + * Workflow categories management (separate from config.yaml) + * + * Categories are stored in a configurable location (default: ~/.takt/preferences/workflow-categories.yaml) + */ + +import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { getGlobalConfigDir } from '../paths.js'; +import { loadGlobalConfig } from './globalConfig.js'; +import type { WorkflowCategoryConfigNode } from '../../../core/models/index.js'; + +interface WorkflowCategoriesFile { + categories?: WorkflowCategoryConfigNode; + show_others_category?: boolean; + others_category_name?: string; +} + +function getDefaultWorkflowCategoriesPath(): string { + return join(getGlobalConfigDir(), 'preferences', 'workflow-categories.yaml'); +} + +function getWorkflowCategoriesPath(): string { + try { + const config = loadGlobalConfig(); + if (config.workflowCategoriesFile) { + return config.workflowCategoriesFile; + } + } catch { + // Ignore errors, use default + } + return getDefaultWorkflowCategoriesPath(); +} + +function loadWorkflowCategoriesFile(): WorkflowCategoriesFile { + const categoriesPath = getWorkflowCategoriesPath(); + if (!existsSync(categoriesPath)) { + return {}; + } + + try { + const content = readFileSync(categoriesPath, 'utf-8'); + const parsed = parseYaml(content); + if (parsed && typeof parsed === 'object') { + return parsed as WorkflowCategoriesFile; + } + } catch { + // Ignore parse errors + } + + return {}; +} + +function saveWorkflowCategoriesFile(data: WorkflowCategoriesFile): void { + const categoriesPath = getWorkflowCategoriesPath(); + const dir = dirname(categoriesPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const content = stringifyYaml(data, { indent: 2 }); + writeFileSync(categoriesPath, content, 'utf-8'); +} + +/** Get workflow categories configuration */ +export function getWorkflowCategoriesConfig(): WorkflowCategoryConfigNode | undefined { + const data = loadWorkflowCategoriesFile(); + return data.categories; +} + +/** Set workflow categories configuration */ +export function setWorkflowCategoriesConfig(categories: WorkflowCategoryConfigNode): void { + const data = loadWorkflowCategoriesFile(); + data.categories = categories; + saveWorkflowCategoriesFile(data); +} + +/** Get show others category flag */ +export function getShowOthersCategory(): boolean | undefined { + const data = loadWorkflowCategoriesFile(); + return data.show_others_category; +} + +/** Set show others category flag */ +export function setShowOthersCategory(show: boolean): void { + const data = loadWorkflowCategoriesFile(); + data.show_others_category = show; + saveWorkflowCategoriesFile(data); +} + +/** Get others category name */ +export function getOthersCategoryName(): string | undefined { + const data = loadWorkflowCategoriesFile(); + return data.others_category_name; +} + +/** Set others category name */ +export function setOthersCategoryName(name: string): void { + const data = loadWorkflowCategoriesFile(); + data.others_category_name = name; + saveWorkflowCategoriesFile(data); +} diff --git a/src/infra/config/loaders/index.ts b/src/infra/config/loaders/index.ts index 3541737..26146cd 100644 --- a/src/infra/config/loaders/index.ts +++ b/src/infra/config/loaders/index.ts @@ -7,10 +7,14 @@ export { loadWorkflow, loadWorkflowByIdentifier, isWorkflowPath, + getWorkflowDescription, loadAllWorkflows, + loadAllWorkflowsWithSources, listWorkflows, listWorkflowEntries, type WorkflowDirEntry, + type WorkflowSource, + type WorkflowWithSource, } from './workflowLoader.js'; export { @@ -21,6 +25,7 @@ export { type CategoryConfig, type CategorizedWorkflows, type MissingWorkflow, + type WorkflowCategoryNode, } from './workflowCategories.js'; export { diff --git a/src/infra/config/loaders/workflowCategories.ts b/src/infra/config/loaders/workflowCategories.ts index caee643..5243777 100644 --- a/src/infra/config/loaders/workflowCategories.ts +++ b/src/infra/config/loaders/workflowCategories.ts @@ -6,40 +6,107 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod/v4'; -import type { WorkflowConfig } from '../../../core/models/index.js'; -import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js'; -import { getLanguage } from '../global/globalConfig.js'; +import { getProjectConfigPath } from '../paths.js'; +import { getLanguage, getBuiltinWorkflowsEnabled, getDisabledBuiltins } from '../global/globalConfig.js'; +import { + getWorkflowCategoriesConfig, + getShowOthersCategory, + getOthersCategoryName, +} from '../global/workflowCategories.js'; import { getLanguageResourcesDir } from '../../resources/index.js'; +import { listBuiltinWorkflowNames } from './workflowResolver.js'; +import type { WorkflowSource, WorkflowWithSource } from './workflowResolver.js'; const CategoryConfigSchema = z.object({ - workflow_categories: z.record(z.string(), z.array(z.string())).optional(), + workflow_categories: z.record(z.string(), z.unknown()).optional(), show_others_category: z.boolean().optional(), others_category_name: z.string().min(1).optional(), }).passthrough(); +export interface WorkflowCategoryNode { + name: string; + workflows: string[]; + children: WorkflowCategoryNode[]; +} + export interface CategoryConfig { - workflowCategories: Record; + workflowCategories: WorkflowCategoryNode[]; showOthersCategory: boolean; othersCategoryName: string; } export interface CategorizedWorkflows { - categories: Map; - allWorkflows: Map; + categories: WorkflowCategoryNode[]; + builtinCategories: WorkflowCategoryNode[]; + allWorkflows: Map; missingWorkflows: MissingWorkflow[]; } export interface MissingWorkflow { - categoryName: string; + categoryPath: string[]; workflowName: string; } interface RawCategoryConfig { - workflow_categories?: Record; + workflow_categories?: Record; show_others_category?: boolean; others_category_name?: string; } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function parseWorkflows(raw: unknown, sourceLabel: string, path: string[]): string[] { + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + throw new Error(`workflows must be an array in ${sourceLabel} at ${path.join(' > ')}`); + } + const workflows: string[] = []; + for (const item of raw) { + if (typeof item !== 'string' || item.trim().length === 0) { + throw new Error(`workflow name must be a non-empty string in ${sourceLabel} at ${path.join(' > ')}`); + } + workflows.push(item); + } + return workflows; +} + +function parseCategoryNode( + name: string, + raw: unknown, + sourceLabel: string, + path: string[], +): WorkflowCategoryNode { + if (!isRecord(raw)) { + throw new Error(`category "${name}" must be an object in ${sourceLabel} at ${path.join(' > ')}`); + } + + const workflows = parseWorkflows(raw.workflows, sourceLabel, path); + const children: WorkflowCategoryNode[] = []; + + for (const [key, value] of Object.entries(raw)) { + if (key === 'workflows') continue; + if (!isRecord(value)) { + throw new Error(`category "${key}" must be an object in ${sourceLabel} at ${[...path, key].join(' > ')}`); + } + children.push(parseCategoryNode(key, value, sourceLabel, [...path, key])); + } + + return { name, workflows, children }; +} + +function parseCategoryTree(raw: unknown, sourceLabel: string): WorkflowCategoryNode[] { + if (!isRecord(raw)) { + throw new Error(`workflow_categories must be an object in ${sourceLabel}`); + } + const categories: WorkflowCategoryNode[] = []; + for (const [name, value] of Object.entries(raw)) { + categories.push(parseCategoryNode(name, value, sourceLabel, [name])); + } + return categories; +} + function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig | null { if (!raw || typeof raw !== 'object') { return null; @@ -64,7 +131,7 @@ function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig : parsed.others_category_name; return { - workflowCategories: parsed.workflow_categories, + workflowCategories: parseCategoryTree(parsed.workflow_categories, sourceLabel), showOthersCategory, othersCategoryName, }; @@ -94,9 +161,16 @@ export function loadDefaultCategories(): CategoryConfig | null { * Priority: user config -> project config -> default categories. */ export function getWorkflowCategories(cwd: string): CategoryConfig | null { - const userConfig = loadCategoryConfigFromPath(getGlobalConfigPath(), 'global config'); - if (userConfig) { - return userConfig; + // Check user config from separate file (~/.takt/workflow-categories.yaml) + const userCategoriesNode = getWorkflowCategoriesConfig(); + if (userCategoriesNode) { + const showOthersCategory = getShowOthersCategory() ?? true; + const othersCategoryName = getOthersCategoryName() ?? 'Others'; + return { + workflowCategories: parseCategoryTree(userCategoriesNode, 'user config'), + showOthersCategory, + othersCategoryName, + }; } const projectConfig = loadCategoryConfigFromPath(getProjectConfigPath(cwd), 'project config'); @@ -107,48 +181,169 @@ export function getWorkflowCategories(cwd: string): CategoryConfig | null { return loadDefaultCategories(); } +function collectMissingWorkflows( + categories: WorkflowCategoryNode[], + allWorkflows: Map, + ignoreWorkflows: Set, +): MissingWorkflow[] { + const missing: MissingWorkflow[] = []; + const visit = (nodes: WorkflowCategoryNode[], path: string[]): void => { + for (const node of nodes) { + const nextPath = [...path, node.name]; + for (const workflowName of node.workflows) { + if (ignoreWorkflows.has(workflowName)) continue; + if (!allWorkflows.has(workflowName)) { + missing.push({ categoryPath: nextPath, workflowName }); + } + } + if (node.children.length > 0) { + visit(node.children, nextPath); + } + } + }; + + visit(categories, []); + return missing; +} + +function buildCategoryTreeForSource( + categories: WorkflowCategoryNode[], + allWorkflows: Map, + sourceFilter: (source: WorkflowSource) => boolean, + categorized: Set, +): WorkflowCategoryNode[] { + const result: WorkflowCategoryNode[] = []; + + for (const node of categories) { + const workflows: string[] = []; + for (const workflowName of node.workflows) { + const entry = allWorkflows.get(workflowName); + if (!entry) continue; + if (!sourceFilter(entry.source)) continue; + workflows.push(workflowName); + categorized.add(workflowName); + } + + const children = buildCategoryTreeForSource(node.children, allWorkflows, sourceFilter, categorized); + if (workflows.length > 0 || children.length > 0) { + result.push({ name: node.name, workflows, children }); + } + } + + return result; +} + +function appendOthersCategory( + categories: WorkflowCategoryNode[], + allWorkflows: Map, + categorized: Set, + sourceFilter: (source: WorkflowSource) => boolean, + othersCategoryName: string, +): WorkflowCategoryNode[] { + if (categories.some((node) => node.name === othersCategoryName)) { + return categories; + } + + const uncategorized: string[] = []; + for (const [workflowName, entry] of allWorkflows.entries()) { + if (!sourceFilter(entry.source)) continue; + if (categorized.has(workflowName)) continue; + uncategorized.push(workflowName); + } + + if (uncategorized.length === 0) { + return categories; + } + + return [...categories, { name: othersCategoryName, workflows: uncategorized, children: [] }]; +} + /** * Build categorized workflows map from configuration. */ export function buildCategorizedWorkflows( - allWorkflows: Map, + allWorkflows: Map, config: CategoryConfig, ): CategorizedWorkflows { - const categories = new Map(); - const categorized = new Set(); - const missingWorkflows: MissingWorkflow[] = []; - - for (const [categoryName, workflowNames] of Object.entries(config.workflowCategories)) { - const validWorkflows: string[] = []; - - for (const workflowName of workflowNames) { - if (allWorkflows.has(workflowName)) { - validWorkflows.push(workflowName); - categorized.add(workflowName); - } else { - missingWorkflows.push({ categoryName, workflowName }); - } + const ignoreMissing = new Set(); + if (!getBuiltinWorkflowsEnabled()) { + for (const name of listBuiltinWorkflowNames({ includeDisabled: true })) { + ignoreMissing.add(name); } - - if (validWorkflows.length > 0) { - categories.set(categoryName, validWorkflows); + } else { + for (const name of getDisabledBuiltins()) { + ignoreMissing.add(name); } } - if (config.showOthersCategory) { - const uncategorized: string[] = []; - for (const workflowName of allWorkflows.keys()) { - if (!categorized.has(workflowName)) { - uncategorized.push(workflowName); - } - } + const missingWorkflows = collectMissingWorkflows( + config.workflowCategories, + allWorkflows, + ignoreMissing, + ); - if (uncategorized.length > 0 && !categories.has(config.othersCategoryName)) { - categories.set(config.othersCategoryName, uncategorized); + const isBuiltin = (source: WorkflowSource): boolean => source === 'builtin'; + const isCustom = (source: WorkflowSource): boolean => source !== 'builtin'; + + const categorizedCustom = new Set(); + const categories = buildCategoryTreeForSource( + config.workflowCategories, + allWorkflows, + isCustom, + categorizedCustom, + ); + + const categorizedBuiltin = new Set(); + const builtinCategories = buildCategoryTreeForSource( + config.workflowCategories, + allWorkflows, + isBuiltin, + categorizedBuiltin, + ); + + const finalCategories = config.showOthersCategory + ? appendOthersCategory( + categories, + allWorkflows, + categorizedCustom, + isCustom, + config.othersCategoryName, + ) + : categories; + + const finalBuiltinCategories = config.showOthersCategory + ? appendOthersCategory( + builtinCategories, + allWorkflows, + categorizedBuiltin, + isBuiltin, + config.othersCategoryName, + ) + : builtinCategories; + + return { + categories: finalCategories, + builtinCategories: finalBuiltinCategories, + allWorkflows, + missingWorkflows, + }; +} + +function findWorkflowCategoryPaths( + workflow: string, + categories: WorkflowCategoryNode[], + prefix: string[], + results: string[], +): void { + for (const node of categories) { + const path = [...prefix, node.name]; + if (node.workflows.includes(workflow)) { + results.push(path.join(' / ')); + } + if (node.children.length > 0) { + findWorkflowCategoryPaths(workflow, node.children, path, results); } } - - return { categories, allWorkflows, missingWorkflows }; } /** @@ -156,13 +351,9 @@ export function buildCategorizedWorkflows( */ export function findWorkflowCategories( workflow: string, - categorized: CategorizedWorkflows, + categories: WorkflowCategoryNode[], ): string[] { const result: string[] = []; - for (const [categoryName, workflows] of categorized.categories) { - if (workflows.includes(workflow)) { - result.push(categoryName); - } - } + findWorkflowCategoryPaths(workflow, categories, [], result); return result; } diff --git a/src/infra/config/loaders/workflowLoader.ts b/src/infra/config/loaders/workflowLoader.ts index 6eec02b..5ec0f9a 100644 --- a/src/infra/config/loaders/workflowLoader.ts +++ b/src/infra/config/loaders/workflowLoader.ts @@ -15,8 +15,12 @@ export { loadWorkflow, isWorkflowPath, loadWorkflowByIdentifier, + getWorkflowDescription, loadAllWorkflows, + loadAllWorkflowsWithSources, listWorkflows, listWorkflowEntries, type WorkflowDirEntry, + type WorkflowSource, + type WorkflowWithSource, } from './workflowResolver.js'; diff --git a/src/infra/config/loaders/workflowResolver.ts b/src/infra/config/loaders/workflowResolver.ts index 80960e4..a8832eb 100644 --- a/src/infra/config/loaders/workflowResolver.ts +++ b/src/infra/config/loaders/workflowResolver.ts @@ -10,14 +10,33 @@ import { join, resolve, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; import type { WorkflowConfig } from '../../../core/models/index.js'; import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from '../paths.js'; -import { getLanguage, getDisabledBuiltins } from '../global/globalConfig.js'; +import { getLanguage, getDisabledBuiltins, getBuiltinWorkflowsEnabled } from '../global/globalConfig.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { loadWorkflowFromFile } from './workflowParser.js'; const log = createLogger('workflow-resolver'); +export type WorkflowSource = 'builtin' | 'user' | 'project'; + +export interface WorkflowWithSource { + config: WorkflowConfig; + source: WorkflowSource; +} + +export function listBuiltinWorkflowNames(options?: { includeDisabled?: boolean }): string[] { + const lang = getLanguage(); + const dir = getBuiltinWorkflowsDir(lang); + const disabled = options?.includeDisabled ? undefined : getDisabledBuiltins(); + const names = new Set(); + for (const entry of iterateWorkflowDir(dir, 'builtin', disabled)) { + names.add(entry.name); + } + return Array.from(names); +} + /** Get builtin workflow by name */ export function getBuiltinWorkflow(name: string): WorkflowConfig | null { + if (!getBuiltinWorkflowsEnabled()) return null; const lang = getLanguage(); const disabled = getDisabledBuiltins(); if (disabled.includes(name)) return null; @@ -126,6 +145,24 @@ export function loadWorkflowByIdentifier( return loadWorkflow(identifier, projectCwd); } +/** + * Get workflow description by identifier. + * Returns the workflow name and description (if available). + */ +export function getWorkflowDescription( + identifier: string, + projectCwd: string, +): { name: string; description: string } { + const workflow = loadWorkflowByIdentifier(identifier, projectCwd); + if (!workflow) { + return { name: identifier, description: '' }; + } + return { + name: workflow.name, + description: workflow.description ?? '', + }; +} + /** Entry for a workflow file found in a directory */ export interface WorkflowDirEntry { /** Workflow name (e.g. "react") */ @@ -134,6 +171,8 @@ export interface WorkflowDirEntry { path: string; /** Category (subdirectory name), undefined for root-level workflows */ category?: string; + /** Workflow source (builtin, user, project) */ + source: WorkflowSource; } /** @@ -143,6 +182,7 @@ export interface WorkflowDirEntry { */ function* iterateWorkflowDir( dir: string, + source: WorkflowSource, disabled?: string[], ): Generator { if (!existsSync(dir)) return; @@ -153,7 +193,7 @@ function* iterateWorkflowDir( if (stat.isFile() && (entry.endsWith('.yaml') || entry.endsWith('.yml'))) { const workflowName = entry.replace(/\.ya?ml$/, ''); if (disabled?.includes(workflowName)) continue; - yield { name: workflowName, path: entryPath }; + yield { name: workflowName, path: entryPath, source }; continue; } @@ -167,21 +207,47 @@ function* iterateWorkflowDir( const workflowName = subEntry.replace(/\.ya?ml$/, ''); const qualifiedName = `${category}/${workflowName}`; if (disabled?.includes(qualifiedName)) continue; - yield { name: qualifiedName, path: subEntryPath, category }; + yield { name: qualifiedName, path: subEntryPath, category, source }; } } } } /** Get the 3-layer directory list (builtin → user → project-local) */ -function getWorkflowDirs(cwd: string): { dir: string; disabled?: string[] }[] { +function getWorkflowDirs(cwd: string): { dir: string; source: WorkflowSource; disabled?: string[] }[] { const disabled = getDisabledBuiltins(); const lang = getLanguage(); - return [ - { dir: getBuiltinWorkflowsDir(lang), disabled }, - { dir: getGlobalWorkflowsDir() }, - { dir: join(getProjectConfigDir(cwd), 'workflows') }, - ]; + const dirs: { dir: string; source: WorkflowSource; disabled?: string[] }[] = []; + if (getBuiltinWorkflowsEnabled()) { + dirs.push({ dir: getBuiltinWorkflowsDir(lang), disabled, source: 'builtin' }); + } + dirs.push({ dir: getGlobalWorkflowsDir(), source: 'user' }); + dirs.push({ dir: join(getProjectConfigDir(cwd), 'workflows'), source: 'project' }); + return dirs; +} + +/** + * Load all workflows with source metadata. + * + * Priority (later entries override earlier): + * 1. Builtin workflows + * 2. User workflows (~/.takt/workflows/) + * 3. Project-local workflows (.takt/workflows/) + */ +export function loadAllWorkflowsWithSources(cwd: string): Map { + const workflows = new Map(); + + for (const { dir, source, disabled } of getWorkflowDirs(cwd)) { + for (const entry of iterateWorkflowDir(dir, source, disabled)) { + try { + workflows.set(entry.name, { config: loadWorkflowFromFile(entry.path), source: entry.source }); + } catch (err) { + log.debug('Skipping invalid workflow file', { path: entry.path, error: getErrorMessage(err) }); + } + } + } + + return workflows; } /** @@ -194,17 +260,10 @@ function getWorkflowDirs(cwd: string): { dir: string; disabled?: string[] }[] { */ export function loadAllWorkflows(cwd: string): Map { const workflows = new Map(); - - for (const { dir, disabled } of getWorkflowDirs(cwd)) { - for (const entry of iterateWorkflowDir(dir, disabled)) { - try { - workflows.set(entry.name, loadWorkflowFromFile(entry.path)); - } catch (err) { - log.debug('Skipping invalid workflow file', { path: entry.path, error: getErrorMessage(err) }); - } - } + const withSources = loadAllWorkflowsWithSources(cwd); + for (const [name, entry] of withSources) { + workflows.set(name, entry.config); } - return workflows; } @@ -215,8 +274,8 @@ export function loadAllWorkflows(cwd: string): Map { export function listWorkflows(cwd: string): string[] { const workflows = new Set(); - for (const { dir, disabled } of getWorkflowDirs(cwd)) { - for (const entry of iterateWorkflowDir(dir, disabled)) { + for (const { dir, source, disabled } of getWorkflowDirs(cwd)) { + for (const entry of iterateWorkflowDir(dir, source, disabled)) { workflows.add(entry.name); } } @@ -235,8 +294,8 @@ export function listWorkflowEntries(cwd: string): WorkflowDirEntry[] { // Later entries override earlier (project-local > user > builtin) const workflows = new Map(); - for (const { dir, disabled } of getWorkflowDirs(cwd)) { - for (const entry of iterateWorkflowDir(dir, disabled)) { + for (const { dir, source, disabled } of getWorkflowDirs(cwd)) { + for (const entry of iterateWorkflowDir(dir, source, disabled)) { workflows.set(entry.name, entry); } } diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index d04bad2..225d6f3 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -2,6 +2,8 @@ * Config module type definitions */ +import type { WorkflowCategoryConfigNode } from '../../core/models/schemas.js'; + /** Permission mode for the project * - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts) * - sacrifice-my-pc: Auto-approves all permission requests (bypassPermissions) @@ -24,7 +26,7 @@ export interface ProjectLocalConfig { /** Verbose output mode */ verbose?: boolean; /** Workflow categories (name -> workflow list) */ - workflow_categories?: Record; + workflow_categories?: Record; /** Show uncategorized workflows under Others category */ show_others_category?: boolean; /** Display name for Others category */ diff --git a/src/shared/prompt/select.ts b/src/shared/prompt/select.ts index ebdfeaf..bc6a701 100644 --- a/src/shared/prompt/select.ts +++ b/src/shared/prompt/select.ts @@ -86,6 +86,7 @@ export type KeyInputResult = | { action: 'confirm'; selectedIndex: number } | { action: 'cancel'; cancelIndex: number } | { action: 'bookmark'; selectedIndex: number } + | { action: 'remove_bookmark'; selectedIndex: number } | { action: 'exit' } | { action: 'none' }; @@ -118,15 +119,18 @@ export function handleKeyInput( if (key === 'b') { return { action: 'bookmark', selectedIndex: currentIndex }; } + if (key === 'r') { + return { action: 'remove_bookmark', selectedIndex: currentIndex }; + } return { action: 'none' }; } /** Print the menu header (message + hint). */ -function printHeader(message: string, showBookmarkHint: boolean): void { +function printHeader(message: string, hasCustomKeyHandler: boolean): void { console.log(); console.log(chalk.cyan(message)); - const hint = showBookmarkHint - ? ' (↑↓ to move, Enter to select, b to bookmark)' + const hint = hasCustomKeyHandler + ? ' (↑↓ to move, Enter to select, b to bookmark, r to remove)' : ' (↑↓ to move, Enter to select)'; console.log(chalk.gray(hint)); console.log(); @@ -165,7 +169,13 @@ function redrawMenu( /** Callbacks for interactive select behavior */ export interface InteractiveSelectCallbacks { - /** Called when 'b' key is pressed. Returns updated options for re-render. */ + /** + * Custom key handler called before default key handling. + * Return updated options to handle the key and re-render. + * Return null to delegate to default handler. + */ + onKeyPress?: (key: string, value: T, index: number) => SelectOptionItem[] | null; + /** Called when 'b' key is pressed. Returns updated options for re-render. @deprecated Use onKeyPress instead */ onBookmark?: (value: T, index: number) => SelectOptionItem[]; /** Custom label for cancel option (default: "Cancel") */ cancelLabel?: string; @@ -191,7 +201,7 @@ function interactiveSelect( let selectedIndex = initialIndex; const cancelLabel = callbacks?.cancelLabel ?? 'Cancel'; - printHeader(message, !!callbacks?.onBookmark); + printHeader(message, !!callbacks?.onKeyPress || !!callbacks?.onBookmark); process.stdout.write('\x1B[?7l'); @@ -213,8 +223,29 @@ function interactiveSelect( }; const onKeypress = (data: Buffer): void => { + const key = data.toString(); + + // Try custom key handler first + if (callbacks?.onKeyPress && selectedIndex < currentOptions.length) { + const item = currentOptions[selectedIndex]; + if (item) { + const customResult = callbacks.onKeyPress(key, item.value, selectedIndex); + if (customResult !== null) { + // Custom handler processed the key + const currentValue = item.value; + currentOptions = customResult; + totalItems = hasCancelOption ? currentOptions.length + 1 : currentOptions.length; + const newIdx = currentOptions.findIndex((o) => o.value === currentValue); + selectedIndex = newIdx >= 0 ? newIdx : Math.min(selectedIndex, currentOptions.length - 1); + totalLines = redrawMenu(currentOptions, selectedIndex, hasCancelOption, totalLines, cancelLabel); + return; + } + } + } + + // Delegate to default handler const result = handleKeyInput( - data.toString(), + key, selectedIndex, totalItems, hasCancelOption, @@ -250,6 +281,9 @@ function interactiveSelect( totalLines = redrawMenu(currentOptions, selectedIndex, hasCancelOption, totalLines, cancelLabel); break; } + case 'remove_bookmark': + // Ignore - should be handled by custom onKeyPress + break; case 'exit': cleanup(onKeypress); process.exit(130);