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 | | Command | Description |
|---------|-------------| |---------|-------------|
| `npm run build` | TypeScript build | | `npm run build` | TypeScript build |
| `npm run watch` | TypeScript build in watch mode |
| `npm run test` | Run all tests | | `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 | | `npm run lint` | ESLint |
| `npx vitest run src/__tests__/client.test.ts` | Run single test file | | `npx vitest run src/__tests__/client.test.ts` | Run single test file |
| `npx vitest run -t "pattern"` | Run tests matching pattern | | `npx vitest run -t "pattern"` | Run tests matching pattern |
| `npm run prepublishOnly` | Lint, build, and test before publishing |
## CLI Subcommands ## CLI Subcommands
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `takt {task}` | Execute task with current workflow | | `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 run` | Execute all pending tasks from `.takt/tasks/` once |
| `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (resident process) | | `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (resident process) |
| `takt add` | Add a new task via AI conversation | | `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 config` | Configure settings (permission mode) |
| `takt --help` | Show help message | | `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 ## 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` - Emits events: `step:start`, `step:complete`, `step:blocked`, `step:loop_detected`, `workflow:complete`, `workflow:abort`, `iteration:limit`
- Supports loop detection (`LoopDetector`) and iteration limits - Supports loop detection (`LoopDetector`) and iteration limits
- Maintains agent sessions per step for conversation continuity - 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`) **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): - 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 6. `instruction_template` content
7. Status output rules (auto-injected for tag-based rules) 7. Status output rules (auto-injected for tag-based rules)
- Localized for `en` and `ja` - Localized for `en` and `ja`
- Related: `ReportInstructionBuilder` (Phase 2), `StatusJudgmentBuilder` (Phase 3)
**Agent Runner** (`src/agents/runner.ts`) **Agent Runner** (`src/agents/runner.ts`)
- Resolves agent specs (name or path) to agent configurations - 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 - `planner`: Read/Glob/Grep/Bash/WebSearch/WebFetch
- Custom agents via `.takt/agents.yaml` or prompt files (.md) - Custom agents via `.takt/agents.yaml` or prompt files (.md)
**Claude Integration** (`src/claude/`) **Provider Integration** (`src/infra/claude/`, `src/infra/codex/`)
- `client.ts` - High-level API: `callClaude()`, `callClaudeCustom()`, `callClaudeAgent()`, `callClaudeSkill()` - **Claude** - Uses `@anthropic-ai/claude-agent-sdk`
- `process.ts` - SDK wrapper with `ClaudeProcess` class - `client.ts` - High-level API: `callClaude()`, `callClaudeCustom()`, `callClaudeAgent()`, `callClaudeSkill()`
- `executor.ts` - Query execution using `@anthropic-ai/claude-agent-sdk` - `process.ts` - SDK wrapper with `ClaudeProcess` class
- `query-manager.ts` - Concurrent query tracking with query IDs - `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/`) **Configuration** (`src/infra/config/`)
- `loader.ts` - Custom agent loading from `.takt/agents.yaml` - `loaders/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/`) - `loaders/workflowParser.ts` - YAML parsing, step/rule normalization with Zod validation
- `agentLoader.ts` - Agent prompt file loading - `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 - `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/`) **Task Management** (`src/features/tasks/`)
- `runner.ts` - TaskRunner class for managing task files (`.takt/tasks/`) - `execute/taskExecution.ts` - Main task execution orchestration
- `watcher.ts` - TaskWatcher class for polling and auto-executing tasks (used by `/watch`) - `execute/workflowExecution.ts` - Workflow execution wrapper
- `index.ts` - Task operations (getNextTask, completeTask, addTask) - `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`) **GitHub Integration** (`src/infra/github/`)
- Fetches issues via `gh` CLI, formats as task text with title/body/labels/comments - `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 ### Data Flow
@ -240,6 +274,36 @@ Key points about parallel steps:
| `{user_inputs}` | Accumulated user inputs (auto-injected if not in template) | | `{user_inputs}` | Accumulated user inputs (auto-injected if not in template) |
| `{report_dir}` | Report directory name | | `{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 Resolution
Model is resolved in the following priority order: 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. **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. **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 - Specific report file names or formats
- Comment/output templates with hardcoded review type names - 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) ## 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. 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. `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 ## Testing Notes
- Vitest for testing framework - Vitest for testing framework
@ -324,3 +419,54 @@ Key constraints:
- Mock workflows and agent configs for integration tests - Mock workflows and agent configs for integration tests
- Test single files: `npx vitest run src/__tests__/filename.test.ts` - Test single files: `npx vitest run src/__tests__/filename.test.ts`
- Pattern matching: `npx vitest run -t "test pattern"` - 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) - Writing unused code "just in case" → Prohibited (will be flagged in review)
- Making design decisions arbitrarily → Report and ask for guidance - Making design decisions arbitrarily → Report and ask for guidance
- Dismissing reviewer feedback → Prohibited (your understanding is wrong) - 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 ## Most Important Rule

View File

@ -16,6 +16,9 @@ log_level: info
# Provider runtime: claude or codex # Provider runtime: claude or codex
provider: claude provider: claude
# Builtin workflows (resources/global/{lang}/workflows)
# enable_builtin_workflows: true
# Default model (optional) # Default model (optional)
# Claude: opus, sonnet, haiku, opusplan, default, or full model name # Claude: opus, sonnet, haiku, opusplan, default, or full model name
# Codex: gpt-5.2-codex, gpt-5.1-codex, etc. # Codex: gpt-5.2-codex, gpt-5.1-codex, etc.

View File

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

View File

@ -1,5 +1,5 @@
# Default TAKT Workflow # 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, # Boilerplate sections (Workflow Context, User Request, Previous Response,
# Additional User Inputs, Instructions heading) are auto-injected by buildInstruction(). # Additional User Inputs, Instructions heading) are auto-injected by buildInstruction().
@ -63,7 +63,7 @@ steps:
- WebFetch - WebFetch
rules: rules:
- condition: Requirements are clear and implementable - condition: Requirements are clear and implementable
next: implement next: architect
- condition: User is asking a question (not an implementation task) - condition: User is asking a question (not an implementation task)
next: COMPLETE next: COMPLETE
- condition: Requirements unclear, insufficient info - condition: Requirements unclear, insufficient info
@ -84,13 +84,79 @@ steps:
2. Identify impact scope 2. Identify impact scope
3. Decide implementation approach 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 - name: implement
edit: true edit: true
agent: ../agents/default/coder.md agent: ../agents/default/coder.md
session: refresh session: refresh
report: report:
- Scope: 01-coder-scope.md - Scope: 02-coder-scope.md
- Decisions: 02-coder-decisions.md - Decisions: 03-coder-decisions.md
allowed_tools: allowed_tools:
- Read - Read
- Glob - Glob
@ -113,10 +179,17 @@ steps:
requires_user_input: true requires_user_input: true
interactive_only: true interactive_only: true
instruction_template: | instruction_template: |
Follow the plan from the plan step and implement. Follow the plan from the plan step and the design from the architect step.
Refer to the plan report ({report:00-plan.md}) and proceed with implementation.
**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. 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):** **Scope report format (create at implementation start):**
```markdown ```markdown
# Change Scope Declaration # Change Scope Declaration
@ -162,7 +235,7 @@ steps:
agent: ../agents/default/ai-antipattern-reviewer.md agent: ../agents/default/ai-antipattern-reviewer.md
pass_previous_response: true pass_previous_response: true
report: report:
name: 03-ai-review.md name: 04-ai-review.md
format: | format: |
```markdown ```markdown
# AI-Generated Code Review # AI-Generated Code Review
@ -278,7 +351,7 @@ steps:
edit: false edit: false
agent: ../agents/default/architecture-reviewer.md agent: ../agents/default/architecture-reviewer.md
report: report:
name: 04-architect-review.md name: 05-architect-review.md
format: | format: |
```markdown ```markdown
# Architecture Review # Architecture Review
@ -318,15 +391,28 @@ steps:
- condition: approved - condition: approved
- condition: needs_fix - condition: needs_fix
instruction_template: | 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 - name: security-review
edit: false edit: false
agent: ../agents/default/security-reviewer.md agent: ../agents/default/security-reviewer.md
report: report:
name: 05-security-review.md name: 06-security-review.md
format: | format: |
```markdown ```markdown
# Security Review # Security Review
@ -417,7 +503,7 @@ steps:
edit: false edit: false
agent: ../agents/default/supervisor.md agent: ../agents/default/supervisor.md
report: report:
- Validation: 06-supervisor-validation.md - Validation: 07-supervisor-validation.md
- Summary: summary.md - Summary: summary.md
allowed_tools: allowed_tools:
- Read - Read
@ -436,7 +522,7 @@ steps:
Run tests, verify the build, and perform final approval. Run tests, verify the build, and perform final approval.
**Workflow Overall Review:** **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? 2. Were all review step issues addressed?
3. Was the original task objective achieved? 3. Was the original task objective achieved?
@ -485,8 +571,9 @@ steps:
## Review Results ## Review Results
| Review | Result | | Review | Result |
|--------|--------| |--------|--------|
| Architect | ✅ APPROVE | | Architecture Design | ✅ Complete |
| AI Review | ✅ APPROVE | | AI Review | ✅ APPROVE |
| Architect Review | ✅ APPROVE |
| Security | ✅ APPROVE | | Security | ✅ APPROVE |
| Supervisor | ✅ APPROVE | | Supervisor | ✅ APPROVE |

View File

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

View File

