This commit is contained in:
nrslib 2026-02-03 14:08:45 +09:00
parent def50ff4a7
commit 32022df79a
44 changed files with 1932 additions and 552 deletions

184
CLAUDE.md
View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -1,22 +1,27 @@
workflow_categories:
"🚀 Quick Start":
workflows:
- minimal
- default
"🔍 Review & Fix":
workflows:
- review-fix-minimal
"🎨 Frontend":
[]
{}
"⚙️ Backend":
[]
{}
"🔧 Full Stack":
"🔧 Expert":
"Full Stack":
workflows:
- expert
- expert-cqrs
"Others":
workflows:
- research
- magi
- review-only

View File

@ -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 |

View File

@ -23,7 +23,7 @@
- 「念のため」で未使用コードを書く → 禁止(レビューで指摘される)
- 設計判断を勝手にする → 報告して判断を仰ぐ
- レビュワーの指摘を軽視する → 禁止(あなたの認識が間違っている)
- **Legacy対応を勝手に追加する → 禁止(明示的な指示がない限り不要)**
- **後方互換・Legacy対応を勝手に追加する → 絶対禁止(フォールバック、古いAPI維持、移行期コードなど、明示的な指示がない限り不要)**
## 最重要ルール

View File

@ -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 など

View File

@ -1,22 +1,26 @@
workflow_categories:
"🚀 クイックスタート":
- minimal
workflows:
- default
- minimal
"🔍 レビュー&修正":
workflows:
- review-fix-minimal
"🎨 フロントエンド":
[]
{}
"⚙️ バックエンド":
[]
{}
"🔧 フルスタック":
workflows:
- expert
- expert-cqrs
"その他":
workflows:
- research
- magi
- review-only

View File

@ -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 |

View File

@ -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', () => ({

View File

@ -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']);
});
});

View File

@ -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';

View File

@ -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', () => ({

View File

@ -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', () => ({

View File

@ -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';

View File

@ -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', () => ({

View File

@ -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', () => ({

View File

@ -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', () => ({

View File

@ -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();

View File

@ -21,7 +21,15 @@ vi.mock('../infra/claude/client.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../infra/claude/client.js')>();
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');

View File

@ -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) => ({

View File

@ -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<string, unknown>;
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');
});
});

View File

@ -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);

View File

