プロンプトを見通しやすく変更

This commit is contained in:
nrslib 2026-02-04 03:33:48 +09:00
parent 25fb6c4dfd
commit 6378ee6174
121 changed files with 2597 additions and 2411 deletions

42
docs/plan.md Normal file
View File

@ -0,0 +1,42 @@
- perform_phase1_message.md
- ここから status Rule を排除するphase3に書けばいい
- perform_phase2_message.md
- 「上記のReport Directory内のファイルのみ使用してください。** 他のレポートディレクトリは検索/参照しないでください。」は上記ってのがいらないのではないか
- 「**このフェーズではツールは使えません。レポート内容をテキストとして直接回答してください。**」が重複することがあるので削除せよ。
- JSON形式について触れる必要はない。
- perform_phase3_message.md
- status Rule を追加する聞く
- perform_agent_system_prompt.md
- これ、エージェントのデータを挿入してないの……?
- 全体的に
- 音楽にひもづける
- つまり、workflowsをやめて pieces にする
- 現workflowファイルにあるstepsもmovementsにする全ファイルの修正
- stepという言葉はmovementになる。phaseもmovementが適しているだろうこれは interactive における phase のことをいっていない)
- _language パラメータは消せ
- ワークフローを指定すると実際に送られるプロンプトを組み立てて表示する機能かツールを作れるか
- メタ領域を用意して説明、どこで利用されるかの説明、使えるテンプレートとその説明をかいて、その他必要な情報あれば入れて。
- 英語と日本語が共通でもかならずファイルはわけて同じ文章を書いておく
- 無駄な空行とか消してほしい
```
{{#if hasPreviousResponse}}
## Previous Response
{{previousResponse}}
{{/if}}
{{#if hasUserInputs}}
## Additional User Inputs
{{userInputs}}
```
これは↓のがいいんじゃない?
```
{{#if hasPreviousResponse}}
## Previous Response
{{previousResponse}}
{{/if}}
{{#if hasUserInputs}}
## Additional User Inputs
{{userInputs}}
```

View File

@ -10,7 +10,7 @@
"takt-cli": "./dist/app/cli/index.js"
},
"scripts": {
"build": "tsc && mkdir -p dist/shared/prompts dist/shared/i18n && cp src/shared/prompts/prompts_en.yaml src/shared/prompts/prompts_ja.yaml dist/shared/prompts/ && cp src/shared/i18n/labels_en.yaml src/shared/i18n/labels_ja.yaml dist/shared/i18n/",
"build": "tsc && mkdir -p dist/shared/prompts/en dist/shared/prompts/ja dist/shared/i18n && cp src/shared/prompts/en/*.md dist/shared/prompts/en/ && cp src/shared/prompts/ja/*.md dist/shared/prompts/ja/ && cp src/shared/i18n/labels_en.yaml src/shared/i18n/labels_ja.yaml dist/shared/i18n/",
"watch": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",

View File

@ -3,17 +3,17 @@
#
# Boilerplate sections (Workflow Context, User Request, Previous Response,
# Additional User Inputs, Instructions heading) are auto-injected by buildInstruction().
# Only step-specific content belongs in instruction_template.
# Only movement-specific content belongs in instruction_template.
#
# Template Variables (available in instruction_template):
# {iteration} - Workflow-wide turn count (total steps executed across all agents)
# {iteration} - Workflow-wide turn count (total movements executed across all agents)
# {max_iterations} - Maximum iterations allowed for the workflow
# {step_iteration} - Per-step iteration count (how many times THIS step has been executed)
# {previous_response} - Output from the previous step (only when pass_previous_response: true)
# {movement_iteration} - Per-movement iteration count (how many times THIS movement has been executed)
# {previous_response} - Output from the previous movement (only when pass_previous_response: true)
# {report_dir} - Report directory name (e.g., "20250126-143052-task-summary")
#
# Step-level Fields:
# report: - Report file(s) for the step (auto-injected as Report File/Files in Workflow Context)
# Movement-level Fields:
# report: - Report file(s) for the movement (auto-injected as Report File/Files in Workflow Context)
# Single: report: 00-plan.md
# Multiple: report:
# - Scope: 01-coder-scope.md
@ -24,9 +24,9 @@ description: Standard development workflow with planning and specialized reviews
max_iterations: 30
initial_step: plan
initial_movement: plan
steps:
movements:
- name: plan
edit: false
agent: ../agents/default/planner.md
@ -75,7 +75,7 @@ steps:
instruction_template: |
Analyze the task and create an implementation plan.
**Note:** If returned from implement step (Previous Response exists),
**Note:** If returned from implement movement (Previous Response exists),
review and revise the plan based on that feedback (replan).
**Tasks (for implementation tasks):**
@ -177,7 +177,7 @@ steps:
requires_user_input: true
interactive_only: true
instruction_template: |
Follow the plan from the plan step and the design from the architect step.
Follow the plan from the plan movement and the design from the architect movement.
**Reports to reference:**
- Plan: {report:00-plan.md}
@ -185,7 +185,7 @@ steps:
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.
**Important:** Do not make design decisions; follow the design determined in the architect movement.
Report if you encounter unclear points or need design changes.
**Scope report format (create at implementation start):**
@ -300,7 +300,7 @@ steps:
- condition: Cannot proceed, insufficient info
next: plan
instruction_template: |
**This is AI Review iteration {step_iteration}.**
**This is AI Review iteration {movement_iteration}.**
If this is iteration 2 or later, it means your previous fixes were not actually applied.
**Your belief that you "already fixed it" is wrong.**
@ -387,8 +387,8 @@ steps:
- condition: approved
- condition: needs_fix
instruction_template: |
**Verify that the implementation follows the design from the architect step.**
Do NOT review AI-specific issues (that's the ai_review step).
**Verify that the implementation follows the design from the architect movement.**
Do NOT review AI-specific issues (that's the ai_review movement).
**Reports to reference:**
- Design: {report:01-architecture.md} (if exists)
@ -402,7 +402,7 @@ steps:
- Dead code
- Call chain verification
**Note:** For small tasks that skipped the architect step, review design validity as usual.
**Note:** For small tasks that skipped the architect movement, review design validity as usual.
- name: security-review
edit: false
@ -517,7 +517,7 @@ steps:
**Workflow Overall Review:**
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 movement issues addressed?
3. Was the original task objective achieved?
**Review Reports:** Read all reports in Report Directory and

View File

@ -10,11 +10,11 @@
# any("needs_fix") → fix → reviewers
#
# Template Variables:
# {iteration} - Workflow-wide turn count (total steps executed across all agents)
# {iteration} - Workflow-wide turn count (total movements executed across all agents)
# {max_iterations} - Maximum iterations allowed for the workflow
# {step_iteration} - Per-step iteration count (how many times THIS step has been executed)
# {movement_iteration} - Per-movement iteration count (how many times THIS movement has been executed)
# {task} - Original user request
# {previous_response} - Output from the previous step
# {previous_response} - Output from the previous movement
# {user_inputs} - Accumulated user inputs during workflow
# {report_dir} - Report directory name (e.g., "20250126-143052-task-summary")
@ -23,11 +23,11 @@ description: CQRS+ES, Frontend, Security, QA Expert Review
max_iterations: 30
initial_step: plan
initial_movement: plan
steps:
movements:
# ===========================================
# Phase 0: Planning
# Movement 0: Planning
# ===========================================
- name: plan
edit: false
@ -65,7 +65,7 @@ steps:
instruction_template: |
Analyze the task and create an implementation plan.
**Note:** If returned from implement step (Previous Response exists),
**Note:** If returned from implement movement (Previous Response exists),
review and revise the plan based on that feedback (replan).
**Tasks:**
@ -80,7 +80,7 @@ steps:
next: ABORT
# ===========================================
# Phase 1: Implementation
# Movement 1: Implementation
# ===========================================
- name: implement
edit: true
@ -99,7 +99,7 @@ steps:
- WebSearch
- WebFetch
instruction_template: |
Follow the plan from the plan step and implement.
Follow the plan from the plan movement and implement.
Refer to the plan report ({report:00-plan.md}) and proceed with implementation.
Use only the Report Directory files shown in Workflow Context. Do not search or open reports outside that directory.
@ -153,7 +153,7 @@ steps:
interactive_only: true
# ===========================================
# Phase 2: AI Review
# Movement 2: AI Review
# ===========================================
- name: ai_review
edit: false
@ -219,7 +219,7 @@ steps:
- WebSearch
- WebFetch
instruction_template: |
**This is AI Review iteration {step_iteration}.**
**This is AI Review iteration {movement_iteration}.**
If this is iteration 2 or later, it means your previous fixes were not actually applied.
**Your belief that you "already fixed it" is wrong.**
@ -271,7 +271,7 @@ steps:
next: plan
# ===========================================
# Phase 3: Expert Reviews (Parallel)
# Movement 3: Expert Reviews (Parallel)
# ===========================================
- name: reviewers
parallel:
@ -320,7 +320,7 @@ steps:
- condition: needs_fix
instruction_template: |
Review the changes from the CQRS (Command Query Responsibility Segregation)
and Event Sourcing perspective. Do NOT review AI-specific issues (that's the ai_review step).
and Event Sourcing perspective. Do NOT review AI-specific issues (that's the ai_review movement).
**Review Criteria:**
- Aggregate design validity
@ -382,7 +382,7 @@ steps:
- TypeScript type safety
**Note**: If this project does not include frontend code,
approve and proceed to the next step.
approve and proceed to the next movement.
- name: security-review
edit: false
@ -525,7 +525,7 @@ steps:
pass_previous_response: true
# ===========================================
# Phase 4: Supervision
# Movement 4: Supervision
# ===========================================
- name: supervise
edit: false
@ -541,7 +541,7 @@ steps:
- WebFetch
instruction_template: |
## Previous Reviews Summary
Reaching this step means all the following reviews have been APPROVED:
Reaching this movement means all the following reviews have been APPROVED:
- AI Review: APPROVED
- CQRS+ES Review: APPROVED
- Frontend Review: APPROVED
@ -552,7 +552,7 @@ steps:
**Workflow Overall Review:**
1. Does the implementation match the plan ({report:00-plan.md})?
2. Were all review step issues addressed?
2. Were all review movement issues addressed?
3. Was the original task objective achieved?
**Review Reports:** Read all reports in Report Directory and

View File

@ -14,17 +14,17 @@
#
# Boilerplate sections (Workflow Context, User Request, Previous Response,
# Additional User Inputs, Instructions heading) are auto-injected by buildInstruction().
# Only step-specific content belongs in instruction_template.
# Only movement-specific content belongs in instruction_template.
#
# Template Variables (available in instruction_template):
# {iteration} - Workflow-wide turn count (total steps executed across all agents)
# {iteration} - Workflow-wide turn count (total movements executed across all agents)
# {max_iterations} - Maximum iterations allowed for the workflow
# {step_iteration} - Per-step iteration count (how many times THIS step has been executed)
# {previous_response} - Output from the previous step (only when pass_previous_response: true)
# {movement_iteration} - Per-movement iteration count (how many times THIS movement has been executed)
# {previous_response} - Output from the previous movement (only when pass_previous_response: true)
# {report_dir} - Report directory name (e.g., "20250126-143052-task-summary")
#
# Step-level Fields:
# report: - Report file(s) for the step (auto-injected as Report File/Files in Workflow Context)
# Movement-level Fields:
# report: - Report file(s) for the movement (auto-injected as Report File/Files in Workflow Context)
# Single: report: 00-plan.md
# Multiple: report:
# - Scope: 01-coder-scope.md
@ -35,11 +35,11 @@ description: Architecture, Frontend, Security, QA Expert Review
max_iterations: 30
initial_step: plan
initial_movement: plan
steps:
movements:
# ===========================================
# Phase 0: Planning
# Movement 0: Planning
# ===========================================
- name: plan
edit: false
@ -77,7 +77,7 @@ steps:
instruction_template: |
Analyze the task and create an implementation plan.
**Note:** If returned from implement step (Previous Response exists),
**Note:** If returned from implement movement (Previous Response exists),
review and revise the plan based on that feedback (replan).
**Tasks:**
@ -92,7 +92,7 @@ steps:
next: ABORT
# ===========================================
# Phase 1: Implementation
# Movement 1: Implementation
# ===========================================
- name: implement
edit: true
@ -111,7 +111,7 @@ steps:
- WebSearch
- WebFetch
instruction_template: |
Follow the plan from the plan step and implement.
Follow the plan from the plan movement and implement.
Refer to the plan report ({report:00-plan.md}) and proceed with implementation.
Use only the Report Directory files shown in Workflow Context. Do not search or open reports outside that directory.
@ -165,7 +165,7 @@ steps:
interactive_only: true
# ===========================================
# Phase 2: AI Review
# Movement 2: AI Review
# ===========================================
- name: ai_review
edit: false
@ -231,7 +231,7 @@ steps:
- WebSearch
- WebFetch
instruction_template: |
**This is AI Review iteration {step_iteration}.**
**This is AI Review iteration {movement_iteration}.**
If this is iteration 2 or later, it means your previous fixes were not actually applied.
**Your belief that you "already fixed it" is wrong.**
@ -284,7 +284,7 @@ steps:
next: plan
# ===========================================
# Phase 3: Expert Reviews (Parallel)
# Movement 3: Expert Reviews (Parallel)
# ===========================================
- name: reviewers
parallel:
@ -335,7 +335,7 @@ steps:
- condition: approved
- condition: needs_fix
instruction_template: |
Focus on **architecture and design** review. Do NOT review AI-specific issues (that's the ai_review step).
Focus on **architecture and design** review. Do NOT review AI-specific issues (that's the ai_review movement).
**Review Criteria:**
- Structure/design validity
@ -395,7 +395,7 @@ steps:
- TypeScript type safety
**Note**: If this project does not include frontend code,
approve and proceed to the next step.
approve and proceed to the next movement.
- name: security-review
edit: false
@ -538,7 +538,7 @@ steps:
pass_previous_response: true
# ===========================================
# Phase 4: Supervision
# Movement 4: Supervision
# ===========================================
- name: supervise
edit: false
@ -554,7 +554,7 @@ steps:
- WebFetch
instruction_template: |
## Previous Reviews Summary
Reaching this step means all the following reviews have been APPROVED:
Reaching this movement means all the following reviews have been APPROVED:
- Architecture Review: APPROVED
- Frontend Review: APPROVED
- AI Review: APPROVED
@ -565,7 +565,7 @@ steps:
**Workflow Overall Review:**
1. Does the implementation match the plan ({report:00-plan.md})?
2. Were all review step issues addressed?
2. Were all review movement issues addressed?
3. Was the original task objective achieved?
**Review Reports:** Read all reports in Report Directory and

View File

@ -3,11 +3,11 @@
# Three personas (scientist, nurturer, pragmatist) analyze from different perspectives and vote
#
# Template Variables:
# {iteration} - Workflow-wide turn count (total steps executed across all agents)
# {iteration} - Workflow-wide turn count (total movements executed across all agents)
# {max_iterations} - Maximum iterations allowed for the workflow
# {step_iteration} - Per-step iteration count (how many times THIS step has been executed)
# {movement_iteration} - Per-movement iteration count (how many times THIS movement has been executed)
# {task} - Original user request
# {previous_response} - Output from the previous step
# {previous_response} - Output from the previous movement
# {user_inputs} - Accumulated user inputs during workflow
# {report_dir} - Report directory name (e.g., "20250126-143052-task-summary")
@ -16,7 +16,7 @@ description: MAGI Deliberation System - Analyze from 3 perspectives and decide b
max_iterations: 5
steps:
movements:
- name: melchior
agent: ../agents/magi/melchior.md
allowed_tools:

View File

@ -3,11 +3,11 @@
# (Simplest configuration - no plan, no architect review)
#
# Template Variables (auto-injected):
# {iteration} - Workflow-wide turn count (total steps executed across all agents)
# {iteration} - Workflow-wide turn count (total movements executed across all agents)
# {max_iterations} - Maximum iterations allowed for the workflow
# {step_iteration} - Per-step iteration count (how many times THIS step has been executed)
# {movement_iteration} - Per-movement iteration count (how many times THIS movement has been executed)
# {task} - Original user request (auto-injected)
# {previous_response} - Output from the previous step (auto-injected)
# {previous_response} - Output from the previous movement (auto-injected)
# {user_inputs} - Accumulated user inputs during workflow (auto-injected)
# {report_dir} - Report directory name (e.g., "20250126-143052-task-summary")
@ -16,9 +16,9 @@ description: Minimal development workflow (implement -> parallel review -> fix i
max_iterations: 20
initial_step: implement
initial_movement: implement
steps:
movements:
- name: implement
edit: true
agent: ../agents/default/coder.md
@ -248,7 +248,7 @@ steps:
- condition: No fix needed (verified target files/spec)
- condition: Cannot proceed, insufficient info
instruction_template: |
**This is AI Review iteration {step_iteration}.**
**This is AI Review iteration {movement_iteration}.**
If this is iteration 2 or later, it means your previous fixes were not actually applied.
**Your belief that you "already fixed it" is wrong.**
@ -350,7 +350,7 @@ steps:
- condition: Cannot proceed, insufficient info
next: implement
instruction_template: |
**This is AI Review iteration {step_iteration}.**
**This is AI Review iteration {movement_iteration}.**
If this is iteration 2 or later, it means your previous fixes were not actually applied.
**Your belief that you "already fixed it" is wrong.**

View File

@ -7,11 +7,11 @@
# -> plan (rejected: restart from planning)
#
# Template Variables:
# {iteration} - Workflow-wide turn count (total steps executed across all agents)
# {iteration} - Workflow-wide turn count (total movements executed across all agents)
# {max_iterations} - Maximum iterations allowed for the workflow
# {step_iteration} - Per-step iteration count (how many times THIS step has been executed)
# {movement_iteration} - Per-movement iteration count (how many times THIS movement has been executed)
# {task} - Original user request
# {previous_response} - Output from the previous step
# {previous_response} - Output from the previous movement
# {user_inputs} - Accumulated user inputs during workflow
# {report_dir} - Report directory name (e.g., "20250126-143052-task-summary")
@ -20,7 +20,7 @@ description: Research workflow - autonomously executes research without asking q
max_iterations: 10
steps:
movements:
- name: plan
agent: ../agents/research/planner.md
allowed_tools:
@ -32,8 +32,8 @@ steps:
instruction_template: |
## Workflow Status
- Iteration: {iteration}/{max_iterations} (workflow-wide)
- Step Iteration: {step_iteration} (times this step has run)
- Step: plan
- Movement Iteration: {movement_iteration} (times this movement has run)
- Movement: plan
## Research Request
{task}
@ -69,8 +69,8 @@ steps:
instruction_template: |
## Workflow Status
- Iteration: {iteration}/{max_iterations} (workflow-wide)
- Step Iteration: {step_iteration} (times this step has run)
- Step: dig
- Movement Iteration: {movement_iteration} (times this movement has run)
- Movement: dig
## Original Research Request
{task}
@ -111,8 +111,8 @@ steps:
instruction_template: |
## Workflow Status
- Iteration: {iteration}/{max_iterations} (workflow-wide)
- Step Iteration: {step_iteration} (times this step has run)
- Step: supervise (research quality evaluation)
- Movement Iteration: {movement_iteration} (times this movement has run)
- Movement: supervise (research quality evaluation)
## Original Research Request
{task}
@ -131,4 +131,4 @@ steps:
- condition: Research results are insufficient and replanning is needed
next: plan
initial_step: plan
initial_movement: plan

View File

@ -1,13 +1,13 @@
# Review-Fix Minimal TAKT Workflow
# Review -> Fix (if needed) -> Re-review -> Complete
# (Starts with review, no implementation step)
# (Starts with review, no implementation movement)
#
# Template Variables (auto-injected):
# {iteration} - Workflow-wide turn count (total steps executed across all agents)
# {iteration} - Workflow-wide turn count (total movements executed across all agents)
# {max_iterations} - Maximum iterations allowed for the workflow
# {step_iteration} - Per-step iteration count (how many times THIS step has been executed)
# {movement_iteration} - Per-movement iteration count (how many times THIS movement has been executed)
# {task} - Original user request (auto-injected)
# {previous_response} - Output from the previous step (auto-injected)
# {previous_response} - Output from the previous movement (auto-injected)
# {user_inputs} - Accumulated user inputs during workflow (auto-injected)
# {report_dir} - Report directory name (e.g., "20250126-143052-task-summary")
@ -16,9 +16,9 @@ description: Review and fix workflow for existing code (starts with review, no i
max_iterations: 20
initial_step: reviewers
initial_movement: reviewers
steps:
movements:
- name: implement
edit: true
agent: ../agents/default/coder.md
@ -248,7 +248,7 @@ steps:
- condition: No fix needed (verified target files/spec)
- condition: Cannot proceed, insufficient info
instruction_template: |
**This is AI Review iteration {step_iteration}.**
**This is AI Review iteration {movement_iteration}.**
If this is iteration 2 or later, it means your previous fixes were not actually applied.
**Your belief that you "already fixed it" is wrong.**
@ -350,7 +350,7 @@ steps:
- condition: Cannot proceed, insufficient info
next: implement
instruction_template: |
**This is AI Review iteration {step_iteration}.**
**This is AI Review iteration {movement_iteration}.**
If this is iteration 2 or later, it means your previous fixes were not actually applied.
**Your belief that you "already fixed it" is wrong.**

View File

@ -8,14 +8,14 @@
# -> COMPLETE (local: console output only)
# -> ABORT (rejected)
#
# All steps have edit: false (no file modifications)
# All movements have edit: false (no file modifications)
#
# Template Variables:
# {iteration} - Workflow-wide turn count
# {max_iterations} - Maximum iterations allowed
# {step_iteration} - Per-step iteration count
# {movement_iteration} - Per-movement iteration count
# {task} - Original user request
# {previous_response} - Output from the previous step
# {previous_response} - Output from the previous movement
# {user_inputs} - Accumulated user inputs
# {report_dir} - Report directory name
@ -24,9 +24,9 @@ description: Review-only workflow - reviews code without making edits
max_iterations: 10
initial_step: plan
initial_movement: plan
steps:
movements:
- name: plan
edit: false
agent: ../agents/default/planner.md
@ -108,7 +108,7 @@ steps:
- condition: approved
- condition: needs_fix
instruction_template: |
Focus on **architecture and design** review. Do NOT review AI-specific issues (that's the ai_review step).
Focus on **architecture and design** review. Do NOT review AI-specific issues (that's the ai_review movement).
Review the code and provide feedback.
@ -248,7 +248,7 @@ steps:
3. Produce a consolidated review summary with overall verdict
4. Determine routing:
- If the task mentions posting to a PR (e.g., "post comments to PR", "comment on PR"),
route to `pr-comment` step (condition: "approved, PR comment requested")
route to `pr-comment` movement (condition: "approved, PR comment requested")
- If local review only, route to COMPLETE (condition: "approved")
- If critical issues found, route to ABORT (condition: "rejected")

View File

@ -482,8 +482,8 @@ function createOrder(data: OrderData) {
- 既存の設定ファイルが新しいスキーマと整合するか
3. ワークフロー定義を変更した場合:
- ステップ種別(通常/parallelに応じた正しいフィールドが使われているか
- 不要なフィールドparallelサブステップのnext等が残っていないか
- ムーブメント種別(通常/parallelに応じた正しいフィールドが使われているか
- 不要なフィールドparallelサブムーブメントのnext等が残っていないか
**このパターンを見つけたら REJECT:**
@ -492,7 +492,7 @@ function createOrder(data: OrderData) {
| 仕様に存在しないフィールドの使用 | 無視されるか予期しない動作 |
| 仕様上無効な値の設定 | 実行時エラーまたは無視される |
| 文書化された制約への違反 | 設計意図に反する |
| ステップ種別とフィールドの不整合 | コピペミスの兆候 |
| ムーブメント種別とフィールドの不整合 | コピペミスの兆候 |
### 9. 呼び出しチェーン検証

View File

@ -98,7 +98,7 @@ Architectが「正しく作られているかVerification」を確認す
確認すること:
- 計画00-plan.mdと実装結果が一致しているか
- 各レビューステップの指摘が適切に対応されているか
- 各レビュームーブメントの指摘が適切に対応されているか
- タスクの本来の目的が達成されているか
**ワークフロー全体の問題:**

View File

@ -34,7 +34,7 @@
### 4. 実装アプローチ
- 段階的な実装手順を設計する
- 各ステップの成果物を明示する
- 各ムーブメントの成果物を明示する
- リスクと代替案を記載する
## 重要

View File

@ -2,11 +2,11 @@
# Plan -> Architect -> Implement -> AI Review -> Reviewers (parallel: Architect + Security) -> Supervisor Approval
#
# Template Variables (auto-injected by buildInstruction):
# {iteration} - Workflow-wide turn count (total steps executed across all agents)
# {iteration} - Workflow-wide turn count (total movements executed across all agents)
# {max_iterations} - Maximum iterations allowed for the workflow
# {step_iteration} - Per-step iteration count (how many times THIS step has been executed)
# {movement_iteration} - Per-movement iteration count (how many times THIS movement has been executed)
# {task} - Original user request
# {previous_response} - Output from the previous step
# {previous_response} - Output from the previous movement
# {user_inputs} - Accumulated user inputs during workflow
# {report_dir} - Report directory name (e.g., "20250126-143052-task-summary")
@ -15,9 +15,9 @@ description: Standard development workflow with planning and specialized reviews
max_iterations: 30
initial_step: plan
initial_movement: plan
steps:
movements:
- name: plan
edit: false
agent: ../agents/default/planner.md
@ -168,7 +168,7 @@ steps:
requires_user_input: true
interactive_only: true
instruction_template: |
planステップで立てた計画と、architectステップで決定した設計に従って実装してください。
planムーブメントで立てた計画と、architectムーブメントで決定した設計に従って実装してください。
**参照するレポート:**
- 計画: {report:00-plan.md}
@ -176,7 +176,7 @@ steps:
Workflow Contextに示されたReport Directory内のファイルのみ参照してください。他のレポートディレクトリは検索/参照しないでください。
**重要:** 設計判断はせず、architectステップで決定された設計に従ってください。
**重要:** 設計判断はせず、architectムーブメントで決定された設計に従ってください。
不明点や設計の変更が必要な場合は報告してください。
**重要**: 実装と同時に単体テストを追加してください。
@ -296,7 +296,7 @@ steps:
- condition: 判断できない、情報不足
next: plan
instruction_template: |
**これは {step_iteration} 回目の AI Review です。**
**これは {movement_iteration} 回目の AI Review です。**
2回目以降は、前回の修正が実際には行われていなかったということです。
**あなたの「修正済み」という認識が間違っています。**
@ -385,8 +385,8 @@ steps:
- condition: approved
- condition: needs_fix
instruction_template: |
**実装がarchitectステップの設計に従っているか**を確認してください。
AI特有の問題はレビューしないでくださいai_reviewステップで行います)。
**実装がarchitectムーブメントの設計に従っているか**を確認してください。
AI特有の問題はレビューしないでくださいai_reviewムーブメントで行います)。
**参照するレポート:**
- 設計: {report:01-architecture.md}(存在する場合)
@ -400,7 +400,7 @@ steps:
- デッドコード
- 呼び出しチェーン検証
**注意:** architectステップをスキップした小規模タスクの場合は、従来通り設計の妥当性も確認してください。
**注意:** architectムーブメントをスキップした小規模タスクの場合は、従来通り設計の妥当性も確認してください。
- name: security-review
edit: false
@ -514,7 +514,7 @@ steps:
**ワークフロー全体の確認:**
1. 計画({report:00-plan.md})と設計({report:01-architecture.md}、存在する場合)に従った実装か
2. 各レビューステップの指摘が対応されているか
2. 各レビュームーブメントの指摘が対応されているか
3. 元のタスク目的が達成されているか
**レポートの確認:** Report Directory内の全レポートを読み、

View File

@ -11,17 +11,17 @@
#
# ボイラープレートセクションWorkflow Context, User Request, Previous Response,
# Additional User Inputs, Instructions headingはbuildInstruction()が自動挿入。
# instruction_templateにはステップ固有の内容のみ記述。
# instruction_templateにはムーブメント固有の内容のみ記述。
#
# テンプレート変数instruction_template内で使用可能:
# {iteration} - ワークフロー全体のターン数(全エージェントで実行されたステップの合計)
# {iteration} - ワークフロー全体のターン数(全エージェントで実行されたムーブメントの合計)
# {max_iterations} - ワークフローの最大イテレーション数
# {step_iteration} - ステップごとのイテレーション数(このステップが何回実行されたか)
# {previous_response} - 前のステップの出力pass_previous_response: true の場合のみ)
# {movement_iteration} - ムーブメントごとのイテレーション数(このムーブメントが何回実行されたか)
# {previous_response} - 前のムーブメントの出力pass_previous_response: true の場合のみ)
# {report_dir} - レポートディレクトリ名(例: "20250126-143052-task-summary"
#
# ステップレベルフィールド:
# report: - ステップのレポートファイルWorkflow ContextにReport File/Filesとして自動挿入
# ムーブメントレベルフィールド:
# report: - ムーブメントのレポートファイルWorkflow ContextにReport File/Filesとして自動挿入
# 単一: report: 00-plan.md
# 複数: report:
# - Scope: 01-coder-scope.md
@ -32,11 +32,11 @@ description: CQRS+ES・フロントエンド・セキュリティ・QA専門家
max_iterations: 30
initial_step: plan
initial_movement: plan
steps:
movements:
# ===========================================
# Phase 0: Planning
# Movement 0: Planning
# ===========================================
- name: plan
edit: false
@ -89,7 +89,7 @@ steps:
next: ABORT
# ===========================================
# Phase 1: Implementation
# Movement 1: Implementation
# ===========================================
- name: implement
edit: true
@ -108,7 +108,7 @@ steps:
- WebSearch
- WebFetch
instruction_template: |
planステップで立てた計画に従って実装してください。
planムーブメントで立てた計画に従って実装してください。
計画レポート({report:00-plan.md})を参照し、実装を進めてください。
Workflow Contextに示されたReport Directory内のファイルのみ参照してください。他のレポートディレクトリは検索/参照しないでください。
@ -162,7 +162,7 @@ steps:
interactive_only: true
# ===========================================
# Phase 2: AI Review
# Movement 2: AI Review
# ===========================================
- name: ai_review
edit: false
@ -228,7 +228,7 @@ steps:
- WebSearch
- WebFetch
instruction_template: |
**これは {step_iteration} 回目の AI Review です。**
**これは {movement_iteration} 回目の AI Review です。**
2回目以降は、前回の修正が実際には行われていなかったということです。
**あなたの「修正済み」という認識が間違っています。**
@ -279,7 +279,7 @@ steps:
next: plan
# ===========================================
# Phase 3: Expert Reviews (Parallel)
# Movement 3: Expert Reviews (Parallel)
# ===========================================
- name: reviewers
parallel:
@ -328,7 +328,7 @@ steps:
- condition: needs_fix
instruction_template: |
CQRSコマンドクエリ責務分離とEvent Sourcingイベントソーシングの観点から
変更をレビューしてください。AI特有の問題のレビューは不要ですai_reviewステップで実施済み)。
変更をレビューしてください。AI特有の問題のレビューは不要ですai_reviewムーブメントで実施済み)。
**レビュー観点:**
- Aggregate設計の妥当性
@ -533,7 +533,7 @@ steps:
pass_previous_response: true
# ===========================================
# Phase 4: Supervision
# Movement 4: Supervision
# ===========================================
- name: supervise
edit: false
@ -549,7 +549,7 @@ steps:
- WebFetch
instruction_template: |
## Previous Reviews Summary
このステップに到達したということは、以下のレビューがすべてAPPROVEされています
このムーブメントに到達したということは、以下のレビューがすべてAPPROVEされています
- AI Review: APPROVED
- CQRS+ES Review: APPROVED
- Frontend Review: APPROVED
@ -560,7 +560,7 @@ steps:
**ワークフロー全体の確認:**
1. 計画({report:00-plan.md})と実装結果が一致しているか
2. 各レビューステップの指摘が対応されているか
2. 各レビュームーブメントの指摘が対応されているか
3. 元のタスク目的が達成されているか
**レポートの確認:** Report Directory内の全レポートを読み、

View File

@ -10,11 +10,11 @@
# any("needs_fix") → fix → reviewers
#
# テンプレート変数:
# {iteration} - ワークフロー全体のターン数(全エージェントで実行されたステップの合計)
# {iteration} - ワークフロー全体のターン数(全エージェントで実行されたムーブメントの合計)
# {max_iterations} - ワークフローの最大イテレーション数
# {step_iteration} - ステップごとのイテレーション数(このステップが何回実行されたか)
# {movement_iteration} - ムーブメントごとのイテレーション数(このムーブメントが何回実行されたか)
# {task} - 元のユーザー要求
# {previous_response} - 前のステップの出力
# {previous_response} - 前のムーブメントの出力
# {user_inputs} - ワークフロー中に蓄積されたユーザー入力
# {report_dir} - レポートディレクトリ名(例: "20250126-143052-task-summary"
@ -23,11 +23,11 @@ description: アーキテクチャ・フロントエンド・セキュリティ
max_iterations: 30
initial_step: plan
initial_movement: plan
steps:
movements:
# ===========================================
# Phase 0: Planning
# Movement 0: Planning
# ===========================================
- name: plan
edit: false
@ -80,7 +80,7 @@ steps:
next: ABORT
# ===========================================
# Phase 1: Implementation
# Movement 1: Implementation
# ===========================================
- name: implement
edit: true
@ -99,7 +99,7 @@ steps:
- WebSearch
- WebFetch
instruction_template: |
planステップで立てた計画に従って実装してください。
planムーブメントで立てた計画に従って実装してください。
計画レポート({report:00-plan.md})を参照し、実装を進めてください。
Workflow Contextに示されたReport Directory内のファイルのみ参照してください。他のレポートディレクトリは検索/参照しないでください。
@ -153,7 +153,7 @@ steps:
interactive_only: true
# ===========================================
# Phase 2: AI Review
# Movement 2: AI Review
# ===========================================
- name: ai_review
edit: false
@ -219,7 +219,7 @@ steps:
- WebSearch
- WebFetch
instruction_template: |
**これは {step_iteration} 回目の AI Review です。**
**これは {movement_iteration} 回目の AI Review です。**
2回目以降は、前回の修正が実際には行われていなかったということです。
**あなたの「修正済み」という認識が間違っています。**
@ -270,7 +270,7 @@ steps:
next: plan
# ===========================================
# Phase 3: Expert Reviews (Parallel)
# Movement 3: Expert Reviews (Parallel)
# ===========================================
- name: reviewers
parallel:
@ -321,7 +321,7 @@ steps:
- condition: approved
- condition: needs_fix
instruction_template: |
**アーキテクチャと設計**のレビューに集中してください。AI特有の問題のレビューは不要ですai_reviewステップで実施済み)。
**アーキテクチャと設計**のレビューに集中してください。AI特有の問題のレビューは不要ですai_reviewムーブメントで実施済み)。
**レビュー観点:**
- 構造・設計の妥当性
@ -524,7 +524,7 @@ steps:
pass_previous_response: true
# ===========================================
# Phase 4: Supervision
# Movement 4: Supervision
# ===========================================
- name: supervise
edit: false
@ -540,7 +540,7 @@ steps:
- WebFetch
instruction_template: |
## Previous Reviews Summary
このステップに到達したということは、以下のレビューがすべてAPPROVEされています
このムーブメントに到達したということは、以下のレビューがすべてAPPROVEされています
- AI Review: APPROVED
- Architecture Review: APPROVED
- Frontend Review: APPROVED
@ -551,7 +551,7 @@ steps:
**ワークフロー全体の確認:**
1. 計画({report:00-plan.md})と実装結果が一致しているか
2. 各レビューステップの指摘が対応されているか
2. 各レビュームーブメントの指摘が対応されているか
3. 元のタスク目的が達成されているか
**レポートの確認:** Report Directory内の全レポートを読み、

View File

@ -3,11 +3,11 @@
# 3つの人格科学者・育成者・実務家が異なる観点から分析・投票する
#
# テンプレート変数:
# {iteration} - ワークフロー全体のターン数(全エージェントで実行されたステップの合計)
# {iteration} - ワークフロー全体のターン数(全エージェントで実行されたムーブメントの合計)
# {max_iterations} - ワークフローの最大イテレーション数
# {step_iteration} - ステップごとのイテレーション数(このステップが何回実行されたか)
# {movement_iteration} - ムーブメントごとのイテレーション数(このムーブメントが何回実行されたか)
# {task} - 元のユーザー要求
# {previous_response} - 前のステップの出力
# {previous_response} - 前のムーブメントの出力
# {user_inputs} - ワークフロー中に蓄積されたユーザー入力
# {report_dir} - レポートディレクトリ名(例: "20250126-143052-task-summary"
@ -16,7 +16,7 @@ description: MAGI合議システム - 3つの観点から分析し多数決で
max_iterations: 5
steps:
movements:
- name: melchior
agent: ../agents/magi/melchior.md
allowed_tools:

View File

@ -1,13 +1,13 @@
# Simple TAKT Workflow
# Implement -> AI Review -> Supervisor Approval
# (最もシンプルな構成 - plan, architect review, fix ステップなし)
# (最もシンプルな構成 - plan, architect review, fix ムーブメントなし)
#
# Template Variables (auto-injected):
# {iteration} - Workflow-wide turn count (total steps executed across all agents)
# {iteration} - Workflow-wide turn count (total movements executed across all agents)
# {max_iterations} - Maximum iterations allowed for the workflow
# {step_iteration} - Per-step iteration count (how many times THIS step has been executed)
# {movement_iteration} - Per-movement iteration count (how many times THIS movement has been executed)
# {task} - Original user request (auto-injected)
# {previous_response} - Output from the previous step (auto-injected)
# {previous_response} - Output from the previous movement (auto-injected)
# {user_inputs} - Accumulated user inputs during workflow (auto-injected)
# {report_dir} - Report directory name (e.g., "20250126-143052-task-summary")
@ -16,9 +16,9 @@ description: Minimal development workflow (implement -> parallel review -> fix i
max_iterations: 20
initial_step: implement
initial_movement: implement
steps:
movements:
- name: implement
edit: true
agent: ../agents/default/coder.md
@ -248,7 +248,7 @@ steps:
- condition: 修正不要(指摘対象ファイル/仕様の確認済み)
- condition: 判断できない、情報不足
instruction_template: |
**これは {step_iteration} 回目の AI Review です。**
**これは {movement_iteration} 回目の AI Review です。**
2回目以降は、前回の修正が実際には行われていなかったということです。
**あなたの「修正済み」という認識が間違っています。**
@ -350,7 +350,7 @@ steps:
- condition: 判断できない、情報不足
next: implement
instruction_template: |
**これは {step_iteration} 回目の AI Review です。**
**これは {movement_iteration} 回目の AI Review です。**
2回目以降は、前回の修正が実際には行われていなかったということです。
**あなたの「修正済み」という認識が間違っています。**

View File

@ -7,11 +7,11 @@
# -> plan (rejected: 計画からやり直し)
#
# テンプレート変数:
# {iteration} - ワークフロー全体のターン数(全エージェントで実行されたステップの合計)
# {iteration} - ワークフロー全体のターン数(全エージェントで実行されたムーブメントの合計)
# {max_iterations} - ワークフローの最大イテレーション数
# {step_iteration} - ステップごとのイテレーション数(このステップが何回実行されたか)
# {movement_iteration} - ムーブメントごとのイテレーション数(このムーブメントが何回実行されたか)
# {task} - 元のユーザー要求
# {previous_response} - 前のステップの出力
# {previous_response} - 前のムーブメントの出力
# {user_inputs} - ワークフロー中に蓄積されたユーザー入力
# {report_dir} - レポートディレクトリ名(例: "20250126-143052-task-summary"
@ -20,7 +20,7 @@ description: 調査ワークフロー - 質問せずに自律的に調査を実
max_iterations: 10
steps:
movements:
- name: plan
agent: ../agents/research/planner.md
allowed_tools:
@ -32,8 +32,8 @@ steps:
instruction_template: |
## ワークフロー状況
- イテレーション: {iteration}/{max_iterations}(ワークフロー全体)
- ステップ実行回数: {step_iteration}(このステップの実行回数)
- ステップ: plan
- ムーブメント実行回数: {movement_iteration}(このムーブメントの実行回数)
- ムーブメント: plan
## 調査依頼
{task}
@ -69,8 +69,8 @@ steps:
instruction_template: |
## ワークフロー状況
- イテレーション: {iteration}/{max_iterations}(ワークフロー全体)
- ステップ実行回数: {step_iteration}(このステップの実行回数)
- ステップ: dig
- ムーブメント実行回数: {movement_iteration}(このムーブメントの実行回数)
- ムーブメント: dig
## 元の調査依頼
{task}
@ -111,8 +111,8 @@ steps:
instruction_template: |
## ワークフロー状況
- イテレーション: {iteration}/{max_iterations}(ワークフロー全体)
- ステップ実行回数: {step_iteration}(このステップの実行回数)
- ステップ: supervise (調査品質評価)
- ムーブメント実行回数: {movement_iteration}(このムーブメントの実行回数)
- ムーブメント: supervise (調査品質評価)
## 元の調査依頼
{task}
@ -131,4 +131,4 @@ steps:
- condition: 調査結果が不十分であり、計画からやり直す必要がある
next: plan
initial_step: plan
initial_movement: plan

View File

@ -1,13 +1,13 @@
# Review-Fix Minimal TAKT Workflow
# Review -> Fix (if needed) -> Re-review -> Complete
# (レビューから開始、実装ステップなし)
# (レビューから開始、実装ムーブメントなし)
#
# Template Variables (auto-injected):
# {iteration} - Workflow-wide turn count (total steps executed across all agents)
# {iteration} - Workflow-wide turn count (total movements executed across all agents)
# {max_iterations} - Maximum iterations allowed for the workflow
# {step_iteration} - Per-step iteration count (how many times THIS step has been executed)
# {movement_iteration} - Per-movement iteration count (how many times THIS movement has been executed)
# {task} - Original user request (auto-injected)
# {previous_response} - Output from the previous step (auto-injected)
# {previous_response} - Output from the previous movement (auto-injected)
# {user_inputs} - Accumulated user inputs during workflow (auto-injected)
# {report_dir} - Report directory name (e.g., "20250126-143052-task-summary")
@ -16,9 +16,9 @@ description: 既存コードのレビューと修正ワークフロー(レビ
max_iterations: 20
initial_step: reviewers
initial_movement: reviewers
steps:
movements:
- name: implement
edit: true
agent: ../agents/default/coder.md
@ -248,7 +248,7 @@ steps:
- condition: 修正不要(指摘対象ファイル/仕様の確認済み)
- condition: 判断できない、情報不足
instruction_template: |
**これは {step_iteration} 回目の AI Review です。**
**これは {movement_iteration} 回目の AI Review です。**
2回目以降は、前回の修正が実際には行われていなかったということです。
**あなたの「修正済み」という認識が間違っています。**
@ -350,7 +350,7 @@ steps:
- condition: 判断できない、情報不足
next: implement
instruction_template: |
**これは {step_iteration} 回目の AI Review です。**
**これは {movement_iteration} 回目の AI Review です。**
2回目以降は、前回の修正が実際には行われていなかったということです。
**あなたの「修正済み」という認識が間違っています。**

View File

@ -8,14 +8,14 @@
# -> COMPLETE (ローカル: コンソール出力のみ)
# -> ABORT (rejected)
#
# 全ステップ edit: falseファイル変更なし
# 全ムーブメント edit: falseファイル変更なし
#
# テンプレート変数:
# {iteration} - ワークフロー全体のターン数
# {max_iterations} - 最大イテレーション数
# {step_iteration} - ステップごとのイテレーション数
# {movement_iteration} - ムーブメントごとのイテレーション数
# {task} - 元のユーザー要求
# {previous_response} - 前のステップの出力
# {previous_response} - 前のムーブメントの出力
# {user_inputs} - 蓄積されたユーザー入力
# {report_dir} - レポートディレクトリ名
@ -24,9 +24,9 @@ description: レビュー専用ワークフロー - コードをレビューす
max_iterations: 10
initial_step: plan
initial_movement: plan
steps:
movements:
- name: plan
edit: false
agent: ../agents/default/planner.md
@ -108,7 +108,7 @@ steps:
- condition: approved
- condition: needs_fix
instruction_template: |
**アーキテクチャと設計**のレビューに集中してください。AI特有の問題はレビューしないでくださいai_reviewステップで行います)。
**アーキテクチャと設計**のレビューに集中してください。AI特有の問題はレビューしないでくださいai_reviewムーブメントで行います)。
コードをレビューしてフィードバックを提供してください。
@ -249,7 +249,7 @@ steps:
4. ルーティング判断:
- タスクにPRへのコメント投稿が含まれている場合
(例: "PRにコメントして"、"PRにレビュー結果を投稿"
→ `pr-comment` ステップcondition: "approved, PR comment requested"
→ `pr-comment` ムーブメントcondition: "approved, PR comment requested"
- ローカルレビューのみ → COMPLETEcondition: "approved"
- 重大な問題が見つかった場合 → ABORTcondition: "rejected"

View File

@ -45,7 +45,7 @@ describe('detectRuleIndex', () => {
expect(detectRuleIndex('[Plan:2]', 'plan')).toBe(1);
});
it('should match step name case-insensitively', () => {
it('should match movement name case-insensitively', () => {
expect(detectRuleIndex('[IMPLEMENT:1]', 'implement')).toBe(0);
expect(detectRuleIndex('[REVIEW:2]', 'review')).toBe(1);
});

View File

@ -51,109 +51,109 @@ describe('getBuiltinWorkflow', () => {
});
});
describe('default workflow parallel reviewers step', () => {
it('should have a reviewers step with parallel sub-steps', () => {
describe('default workflow parallel reviewers movement', () => {
it('should have a reviewers movement with parallel sub-movements', () => {
const workflow = getBuiltinWorkflow('default');
expect(workflow).not.toBeNull();
const reviewersStep = workflow!.steps.find((s) => s.name === 'reviewers');
expect(reviewersStep).toBeDefined();
expect(reviewersStep!.parallel).toBeDefined();
expect(reviewersStep!.parallel).toHaveLength(2);
const reviewersMovement = workflow!.movements.find((s) => s.name === 'reviewers');
expect(reviewersMovement).toBeDefined();
expect(reviewersMovement!.parallel).toBeDefined();
expect(reviewersMovement!.parallel).toHaveLength(2);
});
it('should have arch-review and security-review as parallel sub-steps', () => {
it('should have arch-review and security-review as parallel sub-movements', () => {
const workflow = getBuiltinWorkflow('default');
const reviewersStep = workflow!.steps.find((s) => s.name === 'reviewers')!;
const subStepNames = reviewersStep.parallel!.map((s) => s.name);
const reviewersMovement = workflow!.movements.find((s) => s.name === 'reviewers')!;
const subMovementNames = reviewersMovement.parallel!.map((s) => s.name);
expect(subStepNames).toContain('arch-review');
expect(subStepNames).toContain('security-review');
expect(subMovementNames).toContain('arch-review');
expect(subMovementNames).toContain('security-review');
});
it('should have aggregate conditions on the reviewers parent step', () => {
it('should have aggregate conditions on the reviewers parent movement', () => {
const workflow = getBuiltinWorkflow('default');
const reviewersStep = workflow!.steps.find((s) => s.name === 'reviewers')!;
const reviewersMovement = workflow!.movements.find((s) => s.name === 'reviewers')!;
expect(reviewersStep.rules).toBeDefined();
expect(reviewersStep.rules).toHaveLength(2);
expect(reviewersMovement.rules).toBeDefined();
expect(reviewersMovement.rules).toHaveLength(2);
const allRule = reviewersStep.rules!.find((r) => r.isAggregateCondition && r.aggregateType === 'all');
const allRule = reviewersMovement.rules!.find((r) => r.isAggregateCondition && r.aggregateType === 'all');
expect(allRule).toBeDefined();
expect(allRule!.aggregateConditionText).toBe('approved');
expect(allRule!.next).toBe('supervise');
const anyRule = reviewersStep.rules!.find((r) => r.isAggregateCondition && r.aggregateType === 'any');
const anyRule = reviewersMovement.rules!.find((r) => r.isAggregateCondition && r.aggregateType === 'any');
expect(anyRule).toBeDefined();
expect(anyRule!.aggregateConditionText).toBe('needs_fix');
expect(anyRule!.next).toBe('fix');
});
it('should have matching conditions on sub-steps for aggregation', () => {
it('should have matching conditions on sub-movements for aggregation', () => {
const workflow = getBuiltinWorkflow('default');
const reviewersStep = workflow!.steps.find((s) => s.name === 'reviewers')!;
const reviewersMovement = workflow!.movements.find((s) => s.name === 'reviewers')!;
for (const subStep of reviewersStep.parallel!) {
expect(subStep.rules).toBeDefined();
const conditions = subStep.rules!.map((r) => r.condition);
for (const subMovement of reviewersMovement.parallel!) {
expect(subMovement.rules).toBeDefined();
const conditions = subMovement.rules!.map((r) => r.condition);
expect(conditions).toContain('approved');
expect(conditions).toContain('needs_fix');
}
});
it('should have ai_review transitioning to reviewers step', () => {
it('should have ai_review transitioning to reviewers movement', () => {
const workflow = getBuiltinWorkflow('default');
const aiReviewStep = workflow!.steps.find((s) => s.name === 'ai_review')!;
const aiReviewMovement = workflow!.movements.find((s) => s.name === 'ai_review')!;
const approveRule = aiReviewStep.rules!.find((r) => r.next === 'reviewers');
const approveRule = aiReviewMovement.rules!.find((r) => r.next === 'reviewers');
expect(approveRule).toBeDefined();
});
it('should have ai_fix transitioning to ai_review step', () => {
it('should have ai_fix transitioning to ai_review movement', () => {
const workflow = getBuiltinWorkflow('default');
const aiFixStep = workflow!.steps.find((s) => s.name === 'ai_fix')!;
const aiFixMovement = workflow!.movements.find((s) => s.name === 'ai_fix')!;
const fixedRule = aiFixStep.rules!.find((r) => r.next === 'ai_review');
const fixedRule = aiFixMovement.rules!.find((r) => r.next === 'ai_review');
expect(fixedRule).toBeDefined();
});
it('should have fix step transitioning back to reviewers', () => {
it('should have fix movement transitioning back to reviewers', () => {
const workflow = getBuiltinWorkflow('default');
const fixStep = workflow!.steps.find((s) => s.name === 'fix')!;
const fixMovement = workflow!.movements.find((s) => s.name === 'fix')!;
const fixedRule = fixStep.rules!.find((r) => r.next === 'reviewers');
const fixedRule = fixMovement.rules!.find((r) => r.next === 'reviewers');
expect(fixedRule).toBeDefined();
});
it('should not have old separate review/security_review/improve steps', () => {
it('should not have old separate review/security_review/improve movements', () => {
const workflow = getBuiltinWorkflow('default');
const stepNames = workflow!.steps.map((s) => s.name);
const movementNames = workflow!.movements.map((s) => s.name);
expect(stepNames).not.toContain('review');
expect(stepNames).not.toContain('security_review');
expect(stepNames).not.toContain('improve');
expect(stepNames).not.toContain('security_fix');
expect(movementNames).not.toContain('review');
expect(movementNames).not.toContain('security_review');
expect(movementNames).not.toContain('improve');
expect(movementNames).not.toContain('security_fix');
});
it('should have sub-steps with correct agents', () => {
it('should have sub-movements with correct agents', () => {
const workflow = getBuiltinWorkflow('default');
const reviewersStep = workflow!.steps.find((s) => s.name === 'reviewers')!;
const reviewersMovement = workflow!.movements.find((s) => s.name === 'reviewers')!;
const archReview = reviewersStep.parallel!.find((s) => s.name === 'arch-review')!;
const archReview = reviewersMovement.parallel!.find((s) => s.name === 'arch-review')!;
expect(archReview.agent).toContain('architecture-reviewer');
const secReview = reviewersStep.parallel!.find((s) => s.name === 'security-review')!;
const secReview = reviewersMovement.parallel!.find((s) => s.name === 'security-review')!;
expect(secReview.agent).toContain('security-reviewer');
});
it('should have reports configured on sub-steps', () => {
it('should have reports configured on sub-movements', () => {
const workflow = getBuiltinWorkflow('default');
const reviewersStep = workflow!.steps.find((s) => s.name === 'reviewers')!;
const reviewersMovement = workflow!.movements.find((s) => s.name === 'reviewers')!;
const archReview = reviewersStep.parallel!.find((s) => s.name === 'arch-review')!;
const archReview = reviewersMovement.parallel!.find((s) => s.name === 'arch-review')!;
expect(archReview.report).toBeDefined();
const secReview = reviewersStep.parallel!.find((s) => s.name === 'security-review')!;
const secReview = reviewersMovement.parallel!.find((s) => s.name === 'security-review')!;
expect(secReview.report).toBeDefined();
});
});
@ -180,7 +180,7 @@ describe('loadAllWorkflows', () => {
name: test-workflow
description: Test workflow
max_iterations: 10
steps:
movements:
- name: step1
agent: coder
instruction: "{task}"

View File

@ -3,7 +3,7 @@
*
* Covers:
* - abort() sets state to aborted and emits workflow:abort
* - abort() during step execution interrupts the step
* - abort() during movement execution interrupts the movement
* - isAbortRequested() reflects abort state
* - Double abort() is idempotent
*/
@ -39,7 +39,7 @@ import { WorkflowEngine } from '../core/workflow/index.js';
import { runAgent } from '../agents/runner.js';
import {
makeResponse,
makeStep,
makeMovement,
makeRule,
mockRunAgentSequence,
mockDetectMatchedRuleSequence,
@ -66,15 +66,15 @@ describe('WorkflowEngine: Abort (SIGINT)', () => {
return {
name: 'test',
maxIterations: 10,
initialStep: 'step1',
steps: [
makeStep('step1', {
initialMovement: 'step1',
movements: [
makeMovement('step1', {
rules: [
makeRule('done', 'step2'),
makeRule('fail', 'ABORT'),
],
}),
makeStep('step2', {
makeMovement('step2', {
rules: [
makeRule('done', 'COMPLETE'),
],
@ -84,7 +84,7 @@ describe('WorkflowEngine: Abort (SIGINT)', () => {
}
describe('abort() before run loop iteration', () => {
it('should abort immediately when abort() called before step execution', async () => {
it('should abort immediately when abort() called before movement execution', async () => {
const config = makeSimpleConfig();
const engine = new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
@ -100,17 +100,17 @@ describe('WorkflowEngine: Abort (SIGINT)', () => {
expect(state.status).toBe('aborted');
expect(abortFn).toHaveBeenCalledOnce();
expect(abortFn.mock.calls[0][1]).toContain('SIGINT');
// runAgent should never be called since abort was requested before first step
// runAgent should never be called since abort was requested before first movement
expect(vi.mocked(runAgent)).not.toHaveBeenCalled();
});
});
describe('abort() during step execution', () => {
describe('abort() during movement execution', () => {
it('should abort when abort() is called during runAgent', async () => {
const config = makeSimpleConfig();
const engine = new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
// Simulate abort during step execution: runAgent rejects after abort() is called
// Simulate abort during movement execution: runAgent rejects after abort() is called
vi.mocked(runAgent).mockImplementation(async () => {
engine.abort();
throw new Error('Query interrupted');
@ -158,14 +158,14 @@ describe('WorkflowEngine: Abort (SIGINT)', () => {
});
});
describe('abort between steps', () => {
it('should stop after completing current step when abort() is called', async () => {
describe('abort between movements', () => {
it('should stop after completing current movement when abort() is called', async () => {
const config = makeSimpleConfig();
const engine = new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
// First step completes normally, but abort is called during it
// First movement completes normally, but abort is called during it
vi.mocked(runAgent).mockImplementation(async () => {
// Simulate abort during execution (but the step itself completes)
// Simulate abort during execution (but the movement itself completes)
engine.abort();
return makeResponse({ agent: 'step1', content: 'Step 1 done' });
});
@ -181,7 +181,7 @@ describe('WorkflowEngine: Abort (SIGINT)', () => {
expect(state.status).toBe('aborted');
expect(state.iteration).toBe(1);
// Only step1 runs; step2 should not start because abort is checked at loop top
// Only step1 runs; step2 should not start because abort is checked at loop top (movement names kept as step1/step2 for simplicity)
expect(vi.mocked(runAgent)).toHaveBeenCalledTimes(1);
expect(abortFn).toHaveBeenCalledOnce();
});

View File

@ -1,8 +1,8 @@
/**
* Tests for WorkflowEngine provider/model overrides.
*
* Verifies that CLI-specified overrides take precedence over workflow step defaults,
* and that step-specific values are used when no overrides are present.
* Verifies that CLI-specified overrides take precedence over workflow movement defaults,
* and that movement-specific values are used when no overrides are present.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
@ -32,7 +32,7 @@ import type { WorkflowConfig } from '../core/models/index.js';
import {
makeResponse,
makeRule,
makeStep,
makeMovement,
mockRunAgentSequence,
mockDetectMatchedRuleSequence,
applyDefaultMocks,
@ -44,21 +44,21 @@ describe('WorkflowEngine agent overrides', () => {
applyDefaultMocks();
});
it('respects workflow step provider/model even when CLI overrides are provided', async () => {
const step = makeStep('plan', {
it('respects workflow movement provider/model even when CLI overrides are provided', async () => {
const movement = makeMovement('plan', {
provider: 'claude',
model: 'claude-step',
model: 'claude-movement',
rules: [makeRule('done', 'COMPLETE')],
});
const config: WorkflowConfig = {
name: 'override-test',
steps: [step],
initialStep: 'plan',
movements: [movement],
initialMovement: 'plan',
maxIterations: 1,
};
mockRunAgentSequence([
makeResponse({ agent: step.agent, content: 'done' }),
makeResponse({ agent: movement.agent, content: 'done' }),
]);
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
@ -72,22 +72,22 @@ describe('WorkflowEngine agent overrides', () => {
const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('claude');
expect(options.model).toBe('claude-step');
expect(options.model).toBe('claude-movement');
});
it('allows CLI overrides when workflow step leaves provider/model undefined', async () => {
const step = makeStep('plan', {
it('allows CLI overrides when workflow movement leaves provider/model undefined', async () => {
const movement = makeMovement('plan', {
rules: [makeRule('done', 'COMPLETE')],
});
const config: WorkflowConfig = {
name: 'override-fallback',
steps: [step],
initialStep: 'plan',
movements: [movement],
initialMovement: 'plan',
maxIterations: 1,
};
mockRunAgentSequence([
makeResponse({ agent: step.agent, content: 'done' }),
makeResponse({ agent: movement.agent, content: 'done' }),
]);
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
@ -104,29 +104,29 @@ describe('WorkflowEngine agent overrides', () => {
expect(options.model).toBe('cli-model');
});
it('falls back to workflow step provider/model when no overrides supplied', async () => {
const step = makeStep('plan', {
it('falls back to workflow movement provider/model when no overrides supplied', async () => {
const movement = makeMovement('plan', {
provider: 'claude',
model: 'step-model',
model: 'movement-model',
rules: [makeRule('done', 'COMPLETE')],
});
const config: WorkflowConfig = {
name: 'step-defaults',
steps: [step],
initialStep: 'plan',
name: 'movement-defaults',
movements: [movement],
initialMovement: 'plan',
maxIterations: 1,
};
mockRunAgentSequence([
makeResponse({ agent: step.agent, content: 'done' }),
makeResponse({ agent: movement.agent, content: 'done' }),
]);
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
const engine = new WorkflowEngine(config, '/tmp/project', 'step task', { projectCwd: '/tmp/project' });
const engine = new WorkflowEngine(config, '/tmp/project', 'movement task', { projectCwd: '/tmp/project' });
await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('claude');
expect(options.model).toBe('step-model');
expect(options.model).toBe('movement-model');
});
});

View File

@ -72,7 +72,7 @@ describe('WorkflowEngine Integration: Blocked Handling', () => {
const blockedFn = vi.fn();
const abortFn = vi.fn();
engine.on('step:blocked', blockedFn);
engine.on('movement:blocked', blockedFn);
engine.on('workflow:abort', abortFn);
const state = await engine.run();
@ -132,7 +132,7 @@ describe('WorkflowEngine Integration: Blocked Handling', () => {
]);
const userInputFn = vi.fn();
engine.on('step:user_input', userInputFn);
engine.on('movement:user_input', userInputFn);
const state = await engine.run();

View File

@ -39,7 +39,7 @@ import { runAgent } from '../agents/runner.js';
import { detectMatchedRule } from '../core/workflow/index.js';
import {
makeResponse,
makeStep,
makeMovement,
makeRule,
buildDefaultWorkflowConfig,
mockRunAgentSequence,
@ -119,9 +119,9 @@ describe('WorkflowEngine Integration: Error Handling', () => {
const config = buildDefaultWorkflowConfig({
maxIterations: 100,
loopDetection: { maxConsecutiveSameStep: 3, action: 'abort' },
initialStep: 'loop-step',
steps: [
makeStep('loop-step', {
initialMovement: 'loop-step',
movements: [
makeMovement('loop-step', {
rules: [makeRule('continue', 'loop-step')],
}),
],

View File

@ -7,13 +7,13 @@
* - AI review reject and fix
* - ABORT transition
* - Event emissions
* - Step output tracking
* - Movement output tracking
* - Config validation
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, rmSync } from 'node:fs';
import type { WorkflowConfig, WorkflowStep } from '../core/models/index.js';
import type { WorkflowConfig, WorkflowMovement } from '../core/models/index.js';
// --- Mock setup (must be before imports that use these modules) ---
@ -42,7 +42,7 @@ import { WorkflowEngine } from '../core/workflow/index.js';
import { runAgent } from '../agents/runner.js';
import {
makeResponse,
makeStep,
makeMovement,
makeRule,
buildDefaultWorkflowConfig,
mockRunAgentSequence,
@ -101,7 +101,7 @@ describe('WorkflowEngine Integration: Happy Path', () => {
expect(state.status).toBe('completed');
expect(state.iteration).toBe(5); // plan, implement, ai_review, reviewers, supervise
expect(completeFn).toHaveBeenCalledOnce();
expect(vi.mocked(runAgent)).toHaveBeenCalledTimes(6); // 4 normal + 2 parallel sub-steps
expect(vi.mocked(runAgent)).toHaveBeenCalledTimes(6); // 4 normal + 2 parallel sub-movements
});
});
@ -192,7 +192,7 @@ describe('WorkflowEngine Integration: Happy Path', () => {
// 4. ABORT transition
// =====================================================
describe('ABORT transition', () => {
it('should abort when step transitions to ABORT', async () => {
it('should abort when movement transitions to ABORT', async () => {
const config = buildDefaultWorkflowConfig();
const engine = new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
@ -219,7 +219,7 @@ describe('WorkflowEngine Integration: Happy Path', () => {
// 5. Event emissions
// =====================================================
describe('Event emissions', () => {
it('should emit step:start and step:complete for each step', async () => {
it('should emit movement:start and movement:complete for each movement', async () => {
const config = buildDefaultWorkflowConfig();
const engine = new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
@ -244,26 +244,26 @@ describe('WorkflowEngine Integration: Happy Path', () => {
const startFn = vi.fn();
const completeFn = vi.fn();
engine.on('step:start', startFn);
engine.on('step:complete', completeFn);
engine.on('movement:start', startFn);
engine.on('movement:complete', completeFn);
await engine.run();
// 5 steps: plan, implement, ai_review, reviewers, supervise
// 5 movements: plan, implement, ai_review, reviewers, supervise
expect(startFn).toHaveBeenCalledTimes(5);
expect(completeFn).toHaveBeenCalledTimes(5);
const startedSteps = startFn.mock.calls.map(call => (call[0] as WorkflowStep).name);
expect(startedSteps).toEqual(['plan', 'implement', 'ai_review', 'reviewers', 'supervise']);
const startedMovements = startFn.mock.calls.map(call => (call[0] as WorkflowMovement).name);
expect(startedMovements).toEqual(['plan', 'implement', 'ai_review', 'reviewers', 'supervise']);
});
it('should pass instruction to step:start for normal steps', async () => {
it('should pass instruction to movement:start for normal movements', async () => {
const simpleConfig: WorkflowConfig = {
name: 'test',
maxIterations: 10,
initialStep: 'plan',
steps: [
makeStep('plan', {
initialMovement: 'plan',
movements: [
makeMovement('plan', {
rules: [makeRule('done', 'COMPLETE')],
}),
],
@ -278,18 +278,18 @@ describe('WorkflowEngine Integration: Happy Path', () => {
]);
const startFn = vi.fn();
engine.on('step:start', startFn);
engine.on('movement:start', startFn);
await engine.run();
expect(startFn).toHaveBeenCalledTimes(1);
// step:start should receive (step, iteration, instruction)
const [_step, _iteration, instruction] = startFn.mock.calls[0];
// movement:start should receive (movement, iteration, instruction)
const [_movement, _iteration, instruction] = startFn.mock.calls[0];
expect(typeof instruction).toBe('string');
expect(instruction.length).toBeGreaterThan(0);
});
it('should pass empty instruction to step:start for parallel steps', async () => {
it('should pass empty instruction to movement:start for parallel movements', async () => {
const config = buildDefaultWorkflowConfig();
const engine = new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
@ -313,16 +313,16 @@ describe('WorkflowEngine Integration: Happy Path', () => {
]);
const startFn = vi.fn();
engine.on('step:start', startFn);
engine.on('movement:start', startFn);
await engine.run();
// Find the "reviewers" step:start call (parallel step)
// Find the "reviewers" movement:start call (parallel movement)
const reviewersCall = startFn.mock.calls.find(
(call) => (call[0] as WorkflowStep).name === 'reviewers'
(call) => (call[0] as WorkflowMovement).name === 'reviewers'
);
expect(reviewersCall).toBeDefined();
// Parallel steps emit empty string for instruction
// Parallel movements emit empty string for instruction
const [, , instruction] = reviewersCall!;
expect(instruction).toBe('');
});
@ -348,10 +348,10 @@ describe('WorkflowEngine Integration: Happy Path', () => {
});
// =====================================================
// 6. Step output tracking
// 6. Movement output tracking
// =====================================================
describe('Step output tracking', () => {
it('should store outputs for all executed steps', async () => {
describe('Movement output tracking', () => {
it('should store outputs for all executed movements', async () => {
const config = buildDefaultWorkflowConfig();
const engine = new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
@ -376,10 +376,10 @@ describe('WorkflowEngine Integration: Happy Path', () => {
const state = await engine.run();
expect(state.stepOutputs.get('plan')!.content).toBe('Plan output');
expect(state.stepOutputs.get('implement')!.content).toBe('Implement output');
expect(state.stepOutputs.get('ai_review')!.content).toBe('AI review output');
expect(state.stepOutputs.get('supervise')!.content).toBe('Supervise output');
expect(state.movementOutputs.get('plan')!.content).toBe('Plan output');
expect(state.movementOutputs.get('implement')!.content).toBe('Implement output');
expect(state.movementOutputs.get('ai_review')!.content).toBe('AI review output');
expect(state.movementOutputs.get('supervise')!.content).toBe('Supervise output');
});
});
@ -391,9 +391,9 @@ describe('WorkflowEngine Integration: Happy Path', () => {
const simpleConfig: WorkflowConfig = {
name: 'test',
maxIterations: 10,
initialStep: 'plan',
steps: [
makeStep('plan', {
initialMovement: 'plan',
movements: [
makeMovement('plan', {
rules: [makeRule('done', 'COMPLETE')],
}),
],
@ -424,7 +424,7 @@ describe('WorkflowEngine Integration: Happy Path', () => {
);
});
it('should emit phase events for all steps in happy path', async () => {
it('should emit phase events for all movements in happy path', async () => {
const config = buildDefaultWorkflowConfig();
const engine = new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
@ -454,7 +454,7 @@ describe('WorkflowEngine Integration: Happy Path', () => {
await engine.run();
// 4 normal steps + 2 parallel sub-steps = 6 Phase 1 invocations
// 4 normal movements + 2 parallel sub-movements = 6 Phase 1 invocations
expect(phaseStartFn).toHaveBeenCalledTimes(6);
expect(phaseCompleteFn).toHaveBeenCalledTimes(6);
@ -470,21 +470,21 @@ describe('WorkflowEngine Integration: Happy Path', () => {
// 8. Config validation
// =====================================================
describe('Config validation', () => {
it('should throw when initial step does not exist', () => {
const config = buildDefaultWorkflowConfig({ initialStep: 'nonexistent' });
it('should throw when initial movement does not exist', () => {
const config = buildDefaultWorkflowConfig({ initialMovement: 'nonexistent' });
expect(() => {
new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
}).toThrow('Unknown step: nonexistent');
}).toThrow('Unknown movement: nonexistent');
});
it('should throw when rule references nonexistent step', () => {
it('should throw when rule references nonexistent movement', () => {
const config: WorkflowConfig = {
name: 'test',
maxIterations: 10,
initialStep: 'step1',
steps: [
makeStep('step1', {
initialMovement: 'step1',
movements: [
makeMovement('step1', {
rules: [makeRule('done', 'nonexistent_step')],
}),
],

View File

@ -1,10 +1,10 @@
/**
* WorkflowEngine integration tests: parallel step aggregation.
* WorkflowEngine integration tests: parallel movement aggregation.
*
* Covers:
* - Aggregated output format (## headers and --- separators)
* - Individual sub-step output storage
* - Concurrent execution of sub-steps
* - Individual sub-movement output storage
* - Concurrent execution of sub-movements
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@ -44,7 +44,7 @@ import {
applyDefaultMocks,
} from './engine-test-helpers.js';
describe('WorkflowEngine Integration: Parallel Step Aggregation', () => {
describe('WorkflowEngine Integration: Parallel Movement Aggregation', () => {
let tmpDir: string;
beforeEach(() => {
@ -59,7 +59,7 @@ describe('WorkflowEngine Integration: Parallel Step Aggregation', () => {
}
});
it('should aggregate sub-step outputs with ## headers and --- separators', async () => {
it('should aggregate sub-movement outputs with ## headers and --- separators', async () => {
const config = buildDefaultWorkflowConfig();
const engine = new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
@ -86,7 +86,7 @@ describe('WorkflowEngine Integration: Parallel Step Aggregation', () => {
expect(state.status).toBe('completed');
const reviewersOutput = state.stepOutputs.get('reviewers');
const reviewersOutput = state.movementOutputs.get('reviewers');
expect(reviewersOutput).toBeDefined();
expect(reviewersOutput!.content).toContain('## arch-review');
expect(reviewersOutput!.content).toContain('Architecture review content');
@ -96,7 +96,7 @@ describe('WorkflowEngine Integration: Parallel Step Aggregation', () => {
expect(reviewersOutput!.matchedRuleMethod).toBe('aggregate');
});
it('should store individual sub-step outputs in stepOutputs', async () => {
it('should store individual sub-movement outputs in movementOutputs', async () => {
const config = buildDefaultWorkflowConfig();
const engine = new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
@ -121,14 +121,14 @@ describe('WorkflowEngine Integration: Parallel Step Aggregation', () => {
const state = await engine.run();
expect(state.stepOutputs.has('arch-review')).toBe(true);
expect(state.stepOutputs.has('security-review')).toBe(true);
expect(state.stepOutputs.has('reviewers')).toBe(true);
expect(state.stepOutputs.get('arch-review')!.content).toBe('Arch content');
expect(state.stepOutputs.get('security-review')!.content).toBe('Sec content');
expect(state.movementOutputs.has('arch-review')).toBe(true);
expect(state.movementOutputs.has('security-review')).toBe(true);
expect(state.movementOutputs.has('reviewers')).toBe(true);
expect(state.movementOutputs.get('arch-review')!.content).toBe('Arch content');
expect(state.movementOutputs.get('security-review')!.content).toBe('Sec content');
});
it('should execute sub-steps concurrently (both runAgent calls happen)', async () => {
it('should execute sub-movements concurrently (both runAgent calls happen)', async () => {
const config = buildDefaultWorkflowConfig();
const engine = new WorkflowEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
@ -153,7 +153,7 @@ describe('WorkflowEngine Integration: Parallel Step Aggregation', () => {
await engine.run();
// 6 total: 4 normal + 2 parallel sub-steps
// 6 total: 4 normal + 2 parallel sub-movements
expect(vi.mocked(runAgent)).toHaveBeenCalledTimes(6);
const calledAgents = vi.mocked(runAgent).mock.calls.map(call => call[0]);

View File

@ -1,5 +1,5 @@
/**
* Tests for engine report event emission (step:report)
* Tests for engine report event emission (movement:report)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@ -9,50 +9,50 @@ import { tmpdir } from 'node:os';
import { EventEmitter } from 'node:events';
import { existsSync } from 'node:fs';
import { isReportObjectConfig } from '../core/workflow/index.js';
import type { WorkflowStep, ReportObjectConfig, ReportConfig } from '../core/models/index.js';
import type { WorkflowMovement, ReportObjectConfig, ReportConfig } from '../core/models/index.js';
/**
* Extracted emitStepReports logic for unit testing.
* Mirrors engine.ts emitStepReports + emitIfReportExists.
* Extracted emitMovementReports logic for unit testing.
* Mirrors engine.ts emitMovementReports + emitIfReportExists.
*
* reportDir already includes the `.takt/reports/` prefix (set by engine constructor).
*/
function emitStepReports(
function emitMovementReports(
emitter: EventEmitter,
step: WorkflowStep,
movement: WorkflowMovement,
reportDir: string,
projectCwd: string,
): void {
if (!step.report || !reportDir) return;
if (!movement.report || !reportDir) return;
const baseDir = join(projectCwd, reportDir);
if (typeof step.report === 'string') {
emitIfReportExists(emitter, step, baseDir, step.report);
} else if (isReportObjectConfig(step.report)) {
emitIfReportExists(emitter, step, baseDir, step.report.name);
if (typeof movement.report === 'string') {
emitIfReportExists(emitter, movement, baseDir, movement.report);
} else if (isReportObjectConfig(movement.report)) {
emitIfReportExists(emitter, movement, baseDir, movement.report.name);
} else {
for (const rc of step.report) {
emitIfReportExists(emitter, step, baseDir, rc.path);
for (const rc of movement.report) {
emitIfReportExists(emitter, movement, baseDir, rc.path);
}
}
}
function emitIfReportExists(
emitter: EventEmitter,
step: WorkflowStep,
movement: WorkflowMovement,
baseDir: string,
fileName: string,
): void {
const filePath = join(baseDir, fileName);
if (existsSync(filePath)) {
emitter.emit('step:report', step, filePath, fileName);
emitter.emit('movement:report', movement, filePath, fileName);
}
}
/** Create a minimal WorkflowStep for testing */
function createStep(overrides: Partial<WorkflowStep> = {}): WorkflowStep {
/** Create a minimal WorkflowMovement for testing */
function createMovement(overrides: Partial<WorkflowMovement> = {}): WorkflowMovement {
return {
name: 'test-step',
name: 'test-movement',
agent: 'coder',
agentDisplayName: 'Coder',
instructionTemplate: '',
@ -61,7 +61,7 @@ function createStep(overrides: Partial<WorkflowStep> = {}): WorkflowStep {
};
}
describe('emitStepReports', () => {
describe('emitMovementReports', () => {
let tmpDir: string;
let reportBaseDir: string;
// reportDir now includes .takt/reports/ prefix (matches engine constructor behavior)
@ -77,100 +77,100 @@ describe('emitStepReports', () => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('should emit step:report when string report file exists', () => {
// Given: a step with string report and the file exists
const step = createStep({ report: 'plan.md' });
it('should emit movement:report when string report file exists', () => {
// Given: a movement with string report and the file exists
const movement = createMovement({ report: 'plan.md' });
writeFileSync(join(reportBaseDir, 'plan.md'), '# Plan', 'utf-8');
const emitter = new EventEmitter();
const handler = vi.fn();
emitter.on('step:report', handler);
emitter.on('movement:report', handler);
// When
emitStepReports(emitter, step, reportDirName, tmpDir);
emitMovementReports(emitter, movement, reportDirName, tmpDir);
// Then
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(step, join(reportBaseDir, 'plan.md'), 'plan.md');
expect(handler).toHaveBeenCalledWith(movement, join(reportBaseDir, 'plan.md'), 'plan.md');
});
it('should not emit when string report file does not exist', () => {
// Given: a step with string report but file doesn't exist
const step = createStep({ report: 'missing.md' });
// Given: a movement with string report but file doesn't exist
const movement = createMovement({ report: 'missing.md' });
const emitter = new EventEmitter();
const handler = vi.fn();
emitter.on('step:report', handler);
emitter.on('movement:report', handler);
// When
emitStepReports(emitter, step, reportDirName, tmpDir);
emitMovementReports(emitter, movement, reportDirName, tmpDir);
// Then
expect(handler).not.toHaveBeenCalled();
});
it('should emit step:report when ReportObjectConfig report file exists', () => {
// Given: a step with ReportObjectConfig and the file exists
it('should emit movement:report when ReportObjectConfig report file exists', () => {
// Given: a movement with ReportObjectConfig and the file exists
const report: ReportObjectConfig = { name: '03-review.md', format: '# Review' };
const step = createStep({ report });
const movement = createMovement({ report });
writeFileSync(join(reportBaseDir, '03-review.md'), '# Review\nOK', 'utf-8');
const emitter = new EventEmitter();
const handler = vi.fn();
emitter.on('step:report', handler);
emitter.on('movement:report', handler);
// When
emitStepReports(emitter, step, reportDirName, tmpDir);
emitMovementReports(emitter, movement, reportDirName, tmpDir);
// Then
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(step, join(reportBaseDir, '03-review.md'), '03-review.md');
expect(handler).toHaveBeenCalledWith(movement, join(reportBaseDir, '03-review.md'), '03-review.md');
});
it('should emit for each existing file in ReportConfig[] array', () => {
// Given: a step with array report, two files exist, one missing
// Given: a movement with array report, two files exist, one missing
const report: ReportConfig[] = [
{ label: 'Scope', path: '01-scope.md' },
{ label: 'Decisions', path: '02-decisions.md' },
{ label: 'Missing', path: '03-missing.md' },
];
const step = createStep({ report });
const movement = createMovement({ report });
writeFileSync(join(reportBaseDir, '01-scope.md'), '# Scope', 'utf-8');
writeFileSync(join(reportBaseDir, '02-decisions.md'), '# Decisions', 'utf-8');
const emitter = new EventEmitter();
const handler = vi.fn();
emitter.on('step:report', handler);
emitter.on('movement:report', handler);
// When
emitStepReports(emitter, step, reportDirName, tmpDir);
emitMovementReports(emitter, movement, reportDirName, tmpDir);
// Then: emitted for scope and decisions, not for missing
expect(handler).toHaveBeenCalledTimes(2);
expect(handler).toHaveBeenCalledWith(step, join(reportBaseDir, '01-scope.md'), '01-scope.md');
expect(handler).toHaveBeenCalledWith(step, join(reportBaseDir, '02-decisions.md'), '02-decisions.md');
expect(handler).toHaveBeenCalledWith(movement, join(reportBaseDir, '01-scope.md'), '01-scope.md');
expect(handler).toHaveBeenCalledWith(movement, join(reportBaseDir, '02-decisions.md'), '02-decisions.md');
});
it('should not emit when step has no report', () => {
// Given: a step without report
const step = createStep({ report: undefined });
it('should not emit when movement has no report', () => {
// Given: a movement without report
const movement = createMovement({ report: undefined });
const emitter = new EventEmitter();
const handler = vi.fn();
emitter.on('step:report', handler);
emitter.on('movement:report', handler);
// When
emitStepReports(emitter, step, reportDirName, tmpDir);
emitMovementReports(emitter, movement, reportDirName, tmpDir);
// Then
expect(handler).not.toHaveBeenCalled();
});
it('should not emit when reportDir is empty', () => {
// Given: a step with report but empty reportDir
const step = createStep({ report: 'plan.md' });
// Given: a movement with report but empty reportDir
const movement = createMovement({ report: 'plan.md' });
writeFileSync(join(reportBaseDir, 'plan.md'), '# Plan', 'utf-8');
const emitter = new EventEmitter();
const handler = vi.fn();
emitter.on('step:report', handler);
emitter.on('movement:report', handler);
// When: empty reportDir
emitStepReports(emitter, step, '', tmpDir);
emitMovementReports(emitter, movement, '', tmpDir);
// Then
expect(handler).not.toHaveBeenCalled();

View File

@ -10,7 +10,7 @@ import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import type { WorkflowConfig, WorkflowStep, AgentResponse, WorkflowRule } from '../core/models/index.js';
import type { WorkflowConfig, WorkflowMovement, AgentResponse, WorkflowRule } from '../core/models/index.js';
// --- Mock imports (consumers must call vi.mock before importing this) ---
@ -37,7 +37,7 @@ export function makeRule(condition: string, next: string, extra: Partial<Workflo
return { condition, next, ...extra };
}
export function makeStep(name: string, overrides: Partial<WorkflowStep> = {}): WorkflowStep {
export function makeMovement(name: string, overrides: Partial<WorkflowMovement> = {}): WorkflowMovement {
return {
name,
agent: `../agents/${name}.md`,
@ -53,14 +53,14 @@ export function makeStep(name: string, overrides: Partial<WorkflowStep> = {}): W
* plan implement ai_review (ai_fix) reviewers(parallel) (fix) supervise
*/
export function buildDefaultWorkflowConfig(overrides: Partial<WorkflowConfig> = {}): WorkflowConfig {
const archReviewSubStep = makeStep('arch-review', {
const archReviewSubMovement = makeMovement('arch-review', {
rules: [
makeRule('approved', 'COMPLETE'),
makeRule('needs_fix', 'fix'),
],
});
const securityReviewSubStep = makeStep('security-review', {
const securityReviewSubMovement = makeMovement('security-review', {
rules: [
makeRule('approved', 'COMPLETE'),
makeRule('needs_fix', 'fix'),
@ -71,34 +71,34 @@ export function buildDefaultWorkflowConfig(overrides: Partial<WorkflowConfig> =
name: 'test-default',
description: 'Test workflow',
maxIterations: 30,
initialStep: 'plan',
steps: [
makeStep('plan', {
initialMovement: 'plan',
movements: [
makeMovement('plan', {
rules: [
makeRule('Requirements are clear', 'implement'),
makeRule('Requirements unclear', 'ABORT'),
],
}),
makeStep('implement', {
makeMovement('implement', {
rules: [
makeRule('Implementation complete', 'ai_review'),
makeRule('Cannot proceed', 'plan'),
],
}),
makeStep('ai_review', {
makeMovement('ai_review', {
rules: [
makeRule('No AI-specific issues', 'reviewers'),
makeRule('AI-specific issues found', 'ai_fix'),
],
}),
makeStep('ai_fix', {
makeMovement('ai_fix', {
rules: [
makeRule('AI issues fixed', 'reviewers'),
makeRule('Cannot proceed', 'plan'),
],
}),
makeStep('reviewers', {
parallel: [archReviewSubStep, securityReviewSubStep],
makeMovement('reviewers', {
parallel: [archReviewSubMovement, securityReviewSubMovement],
rules: [
makeRule('all("approved")', 'supervise', {
isAggregateCondition: true,
@ -112,13 +112,13 @@ export function buildDefaultWorkflowConfig(overrides: Partial<WorkflowConfig> =
}),
],
}),
makeStep('fix', {
makeMovement('fix', {
rules: [
makeRule('Fix complete', 'reviewers'),
makeRule('Cannot proceed', 'plan'),
],
}),
makeStep('supervise', {
makeMovement('supervise', {
rules: [
makeRule('All checks passed', 'COMPLETE'),
makeRule('Requirements unmet', 'plan'),

View File

@ -38,7 +38,7 @@ import { WorkflowEngine } from '../core/workflow/index.js';
import { runReportPhase } from '../core/workflow/index.js';
import {
makeResponse,
makeStep,
makeMovement,
makeRule,
mockRunAgentSequence,
mockDetectMatchedRuleSequence,
@ -69,9 +69,9 @@ function buildSimpleConfig(): WorkflowConfig {
name: 'worktree-test',
description: 'Test workflow for worktree',
maxIterations: 10,
initialStep: 'review',
steps: [
makeStep('review', {
initialMovement: 'review',
movements: [
makeMovement('review', {
report: '00-review.md',
rules: [
makeRule('approved', 'COMPLETE'),
@ -132,14 +132,14 @@ describe('WorkflowEngine: worktree reportDir resolution', () => {
});
it('should pass projectCwd-based reportDir to buildInstruction (used by {report_dir} placeholder)', async () => {
// Given: worktree environment with a step that uses {report_dir} in template
// Given: worktree environment with a movement that uses {report_dir} in template
const config: WorkflowConfig = {
name: 'worktree-test',
description: 'Test',
maxIterations: 10,
initialStep: 'review',
steps: [
makeStep('review', {
initialMovement: 'review',
movements: [
makeMovement('review', {
instructionTemplate: 'Write report to {report_dir}',
report: '00-review.md',
rules: [

View File

@ -102,7 +102,7 @@ describe('label integrity', () => {
it('contains all expected workflow keys in en', () => {
expect(() => getLabel('workflow.iterationLimit.maxReached')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.currentStep')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.currentMovement')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.continueQuestion')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.continueLabel')).not.toThrow();
expect(() => getLabel('workflow.iterationLimit.continueDescription')).not.toThrow();

View File

@ -8,9 +8,7 @@ import {
isReportObjectConfig,
ReportInstructionBuilder,
StatusJudgmentBuilder,
buildExecutionMetadata,
renderExecutionMetadata,
generateStatusRulesFromRules,
generateStatusRulesComponents,
type ReportInstructionContext,
type StatusJudgmentContext,
type InstructionContext,
@ -44,8 +42,9 @@ function createMinimalContext(overrides: Partial<InstructionContext> = {}): Inst
task: 'Test task',
iteration: 1,
maxIterations: 10,
stepIteration: 1,
movementIteration: 1,
cwd: '/project',
projectCwd: '/project',
userInputs: [],
...overrides,
};
@ -186,127 +185,7 @@ describe('instruction-builder', () => {
});
});
describe('buildExecutionMetadata', () => {
it('should set workingDirectory', () => {
const context = createMinimalContext({ cwd: '/project' });
const metadata = buildExecutionMetadata(context);
expect(metadata.workingDirectory).toBe('/project');
});
it('should use cwd as workingDirectory even in worktree mode', () => {
const context = createMinimalContext({
cwd: '/worktree-path',
projectCwd: '/project-path',
});
const metadata = buildExecutionMetadata(context);
expect(metadata.workingDirectory).toBe('/worktree-path');
});
it('should default language to en when not specified', () => {
const context = createMinimalContext({ cwd: '/project' });
const metadata = buildExecutionMetadata(context);
expect(metadata.language).toBe('en');
});
it('should propagate language from context', () => {
const context = createMinimalContext({ cwd: '/project', language: 'ja' });
const metadata = buildExecutionMetadata(context);
expect(metadata.language).toBe('ja');
});
it('should propagate edit field when provided', () => {
const context = createMinimalContext({ cwd: '/project' });
expect(buildExecutionMetadata(context, true).edit).toBe(true);
expect(buildExecutionMetadata(context, false).edit).toBe(false);
});
it('should leave edit undefined when not provided', () => {
const context = createMinimalContext({ cwd: '/project' });
const metadata = buildExecutionMetadata(context);
expect(metadata.edit).toBeUndefined();
});
});
describe('renderExecutionMetadata', () => {
it('should render Working Directory and Execution Rules', () => {
const rendered = renderExecutionMetadata({ workingDirectory: '/project', language: 'en' });
expect(rendered).toContain('## Execution Context');
expect(rendered).toContain('- Working Directory: /project');
expect(rendered).toContain('## Execution Rules');
expect(rendered).toContain('Do NOT run git commit');
expect(rendered).toContain('Do NOT use `cd`');
});
it('should end with a trailing empty line', () => {
const rendered = renderExecutionMetadata({ workingDirectory: '/project', language: 'en' });
expect(rendered).toMatch(/\n$/);
});
it('should render in Japanese when language is ja', () => {
const rendered = renderExecutionMetadata({ workingDirectory: '/project', language: 'ja' });
expect(rendered).toContain('## 実行コンテキスト');
expect(rendered).toContain('- 作業ディレクトリ: /project');
expect(rendered).toContain('## 実行ルール');
expect(rendered).toContain('git commit を実行しないでください');
expect(rendered).toContain('cd` を使用しないでください');
});
it('should include English note only for en, not for ja', () => {
const enRendered = renderExecutionMetadata({ workingDirectory: '/project', language: 'en' });
const jaRendered = renderExecutionMetadata({ workingDirectory: '/project', language: 'ja' });
expect(enRendered).toContain('Note:');
expect(jaRendered).not.toContain('Note:');
});
it('should include edit enabled prompt when edit is true (en)', () => {
const rendered = renderExecutionMetadata({ workingDirectory: '/project', language: 'en', edit: true });
expect(rendered).toContain('Editing is ENABLED');
expect(rendered).not.toContain('Editing is DISABLED');
});
it('should include edit disabled prompt when edit is false (en)', () => {
const rendered = renderExecutionMetadata({ workingDirectory: '/project', language: 'en', edit: false });
expect(rendered).toContain('Editing is DISABLED');
expect(rendered).not.toContain('Editing is ENABLED');
});
it('should not include edit prompt when edit is undefined', () => {
const rendered = renderExecutionMetadata({ workingDirectory: '/project', language: 'en' });
expect(rendered).not.toContain('Editing is ENABLED');
expect(rendered).not.toContain('Editing is DISABLED');
expect(rendered).not.toContain('編集が許可');
expect(rendered).not.toContain('編集が禁止');
});
it('should render edit enabled prompt in Japanese when language is ja', () => {
const rendered = renderExecutionMetadata({ workingDirectory: '/project', language: 'ja', edit: true });
expect(rendered).toContain('編集が許可されています');
expect(rendered).not.toContain('編集が禁止');
});
it('should render edit disabled prompt in Japanese when language is ja', () => {
const rendered = renderExecutionMetadata({ workingDirectory: '/project', language: 'ja', edit: false });
expect(rendered).toContain('編集が禁止されています');
expect(rendered).not.toContain('編集が許可');
});
});
describe('generateStatusRulesFromRules', () => {
describe('generateStatusRulesComponents', () => {
const rules: WorkflowRule[] = [
{ condition: '要件が明確で実装可能', next: 'implement' },
{ condition: 'ユーザーが質問をしている', next: 'COMPLETE' },
@ -314,12 +193,11 @@ describe('instruction-builder', () => {
];
it('should generate criteria table with numbered tags (ja)', () => {
const result = generateStatusRulesFromRules('plan', rules, 'ja');
const result = generateStatusRulesComponents('plan', rules, 'ja');
expect(result).toContain('## 判定基準');
expect(result).toContain('| 1 | 要件が明確で実装可能 | `[PLAN:1]` |');
expect(result).toContain('| 2 | ユーザーが質問をしている | `[PLAN:2]` |');
expect(result).toContain('| 3 | 要件が不明確、情報不足 | `[PLAN:3]` |');
expect(result.criteriaTable).toContain('| 1 | 要件が明確で実装可能 | `[PLAN:1]` |');
expect(result.criteriaTable).toContain('| 2 | ユーザーが質問をしている | `[PLAN:2]` |');
expect(result.criteriaTable).toContain('| 3 | 要件が不明確、情報不足 | `[PLAN:3]` |');
});
it('should generate criteria table with numbered tags (en)', () => {
@ -327,47 +205,46 @@ describe('instruction-builder', () => {
{ condition: 'Requirements are clear', next: 'implement' },
{ condition: 'User is asking a question', next: 'COMPLETE' },
];
const result = generateStatusRulesFromRules('plan', enRules, 'en');
const result = generateStatusRulesComponents('plan', enRules, 'en');
expect(result).toContain('## Decision Criteria');
expect(result).toContain('| 1 | Requirements are clear | `[PLAN:1]` |');
expect(result).toContain('| 2 | User is asking a question | `[PLAN:2]` |');
expect(result.criteriaTable).toContain('| 1 | Requirements are clear | `[PLAN:1]` |');
expect(result.criteriaTable).toContain('| 2 | User is asking a question | `[PLAN:2]` |');
});
it('should generate output format section with condition labels', () => {
const result = generateStatusRulesFromRules('plan', rules, 'ja');
it('should generate output list with condition labels', () => {
const result = generateStatusRulesComponents('plan', rules, 'ja');
expect(result).toContain('## 出力フォーマット');
expect(result).toContain('`[PLAN:1]` — 要件が明確で実装可能');
expect(result).toContain('`[PLAN:2]` — ユーザーが質問をしている');
expect(result).toContain('`[PLAN:3]` — 要件が不明確、情報不足');
expect(result.outputList).toContain('`[PLAN:1]` — 要件が明確で実装可能');
expect(result.outputList).toContain('`[PLAN:2]` — ユーザーが質問をしている');
expect(result.outputList).toContain('`[PLAN:3]` — 要件が不明確、情報不足');
});
it('should generate appendix template section when rules have appendix', () => {
const result = generateStatusRulesFromRules('plan', rules, 'ja');
it('should generate appendix content when rules have appendix', () => {
const result = generateStatusRulesComponents('plan', rules, 'ja');
expect(result).toContain('### 追加出力テンプレート');
expect(result).toContain('`[PLAN:3]`');
expect(result).toContain('確認事項:');
expect(result).toContain('- {質問1}');
expect(result.hasAppendix).toBe(true);
expect(result.appendixContent).toContain('[[PLAN:3]]');
expect(result.appendixContent).toContain('確認事項:');
expect(result.appendixContent).toContain('- {質問1}');
});
it('should not generate appendix section when no rules have appendix', () => {
it('should not generate appendix when no rules have appendix', () => {
const noAppendixRules: WorkflowRule[] = [
{ condition: 'Done', next: 'review' },
{ condition: 'Blocked', next: 'plan' },
];
const result = generateStatusRulesFromRules('implement', noAppendixRules, 'en');
const result = generateStatusRulesComponents('implement', noAppendixRules, 'en');
expect(result).not.toContain('Appendix Template');
expect(result.hasAppendix).toBe(false);
expect(result.appendixContent).toBe('');
});
it('should uppercase step name in tags', () => {
const result = generateStatusRulesFromRules('ai_review', [
const result = generateStatusRulesComponents('ai_review', [
{ condition: 'No issues', next: 'supervise' },
], 'en');
expect(result).toContain('`[AI_REVIEW:1]`');
expect(result.criteriaTable).toContain('`[AI_REVIEW:1]`');
});
it('should omit interactive-only rules when interactive is false', () => {
@ -376,12 +253,12 @@ describe('instruction-builder', () => {
{ condition: 'User input required', next: 'implement', interactiveOnly: true },
{ condition: 'Blocked', next: 'plan' },
];
const result = generateStatusRulesFromRules('implement', filteredRules, 'en', { interactive: false });
const result = generateStatusRulesComponents('implement', filteredRules, 'en', { interactive: false });
expect(result).toContain('`[IMPLEMENT:1]`');
expect(result).toContain('`[IMPLEMENT:3]`');
expect(result).not.toContain('User input required');
expect(result).not.toContain('`[IMPLEMENT:2]`');
expect(result.criteriaTable).toContain('`[IMPLEMENT:1]`');
expect(result.criteriaTable).toContain('`[IMPLEMENT:3]`');
expect(result.criteriaTable).not.toContain('User input required');
expect(result.criteriaTable).not.toContain('`[IMPLEMENT:2]`');
});
});
@ -397,9 +274,8 @@ describe('instruction-builder', () => {
const result = buildInstruction(step, context);
expect(result).toContain('Decision Criteria');
expect(result).toContain('[PLAN:1]');
expect(result).toContain('[PLAN:2]');
// Status rules are no longer injected in Phase 1 (only in Phase 3)
expect(result).not.toContain('Decision Criteria');
});
it('should not add status rules when rules do not exist', () => {
@ -429,7 +305,7 @@ describe('instruction-builder', () => {
const context = createMinimalContext({
iteration: 3,
maxIterations: 20,
stepIteration: 2,
movementIteration: 2,
language: 'en',
});
@ -437,8 +313,8 @@ describe('instruction-builder', () => {
expect(result).toContain('## Workflow Context');
expect(result).toContain('- Iteration: 3/20');
expect(result).toContain('- Step Iteration: 2');
expect(result).toContain('- Step: implement');
expect(result).toContain('- Movement Iteration: 2');
expect(result).toContain('- Movement: implement');
});
it('should include report info in Phase 1 when step has report', () => {
@ -495,13 +371,13 @@ describe('instruction-builder', () => {
it('should render Japanese step iteration suffix', () => {
const step = createMinimalStep('Do work');
const context = createMinimalContext({
stepIteration: 3,
movementIteration: 3,
language: 'ja',
});
const result = buildInstruction(step, context);
expect(result).toContain('- Step Iteration: 3このステップの実行回数)');
expect(result).toContain('- Movement Iteration: 3このムーブメントの実行回数)');
});
it('should include workflow structure when workflowSteps is provided', () => {
@ -509,21 +385,21 @@ describe('instruction-builder', () => {
step.name = 'implement';
const context = createMinimalContext({
language: 'en',
workflowSteps: [
workflowMovements: [
{ name: 'plan' },
{ name: 'implement' },
{ name: 'review' },
],
currentStepIndex: 1,
currentMovementIndex: 1,
});
const result = buildInstruction(step, context);
expect(result).toContain('This workflow consists of 3 steps:');
expect(result).toContain('- Step 1: plan');
expect(result).toContain('- Step 2: implement');
expect(result).toContain('This workflow consists of 3 movements:');
expect(result).toContain('- Movement 1: plan');
expect(result).toContain('- Movement 2: implement');
expect(result).toContain('← current');
expect(result).toContain('- Step 3: review');
expect(result).toContain('- Movement 3: review');
});
it('should mark current step with marker', () => {
@ -531,17 +407,17 @@ describe('instruction-builder', () => {
step.name = 'plan';
const context = createMinimalContext({
language: 'en',
workflowSteps: [
workflowMovements: [
{ name: 'plan' },
{ name: 'implement' },
],
currentStepIndex: 0,
currentMovementIndex: 0,
});
const result = buildInstruction(step, context);
expect(result).toContain('- Step 1: plan ← current');
expect(result).not.toContain('- Step 2: implement ← current');
expect(result).toContain('- Movement 1: plan ← current');
expect(result).not.toContain('- Movement 2: implement ← current');
});
it('should include description in parentheses when provided', () => {
@ -549,16 +425,16 @@ describe('instruction-builder', () => {
step.name = 'plan';
const context = createMinimalContext({
language: 'ja',
workflowSteps: [
workflowMovements: [
{ name: 'plan', description: 'タスクを分析し実装計画を作成する' },
{ name: 'implement' },
],
currentStepIndex: 0,
currentMovementIndex: 0,
});
const result = buildInstruction(step, context);
expect(result).toContain('- Step 1: planタスクを分析し実装計画を作成する ← 現在');
expect(result).toContain('- Movement 1: planタスクを分析し実装計画を作成する ← 現在');
});
it('should skip workflow structure when workflowSteps is not provided', () => {
@ -574,8 +450,8 @@ describe('instruction-builder', () => {
const step = createMinimalStep('Do work');
const context = createMinimalContext({
language: 'en',
workflowSteps: [],
currentStepIndex: -1,
workflowMovements: [],
currentMovementIndex: -1,
});
const result = buildInstruction(step, context);
@ -588,34 +464,34 @@ describe('instruction-builder', () => {
step.name = 'plan';
const context = createMinimalContext({
language: 'ja',
workflowSteps: [
workflowMovements: [
{ name: 'plan' },
{ name: 'implement' },
],
currentStepIndex: 0,
currentMovementIndex: 0,
});
const result = buildInstruction(step, context);
expect(result).toContain('このワークフローは2ステップで構成されています:');
expect(result).toContain('このワークフローは2ムーブメントで構成されています:');
expect(result).toContain('← 現在');
});
it('should not show current marker when currentStepIndex is -1', () => {
it('should not show current marker when currentMovementIndex is -1', () => {
const step = createMinimalStep('Do work');
step.name = 'sub-step';
const context = createMinimalContext({
language: 'en',
workflowSteps: [
workflowMovements: [
{ name: 'plan' },
{ name: 'implement' },
],
currentStepIndex: -1,
currentMovementIndex: -1,
});
const result = buildInstruction(step, context);
expect(result).toContain('This workflow consists of 2 steps:');
expect(result).toContain('This workflow consists of 2 movements:');
expect(result).not.toContain('← current');
});
});
@ -689,7 +565,7 @@ describe('instruction-builder', () => {
return {
cwd: '/project',
reportDir: '/project/.takt/reports/20260129-test',
stepIteration: 1,
movementIteration: 1,
language: 'en',
...overrides,
};
@ -803,10 +679,10 @@ describe('instruction-builder', () => {
expect(result).toContain('# Plan');
});
it('should replace {step_iteration} in report output instruction', () => {
it('should replace {movement_iteration} in report output instruction', () => {
const step = createMinimalStep('Do work');
step.report = '00-plan.md';
const ctx = createReportContext({ stepIteration: 5 });
const ctx = createReportContext({ movementIteration: 5 });
const result = buildReportInstruction(step, ctx);
@ -993,9 +869,9 @@ describe('instruction-builder', () => {
expect(result).toContain('Step 3/20');
});
it('should replace {step_iteration}', () => {
const step = createMinimalStep('Run #{step_iteration}');
const context = createMinimalContext({ stepIteration: 2 });
it('should replace {movement_iteration}', () => {
const step = createMinimalStep('Run #{movement_iteration}');
const context = createMinimalContext({ movementIteration: 2 });
const result = buildInstruction(step, context);
@ -1018,7 +894,7 @@ describe('instruction-builder', () => {
expect(result).not.toContain('[TEST-STEP:');
});
it('should include status rules with mixed regular and ai() conditions', () => {
it('should NOT include status rules with mixed regular and ai() conditions (Phase 1 no longer has status rules)', () => {
const step = createMinimalStep('Do work');
step.name = 'review';
step.rules = [
@ -1029,11 +905,11 @@ describe('instruction-builder', () => {
const result = buildInstruction(step, context);
expect(result).toContain('Decision Criteria');
expect(result).toContain('[REVIEW:1]');
// Status rules are no longer injected in Phase 1 (only in Phase 3)
expect(result).not.toContain('Decision Criteria');
});
it('should include status rules with regular conditions only', () => {
it('should NOT include status rules with regular conditions only (Phase 1 no longer has status rules)', () => {
const step = createMinimalStep('Do work');
step.name = 'plan';
step.rules = [
@ -1044,9 +920,8 @@ describe('instruction-builder', () => {
const result = buildInstruction(step, context);
expect(result).toContain('Decision Criteria');
expect(result).toContain('[PLAN:1]');
expect(result).toContain('[PLAN:2]');
// Status rules are no longer injected in Phase 1 (only in Phase 3)
expect(result).not.toContain('Decision Criteria');
});
it('should NOT include status rules when all rules are aggregate conditions', () => {
@ -1076,7 +951,7 @@ describe('instruction-builder', () => {
expect(result).not.toContain('Decision Criteria');
});
it('should include status rules with mixed aggregate and regular conditions', () => {
it('should NOT include status rules with mixed aggregate and regular conditions (Phase 1 no longer has status rules)', () => {
const step = createMinimalStep('Do work');
step.name = 'supervise';
step.rules = [
@ -1087,8 +962,8 @@ describe('instruction-builder', () => {
const result = buildInstruction(step, context);
expect(result).toContain('Decision Criteria');
expect(result).toContain('[SUPERVISE:1]');
// Status rules are no longer injected in Phase 1 (only in Phase 3)
expect(result).not.toContain('Decision Criteria');
});
});

View File

@ -2,7 +2,7 @@
* Error recovery integration tests.
*
* Tests agent error, blocked responses, max iteration limits,
* loop detection, scenario queue exhaustion, and step execution exceptions.
* loop detection, scenario queue exhaustion, and movement execution exceptions.
*
* Mocked: UI, session, phase-runner, notifications, config, callAiJudge
* Not mocked: WorkflowEngine, runAgent, detectMatchedRule, rule-evaluator
@ -59,7 +59,7 @@ function makeRule(condition: string, next: string): WorkflowRule {
return { condition, next };
}
function makeStep(name: string, agentPath: string, rules: WorkflowRule[]): WorkflowStep {
function makeMovement(name: string, agentPath: string, rules: WorkflowRule[]): WorkflowStep {
return {
name,
agent: `./agents/${name}.md`,
@ -78,7 +78,7 @@ function createTestEnv(): { dir: string; agentPaths: Record<string, string> } {
const agentsDir = join(dir, 'agents');
mkdirSync(agentsDir, { recursive: true });
// Agent file names match step names used in makeStep()
// Agent file names match movement names used in makeMovement()
const agents = ['plan', 'implement', 'review', 'supervisor'];
const agentPaths: Record<string, string> = {};
for (const agent of agents) {
@ -103,17 +103,17 @@ function buildWorkflow(agentPaths: Record<string, string>, maxIterations: number
name: 'it-error',
description: 'IT error recovery workflow',
maxIterations,
initialStep: 'plan',
steps: [
makeStep('plan', agentPaths.plan, [
initialMovement: 'plan',
movements: [
makeMovement('plan', agentPaths.plan, [
makeRule('Requirements are clear', 'implement'),
makeRule('Requirements unclear', 'ABORT'),
]),
makeStep('implement', agentPaths.implement, [
makeMovement('implement', agentPaths.implement, [
makeRule('Implementation complete', 'review'),
makeRule('Cannot proceed', 'plan'),
]),
makeStep('review', agentPaths.review, [
makeMovement('review', agentPaths.review, [
makeRule('All checks passed', 'COMPLETE'),
makeRule('Issues found', 'implement'),
]),
@ -189,7 +189,7 @@ describe('Error Recovery IT: max iterations reached', () => {
});
it('should abort when max iterations reached (tight limit)', async () => {
// Only 2 iterations allowed, but workflow needs 3 steps
// Only 2 iterations allowed, but workflow needs 3 movements
setMockScenario([
{ agent: 'plan', status: 'done', content: '[PLAN:1]\n\nClear.' },
{ agent: 'implement', status: 'done', content: '[IMPLEMENT:1]\n\nDone.' },
@ -246,7 +246,7 @@ describe('Error Recovery IT: scenario queue exhaustion', () => {
});
it('should handle scenario queue exhaustion mid-workflow', async () => {
// Only 1 entry, but workflow needs 3 steps
// Only 1 entry, but workflow needs 3 movements
setMockScenario([
{ agent: 'plan', status: 'done', content: '[PLAN:1]\n\nClear.' },
]);
@ -265,7 +265,7 @@ describe('Error Recovery IT: scenario queue exhaustion', () => {
});
});
describe('Error Recovery IT: step events on error paths', () => {
describe('Error Recovery IT: movement events on error paths', () => {
let testDir: string;
let agentPaths: Record<string, string>;
@ -304,7 +304,7 @@ describe('Error Recovery IT: step events on error paths', () => {
expect(abortReason).toBeDefined();
});
it('should emit step:start and step:complete for each executed step before abort', async () => {
it('should emit movement:start and movement:complete for each executed movement before abort', async () => {
setMockScenario([
{ agent: 'plan', status: 'done', content: '[PLAN:2]\n\nRequirements unclear.' },
]);
@ -318,10 +318,10 @@ describe('Error Recovery IT: step events on error paths', () => {
const startedSteps: string[] = [];
const completedSteps: string[] = [];
engine.on('step:start', (step) => {
engine.on('movement:start', (step) => {
startedSteps.push(step.name);
});
engine.on('step:complete', (step) => {
engine.on('movement:complete', (step) => {
completedSteps.push(step.name);
});
@ -362,15 +362,15 @@ describe('Error Recovery IT: programmatic abort', () => {
provider: 'mock',
});
// Abort after the first step completes
engine.on('step:complete', () => {
// Abort after the first movement completes
engine.on('movement:complete', () => {
engine.abort();
});
const state = await engine.run();
expect(state.status).toBe('aborted');
// Should have aborted after 1 step
// Should have aborted after 1 movement
expect(state.iteration).toBeLessThanOrEqual(2);
});
});

View File

@ -2,13 +2,13 @@
* Instruction builder integration tests.
*
* Tests template variable expansion and auto-injection in buildInstruction().
* Uses real workflow step configs (not mocked) against the buildInstruction function.
* Uses real workflow movement configs (not mocked) against the buildInstruction function.
*
* Not mocked: buildInstruction, buildReportInstruction, buildStatusJudgmentInstruction
*/
import { describe, it, expect, vi } from 'vitest';
import type { WorkflowStep, WorkflowRule, AgentResponse } from '../core/models/index.js';
import type { WorkflowMovement, WorkflowRule, AgentResponse } from '../core/models/index.js';
vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
@ -22,14 +22,14 @@ import { StatusJudgmentBuilder, type StatusJudgmentContext } from '../core/workf
import type { InstructionContext } from '../core/workflow/index.js';
// Function wrappers for test readability
function buildInstruction(step: WorkflowStep, ctx: InstructionContext): string {
return new InstructionBuilder(step, ctx).build();
function buildInstruction(movement: WorkflowMovement, ctx: InstructionContext): string {
return new InstructionBuilder(movement, ctx).build();
}
function buildReportInstruction(step: WorkflowStep, ctx: ReportInstructionContext): string {
return new ReportInstructionBuilder(step, ctx).build();
function buildReportInstruction(movement: WorkflowMovement, ctx: ReportInstructionContext): string {
return new ReportInstructionBuilder(movement, ctx).build();
}
function buildStatusJudgmentInstruction(step: WorkflowStep, ctx: StatusJudgmentContext): string {
return new StatusJudgmentBuilder(step, ctx).build();
function buildStatusJudgmentInstruction(movement: WorkflowMovement, ctx: StatusJudgmentContext): string {
return new StatusJudgmentBuilder(movement, ctx).build();
}
// --- Test helpers ---
@ -38,7 +38,7 @@ function makeRule(condition: string, next: string, extra?: Partial<WorkflowRule>
return { condition, next, ...extra };
}
function makeStep(overrides: Partial<WorkflowStep> = {}): WorkflowStep {
function makeMovement(overrides: Partial<WorkflowMovement> = {}): WorkflowMovement {
return {
name: 'test-step',
agent: 'test-agent',
@ -58,7 +58,7 @@ function makeContext(overrides: Partial<InstructionContext> = {}): InstructionCo
task: 'Test task description',
iteration: 3,
maxIterations: 30,
stepIteration: 2,
movementIteration: 2,
cwd: '/tmp/test-project',
projectCwd: '/tmp/test-project',
userInputs: [],
@ -69,7 +69,7 @@ function makeContext(overrides: Partial<InstructionContext> = {}): InstructionCo
describe('Instruction Builder IT: task auto-injection', () => {
it('should auto-inject task as "User Request" section when template has no {task}', () => {
const step = makeStep({ instructionTemplate: 'Do the work.' });
const step = makeMovement({ instructionTemplate: 'Do the work.' });
const ctx = makeContext({ task: 'Build the login page' });
const result = buildInstruction(step, ctx);
@ -79,7 +79,7 @@ describe('Instruction Builder IT: task auto-injection', () => {
});
it('should NOT auto-inject task section when template contains {task}', () => {
const step = makeStep({ instructionTemplate: 'Here is the task: {task}' });
const step = makeMovement({ instructionTemplate: 'Here is the task: {task}' });
const ctx = makeContext({ task: 'Build the login page' });
const result = buildInstruction(step, ctx);
@ -94,7 +94,7 @@ describe('Instruction Builder IT: task auto-injection', () => {
describe('Instruction Builder IT: previous_response auto-injection', () => {
it('should auto-inject previous response when passPreviousResponse is true', () => {
const step = makeStep({
const step = makeMovement({
passPreviousResponse: true,
instructionTemplate: 'Continue the work.',
});
@ -113,7 +113,7 @@ describe('Instruction Builder IT: previous_response auto-injection', () => {
});
it('should NOT inject previous response when passPreviousResponse is false', () => {
const step = makeStep({
const step = makeMovement({
passPreviousResponse: false,
instructionTemplate: 'Do fresh work.',
});
@ -132,7 +132,7 @@ describe('Instruction Builder IT: previous_response auto-injection', () => {
});
it('should NOT auto-inject when template contains {previous_response}', () => {
const step = makeStep({
const step = makeMovement({
passPreviousResponse: true,
instructionTemplate: '## Context\n{previous_response}\n\nDo work.',
});
@ -153,7 +153,7 @@ describe('Instruction Builder IT: previous_response auto-injection', () => {
describe('Instruction Builder IT: user_inputs auto-injection', () => {
it('should auto-inject user inputs section', () => {
const step = makeStep();
const step = makeMovement();
const ctx = makeContext({ userInputs: ['Fix the typo', 'Use TypeScript'] });
const result = buildInstruction(step, ctx);
@ -164,7 +164,7 @@ describe('Instruction Builder IT: user_inputs auto-injection', () => {
});
it('should NOT auto-inject when template contains {user_inputs}', () => {
const step = makeStep({ instructionTemplate: 'Inputs: {user_inputs}' });
const step = makeMovement({ instructionTemplate: 'Inputs: {user_inputs}' });
const ctx = makeContext({ userInputs: ['Input A'] });
const result = buildInstruction(step, ctx);
@ -176,31 +176,31 @@ describe('Instruction Builder IT: user_inputs auto-injection', () => {
});
describe('Instruction Builder IT: iteration variables', () => {
it('should replace {iteration}, {max_iterations}, {step_iteration} in template', () => {
const step = makeStep({
instructionTemplate: 'Iter: {iteration}/{max_iterations}, step iter: {step_iteration}',
it('should replace {iteration}, {max_iterations}, {movement_iteration} in template', () => {
const step = makeMovement({
instructionTemplate: 'Iter: {iteration}/{max_iterations}, movement iter: {movement_iteration}',
});
const ctx = makeContext({ iteration: 5, maxIterations: 30, stepIteration: 2 });
const ctx = makeContext({ iteration: 5, maxIterations: 30, movementIteration: 2 });
const result = buildInstruction(step, ctx);
expect(result).toContain('Iter: 5/30, step iter: 2');
expect(result).toContain('Iter: 5/30, movement iter: 2');
});
it('should include iteration in Workflow Context section', () => {
const step = makeStep();
const ctx = makeContext({ iteration: 7, maxIterations: 20, stepIteration: 3 });
const step = makeMovement();
const ctx = makeContext({ iteration: 7, maxIterations: 20, movementIteration: 3 });
const result = buildInstruction(step, ctx);
expect(result).toContain('Iteration: 7/20');
expect(result).toContain('Step Iteration: 3');
expect(result).toContain('Movement Iteration: 3');
});
});
describe('Instruction Builder IT: report_dir expansion', () => {
it('should replace {report_dir} in template', () => {
const step = makeStep({
const step = makeMovement({
instructionTemplate: 'Read the plan from {report_dir}/00-plan.md',
});
const ctx = makeContext({ reportDir: '/tmp/test-project/.takt/reports/20250126-task' });
@ -211,7 +211,7 @@ describe('Instruction Builder IT: report_dir expansion', () => {
});
it('should replace {report:filename} with full path', () => {
const step = makeStep({
const step = makeMovement({
instructionTemplate: 'Read {report:00-plan.md} for the plan.',
});
const ctx = makeContext({ reportDir: '/tmp/reports' });
@ -224,7 +224,7 @@ describe('Instruction Builder IT: report_dir expansion', () => {
describe('Instruction Builder IT: status output rules injection', () => {
it('should inject status rules for steps with tag-based rules', () => {
const step = makeStep({
const step = makeMovement({
name: 'plan',
rules: [
makeRule('Requirements clear', 'implement'),
@ -235,14 +235,13 @@ describe('Instruction Builder IT: status output rules injection', () => {
const result = buildInstruction(step, ctx);
// Should contain status rules section with the tag format
expect(result).toContain('[PLAN:');
expect(result).toContain('Requirements clear');
expect(result).toContain('Requirements unclear');
// Status rules are NO LONGER injected in Phase 1 (buildInstruction).
// They are only injected in Phase 3 (buildStatusJudgmentInstruction).
expect(result).not.toContain('[PLAN:');
});
it('should NOT inject status rules for steps with only ai() conditions', () => {
const step = makeStep({
const step = makeMovement({
name: 'review',
rules: [
makeRule('ai("approved")', 'COMPLETE', { isAiCondition: true, aiConditionText: 'approved' }),
@ -260,7 +259,7 @@ describe('Instruction Builder IT: status output rules injection', () => {
describe('Instruction Builder IT: edit permission in execution context', () => {
it('should include edit permission rules when edit is true', () => {
const step = makeStep({ edit: true });
const step = makeMovement({ edit: true });
const ctx = makeContext();
const result = buildInstruction(step, ctx);
@ -270,7 +269,7 @@ describe('Instruction Builder IT: edit permission in execution context', () => {
});
it('should indicate read-only when edit is false', () => {
const step = makeStep({ edit: false });
const step = makeMovement({ edit: false });
const ctx = makeContext();
const result = buildInstruction(step, ctx);
@ -283,15 +282,15 @@ describe('Instruction Builder IT: edit permission in execution context', () => {
describe('Instruction Builder IT: buildReportInstruction', () => {
it('should build report instruction with report context', () => {
const step = makeStep({
const step = makeMovement({
name: 'plan',
report: { name: '00-plan.md', format: '# Plan\n{step_iteration}' },
report: { name: '00-plan.md', format: '# Plan\n{movement_iteration}' },
});
const result = buildReportInstruction(step, {
cwd: '/tmp/test',
reportDir: '/tmp/test/.takt/reports/test-dir',
stepIteration: 1,
movementIteration: 1,
language: 'en',
});
@ -301,13 +300,13 @@ describe('Instruction Builder IT: buildReportInstruction', () => {
});
it('should throw for step without report config', () => {
const step = makeStep({ report: undefined });
const step = makeMovement({ report: undefined });
expect(() =>
buildReportInstruction(step, {
cwd: '/tmp',
reportDir: '/tmp/reports',
stepIteration: 1,
movementIteration: 1,
}),
).toThrow(/no report config/);
});
@ -315,7 +314,7 @@ describe('Instruction Builder IT: buildReportInstruction', () => {
describe('Instruction Builder IT: buildStatusJudgmentInstruction', () => {
it('should build Phase 3 instruction with status rules', () => {
const step = makeStep({
const step = makeMovement({
name: 'plan',
rules: [
makeRule('Clear', 'implement'),
@ -331,7 +330,7 @@ describe('Instruction Builder IT: buildStatusJudgmentInstruction', () => {
});
it('should throw for step without rules', () => {
const step = makeStep({ rules: undefined });
const step = makeMovement({ rules: undefined });
expect(() =>
buildStatusJudgmentInstruction(step, { language: 'en' }),
@ -341,7 +340,7 @@ describe('Instruction Builder IT: buildStatusJudgmentInstruction', () => {
describe('Instruction Builder IT: template injection prevention', () => {
it('should escape curly braces in task content', () => {
const step = makeStep();
const step = makeMovement();
const ctx = makeContext({ task: 'Use {variable} in code' });
const result = buildInstruction(step, ctx);
@ -352,7 +351,7 @@ describe('Instruction Builder IT: template injection prevention', () => {
});
it('should escape curly braces in previous response content', () => {
const step = makeStep({
const step = makeMovement({
passPreviousResponse: true,
instructionTemplate: 'Continue.',
});

View File

@ -172,9 +172,9 @@ function createTestWorkflowDir(): { dir: string; workflowPath: string } {
name: it-pipeline
description: Pipeline test workflow
max_iterations: 10
initial_step: plan
initial_movement: plan
steps:
movements:
- name: plan
agent: ./agents/planner.md
rules:

View File

@ -154,9 +154,9 @@ function createTestWorkflowDir(): { dir: string; workflowPath: string } {
name: it-simple
description: Integration test workflow
max_iterations: 10
initial_step: plan
initial_movement: plan
steps:
movements:
- name: plan
agent: ./agents/planner.md
rules:
@ -209,7 +209,7 @@ describe('Pipeline Integration Tests', () => {
it('should complete pipeline with workflow path + skip-git + mock scenario', async () => {
// Scenario: plan -> implement -> review -> COMPLETE
// agent field must match extractAgentName(step.agent), i.e., the .md filename without extension
// agent field must match extractAgentName(movement.agent), i.e., the .md filename without extension
setMockScenario([
{ agent: 'planner', status: 'done', content: '[PLAN:1]\n\nPlan completed. Requirements are clear.' },
{ agent: 'coder', status: 'done', content: '[IMPLEMENT:1]\n\nImplementation complete.' },
@ -231,7 +231,7 @@ describe('Pipeline Integration Tests', () => {
it('should complete pipeline with workflow name + skip-git + mock scenario', async () => {
// Use builtin 'minimal' workflow
// agent field: extractAgentName result (from .md filename)
// tag in content: [STEP_NAME:N] where STEP_NAME is the step name uppercased
// tag in content: [MOVEMENT_NAME:N] where MOVEMENT_NAME is the movement name uppercased
setMockScenario([
{ agent: 'coder', status: 'done', content: 'Implementation complete' },
{ agent: 'ai-antipattern-reviewer', status: 'done', content: 'No AI-specific issues' },

View File

@ -15,7 +15,7 @@
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { WorkflowStep, WorkflowState, WorkflowRule, AgentResponse } from '../core/models/index.js';
import type { WorkflowMovement, WorkflowState, WorkflowRule, AgentResponse } from '../core/models/index.js';
// --- Mocks ---
@ -43,11 +43,11 @@ function makeRule(condition: string, next: string, extra?: Partial<WorkflowRule>
return { condition, next, ...extra };
}
function makeStep(
function makeMovement(
name: string,
rules: WorkflowRule[],
parallel?: WorkflowStep[],
): WorkflowStep {
parallel?: WorkflowMovement[],
): WorkflowMovement {
return {
name,
agent: 'test-agent',
@ -59,22 +59,22 @@ function makeStep(
};
}
function makeState(stepOutputs?: Map<string, AgentResponse>): WorkflowState {
function makeState(movementOutputs?: Map<string, AgentResponse>): WorkflowState {
return {
workflowName: 'it-test',
currentStep: 'test',
currentMovement: 'test',
iteration: 1,
status: 'running',
stepOutputs: stepOutputs ?? new Map(),
stepIterations: new Map(),
movementOutputs: movementOutputs ?? new Map(),
movementIterations: new Map(),
agentSessions: new Map(),
userInputs: [],
};
}
function makeCtx(stepOutputs?: Map<string, AgentResponse>): RuleEvaluatorContext {
function makeCtx(movementOutputs?: Map<string, AgentResponse>): RuleEvaluatorContext {
return {
state: makeState(stepOutputs),
state: makeState(movementOutputs),
cwd: '/tmp/test',
detectRuleIndex,
callAiJudge: mockCallAiJudge,
@ -88,7 +88,7 @@ describe('Rule Evaluation IT: Phase 3 tag detection', () => {
});
it('should detect rule from Phase 3 tag content', async () => {
const step = makeStep('plan', [
const step = makeMovement('plan', [
makeRule('Clear', 'implement'),
makeRule('Unclear', 'ABORT'),
]);
@ -99,7 +99,7 @@ describe('Rule Evaluation IT: Phase 3 tag detection', () => {
});
it('should prefer Phase 3 tag over Phase 1 tag', async () => {
const step = makeStep('plan', [
const step = makeMovement('plan', [
makeRule('Clear', 'implement'),
makeRule('Unclear', 'ABORT'),
]);
@ -118,7 +118,7 @@ describe('Rule Evaluation IT: Phase 1 tag fallback', () => {
});
it('should fall back to Phase 1 tag when Phase 3 has no tag', async () => {
const step = makeStep('plan', [
const step = makeMovement('plan', [
makeRule('Clear', 'implement'),
makeRule('Unclear', 'ABORT'),
]);
@ -129,7 +129,7 @@ describe('Rule Evaluation IT: Phase 1 tag fallback', () => {
});
it('should detect last tag when multiple tags in Phase 1', async () => {
const step = makeStep('plan', [
const step = makeMovement('plan', [
makeRule('Clear', 'implement'),
makeRule('Unclear', 'ABORT'),
]);
@ -147,17 +147,17 @@ describe('Rule Evaluation IT: Aggregate conditions (all/any)', () => {
mockCallAiJudge.mockResolvedValue(-1);
});
it('should match all("approved") when all sub-steps have "approved"', () => {
const subStep1 = makeStep('arch-review', [
it('should match all("approved") when all sub-movements have "approved"', () => {
const subStep1 = makeMovement('arch-review', [
makeRule('approved', ''),
makeRule('needs_fix', ''),
]);
const subStep2 = makeStep('security-review', [
const subStep2 = makeMovement('security-review', [
makeRule('approved', ''),
makeRule('needs_fix', ''),
]);
const parentStep = makeStep('reviewers', [
const parentStep = makeMovement('reviewers', [
makeRule('all("approved")', 'supervise', {
isAggregateCondition: true,
aggregateType: 'all',
@ -185,17 +185,17 @@ describe('Rule Evaluation IT: Aggregate conditions (all/any)', () => {
expect(result).toBe(0); // all("approved") is rule index 0
});
it('should match any("needs_fix") when one sub-step has "needs_fix"', () => {
const subStep1 = makeStep('arch-review', [
it('should match any("needs_fix") when one sub-movement has "needs_fix"', () => {
const subStep1 = makeMovement('arch-review', [
makeRule('approved', ''),
makeRule('needs_fix', ''),
]);
const subStep2 = makeStep('security-review', [
const subStep2 = makeMovement('security-review', [
makeRule('approved', ''),
makeRule('needs_fix', ''),
]);
const parentStep = makeStep('reviewers', [
const parentStep = makeMovement('reviewers', [
makeRule('all("approved")', 'supervise', {
isAggregateCondition: true,
aggregateType: 'all',
@ -224,16 +224,16 @@ describe('Rule Evaluation IT: Aggregate conditions (all/any)', () => {
});
it('should return -1 when no aggregate condition matches', () => {
const subStep1 = makeStep('review-a', [
const subStep1 = makeMovement('review-a', [
makeRule('approved', ''),
makeRule('needs_fix', ''),
]);
const subStep2 = makeStep('review-b', [
const subStep2 = makeMovement('review-b', [
makeRule('approved', ''),
makeRule('needs_fix', ''),
]);
const parentStep = makeStep('reviews', [
const parentStep = makeMovement('reviews', [
makeRule('all("approved")', 'done', {
isAggregateCondition: true,
aggregateType: 'all',
@ -256,8 +256,8 @@ describe('Rule Evaluation IT: Aggregate conditions (all/any)', () => {
expect(result).toBe(-1);
});
it('should return -1 for non-parallel step', () => {
const step = makeStep('step', [
it('should return -1 for non-parallel movement', () => {
const step = makeMovement('step', [
makeRule('all("done")', 'COMPLETE', {
isAggregateCondition: true,
aggregateType: 'all',
@ -279,7 +279,7 @@ describe('Rule Evaluation IT: ai() judge condition', () => {
it('should call AI judge for ai() conditions when no tag match', async () => {
mockCallAiJudge.mockResolvedValue(0); // Judge says first ai() condition matches
const step = makeStep('step', [
const step = makeMovement('step', [
makeRule('ai("The code is approved")', 'COMPLETE', {
isAiCondition: true,
aiConditionText: 'The code is approved',
@ -297,7 +297,7 @@ describe('Rule Evaluation IT: ai() judge condition', () => {
});
it('should skip AI judge if tag already matched', async () => {
const step = makeStep('plan', [
const step = makeMovement('plan', [
makeRule('ai("Clear")', 'implement', {
isAiCondition: true,
aiConditionText: 'Clear',
@ -321,7 +321,7 @@ describe('Rule Evaluation IT: AI judge fallback', () => {
// Second call (all conditions fallback): returns 0
mockCallAiJudge.mockResolvedValue(0);
const step = makeStep('review', [
const step = makeMovement('review', [
makeRule('Approved', 'COMPLETE'),
makeRule('Rejected', 'fix'),
]);
@ -335,7 +335,7 @@ describe('Rule Evaluation IT: AI judge fallback', () => {
it('should throw when no rule matches (AI judge returns -1 for all phases)', async () => {
mockCallAiJudge.mockResolvedValue(-1);
const step = makeStep('review', [
const step = makeMovement('review', [
makeRule('Approved', 'COMPLETE'),
makeRule('Rejected', 'fix'),
]);
@ -353,8 +353,8 @@ describe('Rule Evaluation IT: RuleMatchMethod tracking', () => {
});
it('should record method as "aggregate" for aggregate matches', () => {
const subStep = makeStep('sub', [makeRule('ok', '')]);
const parentStep = makeStep('parent', [
const subStep = makeMovement('sub', [makeRule('ok', '')]);
const parentStep = makeMovement('parent', [
makeRule('all("ok")', 'COMPLETE', {
isAggregateCondition: true,
aggregateType: 'all',
@ -374,7 +374,7 @@ describe('Rule Evaluation IT: RuleMatchMethod tracking', () => {
});
it('should record method as "phase3_tag" for Phase 3 matches', async () => {
const step = makeStep('step', [
const step = makeMovement('step', [
makeRule('Done', 'COMPLETE'),
]);
@ -383,7 +383,7 @@ describe('Rule Evaluation IT: RuleMatchMethod tracking', () => {
});
it('should record method as "phase1_tag" for Phase 1 fallback matches', async () => {
const step = makeStep('step', [
const step = makeMovement('step', [
makeRule('Done', 'COMPLETE'),
]);
@ -392,13 +392,13 @@ describe('Rule Evaluation IT: RuleMatchMethod tracking', () => {
});
});
describe('Rule Evaluation IT: steps without rules', () => {
describe('Rule Evaluation IT: movements without rules', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return undefined for step with no rules', async () => {
const step: WorkflowStep = {
it('should return undefined for movement with no rules', async () => {
const step: WorkflowMovement = {
name: 'step',
agent: 'agent',
agentDisplayName: 'step',
@ -410,8 +410,8 @@ describe('Rule Evaluation IT: steps without rules', () => {
expect(result).toBeUndefined();
});
it('should return undefined for step with empty rules array', async () => {
const step = makeStep('step', []);
it('should return undefined for movement with empty rules array', async () => {
const step = makeMovement('step', []);
const result = await detectMatchedRule(step, 'content', '', makeCtx());
expect(result).toBeUndefined();

View File

@ -2,7 +2,7 @@
* Three-phase execution integration tests.
*
* Tests Phase 1 (main) Phase 2 (report) Phase 3 (status judgment) lifecycle.
* Verifies that the correct combination of phases fires based on step config.
* Verifies that the correct combination of phases fires based on movement config.
*
* Mocked: UI, session, config, callAiJudge
* Selectively mocked: phase-runner (to inspect call patterns)
@ -84,7 +84,7 @@ function buildEngineOptions(projectCwd: string) {
};
}
function makeStep(
function makeMovement(
name: string,
agentPath: string,
rules: WorkflowRule[],
@ -124,7 +124,7 @@ describe('Three-Phase Execution IT: phase1 only (no report, no tag rules)', () =
rmSync(testDir, { recursive: true, force: true });
});
it('should only run Phase 1 when step has no report and no tag rules', async () => {
it('should only run Phase 1 when movement has no report and no tag rules', async () => {
setMockScenario([
{ status: 'done', content: '[STEP:1]\n\nDone.' },
]);
@ -133,9 +133,9 @@ describe('Three-Phase Execution IT: phase1 only (no report, no tag rules)', () =
name: 'it-phase1-only',
description: 'Test',
maxIterations: 5,
initialStep: 'step',
steps: [
makeStep('step', agentPath, [
initialMovement: 'step',
movements: [
makeMovement('step', agentPath, [
makeRule('Done', 'COMPLETE'),
makeRule('Not done', 'ABORT'),
]),
@ -176,7 +176,7 @@ describe('Three-Phase Execution IT: phase1 + phase2 (report defined)', () => {
rmSync(testDir, { recursive: true, force: true });
});
it('should run Phase 1 + Phase 2 when step has report', async () => {
it('should run Phase 1 + Phase 2 when movement has report', async () => {
setMockScenario([
{ status: 'done', content: '[STEP:1]\n\nDone.' },
]);
@ -185,9 +185,9 @@ describe('Three-Phase Execution IT: phase1 + phase2 (report defined)', () => {
name: 'it-phase1-2',
description: 'Test',
maxIterations: 5,
initialStep: 'step',
steps: [
makeStep('step', agentPath, [
initialMovement: 'step',
movements: [
makeMovement('step', agentPath, [
makeRule('Done', 'COMPLETE'),
makeRule('Not done', 'ABORT'),
], { report: 'test-report.md' }),
@ -206,7 +206,7 @@ describe('Three-Phase Execution IT: phase1 + phase2 (report defined)', () => {
expect(mockRunStatusJudgmentPhase).not.toHaveBeenCalled();
});
it('should run Phase 2 for multi-report step', async () => {
it('should run Phase 2 for multi-report movement', async () => {
setMockScenario([
{ status: 'done', content: '[STEP:1]\n\nDone.' },
]);
@ -215,9 +215,9 @@ describe('Three-Phase Execution IT: phase1 + phase2 (report defined)', () => {
name: 'it-phase1-2-multi',
description: 'Test',
maxIterations: 5,
initialStep: 'step',
steps: [
makeStep('step', agentPath, [
initialMovement: 'step',
movements: [
makeMovement('step', agentPath, [
makeRule('Done', 'COMPLETE'),
], { report: [{ label: 'Scope', path: 'scope.md' }, { label: 'Decisions', path: 'decisions.md' }] }),
],
@ -256,7 +256,7 @@ describe('Three-Phase Execution IT: phase1 + phase3 (tag rules defined)', () =>
rmSync(testDir, { recursive: true, force: true });
});
it('should run Phase 1 + Phase 3 when step has tag-based rules but no report', async () => {
it('should run Phase 1 + Phase 3 when movement has tag-based rules but no report', async () => {
setMockScenario([
// Phase 1: main content (no tag — Phase 3 will provide it)
{ status: 'done', content: 'Agent completed the work.' },
@ -266,9 +266,9 @@ describe('Three-Phase Execution IT: phase1 + phase3 (tag rules defined)', () =>
name: 'it-phase1-3',
description: 'Test',
maxIterations: 5,
initialStep: 'step',
steps: [
makeStep('step', agentPath, [
initialMovement: 'step',
movements: [
makeMovement('step', agentPath, [
makeRule('Done', 'COMPLETE'),
makeRule('Not done', 'ABORT'),
]),
@ -308,7 +308,7 @@ describe('Three-Phase Execution IT: all three phases', () => {
rmSync(testDir, { recursive: true, force: true });
});
it('should run Phase 1 → Phase 2 → Phase 3 when step has report and tag rules', async () => {
it('should run Phase 1 → Phase 2 → Phase 3 when movement has report and tag rules', async () => {
setMockScenario([
{ status: 'done', content: 'Agent completed the work.' },
]);
@ -317,9 +317,9 @@ describe('Three-Phase Execution IT: all three phases', () => {
name: 'it-all-phases',
description: 'Test',
maxIterations: 5,
initialStep: 'step',
steps: [
makeStep('step', agentPath, [
initialMovement: 'step',
movements: [
makeMovement('step', agentPath, [
makeRule('Done', 'COMPLETE'),
makeRule('Not done', 'ABORT'),
], { report: 'test-report.md' }),
@ -377,13 +377,13 @@ describe('Three-Phase Execution IT: phase3 tag → rule match', () => {
name: 'it-phase3-tag',
description: 'Test',
maxIterations: 5,
initialStep: 'step1',
steps: [
makeStep('step1', agentPath, [
initialMovement: 'step1',
movements: [
makeMovement('step1', agentPath, [
makeRule('Done', 'step2'),
makeRule('Not done', 'ABORT'),
]),
makeStep('step2', agentPath, [
makeMovement('step2', agentPath, [
makeRule('Checked', 'COMPLETE'),
]),
],

View File

@ -3,7 +3,7 @@
*
* Tests WorkflowEngine with real runAgent + MockProvider + ScenarioQueue.
* No vi.mock on runAgent or detectMatchedRule rules are matched via
* [STEP_NAME:N] tags in scenario content (tag-based detection).
* [MOVEMENT_NAME:N] tags in scenario content (tag-based detection).
*
* Mocked: UI, session, phase-runner (report/judgment phases), notifications, config
* Not mocked: WorkflowEngine, runAgent, detectMatchedRule, rule-evaluator
@ -62,7 +62,7 @@ function makeRule(condition: string, next: string): WorkflowRule {
return { condition, next };
}
function makeStep(name: string, agentPath: string, rules: WorkflowRule[]): WorkflowStep {
function makeMovement(name: string, agentPath: string, rules: WorkflowRule[]): WorkflowStep {
return {
name,
agent: `./agents/${name}.md`,
@ -105,17 +105,17 @@ function buildSimpleWorkflow(agentPaths: Record<string, string>): WorkflowConfig
name: 'it-simple',
description: 'IT simple workflow',
maxIterations: 15,
initialStep: 'plan',
steps: [
makeStep('plan', agentPaths.planner, [
initialMovement: 'plan',
movements: [
makeMovement('plan', agentPaths.planner, [
makeRule('Requirements are clear', 'implement'),
makeRule('Requirements unclear', 'ABORT'),
]),
makeStep('implement', agentPaths.coder, [
makeMovement('implement', agentPaths.coder, [
makeRule('Implementation complete', 'review'),
makeRule('Cannot proceed', 'plan'),
]),
makeStep('review', agentPaths.reviewer, [
makeMovement('review', agentPaths.reviewer, [
makeRule('All checks passed', 'COMPLETE'),
makeRule('Issues found', 'implement'),
]),
@ -128,25 +128,25 @@ function buildLoopWorkflow(agentPaths: Record<string, string>): WorkflowConfig {
name: 'it-loop',
description: 'IT workflow with fix loop',
maxIterations: 20,
initialStep: 'plan',
steps: [
makeStep('plan', agentPaths.planner, [
initialMovement: 'plan',
movements: [
makeMovement('plan', agentPaths.planner, [
makeRule('Requirements are clear', 'implement'),
makeRule('Requirements unclear', 'ABORT'),
]),
makeStep('implement', agentPaths.coder, [
makeMovement('implement', agentPaths.coder, [
makeRule('Implementation complete', 'review'),
makeRule('Cannot proceed', 'plan'),
]),
makeStep('review', agentPaths.reviewer, [
makeMovement('review', agentPaths.reviewer, [
makeRule('Approved', 'supervise'),
makeRule('Needs fix', 'fix'),
]),
makeStep('fix', agentPaths.fixer, [
makeMovement('fix', agentPaths.fixer, [
makeRule('Fix complete', 'review'),
makeRule('Cannot fix', 'ABORT'),
]),
makeStep('supervise', agentPaths.supervisor, [
makeMovement('supervise', agentPaths.supervisor, [
makeRule('All checks passed', 'COMPLETE'),
makeRule('Requirements unmet', 'plan'),
]),
@ -308,7 +308,7 @@ describe('Workflow Engine IT: Max Iterations', () => {
});
});
describe('Workflow Engine IT: Step Output Tracking', () => {
describe('Workflow Engine IT: Movement Output Tracking', () => {
let testDir: string;
let agentPaths: Record<string, string>;
@ -324,7 +324,7 @@ describe('Workflow Engine IT: Step Output Tracking', () => {
rmSync(testDir, { recursive: true, force: true });
});
it('should track step outputs through events', async () => {
it('should track movement outputs through events', async () => {
setMockScenario([
{ agent: 'plan', status: 'done', content: '[PLAN:1]\n\nPlan output.' },
{ agent: 'implement', status: 'done', content: '[IMPLEMENT:1]\n\nImplement output.' },
@ -337,14 +337,14 @@ describe('Workflow Engine IT: Step Output Tracking', () => {
provider: 'mock',
});
const completedSteps: string[] = [];
engine.on('step:complete', (step) => {
completedSteps.push(step.name);
const completedMovements: string[] = [];
engine.on('movement:complete', (movement) => {
completedMovements.push(movement.name);
});
const state = await engine.run();
expect(state.status).toBe('completed');
expect(completedSteps).toEqual(['plan', 'implement', 'review']);
expect(completedMovements).toEqual(['plan', 'implement', 'review']);
});
});

View File

@ -53,8 +53,8 @@ describe('Workflow Loader IT: builtin workflow loading', () => {
expect(config).not.toBeNull();
expect(config!.name).toBe(name);
expect(config!.steps.length).toBeGreaterThan(0);
expect(config!.initialStep).toBeDefined();
expect(config!.movements.length).toBeGreaterThan(0);
expect(config!.initialMovement).toBeDefined();
expect(config!.maxIterations).toBeGreaterThan(0);
});
}
@ -88,9 +88,9 @@ describe('Workflow Loader IT: project-local workflow override', () => {
name: custom-wf
description: Custom project workflow
max_iterations: 5
initial_step: start
initial_movement: start
steps:
movements:
- name: start
agent: ./agents/custom.md
rules:
@ -103,8 +103,8 @@ steps:
expect(config).not.toBeNull();
expect(config!.name).toBe('custom-wf');
expect(config!.steps.length).toBe(1);
expect(config!.steps[0]!.name).toBe('start');
expect(config!.movements.length).toBe(1);
expect(config!.movements[0]!.name).toBe('start');
});
});
@ -123,15 +123,15 @@ describe('Workflow Loader IT: agent path resolution', () => {
const config = loadWorkflow('minimal', testDir);
expect(config).not.toBeNull();
for (const step of config!.steps) {
if (step.agentPath) {
for (const movement of config!.movements) {
if (movement.agentPath) {
// Agent paths should be resolved to absolute paths
expect(step.agentPath).toMatch(/^\//);
expect(movement.agentPath).toMatch(/^\//);
// Agent files should exist
expect(existsSync(step.agentPath)).toBe(true);
expect(existsSync(movement.agentPath)).toBe(true);
}
if (step.parallel) {
for (const sub of step.parallel) {
if (movement.parallel) {
for (const sub of movement.parallel) {
if (sub.agentPath) {
expect(sub.agentPath).toMatch(/^\//);
expect(existsSync(sub.agentPath)).toBe(true);
@ -157,8 +157,8 @@ describe('Workflow Loader IT: rule syntax parsing', () => {
const config = loadWorkflow('default', testDir);
expect(config).not.toBeNull();
// Find the parallel reviewers step
const reviewersStep = config!.steps.find(
// Find the parallel reviewers movement
const reviewersStep = config!.movements.find(
(s) => s.parallel && s.parallel.length > 0,
);
expect(reviewersStep).toBeDefined();
@ -175,7 +175,7 @@ describe('Workflow Loader IT: rule syntax parsing', () => {
const config = loadWorkflow('default', testDir);
expect(config).not.toBeNull();
const reviewersStep = config!.steps.find(
const reviewersStep = config!.movements.find(
(s) => s.parallel && s.parallel.length > 0,
);
@ -186,11 +186,11 @@ describe('Workflow Loader IT: rule syntax parsing', () => {
expect(anyRule!.aggregateConditionText).toBe('needs_fix');
});
it('should parse standard rules with next step', () => {
it('should parse standard rules with next movement', () => {
const config = loadWorkflow('minimal', testDir);
expect(config).not.toBeNull();
const implementStep = config!.steps.find((s) => s.name === 'implement');
const implementStep = config!.movements.find((s) => s.name === 'implement');
expect(implementStep).toBeDefined();
expect(implementStep!.rules).toBeDefined();
expect(implementStep!.rules!.length).toBeGreaterThan(0);
@ -221,34 +221,34 @@ describe('Workflow Loader IT: workflow config validation', () => {
expect(config!.maxIterations).toBeGreaterThan(0);
});
it('should set initial_step from YAML', () => {
it('should set initial_movement from YAML', () => {
const config = loadWorkflow('minimal', testDir);
expect(config).not.toBeNull();
expect(typeof config!.initialStep).toBe('string');
expect(typeof config!.initialMovement).toBe('string');
// initial_step should reference an existing step
const stepNames = config!.steps.map((s) => s.name);
expect(stepNames).toContain(config!.initialStep);
// initial_movement should reference an existing movement
const movementNames = config!.movements.map((s) => s.name);
expect(movementNames).toContain(config!.initialMovement);
});
it('should preserve edit property on steps (review-only has no edit: true)', () => {
it('should preserve edit property on movements (review-only has no edit: true)', () => {
const config = loadWorkflow('review-only', testDir);
expect(config).not.toBeNull();
// review-only: no step should have edit: true
for (const step of config!.steps) {
expect(step.edit).not.toBe(true);
if (step.parallel) {
for (const sub of step.parallel) {
// review-only: no movement should have edit: true
for (const movement of config!.movements) {
expect(movement.edit).not.toBe(true);
if (movement.parallel) {
for (const sub of movement.parallel) {
expect(sub.edit).not.toBe(true);
}
}
}
// expert: implement step should have edit: true
// expert: implement movement should have edit: true
const expertConfig = loadWorkflow('expert', testDir);
expect(expertConfig).not.toBeNull();
const implementStep = expertConfig!.steps.find((s) => s.name === 'implement');
const implementStep = expertConfig!.movements.find((s) => s.name === 'implement');
expect(implementStep).toBeDefined();
expect(implementStep!.edit).toBe(true);
});
@ -257,13 +257,13 @@ describe('Workflow Loader IT: workflow config validation', () => {
const config = loadWorkflow('minimal', testDir);
expect(config).not.toBeNull();
// At least some steps should have passPreviousResponse set
const stepsWithPassPrev = config!.steps.filter((s) => s.passPreviousResponse === true);
expect(stepsWithPassPrev.length).toBeGreaterThan(0);
// At least some movements should have passPreviousResponse set
const movementsWithPassPrev = config!.movements.filter((s) => s.passPreviousResponse === true);
expect(movementsWithPassPrev.length).toBeGreaterThan(0);
});
});
describe('Workflow Loader IT: parallel step loading', () => {
describe('Workflow Loader IT: parallel movement loading', () => {
let testDir: string;
beforeEach(() => {
@ -274,17 +274,17 @@ describe('Workflow Loader IT: parallel step loading', () => {
rmSync(testDir, { recursive: true, force: true });
});
it('should load parallel sub-steps from default workflow', () => {
it('should load parallel sub-movements from default workflow', () => {
const config = loadWorkflow('default', testDir);
expect(config).not.toBeNull();
const parallelStep = config!.steps.find(
const parallelStep = config!.movements.find(
(s) => s.parallel && s.parallel.length > 0,
);
expect(parallelStep).toBeDefined();
expect(parallelStep!.parallel!.length).toBeGreaterThanOrEqual(2);
// Each sub-step should have required fields
// Each sub-movement should have required fields
for (const sub of parallelStep!.parallel!) {
expect(sub.name).toBeDefined();
expect(sub.agent).toBeDefined();
@ -296,7 +296,7 @@ describe('Workflow Loader IT: parallel step loading', () => {
const config = loadWorkflow('expert', testDir);
expect(config).not.toBeNull();
const parallelStep = config!.steps.find(
const parallelStep = config!.movements.find(
(s) => s.parallel && s.parallel.length === 4,
);
expect(parallelStep).toBeDefined();
@ -324,8 +324,8 @@ describe('Workflow Loader IT: report config loading', () => {
const config = loadWorkflow('default', testDir);
expect(config).not.toBeNull();
// default workflow: plan step has a report config
const planStep = config!.steps.find((s) => s.name === 'plan');
// default workflow: plan movement has a report config
const planStep = config!.movements.find((s) => s.name === 'plan');
expect(planStep).toBeDefined();
expect(planStep!.report).toBeDefined();
});
@ -334,8 +334,8 @@ describe('Workflow Loader IT: report config loading', () => {
const config = loadWorkflow('expert', testDir);
expect(config).not.toBeNull();
// implement step has multi-report: [Scope, Decisions]
const implementStep = config!.steps.find((s) => s.name === 'implement');
// implement movement has multi-report: [Scope, Decisions]
const implementStep = config!.movements.find((s) => s.name === 'implement');
expect(implementStep).toBeDefined();
expect(implementStep!.report).toBeDefined();
expect(Array.isArray(implementStep!.report)).toBe(true);
@ -373,7 +373,7 @@ this is not: valid yaml: [[[[
writeFileSync(join(workflowsDir, 'incomplete.yaml'), `
name: incomplete
description: Missing steps
description: Missing movements
`);
expect(() => loadWorkflow('incomplete', testDir)).toThrow();

View File

@ -304,15 +304,15 @@ describe('Workflow Patterns IT: review-only workflow', () => {
expect(state.status).toBe('completed');
});
it('should verify no steps have edit: true', () => {
it('should verify no movements have edit: true', () => {
const config = loadWorkflow('review-only', testDir);
expect(config).not.toBeNull();
for (const step of config!.steps) {
expect(step.edit).not.toBe(true);
if (step.parallel) {
for (const subStep of step.parallel) {
expect(subStep.edit).not.toBe(true);
for (const movement of config!.movements) {
expect(movement.edit).not.toBe(true);
if (movement.parallel) {
for (const subMovement of movement.parallel) {
expect(subMovement.edit).not.toBe(true);
}
}
}

View File

@ -62,7 +62,7 @@ describe('WorkflowConfigRawSchema', () => {
const config = {
name: 'test-workflow',
description: 'A test workflow',
steps: [
movements: [
{
name: 'step1',
agent: 'coder',
@ -77,15 +77,15 @@ describe('WorkflowConfigRawSchema', () => {
const result = WorkflowConfigRawSchema.parse(config);
expect(result.name).toBe('test-workflow');
expect(result.steps).toHaveLength(1);
expect(result.steps[0]?.allowed_tools).toEqual(['Read', 'Grep']);
expect(result.movements).toHaveLength(1);
expect(result.movements![0]?.allowed_tools).toEqual(['Read', 'Grep']);
expect(result.max_iterations).toBe(10);
});
it('should parse step with permission_mode', () => {
it('should parse movement with permission_mode', () => {
const config = {
name: 'test-workflow',
steps: [
movements: [
{
name: 'implement',
agent: 'coder',
@ -100,13 +100,13 @@ describe('WorkflowConfigRawSchema', () => {
};
const result = WorkflowConfigRawSchema.parse(config);
expect(result.steps[0]?.permission_mode).toBe('edit');
expect(result.movements![0]?.permission_mode).toBe('edit');
});
it('should allow omitting permission_mode', () => {
const config = {
name: 'test-workflow',
steps: [
movements: [
{
name: 'plan',
agent: 'planner',
@ -116,13 +116,13 @@ describe('WorkflowConfigRawSchema', () => {
};
const result = WorkflowConfigRawSchema.parse(config);
expect(result.steps[0]?.permission_mode).toBeUndefined();
expect(result.movements![0]?.permission_mode).toBeUndefined();
});
it('should reject invalid permission_mode', () => {
const config = {
name: 'test-workflow',
steps: [
movements: [
{
name: 'step1',
agent: 'coder',
@ -135,10 +135,10 @@ describe('WorkflowConfigRawSchema', () => {
expect(() => WorkflowConfigRawSchema.parse(config)).toThrow();
});
it('should require at least one step', () => {
it('should require at least one movement', () => {
const config = {
name: 'empty-workflow',
steps: [],
movements: [],
};
expect(() => WorkflowConfigRawSchema.parse(config)).toThrow();

View File

@ -1,17 +1,17 @@
/**
* Tests for parallel step execution and ai() condition loader
* Tests for parallel movement execution and ai() condition loader
*
* Covers:
* - Schema validation for parallel sub-steps
* - Workflow loader normalization of ai() conditions and parallel steps
* - Engine parallel step aggregation logic
* - Schema validation for parallel sub-movements
* - Workflow loader normalization of ai() conditions and parallel movements
* - Engine parallel movement aggregation logic
*/
import { describe, it, expect } from 'vitest';
import { WorkflowConfigRawSchema, ParallelSubStepRawSchema, WorkflowStepRawSchema } from '../core/models/index.js';
describe('ParallelSubStepRawSchema', () => {
it('should validate a valid parallel sub-step', () => {
it('should validate a valid parallel sub-movement', () => {
const raw = {
name: 'arch-review',
agent: '~/.takt/agents/default/reviewer.md',
@ -22,7 +22,7 @@ describe('ParallelSubStepRawSchema', () => {
expect(result.success).toBe(true);
});
it('should accept a sub-step without agent (instruction_template only)', () => {
it('should accept a sub-movement without agent (instruction_template only)', () => {
const raw = {
name: 'no-agent-step',
instruction_template: 'Do something',
@ -54,7 +54,7 @@ describe('ParallelSubStepRawSchema', () => {
}
});
it('should accept rules on sub-steps', () => {
it('should accept rules on sub-movements', () => {
const raw = {
name: 'reviewed',
agent: '~/.takt/agents/default/reviewer.md',
@ -74,7 +74,7 @@ describe('ParallelSubStepRawSchema', () => {
});
describe('WorkflowStepRawSchema with parallel', () => {
it('should accept a step with parallel sub-steps (no agent)', () => {
it('should accept a movement with parallel sub-movements (no agent)', () => {
const raw = {
name: 'parallel-review',
parallel: [
@ -90,7 +90,7 @@ describe('WorkflowStepRawSchema with parallel', () => {
expect(result.success).toBe(true);
});
it('should accept a step with neither agent nor parallel (instruction_template only)', () => {
it('should accept a movement with neither agent nor parallel (instruction_template only)', () => {
const raw = {
name: 'orphan-step',
instruction_template: 'Do something',
@ -100,7 +100,7 @@ describe('WorkflowStepRawSchema with parallel', () => {
expect(result.success).toBe(true);
});
it('should accept a step with agent (no parallel)', () => {
it('should accept a movement with agent (no parallel)', () => {
const raw = {
name: 'normal-step',
agent: 'coder.md',
@ -111,7 +111,7 @@ describe('WorkflowStepRawSchema with parallel', () => {
expect(result.success).toBe(true);
});
it('should accept a step with empty parallel array (no agent, no parallel content)', () => {
it('should accept a movement with empty parallel array (no agent, no parallel content)', () => {
const raw = {
name: 'empty-parallel',
parallel: [],
@ -122,11 +122,11 @@ describe('WorkflowStepRawSchema with parallel', () => {
});
});
describe('WorkflowConfigRawSchema with parallel steps', () => {
it('should validate a workflow with parallel step', () => {
describe('WorkflowConfigRawSchema with parallel movements', () => {
it('should validate a workflow with parallel movement', () => {
const raw = {
name: 'test-parallel-workflow',
steps: [
movements: [
{
name: 'plan',
agent: 'planner.md',
@ -144,22 +144,22 @@ describe('WorkflowConfigRawSchema with parallel steps', () => {
],
},
],
initial_step: 'plan',
initial_movement: 'plan',
max_iterations: 10,
};
const result = WorkflowConfigRawSchema.safeParse(raw);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.steps).toHaveLength(2);
expect(result.data.steps[1].parallel).toHaveLength(2);
expect(result.data.movements).toHaveLength(2);
expect(result.data.movements[1].parallel).toHaveLength(2);
}
});
it('should validate a workflow mixing normal and parallel steps', () => {
it('should validate a workflow mixing normal and parallel movements', () => {
const raw = {
name: 'mixed-workflow',
steps: [
movements: [
{ name: 'plan', agent: 'planner.md', rules: [{ condition: 'Done', next: 'implement' }] },
{ name: 'implement', agent: 'coder.md', rules: [{ condition: 'Done', next: 'review' }] },
{
@ -171,14 +171,14 @@ describe('WorkflowConfigRawSchema with parallel steps', () => {
rules: [{ condition: 'All pass', next: 'COMPLETE' }],
},
],
initial_step: 'plan',
initial_movement: 'plan',
};
const result = WorkflowConfigRawSchema.safeParse(raw);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.steps[0].agent).toBe('planner.md');
expect(result.data.steps[2].parallel).toHaveLength(2);
expect(result.data.movements[0].agent).toBe('planner.md');
expect(result.data.movements[2].parallel).toHaveLength(2);
}
});
});
@ -369,7 +369,7 @@ describe('aggregate condition evaluation logic', () => {
{ condition: 'rejected' },
];
it('all(): true when all sub-steps match', () => {
it('all(): true when all sub-movements match', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: 0, rules },
@ -377,7 +377,7 @@ describe('aggregate condition evaluation logic', () => {
expect(evaluateAggregate('all', 'approved', subs)).toBe(true);
});
it('all(): false when some sub-steps do not match', () => {
it('all(): false when some sub-movements do not match', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: 1, rules },
@ -385,7 +385,7 @@ describe('aggregate condition evaluation logic', () => {
expect(evaluateAggregate('all', 'approved', subs)).toBe(false);
});
it('all(): false when sub-step has no matched rule', () => {
it('all(): false when sub-movement has no matched rule', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: undefined, rules },
@ -393,7 +393,7 @@ describe('aggregate condition evaluation logic', () => {
expect(evaluateAggregate('all', 'approved', subs)).toBe(false);
});
it('all(): false when sub-step has no rules', () => {
it('all(): false when sub-movement has no rules', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: 0, rules: undefined },
@ -401,11 +401,11 @@ describe('aggregate condition evaluation logic', () => {
expect(evaluateAggregate('all', 'approved', subs)).toBe(false);
});
it('all(): false with zero sub-steps', () => {
it('all(): false with zero sub-movements', () => {
expect(evaluateAggregate('all', 'approved', [])).toBe(false);
});
it('any(): true when one sub-step matches', () => {
it('any(): true when one sub-movement matches', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: 1, rules },
@ -413,7 +413,7 @@ describe('aggregate condition evaluation logic', () => {
expect(evaluateAggregate('any', 'rejected', subs)).toBe(true);
});
it('any(): true when all sub-steps match', () => {
it('any(): true when all sub-movements match', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 1, rules },
{ name: 'b', matchedRuleIndex: 1, rules },
@ -421,7 +421,7 @@ describe('aggregate condition evaluation logic', () => {
expect(evaluateAggregate('any', 'rejected', subs)).toBe(true);
});
it('any(): false when no sub-steps match', () => {
it('any(): false when no sub-movements match', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: 0, rules },
{ name: 'b', matchedRuleIndex: 0, rules },
@ -429,11 +429,11 @@ describe('aggregate condition evaluation logic', () => {
expect(evaluateAggregate('any', 'rejected', subs)).toBe(false);
});
it('any(): false with zero sub-steps', () => {
it('any(): false with zero sub-movements', () => {
expect(evaluateAggregate('any', 'rejected', [])).toBe(false);
});
it('any(): skips sub-steps without matched rule (does not count as match)', () => {
it('any(): skips sub-movements without matched rule (does not count as match)', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: undefined, rules },
{ name: 'b', matchedRuleIndex: 1, rules },
@ -441,7 +441,7 @@ describe('aggregate condition evaluation logic', () => {
expect(evaluateAggregate('any', 'rejected', subs)).toBe(true);
});
it('any(): false when only unmatched sub-steps exist', () => {
it('any(): false when only unmatched sub-movements exist', () => {
const subs: SubResult[] = [
{ name: 'a', matchedRuleIndex: undefined, rules },
{ name: 'b', matchedRuleIndex: undefined, rules },
@ -473,8 +473,8 @@ describe('aggregate condition evaluation logic', () => {
});
});
describe('parallel step aggregation format', () => {
it('should aggregate sub-step outputs in the expected format', () => {
describe('parallel movement aggregation format', () => {
it('should aggregate sub-movement outputs in the expected format', () => {
// Mirror the aggregation logic from engine.ts
const subResults = [
{ name: 'arch-review', content: 'Architecture looks good.\n## Result: APPROVE' },
@ -492,7 +492,7 @@ describe('parallel step aggregation format', () => {
expect(aggregatedContent).toContain('No security issues.');
});
it('should handle single sub-step', () => {
it('should handle single sub-movement', () => {
const subResults = [
{ name: 'only-step', content: 'Single result' },
];
@ -505,7 +505,7 @@ describe('parallel step aggregation format', () => {
expect(aggregatedContent).not.toContain('---');
});
it('should handle empty content from sub-steps', () => {
it('should handle empty content from sub-movements', () => {
const subResults = [
{ name: 'step-a', content: '' },
{ name: 'step-b', content: 'Has content' },

View File

@ -18,7 +18,7 @@ describe('ParallelLogger', () => {
describe('buildPrefix', () => {
it('should build colored prefix with padding', () => {
const logger = new ParallelLogger({
subStepNames: ['arch-review', 'sec'],
subMovementNames: ['arch-review', 'sec'],
writeFn,
});
@ -34,7 +34,7 @@ describe('ParallelLogger', () => {
it('should cycle colors for index >= 4', () => {
const logger = new ParallelLogger({
subStepNames: ['a', 'b', 'c', 'd', 'e'],
subMovementNames: ['a', 'b', 'c', 'd', 'e'],
writeFn,
});
@ -47,7 +47,7 @@ describe('ParallelLogger', () => {
it('should assign correct colors in order', () => {
const logger = new ParallelLogger({
subStepNames: ['a', 'b', 'c', 'd'],
subMovementNames: ['a', 'b', 'c', 'd'],
writeFn,
});
@ -59,7 +59,7 @@ describe('ParallelLogger', () => {
it('should have no extra padding for longest name', () => {
const logger = new ParallelLogger({
subStepNames: ['long-name', 'short'],
subMovementNames: ['long-name', 'short'],
writeFn,
});
@ -72,7 +72,7 @@ describe('ParallelLogger', () => {
describe('text event line buffering', () => {
it('should buffer partial line and output on newline', () => {
const logger = new ParallelLogger({
subStepNames: ['step-a'],
subMovementNames: ['step-a'],
writeFn,
});
const handler = logger.createStreamHandler('step-a', 0);
@ -91,7 +91,7 @@ describe('ParallelLogger', () => {
it('should handle multiple lines in single text event', () => {
const logger = new ParallelLogger({
subStepNames: ['step-a'],
subMovementNames: ['step-a'],
writeFn,
});
const handler = logger.createStreamHandler('step-a', 0);
@ -104,7 +104,7 @@ describe('ParallelLogger', () => {
it('should output empty line without prefix', () => {
const logger = new ParallelLogger({
subStepNames: ['step-a'],
subMovementNames: ['step-a'],
writeFn,
});
const handler = logger.createStreamHandler('step-a', 0);
@ -118,7 +118,7 @@ describe('ParallelLogger', () => {
it('should keep trailing partial in buffer', () => {
const logger = new ParallelLogger({
subStepNames: ['step-a'],
subMovementNames: ['step-a'],
writeFn,
});
const handler = logger.createStreamHandler('step-a', 0);
@ -137,7 +137,7 @@ describe('ParallelLogger', () => {
describe('block events (tool_use, tool_result, tool_output, thinking)', () => {
it('should prefix tool_use events', () => {
const logger = new ParallelLogger({
subStepNames: ['sub-a'],
subMovementNames: ['sub-a'],
writeFn,
});
const handler = logger.createStreamHandler('sub-a', 0);
@ -154,7 +154,7 @@ describe('ParallelLogger', () => {
it('should prefix tool_result events', () => {
const logger = new ParallelLogger({
subStepNames: ['sub-a'],
subMovementNames: ['sub-a'],
writeFn,
});
const handler = logger.createStreamHandler('sub-a', 0);
@ -170,7 +170,7 @@ describe('ParallelLogger', () => {
it('should prefix multi-line tool output', () => {
const logger = new ParallelLogger({
subStepNames: ['sub-a'],
subMovementNames: ['sub-a'],
writeFn,
});
const handler = logger.createStreamHandler('sub-a', 0);
@ -187,7 +187,7 @@ describe('ParallelLogger', () => {
it('should prefix thinking events', () => {
const logger = new ParallelLogger({
subStepNames: ['sub-a'],
subMovementNames: ['sub-a'],
writeFn,
});
const handler = logger.createStreamHandler('sub-a', 0);
@ -206,7 +206,7 @@ describe('ParallelLogger', () => {
it('should delegate init event to parent callback', () => {
const parentEvents: StreamEvent[] = [];
const logger = new ParallelLogger({
subStepNames: ['sub-a'],
subMovementNames: ['sub-a'],
parentOnStream: (event) => parentEvents.push(event),
writeFn,
});
@ -226,7 +226,7 @@ describe('ParallelLogger', () => {
it('should delegate result event to parent callback', () => {
const parentEvents: StreamEvent[] = [];
const logger = new ParallelLogger({
subStepNames: ['sub-a'],
subMovementNames: ['sub-a'],
parentOnStream: (event) => parentEvents.push(event),
writeFn,
});
@ -245,7 +245,7 @@ describe('ParallelLogger', () => {
it('should delegate error event to parent callback', () => {
const parentEvents: StreamEvent[] = [];
const logger = new ParallelLogger({
subStepNames: ['sub-a'],
subMovementNames: ['sub-a'],
parentOnStream: (event) => parentEvents.push(event),
writeFn,
});
@ -263,7 +263,7 @@ describe('ParallelLogger', () => {
it('should not crash when no parent callback for delegated events', () => {
const logger = new ParallelLogger({
subStepNames: ['sub-a'],
subMovementNames: ['sub-a'],
writeFn,
});
const handler = logger.createStreamHandler('sub-a', 0);
@ -280,7 +280,7 @@ describe('ParallelLogger', () => {
describe('flush', () => {
it('should output remaining buffered content', () => {
const logger = new ParallelLogger({
subStepNames: ['step-a', 'step-b'],
subMovementNames: ['step-a', 'step-b'],
writeFn,
});
const handlerA = logger.createStreamHandler('step-a', 0);
@ -300,7 +300,7 @@ describe('ParallelLogger', () => {
it('should not output empty buffers', () => {
const logger = new ParallelLogger({
subStepNames: ['step-a', 'step-b'],
subMovementNames: ['step-a', 'step-b'],
writeFn,
});
const handlerA = logger.createStreamHandler('step-a', 0);
@ -316,7 +316,7 @@ describe('ParallelLogger', () => {
describe('printSummary', () => {
it('should print completion summary', () => {
const logger = new ParallelLogger({
subStepNames: ['arch-review', 'security-review'],
subMovementNames: ['arch-review', 'security-review'],
writeFn,
});
@ -337,7 +337,7 @@ describe('ParallelLogger', () => {
it('should show (no result) for undefined condition', () => {
const logger = new ParallelLogger({
subStepNames: ['step-a'],
subMovementNames: ['step-a'],
writeFn,
});
@ -349,9 +349,9 @@ describe('ParallelLogger', () => {
expect(fullOutput).toContain('(no result)');
});
it('should right-pad sub-step names to align results', () => {
it('should right-pad sub-movement names to align results', () => {
const logger = new ParallelLogger({
subStepNames: ['short', 'very-long-name'],
subMovementNames: ['short', 'very-long-name'],
writeFn,
});
@ -372,7 +372,7 @@ describe('ParallelLogger', () => {
it('should flush remaining buffers before printing summary', () => {
const logger = new ParallelLogger({
subStepNames: ['step-a'],
subMovementNames: ['step-a'],
writeFn,
});
const handler = logger.createStreamHandler('step-a', 0);
@ -392,10 +392,10 @@ describe('ParallelLogger', () => {
});
});
describe('interleaved output from multiple sub-steps', () => {
describe('interleaved output from multiple sub-movements', () => {
it('should correctly interleave prefixed output', () => {
const logger = new ParallelLogger({
subStepNames: ['step-a', 'step-b'],
subMovementNames: ['step-a', 'step-b'],
writeFn,
});
const handlerA = logger.createStreamHandler('step-a', 0);

View File

@ -1,178 +1,230 @@
/**
* Tests for prompt loader utility (src/shared/prompts/index.ts)
* Tests for Markdown template loader (src/shared/prompts/index.ts)
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { getPrompt, getPromptObject, _resetCache } from '../shared/prompts/index.js';
import { loadTemplate, renderTemplate, _resetCache } from '../shared/prompts/index.js';
beforeEach(() => {
_resetCache();
});
describe('getPrompt', () => {
it('returns a language-independent prompt by key (defaults to en)', () => {
const result = getPrompt('summarize.slugGenerator');
describe('loadTemplate', () => {
it('loads an English template', () => {
const result = loadTemplate('score_slug_system_prompt', 'en');
expect(result).toContain('You are a slug generator');
});
it('returns an English prompt when lang is "en"', () => {
const result = getPrompt('interactive.systemPrompt', 'en');
it('loads an English interactive template', () => {
const result = loadTemplate('score_interactive_system_prompt', 'en');
expect(result).toContain('You are a task planning assistant');
});
it('returns a Japanese prompt when lang is "ja"', () => {
const result = getPrompt('interactive.systemPrompt', 'ja');
it('loads a Japanese template', () => {
const result = loadTemplate('score_interactive_system_prompt', 'ja');
expect(result).toContain('あなたはTAKT');
});
it('throws for a non-existent key', () => {
expect(() => getPrompt('nonexistent.key')).toThrow('Prompt key not found: nonexistent.key');
});
it('throws for a non-existent key with language', () => {
expect(() => getPrompt('nonexistent.key', 'en')).toThrow('Prompt key not found: nonexistent.key (lang: en)');
});
it('returns prompt from en file when lang is explicitly "en"', () => {
const result = getPrompt('summarize.slugGenerator', 'en');
it('loads score_slug_system_prompt with explicit lang', () => {
const result = loadTemplate('score_slug_system_prompt', 'en');
expect(result).toContain('You are a slug generator');
});
describe('template variable substitution', () => {
it('replaces {variableName} placeholders with provided values', () => {
const result = getPrompt('claude.agentDefault', undefined, { agentName: 'test-agent' });
it('throws for a non-existent template with language', () => {
expect(() => loadTemplate('nonexistent_template', 'en')).toThrow('Template not found: nonexistent_template (lang: en)');
});
});
describe('variable substitution', () => {
it('replaces {{variableName}} placeholders with provided values', () => {
const result = loadTemplate('perform_builtin_agent_system_prompt', 'en', { agentName: 'test-agent' });
expect(result).toContain('You are the test-agent agent');
expect(result).toContain('Follow the standard test-agent workflow');
});
it('leaves unmatched placeholders as-is', () => {
const result = getPrompt('claude.agentDefault', undefined, {});
expect(result).toContain('{agentName}');
it('replaces undefined variables with empty string', () => {
const result = loadTemplate('perform_builtin_agent_system_prompt', 'en', {});
expect(result).not.toContain('{{agentName}}');
expect(result).toContain('You are the agent');
});
it('replaces multiple different variables', () => {
const result = getPrompt('claude.judgePrompt', undefined, {
const result = loadTemplate('perform_judge_message', 'en', {
agentOutput: 'test output',
conditionList: '| 1 | Success |',
});
expect(result).toContain('test output');
expect(result).toContain('| 1 | Success |');
});
it('replaces workflow info variables in interactive prompt', () => {
const result = loadTemplate('score_interactive_system_prompt', 'en', {
workflowInfo: true,
workflowName: 'my-workflow',
workflowDescription: 'Test description',
});
expect(result).toContain('"my-workflow"');
expect(result).toContain('Test description');
});
});
describe('getPromptObject', () => {
it('returns an object for a given key and language', () => {
const result = getPromptObject<{ heading: string }>('instruction.metadata', 'en');
expect(result.heading).toBe('## Execution Context');
describe('renderTemplate', () => {
it('processes {{#if}} blocks with truthy value', () => {
const template = 'before{{#if show}}visible{{/if}}after';
const result = renderTemplate(template, { show: true });
expect(result).toBe('beforevisibleafter');
});
it('returns a Japanese object when lang is "ja"', () => {
const result = getPromptObject<{ heading: string }>('instruction.metadata', 'ja');
expect(result.heading).toBe('## 実行コンテキスト');
it('processes {{#if}} blocks with falsy value', () => {
const template = 'before{{#if show}}visible{{/if}}after';
const result = renderTemplate(template, { show: false });
expect(result).toBe('beforeafter');
});
it('throws for a non-existent key', () => {
expect(() => getPromptObject('nonexistent.key')).toThrow('Prompt key not found: nonexistent.key');
it('processes {{#if}}...{{else}}...{{/if}} blocks', () => {
const template = '{{#if flag}}yes{{else}}no{{/if}}';
expect(renderTemplate(template, { flag: true })).toBe('yes');
expect(renderTemplate(template, { flag: false })).toBe('no');
});
it('treats empty string as falsy', () => {
const template = '{{#if value}}has value{{else}}empty{{/if}}';
expect(renderTemplate(template, { value: '' })).toBe('empty');
});
it('treats non-empty string as truthy', () => {
const template = '{{#if value}}has value{{else}}empty{{/if}}';
expect(renderTemplate(template, { value: 'hello' })).toBe('has value');
});
it('handles undefined variable in condition as falsy', () => {
const template = '{{#if missing}}yes{{else}}no{{/if}}';
expect(renderTemplate(template, {})).toBe('no');
});
it('replaces boolean true with "true" string', () => {
const template = 'value is {{flag}}';
expect(renderTemplate(template, { flag: true })).toBe('value is true');
});
it('replaces boolean false with empty string', () => {
const template = 'value is [{{flag}}]';
expect(renderTemplate(template, { flag: false })).toBe('value is []');
});
});
describe('template file existence', () => {
const allTemplates = [
'score_interactive_system_prompt',
'score_summary_system_prompt',
'score_slug_system_prompt',
'perform_phase1_message',
'perform_phase2_message',
'perform_phase3_message',
'perform_agent_system_prompt',
'perform_builtin_agent_system_prompt',
'perform_judge_message',
];
for (const name of allTemplates) {
it(`en/${name}.md exists and is loadable`, () => {
expect(() => loadTemplate(name, 'en')).not.toThrow();
});
it(`ja/${name}.md exists and is loadable`, () => {
expect(() => loadTemplate(name, 'ja')).not.toThrow();
});
}
});
describe('caching', () => {
it('returns the same data on repeated calls', () => {
const first = getPrompt('summarize.slugGenerator');
const second = getPrompt('summarize.slugGenerator');
it('returns consistent results on repeated calls', () => {
const first = loadTemplate('score_slug_system_prompt', 'en');
const second = loadTemplate('score_slug_system_prompt', 'en');
expect(first).toBe(second);
});
it('reloads after cache reset', () => {
const first = getPrompt('summarize.slugGenerator');
const first = loadTemplate('score_slug_system_prompt', 'en');
_resetCache();
const second = getPrompt('summarize.slugGenerator');
const second = loadTemplate('score_slug_system_prompt', 'en');
expect(first).toBe(second);
});
});
describe('YAML content integrity', () => {
it('contains all expected top-level keys in en', () => {
expect(() => getPrompt('interactive.systemPrompt', 'en')).not.toThrow();
expect(() => getPrompt('interactive.summaryPrompt', 'en')).not.toThrow();
expect(() => getPrompt('interactive.workflowInfo', 'en')).not.toThrow();
expect(() => getPrompt('interactive.conversationLabel', 'en')).not.toThrow();
expect(() => getPrompt('interactive.noTranscript', 'en')).not.toThrow();
expect(() => getPrompt('summarize.slugGenerator')).not.toThrow();
expect(() => getPrompt('claude.agentDefault')).not.toThrow();
expect(() => getPrompt('claude.judgePrompt')).not.toThrow();
expect(() => getPromptObject('instruction.metadata', 'en')).not.toThrow();
expect(() => getPromptObject('instruction.sections', 'en')).not.toThrow();
expect(() => getPromptObject('instruction.reportOutput', 'en')).not.toThrow();
expect(() => getPromptObject('instruction.reportPhase', 'en')).not.toThrow();
expect(() => getPromptObject('instruction.reportSections', 'en')).not.toThrow();
expect(() => getPrompt('instruction.statusJudgment.header', 'en')).not.toThrow();
expect(() => getPromptObject('instruction.statusRules', 'en')).not.toThrow();
describe('template content integrity', () => {
it('score_interactive_system_prompt contains core instructions', () => {
const en = loadTemplate('score_interactive_system_prompt', 'en');
expect(en).toContain('task planning assistant');
const ja = loadTemplate('score_interactive_system_prompt', 'ja');
expect(ja).toContain('あなたはTAKT');
});
it('contains all expected top-level keys in ja', () => {
expect(() => getPrompt('interactive.systemPrompt', 'ja')).not.toThrow();
expect(() => getPrompt('interactive.summaryPrompt', 'ja')).not.toThrow();
expect(() => getPrompt('interactive.workflowInfo', 'ja')).not.toThrow();
expect(() => getPrompt('interactive.conversationLabel', 'ja')).not.toThrow();
expect(() => getPrompt('interactive.noTranscript', 'ja')).not.toThrow();
expect(() => getPrompt('summarize.slugGenerator', 'ja')).not.toThrow();
expect(() => getPrompt('claude.agentDefault', 'ja')).not.toThrow();
expect(() => getPrompt('claude.judgePrompt', 'ja')).not.toThrow();
expect(() => getPromptObject('instruction.metadata', 'ja')).not.toThrow();
expect(() => getPromptObject('instruction.sections', 'ja')).not.toThrow();
expect(() => getPromptObject('instruction.reportOutput', 'ja')).not.toThrow();
expect(() => getPromptObject('instruction.reportPhase', 'ja')).not.toThrow();
expect(() => getPromptObject('instruction.reportSections', 'ja')).not.toThrow();
expect(() => getPrompt('instruction.statusJudgment.header', 'ja')).not.toThrow();
expect(() => getPromptObject('instruction.statusRules', 'ja')).not.toThrow();
it('score_slug_system_prompt contains format specification', () => {
const result = loadTemplate('score_slug_system_prompt', 'en');
expect(result).toContain('verb-noun');
expect(result).toContain('max 30 chars');
});
it('instruction.metadata has all required fields', () => {
const en = getPromptObject<Record<string, string>>('instruction.metadata', 'en');
expect(en).toHaveProperty('heading');
expect(en).toHaveProperty('workingDirectory');
expect(en).toHaveProperty('rulesHeading');
expect(en).toHaveProperty('noCommit');
expect(en).toHaveProperty('noCd');
expect(en).toHaveProperty('editEnabled');
expect(en).toHaveProperty('editDisabled');
expect(en).toHaveProperty('note');
it('perform_builtin_agent_system_prompt contains {{agentName}} placeholder', () => {
const result = loadTemplate('perform_builtin_agent_system_prompt', 'en');
expect(result).toContain('{{agentName}}');
});
it('instruction.sections has all required fields', () => {
const en = getPromptObject<Record<string, string>>('instruction.sections', 'en');
expect(en).toHaveProperty('workflowContext');
expect(en).toHaveProperty('iteration');
expect(en).toHaveProperty('step');
expect(en).toHaveProperty('userRequest');
expect(en).toHaveProperty('instructions');
it('perform_agent_system_prompt contains {{agentDefinition}} placeholder', () => {
const result = loadTemplate('perform_agent_system_prompt', 'en');
expect(result).toContain('{{agentDefinition}}');
});
it('instruction.statusRules has appendixInstruction with {tag} placeholder', () => {
const en = getPromptObject<{ appendixInstruction: string }>('instruction.statusRules', 'en');
expect(en.appendixInstruction).toContain('{tag}');
it('perform_judge_message contains {{agentOutput}} and {{conditionList}} placeholders', () => {
const result = loadTemplate('perform_judge_message', 'en');
expect(result).toContain('{{agentOutput}}');
expect(result).toContain('{{conditionList}}');
});
it('en and ja files have the same key structure', () => {
// Verify a sampling of keys exist in both languages
const stringKeys = [
'interactive.systemPrompt',
'summarize.slugGenerator',
'claude.agentDefault',
it('perform_phase1_message contains execution context and rules sections', () => {
const en = loadTemplate('perform_phase1_message', 'en');
expect(en).toContain('## Execution Context');
expect(en).toContain('## Execution Rules');
expect(en).toContain('Do NOT run git commit');
expect(en).toContain('Do NOT use `cd`');
expect(en).toContain('## Workflow Context');
expect(en).toContain('## Instructions');
});
it('perform_phase1_message contains workflow context variables', () => {
const en = loadTemplate('perform_phase1_message', 'en');
expect(en).toContain('{{iteration}}');
expect(en).toContain('{{movement}}');
expect(en).toContain('{{workingDirectory}}');
});
it('perform_phase2_message contains report-specific rules', () => {
const en = loadTemplate('perform_phase2_message', 'en');
expect(en).toContain('Do NOT modify project source files');
expect(en).toContain('## Instructions');
const ja = loadTemplate('perform_phase2_message', 'ja');
expect(ja).toContain('プロジェクトのソースファイルを変更しないでください');
});
it('perform_phase3_message contains criteria and output variables', () => {
const en = loadTemplate('perform_phase3_message', 'en');
expect(en).toContain('{{criteriaTable}}');
expect(en).toContain('{{outputList}}');
});
it('MD files contain only prompt body (no front matter)', () => {
const templates = [
'score_interactive_system_prompt',
'score_summary_system_prompt',
'perform_phase1_message',
'perform_phase2_message',
];
for (const key of stringKeys) {
expect(() => getPrompt(key, 'en')).not.toThrow();
expect(() => getPrompt(key, 'ja')).not.toThrow();
}
const objectKeys = [
'instruction.metadata',
'instruction.sections',
];
for (const key of objectKeys) {
expect(() => getPromptObject(key, 'en')).not.toThrow();
expect(() => getPromptObject(key, 'ja')).not.toThrow();
for (const name of templates) {
const content = loadTemplate(name, 'en');
expect(content).not.toMatch(/^---\n/);
}
});
});

View File

@ -4,7 +4,7 @@
* Covers:
* - Workflow YAML files (EN/JA) load and pass schema validation
* - Workflow structure: plan -> reviewers (parallel) -> supervise -> pr-comment
* - All steps have edit: false
* - All movements have edit: false
* - pr-commenter agent has Bash in allowed_tools
* - Routing rules for local vs PR comment flows
*/
@ -18,7 +18,7 @@ import { WorkflowConfigRawSchema } from '../core/models/index.js';
const RESOURCES_DIR = join(import.meta.dirname, '../../resources/global');
function loadReviewOnlyYaml(lang: 'en' | 'ja') {
const filePath = join(RESOURCES_DIR, lang, 'workflows', 'review-only.yaml');
const filePath = join(RESOURCES_DIR, lang, 'pieces', 'review-only.yaml');
const content = readFileSync(filePath, 'utf-8');
return parseYaml(content);
}
@ -31,27 +31,27 @@ describe('review-only workflow (EN)', () => {
expect(result.success).toBe(true);
});
it('should have correct name and initial_step', () => {
it('should have correct name and initial_movement', () => {
expect(raw.name).toBe('review-only');
expect(raw.initial_step).toBe('plan');
expect(raw.initial_movement).toBe('plan');
});
it('should have max_iterations of 10', () => {
expect(raw.max_iterations).toBe(10);
});
it('should have 4 steps: plan, reviewers, supervise, pr-comment', () => {
const stepNames = raw.steps.map((s: { name: string }) => s.name);
expect(stepNames).toEqual(['plan', 'reviewers', 'supervise', 'pr-comment']);
it('should have 4 movements: plan, reviewers, supervise, pr-comment', () => {
const movementNames = raw.movements.map((s: { name: string }) => s.name);
expect(movementNames).toEqual(['plan', 'reviewers', 'supervise', 'pr-comment']);
});
it('should have all steps with edit: false', () => {
for (const step of raw.steps) {
if (step.edit !== undefined) {
expect(step.edit).toBe(false);
it('should have all movements with edit: false', () => {
for (const movement of raw.movements) {
if (movement.edit !== undefined) {
expect(movement.edit).toBe(false);
}
if (step.parallel) {
for (const sub of step.parallel) {
if (movement.parallel) {
for (const sub of movement.parallel) {
if (sub.edit !== undefined) {
expect(sub.edit).toBe(false);
}
@ -60,8 +60,8 @@ describe('review-only workflow (EN)', () => {
}
});
it('should have reviewers step with 3 parallel sub-steps', () => {
const reviewers = raw.steps.find((s: { name: string }) => s.name === 'reviewers');
it('should have reviewers movement with 3 parallel sub-movements', () => {
const reviewers = raw.movements.find((s: { name: string }) => s.name === 'reviewers');
expect(reviewers).toBeDefined();
expect(reviewers.parallel).toHaveLength(3);
@ -69,8 +69,8 @@ describe('review-only workflow (EN)', () => {
expect(subNames).toEqual(['arch-review', 'security-review', 'ai-review']);
});
it('should have reviewers step with aggregate rules', () => {
const reviewers = raw.steps.find((s: { name: string }) => s.name === 'reviewers');
it('should have reviewers movement with aggregate rules', () => {
const reviewers = raw.movements.find((s: { name: string }) => s.name === 'reviewers');
expect(reviewers.rules).toHaveLength(2);
expect(reviewers.rules[0].condition).toBe('all("approved")');
expect(reviewers.rules[0].next).toBe('supervise');
@ -78,8 +78,8 @@ describe('review-only workflow (EN)', () => {
expect(reviewers.rules[1].next).toBe('supervise');
});
it('should have supervise step with routing rules for local and PR flows', () => {
const supervise = raw.steps.find((s: { name: string }) => s.name === 'supervise');
it('should have supervise movement with routing rules for local and PR flows', () => {
const supervise = raw.movements.find((s: { name: string }) => s.name === 'supervise');
expect(supervise.rules).toHaveLength(3);
const conditions = supervise.rules.map((r: { condition: string }) => r.condition);
@ -97,40 +97,40 @@ describe('review-only workflow (EN)', () => {
expect(rejectRule.next).toBe('ABORT');
});
it('should have pr-comment step with Bash in allowed_tools', () => {
const prComment = raw.steps.find((s: { name: string }) => s.name === 'pr-comment');
it('should have pr-comment movement with Bash in allowed_tools', () => {
const prComment = raw.movements.find((s: { name: string }) => s.name === 'pr-comment');
expect(prComment).toBeDefined();
expect(prComment.allowed_tools).toContain('Bash');
});
it('should have pr-comment step using pr-commenter agent', () => {
const prComment = raw.steps.find((s: { name: string }) => s.name === 'pr-comment');
it('should have pr-comment movement using pr-commenter agent', () => {
const prComment = raw.movements.find((s: { name: string }) => s.name === 'pr-comment');
expect(prComment.agent).toContain('review/pr-commenter.md');
});
it('should have plan step reusing default planner agent', () => {
const plan = raw.steps.find((s: { name: string }) => s.name === 'plan');
it('should have plan movement reusing default planner agent', () => {
const plan = raw.movements.find((s: { name: string }) => s.name === 'plan');
expect(plan.agent).toContain('default/planner.md');
});
it('should have supervise step reusing default supervisor agent', () => {
const supervise = raw.steps.find((s: { name: string }) => s.name === 'supervise');
it('should have supervise movement reusing default supervisor agent', () => {
const supervise = raw.movements.find((s: { name: string }) => s.name === 'supervise');
expect(supervise.agent).toContain('default/supervisor.md');
});
it('should not have any step with edit: true', () => {
for (const step of raw.steps) {
expect(step.edit).not.toBe(true);
if (step.parallel) {
for (const sub of step.parallel) {
it('should not have any movement with edit: true', () => {
for (const movement of raw.movements) {
expect(movement.edit).not.toBe(true);
if (movement.parallel) {
for (const sub of movement.parallel) {
expect(sub.edit).not.toBe(true);
}
}
}
});
it('reviewer sub-steps should not have Bash in allowed_tools', () => {
const reviewers = raw.steps.find((s: { name: string }) => s.name === 'reviewers');
it('reviewer sub-movements should not have Bash in allowed_tools', () => {
const reviewers = raw.movements.find((s: { name: string }) => s.name === 'reviewers');
for (const sub of reviewers.parallel) {
expect(sub.allowed_tools).not.toContain('Bash');
}
@ -145,42 +145,42 @@ describe('review-only workflow (JA)', () => {
expect(result.success).toBe(true);
});
it('should have correct name and initial_step', () => {
it('should have correct name and initial_movement', () => {
expect(raw.name).toBe('review-only');
expect(raw.initial_step).toBe('plan');
expect(raw.initial_movement).toBe('plan');
});
it('should have same step structure as EN version', () => {
const stepNames = raw.steps.map((s: { name: string }) => s.name);
expect(stepNames).toEqual(['plan', 'reviewers', 'supervise', 'pr-comment']);
it('should have same movement structure as EN version', () => {
const movementNames = raw.movements.map((s: { name: string }) => s.name);
expect(movementNames).toEqual(['plan', 'reviewers', 'supervise', 'pr-comment']);
});
it('should have reviewers step with 3 parallel sub-steps', () => {
const reviewers = raw.steps.find((s: { name: string }) => s.name === 'reviewers');
it('should have reviewers movement with 3 parallel sub-movements', () => {
const reviewers = raw.movements.find((s: { name: string }) => s.name === 'reviewers');
expect(reviewers.parallel).toHaveLength(3);
const subNames = reviewers.parallel.map((s: { name: string }) => s.name);
expect(subNames).toEqual(['arch-review', 'security-review', 'ai-review']);
});
it('should have all steps with edit: false or undefined', () => {
for (const step of raw.steps) {
expect(step.edit).not.toBe(true);
if (step.parallel) {
for (const sub of step.parallel) {
it('should have all movements with edit: false or undefined', () => {
for (const movement of raw.movements) {
expect(movement.edit).not.toBe(true);
if (movement.parallel) {
for (const sub of movement.parallel) {
expect(sub.edit).not.toBe(true);
}
}
}
});
it('should have pr-comment step with Bash in allowed_tools', () => {
const prComment = raw.steps.find((s: { name: string }) => s.name === 'pr-comment');
it('should have pr-comment movement with Bash in allowed_tools', () => {
const prComment = raw.movements.find((s: { name: string }) => s.name === 'pr-comment');
expect(prComment.allowed_tools).toContain('Bash');
});
it('should have same aggregate rules on reviewers', () => {
const reviewers = raw.steps.find((s: { name: string }) => s.name === 'reviewers');
const reviewers = raw.movements.find((s: { name: string }) => s.name === 'reviewers');
expect(reviewers.rules[0].condition).toBe('all("approved")');
expect(reviewers.rules[1].condition).toBe('any("needs_fix")');
});
@ -230,7 +230,7 @@ describe('pr-commenter agent files', () => {
describe('pr-comment instruction_template contains workflow-specific procedures', () => {
it('EN: should reference specific report files', () => {
const raw = loadReviewOnlyYaml('en');
const prComment = raw.steps.find((s: { name: string }) => s.name === 'pr-comment');
const prComment = raw.movements.find((s: { name: string }) => s.name === 'pr-comment');
const template = prComment.instruction_template;
expect(template).toContain('01-architect-review.md');
expect(template).toContain('02-security-review.md');
@ -240,7 +240,7 @@ describe('pr-comment instruction_template contains workflow-specific procedures'
it('JA: should reference specific report files', () => {
const raw = loadReviewOnlyYaml('ja');
const prComment = raw.steps.find((s: { name: string }) => s.name === 'pr-comment');
const prComment = raw.movements.find((s: { name: string }) => s.name === 'pr-comment');
const template = prComment.instruction_template;
expect(template).toContain('01-architect-review.md');
expect(template).toContain('02-security-review.md');

View File

@ -1,12 +1,12 @@
/**
* Tests for workflow transitions module
* Tests for workflow transitions module (movement-based)
*/
import { describe, it, expect } from 'vitest';
import { determineNextStepByRules } from '../core/workflow/index.js';
import type { WorkflowStep } from '../core/models/index.js';
import { determineNextMovementByRules } from '../core/workflow/index.js';
import type { WorkflowMovement } from '../core/models/index.js';
function createStepWithRules(rules: { condition: string; next: string }[]): WorkflowStep {
function createMovementWithRules(rules: { condition: string; next: string }[]): WorkflowMovement {
return {
name: 'test-step',
agent: 'test-agent',
@ -20,29 +20,29 @@ function createStepWithRules(rules: { condition: string; next: string }[]): Work
};
}
describe('determineNextStepByRules', () => {
it('should return next step for valid rule index', () => {
const step = createStepWithRules([
describe('determineNextMovementByRules', () => {
it('should return next movement for valid rule index', () => {
const step = createMovementWithRules([
{ condition: 'Clear', next: 'implement' },
{ condition: 'Blocked', next: 'ABORT' },
]);
expect(determineNextStepByRules(step, 0)).toBe('implement');
expect(determineNextStepByRules(step, 1)).toBe('ABORT');
expect(determineNextMovementByRules(step, 0)).toBe('implement');
expect(determineNextMovementByRules(step, 1)).toBe('ABORT');
});
it('should return null for out-of-bounds index', () => {
const step = createStepWithRules([
const step = createMovementWithRules([
{ condition: 'Clear', next: 'implement' },
]);
expect(determineNextStepByRules(step, 1)).toBeNull();
expect(determineNextStepByRules(step, -1)).toBeNull();
expect(determineNextStepByRules(step, 100)).toBeNull();
expect(determineNextMovementByRules(step, 1)).toBeNull();
expect(determineNextMovementByRules(step, -1)).toBeNull();
expect(determineNextMovementByRules(step, 100)).toBeNull();
});
it('should return null when step has no rules', () => {
const step: WorkflowStep = {
it('should return null when movement has no rules', () => {
const step: WorkflowMovement = {
name: 'test-step',
agent: 'test-agent',
agentDisplayName: 'Test Agent',
@ -50,20 +50,20 @@ describe('determineNextStepByRules', () => {
passPreviousResponse: false,
};
expect(determineNextStepByRules(step, 0)).toBeNull();
expect(determineNextMovementByRules(step, 0)).toBeNull();
});
it('should handle COMPLETE as next step', () => {
const step = createStepWithRules([
it('should handle COMPLETE as next movement', () => {
const step = createMovementWithRules([
{ condition: 'All passed', next: 'COMPLETE' },
]);
expect(determineNextStepByRules(step, 0)).toBe('COMPLETE');
expect(determineNextMovementByRules(step, 0)).toBe('COMPLETE');
});
it('should return null when rule exists but next is undefined', () => {
// Parallel sub-step rules may omit `next` (optional field)
const step: WorkflowStep = {
// Parallel sub-movement rules may omit `next` (optional field)
const step: WorkflowMovement = {
name: 'sub-step',
agent: 'test-agent',
agentDisplayName: 'Test Agent',
@ -75,7 +75,7 @@ describe('determineNextStepByRules', () => {
],
};
expect(determineNextStepByRules(step, 0)).toBeNull();
expect(determineNextStepByRules(step, 1)).toBeNull();
expect(determineNextMovementByRules(step, 0)).toBeNull();
expect(determineNextMovementByRules(step, 1)).toBeNull();
});
});

View File

@ -20,7 +20,7 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
const { listWorkflows } = await import('../infra/config/loaders/workflowLoader.js');
const SAMPLE_WORKFLOW = `name: test-workflow
steps:
movements:
- name: step1
agent: coder
instruction: "{task}"

View File

@ -23,10 +23,10 @@ import {
const SAMPLE_WORKFLOW = `name: test-workflow
description: Test workflow
initial_step: step1
initial_movement: step1
max_iterations: 1
steps:
movements:
- name: step1
agent: coder
instruction: "{task}"

View File

@ -75,8 +75,8 @@ function createWorkflowMap(entries: { name: string; source: 'builtin' | 'user' |
source: entry.source,
config: {
name: entry.name,
steps: [],
initialStep: 'start',
movements: [],
initialMovement: 'start',
maxIterations: 1,
},
});

View File

@ -3,11 +3,11 @@
*
* Validates that:
* - expert and expert-cqrs workflows load successfully via loadWorkflow
* - The reviewers step is a parallel step with expected sub-steps
* - ai_review routes to reviewers (not individual review steps)
* - fix step routes back to reviewers
* - Aggregate rules (all/any) are configured on the reviewers step
* - Sub-step rules use simple approved/needs_fix conditions
* - The reviewers movement is a parallel movement with expected sub-movements
* - ai_review routes to reviewers (not individual review movements)
* - fix movement routes back to reviewers
* - Aggregate rules (all/any) are configured on the reviewers movement
* - Sub-movement rules use simple approved/needs_fix conditions
*/
import { describe, it, expect } from 'vitest';
@ -21,15 +21,15 @@ describe('expert workflow parallel structure', () => {
expect(workflow!.name).toBe('expert');
});
it('should have a reviewers parallel step', () => {
const reviewers = workflow!.steps.find((s) => s.name === 'reviewers');
it('should have a reviewers parallel movement', () => {
const reviewers = workflow!.movements.find((s) => s.name === 'reviewers');
expect(reviewers).toBeDefined();
expect(reviewers!.parallel).toBeDefined();
expect(reviewers!.parallel!.length).toBe(4);
});
it('should have arch-review, frontend-review, security-review, qa-review as sub-steps', () => {
const reviewers = workflow!.steps.find((s) => s.name === 'reviewers');
it('should have arch-review, frontend-review, security-review, qa-review as sub-movements', () => {
const reviewers = workflow!.movements.find((s) => s.name === 'reviewers');
const subNames = reviewers!.parallel!.map((s) => s.name);
expect(subNames).toContain('arch-review');
expect(subNames).toContain('frontend-review');
@ -37,16 +37,16 @@ describe('expert workflow parallel structure', () => {
expect(subNames).toContain('qa-review');
});
it('should have aggregate rules on reviewers step', () => {
const reviewers = workflow!.steps.find((s) => s.name === 'reviewers');
it('should have aggregate rules on reviewers movement', () => {
const reviewers = workflow!.movements.find((s) => s.name === 'reviewers');
expect(reviewers!.rules).toBeDefined();
const conditions = reviewers!.rules!.map((r) => r.condition);
expect(conditions).toContain('all("approved")');
expect(conditions).toContain('any("needs_fix")');
});
it('should have simple approved/needs_fix rules on each sub-step', () => {
const reviewers = workflow!.steps.find((s) => s.name === 'reviewers');
it('should have simple approved/needs_fix rules on each sub-movement', () => {
const reviewers = workflow!.movements.find((s) => s.name === 'reviewers');
for (const sub of reviewers!.parallel!) {
expect(sub.rules).toBeDefined();
const conditions = sub.rules!.map((r) => r.condition);
@ -56,39 +56,39 @@ describe('expert workflow parallel structure', () => {
});
it('should route ai_review to reviewers', () => {
const aiReview = workflow!.steps.find((s) => s.name === 'ai_review');
const aiReview = workflow!.movements.find((s) => s.name === 'ai_review');
expect(aiReview).toBeDefined();
const approvedRule = aiReview!.rules!.find((r) => r.next === 'reviewers');
expect(approvedRule).toBeDefined();
});
it('should have a unified fix step routing back to reviewers', () => {
const fix = workflow!.steps.find((s) => s.name === 'fix');
it('should have a unified fix movement routing back to reviewers', () => {
const fix = workflow!.movements.find((s) => s.name === 'fix');
expect(fix).toBeDefined();
const fixComplete = fix!.rules!.find((r) => r.next === 'reviewers');
expect(fixComplete).toBeDefined();
});
it('should not have individual review/fix steps', () => {
const stepNames = workflow!.steps.map((s) => s.name);
expect(stepNames).not.toContain('architect_review');
expect(stepNames).not.toContain('fix_architect');
expect(stepNames).not.toContain('frontend_review');
expect(stepNames).not.toContain('fix_frontend');
expect(stepNames).not.toContain('security_review');
expect(stepNames).not.toContain('fix_security');
expect(stepNames).not.toContain('qa_review');
expect(stepNames).not.toContain('fix_qa');
it('should not have individual review/fix movements', () => {
const movementNames = workflow!.movements.map((s) => s.name);
expect(movementNames).not.toContain('architect_review');
expect(movementNames).not.toContain('fix_architect');
expect(movementNames).not.toContain('frontend_review');
expect(movementNames).not.toContain('fix_frontend');
expect(movementNames).not.toContain('security_review');
expect(movementNames).not.toContain('fix_security');
expect(movementNames).not.toContain('qa_review');
expect(movementNames).not.toContain('fix_qa');
});
it('should route reviewers all("approved") to supervise', () => {
const reviewers = workflow!.steps.find((s) => s.name === 'reviewers');
const reviewers = workflow!.movements.find((s) => s.name === 'reviewers');
const approvedRule = reviewers!.rules!.find((r) => r.condition === 'all("approved")');
expect(approvedRule!.next).toBe('supervise');
});
it('should route reviewers any("needs_fix") to fix', () => {
const reviewers = workflow!.steps.find((s) => s.name === 'reviewers');
const reviewers = workflow!.movements.find((s) => s.name === 'reviewers');
const needsFixRule = reviewers!.rules!.find((r) => r.condition === 'any("needs_fix")');
expect(needsFixRule!.next).toBe('fix');
});
@ -102,15 +102,15 @@ describe('expert-cqrs workflow parallel structure', () => {
expect(workflow!.name).toBe('expert-cqrs');
});
it('should have a reviewers parallel step', () => {
const reviewers = workflow!.steps.find((s) => s.name === 'reviewers');
it('should have a reviewers parallel movement', () => {
const reviewers = workflow!.movements.find((s) => s.name === 'reviewers');
expect(reviewers).toBeDefined();
expect(reviewers!.parallel).toBeDefined();
expect(reviewers!.parallel!.length).toBe(4);
});
it('should have cqrs-es-review instead of arch-review', () => {
const reviewers = workflow!.steps.find((s) => s.name === 'reviewers');
const reviewers = workflow!.movements.find((s) => s.name === 'reviewers');
const subNames = reviewers!.parallel!.map((s) => s.name);
expect(subNames).toContain('cqrs-es-review');
expect(subNames).not.toContain('arch-review');
@ -119,16 +119,16 @@ describe('expert-cqrs workflow parallel structure', () => {
expect(subNames).toContain('qa-review');
});
it('should have aggregate rules on reviewers step', () => {
const reviewers = workflow!.steps.find((s) => s.name === 'reviewers');
it('should have aggregate rules on reviewers movement', () => {
const reviewers = workflow!.movements.find((s) => s.name === 'reviewers');
expect(reviewers!.rules).toBeDefined();
const conditions = reviewers!.rules!.map((r) => r.condition);
expect(conditions).toContain('all("approved")');
expect(conditions).toContain('any("needs_fix")');
});
it('should have simple approved/needs_fix rules on each sub-step', () => {
const reviewers = workflow!.steps.find((s) => s.name === 'reviewers');
it('should have simple approved/needs_fix rules on each sub-movement', () => {
const reviewers = workflow!.movements.find((s) => s.name === 'reviewers');
for (const sub of reviewers!.parallel!) {
expect(sub.rules).toBeDefined();
const conditions = sub.rules!.map((r) => r.condition);
@ -138,33 +138,33 @@ describe('expert-cqrs workflow parallel structure', () => {
});
it('should route ai_review to reviewers', () => {
const aiReview = workflow!.steps.find((s) => s.name === 'ai_review');
const aiReview = workflow!.movements.find((s) => s.name === 'ai_review');
expect(aiReview).toBeDefined();
const approvedRule = aiReview!.rules!.find((r) => r.next === 'reviewers');
expect(approvedRule).toBeDefined();
});
it('should have a unified fix step routing back to reviewers', () => {
const fix = workflow!.steps.find((s) => s.name === 'fix');
it('should have a unified fix movement routing back to reviewers', () => {
const fix = workflow!.movements.find((s) => s.name === 'fix');
expect(fix).toBeDefined();
const fixComplete = fix!.rules!.find((r) => r.next === 'reviewers');
expect(fixComplete).toBeDefined();
});
it('should not have individual review/fix steps', () => {
const stepNames = workflow!.steps.map((s) => s.name);
expect(stepNames).not.toContain('cqrs_es_review');
expect(stepNames).not.toContain('fix_cqrs_es');
expect(stepNames).not.toContain('frontend_review');
expect(stepNames).not.toContain('fix_frontend');
expect(stepNames).not.toContain('security_review');
expect(stepNames).not.toContain('fix_security');
expect(stepNames).not.toContain('qa_review');
expect(stepNames).not.toContain('fix_qa');
it('should not have individual review/fix movements', () => {
const movementNames = workflow!.movements.map((s) => s.name);
expect(movementNames).not.toContain('cqrs_es_review');
expect(movementNames).not.toContain('fix_cqrs_es');
expect(movementNames).not.toContain('frontend_review');
expect(movementNames).not.toContain('fix_frontend');
expect(movementNames).not.toContain('security_review');
expect(movementNames).not.toContain('fix_security');
expect(movementNames).not.toContain('qa_review');
expect(movementNames).not.toContain('fix_qa');
});
it('should use cqrs-es-reviewer agent for the first sub-step', () => {
const reviewers = workflow!.steps.find((s) => s.name === 'reviewers');
it('should use cqrs-es-reviewer agent for the first sub-movement', () => {
const reviewers = workflow!.movements.find((s) => s.name === 'reviewers');
const cqrsReview = reviewers!.parallel!.find((s) => s.name === 'cqrs-es-review');
expect(cqrsReview!.agent).toContain('cqrs-es-reviewer');
});

View File

@ -15,10 +15,10 @@ import {
const SAMPLE_WORKFLOW = `name: test-workflow
description: Test workflow
initial_step: step1
initial_movement: step1
max_iterations: 1
steps:
movements:
- name: step1
agent: coder
instruction: "{task}"
@ -172,10 +172,10 @@ describe('loadAllWorkflows with project-local', () => {
const overrideWorkflow = `name: project-override
description: Project override
initial_step: step1
initial_movement: step1
max_iterations: 1
steps:
movements:
- name: step1
agent: coder
instruction: "{task}"

View File

@ -13,6 +13,7 @@ import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig
import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js';
import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js';
import { createLogger } from '../shared/utils/index.js';
import { loadTemplate } from '../shared/prompts/index.js';
import type { RunAgentOptions } from './types.js';
// Re-export for backward compatibility
@ -192,8 +193,11 @@ export class AgentRunner {
});
// 1. If agentPath is provided (resolved file exists), load prompt from file
// and wrap it through the perform_agent_system_prompt template
if (options.agentPath) {
const systemPrompt = AgentRunner.loadAgentPromptFromPath(options.agentPath);
const agentDefinition = AgentRunner.loadAgentPromptFromPath(options.agentPath);
const language = options.language ?? 'en';
const systemPrompt = loadTemplate('perform_agent_system_prompt', language, { agentDefinition });
const providerType = AgentRunner.resolveProvider(options.cwd, options);
const provider = getProvider(providerType);
return provider.call(agentName, task, AgentRunner.buildProviderCallOptions(options, systemPrompt));

View File

@ -3,7 +3,7 @@
*/
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../infra/claude/index.js';
import type { PermissionMode } from '../core/models/index.js';
import type { PermissionMode, Language } from '../core/models/index.js';
export type { StreamCallback };
@ -26,4 +26,6 @@ export interface RunAgentOptions {
onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */
bypassPermissions?: boolean;
/** Language for template resolution */
language?: Language;
}

View File

@ -1,13 +1,14 @@
/**
* CLI subcommand definitions
*
* Registers all named subcommands (run, watch, add, list, switch, clear, eject, config).
* Registers all named subcommands (run, watch, add, list, switch, clear, eject, config, prompt).
*/
import { clearAgentSessions, getCurrentWorkflow } from '../../infra/config/index.js';
import { success } from '../../shared/ui/index.js';
import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js';
import { switchWorkflow, switchConfig, ejectBuiltin } from '../../features/config/index.js';
import { previewPrompts } from '../../features/prompt/index.js';
import { program, resolvedCwd } from './program.js';
import { resolveAgentOverrides } from './helpers.js';
@ -72,3 +73,11 @@ program
.action(async (key?: string) => {
await switchConfig(resolvedCwd, key);
});
program
.command('prompt')
.description('Preview assembled prompts for each movement and phase')
.argument('[workflow]', 'Workflow name or path (defaults to current)')
.action(async (workflow?: string) => {
await previewPrompts(resolvedCwd, workflow);
});

View File

@ -9,6 +9,7 @@ export type {
AgentResponse,
SessionState,
WorkflowRule,
WorkflowMovement,
WorkflowStep,
LoopDetectionConfig,
WorkflowConfig,

View File

@ -103,7 +103,7 @@ export const ReportFieldSchema = z.union([
export const WorkflowRuleSchema = z.object({
/** Human-readable condition text */
condition: z.string().min(1),
/** Next step name (e.g., implement, COMPLETE, ABORT). Optional for parallel sub-steps (parent handles routing). */
/** Next movement name (e.g., implement, COMPLETE, ABORT). Optional for parallel sub-movements (parent handles routing). */
next: z.string().min(1).optional(),
/** Template for additional AI output */
appendix: z.string().optional(),
@ -113,8 +113,8 @@ export const WorkflowRuleSchema = z.object({
interactive_only: z.boolean().optional(),
});
/** Sub-step schema for parallel execution */
export const ParallelSubStepRawSchema = z.object({
/** Sub-movement schema for parallel execution */
export const ParallelSubMovementRawSchema = z.object({
name: z.string().min(1),
agent: z.string().optional(),
agent_name: z.string().optional(),
@ -130,43 +130,58 @@ export const ParallelSubStepRawSchema = z.object({
pass_previous_response: z.boolean().optional().default(true),
});
/** Workflow step schema - raw YAML format */
export const WorkflowStepRawSchema = z.object({
/** Workflow movement schema - raw YAML format */
export const WorkflowMovementRawSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
/** Agent is required for normal steps, optional for parallel container steps */
/** Agent is required for normal movements, optional for parallel container movements */
agent: z.string().optional(),
/** Session handling for this step */
/** Session handling for this movement */
session: z.enum(['continue', 'refresh']).optional(),
/** Display name for the agent (shown in output). Falls back to agent basename if not specified */
agent_name: z.string().optional(),
allowed_tools: z.array(z.string()).optional(),
provider: z.enum(['claude', 'codex', 'mock']).optional(),
model: z.string().optional(),
/** Permission mode for tool execution in this step */
/** Permission mode for tool execution in this movement */
permission_mode: PermissionModeSchema.optional(),
/** Whether this step is allowed to edit project files */
/** Whether this movement is allowed to edit project files */
edit: z.boolean().optional(),
instruction: z.string().optional(),
instruction_template: z.string().optional(),
/** Rules for step routing */
/** Rules for movement routing */
rules: z.array(WorkflowRuleSchema).optional(),
/** Report file(s) for this step */
/** Report file(s) for this movement */
report: ReportFieldSchema.optional(),
pass_previous_response: z.boolean().optional().default(true),
/** Sub-steps to execute in parallel */
parallel: z.array(ParallelSubStepRawSchema).optional(),
/** Sub-movements to execute in parallel */
parallel: z.array(ParallelSubMovementRawSchema).optional(),
});
/** Workflow configuration schema - raw YAML format */
/** @deprecated Use WorkflowMovementRawSchema instead */
export const WorkflowStepRawSchema = WorkflowMovementRawSchema;
/** @deprecated Use ParallelSubMovementRawSchema instead */
export const ParallelSubStepRawSchema = ParallelSubMovementRawSchema;
/** Workflow configuration schema - raw YAML format (accepts both `movements` and legacy `steps`) */
export const WorkflowConfigRawSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
steps: z.array(WorkflowStepRawSchema).min(1),
/** Preferred key: movements */
movements: z.array(WorkflowMovementRawSchema).min(1).optional(),
/** @deprecated Use `movements` instead */
steps: z.array(WorkflowMovementRawSchema).min(1).optional(),
/** Preferred key: initial_movement */
initial_movement: z.string().optional(),
/** @deprecated Use `initial_movement` instead */
initial_step: z.string().optional(),
max_iterations: z.number().int().positive().optional().default(10),
answer_agent: z.string().optional(),
});
}).refine(
(data) => (data.movements && data.movements.length > 0) || (data.steps && data.steps.length > 0),
{ message: 'Workflow must have at least one movement (use `movements` or legacy `steps` key)' }
);
/** Custom agent configuration schema */
export const CustomAgentConfigSchema = z.object({

View File

@ -28,6 +28,7 @@ export type {
WorkflowRule,
ReportConfig,
ReportObjectConfig,
WorkflowMovement,
WorkflowStep,
LoopDetectionConfig,
WorkflowConfig,

View File

@ -9,7 +9,7 @@ import type { AgentResponse } from './response.js';
export interface WorkflowRule {
/** Human-readable condition text */
condition: string;
/** Next step name (e.g., implement, COMPLETE, ABORT). Optional for parallel sub-steps. */
/** Next movement name (e.g., implement, COMPLETE, ABORT). Optional for parallel sub-movements. */
next?: string;
/** Template for additional AI output */
appendix?: string;
@ -23,16 +23,16 @@ export interface WorkflowRule {
aiConditionText?: string;
/** Whether this condition uses all()/any() aggregate expression (set by loader) */
isAggregateCondition?: boolean;
/** Aggregate type: 'all' requires all sub-steps match, 'any' requires at least one (set by loader) */
/** Aggregate type: 'all' requires all sub-movements match, 'any' requires at least one (set by loader) */
aggregateType?: 'all' | 'any';
/** The condition text(s) inside all("...")/any("...") to match against sub-step results (set by loader).
* - string: all sub-steps must match this single condition (e.g., all("approved"))
* - string[]: each sub-step must match the corresponding condition by index (e.g., all("A", "B"))
/** The condition text(s) inside all("...")/any("...") to match against sub-movement results (set by loader).
* - string: all sub-movements must match this single condition (e.g., all("approved"))
* - string[]: each sub-movement must match the corresponding condition by index (e.g., all("A", "B"))
*/
aggregateConditionText?: string | string[];
}
/** Report file configuration for a workflow step (label: path pair) */
/** Report file configuration for a workflow movement (label: path pair) */
export interface ReportConfig {
/** Display label (e.g., "Scope", "Decisions") */
label: string;
@ -50,39 +50,42 @@ export interface ReportObjectConfig {
format?: string;
}
/** Single step in a workflow */
export interface WorkflowStep {
/** Single movement in a workflow */
export interface WorkflowMovement {
name: string;
/** Brief description of this step's role in the workflow */
/** Brief description of this movement's role in the workflow */
description?: string;
/** Agent name, path, or inline prompt as specified in workflow YAML. Undefined when step runs without an agent. */
/** Agent name, path, or inline prompt as specified in workflow YAML. Undefined when movement runs without an agent. */
agent?: string;
/** Session handling for this step */
/** Session handling for this movement */
session?: 'continue' | 'refresh';
/** Display name for the agent (shown in output). Falls back to agent basename if not specified */
agentDisplayName: string;
/** Allowed tools for this step (optional, passed to agent execution) */
/** Allowed tools for this movement (optional, passed to agent execution) */
allowedTools?: string[];
/** Resolved absolute path to agent prompt file (set by loader) */
agentPath?: string;
/** Provider override for this step */
/** Provider override for this movement */
provider?: 'claude' | 'codex' | 'mock';
/** Model override for this step */
/** Model override for this movement */
model?: string;
/** Permission mode for tool execution in this step */
/** Permission mode for tool execution in this movement */
permissionMode?: PermissionMode;
/** Whether this step is allowed to edit project files (true=allowed, false=prohibited, undefined=no prompt) */
/** Whether this movement is allowed to edit project files (true=allowed, false=prohibited, undefined=no prompt) */
edit?: boolean;
instructionTemplate: string;
/** Rules for step routing */
/** Rules for movement routing */
rules?: WorkflowRule[];
/** Report file configuration. Single string, array of label:path, or object with order/format. */
report?: string | ReportConfig[] | ReportObjectConfig;
passPreviousResponse: boolean;
/** Sub-steps to execute in parallel. When set, this step runs all sub-steps concurrently. */
parallel?: WorkflowStep[];
/** Sub-movements to execute in parallel. When set, this movement runs all sub-movements concurrently. */
parallel?: WorkflowMovement[];
}
/** @deprecated Use WorkflowMovement instead */
export type WorkflowStep = WorkflowMovement;
/** Loop detection configuration */
export interface LoopDetectionConfig {
/** Maximum consecutive runs of the same step before triggering (default: 10) */
@ -95,8 +98,8 @@ export interface LoopDetectionConfig {
export interface WorkflowConfig {
name: string;
description?: string;
steps: WorkflowStep[];
initialStep: string;
movements: WorkflowMovement[];
initialMovement: string;
maxIterations: number;
/** Loop detection settings */
loopDetection?: LoopDetectionConfig;
@ -111,12 +114,12 @@ export interface WorkflowConfig {
/** Runtime state of a workflow execution */
export interface WorkflowState {
workflowName: string;
currentStep: string;
currentMovement: string;
iteration: number;
stepOutputs: Map<string, AgentResponse>;
movementOutputs: Map<string, AgentResponse>;
userInputs: string[];
agentSessions: Map<string, string>;
/** Per-step iteration counters (how many times each step has been executed) */
stepIterations: Map<string, number>;
/** Per-movement iteration counters (how many times each movement has been executed) */
movementIterations: Map<string, number>;
status: 'running' | 'completed' | 'aborted';
}

View File

@ -2,12 +2,17 @@
* Workflow engine constants
*
* Contains all constants used by the workflow engine including
* special step names, limits, and error messages.
* special movement names, limits, and error messages.
*/
/** Special step names for workflow termination */
export const COMPLETE_STEP = 'COMPLETE';
export const ABORT_STEP = 'ABORT';
/** Special movement names for workflow termination */
export const COMPLETE_MOVEMENT = 'COMPLETE';
export const ABORT_MOVEMENT = 'ABORT';
/** @deprecated Use COMPLETE_MOVEMENT instead */
export const COMPLETE_STEP = COMPLETE_MOVEMENT;
/** @deprecated Use ABORT_MOVEMENT instead */
export const ABORT_STEP = ABORT_MOVEMENT;
/** Maximum user inputs to store */
export const MAX_USER_INPUTS = 100;
@ -15,9 +20,9 @@ export const MAX_INPUT_LENGTH = 10000;
/** Error messages */
export const ERROR_MESSAGES = {
LOOP_DETECTED: (stepName: string, count: number) =>
`Loop detected: step "${stepName}" ran ${count} times consecutively without progress.`,
UNKNOWN_STEP: (stepName: string) => `Unknown step: ${stepName}`,
STEP_EXECUTION_FAILED: (message: string) => `Step execution failed: ${message}`,
LOOP_DETECTED: (movementName: string, count: number) =>
`Loop detected: movement "${movementName}" ran ${count} times consecutively without progress.`,
UNKNOWN_MOVEMENT: (movementName: string) => `Unknown movement: ${movementName}`,
MOVEMENT_EXECUTION_FAILED: (message: string) => `Movement execution failed: ${message}`,
MAX_ITERATIONS_REACHED: 'Max iterations reached',
};

View File

@ -1,5 +1,5 @@
/**
* Executes a single workflow step through the 3-phase model.
* Executes a single workflow movement through the 3-phase model.
*
* Phase 1: Main agent execution (with tools)
* Phase 2: Report output (Write-only, optional)
@ -9,7 +9,7 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type {
WorkflowStep,
WorkflowMovement,
WorkflowState,
AgentResponse,
Language,
@ -19,49 +19,49 @@ import { runAgent } from '../../../agents/runner.js';
import { InstructionBuilder, isReportObjectConfig } from '../instruction/InstructionBuilder.js';
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../phase-runner.js';
import { detectMatchedRule } from '../evaluation/index.js';
import { incrementStepIteration, getPreviousOutput } from './state-manager.js';
import { incrementMovementIteration, getPreviousOutput } from './state-manager.js';
import { createLogger } from '../../../shared/utils/index.js';
import type { OptionsBuilder } from './OptionsBuilder.js';
const log = createLogger('step-executor');
const log = createLogger('movement-executor');
export interface StepExecutorDeps {
export interface MovementExecutorDeps {
readonly optionsBuilder: OptionsBuilder;
readonly getCwd: () => string;
readonly getProjectCwd: () => string;
readonly getReportDir: () => string;
readonly getLanguage: () => Language | undefined;
readonly getInteractive: () => boolean;
readonly getWorkflowSteps: () => ReadonlyArray<{ name: string; description?: string }>;
readonly detectRuleIndex: (content: string, stepName: string) => number;
readonly getWorkflowMovements: () => ReadonlyArray<{ name: string; description?: string }>;
readonly detectRuleIndex: (content: string, movementName: string) => number;
readonly callAiJudge: (
agentOutput: string,
conditions: Array<{ index: number; text: string }>,
options: { cwd: string }
) => Promise<number>;
readonly onPhaseStart?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void;
readonly onPhaseComplete?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void;
readonly onPhaseStart?: (step: WorkflowMovement, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void;
readonly onPhaseComplete?: (step: WorkflowMovement, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void;
}
export class StepExecutor {
export class MovementExecutor {
constructor(
private readonly deps: StepExecutorDeps,
private readonly deps: MovementExecutorDeps,
) {}
/** Build Phase 1 instruction from template */
buildInstruction(
step: WorkflowStep,
stepIteration: number,
step: WorkflowMovement,
movementIteration: number,
state: WorkflowState,
task: string,
maxIterations: number,
): string {
const workflowSteps = this.deps.getWorkflowSteps();
const workflowMovements = this.deps.getWorkflowMovements();
return new InstructionBuilder(step, {
task,
iteration: state.iteration,
maxIterations,
stepIteration,
movementIteration,
cwd: this.deps.getCwd(),
projectCwd: this.deps.getProjectCwd(),
userInputs: state.userInputs,
@ -69,39 +69,39 @@ export class StepExecutor {
reportDir: join(this.deps.getProjectCwd(), this.deps.getReportDir()),
language: this.deps.getLanguage(),
interactive: this.deps.getInteractive(),
workflowSteps,
currentStepIndex: workflowSteps.findIndex(s => s.name === step.name),
workflowMovements: workflowMovements,
currentMovementIndex: workflowMovements.findIndex(s => s.name === step.name),
}).build();
}
/**
* Execute a normal (non-parallel) step through all 3 phases.
* Execute a normal (non-parallel) movement through all 3 phases.
*
* Returns the final response (with matchedRuleIndex if a rule matched)
* and the instruction used for Phase 1.
*/
async runNormalStep(
step: WorkflowStep,
async runNormalMovement(
step: WorkflowMovement,
state: WorkflowState,
task: string,
maxIterations: number,
updateAgentSession: (agent: string, sessionId: string | undefined) => void,
prebuiltInstruction?: string,
): Promise<{ response: AgentResponse; instruction: string }> {
const stepIteration = prebuiltInstruction
? state.stepIterations.get(step.name) ?? 1
: incrementStepIteration(state, step.name);
const instruction = prebuiltInstruction ?? this.buildInstruction(step, stepIteration, state, task, maxIterations);
const movementIteration = prebuiltInstruction
? state.movementIterations.get(step.name) ?? 1
: incrementMovementIteration(state, step.name);
const instruction = prebuiltInstruction ?? this.buildInstruction(step, movementIteration, state, task, maxIterations);
const sessionKey = step.agent ?? step.name;
log.debug('Running step', {
step: step.name,
log.debug('Running movement', {
movement: step.name,
agent: step.agent ?? '(none)',
stepIteration,
movementIteration,
iteration: state.iteration,
sessionId: state.agentSessions.get(sessionKey) ?? 'new',
});
// Phase 1: main execution (Write excluded if step has report)
// Phase 1: main execution (Write excluded if movement has report)
this.deps.onPhaseStart?.(step, 1, 'execute', instruction);
const agentOptions = this.deps.optionsBuilder.buildAgentOptions(step);
let response = await runAgent(step.agent, instruction, agentOptions);
@ -112,7 +112,7 @@ export class StepExecutor {
// Phase 2: report output (resume same session, Write only)
if (step.report) {
await runReportPhase(step, stepIteration, phaseCtx);
await runReportPhase(step, movementIteration, phaseCtx);
}
// Phase 3: status judgment (resume session, no tools, output status tag)
@ -129,17 +129,17 @@ export class StepExecutor {
callAiJudge: this.deps.callAiJudge,
});
if (match) {
log.debug('Rule matched', { step: step.name, ruleIndex: match.index, method: match.method });
log.debug('Rule matched', { movement: step.name, ruleIndex: match.index, method: match.method });
response = { ...response, matchedRuleIndex: match.index, matchedRuleMethod: match.method };
}
state.stepOutputs.set(step.name, response);
this.emitStepReports(step);
state.movementOutputs.set(step.name, response);
this.emitMovementReports(step);
return { response, instruction };
}
/** Emit step:report events for each report file that exists */
emitStepReports(step: WorkflowStep): void {
/** Collect movement:report events for each report file that exists */
emitMovementReports(step: WorkflowMovement): void {
if (!step.report) return;
const baseDir = join(this.deps.getProjectCwd(), this.deps.getReportDir());
@ -156,21 +156,22 @@ export class StepExecutor {
}
// Collects report file paths that exist (used by WorkflowEngine to emit events)
private reportFiles: Array<{ step: WorkflowStep; filePath: string; fileName: string }> = [];
private reportFiles: Array<{ step: WorkflowMovement; filePath: string; fileName: string }> = [];
/** Check if report file exists and collect for emission */
private checkReportFile(step: WorkflowStep, baseDir: string, fileName: string): void {
private checkReportFile(step: WorkflowMovement, baseDir: string, fileName: string): void {
const filePath = join(baseDir, fileName);
if (existsSync(filePath)) {
this.reportFiles.push({ step, filePath, fileName });
}
}
/** Drain collected report files (called by engine after step execution) */
drainReportFiles(): Array<{ step: WorkflowStep; filePath: string; fileName: string }> {
/** Drain collected report files (called by engine after movement execution) */
drainReportFiles(): Array<{ step: WorkflowMovement; filePath: string; fileName: string }> {
const files = this.reportFiles;
this.reportFiles = [];
return files;
}
}

View File

@ -6,7 +6,7 @@
*/
import { join } from 'node:path';
import type { WorkflowStep, WorkflowState, Language } from '../../models/types.js';
import type { WorkflowMovement, WorkflowState, Language } from '../../models/types.js';
import type { RunAgentOptions } from '../../../agents/runner.js';
import type { PhaseRunnerContext } from '../phase-runner.js';
import type { WorkflowEngineOptions, PhaseName } from '../types.js';
@ -22,13 +22,14 @@ export class OptionsBuilder {
) {}
/** Build common RunAgentOptions shared by all phases */
buildBaseOptions(step: WorkflowStep): RunAgentOptions {
buildBaseOptions(step: WorkflowMovement): RunAgentOptions {
return {
cwd: this.getCwd(),
agentPath: step.agentPath,
provider: step.provider ?? this.engineOptions.provider,
model: step.model ?? this.engineOptions.model,
permissionMode: step.permissionMode,
language: this.getLanguage(),
onStream: this.engineOptions.onStream,
onPermissionRequest: this.engineOptions.onPermissionRequest,
onAskUserQuestion: this.engineOptions.onAskUserQuestion,
@ -37,8 +38,8 @@ export class OptionsBuilder {
}
/** Build RunAgentOptions for Phase 1 (main execution) */
buildAgentOptions(step: WorkflowStep): RunAgentOptions {
// Phase 1: exclude Write from allowedTools when step has report config AND edit is NOT enabled
buildAgentOptions(step: WorkflowMovement): RunAgentOptions {
// Phase 1: exclude Write from allowedTools when movement has report config AND edit is NOT enabled
// (If edit is enabled, Write is needed for code implementation even if report exists)
// Note: edit defaults to undefined, so check !== true to catch both false and undefined
const allowedTools = step.report && step.edit !== true
@ -57,7 +58,7 @@ export class OptionsBuilder {
/** Build RunAgentOptions for session-resume phases (Phase 2, Phase 3) */
buildResumeOptions(
step: WorkflowStep,
step: WorkflowMovement,
sessionId: string,
overrides: Pick<RunAgentOptions, 'allowedTools' | 'maxTurns'>,
): RunAgentOptions {
@ -75,8 +76,8 @@ export class OptionsBuilder {
buildPhaseRunnerContext(
state: WorkflowState,
updateAgentSession: (agent: string, sessionId: string | undefined) => void,
onPhaseStart?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void,
onPhaseComplete?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void,
onPhaseStart?: (step: WorkflowMovement, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void,
onPhaseComplete?: (step: WorkflowMovement, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void,
): PhaseRunnerContext {
return {
cwd: this.getCwd(),

View File

@ -1,12 +1,12 @@
/**
* Executes parallel workflow steps concurrently and aggregates results.
* Executes parallel workflow movements concurrently and aggregates results.
*
* When onStream is provided, uses ParallelLogger to prefix each
* sub-step's output with `[name]` for readable interleaved display.
* sub-movement's output with `[name]` for readable interleaved display.
*/
import type {
WorkflowStep,
WorkflowMovement,
WorkflowState,
AgentResponse,
} from '../../models/types.js';
@ -14,29 +14,29 @@ import { runAgent } from '../../../agents/runner.js';
import { ParallelLogger } from './parallel-logger.js';
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../phase-runner.js';
import { detectMatchedRule } from '../evaluation/index.js';
import { incrementStepIteration } from './state-manager.js';
import { incrementMovementIteration } from './state-manager.js';
import { createLogger } from '../../../shared/utils/index.js';
import type { OptionsBuilder } from './OptionsBuilder.js';
import type { StepExecutor } from './StepExecutor.js';
import type { MovementExecutor } from './MovementExecutor.js';
import type { WorkflowEngineOptions, PhaseName } from '../types.js';
const log = createLogger('parallel-runner');
export interface ParallelRunnerDeps {
readonly optionsBuilder: OptionsBuilder;
readonly stepExecutor: StepExecutor;
readonly movementExecutor: MovementExecutor;
readonly engineOptions: WorkflowEngineOptions;
readonly getCwd: () => string;
readonly getReportDir: () => string;
readonly getInteractive: () => boolean;
readonly detectRuleIndex: (content: string, stepName: string) => number;
readonly detectRuleIndex: (content: string, movementName: string) => number;
readonly callAiJudge: (
agentOutput: string,
conditions: Array<{ index: number; text: string }>,
options: { cwd: string }
) => Promise<number>;
readonly onPhaseStart?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void;
readonly onPhaseComplete?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void;
readonly onPhaseStart?: (step: WorkflowMovement, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void;
readonly onPhaseComplete?: (step: WorkflowMovement, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void;
}
export class ParallelRunner {
@ -45,28 +45,28 @@ export class ParallelRunner {
) {}
/**
* Run a parallel step: execute all sub-steps concurrently, then aggregate results.
* The aggregated output becomes the parent step's response for rules evaluation.
* Run a parallel movement: execute all sub-movements concurrently, then aggregate results.
* The aggregated output becomes the parent movement's response for rules evaluation.
*/
async runParallelStep(
step: WorkflowStep,
async runParallelMovement(
step: WorkflowMovement,
state: WorkflowState,
task: string,
maxIterations: number,
updateAgentSession: (agent: string, sessionId: string | undefined) => void,
): Promise<{ response: AgentResponse; instruction: string }> {
const subSteps = step.parallel!;
const stepIteration = incrementStepIteration(state, step.name);
log.debug('Running parallel step', {
step: step.name,
subSteps: subSteps.map(s => s.name),
stepIteration,
const subMovements = step.parallel!;
const movementIteration = incrementMovementIteration(state, step.name);
log.debug('Running parallel movement', {
movement: step.name,
subMovements: subMovements.map(s => s.name),
movementIteration,
});
// Create parallel logger for prefixed output (only when streaming is enabled)
const parallelLogger = this.deps.engineOptions.onStream
? new ParallelLogger({
subStepNames: subSteps.map((s) => s.name),
subMovementNames: subMovements.map((s) => s.name),
parentOnStream: this.deps.engineOptions.onStream,
})
: undefined;
@ -80,46 +80,46 @@ export class ParallelRunner {
callAiJudge: this.deps.callAiJudge,
};
// Run all sub-steps concurrently
// Run all sub-movements concurrently
const subResults = await Promise.all(
subSteps.map(async (subStep, index) => {
const subIteration = incrementStepIteration(state, subStep.name);
const subInstruction = this.deps.stepExecutor.buildInstruction(subStep, subIteration, state, task, maxIterations);
subMovements.map(async (subMovement, index) => {
const subIteration = incrementMovementIteration(state, subMovement.name);
const subInstruction = this.deps.movementExecutor.buildInstruction(subMovement, subIteration, state, task, maxIterations);
// Phase 1: main execution (Write excluded if sub-step has report)
const baseOptions = this.deps.optionsBuilder.buildAgentOptions(subStep);
// Phase 1: main execution (Write excluded if sub-movement has report)
const baseOptions = this.deps.optionsBuilder.buildAgentOptions(subMovement);
// Override onStream with parallel logger's prefixed handler (immutable)
const agentOptions = parallelLogger
? { ...baseOptions, onStream: parallelLogger.createStreamHandler(subStep.name, index) }
? { ...baseOptions, onStream: parallelLogger.createStreamHandler(subMovement.name, index) }
: baseOptions;
const subSessionKey = subStep.agent ?? subStep.name;
this.deps.onPhaseStart?.(subStep, 1, 'execute', subInstruction);
const subResponse = await runAgent(subStep.agent, subInstruction, agentOptions);
const subSessionKey = subMovement.agent ?? subMovement.name;
this.deps.onPhaseStart?.(subMovement, 1, 'execute', subInstruction);
const subResponse = await runAgent(subMovement.agent, subInstruction, agentOptions);
updateAgentSession(subSessionKey, subResponse.sessionId);
this.deps.onPhaseComplete?.(subStep, 1, 'execute', subResponse.content, subResponse.status, subResponse.error);
this.deps.onPhaseComplete?.(subMovement, 1, 'execute', subResponse.content, subResponse.status, subResponse.error);
// Phase 2: report output for sub-step
if (subStep.report) {
await runReportPhase(subStep, subIteration, phaseCtx);
// Phase 2: report output for sub-movement
if (subMovement.report) {
await runReportPhase(subMovement, subIteration, phaseCtx);
}
// Phase 3: status judgment for sub-step
// Phase 3: status judgment for sub-movement
let subTagContent = '';
if (needsStatusJudgmentPhase(subStep)) {
subTagContent = await runStatusJudgmentPhase(subStep, phaseCtx);
if (needsStatusJudgmentPhase(subMovement)) {
subTagContent = await runStatusJudgmentPhase(subMovement, phaseCtx);
}
const match = await detectMatchedRule(subStep, subResponse.content, subTagContent, ruleCtx);
const match = await detectMatchedRule(subMovement, subResponse.content, subTagContent, ruleCtx);
const finalResponse = match
? { ...subResponse, matchedRuleIndex: match.index, matchedRuleMethod: match.method }
: subResponse;
state.stepOutputs.set(subStep.name, finalResponse);
this.deps.stepExecutor.emitStepReports(subStep);
state.movementOutputs.set(subMovement.name, finalResponse);
this.deps.movementExecutor.emitMovementReports(subMovement);
return { subStep, response: finalResponse, instruction: subInstruction };
return { subMovement, response: finalResponse, instruction: subInstruction };
}),
);
@ -128,24 +128,24 @@ export class ParallelRunner {
parallelLogger.printSummary(
step.name,
subResults.map((r) => ({
name: r.subStep.name,
condition: r.response.matchedRuleIndex != null && r.subStep.rules
? r.subStep.rules[r.response.matchedRuleIndex]?.condition
name: r.subMovement.name,
condition: r.response.matchedRuleIndex != null && r.subMovement.rules
? r.subMovement.rules[r.response.matchedRuleIndex]?.condition
: undefined,
})),
);
}
// Aggregate sub-step outputs into parent step's response
// Aggregate sub-movement outputs into parent movement's response
const aggregatedContent = subResults
.map((r) => `## ${r.subStep.name}\n${r.response.content}`)
.map((r) => `## ${r.subMovement.name}\n${r.response.content}`)
.join('\n\n---\n\n');
const aggregatedInstruction = subResults
.map((r) => r.instruction)
.join('\n\n');
// Parent step uses aggregate conditions, so tagContent is empty
// Parent movement uses aggregate conditions, so tagContent is empty
const match = await detectMatchedRule(step, aggregatedContent, '', ruleCtx);
const aggregatedResponse: AgentResponse = {
@ -156,8 +156,8 @@ export class ParallelRunner {
...(match && { matchedRuleIndex: match.index, matchedRuleMethod: match.method }),
};
state.stepOutputs.set(step.name, aggregatedResponse);
this.deps.stepExecutor.emitStepReports(step);
state.movementOutputs.set(step.name, aggregatedResponse);
this.deps.movementExecutor.emitMovementReports(step);
return { response: aggregatedResponse, instruction: aggregatedInstruction };
}

View File

@ -1,9 +1,9 @@
/**
* Workflow execution engine.
*
* Orchestrates the main execution loop: step transitions, abort handling,
* loop detection, and iteration limits. Delegates step execution to
* StepExecutor (normal steps) and ParallelRunner (parallel steps).
* Orchestrates the main execution loop: movement transitions, abort handling,
* loop detection, and iteration limits. Delegates movement execution to
* MovementExecutor (normal movements) and ParallelRunner (parallel movements).
*/
import { EventEmitter } from 'node:events';
@ -12,22 +12,22 @@ import { join } from 'node:path';
import type {
WorkflowConfig,
WorkflowState,
WorkflowStep,
WorkflowMovement,
AgentResponse,
} from '../../models/types.js';
import { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from '../constants.js';
import { COMPLETE_MOVEMENT, ABORT_MOVEMENT, ERROR_MESSAGES } from '../constants.js';
import type { WorkflowEngineOptions } from '../types.js';
import { determineNextStepByRules } from './transitions.js';
import { determineNextMovementByRules } from './transitions.js';
import { LoopDetector } from './loop-detector.js';
import { handleBlocked } from './blocked-handler.js';
import {
createInitialState,
addUserInput as addUserInputToState,
incrementStepIteration,
incrementMovementIteration,
} from './state-manager.js';
import { generateReportDir, getErrorMessage, createLogger } from '../../../shared/utils/index.js';
import { OptionsBuilder } from './OptionsBuilder.js';
import { StepExecutor } from './StepExecutor.js';
import { MovementExecutor } from './MovementExecutor.js';
import { ParallelRunner } from './ParallelRunner.js';
const log = createLogger('engine');
@ -41,7 +41,7 @@ export type {
IterationLimitCallback,
WorkflowEngineOptions,
} from '../types.js';
export { COMPLETE_STEP, ABORT_STEP } from '../constants.js';
export { COMPLETE_MOVEMENT, ABORT_MOVEMENT, COMPLETE_STEP, ABORT_STEP } from '../constants.js';
/** Workflow engine for orchestrating agent execution */
export class WorkflowEngine extends EventEmitter {
@ -56,9 +56,9 @@ export class WorkflowEngine extends EventEmitter {
private abortRequested = false;
private readonly optionsBuilder: OptionsBuilder;
private readonly stepExecutor: StepExecutor;
private readonly movementExecutor: MovementExecutor;
private readonly parallelRunner: ParallelRunner;
private readonly detectRuleIndex: (content: string, stepName: string) => number;
private readonly detectRuleIndex: (content: string, movementName: string) => number;
private readonly callAiJudge: (
agentOutput: string,
conditions: Array<{ index: number; text: string }>,
@ -94,14 +94,14 @@ export class WorkflowEngine extends EventEmitter {
() => this.options.language,
);
this.stepExecutor = new StepExecutor({
this.movementExecutor = new MovementExecutor({
optionsBuilder: this.optionsBuilder,
getCwd: () => this.cwd,
getProjectCwd: () => this.projectCwd,
getReportDir: () => this.reportDir,
getLanguage: () => this.options.language,
getInteractive: () => this.options.interactive === true,
getWorkflowSteps: () => this.config.steps.map(s => ({ name: s.name, description: s.description })),
getWorkflowMovements: () => this.config.movements.map(s => ({ name: s.name, description: s.description })),
detectRuleIndex: this.detectRuleIndex,
callAiJudge: this.callAiJudge,
onPhaseStart: (step, phase, phaseName, instruction) => {
@ -114,7 +114,7 @@ export class WorkflowEngine extends EventEmitter {
this.parallelRunner = new ParallelRunner({
optionsBuilder: this.optionsBuilder,
stepExecutor: this.stepExecutor,
movementExecutor: this.movementExecutor,
engineOptions: this.options,
getCwd: () => this.cwd,
getReportDir: () => this.reportDir,
@ -131,8 +131,8 @@ export class WorkflowEngine extends EventEmitter {
log.debug('WorkflowEngine initialized', {
workflow: config.name,
steps: config.steps.map(s => s.name),
initialStep: config.initialStep,
movements: config.movements.map(s => s.name),
initialMovement: config.initialMovement,
maxIterations: config.maxIterations,
});
}
@ -159,21 +159,21 @@ export class WorkflowEngine extends EventEmitter {
/** Validate workflow configuration at construction time */
private validateConfig(): void {
const initialStep = this.config.steps.find((s) => s.name === this.config.initialStep);
if (!initialStep) {
throw new Error(ERROR_MESSAGES.UNKNOWN_STEP(this.config.initialStep));
const initialMovement = this.config.movements.find((s) => s.name === this.config.initialMovement);
if (!initialMovement) {
throw new Error(ERROR_MESSAGES.UNKNOWN_MOVEMENT(this.config.initialMovement));
}
const stepNames = new Set(this.config.steps.map((s) => s.name));
stepNames.add(COMPLETE_STEP);
stepNames.add(ABORT_STEP);
const movementNames = new Set(this.config.movements.map((s) => s.name));
movementNames.add(COMPLETE_MOVEMENT);
movementNames.add(ABORT_MOVEMENT);
for (const step of this.config.steps) {
if (step.rules) {
for (const rule of step.rules) {
if (rule.next && !stepNames.has(rule.next)) {
for (const movement of this.config.movements) {
if (movement.rules) {
for (const rule of movement.rules) {
if (rule.next && !movementNames.has(rule.next)) {
throw new Error(
`Invalid rule in step "${step.name}": target step "${rule.next}" does not exist`
`Invalid rule in movement "${movement.name}": target movement "${rule.next}" does not exist`
);
}
}
@ -206,7 +206,7 @@ export class WorkflowEngine extends EventEmitter {
return this.projectCwd;
}
/** Request graceful abort: interrupt running queries and stop after current step */
/** Request graceful abort: interrupt running queries and stop after current movement */
abort(): void {
if (this.abortRequested) return;
this.abortRequested = true;
@ -218,13 +218,13 @@ export class WorkflowEngine extends EventEmitter {
return this.abortRequested;
}
/** Get step by name */
private getStep(name: string): WorkflowStep {
const step = this.config.steps.find((s) => s.name === name);
if (!step) {
throw new Error(ERROR_MESSAGES.UNKNOWN_STEP(name));
/** Get movement by name */
private getMovement(name: string): WorkflowMovement {
const movement = this.config.movements.find((s) => s.name === name);
if (!movement) {
throw new Error(ERROR_MESSAGES.UNKNOWN_MOVEMENT(name));
}
return step;
return movement;
}
/** Update agent session and notify via callback if session changed */
@ -239,24 +239,24 @@ export class WorkflowEngine extends EventEmitter {
}
}
/** Emit step:report events collected by StepExecutor */
/** Emit movement:report events collected by MovementExecutor */
private emitCollectedReports(): void {
for (const { step, filePath, fileName } of this.stepExecutor.drainReportFiles()) {
this.emit('step:report', step, filePath, fileName);
for (const { step, filePath, fileName } of this.movementExecutor.drainReportFiles()) {
this.emit('movement:report', step, filePath, fileName);
}
}
/** Run a single step (delegates to ParallelRunner if step has parallel sub-steps) */
private async runStep(step: WorkflowStep, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> {
/** Run a single movement (delegates to ParallelRunner if movement has parallel sub-movements) */
private async runMovement(step: WorkflowMovement, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> {
const updateSession = this.updateAgentSession.bind(this);
let result: { response: AgentResponse; instruction: string };
if (step.parallel && step.parallel.length > 0) {
result = await this.parallelRunner.runParallelStep(
result = await this.parallelRunner.runParallelMovement(
step, this.state, this.task, this.config.maxIterations, updateSession,
);
} else {
result = await this.stepExecutor.runNormalStep(
result = await this.movementExecutor.runNormalMovement(
step, this.state, this.task, this.config.maxIterations, updateSession, prebuiltInstruction,
);
}
@ -266,23 +266,23 @@ export class WorkflowEngine extends EventEmitter {
}
/**
* Determine next step for a completed step using rules-based routing.
* Determine next movement for a completed movement using rules-based routing.
*/
private resolveNextStep(step: WorkflowStep, response: AgentResponse): string {
private resolveNextMovement(step: WorkflowMovement, response: AgentResponse): string {
if (response.matchedRuleIndex != null && step.rules) {
const nextByRules = determineNextStepByRules(step, response.matchedRuleIndex);
const nextByRules = determineNextMovementByRules(step, response.matchedRuleIndex);
if (nextByRules) {
return nextByRules;
}
}
throw new Error(`No matching rule found for step "${step.name}" (status: ${response.status})`);
throw new Error(`No matching rule found for movement "${step.name}" (status: ${response.status})`);
}
/** Build instruction (public, used by workflowExecution.ts for logging) */
buildInstruction(step: WorkflowStep, stepIteration: number): string {
return this.stepExecutor.buildInstruction(
step, stepIteration, this.state, this.task, this.config.maxIterations,
buildInstruction(step: WorkflowMovement, movementIteration: number): string {
return this.movementExecutor.buildInstruction(
step, movementIteration, this.state, this.task, this.config.maxIterations,
);
}
@ -302,7 +302,7 @@ export class WorkflowEngine extends EventEmitter {
const additionalIterations = await this.options.onIterationLimit({
currentIteration: this.state.iteration,
maxIterations: this.config.maxIterations,
currentStep: this.state.currentStep,
currentMovement: this.state.currentMovement,
});
if (additionalIterations !== null && additionalIterations > 0) {
@ -319,43 +319,43 @@ export class WorkflowEngine extends EventEmitter {
break;
}
const step = this.getStep(this.state.currentStep);
const loopCheck = this.loopDetector.check(step.name);
const movement = this.getMovement(this.state.currentMovement);
const loopCheck = this.loopDetector.check(movement.name);
if (loopCheck.shouldWarn) {
this.emit('step:loop_detected', step, loopCheck.count);
this.emit('movement:loop_detected', movement, loopCheck.count);
}
if (loopCheck.shouldAbort) {
this.state.status = 'aborted';
this.emit('workflow:abort', this.state, ERROR_MESSAGES.LOOP_DETECTED(step.name, loopCheck.count));
this.emit('workflow:abort', this.state, ERROR_MESSAGES.LOOP_DETECTED(movement.name, loopCheck.count));
break;
}
this.state.iteration++;
// Build instruction before emitting step:start so listeners can log it
const isParallel = step.parallel && step.parallel.length > 0;
// Build instruction before emitting movement:start so listeners can log it
const isParallel = movement.parallel && movement.parallel.length > 0;
let prebuiltInstruction: string | undefined;
if (!isParallel) {
const stepIteration = incrementStepIteration(this.state, step.name);
prebuiltInstruction = this.stepExecutor.buildInstruction(
step, stepIteration, this.state, this.task, this.config.maxIterations,
const movementIteration = incrementMovementIteration(this.state, movement.name);
prebuiltInstruction = this.movementExecutor.buildInstruction(
movement, movementIteration, this.state, this.task, this.config.maxIterations,
);
}
this.emit('step:start', step, this.state.iteration, prebuiltInstruction ?? '');
this.emit('movement:start', movement, this.state.iteration, prebuiltInstruction ?? '');
try {
const { response, instruction } = await this.runStep(step, prebuiltInstruction);
this.emit('step:complete', step, response, instruction);
const { response, instruction } = await this.runMovement(movement, prebuiltInstruction);
this.emit('movement:complete', movement, response, instruction);
if (response.status === 'blocked') {
this.emit('step:blocked', step, response);
const result = await handleBlocked(step, response, this.options);
this.emit('movement:blocked', movement, response);
const result = await handleBlocked(movement, response, this.options);
if (result.shouldContinue && result.userInput) {
this.addUserInput(result.userInput);
this.emit('step:user_input', step, result.userInput);
this.emit('movement:user_input', movement, result.userInput);
continue;
}
@ -364,16 +364,16 @@ export class WorkflowEngine extends EventEmitter {
break;
}
const nextStep = this.resolveNextStep(step, response);
log.debug('Step transition', {
from: step.name,
const nextMovement = this.resolveNextMovement(movement, response);
log.debug('Movement transition', {
from: movement.name,
status: response.status,
matchedRuleIndex: response.matchedRuleIndex,
nextStep,
nextMovement,
});
if (response.matchedRuleIndex != null && step.rules) {
const matchedRule = step.rules[response.matchedRuleIndex];
if (response.matchedRuleIndex != null && movement.rules) {
const matchedRule = movement.rules[response.matchedRuleIndex];
if (matchedRule?.requiresUserInput) {
if (!this.options.onUserInput) {
this.state.status = 'aborted';
@ -381,7 +381,7 @@ export class WorkflowEngine extends EventEmitter {
break;
}
const userInput = await this.options.onUserInput({
step,
movement,
response,
prompt: response.content,
});
@ -391,32 +391,32 @@ export class WorkflowEngine extends EventEmitter {
break;
}
this.addUserInput(userInput);
this.emit('step:user_input', step, userInput);
this.state.currentStep = step.name;
this.emit('movement:user_input', movement, userInput);
this.state.currentMovement = movement.name;
continue;
}
}
if (nextStep === COMPLETE_STEP) {
if (nextMovement === COMPLETE_MOVEMENT) {
this.state.status = 'completed';
this.emit('workflow:complete', this.state);
break;
}
if (nextStep === ABORT_STEP) {
if (nextMovement === ABORT_MOVEMENT) {
this.state.status = 'aborted';
this.emit('workflow:abort', this.state, 'Workflow aborted by step transition');
this.emit('workflow:abort', this.state, 'Workflow aborted by movement transition');
break;
}
this.state.currentStep = nextStep;
this.state.currentMovement = nextMovement;
} catch (error) {
this.state.status = 'aborted';
if (this.abortRequested) {
this.emit('workflow:abort', this.state, 'Workflow interrupted by user (SIGINT)');
} else {
const message = getErrorMessage(error);
this.emit('workflow:abort', this.state, ERROR_MESSAGES.STEP_EXECUTION_FAILED(message));
this.emit('workflow:abort', this.state, ERROR_MESSAGES.MOVEMENT_EXECUTION_FAILED(message));
}
break;
}
@ -428,62 +428,62 @@ export class WorkflowEngine extends EventEmitter {
/** Run a single iteration (for interactive mode) */
async runSingleIteration(): Promise<{
response: AgentResponse;
nextStep: string;
nextMovement: string;
isComplete: boolean;
loopDetected?: boolean;
}> {
const step = this.getStep(this.state.currentStep);
const loopCheck = this.loopDetector.check(step.name);
const movement = this.getMovement(this.state.currentMovement);
const loopCheck = this.loopDetector.check(movement.name);
if (loopCheck.shouldAbort) {
this.state.status = 'aborted';
return {
response: {
agent: step.agent ?? step.name,
agent: movement.agent ?? movement.name,
status: 'blocked',
content: ERROR_MESSAGES.LOOP_DETECTED(step.name, loopCheck.count),
content: ERROR_MESSAGES.LOOP_DETECTED(movement.name, loopCheck.count),
timestamp: new Date(),
},
nextStep: ABORT_STEP,
nextMovement: ABORT_MOVEMENT,
isComplete: true,
loopDetected: true,
};
}
this.state.iteration++;
const { response } = await this.runStep(step);
const nextStep = this.resolveNextStep(step, response);
const isComplete = nextStep === COMPLETE_STEP || nextStep === ABORT_STEP;
const { response } = await this.runMovement(movement);
const nextMovement = this.resolveNextMovement(movement, response);
const isComplete = nextMovement === COMPLETE_MOVEMENT || nextMovement === ABORT_MOVEMENT;
if (response.matchedRuleIndex != null && step.rules) {
const matchedRule = step.rules[response.matchedRuleIndex];
if (response.matchedRuleIndex != null && movement.rules) {
const matchedRule = movement.rules[response.matchedRuleIndex];
if (matchedRule?.requiresUserInput) {
if (!this.options.onUserInput) {
this.state.status = 'aborted';
return { response, nextStep: ABORT_STEP, isComplete: true, loopDetected: loopCheck.isLoop };
return { response, nextMovement: ABORT_MOVEMENT, isComplete: true, loopDetected: loopCheck.isLoop };
}
const userInput = await this.options.onUserInput({
step,
movement,
response,
prompt: response.content,
});
if (userInput === null) {
this.state.status = 'aborted';
return { response, nextStep: ABORT_STEP, isComplete: true, loopDetected: loopCheck.isLoop };
return { response, nextMovement: ABORT_MOVEMENT, isComplete: true, loopDetected: loopCheck.isLoop };
}
this.addUserInput(userInput);
this.emit('step:user_input', step, userInput);
this.state.currentStep = step.name;
return { response, nextStep: step.name, isComplete: false, loopDetected: loopCheck.isLoop };
this.emit('movement:user_input', movement, userInput);
this.state.currentMovement = movement.name;
return { response, nextMovement: movement.name, isComplete: false, loopDetected: loopCheck.isLoop };
}
}
if (!isComplete) {
this.state.currentStep = nextStep;
this.state.currentMovement = nextMovement;
} else {
this.state.status = nextStep === COMPLETE_STEP ? 'completed' : 'aborted';
this.state.status = nextMovement === COMPLETE_MOVEMENT ? 'completed' : 'aborted';
}
return { response, nextStep, isComplete, loopDetected: loopCheck.isLoop };
return { response, nextMovement, isComplete, loopDetected: loopCheck.isLoop };
}
}

View File

@ -5,7 +5,7 @@
* requesting user input to continue.
*/
import type { WorkflowStep, AgentResponse } from '../../models/types.js';
import type { WorkflowMovement, AgentResponse } from '../../models/types.js';
import type { UserInputRequest, WorkflowEngineOptions } from '../types.js';
import { extractBlockedPrompt } from './transitions.js';
@ -22,13 +22,13 @@ export interface BlockedHandlerResult {
/**
* Handle blocked status by requesting user input.
*
* @param step - The step that is blocked
* @param step - The movement that is blocked
* @param response - The blocked response from the agent
* @param options - Workflow engine options containing callbacks
* @returns Result indicating whether to continue and any user input
*/
export async function handleBlocked(
step: WorkflowStep,
step: WorkflowMovement,
response: AgentResponse,
options: WorkflowEngineOptions
): Promise<BlockedHandlerResult> {
@ -42,7 +42,7 @@ export async function handleBlocked(
// Build the request
const request: UserInputRequest = {
step,
movement: step,
response,
prompt,
};

View File

@ -5,6 +5,7 @@
*/
export { WorkflowEngine } from './WorkflowEngine.js';
export { StepExecutor } from './StepExecutor.js';
export { MovementExecutor } from './MovementExecutor.js';
export type { MovementExecutorDeps } from './MovementExecutor.js';
export { ParallelRunner } from './ParallelRunner.js';
export { OptionsBuilder } from './OptionsBuilder.js';

View File

@ -1,7 +1,7 @@
/**
* Loop detection for workflow execution
*
* Detects when a workflow step is executed repeatedly without progress,
* Detects when a workflow movement is executed repeatedly without progress,
* which may indicate an infinite loop.
*/
@ -15,10 +15,10 @@ const DEFAULT_LOOP_DETECTION: Required<LoopDetectionConfig> = {
};
/**
* Loop detector for tracking consecutive same-step executions.
* Loop detector for tracking consecutive same-movement executions.
*/
export class LoopDetector {
private lastStepName: string | null = null;
private lastMovementName: string | null = null;
private consecutiveCount = 0;
private config: Required<LoopDetectionConfig>;
@ -30,15 +30,15 @@ export class LoopDetector {
}
/**
* Check if the given step execution would be a loop.
* Check if the given movement execution would be a loop.
* Updates internal tracking state.
*/
check(stepName: string): LoopCheckResult {
if (this.lastStepName === stepName) {
check(movementName: string): LoopCheckResult {
if (this.lastMovementName === movementName) {
this.consecutiveCount++;
} else {
this.consecutiveCount = 1;
this.lastStepName = stepName;
this.lastMovementName = movementName;
}
const isLoop = this.consecutiveCount > this.config.maxConsecutiveSameStep;
@ -57,7 +57,7 @@ export class LoopDetector {
* Reset the detector state.
*/
reset(): void {
this.lastStepName = null;
this.lastMovementName = null;
this.consecutiveCount = 0;
}

View File

@ -1,20 +1,20 @@
/**
* Parallel step log display
* Parallel movement log display
*
* Provides prefixed, color-coded interleaved output for parallel sub-steps.
* Each sub-step's stream output gets a `[name]` prefix with right-padding
* aligned to the longest sub-step name.
* Provides prefixed, color-coded interleaved output for parallel sub-movements.
* Each sub-movement's stream output gets a `[name]` prefix with right-padding
* aligned to the longest sub-movement name.
*/
import type { StreamCallback, StreamEvent } from '../types.js';
/** ANSI color codes for sub-step prefixes (cycled in order) */
/** ANSI color codes for sub-movement prefixes (cycled in order) */
const COLORS = ['\x1b[36m', '\x1b[33m', '\x1b[35m', '\x1b[32m'] as const; // cyan, yellow, magenta, green
const RESET = '\x1b[0m';
export interface ParallelLoggerOptions {
/** Sub-step names (used to calculate prefix width) */
subStepNames: string[];
/** Sub-movement names (used to calculate prefix width) */
subMovementNames: string[];
/** Parent onStream callback to delegate non-prefixed events */
parentOnStream?: StreamCallback;
/** Override process.stdout.write for testing */
@ -22,9 +22,9 @@ export interface ParallelLoggerOptions {
}
/**
* Logger for parallel step execution.
* Logger for parallel movement execution.
*
* Creates per-sub-step StreamCallback wrappers that:
* Creates per-sub-movement StreamCallback wrappers that:
* - Buffer partial lines until newline
* - Prepend colored `[name]` prefix to each complete line
* - Delegate init/result/error events to the parent callback
@ -36,17 +36,17 @@ export class ParallelLogger {
private readonly writeFn: (text: string) => void;
constructor(options: ParallelLoggerOptions) {
this.maxNameLength = Math.max(...options.subStepNames.map((n) => n.length));
this.maxNameLength = Math.max(...options.subMovementNames.map((n) => n.length));
this.parentOnStream = options.parentOnStream;
this.writeFn = options.writeFn ?? ((text: string) => process.stdout.write(text));
for (const name of options.subStepNames) {
for (const name of options.subMovementNames) {
this.lineBuffers.set(name, '');
}
}
/**
* Build the colored prefix string for a sub-step.
* Build the colored prefix string for a sub-movement.
* Format: `\x1b[COLORm[name]\x1b[0m` + padding spaces
*/
buildPrefix(name: string, index: number): string {
@ -56,19 +56,19 @@ export class ParallelLogger {
}
/**
* Create a StreamCallback wrapper for a specific sub-step.
* Create a StreamCallback wrapper for a specific sub-movement.
*
* - `text`: buffered line-by-line with prefix
* - `tool_use`, `tool_result`, `tool_output`, `thinking`: prefixed per-line, no buffering
* - `init`, `result`, `error`: delegated to parent callback (no prefix)
*/
createStreamHandler(subStepName: string, index: number): StreamCallback {
const prefix = this.buildPrefix(subStepName, index);
createStreamHandler(subMovementName: string, index: number): StreamCallback {
const prefix = this.buildPrefix(subMovementName, index);
return (event: StreamEvent) => {
switch (event.type) {
case 'text':
this.handleTextEvent(subStepName, prefix, event.data.text);
this.handleTextEvent(subMovementName, prefix, event.data.text);
break;
case 'tool_use':
@ -144,13 +144,13 @@ export class ParallelLogger {
}
/**
* Flush remaining line buffers for all sub-steps.
* Call after all sub-steps complete to output any trailing partial lines.
* Flush remaining line buffers for all sub-movements.
* Call after all sub-movements complete to output any trailing partial lines.
*/
flush(): void {
// Build prefixes for flush — need index mapping
// Since we don't store index, iterate lineBuffers in insertion order
// (Map preserves insertion order, matching subStepNames order)
// (Map preserves insertion order, matching subMovementNames order)
let index = 0;
for (const [name, buffer] of this.lineBuffers) {
if (buffer !== '') {
@ -163,7 +163,7 @@ export class ParallelLogger {
}
/**
* Print completion summary after all sub-steps finish.
* Print completion summary after all sub-movements finish.
*
* Format:
* ```
@ -174,7 +174,7 @@ export class ParallelLogger {
* ```
*/
printSummary(
parentStepName: string,
parentMovementName: string,
results: Array<{ name: string; condition: string | undefined }>,
): void {
this.flush();
@ -188,7 +188,7 @@ export class ParallelLogger {
});
// Header line: ── name results ──
const headerText = ` ${parentStepName} results `;
const headerText = ` ${parentMovementName} results `;
const maxLineLength = Math.max(
headerText.length + 4, // 4 for "── " + " ──"
...resultLines.map((l) => l.length),

View File

@ -36,23 +36,23 @@ export class StateManager {
this.state = {
workflowName: config.name,
currentStep: config.initialStep,
currentMovement: config.initialMovement,
iteration: 0,
stepOutputs: new Map(),
movementOutputs: new Map(),
userInputs,
agentSessions,
stepIterations: new Map(),
movementIterations: new Map(),
status: 'running',
};
}
/**
* Increment the iteration counter for a step and return the new value.
* Increment the iteration counter for a movement and return the new value.
*/
incrementStepIteration(stepName: string): number {
const current = this.state.stepIterations.get(stepName) ?? 0;
incrementMovementIteration(movementName: string): number {
const current = this.state.movementIterations.get(movementName) ?? 0;
const next = current + 1;
this.state.stepIterations.set(stepName, next);
this.state.movementIterations.set(movementName, next);
return next;
}
@ -68,10 +68,10 @@ export class StateManager {
}
/**
* Get the most recent step output.
* Get the most recent movement output.
*/
getPreviousOutput(): AgentResponse | undefined {
const outputs = Array.from(this.state.stepOutputs.values());
const outputs = Array.from(this.state.movementOutputs.values());
return outputs[outputs.length - 1];
}
}
@ -89,12 +89,12 @@ export function createInitialState(
}
/**
* Increment the iteration counter for a step and return the new value.
* Increment the iteration counter for a movement and return the new value.
*/
export function incrementStepIteration(state: WorkflowState, stepName: string): number {
const current = state.stepIterations.get(stepName) ?? 0;
export function incrementMovementIteration(state: WorkflowState, movementName: string): number {
const current = state.movementIterations.get(movementName) ?? 0;
const next = current + 1;
state.stepIterations.set(stepName, next);
state.movementIterations.set(movementName, next);
return next;
}
@ -110,9 +110,9 @@ export function addUserInput(state: WorkflowState, input: string): void {
}
/**
* Get the most recent step output.
* Get the most recent movement output.
*/
export function getPreviousOutput(state: WorkflowState): AgentResponse | undefined {
const outputs = Array.from(state.stepOutputs.values());
const outputs = Array.from(state.movementOutputs.values());
return outputs[outputs.length - 1];
}

View File

@ -1,19 +1,19 @@
/**
* Workflow state transition logic
*
* Handles determining the next step based on rules-based routing.
* Handles determining the next movement based on rules-based routing.
*/
import type {
WorkflowStep,
WorkflowMovement,
} from '../../models/types.js';
/**
* Determine next step using rules-based detection.
* Returns the next step name from the matched rule, or null if no rule matched.
* Determine next movement using rules-based detection.
* Returns the next movement name from the matched rule, or null if no rule matched.
*/
export function determineNextStepByRules(
step: WorkflowStep,
export function determineNextMovementByRules(
step: WorkflowMovement,
ruleIndex: number,
): string | null {
const rule = step.rules?.[ruleIndex];

View File

@ -1,38 +1,38 @@
/**
* Aggregate condition evaluator for parallel workflow steps
* Aggregate condition evaluator for parallel workflow movements
*
* Evaluates all()/any() aggregate conditions against sub-step results.
* Evaluates all()/any() aggregate conditions against sub-movement results.
*/
import type { WorkflowStep, WorkflowState } from '../../models/types.js';
import type { WorkflowMovement, WorkflowState } from '../../models/types.js';
import { createLogger } from '../../../shared/utils/index.js';
const log = createLogger('aggregate-evaluator');
/**
* Evaluates aggregate conditions (all()/any()) for parallel parent steps.
* Evaluates aggregate conditions (all()/any()) for parallel parent movements.
*
* For each aggregate rule, checks the matched condition text of sub-steps:
* - all("X"): true when ALL sub-steps have matched condition === X
* - all("A", "B"): true when 1st sub-step matches "A" AND 2nd sub-step matches "B" (order-based)
* - any("X"): true when at least ONE sub-step has matched condition === X
* - any("A", "B"): true when at least ONE sub-step matches "A" OR "B"
* For each aggregate rule, checks the matched condition text of sub-movements:
* - all("X"): true when ALL sub-movements have matched condition === X
* - all("A", "B"): true when 1st sub-movement matches "A" AND 2nd sub-movement matches "B" (order-based)
* - any("X"): true when at least ONE sub-movement has matched condition === X
* - any("A", "B"): true when at least ONE sub-movement matches "A" OR "B"
*
* Edge cases per spec:
* - Sub-step with no matched rule: all() false, any() skip that sub-step
* - No sub-steps (0 ): both false
* - Non-parallel step: both false
* - all("A", "B") with wrong number of sub-steps: false (logged as error)
* - Sub-movement with no matched rule: all() false, any() skip that sub-movement
* - No sub-movements (0 ): both false
* - Non-parallel movement: both false
* - all("A", "B") with wrong number of sub-movements: false (logged as error)
*/
export class AggregateEvaluator {
constructor(
private readonly step: WorkflowStep,
private readonly step: WorkflowMovement,
private readonly state: WorkflowState,
) {}
/**
* Evaluate aggregate conditions.
* Returns the 0-based rule index in the step's rules array, or -1 if no match.
* Returns the 0-based rule index in the movement's rules array, or -1 if no match.
*/
evaluate(): number {
if (!this.step.rules || !this.step.parallel || this.step.parallel.length === 0) return -1;
@ -43,22 +43,22 @@ export class AggregateEvaluator {
continue;
}
const subSteps = this.step.parallel;
const subMovements = this.step.parallel;
const targetCondition = rule.aggregateConditionText;
if (rule.aggregateType === 'all') {
// Multiple conditions: order-based matching (1st sub-step matches 1st condition, etc.)
// Multiple conditions: order-based matching (1st sub-movement matches 1st condition, etc.)
if (Array.isArray(targetCondition)) {
if (targetCondition.length !== subSteps.length) {
if (targetCondition.length !== subMovements.length) {
log.error('all() condition count mismatch', {
step: this.step.name,
movement: this.step.name,
conditionCount: targetCondition.length,
subStepCount: subSteps.length,
subMovementCount: subMovements.length,
});
continue;
}
const allMatch = subSteps.every((sub, idx) => {
const output = this.state.stepOutputs.get(sub.name);
const allMatch = subMovements.every((sub, idx) => {
const output = this.state.movementOutputs.get(sub.name);
if (!output || output.matchedRuleIndex == null || !sub.rules) return false;
const matchedRule = sub.rules[output.matchedRuleIndex];
const expectedCondition = targetCondition[idx];
@ -66,46 +66,46 @@ export class AggregateEvaluator {
return matchedRule?.condition === expectedCondition;
});
if (allMatch) {
log.debug('Aggregate all() matched (multi-condition)', { step: this.step.name, conditions: targetCondition, ruleIndex: i });
log.debug('Aggregate all() matched (multi-condition)', { movement: this.step.name, conditions: targetCondition, ruleIndex: i });
return i;
}
} else {
// Single condition: all sub-steps must match the same condition
const allMatch = subSteps.every((sub) => {
const output = this.state.stepOutputs.get(sub.name);
// Single condition: all sub-movements must match the same condition
const allMatch = subMovements.every((sub) => {
const output = this.state.movementOutputs.get(sub.name);
if (!output || output.matchedRuleIndex == null || !sub.rules) return false;
const matchedRule = sub.rules[output.matchedRuleIndex];
return matchedRule?.condition === targetCondition;
});
if (allMatch) {
log.debug('Aggregate all() matched', { step: this.step.name, condition: targetCondition, ruleIndex: i });
log.debug('Aggregate all() matched', { movement: this.step.name, condition: targetCondition, ruleIndex: i });
return i;
}
}
} else {
// 'any'
if (Array.isArray(targetCondition)) {
// Multiple conditions: at least one sub-step matches at least one condition
const anyMatch = subSteps.some((sub) => {
const output = this.state.stepOutputs.get(sub.name);
// Multiple conditions: at least one sub-movement matches at least one condition
const anyMatch = subMovements.some((sub) => {
const output = this.state.movementOutputs.get(sub.name);
if (!output || output.matchedRuleIndex == null || !sub.rules) return false;
const matchedRule = sub.rules[output.matchedRuleIndex];
return targetCondition.includes(matchedRule?.condition ?? '');
});
if (anyMatch) {
log.debug('Aggregate any() matched (multi-condition)', { step: this.step.name, conditions: targetCondition, ruleIndex: i });
log.debug('Aggregate any() matched (multi-condition)', { movement: this.step.name, conditions: targetCondition, ruleIndex: i });
return i;
}
} else {
// Single condition: at least one sub-step matches the condition
const anyMatch = subSteps.some((sub) => {
const output = this.state.stepOutputs.get(sub.name);
// Single condition: at least one sub-movement matches the condition
const anyMatch = subMovements.some((sub) => {
const output = this.state.movementOutputs.get(sub.name);
if (!output || output.matchedRuleIndex == null || !sub.rules) return false;
const matchedRule = sub.rules[output.matchedRuleIndex];
return matchedRule?.condition === targetCondition;
});
if (anyMatch) {
log.debug('Aggregate any() matched', { step: this.step.name, condition: targetCondition, ruleIndex: i });
log.debug('Aggregate any() matched', { movement: this.step.name, condition: targetCondition, ruleIndex: i });
return i;
}
}

View File

@ -1,13 +1,13 @@
/**
* Rule evaluation logic for workflow steps
* Rule evaluation logic for workflow movements
*
* Evaluates workflow step rules to determine the matched rule index.
* Evaluates workflow movement rules to determine the matched rule index.
* Supports tag-based detection, ai() conditions, aggregate conditions,
* and AI judge fallback.
*/
import type {
WorkflowStep,
WorkflowMovement,
WorkflowState,
RuleMatchMethod,
} from '../../models/types.js';
@ -23,7 +23,7 @@ export interface RuleMatch {
}
export interface RuleEvaluatorContext {
/** Workflow state (for accessing stepOutputs in aggregate evaluation) */
/** Workflow state (for accessing movementOutputs in aggregate evaluation) */
state: WorkflowState;
/** Working directory (for AI judge calls) */
cwd: string;
@ -36,21 +36,21 @@ export interface RuleEvaluatorContext {
}
/**
* Evaluates rules for a workflow step to determine the next transition.
* Evaluates rules for a workflow movement to determine the next transition.
*
* Evaluation order (first match wins):
* 1. Aggregate conditions: all()/any() evaluate sub-step results
* 1. Aggregate conditions: all()/any() evaluate sub-movement results
* 2. Tag detection from Phase 3 output
* 3. Tag detection from Phase 1 output (fallback)
* 4. ai() condition evaluation via AI judge
* 5. All-conditions AI judge (final fallback)
*
* Returns undefined for steps without rules.
* Returns undefined for movements without rules.
* Throws if rules exist but no rule matched (Fail Fast).
*/
export class RuleEvaluator {
constructor(
private readonly step: WorkflowStep,
private readonly step: WorkflowMovement,
private readonly ctx: RuleEvaluatorContext,
) {}
@ -58,7 +58,7 @@ export class RuleEvaluator {
if (!this.step.rules || this.step.rules.length === 0) return undefined;
const interactiveEnabled = this.ctx.interactive === true;
// 1. Aggregate conditions (all/any) — only meaningful for parallel parent steps
// 1. Aggregate conditions (all/any) — only meaningful for parallel parent movements
const aggEvaluator = new AggregateEvaluator(this.step, this.ctx.state);
const aggIndex = aggEvaluator.evaluate();
if (aggIndex >= 0) {
@ -103,7 +103,7 @@ export class RuleEvaluator {
return { index: fallbackIndex, method: 'ai_judge_fallback' };
}
throw new Error(`Status not found for step "${this.step.name}": no rule matched after all detection phases`);
throw new Error(`Status not found for movement "${this.step.name}": no rule matched after all detection phases`);
}
/**
@ -127,7 +127,7 @@ export class RuleEvaluator {
if (aiConditions.length === 0) return -1;
log.debug('Evaluating ai() conditions via judge', {
step: this.step.name,
movement: this.step.name,
conditionCount: aiConditions.length,
});
@ -137,7 +137,7 @@ export class RuleEvaluator {
if (judgeResult >= 0 && judgeResult < aiConditions.length) {
const matched = aiConditions[judgeResult]!;
log.debug('AI judge matched condition', {
step: this.step.name,
movement: this.step.name,
judgeResult,
originalRuleIndex: matched.index,
condition: matched.text,
@ -145,7 +145,7 @@ export class RuleEvaluator {
return matched.index;
}
log.debug('AI judge did not match any condition', { step: this.step.name });
log.debug('AI judge did not match any condition', { movement: this.step.name });
return -1;
}
@ -162,7 +162,7 @@ export class RuleEvaluator {
.map((rule) => ({ index: rule.index, text: rule.text }));
log.debug('Evaluating all conditions via AI judge (final fallback)', {
step: this.step.name,
movement: this.step.name,
conditionCount: conditions.length,
});
@ -170,14 +170,14 @@ export class RuleEvaluator {
if (judgeResult >= 0 && judgeResult < conditions.length) {
log.debug('AI judge (fallback) matched condition', {
step: this.step.name,
movement: this.step.name,
ruleIndex: judgeResult,
condition: conditions[judgeResult]!.text,
});
return judgeResult;
}
log.debug('AI judge (fallback) did not match any condition', { step: this.step.name });
log.debug('AI judge (fallback) did not match any condition', { movement: this.step.name });
return -1;
}

View File

@ -2,7 +2,7 @@
* Rule evaluation - barrel exports
*/
import type { WorkflowStep, WorkflowState } from '../../models/types.js';
import type { WorkflowMovement, WorkflowState } from '../../models/types.js';
import { RuleEvaluator } from './RuleEvaluator.js';
import { AggregateEvaluator } from './AggregateEvaluator.js';
@ -14,11 +14,11 @@ export { AggregateEvaluator } from './AggregateEvaluator.js';
import type { RuleMatch, RuleEvaluatorContext } from './RuleEvaluator.js';
/**
* Detect matched rule for a step's response.
* Detect matched rule for a movement's response.
* Function facade over RuleEvaluator class.
*/
export async function detectMatchedRule(
step: WorkflowStep,
step: WorkflowMovement,
agentContent: string,
tagContent: string,
ctx: RuleEvaluatorContext,
@ -30,6 +30,6 @@ export async function detectMatchedRule(
* Evaluate aggregate conditions.
* Function facade over AggregateEvaluator class.
*/
export function evaluateAggregateConditions(step: WorkflowStep, state: WorkflowState): number {
export function evaluateAggregateConditions(step: WorkflowMovement, state: WorkflowState): number {
return new AggregateEvaluator(step, state).evaluate();
}

View File

@ -2,16 +2,16 @@
* Shared rule utility functions used by both engine.ts and instruction-builder.ts.
*/
import type { WorkflowStep } from '../../models/types.js';
import type { WorkflowMovement } from '../../models/types.js';
/**
* Check whether a step has tag-based rules (i.e., rules that require
* [STEP:N] tag output for detection).
* Check whether a movement has tag-based rules (i.e., rules that require
* [MOVEMENT:N] tag output for detection).
*
* Returns false when all rules are ai() or aggregate conditions,
* meaning no tag-based status output is needed.
*/
export function hasTagBasedRules(step: WorkflowStep): boolean {
export function hasTagBasedRules(step: WorkflowMovement): boolean {
if (!step.rules || step.rules.length === 0) return false;
const allNonTagConditions = step.rules.every((r) => r.isAiCondition || r.isAggregateCondition);
return !allNonTagConditions;

View File

@ -9,7 +9,7 @@
export { WorkflowEngine } from './engine/index.js';
// Constants
export { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from './constants.js';
export { COMPLETE_MOVEMENT, ABORT_MOVEMENT, COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from './constants.js';
// Types
export type {
@ -30,7 +30,7 @@ export type {
} from './types.js';
// Transitions (engine/)
export { determineNextStepByRules, extractBlockedPrompt } from './engine/transitions.js';
export { determineNextMovementByRules, extractBlockedPrompt } from './engine/transitions.js';
// Loop detection (engine/)
export { LoopDetector } from './engine/loop-detector.js';
@ -40,6 +40,7 @@ export {
createInitialState,
addUserInput,
getPreviousOutput,
incrementMovementIteration,
} from './engine/state-manager.js';
// Blocked handling (engine/)
@ -52,8 +53,8 @@ export { ParallelLogger } from './engine/parallel-logger.js';
export { InstructionBuilder, isReportObjectConfig } from './instruction/InstructionBuilder.js';
export { ReportInstructionBuilder, type ReportInstructionContext } from './instruction/ReportInstructionBuilder.js';
export { StatusJudgmentBuilder, type StatusJudgmentContext } from './instruction/StatusJudgmentBuilder.js';
export { buildExecutionMetadata, renderExecutionMetadata, type InstructionContext, type ExecutionMetadata } from './instruction/instruction-context.js';
export { generateStatusRulesFromRules } from './instruction/status-rules.js';
export { buildEditRule, type InstructionContext } from './instruction/instruction-context.js';
export { generateStatusRulesComponents, type StatusRulesComponents } from './instruction/status-rules.js';
// Rule evaluation
export { RuleEvaluator, type RuleMatch, type RuleEvaluatorContext, detectMatchedRule, evaluateAggregateConditions } from './evaluation/index.js';

View File

@ -1,20 +1,15 @@
/**
* Phase 1 instruction builder
*
* Builds the instruction string for main agent execution by:
* 1. Auto-injecting standard sections (Execution Context, Workflow Context,
* User Request, Previous Response, Additional User Inputs, Instructions header,
* Status Output Rules)
* 2. Replacing template placeholders with actual values
* Builds the instruction string for main agent execution.
* Assembles template variables and renders a single complete template.
*/
import type { WorkflowStep, Language, ReportConfig, ReportObjectConfig } from '../../models/types.js';
import { hasTagBasedRules } from '../evaluation/rule-utils.js';
import type { WorkflowMovement, Language, ReportConfig, ReportObjectConfig } from '../../models/types.js';
import type { InstructionContext } from './instruction-context.js';
import { buildExecutionMetadata, renderExecutionMetadata } from './instruction-context.js';
import { generateStatusRulesFromRules } from './status-rules.js';
import { buildEditRule } from './instruction-context.js';
import { escapeTemplateChars, replaceTemplatePlaceholders } from './escape.js';
import { getPromptObject } from '../../../shared/prompts/index.js';
import { loadTemplate } from '../../../shared/prompts/index.js';
/**
* Check if a report config is the object form (ReportObjectConfig).
@ -23,162 +18,142 @@ export function isReportObjectConfig(report: string | ReportConfig[] | ReportObj
return typeof report === 'object' && !Array.isArray(report) && 'name' in report;
}
/** Shape of localized section strings */
interface SectionStrings {
workflowContext: string;
workflowStructure: string;
currentStepMarker: string;
iteration: string;
iterationWorkflowWide: string;
stepIteration: string;
stepIterationTimes: string;
step: string;
reportDirectory: string;
reportFile: string;
reportFiles: string;
phaseNote: string;
userRequest: string;
previousResponse: string;
additionalUserInputs: string;
instructions: string;
}
/** Shape of localized report output strings */
interface ReportOutputStrings {
singleHeading: string;
multiHeading: string;
createRule: string;
appendRule: string;
}
/**
* Builds Phase 1 instructions for agent execution.
*
* Stateless builder all data is passed via constructor context.
* Renders a single complete template with all variables.
*/
export class InstructionBuilder {
constructor(
private readonly step: WorkflowStep,
private readonly step: WorkflowMovement,
private readonly context: InstructionContext,
) {}
/**
* Build the complete instruction string.
*
* Generates a complete instruction by auto-injecting standard sections
* around the step-specific instruction_template content.
* Assembles all template variables and renders the Phase 1 template
* in a single loadTemplate() call.
*/
build(): string {
const language = this.context.language ?? 'en';
const s = getPromptObject<SectionStrings>('instruction.sections', language);
const sections: string[] = [];
// 1. Execution context metadata (working directory + rules + edit permission)
const metadata = buildExecutionMetadata(this.context, this.step.edit);
sections.push(renderExecutionMetadata(metadata));
// Execution context variables
const editRule = buildEditRule(this.step.edit, language);
// 2. Workflow Context (iteration, step, report info)
sections.push(this.renderWorkflowContext(language));
// Workflow structure (loop expansion done in code)
const workflowStructure = this.buildWorkflowStructure(language);
// Skip auto-injection for sections whose placeholders exist in the template,
// to avoid duplicate content.
// Report info
const hasReport = !!(this.step.report && this.context.reportDir);
let reportInfo = '';
let phaseNote = '';
if (hasReport) {
reportInfo = renderReportContext(this.step.report!, this.context.reportDir!);
phaseNote = language === 'ja'
? '**注意:** これはPhase 1本来の作業です。作業完了後、Phase 2で自動的にレポートを生成します。'
: '**Note:** This is Phase 1 (main work). After you complete your work, Phase 2 will automatically generate the report based on your findings.';
}
// Skip auto-injection for sections whose placeholders exist in the template
const tmpl = this.step.instructionTemplate;
const hasTaskPlaceholder = tmpl.includes('{task}');
const hasPreviousResponsePlaceholder = tmpl.includes('{previous_response}');
const hasUserInputsPlaceholder = tmpl.includes('{user_inputs}');
// 3. User Request (skip if template embeds {task} directly)
if (!hasTaskPlaceholder) {
sections.push(`${s.userRequest}\n${escapeTemplateChars(this.context.task)}`);
}
// User Request
const hasTaskSection = !hasTaskPlaceholder;
const userRequest = hasTaskSection ? escapeTemplateChars(this.context.task) : '';
// 4. Previous Response (skip if template embeds {previous_response} directly)
if (this.step.passPreviousResponse && this.context.previousOutput && !hasPreviousResponsePlaceholder) {
sections.push(
`${s.previousResponse}\n${escapeTemplateChars(this.context.previousOutput.content)}`,
// Previous Response
const hasPreviousResponse = !!(
this.step.passPreviousResponse &&
this.context.previousOutput &&
!hasPreviousResponsePlaceholder
);
}
const previousResponse = hasPreviousResponse
? escapeTemplateChars(this.context.previousOutput!.content)
: '';
// 5. Additional User Inputs (skip if template embeds {user_inputs} directly)
if (!hasUserInputsPlaceholder) {
const userInputsStr = this.context.userInputs.join('\n');
sections.push(`${s.additionalUserInputs}\n${escapeTemplateChars(userInputsStr)}`);
}
// User Inputs
const hasUserInputs = !hasUserInputsPlaceholder;
const userInputs = hasUserInputs
? escapeTemplateChars(this.context.userInputs.join('\n'))
: '';
// 6. Instructions header + instruction_template content
const processedTemplate = replaceTemplatePlaceholders(
// Instructions (instruction_template processed)
const instructions = replaceTemplatePlaceholders(
this.step.instructionTemplate,
this.step,
this.context,
);
sections.push(`${s.instructions}\n${processedTemplate}`);
// 7. Status Output Rules (for tag-based detection in Phase 1)
if (hasTagBasedRules(this.step)) {
const statusRulesPrompt = generateStatusRulesFromRules(
this.step.name,
this.step.rules!,
language,
{ interactive: this.context.interactive },
);
sections.push(statusRulesPrompt);
}
return sections.join('\n\n');
}
private renderWorkflowContext(language: Language): string {
const s = getPromptObject<SectionStrings>('instruction.sections', language);
const lines: string[] = [s.workflowContext];
// Workflow structure (if workflow steps info is available)
if (this.context.workflowSteps && this.context.workflowSteps.length > 0) {
lines.push(s.workflowStructure.replace('{count}', String(this.context.workflowSteps.length)));
this.context.workflowSteps.forEach((ws, index) => {
const isCurrent = index === this.context.currentStepIndex;
const marker = isCurrent ? `${s.currentStepMarker}` : '';
const desc = ws.description ? `${ws.description}` : '';
lines.push(`- Step ${index + 1}: ${ws.name}${desc}${marker}`);
return loadTemplate('perform_phase1_message', language, {
workingDirectory: this.context.cwd,
editRule,
workflowStructure,
iteration: `${this.context.iteration}/${this.context.maxIterations}`,
movementIteration: String(this.context.movementIteration),
movement: this.step.name,
hasReport,
reportInfo,
phaseNote,
hasTaskSection,
userRequest,
hasPreviousResponse,
previousResponse,
hasUserInputs,
userInputs,
instructions,
});
lines.push('');
}
lines.push(`- ${s.iteration}: ${this.context.iteration}/${this.context.maxIterations}${s.iterationWorkflowWide}`);
lines.push(`- ${s.stepIteration}: ${this.context.stepIteration}${s.stepIterationTimes}`);
lines.push(`- ${s.step}: ${this.step.name}`);
// If step has report config, include Report Directory path and phase note
if (this.step.report && this.context.reportDir) {
const reportContext = renderReportContext(this.step.report, this.context.reportDir, language);
lines.push(reportContext);
lines.push('');
lines.push(s.phaseNote);
/**
* Build the workflow structure display string.
* Returns empty string if no workflow movements are available.
*/
private buildWorkflowStructure(language: Language): string {
if (!this.context.workflowMovements || this.context.workflowMovements.length === 0) {
return '';
}
return lines.join('\n');
const currentMovementMarker = language === 'ja' ? '現在' : 'current';
const structureHeader = language === 'ja'
? `このワークフローは${this.context.workflowMovements.length}ムーブメントで構成されています:`
: `This workflow consists of ${this.context.workflowMovements.length} movements:`;
const movementLines = this.context.workflowMovements.map((ws, index) => {
const isCurrent = index === this.context.currentMovementIndex;
const marker = isCurrent ? `${currentMovementMarker}` : '';
const desc = ws.description ? `${ws.description}` : '';
return `- Movement ${index + 1}: ${ws.name}${desc}${marker}`;
});
return [structureHeader, ...movementLines].join('\n');
}
}
/**
* Render report context info for Workflow Context section.
* Used by ReportInstructionBuilder.
* Used by InstructionBuilder and ReportInstructionBuilder.
*/
export function renderReportContext(
report: string | ReportConfig[] | ReportObjectConfig,
reportDir: string,
language: Language,
): string {
const s = getPromptObject<SectionStrings>('instruction.sections', language);
const reportDirectory = 'Report Directory';
const reportFile = 'Report File';
const reportFiles = 'Report Files';
const lines: string[] = [
`- ${s.reportDirectory}: ${reportDir}/`,
`- ${reportDirectory}: ${reportDir}/`,
];
if (typeof report === 'string') {
lines.push(`- ${s.reportFile}: ${reportDir}/${report}`);
lines.push(`- ${reportFile}: ${reportDir}/${report}`);
} else if (isReportObjectConfig(report)) {
lines.push(`- ${s.reportFile}: ${reportDir}/${report.name}`);
lines.push(`- ${reportFile}: ${reportDir}/${report.name}`);
} else {
lines.push(`- ${s.reportFiles}:`);
lines.push(`- ${reportFiles}:`);
for (const file of report) {
lines.push(` - ${file.label}: ${reportDir}/${file.path}`);
}
@ -188,20 +163,35 @@ export function renderReportContext(
}
/**
* Generate report output instructions from step.report config.
* Returns undefined if step has no report or no reportDir.
* Generate report output instructions from movement's report config.
* Returns empty string if movement has no report or no reportDir.
*/
export function renderReportOutputInstruction(
step: WorkflowStep,
step: WorkflowMovement,
context: InstructionContext,
language: Language,
): string | undefined {
if (!step.report || !context.reportDir) return undefined;
): string {
if (!step.report || !context.reportDir) return '';
const s = getPromptObject<ReportOutputStrings>('instruction.reportOutput', language);
const isMulti = Array.isArray(step.report);
const heading = isMulti ? s.multiHeading : s.singleHeading;
const appendRule = s.appendRule.replace('{step_iteration}', String(context.stepIteration));
return [heading, s.createRule, appendRule].join('\n');
let heading: string;
let createRule: string;
let appendRule: string;
if (language === 'ja') {
heading = isMulti
? '**レポート出力:** Report Files に出力してください。'
: '**レポート出力:** `Report File` に出力してください。';
createRule = '- ファイルが存在しない場合: 新規作成';
appendRule = `- ファイルが存在する場合: \`## Iteration ${context.movementIteration}\` セクションを追記`;
} else {
heading = isMulti
? '**Report output:** Output to the `Report Files` specified above.'
: '**Report output:** Output to the `Report File` specified above.';
createRule = '- If file does not exist: Create new file';
appendRule = `- If file exists: Append with \`## Iteration ${context.movementIteration}\` section`;
}
return `${heading}\n${createRule}\n${appendRule}`;
}

View File

@ -1,37 +1,15 @@
/**
* Phase 2 instruction builder (report output)
*
* Builds the instruction for the report output phase. Includes:
* - Execution Context (cwd + rules)
* - Workflow Context (report info only)
* - Report output instruction + format
*
* Does NOT include: User Request, Previous Response, User Inputs,
* Status rules, instruction_template.
* Builds the instruction for the report output phase.
* Assembles template variables and renders a single complete template.
*/
import type { WorkflowStep, Language } from '../../models/types.js';
import type { WorkflowMovement, Language } from '../../models/types.js';
import type { InstructionContext } from './instruction-context.js';
import { getMetadataStrings } from './instruction-context.js';
import { replaceTemplatePlaceholders } from './escape.js';
import { isReportObjectConfig, renderReportContext, renderReportOutputInstruction } from './InstructionBuilder.js';
import { getPromptObject } from '../../../shared/prompts/index.js';
/** Shape of localized report phase strings */
interface ReportPhaseStrings {
noSourceEdit: string;
reportDirOnly: string;
instructionBody: string;
reportJsonFormat: string;
reportPlainAllowed: string;
reportOnlyOutput: string;
}
/** Shape of localized report section strings */
interface ReportSectionStrings {
workflowContext: string;
instructions: string;
}
import { loadTemplate } from '../../../shared/prompts/index.js';
/**
* Context for building report phase instruction.
@ -41,8 +19,8 @@ export interface ReportInstructionContext {
cwd: string;
/** Report directory path */
reportDir: string;
/** Step iteration (for {step_iteration} replacement) */
stepIteration: number;
/** Movement iteration (for {step_iteration} replacement) */
movementIteration: number;
/** Language */
language?: Language;
/** Target report file name (when generating a single report) */
@ -51,73 +29,38 @@ export interface ReportInstructionContext {
/**
* Builds Phase 2 (report output) instructions.
*
* Renders a single complete template with all variables.
*/
export class ReportInstructionBuilder {
constructor(
private readonly step: WorkflowStep,
private readonly step: WorkflowMovement,
private readonly context: ReportInstructionContext,
) {}
build(): string {
if (!this.step.report) {
throw new Error(`ReportInstructionBuilder called for step "${this.step.name}" which has no report config`);
throw new Error(`ReportInstructionBuilder called for movement "${this.step.name}" which has no report config`);
}
const language = this.context.language ?? 'en';
const s = getPromptObject<ReportSectionStrings>('instruction.reportSections', language);
const r = getPromptObject<ReportPhaseStrings>('instruction.reportPhase', language);
const m = getMetadataStrings(language);
const sections: string[] = [];
// 1. Execution Context
const execLines = [
m.heading,
`- ${m.workingDirectory}: ${this.context.cwd}`,
'',
m.rulesHeading,
`- ${m.noCommit}`,
`- ${m.noCd}`,
`- ${r.noSourceEdit}`,
`- ${r.reportDirOnly}`,
];
if (m.note) {
execLines.push('');
execLines.push(m.note);
}
execLines.push('');
sections.push(execLines.join('\n'));
// 2. Workflow Context (single file info when targetFile is specified)
const workflowLines = [s.workflowContext];
// Build report context for Workflow Context section
let reportContext: string;
if (this.context.targetFile) {
const sectionStr = getPromptObject<{ reportDirectory: string; reportFile: string }>('instruction.sections', language);
workflowLines.push(`- ${sectionStr.reportDirectory}: ${this.context.reportDir}/`);
workflowLines.push(`- ${sectionStr.reportFile}: ${this.context.reportDir}/${this.context.targetFile}`);
reportContext = `- Report Directory: ${this.context.reportDir}/\n- Report File: ${this.context.reportDir}/${this.context.targetFile}`;
} else {
workflowLines.push(renderReportContext(this.step.report, this.context.reportDir, language));
}
sections.push(workflowLines.join('\n'));
// 3. Instructions (simplified when targetFile is specified)
const instrParts: string[] = [s.instructions];
if (this.context.targetFile) {
instrParts.push(r.instructionBody);
instrParts.push(`**このフェーズではツールは使えません。レポート内容をテキストとして直接回答してください。**`);
instrParts.push(`**レポート本文のみを回答してください。** ファイル名やJSON形式は不要です。`);
} else {
instrParts.push(r.instructionBody);
instrParts.push(r.reportJsonFormat);
instrParts.push(r.reportPlainAllowed);
instrParts.push(r.reportOnlyOutput);
reportContext = renderReportContext(this.step.report, this.context.reportDir);
}
// Report output instruction (auto-generated or explicit order)
const reportContext: InstructionContext = {
// Build report output instruction
let reportOutput = '';
let hasReportOutput = false;
const instrContext: InstructionContext = {
task: '',
iteration: 0,
maxIterations: 0,
stepIteration: this.context.stepIteration,
movementIteration: this.context.movementIteration,
cwd: this.context.cwd,
projectCwd: this.context.cwd,
userInputs: [],
@ -126,26 +69,31 @@ export class ReportInstructionBuilder {
};
if (isReportObjectConfig(this.step.report) && this.step.report.order) {
const processedOrder = replaceTemplatePlaceholders(this.step.report.order.trimEnd(), this.step, reportContext);
instrParts.push('');
instrParts.push(processedOrder);
reportOutput = replaceTemplatePlaceholders(this.step.report.order.trimEnd(), this.step, instrContext);
hasReportOutput = true;
} else if (!this.context.targetFile) {
const reportInstruction = renderReportOutputInstruction(this.step, reportContext, language);
if (reportInstruction) {
instrParts.push('');
instrParts.push(reportInstruction);
const output = renderReportOutputInstruction(this.step, instrContext, language);
if (output) {
reportOutput = output;
hasReportOutput = true;
}
}
// Report format
// Build report format
let reportFormat = '';
let hasReportFormat = false;
if (isReportObjectConfig(this.step.report) && this.step.report.format) {
const processedFormat = replaceTemplatePlaceholders(this.step.report.format.trimEnd(), this.step, reportContext);
instrParts.push('');
instrParts.push(processedFormat);
reportFormat = replaceTemplatePlaceholders(this.step.report.format.trimEnd(), this.step, instrContext);
hasReportFormat = true;
}
sections.push(instrParts.join('\n'));
return sections.join('\n\n');
return loadTemplate('perform_phase2_message', language, {
workingDirectory: this.context.cwd,
reportContext,
hasReportOutput,
reportOutput,
hasReportFormat,
reportFormat,
});
}
}

View File

@ -4,14 +4,13 @@
* Resumes the agent session and asks it to evaluate its work
* and output the appropriate status tag. No tools are allowed.
*
* Includes:
* - Header instruction (review and determine status)
* - Status rules (criteria table + output format)
* Renders a single complete template combining the judgment header
* and status rules (criteria table + output format).
*/
import type { WorkflowStep, Language } from '../../models/types.js';
import { generateStatusRulesFromRules } from './status-rules.js';
import { getPrompt } from '../../../shared/prompts/index.js';
import type { WorkflowMovement, Language } from '../../models/types.js';
import { generateStatusRulesComponents } from './status-rules.js';
import { loadTemplate } from '../../../shared/prompts/index.js';
/**
* Context for building status judgment instruction.
@ -25,33 +24,34 @@ export interface StatusJudgmentContext {
/**
* Builds Phase 3 (status judgment) instructions.
*
* Renders a single complete template with all variables.
*/
export class StatusJudgmentBuilder {
constructor(
private readonly step: WorkflowStep,
private readonly step: WorkflowMovement,
private readonly context: StatusJudgmentContext,
) {}
build(): string {
if (!this.step.rules || this.step.rules.length === 0) {
throw new Error(`StatusJudgmentBuilder called for step "${this.step.name}" which has no rules`);
throw new Error(`StatusJudgmentBuilder called for movement "${this.step.name}" which has no rules`);
}
const language = this.context.language ?? 'en';
const sections: string[] = [];
// Header
sections.push(getPrompt('instruction.statusJudgment.header', language));
// Status rules (criteria table + output format)
const generatedPrompt = generateStatusRulesFromRules(
const components = generateStatusRulesComponents(
this.step.name,
this.step.rules,
language,
{ interactive: this.context.interactive },
);
sections.push(generatedPrompt);
return sections.join('\n\n');
return loadTemplate('perform_phase3_message', language, {
criteriaTable: components.criteriaTable,
outputList: components.outputList,
hasAppendix: components.hasAppendix,
appendixContent: components.appendixContent,
});
}
}

View File

@ -4,7 +4,7 @@
* Used by instruction builders to process instruction_template content.
*/
import type { WorkflowStep } from '../../models/types.js';
import type { WorkflowMovement } from '../../models/types.js';
import type { InstructionContext } from './instruction-context.js';
/**
@ -22,7 +22,7 @@ export function escapeTemplateChars(str: string): string {
*/
export function replaceTemplatePlaceholders(
template: string,
step: WorkflowStep,
step: WorkflowMovement,
context: InstructionContext,
): string {
let result = template;
@ -30,10 +30,12 @@ export function replaceTemplatePlaceholders(
// Replace {task}
result = result.replace(/\{task\}/g, escapeTemplateChars(context.task));
// Replace {iteration}, {max_iterations}, and {step_iteration}
// Replace {iteration}, {max_iterations}, and {movement_iteration}
result = result.replace(/\{iteration\}/g, String(context.iteration));
result = result.replace(/\{max_iterations\}/g, String(context.maxIterations));
result = result.replace(/\{step_iteration\}/g, String(context.stepIteration));
result = result.replace(/\{movement_iteration\}/g, String(context.movementIteration));
// @deprecated Use {movement_iteration} instead
result = result.replace(/\{step_iteration\}/g, String(context.movementIteration));
// Replace {previous_response}
if (step.passPreviousResponse) {

View File

@ -1,12 +1,10 @@
/**
* Instruction context types and execution metadata rendering
* Instruction context types and edit rule generation
*
* Defines the context structures used by instruction builders,
* and renders execution metadata (working directory, rules) as markdown.
* Defines the context structures used by instruction builders.
*/
import type { AgentResponse, Language } from '../../models/types.js';
import { getPromptObject } from '../../../shared/prompts/index.js';
/**
* Context for building instruction from template.
@ -18,15 +16,15 @@ export interface InstructionContext {
iteration: number;
/** Maximum iterations allowed */
maxIterations: number;
/** Current step's iteration number (how many times this step has been executed) */
stepIteration: number;
/** Current movement's iteration number (how many times this movement has been executed) */
movementIteration: number;
/** Working directory (agent work dir, may be a clone) */
cwd: string;
/** Project root directory (where .takt/ lives). */
projectCwd: string;
/** User inputs accumulated during workflow */
userInputs: string[];
/** Previous step output if available */
/** Previous movement output if available */
previousOutput?: AgentResponse;
/** Report directory path */
reportDir?: string;
@ -34,78 +32,30 @@ export interface InstructionContext {
language?: Language;
/** Whether interactive-only rules are enabled */
interactive?: boolean;
/** Top-level workflow steps for workflow structure display */
workflowSteps?: ReadonlyArray<{ name: string; description?: string }>;
/** Index of the current step in workflowSteps (0-based) */
currentStepIndex?: number;
}
/** Execution environment metadata prepended to agent instructions */
export interface ExecutionMetadata {
/** The agent's working directory (may be a clone) */
readonly workingDirectory: string;
/** Language for metadata rendering */
readonly language: Language;
/** Whether file editing is allowed for this step (undefined = no prompt) */
readonly edit?: boolean;
/** Top-level workflow movements for workflow structure display */
workflowMovements?: ReadonlyArray<{ name: string; description?: string }>;
/** Index of the current movement in workflowMovements (0-based) */
currentMovementIndex?: number;
}
/**
* Build execution metadata from instruction context and step config.
* Build the edit rule string for the execution context section.
*
* Pure function: (InstructionContext, edit?) ExecutionMetadata.
* Returns a localized string describing the edit permission for this movement.
* Returns empty string when edit is undefined (no explicit permission).
*/
export function buildExecutionMetadata(context: InstructionContext, edit?: boolean): ExecutionMetadata {
return {
workingDirectory: context.cwd,
language: context.language ?? 'en',
edit,
};
export function buildEditRule(edit: boolean | undefined, language: Language): string {
if (edit === true) {
if (language === 'ja') {
return '**このムーブメントでは編集が許可されています。** ユーザーの要求に応じて、ファイルの作成・変更・削除を行ってください。';
}
/** Shape of localized metadata strings from YAML */
export interface MetadataStrings {
heading: string;
workingDirectory: string;
rulesHeading: string;
noCommit: string;
noCd: string;
editEnabled: string;
editDisabled: string;
note: string;
return '**Editing is ENABLED for this movement.** You may create, modify, and delete files as needed to fulfill the user\'s request.';
}
/** Load metadata strings for the given language from YAML */
export function getMetadataStrings(language: Language): MetadataStrings {
return getPromptObject<MetadataStrings>('instruction.metadata', language);
if (edit === false) {
if (language === 'ja') {
return '**このムーブメントでは編集が禁止されています。** プロジェクトのソースファイルを作成・変更・削除しないでください。コードの読み取り・検索のみ行ってください。レポート出力は後のフェーズで自動的に行われます。';
}
/**
* Render execution metadata as a markdown string.
*
* Pure function: ExecutionMetadata string.
* Always includes heading + Working Directory + Execution Rules.
* Language determines the output language; 'en' includes a note about language consistency.
*/
export function renderExecutionMetadata(metadata: ExecutionMetadata): string {
const strings = getMetadataStrings(metadata.language);
const lines = [
strings.heading,
`- ${strings.workingDirectory}: ${metadata.workingDirectory}`,
'',
strings.rulesHeading,
`- ${strings.noCommit}`,
`- ${strings.noCd}`,
];
if (metadata.edit === true) {
lines.push(`- ${strings.editEnabled}`);
} else if (metadata.edit === false) {
lines.push(`- ${strings.editDisabled}`);
return '**Editing is DISABLED for this movement.** Do NOT create, modify, or delete any project source files. You may only read and search code. Report output will be handled automatically in a later phase.';
}
if (strings.note) {
lines.push('');
lines.push(strings.note);
}
lines.push('');
return lines.join('\n');
return '';
}

View File

@ -1,87 +1,92 @@
/**
* Status rules prompt generation for workflow steps
* Status rules prompt generation for workflow movements
*
* Generates structured prompts that tell agents which numbered tags to output
* based on the step's rule configuration.
* Generates structured status rules content that tells agents which
* numbered tags to output based on the movement's rule configuration.
*
* Returns individual components (criteriaTable, outputList, appendix)
* that are passed as template variables to Phase 1/Phase 3 templates.
*/
import type { WorkflowRule, Language } from '../../models/types.js';
import { getPromptObject } from '../../../shared/prompts/index.js';
/** Shape of localized status rules strings */
interface StatusRulesStrings {
criteriaHeading: string;
headerNum: string;
headerCondition: string;
headerTag: string;
outputHeading: string;
outputInstruction: string;
appendixHeading: string;
appendixInstruction: string;
/** Components of the generated status rules */
export interface StatusRulesComponents {
criteriaTable: string;
outputList: string;
hasAppendix: boolean;
appendixContent: string;
}
/**
* Generate status rules prompt from rules configuration.
* Creates a structured prompt that tells the agent which numbered tags to output.
* Generate status rules components from rules configuration.
*
* Example output for step "plan" with 3 rules:
* ##
* | # | | |
* |---|------|------|
* | 1 | | `[PLAN:1]` |
* | 2 | | `[PLAN:2]` |
* | 3 | | `[PLAN:3]` |
* Loop expansion (criteria table rows, output list items, appendix blocks)
* is done in code and returned as individual string components.
* These are passed as template variables to the Phase 1/Phase 3 templates.
*/
export function generateStatusRulesFromRules(
stepName: string,
export function generateStatusRulesComponents(
movementName: string,
rules: WorkflowRule[],
language: Language,
options?: { interactive?: boolean },
): string {
const tag = stepName.toUpperCase();
const strings = getPromptObject<StatusRulesStrings>('instruction.statusRules', language);
): StatusRulesComponents {
const tag = movementName.toUpperCase();
const interactiveEnabled = options?.interactive;
const visibleRules = rules
.map((rule, index) => ({ rule, index }))
.filter(({ rule }) => interactiveEnabled !== false || !rule.interactiveOnly);
const lines: string[] = [];
// Build criteria table rows
const headerNum = '#';
const headerCondition = language === 'ja' ? '状況' : 'Condition';
const headerTag = language === 'ja' ? 'タグ' : 'Tag';
// Criteria table
lines.push(strings.criteriaHeading);
lines.push('');
lines.push(`| ${strings.headerNum} | ${strings.headerCondition} | ${strings.headerTag} |`);
lines.push('|---|------|------|');
for (const { rule, index } of visibleRules) {
lines.push(`| ${index + 1} | ${rule.condition} | \`[${tag}:${index + 1}]\` |`);
}
lines.push('');
const tableLines = [
`| ${headerNum} | ${headerCondition} | ${headerTag} |`,
'|---|------|------|',
...visibleRules.map(({ rule, index }) =>
`| ${index + 1} | ${rule.condition} | \`[${tag}:${index + 1}]\` |`,
),
];
const criteriaTable = tableLines.join('\n');
// Output format
lines.push(strings.outputHeading);
lines.push('');
lines.push(strings.outputInstruction);
lines.push('');
for (const { rule, index } of visibleRules) {
lines.push(`- \`[${tag}:${index + 1}]\`${rule.condition}`);
}
// Build output list
const outputInstruction = language === 'ja'
? '判定に対応するタグを出力してください:'
: 'Output the tag corresponding to your decision:';
// Appendix templates (if any rules have appendix)
const outputLines = [
outputInstruction,
'',
...visibleRules.map(({ rule, index }) =>
`- \`[${tag}:${index + 1}]\`${rule.condition}`,
),
];
const outputList = outputLines.join('\n');
// Build appendix content
const rulesWithAppendix = visibleRules.filter(({ rule }) => rule.appendix);
if (rulesWithAppendix.length > 0) {
lines.push('');
lines.push(strings.appendixHeading);
const hasAppendix = rulesWithAppendix.length > 0;
let appendixContent = '';
if (hasAppendix) {
const appendixInstructionTemplate = language === 'ja'
? '`[{tag}]` を出力する場合、以下を追記してください:'
: 'When outputting `[{tag}]`, append the following:';
const appendixBlocks: string[] = [];
for (const { rule, index } of visibleRules) {
if (!rule.appendix) continue;
const tagStr = `[${tag}:${index + 1}]`;
lines.push('');
// appendixInstruction contains {tag} as a domain-specific placeholder, not a YAML template variable
lines.push(strings.appendixInstruction.replace('{tag}', tagStr));
lines.push('```');
lines.push(rule.appendix.trimEnd());
lines.push('```');
appendixBlocks.push('');
appendixBlocks.push(appendixInstructionTemplate.replace('{tag}', tagStr));
appendixBlocks.push('```');
appendixBlocks.push(rule.appendix.trimEnd());
appendixBlocks.push('```');
}
appendixContent = appendixBlocks.join('\n');
}
return lines.join('\n');
return { criteriaTable, outputList, hasAppendix, appendixContent };
}

View File

@ -7,7 +7,7 @@
import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve, sep } from 'node:path';
import type { WorkflowStep, Language } from '../models/types.js';
import type { WorkflowMovement, Language } from '../models/types.js';
import type { PhaseName } from './types.js';
import { runAgent, type RunAgentOptions } from '../../agents/runner.js';
import { ReportInstructionBuilder } from './instruction/ReportInstructionBuilder.js';
@ -29,25 +29,25 @@ export interface PhaseRunnerContext {
interactive?: boolean;
/** Get agent session ID */
getSessionId: (agent: string) => string | undefined;
/** Build resume options for a step */
buildResumeOptions: (step: WorkflowStep, sessionId: string, overrides: Pick<RunAgentOptions, 'allowedTools' | 'maxTurns'>) => RunAgentOptions;
/** Build resume options for a movement */
buildResumeOptions: (step: WorkflowMovement, sessionId: string, overrides: Pick<RunAgentOptions, 'allowedTools' | 'maxTurns'>) => RunAgentOptions;
/** Update agent session after a phase run */
updateAgentSession: (agent: string, sessionId: string | undefined) => void;
/** Callback for phase lifecycle logging */
onPhaseStart?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void;
onPhaseStart?: (step: WorkflowMovement, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void;
/** Callback for phase completion logging */
onPhaseComplete?: (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void;
onPhaseComplete?: (step: WorkflowMovement, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void;
}
/**
* Check if a step needs Phase 3 (status judgment).
* Check if a movement needs Phase 3 (status judgment).
* Returns true when at least one rule requires tag-based detection.
*/
export function needsStatusJudgmentPhase(step: WorkflowStep): boolean {
export function needsStatusJudgmentPhase(step: WorkflowMovement): boolean {
return hasTagBasedRules(step);
}
function getReportFiles(report: WorkflowStep['report']): string[] {
function getReportFiles(report: WorkflowMovement['report']): string[] {
if (!report) return [];
if (typeof report === 'string') return [report];
if (isReportObjectConfig(report)) return [report.name];
@ -76,17 +76,17 @@ function writeReportFile(reportDir: string, fileName: string, content: string):
* Plain text responses are written directly to files (no JSON parsing).
*/
export async function runReportPhase(
step: WorkflowStep,
stepIteration: number,
step: WorkflowMovement,
movementIteration: number,
ctx: PhaseRunnerContext,
): Promise<void> {
const sessionKey = step.agent ?? step.name;
let currentSessionId = ctx.getSessionId(sessionKey);
if (!currentSessionId) {
throw new Error(`Report phase requires a session to resume, but no sessionId found for agent "${sessionKey}" in step "${step.name}"`);
throw new Error(`Report phase requires a session to resume, but no sessionId found for agent "${sessionKey}" in movement "${step.name}"`);
}
log.debug('Running report phase', { step: step.name, sessionId: currentSessionId });
log.debug('Running report phase', { movement: step.name, sessionId: currentSessionId });
const reportFiles = getReportFiles(step.report);
if (reportFiles.length === 0) {
@ -99,12 +99,12 @@ export async function runReportPhase(
throw new Error(`Invalid report file name: ${fileName}`);
}
log.debug('Generating report file', { step: step.name, fileName });
log.debug('Generating report file', { movement: step.name, fileName });
const reportInstruction = new ReportInstructionBuilder(step, {
cwd: ctx.cwd,
reportDir: ctx.reportDir,
stepIteration,
movementIteration: movementIteration,
language: ctx.language,
targetFile: fileName,
}).build();
@ -144,10 +144,10 @@ export async function runReportPhase(
}
ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status);
log.debug('Report file generated', { step: step.name, fileName });
log.debug('Report file generated', { movement: step.name, fileName });
}
log.debug('Report phase complete', { step: step.name, filesGenerated: reportFiles.length });
log.debug('Report phase complete', { movement: step.name, filesGenerated: reportFiles.length });
}
/**
@ -156,16 +156,16 @@ export async function runReportPhase(
* Returns the Phase 3 response content (containing the status tag).
*/
export async function runStatusJudgmentPhase(
step: WorkflowStep,
step: WorkflowMovement,
ctx: PhaseRunnerContext,
): Promise<string> {
const sessionKey = step.agent ?? step.name;
const sessionId = ctx.getSessionId(sessionKey);
if (!sessionId) {
throw new Error(`Status judgment phase requires a session to resume, but no sessionId found for agent "${sessionKey}" in step "${step.name}"`);
throw new Error(`Status judgment phase requires a session to resume, but no sessionId found for agent "${sessionKey}" in movement "${step.name}"`);
}
log.debug('Running status judgment phase', { step: step.name, sessionId });
log.debug('Running status judgment phase', { movement: step.name, sessionId });
const judgmentInstruction = new StatusJudgmentBuilder(step, {
language: ctx.language,
@ -199,6 +199,6 @@ export async function runStatusJudgmentPhase(
ctx.updateAgentSession(sessionKey, judgmentResponse.sessionId);
ctx.onPhaseComplete?.(step, 3, 'judge', judgmentResponse.content, judgmentResponse.status);
log.debug('Status judgment phase complete', { step: step.name, status: judgmentResponse.status });
log.debug('Status judgment phase complete', { movement: step.name, status: judgmentResponse.status });
return judgmentResponse.content;
}

View File

@ -6,7 +6,7 @@
*/
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
import type { WorkflowStep, AgentResponse, WorkflowState, Language } from '../models/types.js';
import type { WorkflowMovement, WorkflowStep, AgentResponse, WorkflowState, Language } from '../models/types.js';
export type ProviderType = 'claude' | 'codex' | 'mock';
@ -91,7 +91,7 @@ export type AskUserQuestionHandler = (
input: AskUserQuestionInput
) => Promise<Record<string, string>>;
export type RuleIndexDetector = (content: string, stepName: string) => number;
export type RuleIndexDetector = (content: string, movementName: string) => number;
export interface AiJudgeCondition {
index: number;
@ -108,23 +108,23 @@ export type PhaseName = 'execute' | 'report' | 'judge';
/** Events emitted by workflow engine */
export interface WorkflowEvents {
'step:start': (step: WorkflowStep, iteration: number, instruction: string) => void;
'step:complete': (step: WorkflowStep, response: AgentResponse, instruction: string) => void;
'step:report': (step: WorkflowStep, filePath: string, fileName: string) => void;
'step:blocked': (step: WorkflowStep, response: AgentResponse) => void;
'step:user_input': (step: WorkflowStep, userInput: string) => void;
'phase:start': (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void;
'phase:complete': (step: WorkflowStep, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void;
'movement:start': (step: WorkflowMovement, iteration: number, instruction: string) => void;
'movement:complete': (step: WorkflowMovement, response: AgentResponse, instruction: string) => void;
'movement:report': (step: WorkflowMovement, filePath: string, fileName: string) => void;
'movement:blocked': (step: WorkflowMovement, response: AgentResponse) => void;
'movement:user_input': (step: WorkflowMovement, userInput: string) => void;
'phase:start': (step: WorkflowMovement, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void;
'phase:complete': (step: WorkflowMovement, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void;
'workflow:complete': (state: WorkflowState) => void;
'workflow:abort': (state: WorkflowState, reason: string) => void;
'iteration:limit': (iteration: number, maxIterations: number) => void;
'step:loop_detected': (step: WorkflowStep, consecutiveCount: number) => void;
'movement:loop_detected': (step: WorkflowMovement, consecutiveCount: number) => void;
}
/** User input request for blocked state */
export interface UserInputRequest {
/** The step that is blocked */
step: WorkflowStep;
/** The movement that is blocked */
movement: WorkflowMovement;
/** The blocked response from the agent */
response: AgentResponse;
/** Prompt for the user (extracted from blocked message) */
@ -137,8 +137,8 @@ export interface IterationLimitRequest {
currentIteration: number;
/** Current max iterations */
maxIterations: number;
/** Current step name */
currentStep: string;
/** Current movement name */
currentMovement: string;
}
/** Callback for session updates (when agent session IDs change) */

View File

@ -8,9 +8,9 @@
import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import {
getGlobalWorkflowsDir,
getGlobalPiecesDir,
getGlobalAgentsDir,
getBuiltinWorkflowsDir,
getBuiltinPiecesDir,
getBuiltinAgentsDir,
getLanguage,
} from '../../infra/config/index.js';
@ -25,7 +25,7 @@ export async function ejectBuiltin(name?: string): Promise<void> {
header('Eject Builtin');
const lang = getLanguage();
const builtinWorkflowsDir = getBuiltinWorkflowsDir(lang);
const builtinWorkflowsDir = getBuiltinPiecesDir(lang);
if (!name) {
// List available builtins
@ -40,7 +40,7 @@ export async function ejectBuiltin(name?: string): Promise<void> {
return;
}
const userWorkflowsDir = getGlobalWorkflowsDir();
const userWorkflowsDir = getGlobalPiecesDir();
const userAgentsDir = getGlobalAgentsDir();
const builtinAgentsDir = getBuiltinAgentsDir(lang);

View File

@ -19,8 +19,8 @@ import { getProvider, type ProviderType } from '../../infra/providers/index.js';
import { selectOption } from '../../shared/prompt/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
import { getPrompt } from '../../shared/prompts/index.js';
import { getLabelObject } from '../../shared/i18n/index.js';
import { loadTemplate } from '../../shared/prompts/index.js';
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
const log = createLogger('interactive');
/** Shape of interactive UI text */
@ -40,25 +40,20 @@ function resolveLanguage(lang?: Language): 'en' | 'ja' {
}
function getInteractivePrompts(lang: 'en' | 'ja', workflowContext?: WorkflowContext) {
let systemPrompt = getPrompt('interactive.systemPrompt', lang);
let summaryPrompt = getPrompt('interactive.summaryPrompt', lang);
const hasWorkflow = !!workflowContext;
// Add workflow context to prompts if available
if (workflowContext) {
const workflowInfo = getPrompt('interactive.workflowInfo', lang, {
name: workflowContext.name,
description: workflowContext.description,
const systemPrompt = loadTemplate('score_interactive_system_prompt', lang, {
workflowInfo: hasWorkflow,
workflowName: workflowContext?.name ?? '',
workflowDescription: workflowContext?.description ?? '',
});
systemPrompt += workflowInfo;
summaryPrompt += workflowInfo;
}
return {
systemPrompt,
summaryPrompt,
conversationLabel: getPrompt('interactive.conversationLabel', lang),
noTranscript: getPrompt('interactive.noTranscript', lang),
lang,
workflowContext,
conversationLabel: getLabel('interactive.conversationLabel', lang),
noTranscript: getLabel('interactive.noTranscript', lang),
ui: getLabelObject<InteractiveUIText>('interactive.ui', lang),
};
}
@ -83,23 +78,38 @@ function buildTaskFromHistory(history: ConversationMessage[]): string {
.join('\n\n');
}
/**
* Build the summary prompt (used as both system prompt and user message).
* Renders the complete score_summary_system_prompt template with conversation data.
* Returns empty string if there is no conversation to summarize.
*/
function buildSummaryPrompt(
history: ConversationMessage[],
hasSession: boolean,
summaryPrompt: string,
lang: 'en' | 'ja',
noTranscriptNote: string,
conversationLabel: string,
workflowContext?: WorkflowContext,
): string {
let conversation = '';
if (history.length > 0) {
const historyText = buildTaskFromHistory(history);
return `${summaryPrompt}\n\n${conversationLabel}\n${historyText}`;
}
if (hasSession) {
return `${summaryPrompt}\n\n${conversationLabel}\n${noTranscriptNote}`;
}
conversation = `${conversationLabel}\n${historyText}`;
} else if (hasSession) {
conversation = `${conversationLabel}\n${noTranscriptNote}`;
} else {
return '';
}
const hasWorkflow = !!workflowContext;
return loadTemplate('score_summary_system_prompt', lang, {
workflowInfo: hasWorkflow,
workflowName: workflowContext?.name ?? '',
workflowDescription: workflowContext?.description ?? '',
conversation,
});
}
async function confirmTask(task: string, message: string, confirmLabel: string, yesLabel: string, noLabel: string): Promise<boolean> {
blankLine();
info(message);
@ -305,18 +315,19 @@ export async function interactiveMode(
let summaryPrompt = buildSummaryPrompt(
history,
!!sessionId,
prompts.summaryPrompt,
prompts.lang,
prompts.noTranscript,
prompts.conversationLabel,
prompts.workflowContext,
);
if (summaryPrompt && userNote) {
summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`;
}
if (!summaryPrompt) {
info(prompts.ui.noConversation);
continue;
}
const summaryResult = await callAIWithRetry(summaryPrompt, prompts.summaryPrompt);
if (userNote) {
summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`;
}
const summaryResult = await callAIWithRetry(summaryPrompt, summaryPrompt);
if (!summaryResult) {
info(prompts.ui.summarizeFailed);
continue;

View File

@ -0,0 +1,5 @@
/**
* Prompt feature exports
*/
export { previewPrompts } from './preview.js';

View File

@ -0,0 +1,87 @@
/**
* Prompt preview feature
*
* Loads a workflow and displays the assembled prompt for each movement and phase.
* Useful for debugging and understanding what prompts agents will receive.
*/
import { loadWorkflowByIdentifier, getCurrentWorkflow, loadGlobalConfig } from '../../infra/config/index.js';
import { InstructionBuilder } from '../../core/workflow/instruction/InstructionBuilder.js';
import { ReportInstructionBuilder } from '../../core/workflow/instruction/ReportInstructionBuilder.js';
import { StatusJudgmentBuilder } from '../../core/workflow/instruction/StatusJudgmentBuilder.js';
import { needsStatusJudgmentPhase } from '../../core/workflow/index.js';
import type { InstructionContext } from '../../core/workflow/instruction/instruction-context.js';
import type { Language } from '../../core/models/types.js';
import { header, info, error, blankLine } from '../../shared/ui/index.js';
/**
* Preview all prompts for a workflow.
*
* Loads the workflow definition, then for each movement builds and displays
* the Phase 1, Phase 2, and Phase 3 prompts with sample variable values.
*/
export async function previewPrompts(cwd: string, workflowIdentifier?: string): Promise<void> {
const identifier = workflowIdentifier ?? getCurrentWorkflow(cwd);
const config = loadWorkflowByIdentifier(identifier, cwd);
if (!config) {
error(`Workflow "${identifier}" not found.`);
return;
}
const globalConfig = loadGlobalConfig();
const language: Language = globalConfig.language ?? 'en';
header(`Prompt Preview: ${config.name}`);
info(`Movements: ${config.movements.length}`);
info(`Language: ${language}`);
blankLine();
for (const [i, movement] of config.movements.entries()) {
const separator = '='.repeat(60);
console.log(separator);
console.log(`Movement ${i + 1}: ${movement.name} (agent: ${movement.agentDisplayName})`);
console.log(separator);
// Phase 1: Main execution
const context: InstructionContext = {
task: '<task content>',
iteration: 1,
maxIterations: config.maxIterations,
movementIteration: 1,
cwd,
projectCwd: cwd,
userInputs: [],
workflowMovements: config.movements,
currentMovementIndex: i,
reportDir: movement.report ? '.takt/reports/preview' : undefined,
language,
};
const phase1Builder = new InstructionBuilder(movement, context);
console.log('\n--- Phase 1 (Main Execution) ---\n');
console.log(phase1Builder.build());
// Phase 2: Report output (only if movement has report config)
if (movement.report) {
const reportBuilder = new ReportInstructionBuilder(movement, {
cwd,
reportDir: '.takt/reports/preview',
movementIteration: 1,
language,
});
console.log('\n--- Phase 2 (Report Output) ---\n');
console.log(reportBuilder.build());
}
// Phase 3: Status judgment (only if movement has tag-based rules)
if (needsStatusJudgmentPhase(movement)) {
const judgmentBuilder = new StatusJudgmentBuilder(movement, { language });
console.log('\n--- Phase 3 (Status Judgment) ---\n');
console.log(judgmentBuilder.build());
}
blankLine();
}
}

View File

@ -41,7 +41,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<boolean>
log.debug('Running workflow', {
name: workflowConfig.name,
steps: workflowConfig.steps.map((s: { name: string }) => s.name),
movements: workflowConfig.movements.map((s: { name: string }) => s.name),
});
const globalConfig = loadGlobalConfig();

View File

@ -159,7 +159,7 @@ export async function executeWorkflow(
maxIterations: String(request.maxIterations),
})
);
info(getLabel('workflow.iterationLimit.currentStep', undefined, { currentStep: request.currentStep }));
info(getLabel('workflow.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement }));
const action = await selectOption(getLabel('workflow.iterationLimit.continueQuestion'), [
{
@ -248,8 +248,8 @@ export async function executeWorkflow(
appendNdjsonLine(ndjsonLogPath, record);
});
engine.on('step:start', (step, iteration, instruction) => {
log.debug('Step starting', { step: step.name, agent: step.agentDisplayName, iteration });
engine.on('movement:start', (step, iteration, instruction) => {
log.debug('Movement starting', { step: step.name, agent: step.agentDisplayName, iteration });
info(`[${iteration}/${workflowConfig.maxIterations}] ${step.name} (${step.agentDisplayName})`);
// Log prompt content for debugging
@ -273,8 +273,8 @@ export async function executeWorkflow(
});
engine.on('step:complete', (step, response, instruction) => {
log.debug('Step completed', {
engine.on('movement:complete', (step, response, instruction) => {
log.debug('Movement completed', {
step: step.name,
status: response.status,
matchedRuleIndex: response.matchedRuleIndex,
@ -329,7 +329,7 @@ export async function executeWorkflow(
updateLatestPointer(sessionLog, workflowSessionId, projectCwd);
});
engine.on('step:report', (_step, filePath, fileName) => {
engine.on('movement:report', (_step, filePath, fileName) => {
const content = readFileSync(filePath, 'utf-8');
console.log(`\n📄 Report: ${fileName}\n`);
console.log(content);

View File

@ -105,7 +105,7 @@ export {
COMPLETE_STEP,
ABORT_STEP,
ERROR_MESSAGES,
determineNextStepByRules,
determineNextMovementByRules,
extractBlockedPrompt,
LoopDetector,
createInitialState,
@ -117,8 +117,7 @@ export {
isReportObjectConfig,
ReportInstructionBuilder,
StatusJudgmentBuilder,
buildExecutionMetadata,
renderExecutionMetadata,
buildEditRule,
RuleEvaluator,
detectMatchedRule,
evaluateAggregateConditions,
@ -141,7 +140,7 @@ export type {
ReportInstructionContext,
StatusJudgmentContext,
InstructionContext,
ExecutionMetadata,
StatusRulesComponents,
BlockedHandlerResult,
} from './core/workflow/index.js';

View File

@ -8,7 +8,7 @@ import { executeClaudeCli } from './process.js';
import type { ClaudeSpawnOptions, ClaudeCallOptions } from './types.js';
import type { AgentResponse, Status } from '../../core/models/index.js';
import { createLogger } from '../../shared/utils/index.js';
import { getPrompt } from '../../shared/prompts/index.js';
import { loadTemplate } from '../../shared/prompts/index.js';
// Re-export for backward compatibility
export type { ClaudeCallOptions } from './types.js';
@ -21,8 +21,8 @@ const log = createLogger('client');
*
* Example: detectRuleIndex("... [PLAN:2] ...", "plan") 1
*/
export function detectRuleIndex(content: string, stepName: string): number {
const tag = stepName.toUpperCase();
export function detectRuleIndex(content: string, movementName: string): number {
const tag = movementName.toUpperCase();
const regex = new RegExp(`\\[${tag}:(\\d+)\\]`, 'gi');
const matches = [...content.matchAll(regex)];
const match = matches.at(-1);
@ -153,7 +153,7 @@ export class ClaudeClient {
prompt: string,
options: ClaudeCallOptions,
): Promise<AgentResponse> {
const systemPrompt = getPrompt('claude.agentDefault', undefined, { agentName: claudeAgentName });
const systemPrompt = loadTemplate('perform_builtin_agent_system_prompt', 'en', { agentName: claudeAgentName });
return this.callCustom(claudeAgentName, prompt, systemPrompt, options);
}
@ -219,7 +219,7 @@ export class ClaudeClient {
.map((c) => `| ${c.index + 1} | ${c.text} |`)
.join('\n');
return getPrompt('claude.judgePrompt', undefined, { agentOutput, conditionList });
return loadTemplate('perform_judge_message', 'en', { agentOutput, conditionList });
}
/**

View File

@ -11,9 +11,9 @@ import { join, basename } from 'node:path';
import type { CustomAgentConfig } from '../../../core/models/index.js';
import {
getGlobalAgentsDir,
getGlobalWorkflowsDir,
getGlobalPiecesDir,
getBuiltinAgentsDir,
getBuiltinWorkflowsDir,
getBuiltinPiecesDir,
isPathSafe,
} from '../paths.js';
import { getLanguage } from '../global/globalConfig.js';
@ -23,9 +23,9 @@ function getAllowedAgentBases(): string[] {
const lang = getLanguage();
return [
getGlobalAgentsDir(),
getGlobalWorkflowsDir(),
getGlobalPiecesDir(),
getBuiltinAgentsDir(lang),
getBuiltinWorkflowsDir(lang),
getBuiltinPiecesDir(lang),
];
}

View File

@ -9,11 +9,11 @@ import { readFileSync, existsSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { parse as parseYaml } from 'yaml';
import type { z } from 'zod';
import { WorkflowConfigRawSchema, WorkflowStepRawSchema } from '../../../core/models/index.js';
import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../../../core/models/index.js';
import { WorkflowConfigRawSchema, WorkflowMovementRawSchema } from '../../../core/models/index.js';
import type { WorkflowConfig, WorkflowMovement, WorkflowRule, ReportConfig, ReportObjectConfig } from '../../../core/models/index.js';
/** Parsed step type from Zod schema (replaces `any`) */
type RawStep = z.output<typeof WorkflowStepRawSchema>;
/** Parsed movement type from Zod schema (replaces `any`) */
type RawStep = z.output<typeof WorkflowMovementRawSchema>;
/**
* Resolve agent path from workflow specification.
@ -168,8 +168,8 @@ function normalizeRule(r: {
};
}
/** Normalize a raw step into internal WorkflowStep format. */
function normalizeStepFromRaw(step: RawStep, workflowDir: string): WorkflowStep {
/** Normalize a raw step into internal WorkflowMovement format. */
function normalizeStepFromRaw(step: RawStep, workflowDir: string): WorkflowMovement {
const rules: WorkflowRule[] | undefined = step.rules?.map(normalizeRule);
const agentSpec: string | undefined = step.agent || undefined;
@ -183,7 +183,7 @@ function normalizeStepFromRaw(step: RawStep, workflowDir: string): WorkflowStep
}
}
const result: WorkflowStep = {
const result: WorkflowMovement = {
name: step.name,
description: step.description,
agent: agentSpec,
@ -215,15 +215,20 @@ function normalizeStepFromRaw(step: RawStep, workflowDir: string): WorkflowStep
export function normalizeWorkflowConfig(raw: unknown, workflowDir: string): WorkflowConfig {
const parsed = WorkflowConfigRawSchema.parse(raw);
const steps: WorkflowStep[] = parsed.steps.map((step) =>
// Prefer `movements` over legacy `steps`
const rawMovements = parsed.movements ?? parsed.steps ?? [];
const movements: WorkflowMovement[] = rawMovements.map((step) =>
normalizeStepFromRaw(step, workflowDir),
);
// Prefer `initial_movement` over legacy `initial_step`
const initialMovement = parsed.initial_movement ?? parsed.initial_step ?? movements[0]?.name ?? '';
return {
name: parsed.name,
description: parsed.description,
steps,
initialStep: parsed.initial_step || steps[0]?.name || '',
movements,
initialMovement,
maxIterations: parsed.max_iterations,
answerAgent: parsed.answer_agent,
};

View File

@ -9,7 +9,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs';
import { join, resolve, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import type { WorkflowConfig } from '../../../core/models/index.js';
import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from '../paths.js';
import { getGlobalPiecesDir, getBuiltinPiecesDir, getProjectConfigDir } from '../paths.js';
import { getLanguage, getDisabledBuiltins, getBuiltinWorkflowsEnabled } from '../global/globalConfig.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { loadWorkflowFromFile } from './workflowParser.js';
@ -25,7 +25,7 @@ export interface WorkflowWithSource {
export function listBuiltinWorkflowNames(options?: { includeDisabled?: boolean }): string[] {
const lang = getLanguage();
const dir = getBuiltinWorkflowsDir(lang);
const dir = getBuiltinPiecesDir(lang);
const disabled = options?.includeDisabled ? undefined : getDisabledBuiltins();
const names = new Set<string>();
for (const entry of iterateWorkflowDir(dir, 'builtin', disabled)) {
@ -41,7 +41,7 @@ export function getBuiltinWorkflow(name: string): WorkflowConfig | null {
const disabled = getDisabledBuiltins();
if (disabled.includes(name)) return null;
const builtinDir = getBuiltinWorkflowsDir(lang);
const builtinDir = getBuiltinPiecesDir(lang);
const yamlPath = join(builtinDir, `${name}.yaml`);
if (existsSync(yamlPath)) {
return loadWorkflowFromFile(yamlPath);
@ -109,12 +109,20 @@ export function loadWorkflow(
return loadWorkflowFromFile(projectMatch);
}
const globalWorkflowsDir = getGlobalWorkflowsDir();
const globalMatch = resolveWorkflowFile(globalWorkflowsDir, name);
const globalPiecesDir = getGlobalPiecesDir();
const globalMatch = resolveWorkflowFile(globalPiecesDir, name);
if (globalMatch) {
return loadWorkflowFromFile(globalMatch);
}
// Fallback: legacy ~/.takt/workflows/ directory (deprecated)
const legacyGlobalDir = join(homedir(), '.takt', 'workflows');
const legacyMatch = resolveWorkflowFile(legacyGlobalDir, name);
if (legacyMatch) {
log.info(`Loading workflow from deprecated path ~/.takt/workflows/. Please move to ~/.takt/pieces/.`);
return loadWorkflowFromFile(legacyMatch);
}
return getBuiltinWorkflow(name);
}
@ -219,9 +227,15 @@ function getWorkflowDirs(cwd: string): { dir: string; source: WorkflowSource; di
const lang = getLanguage();
const dirs: { dir: string; source: WorkflowSource; disabled?: string[] }[] = [];
if (getBuiltinWorkflowsEnabled()) {
dirs.push({ dir: getBuiltinWorkflowsDir(lang), disabled, source: 'builtin' });
dirs.push({ dir: getBuiltinPiecesDir(lang), disabled, source: 'builtin' });
}
dirs.push({ dir: getGlobalWorkflowsDir(), source: 'user' });
// Legacy fallback: ~/.takt/workflows/ (deprecated, lowest user priority)
const legacyGlobalDir = join(homedir(), '.takt', 'workflows');
if (existsSync(legacyGlobalDir)) {
log.info(`Scanning deprecated path ~/.takt/workflows/. Please move to ~/.takt/pieces/.`);
dirs.push({ dir: legacyGlobalDir, source: 'user' });
}
dirs.push({ dir: getGlobalPiecesDir(), source: 'user' });
dirs.push({ dir: join(getProjectConfigDir(cwd), 'workflows'), source: 'project' });
return dirs;
}

View File

@ -21,11 +21,14 @@ export function getGlobalAgentsDir(): string {
return join(getGlobalConfigDir(), 'agents');
}
/** Get takt global workflows directory (~/.takt/workflows) */
export function getGlobalWorkflowsDir(): string {
return join(getGlobalConfigDir(), 'workflows');
/** Get takt global pieces directory (~/.takt/pieces) */
export function getGlobalPiecesDir(): string {
return join(getGlobalConfigDir(), 'pieces');
}
/** @deprecated Use getGlobalPiecesDir() instead */
export const getGlobalWorkflowsDir = getGlobalPiecesDir;
/** Get takt global logs directory */
export function getGlobalLogsDir(): string {
return join(getGlobalConfigDir(), 'logs');
@ -36,11 +39,14 @@ export function getGlobalConfigPath(): string {
return join(getGlobalConfigDir(), 'config.yaml');
}
/** Get builtin workflows directory (resources/global/{lang}/workflows) */
export function getBuiltinWorkflowsDir(lang: Language): string {
return join(getLanguageResourcesDir(lang), 'workflows');
/** Get builtin pieces directory (resources/global/{lang}/pieces) */
export function getBuiltinPiecesDir(lang: Language): string {
return join(getLanguageResourcesDir(lang), 'pieces');
}
/** @deprecated Use getBuiltinPiecesDir() instead */
export const getBuiltinWorkflowsDir = getBuiltinPiecesDir;
/** Get builtin agents directory (resources/global/{lang}/agents) */
export function getBuiltinAgentsDir(lang: Language): string {
return join(getLanguageResourcesDir(lang), 'agents');

View File

@ -8,7 +8,7 @@ import * as wanakana from 'wanakana';
import { loadGlobalConfig } from '../config/global/globalConfig.js';
import { getProvider, type ProviderType } from '../providers/index.js';
import { createLogger } from '../../shared/utils/index.js';
import { getPrompt } from '../../shared/prompts/index.js';
import { loadTemplate } from '../../shared/prompts/index.js';
import type { SummarizeOptions } from './types.js';
export type { SummarizeOptions };
@ -70,7 +70,7 @@ export class TaskSummarizer {
const response = await provider.call('summarizer', taskName, {
cwd: options.cwd,
model,
systemPrompt: getPrompt('summarize.slugGenerator'),
systemPrompt: loadTemplate('score_slug_system_prompt', 'en'),
allowedTools: [],
});

View File

@ -7,6 +7,8 @@
# ===== Interactive Mode UI =====
interactive:
conversationLabel: "Conversation:"
noTranscript: "(No local transcript. Summarize the current session context.)"
ui:
intro: "Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)"
resume: "Resuming previous session"
@ -21,7 +23,7 @@ interactive:
workflow:
iterationLimit:
maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxIterations})"
currentStep: "現在のステップ: {currentStep}"
currentMovement: "現在のムーブメント: {currentMovement}"
continueQuestion: "続行しますか?"
continueLabel: "続行する(追加イテレーション数を入力)"
continueDescription: "入力した回数だけ上限を増やします"

View File

@ -7,6 +7,8 @@
# ===== Interactive Mode UI =====
interactive:
conversationLabel: "会話:"
noTranscript: "(ローカル履歴なし。現在のセッション文脈を要約してください。)"
ui:
intro: "対話モード - タスク内容を入力してください。コマンド: /go実行, /cancel終了"
resume: "前回のセッションを再開します"
@ -21,7 +23,7 @@ interactive:
workflow:
iterationLimit:
maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxIterations})"
currentStep: "現在のステップ: {currentStep}"
currentMovement: "現在のムーブメント: {currentMovement}"
continueQuestion: "続行しますか?"
continueLabel: "続行する(追加イテレーション数を入力)"
continueDescription: "入力した回数だけ上限を増やします"

Some files were not shown because too many files have changed in this diff Show More