@ -16,6 +16,9 @@ log_level: info
# プロバイダー: claude または codex # プロバイダー: claude または codex
provider: claude provider: claude
# ビルトインワークフローの読み込み (resources/global/{lang}/workflows)
# enable_builtin_workflows: true
# デフォルトモデル (オプション) # デフォルトモデル (オプション)
# Claude: opus, sonnet, haiku, opusplan, default, またはフルモデル名 # Claude: opus, sonnet, haiku, opusplan, default, またはフルモデル名
# Codex: gpt-5.2-codex, gpt-5.1-codex など # Codex: gpt-5.2-codex, gpt-5.1-codex など

View File

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

View File

@ -1,5 +1,5 @@
# Default TAKT Workflow # 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): # Template Variables (auto-injected by buildInstruction):
# {iteration} - Workflow-wide turn count (total steps executed across all agents) # {iteration} - Workflow-wide turn count (total steps executed across all agents)
@ -54,7 +54,7 @@ steps:
- WebFetch - WebFetch
rules: rules:
- condition: 要件が明確で実装可能 - condition: 要件が明確で実装可能
next: implement next: architect
- condition: ユーザーが質問をしている(実装タスクではない) - condition: ユーザーが質問をしている(実装タスクではない)
next: COMPLETE next: COMPLETE
- condition: 要件が不明確、情報不足 - condition: 要件が不明確、情報不足
@ -75,13 +75,79 @@ steps:
2. 影響範囲を特定する 2. 影響範囲を特定する
3. 実装アプローチを決める 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 - name: implement
edit: true edit: true
agent: ../agents/default/coder.md agent: ../agents/default/coder.md
session: refresh session: refresh
report: report:
- Scope: 01-coder-scope.md - Scope: 02-coder-scope.md
- Decisions: 02-coder-decisions.md - Decisions: 03-coder-decisions.md
allowed_tools: allowed_tools:
- Read - Read
- Glob - Glob
@ -104,10 +170,17 @@ steps:
requires_user_input: true requires_user_input: true
interactive_only: true interactive_only: true
instruction_template: | instruction_template: |
planステップで立てた計画に従って実装してください。 planステップで立てた計画と、architectステップで決定した設計に従って実装してください。
計画レポート({report:00-plan.md})を参照し、実装を進めてください。
**参照するレポート:**
- 計画: {report:00-plan.md}
- 設計: {report:01-architecture.md}(存在する場合)
Workflow Contextに示されたReport Directory内のファイルのみ参照してください。他のレポートディレクトリは検索/参照しないでください。 Workflow Contextに示されたReport Directory内のファイルのみ参照してください。他のレポートディレクトリは検索/参照しないでください。
**重要:** 設計判断はせず、architectステップで決定された設計に従ってください。
不明点や設計の変更が必要な場合は報告してください。
**重要**: 実装と同時に単体テストを追加してください。 **重要**: 実装と同時に単体テストを追加してください。
- 新規作成したクラス・関数には単体テストを追加 - 新規作成したクラス・関数には単体テストを追加
- 既存コードを変更した場合は該当するテストを更新 - 既存コードを変更した場合は該当するテストを更新
@ -157,7 +230,7 @@ steps:
agent: ../agents/default/ai-antipattern-reviewer.md agent: ../agents/default/ai-antipattern-reviewer.md
pass_previous_response: true pass_previous_response: true
report: report:
name: 03-ai-review.md name: 04-ai-review.md
format: | format: |
```markdown ```markdown
# AI生成コードレビュー # AI生成コードレビュー
@ -273,7 +346,7 @@ steps:
edit: false edit: false
agent: ../agents/default/architecture-reviewer.md agent: ../agents/default/architecture-reviewer.md
report: report:
name: 04-architect-review.md name: 05-architect-review.md
format: | format: |
```markdown ```markdown
# アーキテクチャレビュー # アーキテクチャレビュー
@ -316,23 +389,28 @@ steps:
- condition: approved - condition: approved
- condition: needs_fix - condition: needs_fix
instruction_template: | instruction_template: |
**アーキテクチャと設計**のレビューに集中してください。AI特有の問題はレビューしないでくださいai_reviewステップで行います **実装がarchitectステップの設計に従っているか**を確認してください。
AI特有の問題はレビューしないでくださいai_reviewステップで行います
変更をレビューしてフィードバックを提供してください。 **参照するレポート:**
- 設計: {report:01-architecture.md}(存在する場合)
- 実装スコープ: {report:02-coder-scope.md}
**レビュー観点:** **レビュー観点:**
- 構造・設計の妥当性 - 設計との整合性architectが定めたファイル構成・パターンに従っているか
- コード品質 - コード品質
- 変更スコープの適切性 - 変更スコープの適切性
- テストカバレッジ - テストカバレッジ
- デッドコード - デッドコード
- 呼び出しチェーン検証 - 呼び出しチェーン検証
**注意:** architectステップをスキップした小規模タスクの場合は、従来通り設計の妥当性も確認してください。
- name: security-review - name: security-review
edit: false edit: false
agent: ../agents/default/security-reviewer.md agent: ../agents/default/security-reviewer.md
report: report:
name: 05-security-review.md name: 06-security-review.md
format: | format: |
```markdown ```markdown
# セキュリティレビュー # セキュリティレビュー
@ -422,7 +500,7 @@ steps:
edit: false edit: false
agent: ../agents/default/supervisor.md agent: ../agents/default/supervisor.md
report: report:
- Validation: 06-supervisor-validation.md - Validation: 07-supervisor-validation.md
- Summary: summary.md - Summary: summary.md
allowed_tools: allowed_tools:
- Read - Read
@ -441,7 +519,7 @@ steps:
テスト実行、ビルド確認、最終承認を行ってください。 テスト実行、ビルド確認、最終承認を行ってください。
**ワークフロー全体の確認:** **ワークフロー全体の確認:**
1. 計画({report:00-plan.md})と実装結果が一致している 1. 計画({report:00-plan.md})と設計({report:01-architecture.md}、存在する場合)に従った実装か
2. 各レビューステップの指摘が対応されているか 2. 各レビューステップの指摘が対応されているか
3. 元のタスク目的が達成されているか 3. 元のタスク目的が達成されているか
@ -490,8 +568,9 @@ steps:
## レビュー結果 ## レビュー結果
| レビュー | 結果 | | レビュー | 結果 |
|---------|------| |---------|------|
| Architect | ✅ APPROVE | | Architecture Design | ✅ 完了 |
| AI Review | ✅ APPROVE | | AI Review | ✅ APPROVE |
| Architect Review | ✅ APPROVE |
| Security | ✅ APPROVE | | Security | ✅ APPROVE |
| Supervisor | ✅ APPROVE | | Supervisor | ✅ APPROVE |

View File

@ -18,6 +18,7 @@ vi.mock('../infra/providers/index.js', () => ({
vi.mock('../infra/config/global/globalConfig.js', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })), loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })),
getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true),
})); }));
vi.mock('../shared/prompt/index.js', () => ({ vi.mock('../shared/prompt/index.js', () => ({

View File

@ -45,27 +45,25 @@ describe('applyBookmarks', () => {
{ label: 'delta', value: 'delta' }, { 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']); const result = applyBookmarks(options, ['gamma']);
expect(result[0]!.label).toBe('gamma'); expect(result[2]!.label).toBe('gamma [*]');
expect(result[0]!.value).toBe('gamma'); expect(result[2]!.value).toBe('gamma');
expect(result).toHaveLength(4); 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 result = applyBookmarks(options, ['gamma']);
const rest = result.slice(1); expect(result.map((o) => o.value)).toEqual(['alpha', 'beta', 'gamma', 'delta']);
expect(rest.map((o) => o.value)).toEqual(['alpha', 'beta', 'delta']);
}); });
it('should handle multiple bookmarks preserving their relative order', () => { it('should handle multiple bookmarks preserving original order', () => {
const result = applyBookmarks(options, ['delta', 'alpha']); 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]!.value).toBe('alpha');
expect(result[0]!.label).toBe('alpha'); expect(result[0]!.label).toBe('alpha [*]');
expect(result[1]!.value).toBe('delta'); expect(result[3]!.value).toBe('delta');
expect(result[1]!.label).toBe('delta'); expect(result[3]!.label).toBe('delta [*]');
expect(result.slice(2).map((o) => o.value)).toEqual(['beta', 'gamma']); expect(result.map((o) => o.value)).toEqual(['alpha', 'beta', 'gamma', 'delta']);
}); });
it('should return unchanged options when no bookmarks', () => { it('should return unchanged options when no bookmarks', () => {
@ -92,13 +90,13 @@ describe('applyBookmarks', () => {
]; ];
// Only workflow values should match; categories are not bookmarkable // Only workflow values should match; categories are not bookmarkable
const result = applyBookmarks(categoryOptions, ['simple']); const result = applyBookmarks(categoryOptions, ['simple']);
expect(result[0]!.label).toBe('simple'); expect(result[0]!.label).toBe('simple [*]');
expect(result.slice(1).map((o) => o.value)).toEqual(['__category__:frontend', '__category__:backend']); expect(result.map((o) => o.value)).toEqual(['simple', '__category__:frontend', '__category__:backend']);
}); });
it('should handle all items bookmarked', () => { it('should handle all items bookmarked', () => {
const result = applyBookmarks(options, ['alpha', 'beta', 'gamma', 'delta']); 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']); 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', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({})), loadGlobalConfig: vi.fn(() => ({})),
getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true),
})); }));
import { execFileSync } from 'node:child_process'; 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', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })), loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true),
})); }));
vi.mock('../infra/providers/index.js', () => ({ vi.mock('../infra/providers/index.js', () => ({

View File

@ -42,6 +42,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'), getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]), getDisabledBuiltins: vi.fn().mockReturnValue([]),
getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true),
})); }));
vi.mock('../infra/config/project/projectConfig.js', () => ({ 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', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'), getLanguage: vi.fn().mockReturnValue('en'),
getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true),
})); }));
import { InstructionBuilder } from '../core/workflow/index.js'; import { InstructionBuilder } from '../core/workflow/index.js';