@ -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<string, unknown>;
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<string, unknown>;
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<string, WorkflowConfig> {
const workflows = new Map<string, WorkflowConfig>();
for (const name of names) {
workflows.set(name, {
name,
function createWorkflowMap(entries: { name: string; source: 'builtin' | 'user' | 'project' }[]):
Map<string, WorkflowWithSource> {
const workflows = new Map<string, WorkflowWithSource>();
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,6 +115,7 @@ describe('workflow category config loading', () => {
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
workflow_categories:
Default:
workflows:
- simple
show_others_category: true
others_category_name: "Others"
@ -98,26 +123,32 @@ 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:
workflows:
- simple
`);
writeYaml(projectConfigPath, `
workflow_categories:
Project:
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,30 +156,36 @@ show_others_category: false
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
workflow_categories:
Default:
workflows:
- simple
`);
writeYaml(projectConfigPath, `
workflow_categories:
Project:
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:
workflows:
- simple
`);
@ -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']);
});
});

View File

@ -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);
});
});

View File

@ -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);
});

View File

@ -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<string, string[]>;
/** 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 */

View File

@ -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<WorkflowCategoryConfigNode> = 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 */

View File

@ -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<typeof buildWorkflowSelectionItems>,
currentWorkflow: string,
): (value: string) => SelectOptionItem<string>[] {
return (value: string): SelectOptionItem<string>[] => {
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<string | null> {
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<string>('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<string>(`Select workflow in ${categoryName}:`, bookmarkedInCategory, {
cancelLabel: '← Go back',
onBookmark: (value: string): SelectOptionItem<string>[] => {
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) {

View File

@ -2,4 +2,4 @@
* Interactive mode commands.
*/
export { interactiveMode } from './interactive.js';
export { interactiveMode, type WorkflowContext, type InteractiveModeResult } from './interactive.js';

View File

@ -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 = `あなたはTAKTAIエージェントワークフローオーケストレーションツールの対話モードを担当しています。
##
## 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') {
return {
systemPrompt: readPromptFile(
function getInteractivePrompts(lang: 'en' | 'ja', workflowContext?: WorkflowContext) {
let systemPrompt = readPromptFile(
lang,
'interactive-system.md',
lang === 'ja' ? INTERACTIVE_SYSTEM_PROMPT_JA : INTERACTIVE_SYSTEM_PROMPT_EN,
),
summaryPrompt: readPromptFile(
);
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,
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<InteractiveModeResult> {
export async function interactiveMode(
cwd: string,
initialInput?: string,
workflowContext?: WorkflowContext,
): Promise<InteractiveModeResult> {
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.');
}

View File

@ -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<strin
}
const entries = listWorkflowEntries(cwd);
const items = buildWorkflowSelectionItems(entries);
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<string>('Select workflow:', buildFlatOptions(), {
onBookmark: (value: string): SelectOptionItem<string>[] => {
toggleBookmark(value);
return buildFlatOptions();
},
});
}
const createTopLevelBookmarkCallback = (): ((value: string) => SelectOptionItem<string>[]) => {
return (value: string): SelectOptionItem<string>[] => {
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<string>('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<string>(`Select workflow in ${categoryName}:`, bookmarkedCategoryOptions, {
cancelLabel: '← Go back',
onBookmark: (value: string): SelectOptionItem<string>[] => {
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<string | null> {
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<string | null> {
* - 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<string | null> {
export async function determineWorkflow(cwd: string, override?: string): Promise<string | null> {
if (override) {
if (isWorkflowPath(override)) {
return override;

View File

@ -10,6 +10,7 @@ export type { PipelineExecutionOptions } from './execute/types.js';
export {
selectAndExecuteTask,
confirmAndCreateWorktree,
determineWorkflow,
type SelectAndExecuteOptions,
type WorktreeConfirmationResult,
} from './execute/selectAndExecute.js';

View File

@ -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<string>[] {
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<string>[] | 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<string, WorkflowCategoryNode>;
} {
const options: SelectionOption[] = [];
const categoryMap = new Map<string, WorkflowCategoryNode>();
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<string | null> {
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<string>(message, buildOptionsWithBookmarks(), {
cancelLabel: (stack.length > 0 || hasSourceSelection) ? '← Go back' : 'Cancel',
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | 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<string, WorkflowWithSource>,
sourceFilter: WorkflowSource,
): number {
const categorizedWorkflows = new Set<string>();
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<TopLevelSelection | null> {
const uncategorizedCustom = getRootLevelWorkflows(
categorized.categories,
categorized.allWorkflows,
'user'
);
const builtinCount = countWorkflowsIncludingCategories(
categorized.builtinCategories,
categorized.allWorkflows,
'builtin'
);
const buildOptions = (): SelectOptionItem<string>[] => {
const options: SelectOptionItem<string>[] = [];
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<string>('Select workflow:', buildOptions(), {
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | 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<string, WorkflowWithSource>,
sourceFilter: WorkflowSource,
): string[] {
const categorizedWorkflows = new Set<string>();
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<string | null> {
const categoryOptions = buildCategorySelectOptions(categorized, currentWorkflow);
if (categoryOptions.length === 0) {
info('No workflows available for configured categories.');
while (true) {
const selection = await selectTopLevelWorkflowOption(categorized, currentWorkflow);
if (!selection) {
return null;
}
// Loop until user selects a workflow or cancels at category level
while (true) {
const selectedCategory = await selectOption<string>('Select workflow category:', categoryOptions);
if (!selectedCategory) return null;
// 1. Current workflow selected
if (selection.type === 'current') {
return currentWorkflow;
}
const buildWorkflowOptions = (): SelectOptionItem<string>[] | null =>
buildWorkflowOptionsForCategory(categorized, selectedCategory, currentWorkflow);
// 2. Direct workflow selected (e.g., bookmarked workflow)
if (selection.type === 'workflow') {
return selection.name;
}
const baseWorkflowOptions = buildWorkflowOptions();
if (!baseWorkflowOptions) continue;
// 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 applyWorkflowBookmarks = (options: SelectOptionItem<string>[]): SelectOptionItem<string>[] => {
return applyBookmarks(options, getBookmarkedWorkflows()) as SelectOptionItem<string>[];
};
const selectedWorkflow = await selectOption<string>(
`Select workflow in ${selectedCategory}:`,
applyWorkflowBookmarks(baseWorkflowOptions),
{
cancelLabel: '← Go back',
onBookmark: (value: string): SelectOptionItem<string>[] => {
toggleBookmark(value);
const updatedOptions = buildWorkflowOptions();
if (!updatedOptions) return [];
return applyWorkflowBookmarks(updatedOptions);
},
},
// 4. Builtin workflows selected
if (selection.type === 'builtin') {
const rootWorkflows = getRootLevelWorkflows(
categorized.builtinCategories,
categorized.allWorkflows,
'builtin'
);
// If workflow selected, return it. If cancelled (null), go back to category selection
if (selectedWorkflow) return selectedWorkflow;
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<string>('Select workflow:', buildFlatOptions(), {
cancelLabel: '← Go back',
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
if (key === 'b') {
addBookmark(value);
return buildFlatOptions();
}
if (key === 'r') {
removeBookmark(value);
return buildFlatOptions();
}
return null; // Delegate to default handler
},
});
if (workflow) {
return workflow;
}
// null → go back to top-level selection
continue;
}
}
}
async function selectWorkflowFromEntriesWithCategories(
entries: WorkflowDirEntry[],
currentWorkflow: string,
): Promise<string | null> {
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<string>('Select workflow:', buildFlatOptions(), {
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | 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<string>('Select workflow:', buildTopLevelOptions(), {
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | 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<string>(`Select workflow in ${categoryName}:`, buildCategoryOptions(), {
cancelLabel: '← Go back',
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | 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<string | null> {
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);
}

View File

@ -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);
}

View File

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

View File

@ -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,

View File

@ -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);
}

View File

@ -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 {

View File

@ -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<string, string[]>;
workflowCategories: WorkflowCategoryNode[];
showOthersCategory: boolean;
othersCategoryName: string;
}
export interface CategorizedWorkflows {
categories: Map<string, string[]>;
allWorkflows: Map<string, WorkflowConfig>;
categories: WorkflowCategoryNode[];
builtinCategories: WorkflowCategoryNode[];
allWorkflows: Map<string, WorkflowWithSource>;
missingWorkflows: MissingWorkflow[];
}
export interface MissingWorkflow {
categoryName: string;
categoryPath: string[];
workflowName: string;
}
interface RawCategoryConfig {
workflow_categories?: Record<string, string[]>;
workflow_categories?: Record<string, unknown>;
show_others_category?: boolean;
others_category_name?: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, WorkflowWithSource>,
ignoreWorkflows: Set<string>,
): 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<string, WorkflowWithSource>,
sourceFilter: (source: WorkflowSource) => boolean,
categorized: Set<string>,
): 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<string, WorkflowWithSource>,
categorized: Set<string>,
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<string, WorkflowConfig>,
allWorkflows: Map<string, WorkflowWithSource>,
config: CategoryConfig,
): CategorizedWorkflows {
const categories = new Map<string, string[]>();
const categorized = new Set<string>();
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);
const ignoreMissing = new Set<string>();
if (!getBuiltinWorkflowsEnabled()) {
for (const name of listBuiltinWorkflowNames({ includeDisabled: true })) {
ignoreMissing.add(name);
}
} else {
missingWorkflows.push({ categoryName, workflowName });
for (const name of getDisabledBuiltins()) {
ignoreMissing.add(name);
}
}
if (validWorkflows.length > 0) {
categories.set(categoryName, validWorkflows);
}
}
const missingWorkflows = collectMissingWorkflows(
config.workflowCategories,
allWorkflows,
ignoreMissing,
);
if (config.showOthersCategory) {
const uncategorized: string[] = [];
for (const workflowName of allWorkflows.keys()) {
if (!categorized.has(workflowName)) {
uncategorized.push(workflowName);
}
}
const isBuiltin = (source: WorkflowSource): boolean => source === 'builtin';
const isCustom = (source: WorkflowSource): boolean => source !== 'builtin';
if (uncategorized.length > 0 && !categories.has(config.othersCategoryName)) {
categories.set(config.othersCategoryName, uncategorized);
}
}
const categorizedCustom = new Set<string>();
const categories = buildCategoryTreeForSource(
config.workflowCategories,
allWorkflows,
isCustom,
categorizedCustom,
);
return { categories, allWorkflows, missingWorkflows };
const categorizedBuiltin = new Set<string>();
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);
}
}
}
/**
@ -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;
}

View File

@ -15,8 +15,12 @@ export {
loadWorkflow,
isWorkflowPath,
loadWorkflowByIdentifier,
getWorkflowDescription,
loadAllWorkflows,
loadAllWorkflowsWithSources,
listWorkflows,
listWorkflowEntries,
type WorkflowDirEntry,
type WorkflowSource,
type WorkflowWithSource,
} from './workflowResolver.js';

View File

@ -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<string>();
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<WorkflowDirEntry> {
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<string, WorkflowWithSource> {
const workflows = new Map<string, WorkflowWithSource>();
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<string, WorkflowConfig> {
const workflows = new Map<string, WorkflowConfig>();
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<string, WorkflowConfig> {
export function listWorkflows(cwd: string): string[] {
const workflows = new Set<string>();
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<string, WorkflowDirEntry>();
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);
}
}

View File

@ -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<string, string[]>;
workflow_categories?: Record<string, WorkflowCategoryConfigNode>;
/** Show uncategorized workflows under Others category */
show_others_category?: boolean;
/** Display name for Others category */

View File

@ -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<T extends string>(
/** Callbacks for interactive select behavior */
export interface InteractiveSelectCallbacks<T extends string> {
/** 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<T>[] | null;
/** Called when 'b' key is pressed. Returns updated options for re-render. @deprecated Use onKeyPress instead */
onBookmark?: (value: T, index: number) => SelectOptionItem<T>[];
/** Custom label for cancel option (default: "Cancel") */
cancelLabel?: string;
@ -191,7 +201,7 @@ function interactiveSelect<T extends string>(
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<T extends string>(
};
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<T extends string>(
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);