View File

@ -24,6 +24,7 @@ const mockCallAiJudge = vi.fn();
vi.mock('../infra/config/global/globalConfig.js', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'), getLanguage: vi.fn().mockReturnValue('en'),
getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true),
})); }));
vi.mock('../infra/config/project/projectConfig.js', () => ({ vi.mock('../infra/config/project/projectConfig.js', () => ({

View File

@ -47,6 +47,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'), getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]), getDisabledBuiltins: vi.fn().mockReturnValue([]),
getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true),
})); }));
vi.mock('../infra/config/project/projectConfig.js', () => ({ 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', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'), getLanguage: vi.fn().mockReturnValue('en'),
getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true),
})); }));
vi.mock('../infra/config/project/projectConfig.js', () => ({ vi.mock('../infra/config/project/projectConfig.js', () => ({

View File

@ -19,6 +19,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'), getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]), getDisabledBuiltins: vi.fn().mockReturnValue([]),
getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true),
})); }));
// --- Imports (after mocks) --- // --- Imports (after mocks) ---
@ -189,13 +190,13 @@ describe('Workflow Loader IT: rule syntax parsing', () => {
const config = loadWorkflow('minimal', testDir); const config = loadWorkflow('minimal', testDir);
expect(config).not.toBeNull(); expect(config).not.toBeNull();
const planStep = config!.steps.find((s) => s.name === 'plan'); const implementStep = config!.steps.find((s) => s.name === 'implement');
expect(planStep).toBeDefined(); expect(implementStep).toBeDefined();
expect(planStep!.rules).toBeDefined(); expect(implementStep!.rules).toBeDefined();
expect(planStep!.rules!.length).toBeGreaterThan(0); expect(implementStep!.rules!.length).toBeGreaterThan(0);
// Each rule should have condition and next // 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(typeof rule.condition).toBe('string');
expect(rule.condition.length).toBeGreaterThan(0); expect(rule.condition.length).toBeGreaterThan(0);
} }
@ -320,10 +321,10 @@ describe('Workflow Loader IT: report config loading', () => {
}); });
it('should load single report config', () => { it('should load single report config', () => {
const config = loadWorkflow('minimal', testDir); const config = loadWorkflow('default', testDir);
expect(config).not.toBeNull(); 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'); const planStep = config!.steps.find((s) => s.name === 'plan');
expect(planStep).toBeDefined(); expect(planStep).toBeDefined();
expect(planStep!.report).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')>(); const original = await importOriginal<typeof import('../infra/claude/client.js')>();
return { return {
...original, ...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({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'), getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]), getDisabledBuiltins: vi.fn().mockReturnValue([]),
getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true),
})); }));
vi.mock('../infra/config/project/projectConfig.js', () => ({ vi.mock('../infra/config/project/projectConfig.js', () => ({
@ -88,9 +97,9 @@ describe('Workflow Patterns IT: minimal workflow', () => {
expect(config).not.toBeNull(); expect(config).not.toBeNull();
setMockScenario([ setMockScenario([
{ agent: 'coder', status: 'done', content: '[IMPLEMENT:0]\n\nImplementation complete.' }, { agent: 'coder', status: 'done', content: 'Implementation complete.' },
{ agent: 'ai-antipattern-reviewer', status: 'done', content: '[AI_REVIEW:0]\n\nNo AI-specific issues.' }, { agent: 'ai-antipattern-reviewer', status: 'done', content: 'No AI-specific issues.' },
{ agent: 'supervisor', status: 'done', content: '[SUPERVISE:0]\n\nAll checks passed.' }, { agent: 'supervisor', status: 'done', content: 'All checks passed.' },
]); ]);
const engine = createEngine(config!, testDir, 'Test task'); const engine = createEngine(config!, testDir, 'Test task');
@ -104,7 +113,7 @@ describe('Workflow Patterns IT: minimal workflow', () => {
const config = loadWorkflow('minimal', testDir); const config = loadWorkflow('minimal', testDir);
setMockScenario([ 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'); 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', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(), loadGlobalConfig: vi.fn(),
getBuiltinWorkflowsEnabled: vi.fn().mockReturnValue(true),
})); }));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ 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).toBeDefined();
expect(simpleEntry!.category).toBeUndefined(); expect(simpleEntry!.category).toBeUndefined();
expect(simpleEntry!.source).toBe('project');
expect(reactEntry).toBeDefined(); expect(reactEntry).toBeDefined();
expect(reactEntry!.category).toBe('frontend'); expect(reactEntry!.category).toBe('frontend');
expect(reactEntry!.source).toBe('project');
}); });
}); });
@ -199,10 +201,10 @@ describe('workflow categories - loadWorkflow', () => {
describe('buildWorkflowSelectionItems', () => { describe('buildWorkflowSelectionItems', () => {
it('should separate root workflows and categories', () => { it('should separate root workflows and categories', () => {
const entries: WorkflowDirEntry[] = [ const entries: WorkflowDirEntry[] = [
{ name: 'simple', path: '/tmp/simple.yaml' }, { name: 'simple', path: '/tmp/simple.yaml', source: 'project' },
{ name: 'frontend/react', path: '/tmp/frontend/react.yaml', category: 'frontend' }, { name: 'frontend/react', path: '/tmp/frontend/react.yaml', category: 'frontend', source: 'project' },
{ name: 'frontend/vue', path: '/tmp/frontend/vue.yaml', category: 'frontend' }, { name: 'frontend/vue', path: '/tmp/frontend/vue.yaml', category: 'frontend', source: 'project' },
{ name: 'backend/api', path: '/tmp/backend/api.yaml', category: 'backend' }, { name: 'backend/api', path: '/tmp/backend/api.yaml', category: 'backend', source: 'project' },
]; ];
const items = buildWorkflowSelectionItems(entries); const items = buildWorkflowSelectionItems(entries);
@ -225,9 +227,9 @@ describe('buildWorkflowSelectionItems', () => {
it('should sort items alphabetically', () => { it('should sort items alphabetically', () => {
const entries: WorkflowDirEntry[] = [ const entries: WorkflowDirEntry[] = [
{ name: 'zebra', path: '/tmp/zebra.yaml' }, { name: 'zebra', path: '/tmp/zebra.yaml', source: 'project' },
{ name: 'alpha', path: '/tmp/alpha.yaml' }, { name: 'alpha', path: '/tmp/alpha.yaml', source: 'project' },
{ name: 'misc/playground', path: '/tmp/misc/playground.yaml', category: 'misc' }, { name: 'misc/playground', path: '/tmp/misc/playground.yaml', category: 'misc', source: 'project' },
]; ];
const items = buildWorkflowSelectionItems(entries); const items = buildWorkflowSelectionItems(entries);

View File

@ -7,7 +7,7 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto'; 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(() => ({ const pathsState = vi.hoisted(() => ({
globalConfigPath: '', 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) => { vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>; const original = await importOriginal() as Record<string, unknown>;
return { 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 { const {
getWorkflowCategories, getWorkflowCategories,
loadDefaultCategories, loadDefaultCategories,
@ -51,14 +67,18 @@ function writeYaml(path: string, content: string): void {
writeFileSync(path, content.trim() + '\n', 'utf-8'); writeFileSync(path, content.trim() + '\n', 'utf-8');
} }
function createWorkflowMap(names: string[]): Map<string, WorkflowConfig> { function createWorkflowMap(entries: { name: string; source: 'builtin' | 'user' | 'project' }[]):
const workflows = new Map<string, WorkflowConfig>(); Map<string, WorkflowWithSource> {
for (const name of names) { const workflows = new Map<string, WorkflowWithSource>();
workflows.set(name, { for (const entry of entries) {
name, workflows.set(entry.name, {
steps: [], source: entry.source,
initialStep: 'start', config: {
maxIterations: 1, name: entry.name,
steps: [],
initialStep: 'start',
maxIterations: 1,
},
}); });
} }
return workflows; return workflows;
@ -81,6 +101,10 @@ describe('workflow category config loading', () => {
pathsState.projectConfigPath = projectConfigPath; pathsState.projectConfigPath = projectConfigPath;
pathsState.resourcesDir = resourcesDir; pathsState.resourcesDir = resourcesDir;
// Reset workflow categories state
workflowCategoriesState.categories = undefined;
workflowCategoriesState.showOthersCategory = undefined;
workflowCategoriesState.othersCategoryName = undefined;
}); });
afterEach(() => { afterEach(() => {
@ -91,33 +115,40 @@ describe('workflow category config loading', () => {
writeYaml(join(resourcesDir, 'default-categories.yaml'), ` writeYaml(join(resourcesDir, 'default-categories.yaml'), `
workflow_categories: workflow_categories:
Default: Default:
- simple workflows:
- simple
show_others_category: true show_others_category: true
others_category_name: "Others" others_category_name: "Others"
`); `);
const config = getWorkflowCategories(testDir); const config = getWorkflowCategories(testDir);
expect(config).not.toBeNull(); 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', () => { it('should prefer project config over default when workflow_categories is defined', () => {
writeYaml(join(resourcesDir, 'default-categories.yaml'), ` writeYaml(join(resourcesDir, 'default-categories.yaml'), `
workflow_categories: workflow_categories:
Default: Default:
- simple workflows:
- simple
`); `);
writeYaml(projectConfigPath, ` writeYaml(projectConfigPath, `
workflow_categories: workflow_categories:
Project: Project:
- custom workflows:
- custom
show_others_category: false show_others_category: false
`); `);
const config = getWorkflowCategories(testDir); const config = getWorkflowCategories(testDir);
expect(config).not.toBeNull(); expect(config).not.toBeNull();
expect(config!.workflowCategories).toEqual({ Project: ['custom'] }); expect(config!.workflowCategories).toEqual([
{ name: 'Project', workflows: ['custom'], children: [] },
]);
expect(config!.showOthersCategory).toBe(false); expect(config!.showOthersCategory).toBe(false);
}); });
@ -125,31 +156,37 @@ show_others_category: false
writeYaml(join(resourcesDir, 'default-categories.yaml'), ` writeYaml(join(resourcesDir, 'default-categories.yaml'), `
workflow_categories: workflow_categories:
Default: Default:
- simple workflows:
- simple
`); `);
writeYaml(projectConfigPath, ` writeYaml(projectConfigPath, `
workflow_categories: workflow_categories:
Project: Project:
- custom workflows:
- custom
`); `);
writeYaml(globalConfigPath, ` // Simulate user config from separate file
workflow_categories: workflowCategoriesState.categories = {
User: User: {
- preferred workflows: ['preferred'],
`); },
};
const config = getWorkflowCategories(testDir); const config = getWorkflowCategories(testDir);
expect(config).not.toBeNull(); 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', () => { it('should ignore configs without workflow_categories and fall back to default', () => {
writeYaml(join(resourcesDir, 'default-categories.yaml'), ` writeYaml(join(resourcesDir, 'default-categories.yaml'), `
workflow_categories: workflow_categories:
Default: Default:
- simple workflows:
- simple
`); `);
writeYaml(globalConfigPath, ` writeYaml(globalConfigPath, `
@ -158,7 +195,9 @@ show_others_category: false
const config = getWorkflowCategories(testDir); const config = getWorkflowCategories(testDir);
expect(config).not.toBeNull(); 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', () => { it('should return null when default categories file is missing', () => {
@ -168,47 +207,76 @@ show_others_category: false
}); });
describe('buildCategorizedWorkflows', () => { describe('buildCategorizedWorkflows', () => {
beforeEach(() => {
});
it('should warn for missing workflows and generate Others', () => { 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 = { const config = {
workflowCategories: { Cat: ['a', 'missing'] }, workflowCategories: [
{
name: 'Cat',
workflows: ['a', 'missing', 'c'],
children: [],
},
],
showOthersCategory: true, showOthersCategory: true,
othersCategoryName: 'Others', othersCategoryName: 'Others',
}; };
const categorized = buildCategorizedWorkflows(allWorkflows, config); const categorized = buildCategorizedWorkflows(allWorkflows, config);
expect(categorized.categories.get('Cat')).toEqual(['a']); expect(categorized.categories).toEqual([
expect(categorized.categories.get('Others')).toEqual(['b', 'c']); { name: 'Cat', workflows: ['a'], children: [] },
{ name: 'Others', workflows: ['b'], children: [] },
]);
expect(categorized.builtinCategories).toEqual([
{ name: 'Cat', workflows: ['c'], children: [] },
]);
expect(categorized.missingWorkflows).toEqual([ expect(categorized.missingWorkflows).toEqual([
{ categoryName: 'Cat', workflowName: 'missing' }, { categoryPath: ['Cat'], workflowName: 'missing' },
]); ]);
}); });
it('should skip empty categories', () => { it('should skip empty categories', () => {
const allWorkflows = createWorkflowMap(['a']); const allWorkflows = createWorkflowMap([
{ name: 'a', source: 'user' },
]);
const config = { const config = {
workflowCategories: { Empty: [] }, workflowCategories: [
{ name: 'Empty', workflows: [], children: [] },
],
showOthersCategory: false, showOthersCategory: false,
othersCategoryName: 'Others', othersCategoryName: 'Others',
}; };
const categorized = buildCategorizedWorkflows(allWorkflows, config); 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', () => { it('should find categories containing a workflow', () => {
const allWorkflows = createWorkflowMap(['shared']); const categories = [
const config = { { name: 'A', workflows: ['shared'], children: [] },
workflowCategories: { A: ['shared'], B: ['shared'] }, { name: 'B', workflows: ['shared'], children: [] },
showOthersCategory: false, ];
othersCategoryName: 'Others',
};
const categorized = buildCategorizedWorkflows(allWorkflows, config); const paths = findWorkflowCategories('shared', categories).sort();
const categories = findWorkflowCategories('shared', categorized).sort(); expect(paths).toEqual(['A', 'B']);
expect(categories).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 { info, error } from '../../shared/ui/index.js';
import { getErrorMessage } from '../../shared/utils/index.js'; import { getErrorMessage } from '../../shared/utils/index.js';
import { resolveIssueTask, isIssueReference } from '../../infra/github/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 { 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 { DEFAULT_WORKFLOW_NAME } from '../../shared/constants.js';
import { program, resolvedCwd, pipelineMode } from './program.js'; import { program, resolvedCwd, pipelineMode } from './program.js';
import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.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) // 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) { if (!result.confirmed) {
return; return;
} }
selectOptions.interactiveUserInput = true; selectOptions.interactiveUserInput = true;
selectOptions.workflow = workflowId;
await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides); await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides);
}); });

View File

@ -2,6 +2,8 @@
* Configuration types (global and project) * Configuration types (global and project)
*/ */
import type { WorkflowCategoryConfigNode } from './schemas.js';
/** Custom agent configuration */ /** Custom agent configuration */
export interface CustomAgentConfig { export interface CustomAgentConfig {
name: string; name: string;
@ -46,6 +48,8 @@ export interface GlobalConfig {
worktreeDir?: string; worktreeDir?: string;
/** List of builtin workflow/agent names to exclude from fallback loading */ /** List of builtin workflow/agent names to exclude from fallback loading */
disabledBuiltins?: string[]; 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) */ /** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */
anthropicApiKey?: string; anthropicApiKey?: string;
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
@ -54,14 +58,10 @@ export interface GlobalConfig {
pipeline?: PipelineConfig; pipeline?: PipelineConfig;
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */ /** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
minimalOutput?: boolean; minimalOutput?: boolean;
/** Bookmarked workflow names for quick access in selection UI */ /** Path to bookmarks file (default: ~/.takt/preferences/bookmarks.yaml) */
bookmarkedWorkflows?: string[]; bookmarksFile?: string;
/** Workflow category configuration (name -> workflow list) */ /** Path to workflow categories file (default: ~/.takt/preferences/workflow-categories.yaml) */
workflowCategories?: Record<string, string[]>; workflowCategoriesFile?: string;
/** Show uncategorized workflows under Others category */
showOthersCategory?: boolean;
/** Display name for Others category */
othersCategoryName?: string;
} }
/** Project-level configuration */ /** Project-level configuration */

View File

@ -198,6 +198,20 @@ export const PipelineConfigSchema = z.object({
pr_body_template: z.string().optional(), 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 */ /** Global config schema */
export const GlobalConfigSchema = z.object({ export const GlobalConfigSchema = z.object({
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
@ -211,6 +225,8 @@ export const GlobalConfigSchema = z.object({
worktree_dir: z.string().optional(), worktree_dir: z.string().optional(),
/** List of builtin workflow/agent names to exclude from fallback loading */ /** List of builtin workflow/agent names to exclude from fallback loading */
disabled_builtins: z.array(z.string()).optional().default([]), 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 for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */
anthropic_api_key: z.string().optional(), anthropic_api_key: z.string().optional(),
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ /** 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(), pipeline: PipelineConfigSchema.optional(),
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */ /** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
minimal_output: z.boolean().optional().default(false), minimal_output: z.boolean().optional().default(false),
/** Bookmarked workflow names for quick access in selection UI */ /** Path to bookmarks file (default: ~/.takt/preferences/bookmarks.yaml) */
bookmarked_workflows: z.array(z.string()).optional().default([]), bookmarks_file: z.string().optional(),
/** Workflow categories (name -> workflow list) */ /** Path to workflow categories file (default: ~/.takt/preferences/workflow-categories.yaml) */
workflow_categories: z.record(z.string(), z.array(z.string())).optional(), workflow_categories_file: 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(),
}); });
/** Project config schema */ /** Project config schema */

View File

@ -4,99 +4,20 @@
import { import {
listWorkflowEntries, listWorkflowEntries,
loadAllWorkflows, loadAllWorkflowsWithSources,
getWorkflowCategories, getWorkflowCategories,
buildCategorizedWorkflows, buildCategorizedWorkflows,
loadWorkflow, loadWorkflow,
getCurrentWorkflow, getCurrentWorkflow,
setCurrentWorkflow, setCurrentWorkflow,
} from '../../infra/config/index.js'; } from '../../infra/config/index.js';
import {
getBookmarkedWorkflows,
toggleBookmark,
} from '../../infra/config/global/index.js';
import { info, success, error } from '../../shared/ui/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 { import {
buildWorkflowSelectionItems,
buildTopLevelSelectOptions,
parseCategorySelection,
buildCategoryWorkflowOptions,
applyBookmarks,
warnMissingWorkflows, warnMissingWorkflows,
selectWorkflowFromCategorizedWorkflows, selectWorkflowFromCategorizedWorkflows,
type SelectionOption, selectWorkflowFromEntries,
} from '../workflowSelection/index.js'; } 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 * Switch to a different workflow
* @returns true if switch was successful * @returns true if switch was successful
@ -110,7 +31,7 @@ export async function switchWorkflow(cwd: string, workflowName?: string): Promis
const categoryConfig = getWorkflowCategories(cwd); const categoryConfig = getWorkflowCategories(cwd);
let selected: string | null; let selected: string | null;
if (categoryConfig) { if (categoryConfig) {
const allWorkflows = loadAllWorkflows(cwd); const allWorkflows = loadAllWorkflowsWithSources(cwd);
if (allWorkflows.size === 0) { if (allWorkflows.size === 0) {
info('No workflows found.'); info('No workflows found.');
selected = null; selected = null;
@ -120,7 +41,8 @@ export async function switchWorkflow(cwd: string, workflowName?: string): Promis
selected = await selectWorkflowFromCategorizedWorkflows(categorized, current); selected = await selectWorkflowFromCategorizedWorkflows(categorized, current);
} }
} else { } else {
selected = await selectWorkflowWithCategories(cwd); const entries = listWorkflowEntries(cwd);
selected = await selectWorkflowFromEntries(entries, current);
} }
if (!selected) { if (!selected) {

View File

@ -2,4 +2,4 @@
* Interactive mode commands. * 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 ## Your role
- Ask clarifying questions about ambiguous requirements - Ask clarifying questions about ambiguous requirements
- Investigate the codebase to understand context (use Read, Glob, Grep, Bash for reading only) - Clarify and refine the user's request into a clear task instruction
- Suggest improvements or considerations the user might have missed - Create concrete instructions for workflow agents to follow
- Summarize your understanding when appropriate - Summarize your understanding when appropriate
- Keep responses concise and focused - 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 ## 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 create, edit, or delete any files.
- Do NOT run build, test, install, or any commands that modify state. - Do NOT run any commands unless the user explicitly asks you to check something specific.
- Bash is allowed ONLY for read-only investigation (e.g. ls, cat, git log, git diff). Never run destructive or write commands. - 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. - 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. - Preserve constraints and "do not" instructions.
- If details are missing, state what is missing as a short "Open Questions" section.`; - 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`; - Open Questions`;
@ -115,18 +132,31 @@ function readPromptFile(lang: 'en' | 'ja', fileName: string, fallback: string):
return fallback.trim(); return fallback.trim();
} }
function getInteractivePrompts(lang: 'en' | 'ja') { function getInteractivePrompts(lang: 'en' | 'ja', workflowContext?: WorkflowContext) {
let systemPrompt = readPromptFile(
lang,
'interactive-system.md',
lang === 'ja' ? INTERACTIVE_SYSTEM_PROMPT_JA : INTERACTIVE_SYSTEM_PROMPT_EN,
);
let summaryPrompt = readPromptFile(
lang,
'interactive-summary.md',
lang === 'ja' ? INTERACTIVE_SUMMARY_PROMPT_JA : INTERACTIVE_SUMMARY_PROMPT_EN,
);
// Add workflow context to prompts if available
if (workflowContext) {
const workflowInfo = lang === 'ja'
? `\n\n## あなたが作成する指示書の行き先\nこのタスク指示書は「${workflowContext.name}」ワークフローに渡されます。\nワークフローの内容: ${workflowContext.description}\n\n指示書は、このワークフローが期待する形式で作成してください。`
: `\n\n## Destination of Your Task Instruction\nThis task instruction will be passed to the "${workflowContext.name}" workflow.\nWorkflow description: ${workflowContext.description}\n\nCreate the instruction in the format expected by this workflow.`;
systemPrompt += workflowInfo;
summaryPrompt += workflowInfo;
}
return { return {
systemPrompt: readPromptFile( systemPrompt,
lang, summaryPrompt,
'interactive-system.md',
lang === 'ja' ? INTERACTIVE_SYSTEM_PROMPT_JA : INTERACTIVE_SYSTEM_PROMPT_EN,
),
summaryPrompt: readPromptFile(
lang,
'interactive-summary.md',
lang === 'ja' ? INTERACTIVE_SUMMARY_PROMPT_JA : INTERACTIVE_SUMMARY_PROMPT_EN,
),
conversationLabel: lang === 'ja' ? '会話:' : 'Conversation:', conversationLabel: lang === 'ja' ? '会話:' : 'Conversation:',
noTranscript: lang === 'ja' noTranscript: lang === 'ja'
? '(ローカル履歴なし。現在のセッション文脈を要約してください。)' ? '(ローカル履歴なし。現在のセッション文脈を要約してください。)'
@ -251,6 +281,13 @@ export interface InteractiveModeResult {
task: string; task: string;
} }
export interface WorkflowContext {
/** Workflow name (e.g. "minimal") */
name: string;
/** Workflow description */
description: string;
}
/** /**
* Run the interactive task input mode. * Run the interactive task input mode.
* *
@ -260,10 +297,14 @@ export interface InteractiveModeResult {
* /cancel exits without executing * /cancel exits without executing
* Ctrl+D 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 globalConfig = loadGlobalConfig();
const lang = resolveLanguage(globalConfig.language); const lang = resolveLanguage(globalConfig.language);
const prompts = getInteractivePrompts(lang); const prompts = getInteractivePrompts(lang, workflowContext);
if (!globalConfig.provider) { if (!globalConfig.provider) {
throw new Error('Provider is not configured.'); throw new Error('Provider is not configured.');
} }

View File

@ -11,16 +11,11 @@ import {
listWorkflows, listWorkflows,
listWorkflowEntries, listWorkflowEntries,
isWorkflowPath, isWorkflowPath,
loadAllWorkflows, loadAllWorkflowsWithSources,
getWorkflowCategories, getWorkflowCategories,
buildCategorizedWorkflows, buildCategorizedWorkflows,
} from '../../../infra/config/index.js'; } from '../../../infra/config/index.js';
import { import { confirm } from '../../../shared/prompt/index.js';
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 { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../../../infra/task/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../../../infra/task/index.js';
import { DEFAULT_WORKFLOW_NAME } from '../../../shared/constants.js'; import { DEFAULT_WORKFLOW_NAME } from '../../../shared/constants.js';
import { info, error, success } from '../../../shared/ui/index.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 { executeTask } from './taskExecution.js';
import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js'; import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js';
import { import {
buildWorkflowSelectionItems,
buildTopLevelSelectOptions,
parseCategorySelection,
buildCategoryWorkflowOptions,
applyBookmarks,
warnMissingWorkflows, warnMissingWorkflows,
selectWorkflowFromCategorizedWorkflows, selectWorkflowFromCategorizedWorkflows,
type SelectionOption, selectWorkflowFromEntries,
} from '../../workflowSelection/index.js'; } from '../../workflowSelection/index.js';
export type { WorktreeConfirmationResult, SelectAndExecuteOptions }; export type { WorktreeConfirmationResult, SelectAndExecuteOptions };
@ -60,70 +50,7 @@ async function selectWorkflowWithDirectoryCategories(cwd: string): Promise<strin
} }
const entries = listWorkflowEntries(cwd); const entries = listWorkflowEntries(cwd);
const items = buildWorkflowSelectionItems(entries); return selectWorkflowFromEntries(entries, currentWorkflow);
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;
}
} }
@ -134,7 +61,7 @@ async function selectWorkflow(cwd: string): Promise<string | null> {
const categoryConfig = getWorkflowCategories(cwd); const categoryConfig = getWorkflowCategories(cwd);
if (categoryConfig) { if (categoryConfig) {
const current = getCurrentWorkflow(cwd); const current = getCurrentWorkflow(cwd);
const allWorkflows = loadAllWorkflows(cwd); const allWorkflows = loadAllWorkflowsWithSources(cwd);
if (allWorkflows.size === 0) { if (allWorkflows.size === 0) {
info(`No workflows found. Using default: ${DEFAULT_WORKFLOW_NAME}`); info(`No workflows found. Using default: ${DEFAULT_WORKFLOW_NAME}`);
return 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 override is a name, validate it exists in available workflows.
* - If no override, prompt user to select interactively. * - 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 (override) {
if (isWorkflowPath(override)) { if (isWorkflowPath(override)) {
return override; return override;

View File

@ -10,6 +10,7 @@ export type { PipelineExecutionOptions } from './execute/types.js';
export { export {
selectAndExecuteTask, selectAndExecuteTask,
confirmAndCreateWorktree, confirmAndCreateWorktree,
determineWorkflow,
type SelectAndExecuteOptions, type SelectAndExecuteOptions,
type WorktreeConfirmationResult, type WorktreeConfirmationResult,
} from './execute/selectAndExecute.js'; } 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 { info, warn } from '../../shared/ui/index.js';
import { import {
getBookmarkedWorkflows, getBookmarkedWorkflows,
toggleBookmark, addBookmark,
removeBookmark,
} from '../../infra/config/global/index.js'; } from '../../infra/config/global/index.js';
import { import {
findWorkflowCategories, findWorkflowCategories,
type WorkflowDirEntry, type WorkflowDirEntry,
type WorkflowCategoryNode,
type CategorizedWorkflows, type CategorizedWorkflows,
type MissingWorkflow, type MissingWorkflow,
type WorkflowSource,
type WorkflowWithSource,
} from '../../infra/config/index.js'; } from '../../infra/config/index.js';
/** Top-level selection item: either a workflow or a category containing workflows */ /** 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. * Pure function does not mutate inputs.
*/ */
export function applyBookmarks( export function applyBookmarks(
@ -120,116 +124,568 @@ export function applyBookmarks(
bookmarkedWorkflows: string[], bookmarkedWorkflows: string[],
): SelectionOption[] { ): SelectionOption[] {
const bookmarkedSet = new Set(bookmarkedWorkflows); 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)) { if (bookmarkedSet.has(opt.value)) {
bookmarked.push({ ...opt, label: `${BOOKMARK_MARK}${opt.label}` }); return { ...opt, label: `${opt.label}${BOOKMARK_MARK}` };
} else {
rest.push(opt);
} }
} return opt;
});
return [...bookmarked, ...rest];
} }
/** /**
* Warn about missing workflows referenced by categories. * Warn about missing workflows referenced by categories.
*/ */
export function warnMissingWorkflows(missing: MissingWorkflow[]): void { export function warnMissingWorkflows(missing: MissingWorkflow[]): void {
for (const { categoryName, workflowName } of missing) { for (const { categoryPath, workflowName } of missing) {
warn(`Workflow "${workflowName}" in category "${categoryName}" not found`); const pathLabel = categoryPath.join(' / ');
warn(`Workflow "${workflowName}" in category "${pathLabel}" not found`);
} }
} }
function buildCategorySelectOptions( function countWorkflowsInTree(categories: WorkflowCategoryNode[]): number {
categorized: CategorizedWorkflows, let count = 0;
currentWorkflow: string, const visit = (nodes: WorkflowCategoryNode[]): void => {
): SelectOptionItem<string>[] { for (const node of nodes) {
const entries = Array.from(categorized.categories.entries()) count += node.workflows.length;
.map(([categoryName, workflows]) => ({ categoryName, workflows })); if (node.children.length > 0) {
visit(node.children);
return entries.map(({ categoryName, workflows }) => { }
const containsCurrent = workflows.includes(currentWorkflow); }
const label = containsCurrent };
? `${categoryName} (${workflows.length} workflows, current)` visit(categories);
: `${categoryName} (${workflows.length} workflows)`; return count;
return { label, value: categoryName };
});
} }
function buildWorkflowOptionsForCategory( function categoryContainsWorkflow(node: WorkflowCategoryNode, workflow: string): boolean {
categorized: CategorizedWorkflows, if (node.workflows.includes(workflow)) return true;
categoryName: string, for (const child of node.children) {
currentWorkflow: string, if (categoryContainsWorkflow(child, workflow)) return true;
): SelectOptionItem<string>[] | null { }
const workflows = categorized.categories.get(categoryName); return false;
if (!workflows) return null; }
return workflows.map((workflowName) => { function buildCategoryLevelOptions(
const alsoIn = findWorkflowCategories(workflowName, categorized) categories: WorkflowCategoryNode[],
.filter((name) => name !== categoryName); 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 isCurrent = workflowName === currentWorkflow;
const alsoIn = findWorkflowCategories(workflowName, rootCategories)
.filter((path) => path !== currentPathLabel);
const alsoInLabel = alsoIn.length > 0 ? `also in ${alsoIn.join(', ')}` : ''; const alsoInLabel = alsoIn.length > 0 ? `also in ${alsoIn.join(', ')}` : '';
let label = workflowName; let label = `🎼 ${workflowName}`;
if (isCurrent && alsoInLabel) { if (isCurrent && alsoInLabel) {
label = `${workflowName} (current, ${alsoInLabel})`; label = `🎼 ${workflowName} (current, ${alsoInLabel})`;
} else if (isCurrent) { } else if (isCurrent) {
label = `${workflowName} (current)`; label = `🎼 ${workflowName} (current)`;
} else if (alsoInLabel) { } 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( export async function selectWorkflowFromCategorizedWorkflows(
categorized: CategorizedWorkflows, categorized: CategorizedWorkflows,
currentWorkflow: string, currentWorkflow: string,
): Promise<string | null> { ): Promise<string | null> {
const categoryOptions = buildCategorySelectOptions(categorized, currentWorkflow);
if (categoryOptions.length === 0) {
info('No workflows available for configured categories.');
return null;
}
// Loop until user selects a workflow or cancels at category level
while (true) { while (true) {
const selectedCategory = await selectOption<string>('Select workflow category:', categoryOptions); const selection = await selectTopLevelWorkflowOption(categorized, currentWorkflow);
if (!selectedCategory) return null; if (!selection) {
return null;
}
const buildWorkflowOptions = (): SelectOptionItem<string>[] | null => // 1. Current workflow selected
buildWorkflowOptionsForCategory(categorized, selectedCategory, currentWorkflow); if (selection.type === 'current') {
return currentWorkflow;
}
const baseWorkflowOptions = buildWorkflowOptions(); // 2. Direct workflow selected (e.g., bookmarked workflow)
if (!baseWorkflowOptions) continue; if (selection.type === 'workflow') {
return selection.name;
}
const applyWorkflowBookmarks = (options: SelectOptionItem<string>[]): SelectOptionItem<string>[] => { // 3. User-defined category selected
return applyBookmarks(options, getBookmarkedWorkflows()) as SelectOptionItem<string>[]; if (selection.type === 'custom_category') {
}; const workflow = await selectWorkflowFromCategoryTree(
[selection.node],
currentWorkflow,
true,
selection.node.workflows
);
if (workflow) {
return workflow;
}
// null → go back to top-level selection
continue;
}
const selectedWorkflow = await selectOption<string>( // 4. Builtin workflows selected
`Select workflow in ${selectedCategory}:`, if (selection.type === 'builtin') {
applyWorkflowBookmarks(baseWorkflowOptions), const rootWorkflows = getRootLevelWorkflows(
{ categorized.builtinCategories,
categorized.allWorkflows,
'builtin'
);
const workflow = await selectWorkflowFromCategoryTree(
categorized.builtinCategories,
currentWorkflow,
true,
rootWorkflows
);
if (workflow) {
return workflow;
}
// null → go back to top-level selection
continue;
}
// 5. Custom uncategorized workflows selected
if (selection.type === 'custom_uncategorized') {
const uncategorizedCustom = getRootLevelWorkflows(
categorized.categories,
categorized.allWorkflows,
'user'
);
const baseOptions: SelectionOption[] = uncategorizedCustom.map((name) => ({
label: name === currentWorkflow ? `🎼 ${name} (current)` : `🎼 ${name}`,
value: name,
}));
const buildFlatOptions = (): SelectionOption[] =>
applyBookmarks(baseOptions, getBookmarkedWorkflows());
const workflow = await selectOption<string>('Select workflow:', buildFlatOptions(), {
cancelLabel: '← Go back', cancelLabel: '← Go back',
onBookmark: (value: string): SelectOptionItem<string>[] => { onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
toggleBookmark(value); if (key === 'b') {
const updatedOptions = buildWorkflowOptions(); addBookmark(value);
if (!updatedOptions) return []; return buildFlatOptions();
return applyWorkflowBookmarks(updatedOptions); }
if (key === 'r') {
removeBookmark(value);
return buildFlatOptions();
}
return null; // Delegate to default handler
}, },
}, });
);
// If workflow selected, return it. If cancelled (null), go back to category selection if (workflow) {
if (selectedWorkflow) return selectedWorkflow; 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', defaultWorkflow: 'default',
logLevel: 'info', logLevel: 'info',
provider: 'claude', provider: 'claude',
enableBuiltinWorkflows: true,
}; };
} }
@ -78,6 +79,7 @@ export class GlobalConfigManager {
} : undefined, } : undefined,
worktreeDir: parsed.worktree_dir, worktreeDir: parsed.worktree_dir,
disabledBuiltins: parsed.disabled_builtins, disabledBuiltins: parsed.disabled_builtins,
enableBuiltinWorkflows: parsed.enable_builtin_workflows,
anthropicApiKey: parsed.anthropic_api_key, anthropicApiKey: parsed.anthropic_api_key,
openaiApiKey: parsed.openai_api_key, openaiApiKey: parsed.openai_api_key,
pipeline: parsed.pipeline ? { pipeline: parsed.pipeline ? {
@ -86,10 +88,8 @@ export class GlobalConfigManager {
prBodyTemplate: parsed.pipeline.pr_body_template, prBodyTemplate: parsed.pipeline.pr_body_template,
} : undefined, } : undefined,
minimalOutput: parsed.minimal_output, minimalOutput: parsed.minimal_output,
bookmarkedWorkflows: parsed.bookmarked_workflows, bookmarksFile: parsed.bookmarks_file,
workflowCategories: parsed.workflow_categories, workflowCategoriesFile: parsed.workflow_categories_file,
showOthersCategory: parsed.show_others_category,
othersCategoryName: parsed.others_category_name,
}; };
this.cachedConfig = config; this.cachedConfig = config;
return config; return config;
@ -120,6 +120,9 @@ export class GlobalConfigManager {
if (config.disabledBuiltins && config.disabledBuiltins.length > 0) { if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
raw.disabled_builtins = config.disabledBuiltins; raw.disabled_builtins = config.disabledBuiltins;
} }
if (config.enableBuiltinWorkflows !== undefined) {
raw.enable_builtin_workflows = config.enableBuiltinWorkflows;
}
if (config.anthropicApiKey) { if (config.anthropicApiKey) {
raw.anthropic_api_key = config.anthropicApiKey; raw.anthropic_api_key = config.anthropicApiKey;
} }
@ -138,17 +141,11 @@ export class GlobalConfigManager {
if (config.minimalOutput !== undefined) { if (config.minimalOutput !== undefined) {
raw.minimal_output = config.minimalOutput; raw.minimal_output = config.minimalOutput;
} }
if (config.bookmarkedWorkflows && config.bookmarkedWorkflows.length > 0) { if (config.bookmarksFile) {
raw.bookmarked_workflows = config.bookmarkedWorkflows; raw.bookmarks_file = config.bookmarksFile;
} }
if (config.workflowCategories !== undefined) { if (config.workflowCategoriesFile) {
raw.workflow_categories = config.workflowCategories; raw.workflow_categories_file = config.workflowCategoriesFile;
}
if (config.showOthersCategory !== undefined) {
raw.show_others_category = config.showOthersCategory;
}
if (config.othersCategoryName !== undefined) {
raw.others_category_name = config.othersCategoryName;
} }
writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
this.invalidateCache(); 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 { export function getLanguage(): Language {
try { try {
const config = loadGlobalConfig(); const config = loadGlobalConfig();
@ -287,25 +293,3 @@ export function getEffectiveDebugConfig(projectDir?: string): DebugConfig | unde
return debugConfig; 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, loadGlobalConfig,
saveGlobalConfig, saveGlobalConfig,
getDisabledBuiltins, getDisabledBuiltins,
getBuiltinWorkflowsEnabled,
getLanguage, getLanguage,
setLanguage, setLanguage,
setProvider, setProvider,
@ -17,10 +18,24 @@ export {
resolveOpenaiApiKey, resolveOpenaiApiKey,
loadProjectDebugConfig, loadProjectDebugConfig,
getEffectiveDebugConfig, getEffectiveDebugConfig,
getBookmarkedWorkflows,
toggleBookmark,
} from './globalConfig.js'; } from './globalConfig.js';
export {
getBookmarkedWorkflows,
addBookmark,
removeBookmark,
isBookmarked,
} from './bookmarks.js';
export {
getWorkflowCategoriesConfig,
setWorkflowCategoriesConfig,
getShowOthersCategory,
setShowOthersCategory,
getOthersCategoryName,
setOthersCategoryName,
} from './workflowCategories.js';
export { export {
needsLanguageSetup, needsLanguageSetup,
promptLanguageSelection, 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, loadWorkflow,
loadWorkflowByIdentifier, loadWorkflowByIdentifier,
isWorkflowPath, isWorkflowPath,
getWorkflowDescription,
loadAllWorkflows, loadAllWorkflows,
loadAllWorkflowsWithSources,
listWorkflows, listWorkflows,
listWorkflowEntries, listWorkflowEntries,
type WorkflowDirEntry, type WorkflowDirEntry,
type WorkflowSource,
type WorkflowWithSource,
} from './workflowLoader.js'; } from './workflowLoader.js';
export { export {
@ -21,6 +25,7 @@ export {
type CategoryConfig, type CategoryConfig,
type CategorizedWorkflows, type CategorizedWorkflows,
type MissingWorkflow, type MissingWorkflow,
type WorkflowCategoryNode,
} from './workflowCategories.js'; } from './workflowCategories.js';
export { export {

View File

@ -6,40 +6,107 @@ import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { parse as parseYaml } from 'yaml'; import { parse as parseYaml } from 'yaml';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import type { WorkflowConfig } from '../../../core/models/index.js'; import { getProjectConfigPath } from '../paths.js';
import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js'; import { getLanguage, getBuiltinWorkflowsEnabled, getDisabledBuiltins } from '../global/globalConfig.js';
import { getLanguage } from '../global/globalConfig.js'; import {
getWorkflowCategoriesConfig,
getShowOthersCategory,
getOthersCategoryName,
} from '../global/workflowCategories.js';
import { getLanguageResourcesDir } from '../../resources/index.js'; import { getLanguageResourcesDir } from '../../resources/index.js';
import { listBuiltinWorkflowNames } from './workflowResolver.js';
import type { WorkflowSource, WorkflowWithSource } from './workflowResolver.js';
const CategoryConfigSchema = z.object({ 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(), show_others_category: z.boolean().optional(),
others_category_name: z.string().min(1).optional(), others_category_name: z.string().min(1).optional(),
}).passthrough(); }).passthrough();
export interface WorkflowCategoryNode {
name: string;
workflows: string[];
children: WorkflowCategoryNode[];
}
export interface CategoryConfig { export interface CategoryConfig {
workflowCategories: Record<string, string[]>; workflowCategories: WorkflowCategoryNode[];
showOthersCategory: boolean; showOthersCategory: boolean;
othersCategoryName: string; othersCategoryName: string;
} }
export interface CategorizedWorkflows { export interface CategorizedWorkflows {
categories: Map<string, string[]>; categories: WorkflowCategoryNode[];
allWorkflows: Map<string, WorkflowConfig>; builtinCategories: WorkflowCategoryNode[];
allWorkflows: Map<string, WorkflowWithSource>;
missingWorkflows: MissingWorkflow[]; missingWorkflows: MissingWorkflow[];
} }
export interface MissingWorkflow { export interface MissingWorkflow {
categoryName: string; categoryPath: string[];
workflowName: string; workflowName: string;
} }
interface RawCategoryConfig { interface RawCategoryConfig {
workflow_categories?: Record<string, string[]>; workflow_categories?: Record<string, unknown>;
show_others_category?: boolean; show_others_category?: boolean;
others_category_name?: string; 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 { function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig | null {
if (!raw || typeof raw !== 'object') { if (!raw || typeof raw !== 'object') {
return null; return null;
@ -64,7 +131,7 @@ function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig
: parsed.others_category_name; : parsed.others_category_name;
return { return {
workflowCategories: parsed.workflow_categories, workflowCategories: parseCategoryTree(parsed.workflow_categories, sourceLabel),
showOthersCategory, showOthersCategory,
othersCategoryName, othersCategoryName,
}; };
@ -94,9 +161,16 @@ export function loadDefaultCategories(): CategoryConfig | null {
* Priority: user config -> project config -> default categories. * Priority: user config -> project config -> default categories.
*/ */
export function getWorkflowCategories(cwd: string): CategoryConfig | null { export function getWorkflowCategories(cwd: string): CategoryConfig | null {
const userConfig = loadCategoryConfigFromPath(getGlobalConfigPath(), 'global config'); // Check user config from separate file (~/.takt/workflow-categories.yaml)
if (userConfig) { const userCategoriesNode = getWorkflowCategoriesConfig();
return userConfig; 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'); const projectConfig = loadCategoryConfigFromPath(getProjectConfigPath(cwd), 'project config');
@ -107,48 +181,169 @@ export function getWorkflowCategories(cwd: string): CategoryConfig | null {
return loadDefaultCategories(); 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. * Build categorized workflows map from configuration.
*/ */
export function buildCategorizedWorkflows( export function buildCategorizedWorkflows(
allWorkflows: Map<string, WorkflowConfig>, allWorkflows: Map<string, WorkflowWithSource>,
config: CategoryConfig, config: CategoryConfig,
): CategorizedWorkflows { ): CategorizedWorkflows {
const categories = new Map<string, string[]>(); const ignoreMissing = new Set<string>();
const categorized = new Set<string>(); if (!getBuiltinWorkflowsEnabled()) {
const missingWorkflows: MissingWorkflow[] = []; for (const name of listBuiltinWorkflowNames({ includeDisabled: true })) {
ignoreMissing.add(name);
for (const [categoryName, workflowNames] of Object.entries(config.workflowCategories)) {
const validWorkflows: string[] = [];
for (const workflowName of workflowNames) {
if (allWorkflows.has(workflowName)) {
validWorkflows.push(workflowName);
categorized.add(workflowName);
} else {
missingWorkflows.push({ categoryName, workflowName });
}
} }
} else {
if (validWorkflows.length > 0) { for (const name of getDisabledBuiltins()) {
categories.set(categoryName, validWorkflows); ignoreMissing.add(name);
} }
} }
if (config.showOthersCategory) { const missingWorkflows = collectMissingWorkflows(
const uncategorized: string[] = []; config.workflowCategories,
for (const workflowName of allWorkflows.keys()) { allWorkflows,
if (!categorized.has(workflowName)) { ignoreMissing,
uncategorized.push(workflowName); );
}
}
if (uncategorized.length > 0 && !categories.has(config.othersCategoryName)) { const isBuiltin = (source: WorkflowSource): boolean => source === 'builtin';
categories.set(config.othersCategoryName, uncategorized); const isCustom = (source: WorkflowSource): boolean => source !== 'builtin';
const categorizedCustom = new Set<string>();
const categories = buildCategoryTreeForSource(
config.workflowCategories,
allWorkflows,
isCustom,
categorizedCustom,
);
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);
} }
} }
return { categories, allWorkflows, missingWorkflows };
} }
/** /**
@ -156,13 +351,9 @@ export function buildCategorizedWorkflows(
*/ */
export function findWorkflowCategories( export function findWorkflowCategories(
workflow: string, workflow: string,
categorized: CategorizedWorkflows, categories: WorkflowCategoryNode[],
): string[] { ): string[] {
const result: string[] = []; const result: string[] = [];
for (const [categoryName, workflows] of categorized.categories) { findWorkflowCategoryPaths(workflow, categories, [], result);
if (workflows.includes(workflow)) {
result.push(categoryName);
}
}
return result; return result;
} }

View File

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

View File

@ -10,14 +10,33 @@ import { join, resolve, isAbsolute } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import type { WorkflowConfig } from '../../../core/models/index.js'; import type { WorkflowConfig } from '../../../core/models/index.js';
import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from '../paths.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 { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { loadWorkflowFromFile } from './workflowParser.js'; import { loadWorkflowFromFile } from './workflowParser.js';
const log = createLogger('workflow-resolver'); 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 */ /** Get builtin workflow by name */
export function getBuiltinWorkflow(name: string): WorkflowConfig | null { export function getBuiltinWorkflow(name: string): WorkflowConfig | null {
if (!getBuiltinWorkflowsEnabled()) return null;
const lang = getLanguage(); const lang = getLanguage();
const disabled = getDisabledBuiltins(); const disabled = getDisabledBuiltins();
if (disabled.includes(name)) return null; if (disabled.includes(name)) return null;
@ -126,6 +145,24 @@ export function loadWorkflowByIdentifier(
return loadWorkflow(identifier, projectCwd); 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 */ /** Entry for a workflow file found in a directory */
export interface WorkflowDirEntry { export interface WorkflowDirEntry {
/** Workflow name (e.g. "react") */ /** Workflow name (e.g. "react") */
@ -134,6 +171,8 @@ export interface WorkflowDirEntry {
path: string; path: string;
/** Category (subdirectory name), undefined for root-level workflows */ /** Category (subdirectory name), undefined for root-level workflows */
category?: string; category?: string;
/** Workflow source (builtin, user, project) */
source: WorkflowSource;
} }
/** /**
@ -143,6 +182,7 @@ export interface WorkflowDirEntry {
*/ */
function* iterateWorkflowDir( function* iterateWorkflowDir(
dir: string, dir: string,
source: WorkflowSource,
disabled?: string[], disabled?: string[],
): Generator<WorkflowDirEntry> { ): Generator<WorkflowDirEntry> {
if (!existsSync(dir)) return; if (!existsSync(dir)) return;
@ -153,7 +193,7 @@ function* iterateWorkflowDir(
if (stat.isFile() && (entry.endsWith('.yaml') || entry.endsWith('.yml'))) { if (stat.isFile() && (entry.endsWith('.yaml') || entry.endsWith('.yml'))) {
const workflowName = entry.replace(/\.ya?ml$/, ''); const workflowName = entry.replace(/\.ya?ml$/, '');
if (disabled?.includes(workflowName)) continue; if (disabled?.includes(workflowName)) continue;
yield { name: workflowName, path: entryPath }; yield { name: workflowName, path: entryPath, source };
continue; continue;
} }
@ -167,21 +207,47 @@ function* iterateWorkflowDir(
const workflowName = subEntry.replace(/\.ya?ml$/, ''); const workflowName = subEntry.replace(/\.ya?ml$/, '');
const qualifiedName = `${category}/${workflowName}`; const qualifiedName = `${category}/${workflowName}`;
if (disabled?.includes(qualifiedName)) continue; 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) */ /** 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 disabled = getDisabledBuiltins();
const lang = getLanguage(); const lang = getLanguage();
return [ const dirs: { dir: string; source: WorkflowSource; disabled?: string[] }[] = [];
{ dir: getBuiltinWorkflowsDir(lang), disabled }, if (getBuiltinWorkflowsEnabled()) {
{ dir: getGlobalWorkflowsDir() }, dirs.push({ dir: getBuiltinWorkflowsDir(lang), disabled, source: 'builtin' });
{ dir: join(getProjectConfigDir(cwd), 'workflows') }, }
]; 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> { export function loadAllWorkflows(cwd: string): Map<string, WorkflowConfig> {
const workflows = new Map<string, WorkflowConfig>(); const workflows = new Map<string, WorkflowConfig>();
const withSources = loadAllWorkflowsWithSources(cwd);
for (const { dir, disabled } of getWorkflowDirs(cwd)) { for (const [name, entry] of withSources) {
for (const entry of iterateWorkflowDir(dir, disabled)) { workflows.set(name, entry.config);
try {
workflows.set(entry.name, loadWorkflowFromFile(entry.path));
} catch (err) {
log.debug('Skipping invalid workflow file', { path: entry.path, error: getErrorMessage(err) });
}
}
} }
return workflows; return workflows;
} }
@ -215,8 +274,8 @@ export function loadAllWorkflows(cwd: string): Map<string, WorkflowConfig> {
export function listWorkflows(cwd: string): string[] { export function listWorkflows(cwd: string): string[] {
const workflows = new Set<string>(); const workflows = new Set<string>();
for (const { dir, disabled } of getWorkflowDirs(cwd)) { for (const { dir, source, disabled } of getWorkflowDirs(cwd)) {
for (const entry of iterateWorkflowDir(dir, disabled)) { for (const entry of iterateWorkflowDir(dir, source, disabled)) {
workflows.add(entry.name); workflows.add(entry.name);
} }
} }
@ -235,8 +294,8 @@ export function listWorkflowEntries(cwd: string): WorkflowDirEntry[] {
// Later entries override earlier (project-local > user > builtin) // Later entries override earlier (project-local > user > builtin)
const workflows = new Map<string, WorkflowDirEntry>(); const workflows = new Map<string, WorkflowDirEntry>();
for (const { dir, disabled } of getWorkflowDirs(cwd)) { for (const { dir, source, disabled } of getWorkflowDirs(cwd)) {
for (const entry of iterateWorkflowDir(dir, disabled)) { for (const entry of iterateWorkflowDir(dir, source, disabled)) {
workflows.set(entry.name, entry); workflows.set(entry.name, entry);
} }
} }

View File

@ -2,6 +2,8 @@
* Config module type definitions * Config module type definitions
*/ */
import type { WorkflowCategoryConfigNode } from '../../core/models/schemas.js';
/** Permission mode for the project /** Permission mode for the project
* - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts) * - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts)
* - sacrifice-my-pc: Auto-approves all permission requests (bypassPermissions) * - sacrifice-my-pc: Auto-approves all permission requests (bypassPermissions)
@ -24,7 +26,7 @@ export interface ProjectLocalConfig {
/** Verbose output mode */ /** Verbose output mode */
verbose?: boolean; verbose?: boolean;
/** Workflow categories (name -> workflow list) */ /** Workflow categories (name -> workflow list) */
workflow_categories?: Record<string, string[]>; workflow_categories?: Record<string, WorkflowCategoryConfigNode>;
/** Show uncategorized workflows under Others category */ /** Show uncategorized workflows under Others category */
show_others_category?: boolean; show_others_category?: boolean;
/** Display name for Others category */ /** Display name for Others category */

View File

@ -86,6 +86,7 @@ export type KeyInputResult =
| { action: 'confirm'; selectedIndex: number } | { action: 'confirm'; selectedIndex: number }
| { action: 'cancel'; cancelIndex: number } | { action: 'cancel'; cancelIndex: number }
| { action: 'bookmark'; selectedIndex: number } | { action: 'bookmark'; selectedIndex: number }
| { action: 'remove_bookmark'; selectedIndex: number }
| { action: 'exit' } | { action: 'exit' }
| { action: 'none' }; | { action: 'none' };
@ -118,15 +119,18 @@ export function handleKeyInput(
if (key === 'b') { if (key === 'b') {
return { action: 'bookmark', selectedIndex: currentIndex }; return { action: 'bookmark', selectedIndex: currentIndex };
} }
if (key === 'r') {
return { action: 'remove_bookmark', selectedIndex: currentIndex };
}
return { action: 'none' }; return { action: 'none' };
} }
/** Print the menu header (message + hint). */ /** Print the menu header (message + hint). */
function printHeader(message: string, showBookmarkHint: boolean): void { function printHeader(message: string, hasCustomKeyHandler: boolean): void {
console.log(); console.log();
console.log(chalk.cyan(message)); console.log(chalk.cyan(message));
const hint = showBookmarkHint const hint = hasCustomKeyHandler
? ' (↑↓ to move, Enter to select, b to bookmark)' ? ' (↑↓ to move, Enter to select, b to bookmark, r to remove)'
: ' (↑↓ to move, Enter to select)'; : ' (↑↓ to move, Enter to select)';
console.log(chalk.gray(hint)); console.log(chalk.gray(hint));
console.log(); console.log();
@ -165,7 +169,13 @@ function redrawMenu<T extends string>(
/** Callbacks for interactive select behavior */ /** Callbacks for interactive select behavior */
export interface InteractiveSelectCallbacks<T extends string> { 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>[]; onBookmark?: (value: T, index: number) => SelectOptionItem<T>[];
/** Custom label for cancel option (default: "Cancel") */ /** Custom label for cancel option (default: "Cancel") */
cancelLabel?: string; cancelLabel?: string;
@ -191,7 +201,7 @@ function interactiveSelect<T extends string>(
let selectedIndex = initialIndex; let selectedIndex = initialIndex;
const cancelLabel = callbacks?.cancelLabel ?? 'Cancel'; const cancelLabel = callbacks?.cancelLabel ?? 'Cancel';
printHeader(message, !!callbacks?.onBookmark); printHeader(message, !!callbacks?.onKeyPress || !!callbacks?.onBookmark);
process.stdout.write('\x1B[?7l'); process.stdout.write('\x1B[?7l');
@ -213,8 +223,29 @@ function interactiveSelect<T extends string>(
}; };
const onKeypress = (data: Buffer): void => { 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( const result = handleKeyInput(
data.toString(), key,
selectedIndex, selectedIndex,
totalItems, totalItems,
hasCancelOption, hasCancelOption,
@ -250,6 +281,9 @@ function interactiveSelect<T extends string>(
totalLines = redrawMenu(currentOptions, selectedIndex, hasCancelOption, totalLines, cancelLabel); totalLines = redrawMenu(currentOptions, selectedIndex, hasCancelOption, totalLines, cancelLabel);
break; break;
} }
case 'remove_bookmark':
// Ignore - should be handled by custom onKeyPress
break;
case 'exit': case 'exit':
cleanup(onKeypress); cleanup(onKeypress);
process.exit(130); process.exit(130);