Merge pull request #182 from nrslib/release/v0.10.0

Release v0.10.0
This commit is contained in:
nrs 2026-02-09 19:18:01 +09:00 committed by GitHub
commit 4c6861457c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
102 changed files with 5299 additions and 3593 deletions

View File

@ -4,6 +4,46 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.10.0] - 2026-02-09
### Added
- **`structural-reform` builtin piece**: Full project review and structural reform — iterative codebase restructuring with staged file splits, powered by `loop_monitors`
- **`unit-test` builtin piece**: Unit test focused piece — test analysis → test implementation → review → fix, with `loop_monitors` for cycle control
- **`test-planner` persona**: Specialized persona for analyzing codebase and planning comprehensive test strategies
- **Interactive mode variants**: Four selectable modes after piece selection — `assistant` (default: AI-guided requirement refinement), `persona` (conversation with first movement's persona), `quiet` (generate instructions without questions), `passthrough` (user input used as-is)
- **`persona_providers` config**: Per-persona provider overrides (e.g., `{ coder: 'codex' }`) — route specific personas to different providers without creating hybrid pieces
- **`task_poll_interval_ms` config**: Configurable polling interval for `takt run` to detect new tasks during execution (default: 500ms, range: 1005000ms)
- **`interactive_mode` piece field**: Piece-level default interactive mode override (e.g., set `passthrough` for pieces that don't benefit from AI planning)
- **Task-level output prefixing**: Colored `[taskName]` prefix on all output lines during parallel `takt run` execution, preventing mid-line interleaving between concurrent tasks
- **Review policy facet**: Shared review policy (`builtins/{lang}/policies/review.md`) for consistent review criteria across pieces
### Changed
- **BREAKING:** Removed all Hybrid Codex pieces (`*-hybrid-codex`) — replaced by `persona_providers` config which achieves the same result without duplicating piece files
- Removed `tools/generate-hybrid-codex.mjs` (no longer needed with `persona_providers`)
- Improved parallel execution output: movement-level prefix now includes task context and iteration info in concurrent runs
- Codex client now detects stream hangs (10-minute idle timeout) and distinguishes timeout vs external abort in error messages
- Parallel task execution (`takt run`) now polls for newly added tasks during execution instead of only checking between task completions
- Parallel task execution no longer enforces per-task time limits (previously had a timeout)
- Issue references now routed through interactive mode (as initial input) instead of skipping interactive mode entirely
- Builtin `config.yaml` updated to document all GlobalConfig fields
- Extracted `conversationLoop.ts` for shared conversation logic across interactive mode variants
- Line editor improvements: additional key bindings and edge case fixes
### Fixed
- Codex processes hanging indefinitely when stream becomes idle — now aborted after 10 minutes of inactivity, releasing worker pool slots
### Internal
- New test coverage: engine-persona-providers, interactive-mode (532 lines), task-prefix-writer, workerPool expansion, pieceResolver expansion, lineEditor expansion, parallel-logger expansion, globalConfig-defaults expansion, pieceExecution-debug-prompts expansion, it-piece-loader expansion, runAllTasks-concurrency expansion, engine-parallel
- Extracted `TaskPrefixWriter` for task-level parallel output management
- Extracted `modeSelection.ts`, `passthroughMode.ts`, `personaMode.ts`, `quietMode.ts` from interactive module
- `InteractiveMode` type model added (`src/core/models/interactive-mode.ts`)
- `PieceEngine` validates `taskPrefix`/`taskColorIndex` pair consistency at construction
- Implementation notes document added (`docs/implements/retry-and-session.ja.md`)
## [0.9.0] - 2026-02-08 ## [0.9.0] - 2026-02-08
### Added ### Added

View File

@ -92,13 +92,25 @@ takt
takt hello takt hello
``` ```
**Note:** Issue references (`#6`) and `--task` / `--issue` options skip interactive mode and execute the task directly. All other inputs (including text with spaces) enter interactive mode for requirement refinement. **Note:** `--task` option skips interactive mode and executes the task directly. Issue references (`#6`, `--issue`) are used as initial input in interactive mode.
**Flow:** **Flow:**
1. Select piece 1. Select piece
2. Refine task content through conversation with AI 2. Select interactive mode (assistant / persona / quiet / passthrough)
3. Finalize task instructions with `/go` (you can also add additional instructions like `/go additional instructions`), or use `/play <task>` to execute a task immediately 3. Refine task content through conversation with AI
4. Execute (create worktree, run piece, create PR) 4. Finalize task instructions with `/go` (you can also add additional instructions like `/go additional instructions`), or use `/play <task>` to execute a task immediately
5. Execute (create worktree, run piece, create PR)
#### Interactive Mode Variants
| Mode | Description |
|------|-------------|
| `assistant` | Default. AI asks clarifying questions before generating task instructions. |
| `persona` | Conversation with the first movement's persona (uses its system prompt and tools). |
| `quiet` | Generates task instructions without asking questions (best-effort). |
| `passthrough` | Passes user input directly as task text without AI processing. |
Pieces can set a default mode via the `interactive_mode` field in YAML.
#### Execution Example #### Execution Example
@ -451,8 +463,10 @@ TAKT includes multiple builtin pieces:
| `passthrough` | Thinnest wrapper. Pass task directly to coder as-is. No review. | | `passthrough` | Thinnest wrapper. Pass task directly to coder as-is. No review. |
| `compound-eye` | Multi-model review: sends the same instruction to Claude and Codex simultaneously, then synthesizes both responses. | | `compound-eye` | Multi-model review: sends the same instruction to Claude and Codex simultaneously, then synthesizes both responses. |
| `review-only` | Read-only code review piece that makes no changes. | | `review-only` | Read-only code review piece that makes no changes. |
| `structural-reform` | Full project review and structural reform: iterative codebase restructuring with staged file splits. |
| `unit-test` | Unit test focused piece: test analysis → test implementation → review → fix. |
**Hybrid Codex variants** (`*-hybrid-codex`): Each major piece has a Codex variant where the coder agent runs on Codex while reviewers use Claude. Available for: default, minimal, expert, expert-cqrs, passthrough, review-fix-minimal, coding. **Per-persona provider overrides:** Use `persona_providers` in config to route specific personas to different providers (e.g., coder on Codex, reviewers on Claude) without duplicating pieces.
Use `takt switch` to switch pieces. Use `takt switch` to switch pieces.
@ -475,6 +489,7 @@ Use `takt switch` to switch pieces.
| **research-planner** | Research task planning and scope definition | | **research-planner** | Research task planning and scope definition |
| **research-digger** | Deep investigation and information gathering | | **research-digger** | Deep investigation and information gathering |
| **research-supervisor** | Research quality validation and completeness assessment | | **research-supervisor** | Research quality validation and completeness assessment |
| **test-planner** | Test strategy analysis and comprehensive test planning |
| **pr-commenter** | Posts review findings as GitHub PR comments | | **pr-commenter** | Posts review findings as GitHub PR comments |
## Custom Personas ## Custom Personas
@ -543,8 +558,15 @@ branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai'
prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate) prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate)
notification_sound: true # Enable/disable notification sounds notification_sound: true # Enable/disable notification sounds
concurrency: 1 # Parallel task count for takt run (1-10, default: 1 = sequential) concurrency: 1 # Parallel task count for takt run (1-10, default: 1 = sequential)
task_poll_interval_ms: 500 # Polling interval for new tasks during takt run (100-5000, default: 500)
interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, default: 3) interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, default: 3)
# Per-persona provider overrides (optional)
# Route specific personas to different providers without duplicating pieces
# persona_providers:
# coder: codex # Run coder on Codex
# ai-antipattern-reviewer: claude # Keep reviewers on Claude
# API Key configuration (optional) # API Key configuration (optional)
# Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY # Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY
anthropic_api_key: sk-ant-... # For Claude (Anthropic) anthropic_api_key: sk-ant-... # For Claude (Anthropic)

View File

@ -1,34 +1,85 @@
# TAKT Global Configuration # TAKT Global Configuration
# This file contains default settings for takt. # Location: ~/.takt/config.yaml
# Language setting (en or ja) # ── Basic ──
# Language (en | ja)
language: en language: en
# Default piece to use when no piece is specified # Default piece when no piece is specified
default_piece: default default_piece: default
# Log level: debug, info, warn, error # Log level (debug | info | warn | error)
log_level: info log_level: info
# Provider runtime: claude or codex # ── Provider & Model ──
# Provider runtime (claude | codex)
provider: claude provider: claude
# Builtin pieces (resources/global/{lang}/pieces)
# enable_builtin_pieces: true
# Default model (optional) # Default model (optional)
# Claude: opus, sonnet, haiku, opusplan, default, or full model name # Claude: opus, sonnet, haiku
# Codex: gpt-5.2-codex, gpt-5.1-codex, etc. # Codex: gpt-5.2-codex, gpt-5.1-codex, etc.
# model: sonnet # model: sonnet
# Anthropic API key (optional, overridden by TAKT_ANTHROPIC_API_KEY env var) # Per-persona provider override (optional)
# anthropic_api_key: "" # Override provider for specific personas. Others use the global provider.
# persona_providers:
# coder: codex
# OpenAI API key (optional, overridden by TAKT_OPENAI_API_KEY env var) # ── API Keys ──
# Optional. Environment variables take priority:
# TAKT_ANTHROPIC_API_KEY, TAKT_OPENAI_API_KEY
# anthropic_api_key: ""
# openai_api_key: "" # openai_api_key: ""
# Pipeline execution settings (optional) # ── Execution ──
# Customize branch naming, commit messages, and PR body for pipeline mode (--task).
# Worktree (shared clone) directory (default: ../{clone-name} relative to project)
# worktree_dir: ~/takt-worktrees
# Auto-create PR after worktree execution (default: prompt in interactive mode)
# auto_pr: false
# Prevent macOS idle sleep during execution using caffeinate (default: false)
# prevent_sleep: false
# ── Parallel Execution (takt run) ──
# Number of tasks to run concurrently (1 = sequential, max: 10)
# concurrency: 1
# Polling interval in ms for picking up new tasks (100-5000, default: 500)
# task_poll_interval_ms: 500
# ── Interactive Mode ──
# Number of movement previews shown in interactive mode (0 to disable, max: 10)
# interactive_preview_movements: 3
# Branch name generation strategy (romaji: fast default | ai: slow)
# branch_name_strategy: romaji
# ── Output ──
# Notification sounds (default: true)
# notification_sound: true
# Minimal output for CI - suppress AI output (default: false)
# minimal_output: false
# ── Builtin Pieces ──
# Enable builtin pieces (default: true)
# enable_builtin_pieces: true
# Exclude specific builtins from loading
# disabled_builtins:
# - magi
# ── Pipeline Mode (--pipeline) ──
# pipeline: # pipeline:
# default_branch_prefix: "takt/" # default_branch_prefix: "takt/"
# commit_message_template: "feat: {title} (#{issue})" # commit_message_template: "feat: {title} (#{issue})"
@ -37,10 +88,14 @@ provider: claude
# {issue_body} # {issue_body}
# Closes #{issue} # Closes #{issue}
# Notification sounds (true: enabled, false: disabled, default: true) # ── Preferences ──
# notification_sound: true
# Custom paths for preference files
# bookmarks_file: ~/.takt/preferences/bookmarks.yaml
# piece_categories_file: ~/.takt/preferences/piece-categories.yaml
# ── Debug ──
# Debug settings (optional)
# debug: # debug:
# enabled: false # enabled: false
# log_file: ~/.takt/logs/debug.log # log_file: ~/.takt/logs/debug.log

View File

@ -0,0 +1,52 @@
Implement unit tests according to the test plan.
Refer only to files within the Report Directory shown in the Piece Context. Do not search or reference other report directories.
**Important: Do NOT modify production code. Only test files may be edited.**
**Actions:**
1. Review the test plan report
2. Implement the planned test cases
3. Run tests and verify all pass
4. Confirm existing tests are not broken
**Test implementation constraints:**
- Follow the project's existing test patterns (naming conventions, directory structure, helpers)
- Write tests in Given-When-Then structure
- One concept per test. Do not mix multiple concerns in a single test
**Scope output contract (create at the start of implementation):**
```markdown
# Change Scope Declaration
## Task
{One-line task summary}
## Planned changes
| Type | File |
|------|------|
| Create | `src/__tests__/example.test.ts` |
## Estimated size
Small / Medium / Large
## Impact area
- {Affected modules or features}
```
**Decisions output contract (at implementation completion, only if decisions were made):**
```markdown
# Decision Log
## 1. {Decision}
- **Context**: {Why the decision was needed}
- **Options considered**: {List of options}
- **Rationale**: {Reason for the choice}
```
**Required output (include headings)**
## Work results
- {Summary of actions taken}
## Changes made
- {Summary of changes}
## Test results
- {Command executed and results}

View File

@ -0,0 +1,11 @@
Analyze the target code and identify missing unit tests.
**Note:** If a Previous Response exists, this is a replan due to rejection.
Revise the test plan taking that feedback into account.
**Actions:**
1. Read the target module source code and understand its behavior, branches, and state transitions
2. Read existing tests and identify what is already covered
3. Identify missing test cases (happy path, error cases, boundary values, edge cases)
4. Determine test strategy (mock approach, existing test helper usage, fixture design)
5. Provide concrete guidelines for the test implementer

View File

@ -9,8 +9,14 @@ Do not review AI-specific issues (already covered by the ai_review movement).
- Dead code - Dead code
- Call chain verification - Call chain verification
**Previous finding tracking (required):**
- First, extract open findings from "Previous Response"
- Assign `finding_id` to each finding and classify current status as `new / persists / resolved`
- If status is `persists`, provide concrete unresolved evidence (file/line)
## Judgment Procedure ## Judgment Procedure
1. Review the change diff and detect issues based on the architecture and design criteria above 1. First, extract previous open findings and preliminarily classify as `new / persists / resolved`
2. For each detected issue, classify as blocking/non-blocking based on Policy's scope determination table and judgment rules 2. Review the change diff and detect issues based on the architecture and design criteria above
3. If there is even one blocking issue, judge as REJECT 3. For each detected issue, classify as blocking/non-blocking based on Policy's scope determination table and judgment rules
4. If there is even one blocking issue (`new` or `persists`), judge as REJECT

View File

@ -7,8 +7,14 @@ Review the changes from a quality assurance perspective.
- Logging and monitoring - Logging and monitoring
- Maintainability - Maintainability
**Previous finding tracking (required):**
- First, extract open findings from "Previous Response"
- Assign `finding_id` to each finding and classify current status as `new / persists / resolved`
- If status is `persists`, provide concrete unresolved evidence (file/line)
## Judgment Procedure ## Judgment Procedure
1. Review the change diff and detect issues based on the quality assurance criteria above 1. First, extract previous open findings and preliminarily classify as `new / persists / resolved`
2. For each detected issue, classify as blocking/non-blocking based on Policy's scope determination table and judgment rules 2. Review the change diff and detect issues based on the quality assurance criteria above
3. If there is even one blocking issue, judge as REJECT 3. For each detected issue, classify as blocking/non-blocking based on Policy's scope determination table and judgment rules
4. If there is even one blocking issue (`new` or `persists`), judge as REJECT

View File

@ -0,0 +1,14 @@
Review the changes from a test quality perspective.
**Review criteria:**
- Whether all test plan items are covered
- Test quality (Given-When-Then structure, independence, reproducibility)
- Test naming conventions
- Completeness (unnecessary tests, missing cases)
- Appropriateness of mocks and fixtures
## Judgment Procedure
1. Cross-reference the test plan report ({report:00-test-plan.md}) with the implemented tests
2. For each detected issue, classify as blocking/non-blocking based on Policy's scope determination table and judgment rules
3. If there is even one blocking issue, judge as REJECT

View File

@ -14,10 +14,15 @@
- [x] Dead code - [x] Dead code
- [x] Call chain verification - [x] Call chain verification
## Previous Open Findings
| finding_id | Previous Status | Current Status (new/persists/resolved) | Evidence |
|------------|-----------------|-----------------------------------------|----------|
| ARCH-EXAMPLE-src-file-L42 | open | persists | `src/file.ts:42` |
## Issues (if REJECT) ## Issues (if REJECT)
| # | Scope | Location | Issue | Fix Suggestion | | # | finding_id | Status (new/persists) | Scope | Location | Issue | Fix Suggestion |
|---|-------|----------|-------|----------------| |---|------------|-----------------------|-------|----------|-------|----------------|
| 1 | In-scope | `src/file.ts:42` | Issue description | Fix approach | | 1 | ARCH-EXAMPLE-src-file-L42 | new | In-scope | `src/file.ts:42` | Issue description | Fix approach |
Scope: "In-scope" (fixable in this change) / "Out-of-scope" (existing issue, non-blocking) Scope: "In-scope" (fixable in this change) / "Out-of-scope" (existing issue, non-blocking)

View File

@ -15,8 +15,13 @@
| Documentation | ✅ | - | | Documentation | ✅ | - |
| Maintainability | ✅ | - | | Maintainability | ✅ | - |
## Previous Open Findings
| finding_id | Previous Status | Current Status (new/persists/resolved) | Evidence |
|------------|-----------------|-----------------------------------------|----------|
| QA-EXAMPLE-src-file-L42 | open | persists | `src/file.ts:42` |
## Issues (if REJECT) ## Issues (if REJECT)
| # | Category | Issue | Fix Suggestion | | # | finding_id | Status (new/persists) | Category | Issue | Fix Suggestion |
|---|----------|-------|----------------| |---|------------|-----------------------|----------|-------|----------------|
| 1 | Testing | Issue description | Fix approach | | 1 | QA-EXAMPLE-src-file-L42 | new | Testing | Issue description | Fix approach |
``` ```

View File

@ -0,0 +1,24 @@
```markdown
# Test Plan
## Target Modules
{List of modules to analyze}
## Existing Test Analysis
| Module | Existing Tests | Coverage Status |
|--------|---------------|-----------------|
| `src/xxx.ts` | `xxx.test.ts` | {Coverage status} |
## Missing Test Cases
| # | Target | Test Case | Priority | Reason |
|---|--------|-----------|----------|--------|
| 1 | `src/xxx.ts` | {Test case summary} | High/Medium/Low | {Reason} |
## Test Strategy
- {Mock approach}
- {Fixture design}
- {Test helper usage}
## Implementation Guidelines
- {Concrete instructions for the test implementer}
```

View File

@ -0,0 +1,25 @@
# Test Planner
You are a **test analysis and planning specialist**. You understand the behavior of target code, analyze existing test coverage, and systematically identify missing test cases.
## Role Boundaries
**Do:**
- Analyze target code behavior, branches, and state transitions
- Analyze existing test coverage
- Identify missing test cases (happy path, error cases, boundary values, edge cases)
- Determine test strategy (mock approach, fixture design, test helper usage)
- Provide concrete guidelines for test implementers
**Don't:**
- Plan production code changes (Planner's job)
- Implement test code (Coder's job)
- Review code (Reviewer's job)
## Behavioral Principles
- Read the code before planning. Don't list test cases based on guesses
- Always check existing tests. Don't duplicate already-covered scenarios
- Prioritize tests: business logic and state transitions > edge cases > simple CRUD
- Provide instructions at a granularity that prevents test implementers from hesitating
- Follow the project's existing test patterns. Don't propose novel conventions

View File

@ -10,6 +10,7 @@ piece_categories:
pieces: pieces:
- review-fix-minimal - review-fix-minimal
- review-only - review-only
- unit-test
🎨 Frontend: {} 🎨 Frontend: {}
⚙️ Backend: {} ⚙️ Backend: {}
🔧 Expert: 🔧 Expert:
@ -17,20 +18,9 @@ piece_categories:
pieces: pieces:
- expert - expert
- expert-cqrs - expert-cqrs
🔀 Hybrid (Codex Coding): Refactoring:
🚀 Quick Start:
pieces: pieces:
- coding-hybrid-codex - structural-reform
- default-hybrid-codex
- minimal-hybrid-codex
- passthrough-hybrid-codex
🔧 Expert:
pieces:
- expert-cqrs-hybrid-codex
- expert-hybrid-codex
🔍 Review & Fix:
pieces:
- review-fix-minimal-hybrid-codex
Others: Others:
pieces: pieces:
- research - research

View File

@ -1,158 +0,0 @@
# Auto-generated from coding.yaml by tools/generate-hybrid-codex.mjs
# Do not edit manually. Edit the source piece and re-run the generator.
name: coding-hybrid-codex
description: Lightweight development piece with planning and parallel reviews (plan -> implement -> parallel review -> complete)
max_iterations: 20
knowledge:
architecture: ../knowledge/architecture.md
personas:
planner: ../personas/planner.md
coder: ../personas/coder.md
ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md
architecture-reviewer: ../personas/architecture-reviewer.md
instructions:
plan: ../instructions/plan.md
implement: ../instructions/implement.md
ai-review: ../instructions/ai-review.md
review-arch: ../instructions/review-arch.md
fix: ../instructions/fix.md
report_formats:
plan: ../output-contracts/plan.md
ai-review: ../output-contracts/ai-review.md
architecture-review: ../output-contracts/architecture-review.md
initial_movement: plan
movements:
- name: plan
edit: false
persona: planner
knowledge: architecture
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
rules:
- condition: Requirements are clear and implementable
next: implement
- condition: User is asking a question (not an implementation task)
next: COMPLETE
- condition: Requirements are unclear, insufficient information
next: ABORT
instruction: plan
output_contracts:
report:
- name: 00-plan.md
format: plan
- name: implement
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
session: refresh
knowledge: architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: Implementation complete
next: reviewers
- condition: Implementation not started (report only)
next: reviewers
- condition: Cannot determine, insufficient information
next: reviewers
- condition: User input required
next: implement
requires_user_input: true
interactive_only: true
instruction: implement
output_contracts:
report:
- Scope: 02-coder-scope.md
- Decisions: 03-coder-decisions.md
- name: reviewers
parallel:
- name: ai_review
edit: false
persona: ai-antipattern-reviewer
policy:
- review
- ai-antipattern
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: No AI-specific issues
- condition: AI-specific issues found
instruction: ai-review
output_contracts:
report:
- name: 04-ai-review.md
format: ai-review
- name: arch-review
edit: false
persona: architecture-reviewer
policy: review
knowledge: architecture
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-arch
output_contracts:
report:
- name: 05-architect-review.md
format: architecture-review
rules:
- condition: all("No AI-specific issues", "approved")
next: COMPLETE
- condition: any("AI-specific issues found", "needs_fix")
next: fix
- name: fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
knowledge: architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: Fix complete
next: reviewers
- condition: Cannot determine, insufficient information
next: ABORT
instruction: fix
policies:
coding: ../policies/coding.md
review: ../policies/review.md
testing: ../policies/testing.md
ai-antipattern: ../policies/ai-antipattern.md

View File

@ -1,341 +0,0 @@
# Auto-generated from expert-cqrs.yaml by tools/generate-hybrid-codex.mjs
# Do not edit manually. Edit the source piece and re-run the generator.
name: expert-cqrs-hybrid-codex
description: CQRS+ES, Frontend, Security, QA Expert Review
max_iterations: 30
knowledge:
frontend: ../knowledge/frontend.md
backend: ../knowledge/backend.md
cqrs-es: ../knowledge/cqrs-es.md
security: ../knowledge/security.md
architecture: ../knowledge/architecture.md
personas:
planner: ../personas/planner.md
coder: ../personas/coder.md
ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md
architecture-reviewer: ../personas/architecture-reviewer.md
cqrs-es-reviewer: ../personas/cqrs-es-reviewer.md
frontend-reviewer: ../personas/frontend-reviewer.md
security-reviewer: ../personas/security-reviewer.md
qa-reviewer: ../personas/qa-reviewer.md
expert-supervisor: ../personas/expert-supervisor.md
instructions:
plan: ../instructions/plan.md
implement: ../instructions/implement.md
ai-review: ../instructions/ai-review.md
ai-fix: ../instructions/ai-fix.md
arbitrate: ../instructions/arbitrate.md
review-cqrs-es: ../instructions/review-cqrs-es.md
review-frontend: ../instructions/review-frontend.md
review-security: ../instructions/review-security.md
review-qa: ../instructions/review-qa.md
fix: ../instructions/fix.md
supervise: ../instructions/supervise.md
fix-supervisor: ../instructions/fix-supervisor.md
report_formats:
plan: ../output-contracts/plan.md
ai-review: ../output-contracts/ai-review.md
cqrs-es-review: ../output-contracts/cqrs-es-review.md
frontend-review: ../output-contracts/frontend-review.md
security-review: ../output-contracts/security-review.md
qa-review: ../output-contracts/qa-review.md
validation: ../output-contracts/validation.md
summary: ../output-contracts/summary.md
initial_movement: plan
movements:
- name: plan
edit: false
persona: planner
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction: plan
rules:
- condition: Task analysis and planning is complete
next: implement
- condition: Requirements are unclear and planning cannot proceed
next: ABORT
output_contracts:
report:
- name: 00-plan.md
format: plan
- name: implement
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
session: refresh
knowledge:
- frontend
- backend
- cqrs-es
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
instruction: implement
rules:
- condition: Implementation is complete
next: ai_review
- condition: No implementation (report only)
next: ai_review
- condition: Cannot proceed with implementation
next: ai_review
- condition: User input required
next: implement
requires_user_input: true
interactive_only: true
output_contracts:
report:
- Scope: 01-coder-scope.md
- Decisions: 02-coder-decisions.md
- name: ai_review
edit: false
persona: ai-antipattern-reviewer
policy:
- review
- ai-antipattern
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction: ai-review
rules:
- condition: No AI-specific issues found
next: reviewers
- condition: AI-specific issues detected
next: ai_fix
output_contracts:
report:
- name: 03-ai-review.md
format: ai-review
- name: ai_fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
session: refresh
knowledge:
- frontend
- backend
- cqrs-es
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
instruction: ai-fix
rules:
- condition: AI Reviewer's issues have been fixed
next: ai_review
- condition: No fix needed (verified target files/spec)
next: ai_no_fix
- condition: Unable to proceed with fixes
next: ai_no_fix
- name: ai_no_fix
edit: false
persona: architecture-reviewer
policy: review
allowed_tools:
- Read
- Glob
- Grep
rules:
- condition: ai_review's findings are valid (fix required)
next: ai_fix
- condition: ai_fix's judgment is valid (no fix needed)
next: reviewers
instruction: arbitrate
- name: reviewers
parallel:
- name: cqrs-es-review
edit: false
persona: cqrs-es-reviewer
policy: review
knowledge:
- cqrs-es
- backend
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-cqrs-es
output_contracts:
report:
- name: 04-cqrs-es-review.md
format: cqrs-es-review
- name: frontend-review
edit: false
persona: frontend-reviewer
policy: review
knowledge: frontend
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-frontend
output_contracts:
report:
- name: 05-frontend-review.md
format: frontend-review
- name: security-review
edit: false
persona: security-reviewer
policy: review
knowledge: security
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-security
output_contracts:
report:
- name: 06-security-review.md
format: security-review
- name: qa-review
edit: false
persona: qa-reviewer
policy:
- review
- qa
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-qa
output_contracts:
report:
- name: 07-qa-review.md
format: qa-review
rules:
- condition: all("approved")
next: supervise
- condition: any("needs_fix")
next: fix
- name: fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
knowledge:
- frontend
- backend
- cqrs-es
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: Fix complete
next: reviewers
- condition: Cannot proceed, insufficient info
next: plan
instruction: fix
- name: supervise
edit: false
persona: expert-supervisor
policy: review
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction: supervise
rules:
- condition: All validations pass and ready to merge
next: COMPLETE
- condition: Issues detected during final review
next: fix_supervisor
output_contracts:
report:
- Validation: 08-supervisor-validation.md
- Summary: summary.md
- name: fix_supervisor
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
knowledge:
- frontend
- backend
- cqrs-es
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
instruction: fix-supervisor
rules:
- condition: Supervisor's issues have been fixed
next: supervise
- condition: Unable to proceed with fixes
next: plan
policies:
coding: ../policies/coding.md
review: ../policies/review.md
testing: ../policies/testing.md
ai-antipattern: ../policies/ai-antipattern.md
qa: ../policies/qa.md

View File

@ -1,335 +0,0 @@
# Auto-generated from expert.yaml by tools/generate-hybrid-codex.mjs
# Do not edit manually. Edit the source piece and re-run the generator.
name: expert-hybrid-codex
description: Architecture, Frontend, Security, QA Expert Review
max_iterations: 30
knowledge:
frontend: ../knowledge/frontend.md
backend: ../knowledge/backend.md
security: ../knowledge/security.md
architecture: ../knowledge/architecture.md
personas:
planner: ../personas/planner.md
coder: ../personas/coder.md
ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md
architecture-reviewer: ../personas/architecture-reviewer.md
frontend-reviewer: ../personas/frontend-reviewer.md
security-reviewer: ../personas/security-reviewer.md
qa-reviewer: ../personas/qa-reviewer.md
expert-supervisor: ../personas/expert-supervisor.md
instructions:
plan: ../instructions/plan.md
implement: ../instructions/implement.md
ai-review: ../instructions/ai-review.md
ai-fix: ../instructions/ai-fix.md
arbitrate: ../instructions/arbitrate.md
review-arch: ../instructions/review-arch.md
review-frontend: ../instructions/review-frontend.md
review-security: ../instructions/review-security.md
review-qa: ../instructions/review-qa.md
fix: ../instructions/fix.md
supervise: ../instructions/supervise.md
fix-supervisor: ../instructions/fix-supervisor.md
report_formats:
plan: ../output-contracts/plan.md
ai-review: ../output-contracts/ai-review.md
architecture-review: ../output-contracts/architecture-review.md
frontend-review: ../output-contracts/frontend-review.md
security-review: ../output-contracts/security-review.md
qa-review: ../output-contracts/qa-review.md
validation: ../output-contracts/validation.md
summary: ../output-contracts/summary.md
initial_movement: plan
movements:
- name: plan
edit: false
persona: planner
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction: plan
rules:
- condition: Task analysis and planning is complete
next: implement
- condition: Requirements are unclear and planning cannot proceed
next: ABORT
output_contracts:
report:
- name: 00-plan.md
format: plan
- name: implement
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
session: refresh
knowledge:
- frontend
- backend
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
instruction: implement
rules:
- condition: Implementation is complete
next: ai_review
- condition: No implementation (report only)
next: ai_review
- condition: Cannot proceed with implementation
next: ai_review
- condition: User input required
next: implement
requires_user_input: true
interactive_only: true
output_contracts:
report:
- Scope: 01-coder-scope.md
- Decisions: 02-coder-decisions.md
- name: ai_review
edit: false
persona: ai-antipattern-reviewer
policy:
- review
- ai-antipattern
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction: ai-review
rules:
- condition: No AI-specific issues found
next: reviewers
- condition: AI-specific issues detected
next: ai_fix
output_contracts:
report:
- name: 03-ai-review.md
format: ai-review
- name: ai_fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
session: refresh
knowledge:
- frontend
- backend
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
instruction: ai-fix
rules:
- condition: AI Reviewer's issues have been fixed
next: ai_review
- condition: No fix needed (verified target files/spec)
next: ai_no_fix
- condition: Unable to proceed with fixes
next: ai_no_fix
- name: ai_no_fix
edit: false
persona: architecture-reviewer
policy: review
allowed_tools:
- Read
- Glob
- Grep
rules:
- condition: ai_review's findings are valid (fix required)
next: ai_fix
- condition: ai_fix's judgment is valid (no fix needed)
next: reviewers
instruction: arbitrate
- name: reviewers
parallel:
- name: arch-review
edit: false
persona: architecture-reviewer
policy: review
knowledge:
- architecture
- backend
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-arch
output_contracts:
report:
- name: 04-architect-review.md
format: architecture-review
- name: frontend-review
edit: false
persona: frontend-reviewer
policy: review
knowledge: frontend
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-frontend
output_contracts:
report:
- name: 05-frontend-review.md
format: frontend-review
- name: security-review
edit: false
persona: security-reviewer
policy: review
knowledge: security
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-security
output_contracts:
report:
- name: 06-security-review.md
format: security-review
- name: qa-review
edit: false
persona: qa-reviewer
policy:
- review
- qa
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-qa
output_contracts:
report:
- name: 07-qa-review.md
format: qa-review
rules:
- condition: all("approved")
next: supervise
- condition: any("needs_fix")
next: fix
- name: fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
knowledge:
- frontend
- backend
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: Fix complete
next: reviewers
- condition: Cannot proceed, insufficient info
next: plan
instruction: fix
- name: supervise
edit: false
persona: expert-supervisor
policy: review
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction: supervise
rules:
- condition: All validations pass and ready to merge
next: COMPLETE
- condition: Issues detected during final review
next: fix_supervisor
output_contracts:
report:
- Validation: 08-supervisor-validation.md
- Summary: summary.md
- name: fix_supervisor
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
knowledge:
- frontend
- backend
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
instruction: fix-supervisor
rules:
- condition: Supervisor's issues have been fixed
next: supervise
- condition: Unable to proceed with fixes
next: plan
policies:
coding: ../policies/coding.md
review: ../policies/review.md
testing: ../policies/testing.md
ai-antipattern: ../policies/ai-antipattern.md
qa: ../policies/qa.md

View File

@ -1,202 +0,0 @@
# Auto-generated from minimal.yaml by tools/generate-hybrid-codex.mjs
# Do not edit manually. Edit the source piece and re-run the generator.
name: minimal-hybrid-codex
description: Minimal development piece (implement -> parallel review -> fix if needed -> complete)
max_iterations: 20
personas:
coder: ../personas/coder.md
ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md
supervisor: ../personas/supervisor.md
instructions:
implement: ../instructions/implement.md
review-ai: ../instructions/review-ai.md
ai-fix: ../instructions/ai-fix.md
supervise: ../instructions/supervise.md
fix-supervisor: ../instructions/fix-supervisor.md
report_formats:
ai-review: ../output-contracts/ai-review.md
initial_movement: implement
movements:
- name: implement
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
instruction: implement
rules:
- condition: Implementation complete
next: reviewers
- condition: Cannot proceed, insufficient info
next: ABORT
- condition: User input required because there are items to confirm with the user
next: implement
requires_user_input: true
interactive_only: true
output_contracts:
report:
- Scope: 01-coder-scope.md
- Decisions: 02-coder-decisions.md
- name: reviewers
parallel:
- name: ai_review
edit: false
persona: ai-antipattern-reviewer
policy:
- review
- ai-antipattern
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction: review-ai
rules:
- condition: No AI-specific issues
- condition: AI-specific issues found
output_contracts:
report:
- name: 03-ai-review.md
format: ai-review
- name: supervise
edit: false
persona: supervisor
policy: review
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction: supervise
rules:
- condition: All checks passed
- condition: Requirements unmet, tests failing
output_contracts:
report:
- Validation: 05-supervisor-validation.md
- Summary: summary.md
rules:
- condition: all("No AI-specific issues", "All checks passed")
next: COMPLETE
- condition: all("AI-specific issues found", "Requirements unmet, tests failing")
next: fix_both
- condition: any("AI-specific issues found")
next: ai_fix
- condition: any("Requirements unmet, tests failing")
next: supervise_fix
- name: fix_both
parallel:
- name: ai_fix_parallel
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: AI Reviewer's issues fixed
- condition: No fix needed (verified target files/spec)
- condition: Cannot proceed, insufficient info
instruction: ai-fix
- name: supervise_fix_parallel
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: Supervisor's issues fixed
- condition: Cannot proceed, insufficient info
instruction: fix-supervisor
rules:
- condition: all("AI Reviewer's issues fixed", "Supervisor's issues fixed")
next: reviewers
- condition: any("No fix needed (verified target files/spec)", "Cannot proceed, insufficient info")
next: implement
- name: ai_fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: AI Reviewer's issues fixed
next: reviewers
- condition: No fix needed (verified target files/spec)
next: implement
- condition: Cannot proceed, insufficient info
next: implement
instruction: ai-fix
- name: supervise_fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: Supervisor's issues fixed
next: reviewers
- condition: Cannot proceed, insufficient info
next: implement
instruction: fix-supervisor
policies:
coding: ../policies/coding.md
review: ../policies/review.md
testing: ../policies/testing.md
ai-antipattern: ../policies/ai-antipattern.md

View File

@ -1,44 +0,0 @@
# Auto-generated from passthrough.yaml by tools/generate-hybrid-codex.mjs
# Do not edit manually. Edit the source piece and re-run the generator.
name: passthrough-hybrid-codex
description: Single-agent thin wrapper. Pass task directly to coder as-is.
max_iterations: 10
personas:
coder: ../personas/coder.md
initial_movement: execute
movements:
- name: execute
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: Task complete
next: COMPLETE
- condition: Cannot proceed
next: ABORT
- condition: User input required
next: execute
requires_user_input: true
interactive_only: true
instruction_template: |
Do the task.
output_contracts:
report:
- Summary: summary.md
policies:
coding: ../policies/coding.md
testing: ../policies/testing.md

View File

@ -1,202 +0,0 @@
# Auto-generated from review-fix-minimal.yaml by tools/generate-hybrid-codex.mjs
# Do not edit manually. Edit the source piece and re-run the generator.
name: review-fix-minimal-hybrid-codex
description: Review and fix piece for existing code (starts with review, no implementation)
max_iterations: 20
personas:
coder: ../personas/coder.md
ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md
supervisor: ../personas/supervisor.md
instructions:
implement: ../instructions/implement.md
review-ai: ../instructions/review-ai.md
ai-fix: ../instructions/ai-fix.md
supervise: ../instructions/supervise.md
fix-supervisor: ../instructions/fix-supervisor.md
report_formats:
ai-review: ../output-contracts/ai-review.md
initial_movement: reviewers
movements:
- name: implement
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
instruction: implement
rules:
- condition: Implementation complete
next: reviewers
- condition: Cannot proceed, insufficient info
next: ABORT
- condition: User input required because there are items to confirm with the user
next: implement
requires_user_input: true
interactive_only: true
output_contracts:
report:
- Scope: 01-coder-scope.md
- Decisions: 02-coder-decisions.md
- name: reviewers
parallel:
- name: ai_review
edit: false
persona: ai-antipattern-reviewer
policy:
- review
- ai-antipattern
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction: review-ai
rules:
- condition: No AI-specific issues
- condition: AI-specific issues found
output_contracts:
report:
- name: 03-ai-review.md
format: ai-review
- name: supervise
edit: false
persona: supervisor
policy: review
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction: supervise
rules:
- condition: All checks passed
- condition: Requirements unmet, tests failing
output_contracts:
report:
- Validation: 05-supervisor-validation.md
- Summary: summary.md
rules:
- condition: all("No AI-specific issues", "All checks passed")
next: COMPLETE
- condition: all("AI-specific issues found", "Requirements unmet, tests failing")
next: fix_both
- condition: any("AI-specific issues found")
next: ai_fix
- condition: any("Requirements unmet, tests failing")
next: supervise_fix
- name: fix_both
parallel:
- name: ai_fix_parallel
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: AI Reviewer's issues fixed
- condition: No fix needed (verified target files/spec)
- condition: Cannot proceed, insufficient info
instruction: ai-fix
- name: supervise_fix_parallel
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: Supervisor's issues fixed
- condition: Cannot proceed, insufficient info
instruction: fix-supervisor
rules:
- condition: all("AI Reviewer's issues fixed", "Supervisor's issues fixed")
next: reviewers
- condition: any("No fix needed (verified target files/spec)", "Cannot proceed, insufficient info")
next: implement
- name: ai_fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: AI Reviewer's issues fixed
next: reviewers
- condition: No fix needed (verified target files/spec)
next: implement
- condition: Cannot proceed, insufficient info
next: implement
instruction: ai-fix
- name: supervise_fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: Supervisor's issues fixed
next: reviewers
- condition: Cannot proceed, insufficient info
next: implement
instruction: fix-supervisor
policies:
coding: ../policies/coding.md
review: ../policies/review.md
testing: ../policies/testing.md
ai-antipattern: ../policies/ai-antipattern.md

View File

@ -0,0 +1,455 @@
name: structural-reform
description: Full project review and structural reform - iterative codebase restructuring with staged file splits
max_iterations: 50
policies:
coding: ../policies/coding.md
review: ../policies/review.md
testing: ../policies/testing.md
qa: ../policies/qa.md
knowledge:
backend: ../knowledge/backend.md
architecture: ../knowledge/architecture.md
personas:
planner: ../personas/planner.md
coder: ../personas/coder.md
architecture-reviewer: ../personas/architecture-reviewer.md
qa-reviewer: ../personas/qa-reviewer.md
supervisor: ../personas/supervisor.md
instructions:
implement: ../instructions/implement.md
review-arch: ../instructions/review-arch.md
review-qa: ../instructions/review-qa.md
fix: ../instructions/fix.md
initial_movement: review
loop_monitors:
- cycle:
- implement
- fix
threshold: 3
judge:
persona: supervisor
instruction_template: |
The implement -> reviewers -> fix loop has repeated {cycle_count} times for the current reform target.
Review the reports from each cycle and determine whether this loop
is making progress or repeating the same issues.
**Reports to reference:**
- Architect review: {report:04-architect-review.md}
- QA review: {report:05-qa-review.md}
**Judgment criteria:**
- Are review findings being addressed in each fix cycle?
- Are the same issues recurring without resolution?
- Is the implementation converging toward approval?
rules:
- condition: Healthy (making progress toward approval)
next: implement
- condition: Unproductive (same issues recurring, no convergence)
next: next_target
movements:
- name: review
edit: false
persona: architecture-reviewer
policy: review
knowledge:
- architecture
- backend
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction_template: |
## Piece Status
- Iteration: {iteration}/{max_iterations} (piece-wide)
- Movement Iteration: {movement_iteration} (times this movement has run)
- Movement: review (full project review)
## User Request
{task}
## Instructions
Conduct a comprehensive structural review of the entire project codebase.
**Focus areas:**
1. **God Classes/Functions**: Files exceeding 300 lines, classes with multiple responsibilities
2. **Coupling**: Circular dependencies, tight coupling between modules
3. **Cohesion**: Low-cohesion modules mixing unrelated concerns
4. **Testability**: Untestable code due to tight coupling or side effects
5. **Layer violations**: Wrong dependency directions, domain logic in adapters
6. **DRY violations**: Duplicated logic across 3+ locations
**For each issue found, report:**
- File path and line count
- Problem category (God Class, Low Cohesion, etc.)
- Severity (Critical / High / Medium)
- Specific responsibilities that should be separated
- Dependencies that would be affected by splitting
**Output format:**
```markdown
# Full Project Structural Review
## Summary
- Total files reviewed: N
- Issues found: N (Critical: N, High: N, Medium: N)
## Critical Issues
### 1. {File path} ({line count} lines)
- **Problem**: {category}
- **Severity**: Critical
- **Responsibilities found**:
1. {responsibility 1}
2. {responsibility 2}
- **Proposed split**:
- `{new-file-1}.ts`: {responsibility}
- `{new-file-2}.ts`: {responsibility}
- **Affected dependents**: {files that import this module}
## High Priority Issues
...
## Medium Priority Issues
...
## Dependency Graph Concerns
- {circular dependencies, layering violations}
## Recommended Reform Order
1. {file} - {reason for priority}
2. {file} - {reason for priority}
```
rules:
- condition: Full review is complete with findings
next: plan_reform
- condition: No structural issues found
next: COMPLETE
output_contracts:
report:
- name: 00-full-review.md
- name: plan_reform
edit: false
persona: planner
knowledge: architecture
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction_template: |
## Piece Status
- Iteration: {iteration}/{max_iterations} (piece-wide)
- Movement Iteration: {movement_iteration} (times this movement has run)
- Movement: plan_reform (reform plan creation)
## User Request
{task}
## Full Review Results
{previous_response}
## Additional User Inputs
{user_inputs}
## Instructions
Based on the full review results, create a concrete reform execution plan.
**Planning principles:**
- One file split per iteration (keep changes manageable)
- Order by dependency: split leaf nodes first, then work inward
- Each split must leave tests and build passing
- No backward compatibility concerns (per user instruction)
**For each reform target, specify:**
1. Target file and current line count
2. Proposed new files with responsibilities
3. Expected changes to imports in dependent files
4. Test strategy (new tests needed, existing tests to update)
5. Risk assessment (what could break)
**Output format:**
```markdown
# Structural Reform Plan
## Reform Targets (ordered by execution priority)
### Target 1: {file path}
- **Current state**: {line count} lines, {N} responsibilities
- **Proposed split**:
| New file | Responsibility | Estimated lines |
|----------|---------------|-----------------|
| `{path}` | {responsibility} | ~{N} |
- **Dependent files**: {list of files that import this}
- **Test plan**: {what tests to add/update}
- **Risk**: {Low/Medium/High} - {description}
### Target 2: {file path}
...
## Execution Order Rationale
{Why this order minimizes risk and dependency conflicts}
## Success Criteria
- All tests pass after each split
- Build succeeds after each split
- No file exceeds 300 lines
- Each file has single responsibility
```
rules:
- condition: Reform plan is complete and ready to execute
next: implement
- condition: No actionable reforms identified
next: COMPLETE
- condition: Requirements unclear, need user input
next: ABORT
appendix: |
Clarifications needed:
- {Question 1}
- {Question 2}
output_contracts:
report:
- name: 01-reform-plan.md
format: plan
- name: implement
edit: true
persona: coder
policy:
- coding
- testing
session: refresh
knowledge:
- backend
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
instruction: implement
rules:
- condition: Implementation complete
next: reviewers
- condition: Cannot proceed, insufficient info
next: reviewers
- condition: User input required
next: implement
requires_user_input: true
interactive_only: true
output_contracts:
report:
- Scope: 02-coder-scope.md
- Decisions: 03-coder-decisions.md
- name: reviewers
parallel:
- name: arch-review
edit: false
persona: architecture-reviewer
policy: review
knowledge:
- architecture
- backend
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-arch
output_contracts:
report:
- name: 04-architect-review.md
format: architecture-review
- name: qa-review
edit: false
persona: qa-reviewer
policy:
- review
- qa
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-qa
output_contracts:
report:
- name: 05-qa-review.md
format: qa-review
rules:
- condition: all("approved")
next: verify
- condition: any("needs_fix")
next: fix
- name: fix
edit: true
persona: coder
policy:
- coding
- testing
knowledge:
- backend
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: Fix complete
next: reviewers
- condition: Cannot proceed, insufficient info
next: plan_reform
instruction: fix
- name: verify
edit: false
persona: supervisor
policy: review
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction_template: |
## Piece Status
- Iteration: {iteration}/{max_iterations} (piece-wide)
- Movement Iteration: {movement_iteration} (times this movement has run)
- Movement: verify (build and test verification)
## Instructions
Verify that the current reform step has been completed successfully.
**Verification checklist:**
1. **Build**: Run the build command and confirm it passes
2. **Tests**: Run the test suite and confirm all tests pass
3. **File sizes**: Confirm no new file exceeds 300 lines
4. **Single responsibility**: Confirm each new file has a clear, single purpose
5. **Import consistency**: Confirm all imports are updated correctly
**Report format:**
```markdown
# Verification Results
## Result: PASS / FAIL
| Check | Status | Details |
|-------|--------|---------|
| Build | PASS/FAIL | {output summary} |
| Tests | PASS/FAIL | {N passed, N failed} |
| File sizes | PASS/FAIL | {any file > 300 lines} |
| Single responsibility | PASS/FAIL | {assessment} |
| Import consistency | PASS/FAIL | {any broken imports} |
## Issues (if FAIL)
1. {issue description}
```
rules:
- condition: All verifications passed
next: next_target
- condition: Verification failed
next: fix
output_contracts:
report:
- name: 06-verification.md
format: validation
- name: next_target
edit: false
persona: planner
knowledge: architecture
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction_template: |
## Piece Status
- Iteration: {iteration}/{max_iterations} (piece-wide)
- Movement Iteration: {movement_iteration} (times this movement has run)
- Movement: next_target (progress check and next target selection)
## Original Reform Plan
{report:01-reform-plan.md}
## Latest Verification
{previous_response}
## Instructions
Assess the progress of the structural reform and determine the next action.
**Steps:**
1. Review the reform plan and identify which targets have been completed
2. Check the current codebase state against the plan
3. Determine if there are remaining reform targets
**Output format:**
```markdown
# Reform Progress
## Completed Targets
| # | Target | Status |
|---|--------|--------|
| 1 | {file} | Completed |
| 2 | {file} | Completed |
## Remaining Targets
| # | Target | Priority |
|---|--------|----------|
| 3 | {file} | Next |
| 4 | {file} | Pending |
## Next Action
- **Target**: {next file to reform}
- **Plan**: {brief description of the split}
## Overall Progress
{N}/{total} targets completed. Estimated remaining iterations: {N}
```
rules:
- condition: More reform targets remain
next: implement
- condition: All reform targets completed
next: COMPLETE
output_contracts:
report:
- name: 07-progress.md
report_formats:
plan: ../output-contracts/plan.md
architecture-review: ../output-contracts/architecture-review.md
qa-review: ../output-contracts/qa-review.md
validation: ../output-contracts/validation.md
summary: ../output-contracts/summary.md

View File

@ -1,37 +1,32 @@
# Auto-generated from default.yaml by tools/generate-hybrid-codex.mjs name: unit-test
# Do not edit manually. Edit the source piece and re-run the generator. description: Unit test focused piece (test analysis → test implementation → review → fix)
max_iterations: 20
name: default-hybrid-codex policies:
description: Standard development piece with planning and specialized reviews coding: ../policies/coding.md
max_iterations: 30 review: ../policies/review.md
testing: ../policies/testing.md
ai-antipattern: ../policies/ai-antipattern.md
qa: ../policies/qa.md
knowledge: knowledge:
backend: ../knowledge/backend.md
architecture: ../knowledge/architecture.md architecture: ../knowledge/architecture.md
backend: ../knowledge/backend.md
personas: personas:
planner: ../personas/planner.md test-planner: ../personas/test-planner.md
coder: ../personas/coder.md coder: ../personas/coder.md
ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md
architecture-reviewer: ../personas/architecture-reviewer.md architecture-reviewer: ../personas/architecture-reviewer.md
qa-reviewer: ../personas/qa-reviewer.md qa-reviewer: ../personas/qa-reviewer.md
supervisor: ../personas/supervisor.md supervisor: ../personas/supervisor.md
instructions: instructions:
plan: ../instructions/plan.md plan-test: ../instructions/plan-test.md
implement: ../instructions/implement.md implement-test: ../instructions/implement-test.md
ai-review: ../instructions/ai-review.md ai-review: ../instructions/ai-review.md
ai-fix: ../instructions/ai-fix.md ai-fix: ../instructions/ai-fix.md
arbitrate: ../instructions/arbitrate.md arbitrate: ../instructions/arbitrate.md
review-arch: ../instructions/review-arch.md review-test: ../instructions/review-test.md
review-qa: ../instructions/review-qa.md
fix: ../instructions/fix.md fix: ../instructions/fix.md
supervise: ../instructions/supervise.md supervise: ../instructions/supervise.md
report_formats: initial_movement: plan_test
plan: ../output-contracts/plan.md
ai-review: ../output-contracts/ai-review.md
architecture-review: ../output-contracts/architecture-review.md
qa-review: ../output-contracts/qa-review.md
validation: ../output-contracts/validation.md
summary: ../output-contracts/summary.md
initial_movement: plan
loop_monitors: loop_monitors:
- cycle: - cycle:
- ai_review - ai_review
@ -56,12 +51,15 @@ loop_monitors:
- condition: Healthy (making progress) - condition: Healthy (making progress)
next: ai_review next: ai_review
- condition: Unproductive (no improvement) - condition: Unproductive (no improvement)
next: reviewers next: review_test
movements: movements:
- name: plan - name: plan_test
edit: false edit: false
persona: planner persona: test-planner
knowledge: architecture policy: testing
knowledge:
- architecture
- backend
allowed_tools: allowed_tools:
- Read - Read
- Glob - Glob
@ -70,9 +68,9 @@ movements:
- WebSearch - WebSearch
- WebFetch - WebFetch
rules: rules:
- condition: Requirements are clear and implementable - condition: Test plan complete
next: implement next: implement_test
- condition: User is asking a question (not an implementation task) - condition: User is asking a question (not a test task)
next: COMPLETE next: COMPLETE
- condition: Requirements unclear, insufficient info - condition: Requirements unclear, insufficient info
next: ABORT next: ABORT
@ -80,15 +78,15 @@ movements:
Clarifications needed: Clarifications needed:
- {Question 1} - {Question 1}
- {Question 2} - {Question 2}
instruction: plan instruction: plan-test
output_contracts: output_contracts:
report: report:
- name: 00-plan.md - name: 00-test-plan.md
format: plan format: test-plan
- name: implement
- name: implement_test
edit: true edit: true
persona: coder persona: coder
provider: codex
policy: policy:
- coding - coding
- testing - testing
@ -107,21 +105,22 @@ movements:
- WebFetch - WebFetch
permission_mode: edit permission_mode: edit
rules: rules:
- condition: Implementation complete - condition: Test implementation complete
next: ai_review next: ai_review
- condition: No implementation (report only) - condition: No implementation (report only)
next: ai_review next: ai_review
- condition: Cannot proceed, insufficient info - condition: Cannot proceed, insufficient info
next: ai_review next: ai_review
- condition: User input required - condition: User input required
next: implement next: implement_test
requires_user_input: true requires_user_input: true
interactive_only: true interactive_only: true
instruction: implement instruction: implement-test
output_contracts: output_contracts:
report: report:
- Scope: 02-coder-scope.md - Scope: 02-coder-scope.md
- Decisions: 03-coder-decisions.md - Decisions: 03-coder-decisions.md
- name: ai_review - name: ai_review
edit: false edit: false
persona: ai-antipattern-reviewer persona: ai-antipattern-reviewer
@ -136,7 +135,7 @@ movements:
- WebFetch - WebFetch
rules: rules:
- condition: No AI-specific issues - condition: No AI-specific issues
next: reviewers next: review_test
- condition: AI-specific issues found - condition: AI-specific issues found
next: ai_fix next: ai_fix
instruction: ai-review instruction: ai-review
@ -144,10 +143,10 @@ movements:
report: report:
- name: 04-ai-review.md - name: 04-ai-review.md
format: ai-review format: ai-review
- name: ai_fix - name: ai_fix
edit: true edit: true
persona: coder persona: coder
provider: codex
policy: policy:
- coding - coding
- testing - testing
@ -173,6 +172,7 @@ movements:
- condition: Cannot proceed, insufficient info - condition: Cannot proceed, insufficient info
next: ai_no_fix next: ai_no_fix
instruction: ai-fix instruction: ai-fix
- name: ai_no_fix - name: ai_no_fix
edit: false edit: false
persona: architecture-reviewer persona: architecture-reviewer
@ -185,63 +185,39 @@ movements:
- condition: ai_review's findings are valid (fix required) - condition: ai_review's findings are valid (fix required)
next: ai_fix next: ai_fix
- condition: ai_fix's judgment is valid (no fix needed) - condition: ai_fix's judgment is valid (no fix needed)
next: reviewers next: review_test
instruction: arbitrate instruction: arbitrate
- name: reviewers
parallel: - name: review_test
- name: arch-review edit: false
edit: false persona: qa-reviewer
persona: architecture-reviewer policy:
policy: review - review
knowledge: - qa
- architecture allowed_tools:
- backend - Read
allowed_tools: - Glob
- Read - Grep
- Glob - WebSearch
- Grep - WebFetch
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-arch
output_contracts:
report:
- name: 05-architect-review.md
format: architecture-review
- name: qa-review
edit: false
persona: qa-reviewer
policy:
- review
- qa
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-qa
output_contracts:
report:
- name: 06-qa-review.md
format: qa-review
rules: rules:
- condition: all("approved") - condition: approved
next: supervise next: supervise
- condition: any("needs_fix") - condition: needs_fix
next: fix next: fix
instruction: review-test
output_contracts:
report:
- name: 05-qa-review.md
format: qa-review
- name: fix - name: fix
edit: true edit: true
persona: coder persona: coder
provider: codex
policy: policy:
- coding - coding
- testing - testing
session: refresh
knowledge: knowledge:
- backend - backend
- architecture - architecture
@ -257,10 +233,11 @@ movements:
permission_mode: edit permission_mode: edit
rules: rules:
- condition: Fix complete - condition: Fix complete
next: reviewers next: review_test
- condition: Cannot proceed, insufficient info - condition: Cannot proceed, insufficient info
next: plan next: plan_test
instruction: fix instruction: fix
- name: supervise - name: supervise
edit: false edit: false
persona: supervisor persona: supervisor
@ -276,15 +253,15 @@ movements:
- condition: All checks passed - condition: All checks passed
next: COMPLETE next: COMPLETE
- condition: Requirements unmet, tests failing, build errors - condition: Requirements unmet, tests failing, build errors
next: plan next: plan_test
instruction: supervise instruction: supervise
output_contracts: output_contracts:
report: report:
- Validation: 07-supervisor-validation.md - Validation: 06-supervisor-validation.md
- Summary: summary.md - Summary: summary.md
policies: report_formats:
coding: ../policies/coding.md test-plan: ../output-contracts/test-plan.md
review: ../policies/review.md ai-review: ../output-contracts/ai-review.md
testing: ../policies/testing.md qa-review: ../output-contracts/qa-review.md
ai-antipattern: ../policies/ai-antipattern.md validation: ../output-contracts/validation.md
qa: ../policies/qa.md summary: ../output-contracts/summary.md

View File

@ -86,6 +86,18 @@ Every issue raised must include the following.
Extract into a shared function." Extract into a shared function."
``` ```
## Finding ID Tracking (`finding_id`)
To prevent circular rejections, track findings by ID.
- Every issue raised in a REJECT must include a `finding_id`
- If the same issue is raised again, reuse the same `finding_id`
- For repeated issues, set status to `persists` and include concrete evidence (file/line) that it remains unresolved
- New issues must use status `new`
- Resolved issues must be listed with status `resolved`
- Issues without `finding_id` are invalid (cannot be used as rejection grounds)
- REJECT is allowed only when there is at least one `new` or `persists` issue
## Boy Scout Rule ## Boy Scout Rule
Leave it better than you found it. Leave it better than you found it.

View File

@ -1,34 +1,85 @@
# TAKT グローバル設定 # TAKT グローバル設定
# takt のデフォルト設定ファイルです。 # 配置場所: ~/.takt/config.yaml
# 言語設定 (en または ja) # ── 基本設定 ──
# 言語 (en | ja)
language: ja language: ja
# デフォルトのピース - 指定がない場合に使用します # デフォルトピース(指定なし時に使用)
default_piece: default default_piece: default
# ログレベル: debug, info, warn, error # ログレベル (debug | info | warn | error)
log_level: info log_level: info
# プロバイダー: claude または codex # ── プロバイダー & モデル ──
# プロバイダー (claude | codex)
provider: claude provider: claude
# ビルトインピースの読み込み (resources/global/{lang}/pieces) # デフォルトモデル(オプション)
# enable_builtin_pieces: true # Claude: opus, sonnet, haiku
# デフォルトモデル (オプション)
# Claude: opus, sonnet, haiku, opusplan, default, またはフルモデル名
# Codex: gpt-5.2-codex, gpt-5.1-codex など # Codex: gpt-5.2-codex, gpt-5.1-codex など
# model: sonnet # model: sonnet
# Anthropic APIキー (オプション、環境変数 TAKT_ANTHROPIC_API_KEY で上書き可能) # ペルソナ単位のプロバイダー上書き(オプション)
# anthropic_api_key: "" # 特定ペルソナだけプロバイダーを変更。未指定のペルソナはグローバル設定を使用。
# persona_providers:
# coder: codex
# OpenAI APIキー (オプション、環境変数 TAKT_OPENAI_API_KEY で上書き可能) # ── APIキー ──
# オプション。環境変数が優先:
# TAKT_ANTHROPIC_API_KEY, TAKT_OPENAI_API_KEY
# anthropic_api_key: ""
# openai_api_key: "" # openai_api_key: ""
# パイプライン実行設定 (オプション) # ── 実行設定 ──
# パイプラインモード (--task) のブランチ名、コミットメッセージ、PRの本文をカスタマイズできます。
# ワークツリーshared cloneディレクトリデフォルト: プロジェクトの ../{clone-name}
# worktree_dir: ~/takt-worktrees
# ワークツリー実行後に自動PR作成デフォルト: 対話モードで確認)
# auto_pr: false
# macOS のアイドルスリープを防止(デフォルト: false
# prevent_sleep: false
# ── 並列実行 (takt run) ──
# タスクの同時実行数1 = 逐次実行、最大: 10
# concurrency: 1
# 新規タスクのポーリング間隔 ms100-5000、デフォルト: 500
# task_poll_interval_ms: 500
# ── 対話モード ──
# ムーブメントプレビューの表示数0 で無効、最大: 10
# interactive_preview_movements: 3
# ブランチ名の生成方式romaji: 高速デフォルト | ai: 低速)
# branch_name_strategy: romaji
# ── 出力 ──
# 通知音(デフォルト: true
# notification_sound: true
# CI 向け最小出力 - AI 出力を抑制(デフォルト: false
# minimal_output: false
# ── ビルトインピース ──
# ビルトインピースの有効化(デフォルト: true
# enable_builtin_pieces: true
# 特定のビルトインを除外
# disabled_builtins:
# - magi
# ── パイプラインモード (--pipeline) ──
# pipeline: # pipeline:
# default_branch_prefix: "takt/" # default_branch_prefix: "takt/"
# commit_message_template: "feat: {title} (#{issue})" # commit_message_template: "feat: {title} (#{issue})"
@ -37,10 +88,14 @@ provider: claude
# {issue_body} # {issue_body}
# Closes #{issue} # Closes #{issue}
# 通知音 (true: 有効 / false: 無効、デフォルト: true) # ── プリファレンス ──
# notification_sound: true
# プリファレンスファイルのカスタムパス
# bookmarks_file: ~/.takt/preferences/bookmarks.yaml
# piece_categories_file: ~/.takt/preferences/piece-categories.yaml
# ── デバッグ ──
# デバッグ設定 (オプション)
# debug: # debug:
# enabled: false # enabled: false
# log_file: ~/.takt/logs/debug.log # log_file: ~/.takt/logs/debug.log

View File

@ -0,0 +1,52 @@
テスト計画に従って単体テストを実装してください。
Piece Contextに示されたReport Directory内のファイルのみ参照してください。他のレポートディレクトリは検索/参照しないでください。
**重要: プロダクションコードは変更しないでください。テストファイルのみ編集可能です。**
**やること:**
1. テスト計画のレポートを確認する
2. 計画されたテストケースを実装する
3. テストを実行して全パスを確認する
4. 既存テストが壊れていないことを確認する
**テスト実装の制約:**
- プロジェクトの既存テストパターン(命名規約、ディレクトリ構成、ヘルパー)に従う
- Given-When-Then 構造で記述する
- 1テスト1概念。複数の関心事を1テストに混ぜない
**Scope出力契約実装開始時に作成:**
```markdown
# 変更スコープ宣言
## タスク
{タスクの1行要約}
## 変更予定
| 種別 | ファイル |
|------|---------|
| 作成 | `src/__tests__/example.test.ts` |
## 推定規模
Small / Medium / Large
## 影響範囲
- {影響するモジュールや機能}
```
**Decisions出力契約実装完了時、決定がある場合のみ:**
```markdown
# 決定ログ
## 1. {決定内容}
- **背景**: {なぜ決定が必要だったか}
- **検討した選択肢**: {選択肢リスト}
- **理由**: {選んだ理由}
```
**必須出力(見出しを含める)**
## 作業結果
- {実施内容の要約}
## 変更内容
- {変更内容の要約}
## テスト結果
- {実行コマンドと結果}

View File

@ -0,0 +1,11 @@
対象コードを分析し、不足している単体テストを洗い出してください。
**注意:** Previous Responseがある場合は差し戻しのため、
その内容を踏まえてテスト計画を見直してください。
**やること:**
1. 対象モジュールのソースコードを読み、振る舞い・分岐・状態遷移を理解する
2. 既存テストを読み、カバーされている観点を把握する
3. 不足しているテストケース(正常系・異常系・境界値・エッジケース)を洗い出す
4. テスト方針(モック戦略、既存テストヘルパーの活用、フィクスチャ設計)を決める
5. テスト実装者向けの具体的なガイドラインを出す

View File

@ -9,8 +9,14 @@ AI特有の問題はレビューしないでくださいai_reviewムーブメ
- デッドコード - デッドコード
- 呼び出しチェーン検証 - 呼び出しチェーン検証
**前回指摘の追跡(必須):**
- まず「Previous Response」から前回の open findings を抽出する
- 各 finding に `finding_id` を付け、今回の状態を `new / persists / resolved` で判定する
- `persists` と判定する場合は、未解決である根拠(ファイル/行)を必ず示す
## 判定手順 ## 判定手順
1. 変更差分を確認し、構造・設計の観点に基づいて問題を検出する 1. まず前回open findingsを抽出し、`new / persists / resolved` を仮判定する
2. 検出した問題ごとに、Policyのスコープ判定表と判定ルールに基づいてブロッキング/非ブロッキングを分類する 2. 変更差分を確認し、構造・設計の観点に基づいて問題を検出する
3. ブロッキング問題が1件でもあればREJECTと判定する 3. 検出した問題ごとに、Policyのスコープ判定表と判定ルールに基づいてブロッキング/非ブロッキングを分類する
4. ブロッキング問題(`new` または `persists`が1件でもあればREJECTと判定する

View File

@ -7,8 +7,14 @@
- ログとモニタリング - ログとモニタリング
- 保守性 - 保守性
**前回指摘の追跡(必須):**
- まず「Previous Response」から前回の open findings を抽出する
- 各 finding に `finding_id` を付け、今回の状態を `new / persists / resolved` で判定する
- `persists` と判定する場合は、未解決である根拠(ファイル/行)を必ず示す
## 判定手順 ## 判定手順
1. 変更差分を確認し、品質保証の観点に基づいて問題を検出する 1. まず前回open findingsを抽出し、`new / persists / resolved` を仮判定する
2. 検出した問題ごとに、Policyのスコープ判定表と判定ルールに基づいてブロッキング/非ブロッキングを分類する 2. 変更差分を確認し、品質保証の観点に基づいて問題を検出する
3. ブロッキング問題が1件でもあればREJECTと判定する 3. 検出した問題ごとに、Policyのスコープ判定表と判定ルールに基づいてブロッキング/非ブロッキングを分類する
4. ブロッキング問題(`new` または `persists`が1件でもあればREJECTと判定する

View File

@ -0,0 +1,14 @@
テスト品質の観点から変更をレビューしてください。
**レビュー観点:**
- テスト計画の観点がすべてカバーされているか
- テスト品質Given-When-Then構造、独立性、再現性
- テスト命名規約
- 過不足(不要なテスト、足りないケース)
- モック・フィクスチャの適切さ
## 判定手順
1. テスト計画レポート({report:00-test-plan.md})と実装されたテストを突合する
2. 検出した問題ごとに、Policyのスコープ判定表と判定ルールに基づいてブロッキング/非ブロッキングを分類する
3. ブロッキング問題が1件でもあればREJECTと判定する

View File

@ -14,10 +14,15 @@
- [x] デッドコード - [x] デッドコード
- [x] 呼び出しチェーン検証 - [x] 呼び出しチェーン検証
## 前回Open Findings
| finding_id | 前回状態 | 今回状態(new/persists/resolved) | 根拠 |
|------------|----------|----------------------------------|------|
| ARCH-EXAMPLE-src-file-L42 | open | persists | `src/file.ts:42` |
## 問題点REJECTの場合 ## 問題点REJECTの場合
| # | スコープ | 場所 | 問題 | 修正案 | | # | finding_id | 状態(new/persists) | スコープ | 場所 | 問題 | 修正案 |
|---|---------|------|------|--------| |---|------------|--------------------|---------|------|------|--------|
| 1 | スコープ内 | `src/file.ts:42` | 問題の説明 | 修正方法 | | 1 | ARCH-EXAMPLE-src-file-L42 | new | スコープ内 | `src/file.ts:42` | 問題の説明 | 修正方法 |
スコープ: 「スコープ内」(今回修正可能)/ 「スコープ外」(既存問題・非ブロッキング) スコープ: 「スコープ内」(今回修正可能)/ 「スコープ外」(既存問題・非ブロッキング)

View File

@ -15,8 +15,13 @@
| ドキュメント | ✅ | - | | ドキュメント | ✅ | - |
| 保守性 | ✅ | - | | 保守性 | ✅ | - |
## 前回Open Findings
| finding_id | 前回状態 | 今回状態(new/persists/resolved) | 根拠 |
|------------|----------|----------------------------------|------|
| QA-EXAMPLE-src-file-L42 | open | persists | `src/file.ts:42` |
## 問題点REJECTの場合 ## 問題点REJECTの場合
| # | カテゴリ | 問題 | 修正案 | | # | finding_id | 状態(new/persists) | カテゴリ | 問題 | 修正案 |
|---|---------|------|--------| |---|------------|--------------------|---------|------|--------|
| 1 | テスト | 問題の説明 | 修正方法 | | 1 | QA-EXAMPLE-src-file-L42 | new | テスト | 問題の説明 | 修正方法 |
``` ```

View File

@ -0,0 +1,24 @@
```markdown
# テスト計画
## 対象モジュール
{分析対象のモジュール一覧}
## 既存テストの分析
| モジュール | 既存テスト | カバレッジ状況 |
|-----------|-----------|--------------|
| `src/xxx.ts` | `xxx.test.ts` | {カバー状況} |
## 不足テストケース
| # | 対象 | テストケース | 優先度 | 理由 |
|---|------|------------|--------|------|
| 1 | `src/xxx.ts` | {テストケース概要} | 高/中/低 | {理由} |
## テスト方針
- {モック戦略}
- {フィクスチャ設計}
- {テストヘルパー活用}
## 実装ガイドライン
- {テスト実装者向けの具体的指示}
```

View File

@ -0,0 +1,25 @@
# Test Planner
あなたはテスト分析と計画の専門家です。対象コードの振る舞いを理解し、既存テストのカバレッジを分析して、不足しているテストケースを体系的に洗い出す。
## 役割の境界
**やること:**
- 対象コードの振る舞い・分岐・状態遷移を読み解く
- 既存テストのカバレッジを分析する
- 不足しているテストケース(正常系・異常系・境界値・エッジケース)を洗い出す
- テスト戦略(モック方針、フィクスチャ設計、テストヘルパー活用)を決める
- テスト実装者への具体的なガイドラインを出す
**やらないこと:**
- プロダクションコードの変更計画Plannerの仕事
- テストコードの実装Coderの仕事
- コードレビューReviewerの仕事
## 行動姿勢
- コードを読んでから計画する。推測でテストケースを列挙しない
- 既存テストを必ず確認する。カバー済みの観点を重複して計画しない
- テスト優先度を付ける。ビジネスロジック・状態遷移 > エッジケース > 単純なCRUD
- テスト実装者が迷わない粒度で指示を出す
- プロジェクトの既存テストパターンに合わせる。独自の書き方を提案しない

View File

@ -10,26 +10,17 @@ piece_categories:
pieces: pieces:
- review-fix-minimal - review-fix-minimal
- review-only - review-only
- unit-test
🎨 フロントエンド: {} 🎨 フロントエンド: {}
⚙️ バックエンド: {} ⚙️ バックエンド: {}
🔧 フルスタック: 🔧 エキスパート:
pieces: フルスタック:
- expert
- expert-cqrs
🔀 ハイブリッド (Codex Coding):
🚀 クイックスタート:
pieces: pieces:
- coding-hybrid-codex - expert
- default-hybrid-codex - expert-cqrs
- minimal-hybrid-codex リファクタリング:
- passthrough-hybrid-codex
🔧 フルスタック:
pieces: pieces:
- expert-cqrs-hybrid-codex - structural-reform
- expert-hybrid-codex
🔍 レビュー&修正:
pieces:
- review-fix-minimal-hybrid-codex
その他: その他:
pieces: pieces:
- research - research

View File

@ -1,158 +0,0 @@
# Auto-generated from coding.yaml by tools/generate-hybrid-codex.mjs
# Do not edit manually. Edit the source piece and re-run the generator.
name: coding-hybrid-codex
description: Lightweight development piece with planning and parallel reviews (plan -> implement -> parallel review -> complete)
max_iterations: 20
knowledge:
architecture: ../knowledge/architecture.md
personas:
planner: ../personas/planner.md
coder: ../personas/coder.md
ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md
architecture-reviewer: ../personas/architecture-reviewer.md
instructions:
plan: ../instructions/plan.md
implement: ../instructions/implement.md
ai-review: ../instructions/ai-review.md
review-arch: ../instructions/review-arch.md
fix: ../instructions/fix.md
report_formats:
plan: ../output-contracts/plan.md
ai-review: ../output-contracts/ai-review.md
architecture-review: ../output-contracts/architecture-review.md
initial_movement: plan
movements:
- name: plan
edit: false
persona: planner
knowledge: architecture
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
rules:
- condition: 要件が明確で実装可能
next: implement
- condition: ユーザーが質問をしている(実装タスクではない)
next: COMPLETE
- condition: 要件が不明確、情報不足
next: ABORT
instruction: plan
output_contracts:
report:
- name: 00-plan.md
format: plan
- name: implement
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
session: refresh
knowledge: architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: 実装完了
next: reviewers
- condition: 実装未着手(レポートのみ)
next: reviewers
- condition: 判断できない、情報不足
next: reviewers
- condition: ユーザー入力が必要
next: implement
requires_user_input: true
interactive_only: true
instruction: implement
output_contracts:
report:
- Scope: 02-coder-scope.md
- Decisions: 03-coder-decisions.md
- name: reviewers
parallel:
- name: ai_review
edit: false
persona: ai-antipattern-reviewer
policy:
- review
- ai-antipattern
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: AI特有の問題なし
- condition: AI特有の問題あり
instruction: ai-review
output_contracts:
report:
- name: 04-ai-review.md
format: ai-review
- name: arch-review
edit: false
persona: architecture-reviewer
policy: review
knowledge: architecture
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-arch
output_contracts:
report:
- name: 05-architect-review.md
format: architecture-review
rules:
- condition: all("AI特有の問題なし", "approved")
next: COMPLETE
- condition: any("AI特有の問題あり", "needs_fix")
next: fix
- name: fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
knowledge: architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: 修正完了
next: reviewers
- condition: 判断できない、情報不足
next: ABORT
instruction: fix
policies:
coding: ../policies/coding.md
review: ../policies/review.md
testing: ../policies/testing.md
ai-antipattern: ../policies/ai-antipattern.md

View File

@ -1,341 +0,0 @@
# Auto-generated from expert-cqrs.yaml by tools/generate-hybrid-codex.mjs
# Do not edit manually. Edit the source piece and re-run the generator.
name: expert-cqrs-hybrid-codex
description: CQRS+ES・フロントエンド・セキュリティ・QA専門家レビュー
max_iterations: 30
knowledge:
frontend: ../knowledge/frontend.md
backend: ../knowledge/backend.md
cqrs-es: ../knowledge/cqrs-es.md
security: ../knowledge/security.md
architecture: ../knowledge/architecture.md
personas:
planner: ../personas/planner.md
coder: ../personas/coder.md
ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md
architecture-reviewer: ../personas/architecture-reviewer.md
cqrs-es-reviewer: ../personas/cqrs-es-reviewer.md
frontend-reviewer: ../personas/frontend-reviewer.md
security-reviewer: ../personas/security-reviewer.md
qa-reviewer: ../personas/qa-reviewer.md
expert-supervisor: ../personas/expert-supervisor.md
instructions:
plan: ../instructions/plan.md
implement: ../instructions/implement.md
ai-review: ../instructions/ai-review.md
ai-fix: ../instructions/ai-fix.md
arbitrate: ../instructions/arbitrate.md
review-cqrs-es: ../instructions/review-cqrs-es.md
review-frontend: ../instructions/review-frontend.md
review-security: ../instructions/review-security.md
review-qa: ../instructions/review-qa.md
fix: ../instructions/fix.md
supervise: ../instructions/supervise.md
fix-supervisor: ../instructions/fix-supervisor.md
report_formats:
plan: ../output-contracts/plan.md
ai-review: ../output-contracts/ai-review.md
cqrs-es-review: ../output-contracts/cqrs-es-review.md
frontend-review: ../output-contracts/frontend-review.md
security-review: ../output-contracts/security-review.md
qa-review: ../output-contracts/qa-review.md
validation: ../output-contracts/validation.md
summary: ../output-contracts/summary.md
initial_movement: plan
movements:
- name: plan
edit: false
persona: planner
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction: plan
rules:
- condition: タスク分析と計画が完了した
next: implement
- condition: 要件が不明確で計画を立てられない
next: ABORT
output_contracts:
report:
- name: 00-plan.md
format: plan
- name: implement
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
session: refresh
knowledge:
- frontend
- backend
- cqrs-es
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
instruction: implement
rules:
- condition: 実装が完了した
next: ai_review
- condition: 実装未着手(レポートのみ)
next: ai_review
- condition: 実装を進行できない
next: ai_review
- condition: ユーザー入力が必要
next: implement
requires_user_input: true
interactive_only: true
output_contracts:
report:
- Scope: 01-coder-scope.md
- Decisions: 02-coder-decisions.md
- name: ai_review
edit: false
persona: ai-antipattern-reviewer
policy:
- review
- ai-antipattern
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction: ai-review
rules:
- condition: AI特有の問題が見つからない
next: reviewers
- condition: AI特有の問題が検出された
next: ai_fix
output_contracts:
report:
- name: 03-ai-review.md
format: ai-review
- name: ai_fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
session: refresh
knowledge:
- frontend
- backend
- cqrs-es
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
instruction: ai-fix
rules:
- condition: AI Reviewerの指摘に対する修正が完了した
next: ai_review
- condition: 修正不要(指摘対象ファイル/仕様の確認済み)
next: ai_no_fix
- condition: 修正を進行できない
next: ai_no_fix
- name: ai_no_fix
edit: false
persona: architecture-reviewer
policy: review
allowed_tools:
- Read
- Glob
- Grep
rules:
- condition: ai_reviewの指摘が妥当修正すべき
next: ai_fix
- condition: ai_fixの判断が妥当修正不要
next: reviewers
instruction: arbitrate
- name: reviewers
parallel:
- name: cqrs-es-review
edit: false
persona: cqrs-es-reviewer
policy: review
knowledge:
- cqrs-es
- backend
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-cqrs-es
output_contracts:
report:
- name: 04-cqrs-es-review.md
format: cqrs-es-review
- name: frontend-review
edit: false
persona: frontend-reviewer
policy: review
knowledge: frontend
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-frontend
output_contracts:
report:
- name: 05-frontend-review.md
format: frontend-review
- name: security-review
edit: false
persona: security-reviewer
policy: review
knowledge: security
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-security
output_contracts:
report:
- name: 06-security-review.md
format: security-review
- name: qa-review
edit: false
persona: qa-reviewer
policy:
- review
- qa
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-qa
output_contracts:
report:
- name: 07-qa-review.md
format: qa-review
rules:
- condition: all("approved")
next: supervise
- condition: any("needs_fix")
next: fix
- name: fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
knowledge:
- frontend
- backend
- cqrs-es
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: 修正が完了した
next: reviewers
- condition: 修正を進行できない
next: plan
instruction: fix
- name: supervise
edit: false
persona: expert-supervisor
policy: review
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction: supervise
rules:
- condition: すべての検証が完了し、マージ可能な状態である
next: COMPLETE
- condition: 問題が検出された
next: fix_supervisor
output_contracts:
report:
- Validation: 08-supervisor-validation.md
- Summary: summary.md
- name: fix_supervisor
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
knowledge:
- frontend
- backend
- cqrs-es
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
instruction: fix-supervisor
rules:
- condition: 監督者の指摘に対する修正が完了した
next: supervise
- condition: 修正を進行できない
next: plan
policies:
coding: ../policies/coding.md
review: ../policies/review.md
testing: ../policies/testing.md
ai-antipattern: ../policies/ai-antipattern.md
qa: ../policies/qa.md

View File

@ -1,335 +0,0 @@
# Auto-generated from expert.yaml by tools/generate-hybrid-codex.mjs
# Do not edit manually. Edit the source piece and re-run the generator.
name: expert-hybrid-codex
description: アーキテクチャ・フロントエンド・セキュリティ・QA専門家レビュー
max_iterations: 30
knowledge:
frontend: ../knowledge/frontend.md
backend: ../knowledge/backend.md
security: ../knowledge/security.md
architecture: ../knowledge/architecture.md
personas:
planner: ../personas/planner.md
coder: ../personas/coder.md
ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md
architecture-reviewer: ../personas/architecture-reviewer.md
frontend-reviewer: ../personas/frontend-reviewer.md
security-reviewer: ../personas/security-reviewer.md
qa-reviewer: ../personas/qa-reviewer.md
expert-supervisor: ../personas/expert-supervisor.md
instructions:
plan: ../instructions/plan.md
implement: ../instructions/implement.md
ai-review: ../instructions/ai-review.md
ai-fix: ../instructions/ai-fix.md
arbitrate: ../instructions/arbitrate.md
review-arch: ../instructions/review-arch.md
review-frontend: ../instructions/review-frontend.md
review-security: ../instructions/review-security.md
review-qa: ../instructions/review-qa.md
fix: ../instructions/fix.md
supervise: ../instructions/supervise.md
fix-supervisor: ../instructions/fix-supervisor.md
report_formats:
plan: ../output-contracts/plan.md
ai-review: ../output-contracts/ai-review.md
architecture-review: ../output-contracts/architecture-review.md
frontend-review: ../output-contracts/frontend-review.md
security-review: ../output-contracts/security-review.md
qa-review: ../output-contracts/qa-review.md
validation: ../output-contracts/validation.md
summary: ../output-contracts/summary.md
initial_movement: plan
movements:
- name: plan
edit: false
persona: planner
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction: plan
rules:
- condition: タスク分析と計画が完了した
next: implement
- condition: 要件が不明確で計画を立てられない
next: ABORT
output_contracts:
report:
- name: 00-plan.md
format: plan
- name: implement
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
session: refresh
knowledge:
- frontend
- backend
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
instruction: implement
rules:
- condition: 実装が完了した
next: ai_review
- condition: 実装未着手(レポートのみ)
next: ai_review
- condition: 実装を進行できない
next: ai_review
- condition: ユーザー入力が必要
next: implement
requires_user_input: true
interactive_only: true
output_contracts:
report:
- Scope: 01-coder-scope.md
- Decisions: 02-coder-decisions.md
- name: ai_review
edit: false
persona: ai-antipattern-reviewer
policy:
- review
- ai-antipattern
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction: ai-review
rules:
- condition: AI特有の問題が見つからない
next: reviewers
- condition: AI特有の問題が検出された
next: ai_fix
output_contracts:
report:
- name: 03-ai-review.md
format: ai-review
- name: ai_fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
session: refresh
knowledge:
- frontend
- backend
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
instruction: ai-fix
rules:
- condition: AI Reviewerの指摘に対する修正が完了した
next: ai_review
- condition: 修正不要(指摘対象ファイル/仕様の確認済み)
next: ai_no_fix
- condition: 修正を進行できない
next: ai_no_fix
- name: ai_no_fix
edit: false
persona: architecture-reviewer
policy: review
allowed_tools:
- Read
- Glob
- Grep
rules:
- condition: ai_reviewの指摘が妥当修正すべき
next: ai_fix
- condition: ai_fixの判断が妥当修正不要
next: reviewers
instruction: arbitrate
- name: reviewers
parallel:
- name: arch-review
edit: false
persona: architecture-reviewer
policy: review
knowledge:
- architecture
- backend
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-arch
output_contracts:
report:
- name: 04-architect-review.md
format: architecture-review
- name: frontend-review
edit: false
persona: frontend-reviewer
policy: review
knowledge: frontend
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-frontend
output_contracts:
report:
- name: 05-frontend-review.md
format: frontend-review
- name: security-review
edit: false
persona: security-reviewer
policy: review
knowledge: security
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-security
output_contracts:
report:
- name: 06-security-review.md
format: security-review
- name: qa-review
edit: false
persona: qa-reviewer
policy:
- review
- qa
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-qa
output_contracts:
report:
- name: 07-qa-review.md
format: qa-review
rules:
- condition: all("approved")
next: supervise
- condition: any("needs_fix")
next: fix
- name: fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
knowledge:
- frontend
- backend
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: 修正が完了した
next: reviewers
- condition: 修正を進行できない
next: plan
instruction: fix
- name: supervise
edit: false
persona: expert-supervisor
policy: review
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction: supervise
rules:
- condition: すべての検証が完了し、マージ可能な状態である
next: COMPLETE
- condition: 問題が検出された
next: fix_supervisor
output_contracts:
report:
- Validation: 08-supervisor-validation.md
- Summary: summary.md
- name: fix_supervisor
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
knowledge:
- frontend
- backend
- security
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
instruction: fix-supervisor
rules:
- condition: 監督者の指摘に対する修正が完了した
next: supervise
- condition: 修正を進行できない
next: plan
policies:
coding: ../policies/coding.md
review: ../policies/review.md
testing: ../policies/testing.md
ai-antipattern: ../policies/ai-antipattern.md
qa: ../policies/qa.md

View File

@ -1,202 +0,0 @@
# Auto-generated from minimal.yaml by tools/generate-hybrid-codex.mjs
# Do not edit manually. Edit the source piece and re-run the generator.
name: minimal-hybrid-codex
description: Minimal development piece (implement -> parallel review -> fix if needed -> complete)
max_iterations: 20
personas:
coder: ../personas/coder.md
ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md
supervisor: ../personas/supervisor.md
instructions:
implement: ../instructions/implement.md
review-ai: ../instructions/review-ai.md
ai-fix: ../instructions/ai-fix.md
supervise: ../instructions/supervise.md
fix-supervisor: ../instructions/fix-supervisor.md
report_formats:
ai-review: ../output-contracts/ai-review.md
initial_movement: implement
movements:
- name: implement
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
instruction: implement
rules:
- condition: 実装が完了した
next: reviewers
- condition: 実装を進行できない
next: ABORT
- condition: ユーザーへの確認事項があるためユーザー入力が必要
next: implement
requires_user_input: true
interactive_only: true
output_contracts:
report:
- Scope: 01-coder-scope.md
- Decisions: 02-coder-decisions.md
- name: reviewers
parallel:
- name: ai_review
edit: false
persona: ai-antipattern-reviewer
policy:
- review
- ai-antipattern
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction: review-ai
rules:
- condition: AI特有の問題なし
- condition: AI特有の問題あり
output_contracts:
report:
- name: 03-ai-review.md
format: ai-review
- name: supervise
edit: false
persona: supervisor
policy: review
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction: supervise
rules:
- condition: すべて問題なし
- condition: 要求未達成、テスト失敗、ビルドエラー
output_contracts:
report:
- Validation: 05-supervisor-validation.md
- Summary: summary.md
rules:
- condition: all("AI特有の問題なし", "すべて問題なし")
next: COMPLETE
- condition: all("AI特有の問題あり", "要求未達成、テスト失敗、ビルドエラー")
next: fix_both
- condition: any("AI特有の問題あり")
next: ai_fix
- condition: any("要求未達成、テスト失敗、ビルドエラー")
next: supervise_fix
- name: fix_both
parallel:
- name: ai_fix_parallel
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: AI問題の修正完了
- condition: 修正不要(指摘対象ファイル/仕様の確認済み)
- condition: 判断できない、情報不足
instruction: ai-fix
- name: supervise_fix_parallel
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: 監督者の指摘に対する修正が完了した
- condition: 修正を進行できない
instruction: fix-supervisor
rules:
- condition: all("AI問題の修正完了", "監督者の指摘に対する修正が完了した")
next: reviewers
- condition: any("修正不要(指摘対象ファイル/仕様の確認済み)", "判断できない、情報不足", "修正を進行できない")
next: implement
- name: ai_fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: AI問題の修正完了
next: reviewers
- condition: 修正不要(指摘対象ファイル/仕様の確認済み)
next: implement
- condition: 判断できない、情報不足
next: implement
instruction: ai-fix
- name: supervise_fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: 監督者の指摘に対する修正が完了した
next: reviewers
- condition: 修正を進行できない
next: implement
instruction: fix-supervisor
policies:
coding: ../policies/coding.md
review: ../policies/review.md
testing: ../policies/testing.md
ai-antipattern: ../policies/ai-antipattern.md

View File

@ -1,44 +0,0 @@
# Auto-generated from passthrough.yaml by tools/generate-hybrid-codex.mjs
# Do not edit manually. Edit the source piece and re-run the generator.
name: passthrough-hybrid-codex
description: Single-agent thin wrapper. Pass task directly to coder as-is.
max_iterations: 10
personas:
coder: ../personas/coder.md
initial_movement: execute
movements:
- name: execute
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: タスク完了
next: COMPLETE
- condition: 進行できない
next: ABORT
- condition: ユーザー入力が必要
next: execute
requires_user_input: true
interactive_only: true
instruction_template: |
タスクをこなしてください。
output_contracts:
report:
- Summary: summary.md
policies:
coding: ../policies/coding.md
testing: ../policies/testing.md

View File

@ -1,202 +0,0 @@
# Auto-generated from review-fix-minimal.yaml by tools/generate-hybrid-codex.mjs
# Do not edit manually. Edit the source piece and re-run the generator.
name: review-fix-minimal-hybrid-codex
description: 既存コードのレビューと修正ピース(レビュー開始、実装なし)
max_iterations: 20
personas:
coder: ../personas/coder.md
ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md
supervisor: ../personas/supervisor.md
instructions:
implement: ../instructions/implement.md
review-ai: ../instructions/review-ai.md
ai-fix: ../instructions/ai-fix.md
supervise: ../instructions/supervise.md
fix-supervisor: ../instructions/fix-supervisor.md
report_formats:
ai-review: ../output-contracts/ai-review.md
initial_movement: reviewers
movements:
- name: implement
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
instruction: implement
rules:
- condition: 実装が完了した
next: reviewers
- condition: 実装を進行できない
next: ABORT
- condition: ユーザーへの確認事項があるためユーザー入力が必要
next: implement
requires_user_input: true
interactive_only: true
output_contracts:
report:
- Scope: 01-coder-scope.md
- Decisions: 02-coder-decisions.md
- name: reviewers
parallel:
- name: ai_review
edit: false
persona: ai-antipattern-reviewer
policy:
- review
- ai-antipattern
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction: review-ai
rules:
- condition: AI特有の問題なし
- condition: AI特有の問題あり
output_contracts:
report:
- name: 03-ai-review.md
format: ai-review
- name: supervise
edit: false
persona: supervisor
policy: review
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction: supervise
rules:
- condition: すべて問題なし
- condition: 要求未達成、テスト失敗、ビルドエラー
output_contracts:
report:
- Validation: 05-supervisor-validation.md
- Summary: summary.md
rules:
- condition: all("AI特有の問題なし", "すべて問題なし")
next: COMPLETE
- condition: all("AI特有の問題あり", "要求未達成、テスト失敗、ビルドエラー")
next: fix_both
- condition: any("AI特有の問題あり")
next: ai_fix
- condition: any("要求未達成、テスト失敗、ビルドエラー")
next: supervise_fix
- name: fix_both
parallel:
- name: ai_fix_parallel
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: AI問題の修正完了
- condition: 修正不要(指摘対象ファイル/仕様の確認済み)
- condition: 判断できない、情報不足
instruction: ai-fix
- name: supervise_fix_parallel
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: 監督者の指摘に対する修正が完了した
- condition: 修正を進行できない
instruction: fix-supervisor
rules:
- condition: all("AI問題の修正完了", "監督者の指摘に対する修正が完了した")
next: reviewers
- condition: any("修正不要(指摘対象ファイル/仕様の確認済み)", "判断できない、情報不足", "修正を進行できない")
next: implement
- name: ai_fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: AI問題の修正完了
next: reviewers
- condition: 修正不要(指摘対象ファイル/仕様の確認済み)
next: implement
- condition: 判断できない、情報不足
next: implement
instruction: ai-fix
- name: supervise_fix
edit: true
persona: coder
provider: codex
policy:
- coding
- testing
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: 監督者の指摘に対する修正が完了した
next: reviewers
- condition: 修正を進行できない
next: implement
instruction: fix-supervisor
policies:
coding: ../policies/coding.md
review: ../policies/review.md
testing: ../policies/testing.md
ai-antipattern: ../policies/ai-antipattern.md

View File

@ -0,0 +1,455 @@
name: structural-reform
description: プロジェクト全体レビューと構造改革 - 段階的なファイル分割による反復的コードベース再構築
max_iterations: 50
policies:
coding: ../policies/coding.md
review: ../policies/review.md
testing: ../policies/testing.md
qa: ../policies/qa.md
knowledge:
backend: ../knowledge/backend.md
architecture: ../knowledge/architecture.md
personas:
planner: ../personas/planner.md
coder: ../personas/coder.md
architecture-reviewer: ../personas/architecture-reviewer.md
qa-reviewer: ../personas/qa-reviewer.md
supervisor: ../personas/supervisor.md
instructions:
implement: ../instructions/implement.md
review-arch: ../instructions/review-arch.md
review-qa: ../instructions/review-qa.md
fix: ../instructions/fix.md
initial_movement: review
loop_monitors:
- cycle:
- implement
- fix
threshold: 3
judge:
persona: supervisor
instruction_template: |
implement → reviewers → fix のループが現在の改革ターゲットに対して {cycle_count} 回繰り返されました。
各サイクルのレポートを確認し、このループが進捗しているか、
同じ問題を繰り返しているかを判断してください。
**参照するレポート:**
- アーキテクチャレビュー: {report:04-architect-review.md}
- QAレビュー: {report:05-qa-review.md}
**判断基準:**
- 各修正サイクルでレビュー指摘が解消されているか
- 同じ問題が解決されずに繰り返されていないか
- 実装が承認に向かって収束しているか
rules:
- condition: 健全(承認に向けて進捗あり)
next: implement
- condition: 非生産的(同じ問題が繰り返され、収束なし)
next: next_target
movements:
- name: review
edit: false
persona: architecture-reviewer
policy: review
knowledge:
- architecture
- backend
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
instruction_template: |
## ピースステータス
- イテレーション: {iteration}/{max_iterations}(ピース全体)
- ムーブメントイテレーション: {movement_iteration}(このムーブメントの実行回数)
- ムーブメント: reviewプロジェクト全体レビュー
## ユーザーリクエスト
{task}
## 指示
プロジェクトのコードベース全体に対して、包括的な構造レビューを実施してください。
**重点領域:**
1. **God Class/Function**: 300行を超えるファイル、複数の責務を持つクラス
2. **結合度**: 循環依存、モジュール間の密結合
3. **凝集度**: 無関係な関心事が混在した低凝集モジュール
4. **テスタビリティ**: 密結合や副作用によるテスト困難なコード
5. **レイヤー違反**: 依存方向の誤り、アダプター層にドメインロジック
6. **DRY違反**: 3箇所以上の重複ロジック
**発見した問題ごとに報告:**
- ファイルパスと行数
- 問題カテゴリGod Class、低凝集など
- 深刻度Critical / High / Medium
- 分離すべき具体的な責務
- 分割により影響を受ける依存先
**出力フォーマット:**
```markdown
# プロジェクト全体構造レビュー
## サマリー
- レビュー対象ファイル数: N
- 検出された問題: NCritical: N, High: N, Medium: N
## Critical Issues
### 1. {ファイルパス}{行数}行)
- **問題**: {カテゴリ}
- **深刻度**: Critical
- **検出された責務**:
1. {責務1}
2. {責務2}
- **分割提案**:
- `{新ファイル1}.ts`: {責務}
- `{新ファイル2}.ts`: {責務}
- **影響を受ける依存先**: {このモジュールをインポートしているファイル}
## High Priority Issues
...
## Medium Priority Issues
...
## 依存グラフの懸念事項
- {循環依存、レイヤー違反}
## 推奨改革順序
1. {ファイル} - {優先理由}
2. {ファイル} - {優先理由}
```
rules:
- condition: 全体レビューが完了し、問題が検出された
next: plan_reform
- condition: 構造的な問題は見つからなかった
next: COMPLETE
output_contracts:
report:
- name: 00-full-review.md
- name: plan_reform
edit: false
persona: planner
knowledge: architecture
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction_template: |
## ピースステータス
- イテレーション: {iteration}/{max_iterations}(ピース全体)
- ムーブメントイテレーション: {movement_iteration}(このムーブメントの実行回数)
- ムーブメント: plan_reform改革計画策定
## ユーザーリクエスト
{task}
## 全体レビュー結果
{previous_response}
## ユーザー追加入力
{user_inputs}
## 指示
全体レビュー結果を基に、具体的な改革実行計画を策定してください。
**計画の原則:**
- 1イテレーションにつき1ファイルの分割変更を管理可能に保つ
- 依存順に実行:まずリーフノードを分割し、内側に向かって進む
- 各分割後にテストとビルドが通ること
- 後方互換は不要(ユーザー指示通り)
**各改革ターゲットについて指定:**
1. 対象ファイルと現在の行数
2. 提案する新ファイルと責務
3. 依存先ファイルのインポート変更予定
4. テスト戦略(新規テスト、既存テストの更新)
5. リスク評価(何が壊れる可能性があるか)
**出力フォーマット:**
```markdown
# 構造改革計画
## 改革ターゲット(実行優先順)
### ターゲット1: {ファイルパス}
- **現状**: {行数}行、{N}個の責務
- **分割提案**:
| 新ファイル | 責務 | 推定行数 |
|----------|------|---------|
| `{パス}` | {責務} | ~{N} |
- **依存先ファイル**: {このモジュールをインポートしているファイル一覧}
- **テスト計画**: {追加・更新すべきテスト}
- **リスク**: {Low/Medium/High} - {説明}
### ターゲット2: {ファイルパス}
...
## 実行順序の根拠
{この順序がリスクと依存関係の競合を最小化する理由}
## 成功基準
- 各分割後に全テストが通る
- 各分割後にビルドが成功する
- 300行を超えるファイルがない
- 各ファイルが単一責務を持つ
```
rules:
- condition: 改革計画が完成し、実行可能な状態
next: implement
- condition: 実行可能な改革が特定されなかった
next: COMPLETE
- condition: 要件が不明確、ユーザー入力が必要
next: ABORT
appendix: |
確認事項:
- {質問1}
- {質問2}
output_contracts:
report:
- name: 01-reform-plan.md
format: plan
- name: implement
edit: true
persona: coder
policy:
- coding
- testing
session: refresh
knowledge:
- backend
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
instruction: implement
rules:
- condition: 実装完了
next: reviewers
- condition: 判断できない、情報不足
next: reviewers
- condition: ユーザー入力が必要
next: implement
requires_user_input: true
interactive_only: true
output_contracts:
report:
- Scope: 02-coder-scope.md
- Decisions: 03-coder-decisions.md
- name: reviewers
parallel:
- name: arch-review
edit: false
persona: architecture-reviewer
policy: review
knowledge:
- architecture
- backend
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-arch
output_contracts:
report:
- name: 04-architect-review.md
format: architecture-review
- name: qa-review
edit: false
persona: qa-reviewer
policy:
- review
- qa
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-qa
output_contracts:
report:
- name: 05-qa-review.md
format: qa-review
rules:
- condition: all("approved")
next: verify
- condition: any("needs_fix")
next: fix
- name: fix
edit: true
persona: coder
policy:
- coding
- testing
knowledge:
- backend
- architecture
allowed_tools:
- Read
- Glob
- Grep
- Edit
- Write
- Bash
- WebSearch
- WebFetch
permission_mode: edit
rules:
- condition: 修正完了
next: reviewers
- condition: 判断できない、情報不足
next: plan_reform
instruction: fix
- name: verify
edit: false
persona: supervisor
policy: review
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction_template: |
## ピースステータス
- イテレーション: {iteration}/{max_iterations}(ピース全体)
- ムーブメントイテレーション: {movement_iteration}(このムーブメントの実行回数)
- ムーブメント: verifyビルド・テスト検証
## 指示
現在の改革ステップが正常に完了したことを検証してください。
**検証チェックリスト:**
1. **ビルド**: ビルドコマンドを実行し、成功することを確認
2. **テスト**: テストスイートを実行し、全テストが通ることを確認
3. **ファイルサイズ**: 新しいファイルが300行を超えていないことを確認
4. **単一責務**: 各新ファイルが明確な単一の目的を持つことを確認
5. **インポート整合性**: すべてのインポートが正しく更新されていることを確認
**レポートフォーマット:**
```markdown
# 検証結果
## 結果: PASS / FAIL
| チェック項目 | 状態 | 詳細 |
|------------|------|------|
| ビルド | PASS/FAIL | {出力サマリー} |
| テスト | PASS/FAIL | {N passed, N failed} |
| ファイルサイズ | PASS/FAIL | {300行超のファイルの有無} |
| 単一責務 | PASS/FAIL | {評価} |
| インポート整合性 | PASS/FAIL | {壊れたインポートの有無} |
## 問題点FAILの場合
1. {問題の説明}
```
rules:
- condition: すべての検証に合格
next: next_target
- condition: 検証に失敗
next: fix
output_contracts:
report:
- name: 06-verification.md
format: validation
- name: next_target
edit: false
persona: planner
knowledge: architecture
allowed_tools:
- Read
- Glob
- Grep
- Bash
- WebSearch
- WebFetch
instruction_template: |
## ピースステータス
- イテレーション: {iteration}/{max_iterations}(ピース全体)
- ムーブメントイテレーション: {movement_iteration}(このムーブメントの実行回数)
- ムーブメント: next_target進捗確認と次ターゲット選択
## 改革計画
{report:01-reform-plan.md}
## 最新の検証結果
{previous_response}
## 指示
構造改革の進捗を評価し、次のアクションを決定してください。
**手順:**
1. 改革計画を確認し、完了したターゲットを特定
2. 現在のコードベースの状態を計画と照合
3. 残りの改革ターゲットがあるか判断
**出力フォーマット:**
```markdown
# 改革進捗
## 完了ターゲット
| # | ターゲット | 状態 |
|---|----------|------|
| 1 | {ファイル} | 完了 |
| 2 | {ファイル} | 完了 |
## 残りターゲット
| # | ターゲット | 優先度 |
|---|----------|-------|
| 3 | {ファイル} | 次 |
| 4 | {ファイル} | 保留 |
## 次のアクション
- **ターゲット**: {次に改革するファイル}
- **計画**: {分割の概要}
## 全体進捗
{N}/{合計}ターゲット完了。残りの推定イテレーション数: {N}
```
rules:
- condition: まだ改革ターゲットが残っている
next: implement
- condition: すべての改革ターゲットが完了
next: COMPLETE
output_contracts:
report:
- name: 07-progress.md
report_formats:
plan: ../output-contracts/plan.md
architecture-review: ../output-contracts/architecture-review.md
qa-review: ../output-contracts/qa-review.md
validation: ../output-contracts/validation.md
summary: ../output-contracts/summary.md

View File

@ -1,37 +1,32 @@
# Auto-generated from default.yaml by tools/generate-hybrid-codex.mjs name: unit-test
# Do not edit manually. Edit the source piece and re-run the generator. description: 単体テスト追加に特化したピース(テスト分析→テスト実装→レビュー→修正)
max_iterations: 20
name: default-hybrid-codex policies:
description: Standard development piece with planning and specialized reviews coding: ../policies/coding.md
max_iterations: 30 review: ../policies/review.md
testing: ../policies/testing.md
ai-antipattern: ../policies/ai-antipattern.md
qa: ../policies/qa.md
knowledge: knowledge:
architecture: ../knowledge/architecture.md architecture: ../knowledge/architecture.md
backend: ../knowledge/backend.md backend: ../knowledge/backend.md
personas: personas:
planner: ../personas/planner.md test-planner: ../personas/test-planner.md
coder: ../personas/coder.md coder: ../personas/coder.md
ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md
architecture-reviewer: ../personas/architecture-reviewer.md architecture-reviewer: ../personas/architecture-reviewer.md
qa-reviewer: ../personas/qa-reviewer.md qa-reviewer: ../personas/qa-reviewer.md
supervisor: ../personas/supervisor.md supervisor: ../personas/supervisor.md
instructions: instructions:
plan: ../instructions/plan.md plan-test: ../instructions/plan-test.md
implement: ../instructions/implement.md implement-test: ../instructions/implement-test.md
ai-review: ../instructions/ai-review.md ai-review: ../instructions/ai-review.md
ai-fix: ../instructions/ai-fix.md ai-fix: ../instructions/ai-fix.md
arbitrate: ../instructions/arbitrate.md arbitrate: ../instructions/arbitrate.md
review-arch: ../instructions/review-arch.md review-test: ../instructions/review-test.md
review-qa: ../instructions/review-qa.md
fix: ../instructions/fix.md fix: ../instructions/fix.md
supervise: ../instructions/supervise.md supervise: ../instructions/supervise.md
report_formats: initial_movement: plan_test
plan: ../output-contracts/plan.md
ai-review: ../output-contracts/ai-review.md
architecture-review: ../output-contracts/architecture-review.md
qa-review: ../output-contracts/qa-review.md
validation: ../output-contracts/validation.md
summary: ../output-contracts/summary.md
initial_movement: plan
loop_monitors: loop_monitors:
- cycle: - cycle:
- ai_review - ai_review
@ -56,12 +51,15 @@ loop_monitors:
- condition: 健全(進捗あり) - condition: 健全(進捗あり)
next: ai_review next: ai_review
- condition: 非生産的(改善なし) - condition: 非生産的(改善なし)
next: reviewers next: review_test
movements: movements:
- name: plan - name: plan_test
edit: false edit: false
persona: planner persona: test-planner
knowledge: architecture policy: testing
knowledge:
- architecture
- backend
allowed_tools: allowed_tools:
- Read - Read
- Glob - Glob
@ -70,9 +68,9 @@ movements:
- WebSearch - WebSearch
- WebFetch - WebFetch
rules: rules:
- condition: 要件が明確で実装可能 - condition: テスト計画が完了
next: implement next: implement_test
- condition: ユーザーが質問をしている(実装タスクではない) - condition: ユーザーが質問をしている(テスト追加タスクではない)
next: COMPLETE next: COMPLETE
- condition: 要件が不明確、情報不足 - condition: 要件が不明確、情報不足
next: ABORT next: ABORT
@ -80,15 +78,15 @@ movements:
確認事項: 確認事項:
- {質問1} - {質問1}
- {質問2} - {質問2}
instruction: plan instruction: plan-test
output_contracts: output_contracts:
report: report:
- name: 00-plan.md - name: 00-test-plan.md
format: plan format: test-plan
- name: implement
- name: implement_test
edit: true edit: true
persona: coder persona: coder
provider: codex
policy: policy:
- coding - coding
- testing - testing
@ -107,21 +105,22 @@ movements:
- WebFetch - WebFetch
permission_mode: edit permission_mode: edit
rules: rules:
- condition: 実装完了 - condition: テスト実装完了
next: ai_review next: ai_review
- condition: 実装未着手(レポートのみ) - condition: 実装未着手(レポートのみ)
next: ai_review next: ai_review
- condition: 判断できない、情報不足 - condition: 判断できない、情報不足
next: ai_review next: ai_review
- condition: ユーザー入力が必要 - condition: ユーザー入力が必要
next: implement next: implement_test
requires_user_input: true requires_user_input: true
interactive_only: true interactive_only: true
instruction: implement instruction: implement-test
output_contracts: output_contracts:
report: report:
- Scope: 02-coder-scope.md - Scope: 02-coder-scope.md
- Decisions: 03-coder-decisions.md - Decisions: 03-coder-decisions.md
- name: ai_review - name: ai_review
edit: false edit: false
persona: ai-antipattern-reviewer persona: ai-antipattern-reviewer
@ -136,7 +135,7 @@ movements:
- WebFetch - WebFetch
rules: rules:
- condition: AI特有の問題なし - condition: AI特有の問題なし
next: reviewers next: review_test
- condition: AI特有の問題あり - condition: AI特有の問題あり
next: ai_fix next: ai_fix
instruction: ai-review instruction: ai-review
@ -144,10 +143,10 @@ movements:
report: report:
- name: 04-ai-review.md - name: 04-ai-review.md
format: ai-review format: ai-review
- name: ai_fix - name: ai_fix
edit: true edit: true
persona: coder persona: coder
provider: codex
policy: policy:
- coding - coding
- testing - testing
@ -173,6 +172,7 @@ movements:
- condition: 判断できない、情報不足 - condition: 判断できない、情報不足
next: ai_no_fix next: ai_no_fix
instruction: ai-fix instruction: ai-fix
- name: ai_no_fix - name: ai_no_fix
edit: false edit: false
persona: architecture-reviewer persona: architecture-reviewer
@ -185,63 +185,39 @@ movements:
- condition: ai_reviewの指摘が妥当修正すべき - condition: ai_reviewの指摘が妥当修正すべき
next: ai_fix next: ai_fix
- condition: ai_fixの判断が妥当修正不要 - condition: ai_fixの判断が妥当修正不要
next: reviewers next: review_test
instruction: arbitrate instruction: arbitrate
- name: reviewers
parallel: - name: review_test
- name: arch-review edit: false
edit: false persona: qa-reviewer
persona: architecture-reviewer policy:
policy: review - review
knowledge: - qa
- architecture allowed_tools:
- backend - Read
allowed_tools: - Glob
- Read - Grep
- Glob - WebSearch
- Grep - WebFetch
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-arch
output_contracts:
report:
- name: 05-architect-review.md
format: architecture-review
- name: qa-review
edit: false
persona: qa-reviewer
policy:
- review
- qa
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
- WebFetch
rules:
- condition: approved
- condition: needs_fix
instruction: review-qa
output_contracts:
report:
- name: 06-qa-review.md
format: qa-review
rules: rules:
- condition: all("approved") - condition: approved
next: supervise next: supervise
- condition: any("needs_fix") - condition: needs_fix
next: fix next: fix
instruction: review-test
output_contracts:
report:
- name: 05-qa-review.md
format: qa-review
- name: fix - name: fix
edit: true edit: true
persona: coder persona: coder
provider: codex
policy: policy:
- coding - coding
- testing - testing
session: refresh
knowledge: knowledge:
- backend - backend
- architecture - architecture
@ -257,10 +233,11 @@ movements:
permission_mode: edit permission_mode: edit
rules: rules:
- condition: 修正完了 - condition: 修正完了
next: reviewers next: review_test
- condition: 判断できない、情報不足 - condition: 判断できない、情報不足
next: plan next: plan_test
instruction: fix instruction: fix
- name: supervise - name: supervise
edit: false edit: false
persona: supervisor persona: supervisor
@ -276,15 +253,15 @@ movements:
- condition: すべて問題なし - condition: すべて問題なし
next: COMPLETE next: COMPLETE
- condition: 要求未達成、テスト失敗、ビルドエラー - condition: 要求未達成、テスト失敗、ビルドエラー
next: plan next: plan_test
instruction: supervise instruction: supervise
output_contracts: output_contracts:
report: report:
- Validation: 07-supervisor-validation.md - Validation: 06-supervisor-validation.md
- Summary: summary.md - Summary: summary.md
policies: report_formats:
coding: ../policies/coding.md test-plan: ../output-contracts/test-plan.md
review: ../policies/review.md ai-review: ../output-contracts/ai-review.md
testing: ../policies/testing.md qa-review: ../output-contracts/qa-review.md
ai-antipattern: ../policies/ai-antipattern.md validation: ../output-contracts/validation.md
qa: ../policies/qa.md summary: ../output-contracts/summary.md

View File

@ -86,6 +86,18 @@
共通関数に抽出してください」 共通関数に抽出してください」
``` ```
## 指摘ID管理finding_id
同じ指摘の堂々巡りを防ぐため、指摘をIDで追跡する。
- REJECT時に挙げる各問題には `finding_id` を必須で付ける
- 同じ問題を再指摘する場合は、同じ `finding_id` を再利用する
- 再指摘時は状態を `persists` とし、未解決である根拠(ファイル/行)を必ず示す
- 新規指摘は状態 `new` とする
- 解消済みは状態 `resolved` として一覧化する
- `finding_id` のない指摘は無効(判定根拠として扱わない)
- REJECTは `new` または `persists` の問題が1件以上ある場合のみ許可する
## ボーイスカウトルール ## ボーイスカウトルール
来たときよりも美しく。 来たときよりも美しく。

View File

@ -76,6 +76,10 @@ takt --pipeline --task "バグを修正して" --auto-pr
## 使い方 ## 使い方
## 実装メモ
- failed タスクの retry とセッション再開: [`docs/implements/retry-and-session.ja.md`](./implements/retry-and-session.ja.md)
### 対話モード ### 対話モード
AI との会話でタスク内容を詰めてから実行するモード。タスクの要件が曖昧な場合や、AI と相談しながら内容を整理したい場合に便利です。 AI との会話でタスク内容を詰めてから実行するモード。タスクの要件が曖昧な場合や、AI と相談しながら内容を整理したい場合に便利です。
@ -88,13 +92,25 @@ takt
takt hello takt hello
``` ```
**注意:** Issue 参照(`#6`)や `--task` / `--issue` オプションを指定すると対話モードをスキップして直接タスク実行されます。それ以外の入力(スペースを含む文字列を含む)はすべて対話モードに入ります。 **注意:** `--task` オプションを指定すると対話モードをスキップして直接タスク実行されます。Issue 参照(`#6``--issue`)は対話モードの初期入力として使用されます。
**フロー:** **フロー:**
1. ピース選択 1. ピース選択
2. AI との会話でタスク内容を整理 2. 対話モード選択assistant / persona / quiet / passthrough
3. `/go` でタスク指示を確定(`/go 追加の指示` のように指示を追加することも可能)、または `/play <タスク>` で即座に実行 3. AI との会話でタスク内容を整理
4. 実行worktree 作成、ピース実行、PR 作成) 4. `/go` でタスク指示を確定(`/go 追加の指示` のように指示を追加することも可能)、または `/play <タスク>` で即座に実行
5. 実行worktree 作成、ピース実行、PR 作成)
#### 対話モードの種類
| モード | 説明 |
|--------|------|
| `assistant` | デフォルト。AI が質問を通じてタスク要件を明確にしてから指示を生成。 |
| `persona` | 最初のムーブメントのペルソナとの会話(ペルソナのシステムプロンプトとツールを使用)。 |
| `quiet` | 質問なしでタスク指示を生成(ベストエフォート)。 |
| `passthrough` | ユーザー入力をそのままタスクテキストとして使用。AI 処理なし。 |
ピースの `interactive_mode` フィールドでデフォルトモードを設定可能。
#### 実行例 #### 実行例
@ -447,8 +463,10 @@ TAKTには複数のビルトインピースが同梱されています:
| `passthrough` | 最小構成。タスクをそのまま coder に渡す薄いラッパー。レビューなし。 | | `passthrough` | 最小構成。タスクをそのまま coder に渡す薄いラッパー。レビューなし。 |
| `compound-eye` | マルチモデルレビュー: Claude と Codex に同じ指示を同時送信し、両方の回答を統合。 | | `compound-eye` | マルチモデルレビュー: Claude と Codex に同じ指示を同時送信し、両方の回答を統合。 |
| `review-only` | 変更を加えない読み取り専用のコードレビューピース。 | | `review-only` | 変更を加えない読み取り専用のコードレビューピース。 |
| `structural-reform` | プロジェクト全体の構造改革: 段階的なファイル分割を伴う反復的なコードベース再構成。 |
| `unit-test` | ユニットテスト重視ピース: テスト分析 → テスト実装 → レビュー → 修正。 |
**Hybrid Codex バリアント** (`*-hybrid-codex`): 主要ピースごとに、coder エージェントを Codex で実行しレビュアーは Claude を使うハイブリッド構成が用意されています。対象: default, minimal, expert, expert-cqrs, passthrough, review-fix-minimal, coding。 **ペルソナ別プロバイダー設定:** 設定ファイルの `persona_providers` で、特定のペルソナを異なるプロバイダーにルーティングできます(例: coder は Codex、レビュアーは Claude。ピースを複製する必要はありません
`takt switch` でピースを切り替えられます。 `takt switch` でピースを切り替えられます。
@ -471,6 +489,7 @@ TAKTには複数のビルトインピースが同梱されています:
| **research-planner** | リサーチタスクの計画・スコープ定義 | | **research-planner** | リサーチタスクの計画・スコープ定義 |
| **research-digger** | 深掘り調査と情報収集 | | **research-digger** | 深掘り調査と情報収集 |
| **research-supervisor** | リサーチ品質の検証と網羅性の評価 | | **research-supervisor** | リサーチ品質の検証と網羅性の評価 |
| **test-planner** | テスト戦略分析と包括的なテスト計画 |
| **pr-commenter** | レビュー結果を GitHub PR にコメントとして投稿 | | **pr-commenter** | レビュー結果を GitHub PR にコメントとして投稿 |
## カスタムペルソナ ## カスタムペルソナ
@ -539,8 +558,15 @@ branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)ま
prevent_sleep: false # macOS の実行中スリープ防止caffeinate prevent_sleep: false # macOS の実行中スリープ防止caffeinate
notification_sound: true # 通知音の有効/無効 notification_sound: true # 通知音の有効/無効
concurrency: 1 # takt run の並列タスク数1-10、デフォルト: 1 = 逐次実行) concurrency: 1 # takt run の並列タスク数1-10、デフォルト: 1 = 逐次実行)
task_poll_interval_ms: 500 # takt run 中の新タスク検出ポーリング間隔100-5000、デフォルト: 500
interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数0-10、デフォルト: 3 interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数0-10、デフォルト: 3
# ペルソナ別プロバイダー設定(オプション)
# ピースを複製せずに特定のペルソナを異なるプロバイダーにルーティング
# persona_providers:
# coder: codex # coder を Codex で実行
# ai-antipattern-reviewer: claude # レビュアーは Claude のまま
# API Key 設定(オプション) # API Key 設定(オプション)
# 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY で上書き可能 # 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY で上書き可能
anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合 anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合

View File

@ -0,0 +1,58 @@
# failed -> retry とセッション再開の動作原理
このドキュメントは、`takt list``failed` タスクを `Retry` したときの挙動と、AI セッション再開の実際の仕組みを整理した実装メモです。
## 問題の要約
- `failed -> retry` で「前回のセッションがそのまま復活するか」が分かりづらい。
- 実際には「タスク固有のセッション再開」ではなく「保存済みの persona セッション利用」で再開される。
## 解決方針(実装の見方)
- `Retry` が何を再投入するかを確認する。
- 再実行時にどのセッションIDを読み込むかを確認する。
- `session: refresh` や worktree 条件など、再開しないケースを明記する。
## 実装の流れ
1. `takt list``failed` タスクに `Retry` を選ぶ。
2. `retryFailedTask()``TaskRunner.requeueFailedTask()` を呼ぶ。
3. 失敗タスク配下の元タスクファイルを `.takt/tasks/` にコピーする。
4. YAML タスクの場合のみ、必要に応じて `start_movement``retry_note` を書き換える。
参照:
- `src/features/tasks/list/taskRetryActions.ts`
- `src/infra/task/runner.ts`
## 重要: Retry で復元される情報
`requeueFailedTask()` はタスクファイルに `sessionId` を書き戻さない。
復元対象は `start_movement``retry_note` のみ。
つまり、`failed` タスクを `Retry` しても「その failed ディレクトリに紐づくセッションID」を直接復元する実装にはなっていない。
## セッション再開の実際
再実行時の `pieceExecution` で、プロジェクト保存済みセッションをロードして `initialSessions` に渡す。
- 通常実行: `loadPersonaSessions(projectCwd, provider)`
- worktree 実行: `loadWorktreeSessions(projectCwd, cwd, provider)`
その後、各 movement の Phase 1 実行で `OptionsBuilder.buildAgentOptions()``sessionId` を組み立てる。
参照:
- `src/features/tasks/execute/pieceExecution.ts`
- `src/core/piece/engine/OptionsBuilder.ts`
## セッション再開しない条件
- movement 側が `session: refresh` の場合。
- `cwd !== projectCwd` など、セッション再開を抑止する条件に当たる場合。
このため、`Retry` で常に同じ会話が厳密再開されるわけではない。
## 運用上の注意
- `failed -> retry` は正しい復旧手順。
- ただしセッションは「タスク単位」ではなく「persona の保存状態」基準で再開される。
- 前回文脈を確実に残したい場合は、`retry_note` に再試行理由と前提を明記する。

View File

@ -11,6 +11,11 @@ export interface TestRepo {
cleanup: () => void; cleanup: () => void;
} }
export interface CreateTestRepoOptions {
/** Skip creating a test branch (stay on default branch). Use for pipeline tests. */
skipBranch?: boolean;
}
function getGitHubUser(): string { function getGitHubUser(): string {
const user = execFileSync('gh', ['api', 'user', '--jq', '.login'], { const user = execFileSync('gh', ['api', 'user', '--jq', '.login'], {
encoding: 'utf-8', encoding: 'utf-8',
@ -33,7 +38,7 @@ function getGitHubUser(): string {
* 2. Close any PRs created during the test * 2. Close any PRs created during the test
* 3. Delete local directory * 3. Delete local directory
*/ */
export function createTestRepo(): TestRepo { export function createTestRepo(options?: CreateTestRepoOptions): TestRepo {
const user = getGitHubUser(); const user = getGitHubUser();
const repoName = `${user}/takt-testing`; const repoName = `${user}/takt-testing`;
@ -56,49 +61,80 @@ export function createTestRepo(): TestRepo {
stdio: 'pipe', stdio: 'pipe',
}); });
// Create test branch // Create test branch (unless skipped for pipeline tests)
const testBranch = `e2e-test-${Date.now()}`; const testBranch = options?.skipBranch
execFileSync('git', ['checkout', '-b', testBranch], { ? undefined
cwd: repoPath, : `e2e-test-${Date.now()}`;
stdio: 'pipe', if (testBranch) {
}); execFileSync('git', ['checkout', '-b', testBranch], {
cwd: repoPath,
stdio: 'pipe',
});
}
const currentBranch = testBranch
?? execFileSync('git', ['branch', '--show-current'], {
cwd: repoPath,
encoding: 'utf-8',
}).trim();
return { return {
path: repoPath, path: repoPath,
repoName, repoName,
branch: testBranch, branch: currentBranch,
cleanup: () => { cleanup: () => {
// 1. Delete remote branch (best-effort) if (testBranch) {
try { // 1. Delete remote branch (best-effort)
execFileSync( try {
'git',
['push', 'origin', '--delete', testBranch],
{ cwd: repoPath, stdio: 'pipe' },
);
} catch {
// Branch may not have been pushed; ignore
}
// 2. Close any PRs from this branch (best-effort)
try {
const prList = execFileSync(
'gh',
['pr', 'list', '--head', testBranch, '--repo', repoName, '--json', 'number', '--jq', '.[].number'],
{ encoding: 'utf-8', stdio: 'pipe' },
).trim();
for (const prNumber of prList.split('\n').filter(Boolean)) {
execFileSync( execFileSync(
'gh', 'git',
['pr', 'close', prNumber, '--repo', repoName, '--delete-branch'], ['push', 'origin', '--delete', testBranch],
{ stdio: 'pipe' }, { cwd: repoPath, stdio: 'pipe' },
); );
} catch {
// Branch may not have been pushed; ignore
}
// 2. Close any PRs from this branch (best-effort)
try {
const prList = execFileSync(
'gh',
['pr', 'list', '--head', testBranch, '--repo', repoName, '--json', 'number', '--jq', '.[].number'],
{ encoding: 'utf-8', stdio: 'pipe' },
).trim();
for (const prNumber of prList.split('\n').filter(Boolean)) {
execFileSync(
'gh',
['pr', 'close', prNumber, '--repo', repoName, '--delete-branch'],
{ stdio: 'pipe' },
);
}
} catch {
// No PRs or already closed; ignore
}
} else {
// Pipeline mode: clean up takt-created PRs (best-effort)
try {
const prNumbers = execFileSync(
'gh',
['pr', 'list', '--state', 'open', '--repo', repoName, '--json', 'number', '--jq', '.[].number'],
{ encoding: 'utf-8', stdio: 'pipe' },
).trim();
for (const prNumber of prNumbers.split('\n').filter(Boolean)) {
execFileSync(
'gh',
['pr', 'close', prNumber, '--repo', repoName, '--delete-branch'],
{ stdio: 'pipe' },
);
}
} catch {
// ignore
} }
} catch {
// No PRs or already closed; ignore
} }
// 3. Delete local directory last // Delete local directory last
try { try {
rmSync(repoPath, { recursive: true, force: true }); rmSync(repoPath, { recursive: true, force: true });
} catch { } catch {

View File

@ -17,7 +17,7 @@ describe('E2E: GitHub Issue processing', () => {
beforeEach(() => { beforeEach(() => {
isolatedEnv = createIsolatedEnv(); isolatedEnv = createIsolatedEnv();
testRepo = createTestRepo(); testRepo = createTestRepo({ skipBranch: true });
// Create a test issue // Create a test issue
const createOutput = execFileSync( const createOutput = execFileSync(

View File

@ -16,7 +16,7 @@ describe('E2E: Pipeline mode (--pipeline --auto-pr)', () => {
beforeEach(() => { beforeEach(() => {
isolatedEnv = createIsolatedEnv(); isolatedEnv = createIsolatedEnv();
testRepo = createTestRepo(); testRepo = createTestRepo({ skipBranch: true });
}); });
afterEach(() => { afterEach(() => {

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "takt", "name": "takt",
"version": "0.9.0", "version": "0.10.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "takt", "name": "takt",
"version": "0.9.0", "version": "0.10.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.37", "@anthropic-ai/claude-agent-sdk": "^0.2.37",

View File

@ -1,6 +1,6 @@
{ {
"name": "takt", "name": "takt",
"version": "0.9.0", "version": "0.10.0",
"description": "TAKT: Task Agent Koordination Tool - AI Agent Piece Orchestration", "description": "TAKT: Task Agent Koordination Tool - AI Agent Piece Orchestration",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -45,6 +45,11 @@ vi.mock('../features/pipeline/index.js', () => ({
vi.mock('../features/interactive/index.js', () => ({ vi.mock('../features/interactive/index.js', () => ({
interactiveMode: vi.fn(), interactiveMode: vi.fn(),
selectInteractiveMode: vi.fn(() => 'assistant'),
passthroughMode: vi.fn(),
quietMode: vi.fn(),
personaMode: vi.fn(),
resolveLanguage: vi.fn(() => 'en'),
})); }));
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({

View File

@ -160,4 +160,71 @@ describe('PieceEngine Integration: Parallel Movement Aggregation', () => {
expect(calledAgents).toContain('../personas/arch-review.md'); expect(calledAgents).toContain('../personas/arch-review.md');
expect(calledAgents).toContain('../personas/security-review.md'); expect(calledAgents).toContain('../personas/security-review.md');
}); });
it('should output rich parallel prefix when taskPrefix/taskColorIndex are provided', async () => {
const config = buildDefaultPieceConfig();
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const parentOnStream = vi.fn();
const responsesByPersona = new Map<string, ReturnType<typeof makeResponse>>([
['../personas/plan.md', makeResponse({ persona: 'plan', content: 'Plan done' })],
['../personas/implement.md', makeResponse({ persona: 'implement', content: 'Impl done' })],
['../personas/ai_review.md', makeResponse({ persona: 'ai_review', content: 'OK' })],
['../personas/arch-review.md', makeResponse({ persona: 'arch-review', content: 'Architecture review content' })],
['../personas/security-review.md', makeResponse({ persona: 'security-review', content: 'Security review content' })],
['../personas/supervise.md', makeResponse({ persona: 'supervise', content: 'All passed' })],
]);
vi.mocked(runAgent).mockImplementation(async (persona, _task, options) => {
const response = responsesByPersona.get(persona ?? '');
if (!response) {
throw new Error(`Unexpected persona: ${persona}`);
}
if (persona === '../personas/arch-review.md') {
options.onStream?.({ type: 'text', data: { text: 'arch stream line\n' } });
}
if (persona === '../personas/security-review.md') {
options.onStream?.({ type: 'text', data: { text: 'security stream line\n' } });
}
return response;
});
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'aggregate' },
{ index: 0, method: 'phase1_tag' },
]);
const engine = new PieceEngine(config, tmpDir, 'test task', {
projectCwd: tmpDir,
onStream: parentOnStream,
taskPrefix: 'override-persona-provider',
taskColorIndex: 0,
});
try {
const state = await engine.run();
expect(state.status).toBe('completed');
const output = stdoutSpy.mock.calls.map((call) => String(call[0])).join('');
expect(output).toContain('[over]');
expect(output).toContain('[reviewers][arch-review](4/30)(1) arch stream line');
expect(output).toContain('[reviewers][security-review](4/30)(1) security stream line');
} finally {
stdoutSpy.mockRestore();
}
});
it('should fail fast when taskPrefix is provided without taskColorIndex', () => {
const config = buildDefaultPieceConfig();
expect(
() => new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, taskPrefix: 'override-persona-provider' })
).toThrow('taskPrefix and taskColorIndex must be provided together');
});
}); });

View File

@ -0,0 +1,204 @@
/**
* Tests for persona_providers config-level provider override.
*
* Verifies the provider resolution priority:
* 1. Movement YAML provider (highest)
* 2. persona_providers[personaDisplayName]
* 3. CLI/global provider (lowest)
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
vi.mock('../core/piece/evaluation/index.js', () => ({
detectMatchedRule: vi.fn(),
}));
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn(),
runReportPhase: vi.fn(),
runStatusJudgmentPhase: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
}));
import { PieceEngine } from '../core/piece/index.js';
import { runAgent } from '../agents/runner.js';
import type { PieceConfig } from '../core/models/index.js';
import {
makeResponse,
makeRule,
makeMovement,
mockRunAgentSequence,
mockDetectMatchedRuleSequence,
applyDefaultMocks,
} from './engine-test-helpers.js';
describe('PieceEngine persona_providers override', () => {
beforeEach(() => {
vi.resetAllMocks();
applyDefaultMocks();
});
it('should use persona_providers when movement has no provider and persona matches', async () => {
const movement = makeMovement('implement', {
personaDisplayName: 'coder',
rules: [makeRule('done', 'COMPLETE')],
});
const config: PieceConfig = {
name: 'persona-provider-test',
movements: [movement],
initialMovement: 'implement',
maxIterations: 1,
};
mockRunAgentSequence([
makeResponse({ persona: movement.persona, content: 'done' }),
]);
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
projectCwd: '/tmp/project',
provider: 'claude',
personaProviders: { coder: 'codex' },
});
await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('codex');
});
it('should use global provider when persona is not in persona_providers', async () => {
const movement = makeMovement('plan', {
personaDisplayName: 'planner',
rules: [makeRule('done', 'COMPLETE')],
});
const config: PieceConfig = {
name: 'persona-provider-nomatch',
movements: [movement],
initialMovement: 'plan',
maxIterations: 1,
};
mockRunAgentSequence([
makeResponse({ persona: movement.persona, content: 'done' }),
]);
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
projectCwd: '/tmp/project',
provider: 'claude',
personaProviders: { coder: 'codex' },
});
await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('claude');
});
it('should prioritize movement provider over persona_providers', async () => {
const movement = makeMovement('implement', {
personaDisplayName: 'coder',
provider: 'claude',
rules: [makeRule('done', 'COMPLETE')],
});
const config: PieceConfig = {
name: 'movement-over-persona',
movements: [movement],
initialMovement: 'implement',
maxIterations: 1,
};
mockRunAgentSequence([
makeResponse({ persona: movement.persona, content: 'done' }),
]);
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
projectCwd: '/tmp/project',
provider: 'mock',
personaProviders: { coder: 'codex' },
});
await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('claude');
});
it('should work without persona_providers (undefined)', async () => {
const movement = makeMovement('plan', {
personaDisplayName: 'planner',
rules: [makeRule('done', 'COMPLETE')],
});
const config: PieceConfig = {
name: 'no-persona-providers',
movements: [movement],
initialMovement: 'plan',
maxIterations: 1,
};
mockRunAgentSequence([
makeResponse({ persona: movement.persona, content: 'done' }),
]);
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
projectCwd: '/tmp/project',
provider: 'claude',
});
await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('claude');
});
it('should apply different providers to different personas in a multi-movement piece', async () => {
const planMovement = makeMovement('plan', {
personaDisplayName: 'planner',
rules: [makeRule('done', 'implement')],
});
const implementMovement = makeMovement('implement', {
personaDisplayName: 'coder',
rules: [makeRule('done', 'COMPLETE')],
});
const config: PieceConfig = {
name: 'multi-persona-providers',
movements: [planMovement, implementMovement],
initialMovement: 'plan',
maxIterations: 3,
};
mockRunAgentSequence([
makeResponse({ persona: planMovement.persona, content: 'done' }),
makeResponse({ persona: implementMovement.persona, content: 'done' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
]);
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
projectCwd: '/tmp/project',
provider: 'claude',
personaProviders: { coder: 'codex' },
});
await engine.run();
const calls = vi.mocked(runAgent).mock.calls;
// Plan movement: planner not in persona_providers → claude
expect(calls[0][2].provider).toBe('claude');
// Implement movement: coder in persona_providers → codex
expect(calls[1][2].provider).toBe('codex');
});
});

View File

@ -158,6 +158,21 @@ describe('resolveRefToContent with layer resolution', () => {
// No context, no file — returns the spec as-is (inline content behavior) // No context, no file — returns the spec as-is (inline content behavior)
expect(content).toBe('some-name'); expect(content).toBe('some-name');
}); });
it('should fall back to resolveResourceContent when facet not found with context', () => {
// Given: facetType and context provided, but no matching facet file exists
// When: resolveRefToContent is called with a name that has no facet file
const content = resolveRefToContent(
'nonexistent-facet-xyz',
undefined,
tempDir,
'policies',
context,
);
// Then: falls back to resolveResourceContent, which returns the ref as inline content
expect(content).toBe('nonexistent-facet-xyz');
});
}); });
describe('resolveRefList with layer resolution', () => { describe('resolveRefList with layer resolution', () => {

View File

@ -336,6 +336,63 @@ describe('loadGlobalConfig', () => {
expect(config.interactivePreviewMovements).toBe(0); expect(config.interactivePreviewMovements).toBe(0);
}); });
describe('persona_providers', () => {
it('should load persona_providers from config.yaml', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'persona_providers:',
' coder: codex',
' reviewer: claude',
].join('\n'),
'utf-8',
);
const config = loadGlobalConfig();
expect(config.personaProviders).toEqual({
coder: 'codex',
reviewer: 'claude',
});
});
it('should save and reload persona_providers', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
config.personaProviders = { coder: 'codex' };
saveGlobalConfig(config);
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.personaProviders).toEqual({ coder: 'codex' });
});
it('should have undefined personaProviders by default', () => {
const config = loadGlobalConfig();
expect(config.personaProviders).toBeUndefined();
});
it('should not save persona_providers when empty', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
config.personaProviders = {};
saveGlobalConfig(config);
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.personaProviders).toBeUndefined();
});
});
describe('provider/model compatibility validation', () => { describe('provider/model compatibility validation', () => {
it('should throw when provider is codex but model is a Claude alias (opus)', () => { it('should throw when provider is codex but model is a Claude alias (opus)', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');

View File

@ -0,0 +1,532 @@
/**
* Tests for interactive mode variants (assistant, persona, quiet, passthrough)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ── Mocks ──────────────────────────────────────────────
vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
}));
vi.mock('../infra/providers/index.js', () => ({
getProvider: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock('../shared/context.js', () => ({
isQuietMode: vi.fn(() => false),
}));
vi.mock('../infra/config/paths.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
loadPersonaSessions: vi.fn(() => ({})),
updatePersonaSession: vi.fn(),
getProjectConfigDir: vi.fn(() => '/tmp'),
loadSessionState: vi.fn(() => null),
clearSessionState: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(),
error: vi.fn(),
blankLine: vi.fn(),
StreamDisplay: vi.fn().mockImplementation(() => ({
createHandler: vi.fn(() => vi.fn()),
flush: vi.fn(),
})),
}));
vi.mock('../shared/prompt/index.js', () => ({
selectOption: vi.fn(),
selectOptionWithDefault: vi.fn(),
}));
import { getProvider } from '../infra/providers/index.js';
import { selectOptionWithDefault, selectOption } from '../shared/prompt/index.js';
const mockGetProvider = vi.mocked(getProvider);
const mockSelectOptionWithDefault = vi.mocked(selectOptionWithDefault);
const mockSelectOption = vi.mocked(selectOption);
// ── Stdin helpers (same pattern as interactive.test.ts) ──
let savedIsTTY: boolean | undefined;
let savedIsRaw: boolean | undefined;
let savedSetRawMode: typeof process.stdin.setRawMode | undefined;
let savedStdoutWrite: typeof process.stdout.write;
let savedStdinOn: typeof process.stdin.on;
let savedStdinRemoveListener: typeof process.stdin.removeListener;
let savedStdinResume: typeof process.stdin.resume;
let savedStdinPause: typeof process.stdin.pause;
function setupRawStdin(rawInputs: string[]): void {
savedIsTTY = process.stdin.isTTY;
savedIsRaw = process.stdin.isRaw;
savedSetRawMode = process.stdin.setRawMode;
savedStdoutWrite = process.stdout.write;
savedStdinOn = process.stdin.on;
savedStdinRemoveListener = process.stdin.removeListener;
savedStdinResume = process.stdin.resume;
savedStdinPause = process.stdin.pause;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
Object.defineProperty(process.stdin, 'isRaw', { value: false, configurable: true, writable: true });
process.stdin.setRawMode = vi.fn((mode: boolean) => {
(process.stdin as unknown as { isRaw: boolean }).isRaw = mode;
return process.stdin;
}) as unknown as typeof process.stdin.setRawMode;
process.stdout.write = vi.fn(() => true) as unknown as typeof process.stdout.write;
process.stdin.resume = vi.fn(() => process.stdin) as unknown as typeof process.stdin.resume;
process.stdin.pause = vi.fn(() => process.stdin) as unknown as typeof process.stdin.pause;
let currentHandler: ((data: Buffer) => void) | null = null;
let inputIndex = 0;
process.stdin.on = vi.fn(((event: string, handler: (...args: unknown[]) => void) => {
if (event === 'data') {
currentHandler = handler as (data: Buffer) => void;
if (inputIndex < rawInputs.length) {
const data = rawInputs[inputIndex]!;
inputIndex++;
queueMicrotask(() => {
if (currentHandler) {
currentHandler(Buffer.from(data, 'utf-8'));
}
});
}
}
return process.stdin;
}) as typeof process.stdin.on);
process.stdin.removeListener = vi.fn(((event: string) => {
if (event === 'data') {
currentHandler = null;
}
return process.stdin;
}) as typeof process.stdin.removeListener);
}
function restoreStdin(): void {
if (savedIsTTY !== undefined) {
Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true });
}
if (savedIsRaw !== undefined) {
Object.defineProperty(process.stdin, 'isRaw', { value: savedIsRaw, configurable: true, writable: true });
}
if (savedSetRawMode) process.stdin.setRawMode = savedSetRawMode;
if (savedStdoutWrite) process.stdout.write = savedStdoutWrite;
if (savedStdinOn) process.stdin.on = savedStdinOn;
if (savedStdinRemoveListener) process.stdin.removeListener = savedStdinRemoveListener;
if (savedStdinResume) process.stdin.resume = savedStdinResume;
if (savedStdinPause) process.stdin.pause = savedStdinPause;
}
function toRawInputs(inputs: (string | null)[]): string[] {
return inputs.map((input) => {
if (input === null) return '\x04';
return input + '\r';
});
}
function setupMockProvider(responses: string[]): void {
let callIndex = 0;
const mockCall = vi.fn(async () => {
const content = callIndex < responses.length ? responses[callIndex] : 'AI response';
callIndex++;
return {
persona: 'interactive',
status: 'done' as const,
content: content!,
timestamp: new Date(),
};
});
const mockSetup = vi.fn(() => ({ call: mockCall }));
const mockProvider = {
setup: mockSetup,
_call: mockCall,
_setup: mockSetup,
};
mockGetProvider.mockReturnValue(mockProvider);
}
// ── Imports (after mocks) ──
import { INTERACTIVE_MODES, DEFAULT_INTERACTIVE_MODE } from '../core/models/interactive-mode.js';
import { selectInteractiveMode } from '../features/interactive/modeSelection.js';
import { passthroughMode } from '../features/interactive/passthroughMode.js';
import { quietMode } from '../features/interactive/quietMode.js';
import { personaMode } from '../features/interactive/personaMode.js';
import type { PieceContext } from '../features/interactive/interactive.js';
import type { FirstMovementInfo } from '../infra/config/loaders/pieceResolver.js';
// ── Setup ──
beforeEach(() => {
vi.clearAllMocks();
mockSelectOptionWithDefault.mockResolvedValue('assistant');
mockSelectOption.mockResolvedValue('execute');
});
afterEach(() => {
restoreStdin();
});
// ── InteractiveMode type & constants tests ──
describe('InteractiveMode type', () => {
it('should define all four modes', () => {
expect(INTERACTIVE_MODES).toEqual(['assistant', 'persona', 'quiet', 'passthrough']);
});
it('should have assistant as default mode', () => {
expect(DEFAULT_INTERACTIVE_MODE).toBe('assistant');
});
});
// ── Mode selection tests ──
describe('selectInteractiveMode', () => {
it('should call selectOptionWithDefault with four mode options', async () => {
// When
await selectInteractiveMode('en');
// Then
expect(mockSelectOptionWithDefault).toHaveBeenCalledWith(
expect.any(String),
expect.arrayContaining([
expect.objectContaining({ value: 'assistant' }),
expect.objectContaining({ value: 'persona' }),
expect.objectContaining({ value: 'quiet' }),
expect.objectContaining({ value: 'passthrough' }),
]),
'assistant',
);
});
it('should use piece default when provided', async () => {
// When
await selectInteractiveMode('en', 'quiet');
// Then
expect(mockSelectOptionWithDefault).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
'quiet',
);
});
it('should return null when user cancels', async () => {
// Given
mockSelectOptionWithDefault.mockResolvedValue(null);
// When
const result = await selectInteractiveMode('en');
// Then
expect(result).toBeNull();
});
it('should return selected mode value', async () => {
// Given
mockSelectOptionWithDefault.mockResolvedValue('persona');
// When
const result = await selectInteractiveMode('ja');
// Then
expect(result).toBe('persona');
});
it('should present options in correct order', async () => {
// When
await selectInteractiveMode('en');
// Then
const options = mockSelectOptionWithDefault.mock.calls[0]?.[1] as Array<{ value: string }>;
expect(options?.[0]?.value).toBe('assistant');
expect(options?.[1]?.value).toBe('persona');
expect(options?.[2]?.value).toBe('quiet');
expect(options?.[3]?.value).toBe('passthrough');
});
});
// ── Passthrough mode tests ──
describe('passthroughMode', () => {
it('should return initialInput directly when provided', async () => {
// When
const result = await passthroughMode('en', 'my task text');
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('my task text');
});
it('should return cancel when user sends EOF', async () => {
// Given
setupRawStdin(toRawInputs([null]));
// When
const result = await passthroughMode('en');
// Then
expect(result.action).toBe('cancel');
expect(result.task).toBe('');
});
it('should return cancel when user enters empty input', async () => {
// Given
setupRawStdin(toRawInputs(['']));
// When
const result = await passthroughMode('en');
// Then
expect(result.action).toBe('cancel');
});
it('should return user input as task when entered', async () => {
// Given
setupRawStdin(toRawInputs(['implement login feature']));
// When
const result = await passthroughMode('en');
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('implement login feature');
});
it('should trim whitespace from user input', async () => {
// Given
setupRawStdin(toRawInputs([' my task ']));
// When
const result = await passthroughMode('en');
// Then
expect(result.task).toBe('my task');
});
});
// ── Quiet mode tests ──
describe('quietMode', () => {
it('should generate instructions from initialInput without questions', async () => {
// Given
setupMockProvider(['Generated task instruction for login feature.']);
mockSelectOption.mockResolvedValue('execute');
// When
const result = await quietMode('/project', 'implement login feature');
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('Generated task instruction for login feature.');
});
it('should return cancel when user sends EOF for input', async () => {
// Given
setupRawStdin(toRawInputs([null]));
setupMockProvider([]);
// When
const result = await quietMode('/project');
// Then
expect(result.action).toBe('cancel');
});
it('should return cancel when user enters empty input', async () => {
// Given
setupRawStdin(toRawInputs(['']));
setupMockProvider([]);
// When
const result = await quietMode('/project');
// Then
expect(result.action).toBe('cancel');
});
it('should prompt for input when no initialInput is provided', async () => {
// Given
setupRawStdin(toRawInputs(['fix the bug']));
setupMockProvider(['Fix the bug instruction.']);
mockSelectOption.mockResolvedValue('execute');
// When
const result = await quietMode('/project');
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('Fix the bug instruction.');
});
it('should include piece context in summary generation', async () => {
// Given
const pieceContext: PieceContext = {
name: 'test-piece',
description: 'A test piece',
pieceStructure: '1. implement\n2. review',
movementPreviews: [],
};
setupMockProvider(['Instruction with piece context.']);
mockSelectOption.mockResolvedValue('execute');
// When
const result = await quietMode('/project', 'some task', pieceContext);
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('Instruction with piece context.');
});
});
// ── Persona mode tests ──
describe('personaMode', () => {
const mockFirstMovement: FirstMovementInfo = {
personaContent: 'You are a senior coder. Write clean, maintainable code.',
personaDisplayName: 'Coder',
allowedTools: ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'],
};
it('should return cancel when user types /cancel', async () => {
// Given
setupRawStdin(toRawInputs(['/cancel']));
setupMockProvider([]);
// When
const result = await personaMode('/project', mockFirstMovement);
// Then
expect(result.action).toBe('cancel');
expect(result.task).toBe('');
});
it('should return cancel on EOF', async () => {
// Given
setupRawStdin(toRawInputs([null]));
setupMockProvider([]);
// When
const result = await personaMode('/project', mockFirstMovement);
// Then
expect(result.action).toBe('cancel');
});
it('should use first movement persona as system prompt', async () => {
// Given
setupRawStdin(toRawInputs(['fix bug', '/cancel']));
setupMockProvider(['I see the issue.']);
// When
await personaMode('/project', mockFirstMovement);
// Then: the provider should be set up with persona content as system prompt
const mockProvider = mockGetProvider.mock.results[0]!.value as { _setup: ReturnType<typeof vi.fn> };
expect(mockProvider._setup).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: 'You are a senior coder. Write clean, maintainable code.',
}),
);
});
it('should use first movement allowed tools', async () => {
// Given
setupRawStdin(toRawInputs(['check the code', '/cancel']));
setupMockProvider(['Looking at the code.']);
// When
await personaMode('/project', mockFirstMovement);
// Then
const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
expect(mockProvider._call).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
allowedTools: ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'],
}),
);
});
it('should process initialInput as first message', async () => {
// Given
setupRawStdin(toRawInputs(['/go']));
setupMockProvider(['I analyzed the issue.', 'Task summary.']);
mockSelectOption.mockResolvedValue('execute');
// When
const result = await personaMode('/project', mockFirstMovement, 'fix the login');
// Then
expect(result.action).toBe('execute');
const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
expect(mockProvider._call).toHaveBeenCalledTimes(2);
const firstPrompt = mockProvider._call.mock.calls[0]?.[0] as string;
expect(firstPrompt).toBe('fix the login');
});
it('should handle /play command', async () => {
// Given
setupRawStdin(toRawInputs(['/play direct task text']));
setupMockProvider([]);
// When
const result = await personaMode('/project', mockFirstMovement);
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('direct task text');
});
it('should fall back to default tools when first movement has none', async () => {
// Given
const noToolsMovement: FirstMovementInfo = {
personaContent: 'Persona prompt',
personaDisplayName: 'Agent',
allowedTools: [],
};
setupRawStdin(toRawInputs(['test', '/cancel']));
setupMockProvider(['response']);
// When
await personaMode('/project', noToolsMovement);
// Then
const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
expect(mockProvider._call).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
}),
);
});
it('should handle multi-turn conversation before /go', async () => {
// Given
setupRawStdin(toRawInputs(['first message', 'second message', '/go']));
setupMockProvider(['reply 1', 'reply 2', 'Final summary.']);
mockSelectOption.mockResolvedValue('execute');
// When
const result = await personaMode('/project', mockFirstMovement);
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('Final summary.');
});
});

View File

@ -25,6 +25,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({
// --- Imports (after mocks) --- // --- Imports (after mocks) ---
import { loadPiece } from '../infra/config/index.js'; import { loadPiece } from '../infra/config/index.js';
import { listBuiltinPieceNames } from '../infra/config/loaders/pieceResolver.js';
// --- Test helpers --- // --- Test helpers ---
@ -45,7 +46,7 @@ describe('Piece Loader IT: builtin piece loading', () => {
rmSync(testDir, { recursive: true, force: true }); rmSync(testDir, { recursive: true, force: true });
}); });
const builtinNames = ['default', 'minimal', 'expert', 'expert-cqrs', 'research', 'magi', 'review-only', 'review-fix-minimal']; const builtinNames = listBuiltinPieceNames({ includeDisabled: true });
for (const name of builtinNames) { for (const name of builtinNames) {
it(`should load builtin piece: ${name}`, () => { it(`should load builtin piece: ${name}`, () => {
@ -572,6 +573,139 @@ movements:
}); });
}); });
describe('Piece Loader IT: structural-reform piece', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should load structural-reform with 7 movements', () => {
const config = loadPiece('structural-reform', testDir);
expect(config).not.toBeNull();
expect(config!.name).toBe('structural-reform');
expect(config!.movements.length).toBe(7);
expect(config!.maxIterations).toBe(50);
expect(config!.initialMovement).toBe('review');
});
it('should have expected movement names in order', () => {
const config = loadPiece('structural-reform', testDir);
expect(config).not.toBeNull();
const movementNames = config!.movements.map((m) => m.name);
expect(movementNames).toEqual([
'review',
'plan_reform',
'implement',
'reviewers',
'fix',
'verify',
'next_target',
]);
});
it('should have review as read-only with instruction_template', () => {
const config = loadPiece('structural-reform', testDir);
expect(config).not.toBeNull();
const review = config!.movements.find((m) => m.name === 'review');
expect(review).toBeDefined();
expect(review!.edit).not.toBe(true);
expect(review!.instructionTemplate).toBeDefined();
expect(review!.instructionTemplate).toContain('{task}');
});
it('should have implement with edit: true and session: refresh', () => {
const config = loadPiece('structural-reform', testDir);
expect(config).not.toBeNull();
const implement = config!.movements.find((m) => m.name === 'implement');
expect(implement).toBeDefined();
expect(implement!.edit).toBe(true);
expect(implement!.session).toBe('refresh');
});
it('should have 2 parallel reviewers (arch-review and qa-review)', () => {
const config = loadPiece('structural-reform', testDir);
expect(config).not.toBeNull();
const reviewers = config!.movements.find(
(m) => m.parallel && m.parallel.length > 0,
);
expect(reviewers).toBeDefined();
expect(reviewers!.parallel!.length).toBe(2);
const subNames = reviewers!.parallel!.map((s) => s.name);
expect(subNames).toContain('arch-review');
expect(subNames).toContain('qa-review');
});
it('should have aggregate rules on reviewers movement', () => {
const config = loadPiece('structural-reform', testDir);
expect(config).not.toBeNull();
const reviewers = config!.movements.find(
(m) => m.parallel && m.parallel.length > 0,
);
expect(reviewers).toBeDefined();
const allRule = reviewers!.rules?.find(
(r) => r.isAggregateCondition && r.aggregateType === 'all',
);
expect(allRule).toBeDefined();
expect(allRule!.aggregateConditionText).toBe('approved');
expect(allRule!.next).toBe('verify');
const anyRule = reviewers!.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 verify movement with instruction_template', () => {
const config = loadPiece('structural-reform', testDir);
expect(config).not.toBeNull();
const verify = config!.movements.find((m) => m.name === 'verify');
expect(verify).toBeDefined();
expect(verify!.edit).not.toBe(true);
expect(verify!.instructionTemplate).toBeDefined();
});
it('should have next_target movement routing to implement or COMPLETE', () => {
const config = loadPiece('structural-reform', testDir);
expect(config).not.toBeNull();
const nextTarget = config!.movements.find((m) => m.name === 'next_target');
expect(nextTarget).toBeDefined();
expect(nextTarget!.edit).not.toBe(true);
const nextValues = nextTarget!.rules?.map((r) => r.next);
expect(nextValues).toContain('implement');
expect(nextValues).toContain('COMPLETE');
});
it('should have loop_monitors for implement-fix cycle', () => {
const config = loadPiece('structural-reform', testDir);
expect(config).not.toBeNull();
expect(config!.loopMonitors).toBeDefined();
expect(config!.loopMonitors!.length).toBe(1);
const monitor = config!.loopMonitors![0]!;
expect(monitor.cycle).toEqual(['implement', 'fix']);
expect(monitor.threshold).toBe(3);
expect(monitor.judge).toBeDefined();
});
});
describe('Piece Loader IT: invalid YAML handling', () => { describe('Piece Loader IT: invalid YAML handling', () => {
let testDir: string; let testDir: string;

View File

@ -131,9 +131,11 @@ describe('readMultilineInput cursor navigation', () => {
let savedStdinRemoveListener: typeof process.stdin.removeListener; let savedStdinRemoveListener: typeof process.stdin.removeListener;
let savedStdinResume: typeof process.stdin.resume; let savedStdinResume: typeof process.stdin.resume;
let savedStdinPause: typeof process.stdin.pause; let savedStdinPause: typeof process.stdin.pause;
let savedColumns: number | undefined;
let columnsOverridden = false;
let stdoutCalls: string[]; let stdoutCalls: string[];
function setupRawStdin(rawInputs: string[]): void { function setupRawStdin(rawInputs: string[], termColumns?: number): void {
savedIsTTY = process.stdin.isTTY; savedIsTTY = process.stdin.isTTY;
savedIsRaw = process.stdin.isRaw; savedIsRaw = process.stdin.isRaw;
savedSetRawMode = process.stdin.setRawMode; savedSetRawMode = process.stdin.setRawMode;
@ -142,6 +144,13 @@ describe('readMultilineInput cursor navigation', () => {
savedStdinRemoveListener = process.stdin.removeListener; savedStdinRemoveListener = process.stdin.removeListener;
savedStdinResume = process.stdin.resume; savedStdinResume = process.stdin.resume;
savedStdinPause = process.stdin.pause; savedStdinPause = process.stdin.pause;
savedColumns = process.stdout.columns;
columnsOverridden = false;
if (termColumns !== undefined) {
Object.defineProperty(process.stdout, 'columns', { value: termColumns, configurable: true, writable: true });
columnsOverridden = true;
}
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
Object.defineProperty(process.stdin, 'isRaw', { value: false, configurable: true, writable: true }); Object.defineProperty(process.stdin, 'isRaw', { value: false, configurable: true, writable: true });
@ -197,6 +206,10 @@ describe('readMultilineInput cursor navigation', () => {
if (savedStdinRemoveListener) process.stdin.removeListener = savedStdinRemoveListener; if (savedStdinRemoveListener) process.stdin.removeListener = savedStdinRemoveListener;
if (savedStdinResume) process.stdin.resume = savedStdinResume; if (savedStdinResume) process.stdin.resume = savedStdinResume;
if (savedStdinPause) process.stdin.pause = savedStdinPause; if (savedStdinPause) process.stdin.pause = savedStdinPause;
if (columnsOverridden) {
Object.defineProperty(process.stdout, 'columns', { value: savedColumns, configurable: true, writable: true });
columnsOverridden = false;
}
} }
beforeEach(() => { beforeEach(() => {
@ -611,4 +624,338 @@ describe('readMultilineInput cursor navigation', () => {
expect(result).toBe('abc\ndef\nghiX'); expect(result).toBe('abc\ndef\nghiX');
}); });
}); });
describe('soft-wrap: arrow up within wrapped line', () => {
it('should move to previous display row within same logical line', async () => {
// Given: termWidth=20, prompt "> " (2 cols), first display row = 18 chars, second = 20 chars
// Type 30 chars "abcdefghijklmnopqrstuvwxyz1234" → wraps at pos 18
// Display row 1: "abcdefghijklmnopqr" (18 chars, cols 3-20 with prompt)
// Display row 2: "stuvwxyz1234" (12 chars, cols 1-12)
// Cursor at end (pos 30, display col 12), press ↑ → display col 12 in row 1 → pos 12
// Insert "X" → "abcdefghijklXmnopqrstuvwxyz1234"
setupRawStdin([
'abcdefghijklmnopqrstuvwxyz1234\x1B[AX\r',
], 20);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcdefghijklXmnopqrstuvwxyz1234');
});
it('should do nothing when on first display row of first logical line', async () => {
// Given: termWidth=20, prompt "> " (2 cols), type "abcdefghij" (10 chars, fits in first row of 18 cols)
// Cursor at end (pos 10, first display row), press ↑ → no previous row, nothing happens
// Insert "X" → "abcdefghijX"
setupRawStdin([
'abcdefghij\x1B[AX\r',
], 20);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcdefghijX');
});
});
describe('soft-wrap: arrow down within wrapped line', () => {
it('should move to next display row within same logical line', async () => {
// Given: termWidth=20, prompt "> " (2 cols), first row = 18 chars
// Type 30 chars, Home → pos 0, then ↓ → display col 0 in row 2 → pos 18
// Insert "X" → "abcdefghijklmnopqrXstuvwxyz1234"
setupRawStdin([
'abcdefghijklmnopqrstuvwxyz1234\x1B[H\x1B[BX\r',
], 20);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcdefghijklmnopqrXstuvwxyz1234');
});
it('should do nothing when on last display row of last logical line', async () => {
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars (wraps into 2 display rows)
// Cursor at end (last display row), press ↓ → nothing happens
// Insert "X" → "abcdefghijklmnopqrstuvwxyz1234X"
setupRawStdin([
'abcdefghijklmnopqrstuvwxyz1234\x1B[BX\r',
], 20);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcdefghijklmnopqrstuvwxyz1234X');
});
});
describe('soft-wrap: Ctrl+A moves to display row start', () => {
it('should move to display row start on wrapped second row', async () => {
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars
// Cursor at end (pos 30), Ctrl+A → display row start (pos 18), insert "X"
// Result: "abcdefghijklmnopqrXstuvwxyz1234"
setupRawStdin([
'abcdefghijklmnopqrstuvwxyz1234\x01X\r',
], 20);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcdefghijklmnopqrXstuvwxyz1234');
});
it('should move to display row start on first row', async () => {
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars
// Move cursor to middle of first display row (Home, Right*5 → pos 5)
// Ctrl+A → pos 0, insert "X"
// Result: "Xabcdefghijklmnopqrstuvwxyz1234"
setupRawStdin([
'abcdefghijklmnopqrstuvwxyz1234\x1B[H\x1B[C\x1B[C\x1B[C\x1B[C\x1B[C\x01X\r',
], 20);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('Xabcdefghijklmnopqrstuvwxyz1234');
});
});
describe('soft-wrap: Ctrl+E moves to display row end', () => {
it('should move to display row end on first row', async () => {
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars
// Home → pos 0, Ctrl+E → end of first display row (pos 18), insert "X"
// Result: "abcdefghijklmnopqrXstuvwxyz1234"
setupRawStdin([
'abcdefghijklmnopqrstuvwxyz1234\x1B[H\x05X\r',
], 20);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcdefghijklmnopqrXstuvwxyz1234');
});
});
describe('soft-wrap: Home moves to logical line start', () => {
it('should move from wrapped second row to logical line start', async () => {
// Given: termWidth=20, prompt "> " (2 cols), first row = 18 chars
// Type 30 chars, cursor at end (pos 30, second display row)
// Home → logical line start (pos 0), insert "X"
// Result: "Xabcdefghijklmnopqrstuvwxyz1234"
setupRawStdin([
'abcdefghijklmnopqrstuvwxyz1234\x1B[HX\r',
], 20);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('Xabcdefghijklmnopqrstuvwxyz1234');
});
it('should emit cursor up sequence when crossing display rows', async () => {
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars (wraps into 2 rows)
// Cursor at end (second display row), Home → pos 0 (first display row)
setupRawStdin([
'abcdefghijklmnopqrstuvwxyz1234\x1B[H\r',
], 20);
// When
await callReadMultilineInput('> ');
// Then: should contain \x1B[{n}A for moving up display rows
const hasUpMove = stdoutCalls.some(c => /^\x1B\[\d+A$/.test(c));
expect(hasUpMove).toBe(true);
});
});
describe('soft-wrap: End moves to logical line end', () => {
it('should move from first display row to logical line end', async () => {
// Given: termWidth=20, prompt "> " (2 cols), first row = 18 chars
// Type 30 chars, Home → pos 0, End → logical line end (pos 30), insert "X"
// Result: "abcdefghijklmnopqrstuvwxyz1234X"
setupRawStdin([
'abcdefghijklmnopqrstuvwxyz1234\x1B[H\x1B[FX\r',
], 20);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcdefghijklmnopqrstuvwxyz1234X');
});
it('should emit cursor down sequence when crossing display rows', async () => {
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars (wraps into 2 rows)
// Home → pos 0 (first display row), End → pos 30 (second display row)
setupRawStdin([
'abcdefghijklmnopqrstuvwxyz1234\x1B[H\x1B[F\r',
], 20);
// When
await callReadMultilineInput('> ');
// Then: should contain \x1B[{n}B for moving down display rows
const hasDownMove = stdoutCalls.some(c => /^\x1B\[\d+B$/.test(c));
expect(hasDownMove).toBe(true);
});
it('should stay at end when already at logical line end on last display row', async () => {
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars
// Cursor at end (pos 30, already at logical line end), End → nothing changes, insert "X"
// Result: "abcdefghijklmnopqrstuvwxyz1234X"
setupRawStdin([
'abcdefghijklmnopqrstuvwxyz1234\x1B[FX\r',
], 20);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcdefghijklmnopqrstuvwxyz1234X');
});
});
describe('soft-wrap: non-wrapped text retains original behavior', () => {
it('should not affect arrow up on short single-line text', async () => {
// Given: termWidth=80, short text "abc" (no wrap), ↑ does nothing
setupRawStdin([
'abc\x1B[AX\r',
], 80);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcX');
});
it('should not affect arrow down on short single-line text', async () => {
// Given: termWidth=80, short text "abc" (no wrap), ↓ does nothing
setupRawStdin([
'abc\x1B[BX\r',
], 80);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcX');
});
it('should still navigate between logical lines with arrow up', async () => {
// Given: termWidth=80, "abcde\nfgh" (no wrap), cursor at end of "fgh", ↑ → "abcde" at col 3
setupRawStdin([
'abcde\x1B[13;2ufgh\x1B[AX\r',
], 80);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcXde\nfgh');
});
});
describe('soft-wrap: full-width characters', () => {
it('should calculate display row boundaries with full-width chars', async () => {
// Given: termWidth=10, prompt "> " (2 cols), first row available = 8 cols
// Type "あいうえ" (4 full-width chars = 8 display cols = fills first row exactly)
// Then type "お" (2 cols, starts second row)
// Cursor at end (after "お"), Ctrl+A → display row start (pos 4, start of "お")
// Insert "X"
// Result: "あいうえXお"
setupRawStdin([
'あいうえお\x01X\r',
], 10);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('あいうえXお');
});
it('should push full-width char to next row when only 1 column remains', async () => {
// Given: termWidth=10, prompt "> " (2 cols), first row available = 8 cols
// Type "abcdefg" (7 cols) then "あ" (2 cols) → 7+2=9 > 8, "あ" goes to row 2
// Cursor at end (after "あ"), Ctrl+A → display row start at "あ" (pos 7)
// Insert "X"
// Result: "abcdefgXあ"
setupRawStdin([
'abcdefgあ\x01X\r',
], 10);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcdefgXあ');
});
});
describe('soft-wrap: prompt width consideration', () => {
it('should account for prompt width in first display row', async () => {
// Given: termWidth=10, prompt "> " (2 cols), first row = 8 chars
// Type "12345678" (8 chars = fills first row) then "9" (starts row 2)
// Cursor at "9" (pos 9), ↑ → row 1 at display col 1, but only 8 chars available
// Display col 1 → pos 1
// Insert "X" → "1X234567890" ... wait, let me recalculate.
// Actually: cursor at end of "123456789" (pos 9, display col 1 in row 2)
// ↑ → display col 1 in row 1 → pos 1
// Insert "X" → "1X23456789"
setupRawStdin([
'123456789\x1B[AX\r',
], 10);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('1X23456789');
});
it('should not add prompt offset for second logical line', async () => {
// Given: termWidth=10, prompt "> " (2 cols)
// Type "ab\n123456789" → second logical line "123456789" (9 chars, fits in 10 col row)
// Cursor at end (pos 12), ↑ → "ab" at display col 9 → clamped to col 2 → pos 2 (end of "ab")
// Insert "X" → "abX\n123456789"
setupRawStdin([
'ab\x1B[13;2u123456789\x1B[AX\r',
], 10);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abX\n123456789');
});
});
describe('soft-wrap: cross logical line with display rows', () => {
it('should move from wrapped logical line to previous logical line last display row', async () => {
// Given: termWidth=20, prompt "> " (2 cols)
// Line 1: "abcdefghijklmnopqrstuvwx" (24 chars) → wraps: row 1 (18 chars) + row 2 (6 chars)
// Line 2: "123"
// Cursor at end of "123" (display col 3), ↑ → last display row of line 1 (row 2: "uvwx", 6 chars)
// Display col 3 → pos 21 ("v" position... let me calculate)
// Row 2 of line 1 starts at pos 18 ("stuvwx"), display col 3 → pos 21
// Insert "X" → "abcdefghijklmnopqrstuXvwx\n123"
setupRawStdin([
'abcdefghijklmnopqrstuvwx\x1B[13;2u123\x1B[AX\r',
], 20);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abcdefghijklmnopqrstuXvwx\n123');
});
});
}); });

View File

@ -67,6 +67,29 @@ describe('ParallelLogger', () => {
// No padding needed (0 spaces) // No padding needed (0 spaces)
expect(prefix).toMatch(/\x1b\[0m $/); expect(prefix).toMatch(/\x1b\[0m $/);
}); });
it('should build rich prefix with task and parent movement for parallel task mode', () => {
const logger = new ParallelLogger({
subMovementNames: ['arch-review'],
writeFn,
progressInfo: {
iteration: 4,
maxIterations: 30,
},
taskLabel: 'override-persona-provider',
taskColorIndex: 0,
parentMovementName: 'reviewers',
movementIteration: 1,
});
const prefix = logger.buildPrefix('arch-review', 0);
expect(prefix).toContain('\x1b[36m');
expect(prefix).toContain('[over]');
expect(prefix).toContain('[reviewers]');
expect(prefix).toContain('[arch-review]');
expect(prefix).toContain('(4/30)(1)');
expect(prefix).not.toContain('step 1/1');
});
}); });
describe('text event line buffering', () => { describe('text event line buffering', () => {

View File

@ -14,10 +14,12 @@ const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted((
class MockPieceEngine extends EE { class MockPieceEngine extends EE {
private config: PieceConfig; private config: PieceConfig;
private task: string;
constructor(config: PieceConfig, _cwd: string, _task: string, _options: unknown) { constructor(config: PieceConfig, _cwd: string, task: string, _options: unknown) {
super(); super();
this.config = config; this.config = config;
this.task = task;
} }
abort(): void {} abort(): void {}
@ -26,6 +28,7 @@ const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted((
const step = this.config.movements[0]!; const step = this.config.movements[0]!;
const timestamp = new Date('2026-02-07T00:00:00.000Z'); const timestamp = new Date('2026-02-07T00:00:00.000Z');
const shouldRepeatMovement = this.task === 'repeat-movement-task';
this.emit('movement:start', step, 1, 'movement instruction'); this.emit('movement:start', step, 1, 'movement instruction');
this.emit('phase:start', step, 1, 'execute', 'phase prompt'); this.emit('phase:start', step, 1, 'execute', 'phase prompt');
this.emit('phase:complete', step, 1, 'execute', 'phase response', 'done'); this.emit('phase:complete', step, 1, 'execute', 'phase response', 'done');
@ -40,9 +43,23 @@ const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted((
}, },
'movement instruction' 'movement instruction'
); );
if (shouldRepeatMovement) {
this.emit('movement:start', step, 2, 'movement instruction repeat');
this.emit(
'movement:complete',
step,
{
persona: step.personaDisplayName,
status: 'done',
content: 'movement response repeat',
timestamp,
},
'movement instruction repeat'
);
}
this.emit('piece:complete', { status: 'completed', iteration: 1 }); this.emit('piece:complete', { status: 'completed', iteration: 1 });
return { status: 'completed', iteration: 1 }; return { status: 'completed', iteration: shouldRepeatMovement ? 2 : 1 };
} }
} }
@ -187,4 +204,32 @@ describe('executePiece debug prompts logging', () => {
expect(mockWritePromptLog).not.toHaveBeenCalled(); expect(mockWritePromptLog).not.toHaveBeenCalled();
}); });
it('should update movement prefix context on each movement:start event', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
try {
await executePiece(makeConfig(), 'repeat-movement-task', '/tmp/project', {
projectCwd: '/tmp/project',
taskPrefix: 'override-persona-provider',
taskColorIndex: 0,
});
const output = stdoutSpy.mock.calls.map((call) => String(call[0])).join('');
const normalizedOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
expect(normalizedOutput).toContain('[over][implement](1/5)(1) [INFO] [1/5] implement (coder)');
expect(normalizedOutput).toContain('[over][implement](2/5)(2) [INFO] [2/5] implement (coder)');
} finally {
stdoutSpy.mockRestore();
}
});
it('should fail fast when taskPrefix is provided without taskColorIndex', async () => {
await expect(
executePiece(makeConfig(), 'task', '/tmp/project', {
projectCwd: '/tmp/project',
taskPrefix: 'override-persona-provider',
})
).rejects.toThrow('taskPrefix and taskColorIndex must be provided together');
});
}); });

View File

@ -563,3 +563,215 @@ movements:
expect(result.movementPreviews[0].personaDisplayName).toBe('Custom Agent Name'); expect(result.movementPreviews[0].personaDisplayName).toBe('Custom Agent Name');
}); });
}); });
describe('getPieceDescription interactiveMode field', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-test-interactive-mode-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('should return interactiveMode when piece defines interactive_mode', () => {
const pieceYaml = `name: test-mode
initial_movement: step1
max_iterations: 1
interactive_mode: quiet
movements:
- name: step1
persona: agent
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-mode.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.interactiveMode).toBe('quiet');
});
it('should return undefined interactiveMode when piece omits interactive_mode', () => {
const pieceYaml = `name: test-no-mode
initial_movement: step1
max_iterations: 1
movements:
- name: step1
persona: agent
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-no-mode.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.interactiveMode).toBeUndefined();
});
it('should return interactiveMode for each valid mode value', () => {
for (const mode of ['assistant', 'persona', 'quiet', 'passthrough'] as const) {
const pieceYaml = `name: test-${mode}
initial_movement: step1
max_iterations: 1
interactive_mode: ${mode}
movements:
- name: step1
persona: agent
instruction: "Do something"
`;
const piecePath = join(tempDir, `test-${mode}.yaml`);
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.interactiveMode).toBe(mode);
}
});
});
describe('getPieceDescription firstMovement field', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-test-first-movement-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('should return firstMovement with inline persona content', () => {
const pieceYaml = `name: test-first
initial_movement: plan
max_iterations: 1
movements:
- name: plan
persona: You are a planner.
persona_name: Planner
instruction: "Plan the task"
allowed_tools:
- Read
- Glob
`;
const piecePath = join(tempDir, 'test-first.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.firstMovement).toBeDefined();
expect(result.firstMovement!.personaContent).toBe('You are a planner.');
expect(result.firstMovement!.personaDisplayName).toBe('Planner');
expect(result.firstMovement!.allowedTools).toEqual(['Read', 'Glob']);
});
it('should return firstMovement with persona file content', () => {
const personaContent = '# Expert Planner\nYou plan tasks with precision.';
const personaPath = join(tempDir, 'planner-persona.md');
writeFileSync(personaPath, personaContent);
const pieceYaml = `name: test-persona-file
initial_movement: plan
max_iterations: 1
personas:
planner: ./planner-persona.md
movements:
- name: plan
persona: planner
persona_name: Planner
instruction: "Plan the task"
`;
const piecePath = join(tempDir, 'test-persona-file.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.firstMovement).toBeDefined();
expect(result.firstMovement!.personaContent).toBe(personaContent);
});
it('should return undefined firstMovement when initialMovement not found', () => {
const pieceYaml = `name: test-missing
initial_movement: nonexistent
max_iterations: 1
movements:
- name: step1
persona: agent
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-missing.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.firstMovement).toBeUndefined();
});
it('should return empty allowedTools array when movement has no tools', () => {
const pieceYaml = `name: test-no-tools
initial_movement: step1
max_iterations: 1
movements:
- name: step1
persona: agent
persona_name: Agent
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-no-tools.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.firstMovement).toBeDefined();
expect(result.firstMovement!.allowedTools).toEqual([]);
});
it('should fallback to inline persona when personaPath is unreadable', () => {
const personaPath = join(tempDir, 'unreadable.md');
writeFileSync(personaPath, '# Persona');
chmodSync(personaPath, 0o000);
const pieceYaml = `name: test-fallback
initial_movement: step1
max_iterations: 1
personas:
myagent: ./unreadable.md
movements:
- name: step1
persona: myagent
persona_name: Agent
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-fallback.yaml');
writeFileSync(piecePath, pieceYaml);
try {
const result = getPieceDescription(piecePath, tempDir);
expect(result.firstMovement).toBeDefined();
// personaPath is unreadable, so fallback to empty (persona was resolved to a path)
expect(result.firstMovement!.personaContent).toBe('');
} finally {
chmodSync(personaPath, 0o644);
}
});
});

View File

@ -14,6 +14,7 @@ vi.mock('../infra/config/index.js', () => ({
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
concurrency: 1, concurrency: 1,
taskPollIntervalMs: 500,
})), })),
})); }));
@ -142,6 +143,7 @@ describe('runAllTasks concurrency', () => {
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
concurrency: 1, concurrency: 1,
taskPollIntervalMs: 500,
}); });
}); });
@ -182,6 +184,7 @@ describe('runAllTasks concurrency', () => {
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
concurrency: 3, concurrency: 3,
taskPollIntervalMs: 500,
}); });
}); });
@ -209,13 +212,25 @@ describe('runAllTasks concurrency', () => {
.mockReturnValueOnce([task1, task2, task3]) .mockReturnValueOnce([task1, task2, task3])
.mockReturnValueOnce([]); .mockReturnValueOnce([]);
// In parallel mode, task start messages go through TaskPrefixWriter → process.stdout.write
const stdoutChunks: string[] = [];
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
stdoutChunks.push(String(chunk));
return true;
});
// When // When
await runAllTasks('/project'); await runAllTasks('/project');
writeSpy.mockRestore();
// Then: Task names displayed // Then: Task names displayed with prefix in stdout
expect(mockInfo).toHaveBeenCalledWith('=== Task: task-1 ==='); const allOutput = stdoutChunks.join('');
expect(mockInfo).toHaveBeenCalledWith('=== Task: task-2 ==='); expect(allOutput).toContain('[task]');
expect(mockInfo).toHaveBeenCalledWith('=== Task: task-3 ==='); expect(allOutput).toContain('=== Task: task-1 ===');
expect(allOutput).toContain('[task]');
expect(allOutput).toContain('=== Task: task-2 ===');
expect(allOutput).toContain('[task]');
expect(allOutput).toContain('=== Task: task-3 ===');
expect(mockStatus).toHaveBeenCalledWith('Total', '3'); expect(mockStatus).toHaveBeenCalledWith('Total', '3');
}); });
@ -245,6 +260,7 @@ describe('runAllTasks concurrency', () => {
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
concurrency: 1, concurrency: 1,
taskPollIntervalMs: 500,
}); });
const task1 = createTask('task-1'); const task1 = createTask('task-1');
@ -277,6 +293,7 @@ describe('runAllTasks concurrency', () => {
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
concurrency: 3, concurrency: 3,
taskPollIntervalMs: 500,
}); });
// Return a valid piece config so executeTask reaches executePiece // Return a valid piece config so executeTask reaches executePiece
mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never); mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never);
@ -323,6 +340,7 @@ describe('runAllTasks concurrency', () => {
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
concurrency: 2, concurrency: 2,
taskPollIntervalMs: 500,
}); });
const task1 = createTask('fast'); const task1 = createTask('fast');
@ -412,6 +430,7 @@ describe('runAllTasks concurrency', () => {
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
concurrency: 1, concurrency: 1,
taskPollIntervalMs: 500,
}); });
const task1 = createTask('sequential-task'); const task1 = createTask('sequential-task');

View File

@ -0,0 +1,203 @@
/**
* Tests for TaskPrefixWriter
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { TaskPrefixWriter } from '../shared/ui/TaskPrefixWriter.js';
describe('TaskPrefixWriter', () => {
let output: string[];
let writeFn: (text: string) => void;
beforeEach(() => {
output = [];
writeFn = (text: string) => output.push(text);
});
describe('constructor', () => {
it('should cycle colors for different colorIndex values', () => {
const writer0 = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 0, writeFn });
const writer4 = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 4, writeFn });
writer0.writeLine('hello');
writer4.writeLine('hello');
// Both index 0 and 4 should use cyan (\x1b[36m)
expect(output[0]).toContain('\x1b[36m');
expect(output[1]).toContain('\x1b[36m');
});
it('should assign correct colors in order', () => {
const writers = [0, 1, 2, 3].map(
(i) => new TaskPrefixWriter({ taskName: `t${i}`, colorIndex: i, writeFn }),
);
writers.forEach((w) => w.writeLine('x'));
expect(output[0]).toContain('\x1b[36m'); // cyan
expect(output[1]).toContain('\x1b[33m'); // yellow
expect(output[2]).toContain('\x1b[35m'); // magenta
expect(output[3]).toContain('\x1b[32m'); // green
});
});
describe('writeLine', () => {
it('should output single line with truncated task prefix', () => {
const writer = new TaskPrefixWriter({ taskName: 'my-task', colorIndex: 0, writeFn });
writer.writeLine('Hello World');
expect(output).toHaveLength(1);
expect(output[0]).toContain('[my-t]');
expect(output[0]).toContain('Hello World');
expect(output[0]).toMatch(/\n$/);
});
it('should output empty line as bare newline', () => {
const writer = new TaskPrefixWriter({ taskName: 'my-task', colorIndex: 0, writeFn });
writer.writeLine('');
expect(output).toHaveLength(1);
expect(output[0]).toBe('\n');
});
it('should split multi-line text and prefix each non-empty line', () => {
const writer = new TaskPrefixWriter({ taskName: 'my-task', colorIndex: 0, writeFn });
writer.writeLine('Line 1\nLine 2\n\nLine 4');
expect(output).toHaveLength(4);
expect(output[0]).toContain('Line 1');
expect(output[1]).toContain('Line 2');
expect(output[2]).toBe('\n'); // empty line
expect(output[3]).toContain('Line 4');
});
it('should strip ANSI codes from input text', () => {
const writer = new TaskPrefixWriter({ taskName: 'my-task', colorIndex: 0, writeFn });
writer.writeLine('\x1b[31mRed Text\x1b[0m');
expect(output).toHaveLength(1);
expect(output[0]).toContain('Red Text');
expect(output[0]).not.toContain('\x1b[31m');
});
});
describe('writeChunk (line buffering)', () => {
it('should buffer partial line and output on newline', () => {
const writer = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 0, writeFn });
writer.writeChunk('Hello');
expect(output).toHaveLength(0);
writer.writeChunk(' World\n');
expect(output).toHaveLength(1);
expect(output[0]).toContain('[task]');
expect(output[0]).toContain('Hello World');
});
it('should handle multiple lines in single chunk', () => {
const writer = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 0, writeFn });
writer.writeChunk('Line 1\nLine 2\n');
expect(output).toHaveLength(2);
expect(output[0]).toContain('Line 1');
expect(output[1]).toContain('Line 2');
});
it('should output empty line without prefix', () => {
const writer = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 0, writeFn });
writer.writeChunk('Hello\n\nWorld\n');
expect(output).toHaveLength(3);
expect(output[0]).toContain('Hello');
expect(output[1]).toBe('\n');
expect(output[2]).toContain('World');
});
it('should keep trailing partial in buffer', () => {
const writer = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 0, writeFn });
writer.writeChunk('Complete\nPartial');
expect(output).toHaveLength(1);
expect(output[0]).toContain('Complete');
writer.flush();
expect(output).toHaveLength(2);
expect(output[1]).toContain('Partial');
});
it('should strip ANSI codes from streamed chunks', () => {
const writer = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 0, writeFn });
writer.writeChunk('\x1b[31mHello');
writer.writeChunk(' World\x1b[0m\n');
expect(output).toHaveLength(1);
expect(output[0]).toContain('Hello World');
expect(output[0]).not.toContain('\x1b[31m');
});
});
describe('flush', () => {
it('should output remaining buffered content with prefix', () => {
const writer = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 0, writeFn });
writer.writeChunk('partial content');
expect(output).toHaveLength(0);
writer.flush();
expect(output).toHaveLength(1);
expect(output[0]).toContain('[task]');
expect(output[0]).toContain('partial content');
expect(output[0]).toMatch(/\n$/);
});
it('should not output anything when buffer is empty', () => {
const writer = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 0, writeFn });
writer.writeChunk('complete line\n');
output.length = 0;
writer.flush();
expect(output).toHaveLength(0);
});
it('should clear buffer after flush', () => {
const writer = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 0, writeFn });
writer.writeChunk('content');
writer.flush();
output.length = 0;
writer.flush();
expect(output).toHaveLength(0);
});
});
describe('setMovementContext', () => {
it('should include movement context in prefix after context update', () => {
const writer = new TaskPrefixWriter({ taskName: 'override-persona-provider', colorIndex: 0, writeFn });
writer.setMovementContext({
movementName: 'implement',
iteration: 4,
maxIterations: 30,
movementIteration: 2,
});
writer.writeLine('content');
expect(output).toHaveLength(1);
expect(output[0]).toContain('[over]');
expect(output[0]).toContain('[implement](4/30)(2)');
expect(output[0]).toContain('content');
});
});
});

View File

@ -23,6 +23,15 @@ vi.mock('../shared/i18n/index.js', () => ({
getLabel: vi.fn((key: string) => key), getLabel: vi.fn((key: string) => key),
})); }));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
const mockExecuteAndCompleteTask = vi.fn(); const mockExecuteAndCompleteTask = vi.fn();
vi.mock('../features/tasks/execute/taskExecution.js', () => ({ vi.mock('../features/tasks/execute/taskExecution.js', () => ({
@ -34,6 +43,8 @@ import { info } from '../shared/ui/index.js';
const mockInfo = vi.mocked(info); const mockInfo = vi.mocked(info);
const TEST_POLL_INTERVAL_MS = 50;
function createTask(name: string): TaskInfo { function createTask(name: string): TaskInfo {
return { return {
name, name,
@ -68,7 +79,7 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // When
const result = await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default'); const result = await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
// Then // Then
expect(result).toEqual({ success: 2, fail: 0 }); expect(result).toEqual({ success: 2, fail: 0 });
@ -85,23 +96,32 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // When
const result = await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default'); const result = await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
// Then // Then
expect(result).toEqual({ success: 2, fail: 1 }); expect(result).toEqual({ success: 2, fail: 1 });
}); });
it('should display task name for each task', async () => { it('should display task name for each task via prefix writer in parallel mode', async () => {
// Given // Given
const tasks = [createTask('alpha'), createTask('beta')]; const tasks = [createTask('alpha'), createTask('beta')];
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
const stdoutChunks: string[] = [];
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
stdoutChunks.push(String(chunk));
return true;
});
// When // When
await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default'); await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
// Then // Then: Task names appear in prefixed stdout output
expect(mockInfo).toHaveBeenCalledWith('=== Task: alpha ==='); writeSpy.mockRestore();
expect(mockInfo).toHaveBeenCalledWith('=== Task: beta ==='); const allOutput = stdoutChunks.join('');
expect(allOutput).toContain('[alph]');
expect(allOutput).toContain('=== Task: alpha ===');
expect(allOutput).toContain('[beta]');
expect(allOutput).toContain('=== Task: beta ===');
}); });
it('should pass taskPrefix for parallel execution (concurrency > 1)', async () => { it('should pass taskPrefix for parallel execution (concurrency > 1)', async () => {
@ -110,7 +130,7 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // When
await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default'); await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
// Then // Then
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
@ -118,6 +138,7 @@ describe('runWithWorkerPool', () => {
expect(parallelOpts).toEqual({ expect(parallelOpts).toEqual({
abortSignal: expect.any(AbortSignal), abortSignal: expect.any(AbortSignal),
taskPrefix: 'my-task', taskPrefix: 'my-task',
taskColorIndex: 0,
}); });
}); });
@ -127,7 +148,7 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // When
await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default'); await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
// Then // Then
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
@ -135,6 +156,7 @@ describe('runWithWorkerPool', () => {
expect(parallelOpts).toEqual({ expect(parallelOpts).toEqual({
abortSignal: undefined, abortSignal: undefined,
taskPrefix: undefined, taskPrefix: undefined,
taskColorIndex: undefined,
}); });
}); });
@ -145,7 +167,7 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([[task2]]); const runner = createMockTaskRunner([[task2]]);
// When // When
await runWithWorkerPool(runner as never, [task1], 2, '/cwd', 'default'); await runWithWorkerPool(runner as never, [task1], 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
// Then // Then
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(2); expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(2);
@ -173,7 +195,7 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // When
await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default'); await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
// Then: Never exceeded concurrency of 2 // Then: Never exceeded concurrency of 2
expect(maxActive).toBeLessThanOrEqual(2); expect(maxActive).toBeLessThanOrEqual(2);
@ -192,7 +214,7 @@ describe('runWithWorkerPool', () => {
}); });
// When // When
await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default'); await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
// Then: All tasks received the same AbortSignal // Then: All tasks received the same AbortSignal
expect(receivedSignals).toHaveLength(3); expect(receivedSignals).toHaveLength(3);
@ -208,7 +230,7 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // When
const result = await runWithWorkerPool(runner as never, [], 2, '/cwd', 'default'); const result = await runWithWorkerPool(runner as never, [], 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
// Then // Then
expect(result).toEqual({ success: 0, fail: 0 }); expect(result).toEqual({ success: 0, fail: 0 });
@ -222,9 +244,107 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // When
const result = await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default'); const result = await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
// Then: Treated as failure // Then: Treated as failure
expect(result).toEqual({ success: 0, fail: 1 }); expect(result).toEqual({ success: 0, fail: 1 });
}); });
describe('polling', () => {
it('should pick up tasks added during execution via polling', async () => {
// Given: 1 initial task running with concurrency=2, a second task appears via poll
const task1 = createTask('initial');
const task2 = createTask('added-later');
const executionOrder: string[] = [];
mockExecuteAndCompleteTask.mockImplementation((task: TaskInfo) => {
executionOrder.push(`start:${task.name}`);
return new Promise((resolve) => {
setTimeout(() => {
executionOrder.push(`end:${task.name}`);
resolve(true);
}, 80);
});
});
let claimCallCount = 0;
const runner = {
getNextTask: vi.fn(() => null),
claimNextTasks: vi.fn(() => {
claimCallCount++;
// Return the new task on the second call (triggered by polling)
if (claimCallCount === 2) return [task2];
return [];
}),
completeTask: vi.fn(),
failTask: vi.fn(),
};
// When: pollIntervalMs=30 so polling fires before task1 completes (80ms)
const result = await runWithWorkerPool(
runner as never, [task1], 2, '/cwd', 'default', undefined, 30,
);
// Then: Both tasks were executed
expect(result).toEqual({ success: 2, fail: 0 });
expect(executionOrder).toContain('start:initial');
expect(executionOrder).toContain('start:added-later');
// task2 started before task1 ended (picked up by polling, not by task completion)
const task2Start = executionOrder.indexOf('start:added-later');
const task1End = executionOrder.indexOf('end:initial');
expect(task2Start).toBeLessThan(task1End);
});
it('should work correctly with concurrency=1 (sequential behavior preserved)', async () => {
// Given: concurrency=1, tasks claimed sequentially
const task1 = createTask('seq-1');
const task2 = createTask('seq-2');
const executionOrder: string[] = [];
mockExecuteAndCompleteTask.mockImplementation((task: TaskInfo) => {
executionOrder.push(`start:${task.name}`);
return new Promise((resolve) => {
setTimeout(() => {
executionOrder.push(`end:${task.name}`);
resolve(true);
}, 20);
});
});
const runner = createMockTaskRunner([[task2]]);
// When
const result = await runWithWorkerPool(
runner as never, [task1], 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS,
);
// Then: Tasks executed sequentially — task2 starts after task1 ends
expect(result).toEqual({ success: 2, fail: 0 });
const task2Start = executionOrder.indexOf('start:seq-2');
const task1End = executionOrder.indexOf('end:seq-1');
expect(task2Start).toBeGreaterThan(task1End);
});
it('should not leak poll timer when task completes before poll fires', async () => {
// Given: A task that completes in 200ms, poll interval is 5000ms
const task1 = createTask('fast-task');
mockExecuteAndCompleteTask.mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => resolve(true), 200);
});
});
const runner = createMockTaskRunner([]);
// When: Task completes before poll timer fires; cancel() cleans up timer
const result = await runWithWorkerPool(
runner as never, [task1], 1, '/cwd', 'default', undefined, 5000,
);
// Then: Result is returned without hanging (timer was cleaned up by cancel())
expect(result).toEqual({ success: 1, fail: 0 });
});
});
}); });

View File

@ -100,6 +100,7 @@ export class AgentRunner {
): ProviderCallOptions { ): ProviderCallOptions {
return { return {
cwd: options.cwd, cwd: options.cwd,
abortSignal: options.abortSignal,
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools: options.allowedTools ?? agentConfig?.allowedTools, allowedTools: options.allowedTools ?? agentConfig?.allowedTools,
mcpServers: options.mcpServers, mcpServers: options.mcpServers,

View File

@ -10,6 +10,7 @@ export type { StreamCallback };
/** Common options for running agents */ /** Common options for running agents */
export interface RunAgentOptions { export interface RunAgentOptions {
cwd: string; cwd: string;
abortSignal?: AbortSignal;
sessionId?: string; sessionId?: string;
model?: string; model?: string;
provider?: 'claude' | 'codex' | 'mock'; provider?: 'claude' | 'codex' | 'mock';

View File

@ -7,10 +7,19 @@
import { info, error } from '../../shared/ui/index.js'; import { info, error } from '../../shared/ui/index.js';
import { getErrorMessage } from '../../shared/utils/index.js'; import { getErrorMessage } from '../../shared/utils/index.js';
import { getLabel } from '../../shared/i18n/index.js';
import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js'; import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js';
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
import { executePipeline } from '../../features/pipeline/index.js'; import { executePipeline } from '../../features/pipeline/index.js';
import { interactiveMode } from '../../features/interactive/index.js'; import {
interactiveMode,
selectInteractiveMode,
passthroughMode,
quietMode,
personaMode,
resolveLanguage,
type InteractiveModeResult,
} from '../../features/interactive/index.js';
import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js'; import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js';
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
import { program, resolvedCwd, pipelineMode } from './program.js'; import { program, resolvedCwd, pipelineMode } from './program.js';
@ -118,16 +127,57 @@ export async function executeDefaultAction(task?: string): Promise<void> {
} }
// All paths below go through interactive mode // All paths below go through interactive mode
const globalConfig = loadGlobalConfig();
const lang = resolveLanguage(globalConfig.language);
const pieceId = await determinePiece(resolvedCwd, selectOptions.piece); const pieceId = await determinePiece(resolvedCwd, selectOptions.piece);
if (pieceId === null) { if (pieceId === null) {
info('Cancelled'); info(getLabel('interactive.ui.cancelled', lang));
return; return;
} }
const globalConfig = loadGlobalConfig();
const previewCount = globalConfig.interactivePreviewMovements; const previewCount = globalConfig.interactivePreviewMovements;
const pieceContext = getPieceDescription(pieceId, resolvedCwd, previewCount); const pieceDesc = getPieceDescription(pieceId, resolvedCwd, previewCount);
const result = await interactiveMode(resolvedCwd, initialInput, pieceContext);
// Mode selection after piece selection
const selectedMode = await selectInteractiveMode(lang, pieceDesc.interactiveMode);
if (selectedMode === null) {
info(getLabel('interactive.ui.cancelled', lang));
return;
}
const pieceContext = {
name: pieceDesc.name,
description: pieceDesc.description,
pieceStructure: pieceDesc.pieceStructure,
movementPreviews: pieceDesc.movementPreviews,
};
let result: InteractiveModeResult;
switch (selectedMode) {
case 'assistant':
result = await interactiveMode(resolvedCwd, initialInput, pieceContext);
break;
case 'passthrough':
result = await passthroughMode(lang, initialInput);
break;
case 'quiet':
result = await quietMode(resolvedCwd, initialInput, pieceContext);
break;
case 'persona': {
if (!pieceDesc.firstMovement) {
info(getLabel('interactive.ui.personaFallback', lang));
result = await interactiveMode(resolvedCwd, initialInput, pieceContext);
} else {
result = await personaMode(resolvedCwd, pieceDesc.firstMovement, initialInput, pieceContext);
}
break;
}
}
switch (result.action) { switch (result.action) {
case 'execute': case 'execute':

View File

@ -61,6 +61,8 @@ export interface GlobalConfig {
bookmarksFile?: string; bookmarksFile?: string;
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
pieceCategoriesFile?: string; pieceCategoriesFile?: string;
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
personaProviders?: Record<string, 'claude' | 'codex' | 'mock'>;
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
branchNameStrategy?: 'romaji' | 'ai'; branchNameStrategy?: 'romaji' | 'ai';
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
@ -71,6 +73,8 @@ export interface GlobalConfig {
interactivePreviewMovements?: number; interactivePreviewMovements?: number;
/** Number of tasks to run concurrently in takt run (default: 1 = sequential) */ /** Number of tasks to run concurrently in takt run (default: 1 = sequential) */
concurrency: number; concurrency: number;
/** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */
taskPollIntervalMs: number;
} }
/** Project-level configuration */ /** Project-level configuration */

View File

@ -35,6 +35,9 @@ export * from './config.js';
// Re-export from schemas.ts // Re-export from schemas.ts
export * from './schemas.js'; export * from './schemas.js';
// Re-export from interactive-mode.ts
export { INTERACTIVE_MODES, DEFAULT_INTERACTIVE_MODE, type InteractiveMode } from './interactive-mode.js';
// Re-export from session.ts (functions only, not types) // Re-export from session.ts (functions only, not types)
export { export {
createSessionState, createSessionState,

View File

@ -0,0 +1,18 @@
/**
* Interactive mode variants for conversational task input.
*
* Defines the four modes available when using interactive mode:
* - assistant: Asks clarifying questions before generating instructions (default)
* - persona: Uses the first movement's persona for conversation
* - quiet: Generates instructions without asking questions (best-effort)
* - passthrough: Passes user input directly as task text
*/
/** Available interactive mode variants */
export const INTERACTIVE_MODES = ['assistant', 'persona', 'quiet', 'passthrough'] as const;
/** Interactive mode type */
export type InteractiveMode = typeof INTERACTIVE_MODES[number];
/** Default interactive mode */
export const DEFAULT_INTERACTIVE_MODE: InteractiveMode = 'assistant';

View File

@ -4,6 +4,7 @@
import type { PermissionMode } from './status.js'; import type { PermissionMode } from './status.js';
import type { AgentResponse } from './response.js'; import type { AgentResponse } from './response.js';
import type { InteractiveMode } from './interactive-mode.js';
/** Rule-based transition configuration (unified format) */ /** Rule-based transition configuration (unified format) */
export interface PieceRule { export interface PieceRule {
@ -184,6 +185,8 @@ export interface PieceConfig {
* instead of prompting the user interactively. * instead of prompting the user interactively.
*/ */
answerAgent?: string; answerAgent?: string;
/** Default interactive mode for this piece (overrides user default) */
interactiveMode?: InteractiveMode;
} }
/** Runtime state of a piece execution */ /** Runtime state of a piece execution */

View File

@ -7,6 +7,7 @@
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import { DEFAULT_LANGUAGE } from '../../shared/constants.js'; import { DEFAULT_LANGUAGE } from '../../shared/constants.js';
import { McpServersSchema } from './mcp-schemas.js'; import { McpServersSchema } from './mcp-schemas.js';
import { INTERACTIVE_MODES } from './interactive-mode.js';
export { McpServerConfigSchema, McpServersSchema } from './mcp-schemas.js'; export { McpServerConfigSchema, McpServersSchema } from './mcp-schemas.js';
@ -218,6 +219,9 @@ export const LoopMonitorSchema = z.object({
judge: LoopMonitorJudgeSchema, judge: LoopMonitorJudgeSchema,
}); });
/** Interactive mode schema for piece-level default */
export const InteractiveModeSchema = z.enum(INTERACTIVE_MODES);
/** Piece configuration schema - raw YAML format */ /** Piece configuration schema - raw YAML format */
export const PieceConfigRawSchema = z.object({ export const PieceConfigRawSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
@ -237,6 +241,8 @@ export const PieceConfigRawSchema = z.object({
max_iterations: z.number().int().positive().optional().default(10), max_iterations: z.number().int().positive().optional().default(10),
loop_monitors: z.array(LoopMonitorSchema).optional(), loop_monitors: z.array(LoopMonitorSchema).optional(),
answer_agent: z.string().optional(), answer_agent: z.string().optional(),
/** Default interactive mode for this piece (overrides user default) */
interactive_mode: InteractiveModeSchema.optional(),
}); });
/** Custom agent configuration schema */ /** Custom agent configuration schema */
@ -312,6 +318,8 @@ export const GlobalConfigSchema = z.object({
bookmarks_file: z.string().optional(), bookmarks_file: z.string().optional(),
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
piece_categories_file: z.string().optional(), piece_categories_file: z.string().optional(),
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'mock'])).optional(),
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
branch_name_strategy: z.enum(['romaji', 'ai']).optional(), branch_name_strategy: z.enum(['romaji', 'ai']).optional(),
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
@ -322,6 +330,8 @@ export const GlobalConfigSchema = z.object({
interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3), interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3),
/** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */ /** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */
concurrency: z.number().int().min(1).max(10).optional().default(1), concurrency: z.number().int().min(1).max(10).optional().default(1),
/** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */
task_poll_interval_ms: z.number().int().min(100).max(5000).optional().default(500),
}); });
/** Project config schema */ /** Project config schema */

View File

@ -33,8 +33,9 @@ export class OptionsBuilder {
return { return {
cwd: this.getCwd(), cwd: this.getCwd(),
abortSignal: this.engineOptions.abortSignal,
personaPath: step.personaPath, personaPath: step.personaPath,
provider: step.provider ?? this.engineOptions.provider, provider: step.provider ?? this.engineOptions.personaProviders?.[step.personaDisplayName] ?? this.engineOptions.provider,
model: step.model ?? this.engineOptions.model, model: step.model ?? this.engineOptions.model,
permissionMode: step.permissionMode, permissionMode: step.permissionMode,
language: this.getLanguage(), language: this.getLanguage(),

View File

@ -20,6 +20,7 @@ import { buildSessionKey } from '../session-key.js';
import type { OptionsBuilder } from './OptionsBuilder.js'; import type { OptionsBuilder } from './OptionsBuilder.js';
import type { MovementExecutor } from './MovementExecutor.js'; import type { MovementExecutor } from './MovementExecutor.js';
import type { PieceEngineOptions, PhaseName } from '../types.js'; import type { PieceEngineOptions, PhaseName } from '../types.js';
import type { ParallelLoggerOptions } from './parallel-logger.js';
const log = createLogger('parallel-runner'); const log = createLogger('parallel-runner');
@ -69,14 +70,7 @@ export class ParallelRunner {
// Create parallel logger for prefixed output (only when streaming is enabled) // Create parallel logger for prefixed output (only when streaming is enabled)
const parallelLogger = this.deps.engineOptions.onStream const parallelLogger = this.deps.engineOptions.onStream
? new ParallelLogger({ ? new ParallelLogger(this.buildParallelLoggerOptions(step.name, movementIteration, subMovements.map((s) => s.name), state.iteration, maxIterations))
subMovementNames: subMovements.map((s) => s.name),
parentOnStream: this.deps.engineOptions.onStream,
progressInfo: {
iteration: state.iteration,
maxIterations,
},
})
: undefined; : undefined;
const ruleCtx = { const ruleCtx = {
@ -202,4 +196,33 @@ export class ParallelRunner {
return { response: aggregatedResponse, instruction: aggregatedInstruction }; return { response: aggregatedResponse, instruction: aggregatedInstruction };
} }
private buildParallelLoggerOptions(
movementName: string,
movementIteration: number,
subMovementNames: string[],
iteration: number,
maxIterations: number,
): ParallelLoggerOptions {
const options: ParallelLoggerOptions = {
subMovementNames,
parentOnStream: this.deps.engineOptions.onStream,
progressInfo: {
iteration,
maxIterations,
},
};
if (this.deps.engineOptions.taskPrefix != null && this.deps.engineOptions.taskColorIndex != null) {
return {
...options,
taskLabel: this.deps.engineOptions.taskPrefix,
taskColorIndex: this.deps.engineOptions.taskColorIndex,
parentMovementName: movementName,
movementIteration,
};
}
return options;
}
} }

View File

@ -69,6 +69,7 @@ export class PieceEngine extends EventEmitter {
constructor(config: PieceConfig, cwd: string, task: string, options: PieceEngineOptions) { constructor(config: PieceConfig, cwd: string, task: string, options: PieceEngineOptions) {
super(); super();
this.assertTaskPrefixPair(options.taskPrefix, options.taskColorIndex);
this.config = config; this.config = config;
this.projectCwd = options.projectCwd; this.projectCwd = options.projectCwd;
this.cwd = cwd; this.cwd = cwd;
@ -146,6 +147,14 @@ export class PieceEngine extends EventEmitter {
}); });
} }
private assertTaskPrefixPair(taskPrefix: string | undefined, taskColorIndex: number | undefined): void {
const hasTaskPrefix = taskPrefix != null;
const hasTaskColorIndex = taskColorIndex != null;
if (hasTaskPrefix !== hasTaskColorIndex) {
throw new Error('taskPrefix and taskColorIndex must be provided together');
}
}
/** Ensure report directory exists (in cwd, which is clone dir in worktree mode) */ /** Ensure report directory exists (in cwd, which is clone dir in worktree mode) */
private ensureReportDirExists(): void { private ensureReportDirExists(): void {
const reportDirPath = join(this.cwd, this.reportDir); const reportDirPath = join(this.cwd, this.reportDir);

View File

@ -30,6 +30,14 @@ export interface ParallelLoggerOptions {
writeFn?: (text: string) => void; writeFn?: (text: string) => void;
/** Progress information for display */ /** Progress information for display */
progressInfo?: ParallelProgressInfo; progressInfo?: ParallelProgressInfo;
/** Task label for rich parallel prefix display */
taskLabel?: string;
/** Task color index for rich parallel prefix display */
taskColorIndex?: number;
/** Parent movement name for rich parallel prefix display */
parentMovementName?: string;
/** Parent movement iteration count for rich parallel prefix display */
movementIteration?: number;
} }
/** /**
@ -47,6 +55,10 @@ export class ParallelLogger {
private readonly writeFn: (text: string) => void; private readonly writeFn: (text: string) => void;
private readonly progressInfo?: ParallelProgressInfo; private readonly progressInfo?: ParallelProgressInfo;
private readonly totalSubMovements: number; private readonly totalSubMovements: number;
private readonly taskLabel?: string;
private readonly taskColorIndex?: number;
private readonly parentMovementName?: string;
private readonly movementIteration?: number;
constructor(options: ParallelLoggerOptions) { constructor(options: ParallelLoggerOptions) {
this.maxNameLength = Math.max(...options.subMovementNames.map((n) => n.length)); this.maxNameLength = Math.max(...options.subMovementNames.map((n) => n.length));
@ -54,6 +66,10 @@ export class ParallelLogger {
this.writeFn = options.writeFn ?? ((text: string) => process.stdout.write(text)); this.writeFn = options.writeFn ?? ((text: string) => process.stdout.write(text));
this.progressInfo = options.progressInfo; this.progressInfo = options.progressInfo;
this.totalSubMovements = options.subMovementNames.length; this.totalSubMovements = options.subMovementNames.length;
this.taskLabel = options.taskLabel ? options.taskLabel.slice(0, 4) : undefined;
this.taskColorIndex = options.taskColorIndex;
this.parentMovementName = options.parentMovementName;
this.movementIteration = options.movementIteration;
for (const name of options.subMovementNames) { for (const name of options.subMovementNames) {
this.lineBuffers.set(name, ''); this.lineBuffers.set(name, '');
@ -65,6 +81,12 @@ export class ParallelLogger {
* Format: `\x1b[COLORm[name](iteration/max) step index/total\x1b[0m` + padding spaces * Format: `\x1b[COLORm[name](iteration/max) step index/total\x1b[0m` + padding spaces
*/ */
buildPrefix(name: string, index: number): string { buildPrefix(name: string, index: number): string {
if (this.taskLabel && this.parentMovementName && this.progressInfo && this.movementIteration != null && this.taskColorIndex != null) {
const taskColor = COLORS[this.taskColorIndex % COLORS.length];
const { iteration, maxIterations } = this.progressInfo;
return `${taskColor}[${this.taskLabel}]${RESET}[${this.parentMovementName}][${name}](${iteration}/${maxIterations})(${this.movementIteration}) `;
}
const color = COLORS[index % COLORS.length]; const color = COLORS[index % COLORS.length];
const padding = ' '.repeat(this.maxNameLength - name.length); const padding = ' '.repeat(this.maxNameLength - name.length);

View File

@ -153,6 +153,7 @@ export type IterationLimitCallback = (request: IterationLimitRequest) => Promise
/** Options for piece engine */ /** Options for piece engine */
export interface PieceEngineOptions { export interface PieceEngineOptions {
abortSignal?: AbortSignal;
/** Callback for streaming real-time output */ /** Callback for streaming real-time output */
onStream?: StreamCallback; onStream?: StreamCallback;
/** Callback for requesting user input when an agent is blocked */ /** Callback for requesting user input when an agent is blocked */
@ -177,6 +178,8 @@ export interface PieceEngineOptions {
language?: Language; language?: Language;
provider?: ProviderType; provider?: ProviderType;
model?: string; model?: string;
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
personaProviders?: Record<string, ProviderType>;
/** Enable interactive-only rules and user-input transitions */ /** Enable interactive-only rules and user-input transitions */
interactive?: boolean; interactive?: boolean;
/** Rule tag index detector (required for rules evaluation) */ /** Rule tag index detector (required for rules evaluation) */
@ -187,6 +190,10 @@ export interface PieceEngineOptions {
startMovement?: string; startMovement?: string;
/** Retry note explaining why task is being retried */ /** Retry note explaining why task is being retried */
retryNote?: string; retryNote?: string;
/** Task name prefix for parallel task execution output */
taskPrefix?: string;
/** Color index for task prefix (cycled across tasks) */
taskColorIndex?: number;
} }
/** Loop detection result */ /** Loop detection result */

View File

@ -0,0 +1,300 @@
/**
* Shared conversation loop for interactive modes (assistant & persona).
*
* Extracts the common patterns:
* - Provider/session initialization
* - AI call with retry on stale session
* - Session state display/clear
* - Conversation loop (slash commands, AI messaging, /go summary)
*/
import chalk from 'chalk';
import {
loadGlobalConfig,
loadPersonaSessions,
updatePersonaSession,
loadSessionState,
clearSessionState,
} from '../../infra/config/index.js';
import { isQuietMode } from '../../shared/context.js';
import { getProvider, type ProviderType } from '../../infra/providers/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
import { readMultilineInput } from './lineEditor.js';
import {
type PieceContext,
type InteractiveModeResult,
type InteractiveUIText,
type ConversationMessage,
resolveLanguage,
buildSummaryPrompt,
selectPostSummaryAction,
formatSessionStatus,
} from './interactive.js';
const log = createLogger('conversation-loop');
/** Result from a single AI call */
export interface CallAIResult {
content: string;
sessionId?: string;
success: boolean;
}
/** Initialized session context for conversation loops */
export interface SessionContext {
provider: ReturnType<typeof getProvider>;
providerType: ProviderType;
model: string | undefined;
lang: 'en' | 'ja';
personaName: string;
sessionId: string | undefined;
}
/**
* Initialize provider, session, and language for interactive conversation.
*/
export function initializeSession(cwd: string, personaName: string): SessionContext {
const globalConfig = loadGlobalConfig();
const lang = resolveLanguage(globalConfig.language);
if (!globalConfig.provider) {
throw new Error('Provider is not configured.');
}
const providerType = globalConfig.provider as ProviderType;
const provider = getProvider(providerType);
const model = globalConfig.model as string | undefined;
const savedSessions = loadPersonaSessions(cwd, providerType);
const sessionId: string | undefined = savedSessions[personaName];
return { provider, providerType, model, lang, personaName, sessionId };
}
/**
* Display and clear previous session state if present.
*/
export function displayAndClearSessionState(cwd: string, lang: 'en' | 'ja'): void {
const sessionState = loadSessionState(cwd);
if (sessionState) {
const statusLabel = formatSessionStatus(sessionState, lang);
info(statusLabel);
blankLine();
clearSessionState(cwd);
}
}
/**
* Call AI with automatic retry on stale/invalid session.
*
* On session failure, clears sessionId and retries once without session.
* Updates sessionId and persists it on success.
*/
export async function callAIWithRetry(
prompt: string,
systemPrompt: string,
allowedTools: string[],
cwd: string,
ctx: SessionContext,
): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> {
const display = new StreamDisplay('assistant', isQuietMode());
let { sessionId } = ctx;
try {
const agent = ctx.provider.setup({ name: ctx.personaName, systemPrompt });
const response = await agent.call(prompt, {
cwd,
model: ctx.model,
sessionId,
allowedTools,
onStream: display.createHandler(),
});
display.flush();
const success = response.status !== 'blocked';
if (!success && sessionId) {
log.info('Session invalid, retrying without session');
sessionId = undefined;
const retryDisplay = new StreamDisplay('assistant', isQuietMode());
const retryAgent = ctx.provider.setup({ name: ctx.personaName, systemPrompt });
const retry = await retryAgent.call(prompt, {
cwd,
model: ctx.model,
sessionId: undefined,
allowedTools,
onStream: retryDisplay.createHandler(),
});
retryDisplay.flush();
if (retry.sessionId) {
sessionId = retry.sessionId;
updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType);
}
return {
result: { content: retry.content, sessionId: retry.sessionId, success: retry.status !== 'blocked' },
sessionId,
};
}
if (response.sessionId) {
sessionId = response.sessionId;
updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType);
}
return {
result: { content: response.content, sessionId: response.sessionId, success },
sessionId,
};
} catch (e) {
const msg = getErrorMessage(e);
log.error('AI call failed', { error: msg });
error(msg);
blankLine();
return { result: null, sessionId };
}
}
/** Strategy for customizing conversation loop behavior */
export interface ConversationStrategy {
/** System prompt for AI calls */
systemPrompt: string;
/** Allowed tools for AI calls */
allowedTools: string[];
/** Transform user message before sending to AI (e.g., policy injection) */
transformPrompt: (userMessage: string) => string;
/** Intro message displayed at start */
introMessage: string;
}
/**
* Run the shared conversation loop.
*
* Handles: EOF, /play, /go (summary), /cancel, regular AI messaging.
* The Strategy object controls system prompt, tool access, and prompt transformation.
*/
export async function runConversationLoop(
cwd: string,
ctx: SessionContext,
strategy: ConversationStrategy,
pieceContext: PieceContext | undefined,
initialInput: string | undefined,
): Promise<InteractiveModeResult> {
const history: ConversationMessage[] = [];
let sessionId = ctx.sessionId;
const ui = getLabelObject<InteractiveUIText>('interactive.ui', ctx.lang);
const conversationLabel = getLabel('interactive.conversationLabel', ctx.lang);
const noTranscript = getLabel('interactive.noTranscript', ctx.lang);
info(strategy.introMessage);
if (sessionId) {
info(ui.resume);
}
blankLine();
/** Helper: call AI with current session and update session state */
async function doCallAI(prompt: string, sysPrompt: string, tools: string[]): Promise<CallAIResult | null> {
const { result, sessionId: newSessionId } = await callAIWithRetry(
prompt, sysPrompt, tools, cwd, { ...ctx, sessionId },
);
sessionId = newSessionId;
return result;
}
if (initialInput) {
history.push({ role: 'user', content: initialInput });
log.debug('Processing initial input', { initialInput, sessionId });
const promptWithTransform = strategy.transformPrompt(initialInput);
const result = await doCallAI(promptWithTransform, strategy.systemPrompt, strategy.allowedTools);
if (result) {
if (!result.success) {
error(result.content);
blankLine();
return { action: 'cancel', task: '' };
}
history.push({ role: 'assistant', content: result.content });
blankLine();
} else {
history.pop();
}
}
while (true) {
const input = await readMultilineInput(chalk.green('> '));
if (input === null) {
blankLine();
info(ui.cancelled);
return { action: 'cancel', task: '' };
}
const trimmed = input.trim();
if (!trimmed) {
continue;
}
if (trimmed.startsWith('/play')) {
const task = trimmed.slice(5).trim();
if (!task) {
info(ui.playNoTask);
continue;
}
log.info('Play command', { task });
return { action: 'execute', task };
}
if (trimmed.startsWith('/go')) {
const userNote = trimmed.slice(3).trim();
let summaryPrompt = buildSummaryPrompt(
history, !!sessionId, ctx.lang, noTranscript, conversationLabel, pieceContext,
);
if (!summaryPrompt) {
info(ui.noConversation);
continue;
}
if (userNote) {
summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`;
}
const summaryResult = await doCallAI(summaryPrompt, summaryPrompt, strategy.allowedTools);
if (!summaryResult) {
info(ui.summarizeFailed);
continue;
}
if (!summaryResult.success) {
error(summaryResult.content);
blankLine();
return { action: 'cancel', task: '' };
}
const task = summaryResult.content.trim();
const selectedAction = await selectPostSummaryAction(task, ui.proposed, ui);
if (selectedAction === 'continue' || selectedAction === null) {
info(ui.continuePrompt);
continue;
}
log.info('Conversation action selected', { action: selectedAction, messageCount: history.length });
return { action: selectedAction, task };
}
if (trimmed === '/cancel') {
info(ui.cancelled);
return { action: 'cancel', task: '' };
}
history.push({ role: 'user', content: trimmed });
log.debug('Sending to AI', { messageCount: history.length, sessionId });
process.stdin.pause();
const promptWithTransform = strategy.transformPrompt(trimmed);
const result = await doCallAI(promptWithTransform, strategy.systemPrompt, strategy.allowedTools);
if (result) {
if (!result.success) {
error(result.content);
blankLine();
history.pop();
return { action: 'cancel', task: '' };
}
history.push({ role: 'assistant', content: result.content });
blankLine();
} else {
history.pop();
}
}
}

View File

@ -4,7 +4,17 @@
export { export {
interactiveMode, interactiveMode,
resolveLanguage,
buildSummaryPrompt,
selectPostSummaryAction,
formatMovementPreviews,
formatSessionStatus,
type PieceContext, type PieceContext,
type InteractiveModeResult, type InteractiveModeResult,
type InteractiveModeAction, type InteractiveModeAction,
} from './interactive.js'; } from './interactive.js';
export { selectInteractiveMode } from './modeSelection.js';
export { passthroughMode } from './passthroughMode.js';
export { quietMode } from './quietMode.js';
export { personaMode } from './personaMode.js';

View File

@ -10,29 +10,23 @@
* /cancel - Cancel and exit * /cancel - Cancel and exit
*/ */
import chalk from 'chalk';
import type { Language } from '../../core/models/index.js'; import type { Language } from '../../core/models/index.js';
import { import {
loadGlobalConfig,
loadPersonaSessions,
updatePersonaSession,
loadSessionState,
clearSessionState,
type SessionState, type SessionState,
type MovementPreview, type MovementPreview,
} from '../../infra/config/index.js'; } from '../../infra/config/index.js';
import { isQuietMode } from '../../shared/context.js';
import { getProvider, type ProviderType } from '../../infra/providers/index.js';
import { selectOption } from '../../shared/prompt/index.js'; import { selectOption } from '../../shared/prompt/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { info, blankLine } from '../../shared/ui/index.js';
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
import { loadTemplate } from '../../shared/prompts/index.js'; import { loadTemplate } from '../../shared/prompts/index.js';
import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
import { readMultilineInput } from './lineEditor.js'; import {
const log = createLogger('interactive'); initializeSession,
displayAndClearSessionState,
runConversationLoop,
} from './conversationLoop.js';
/** Shape of interactive UI text */ /** Shape of interactive UI text */
interface InteractiveUIText { export interface InteractiveUIText {
intro: string; intro: string;
resume: string; resume: string;
noConversation: string; noConversation: string;
@ -53,7 +47,7 @@ interface InteractiveUIText {
/** /**
* Format session state for display * Format session state for display
*/ */
function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): string { export function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): string {
const lines: string[] = []; const lines: string[] = [];
// Status line // Status line
@ -87,7 +81,7 @@ function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): string {
return lines.join('\n'); return lines.join('\n');
} }
function resolveLanguage(lang?: Language): 'en' | 'ja' { export function resolveLanguage(lang?: Language): 'en' | 'ja' {
return lang === 'ja' ? 'ja' : 'en'; return lang === 'ja' ? 'ja' : 'en';
} }
@ -122,37 +116,11 @@ export function formatMovementPreviews(previews: MovementPreview[], lang: 'en' |
}).join('\n\n'); }).join('\n\n');
} }
function getInteractivePrompts(lang: 'en' | 'ja', pieceContext?: PieceContext) { export interface ConversationMessage {
const hasPreview = !!pieceContext?.movementPreviews?.length;
const systemPrompt = loadTemplate('score_interactive_system_prompt', lang, {
hasPiecePreview: hasPreview,
pieceStructure: pieceContext?.pieceStructure ?? '',
movementDetails: hasPreview ? formatMovementPreviews(pieceContext!.movementPreviews!, lang) : '',
});
const policyContent = loadTemplate('score_interactive_policy', lang, {});
return {
systemPrompt,
policyContent,
lang,
pieceContext,
conversationLabel: getLabel('interactive.conversationLabel', lang),
noTranscript: getLabel('interactive.noTranscript', lang),
ui: getLabelObject<InteractiveUIText>('interactive.ui', lang),
};
}
interface ConversationMessage {
role: 'user' | 'assistant'; role: 'user' | 'assistant';
content: string; content: string;
} }
interface CallAIResult {
content: string;
sessionId?: string;
success: boolean;
}
/** /**
* Build the final task description from conversation history for executeTask. * Build the final task description from conversation history for executeTask.
*/ */
@ -167,7 +135,7 @@ function buildTaskFromHistory(history: ConversationMessage[]): string {
* Renders the complete score_summary_system_prompt template with conversation data. * Renders the complete score_summary_system_prompt template with conversation data.
* Returns empty string if there is no conversation to summarize. * Returns empty string if there is no conversation to summarize.
*/ */
function buildSummaryPrompt( export function buildSummaryPrompt(
history: ConversationMessage[], history: ConversationMessage[],
hasSession: boolean, hasSession: boolean,
lang: 'en' | 'ja', lang: 'en' | 'ja',
@ -199,9 +167,9 @@ function buildSummaryPrompt(
}); });
} }
type PostSummaryAction = InteractiveModeAction | 'continue'; export type PostSummaryAction = InteractiveModeAction | 'continue';
async function selectPostSummaryAction( export async function selectPostSummaryAction(
task: string, task: string,
proposedLabel: string, proposedLabel: string,
ui: InteractiveUIText, ui: InteractiveUIText,
@ -218,34 +186,6 @@ async function selectPostSummaryAction(
]); ]);
} }
/**
* Call AI with the same pattern as piece execution.
* The key requirement is passing onStream the Agent SDK requires
* includePartialMessages to be true for the async iterator to yield.
*/
async function callAI(
provider: ReturnType<typeof getProvider>,
prompt: string,
cwd: string,
model: string | undefined,
sessionId: string | undefined,
display: StreamDisplay,
systemPrompt: string,
): Promise<CallAIResult> {
const agent = provider.setup({ name: 'interactive', systemPrompt });
const response = await agent.call(prompt, {
cwd,
model,
sessionId,
allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
onStream: display.createHandler(),
});
display.flush();
const success = response.status !== 'blocked';
return { content: response.content, sessionId: response.sessionId, success };
}
export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel'; export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel';
export interface InteractiveModeResult { export interface InteractiveModeResult {
@ -266,6 +206,8 @@ export interface PieceContext {
movementPreviews?: MovementPreview[]; movementPreviews?: MovementPreview[];
} }
export const DEFAULT_INTERACTIVE_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
/** /**
* Run the interactive task input mode. * Run the interactive task input mode.
* *
@ -280,206 +222,37 @@ export async function interactiveMode(
initialInput?: string, initialInput?: string,
pieceContext?: PieceContext, pieceContext?: PieceContext,
): Promise<InteractiveModeResult> { ): Promise<InteractiveModeResult> {
const globalConfig = loadGlobalConfig(); const ctx = initializeSession(cwd, 'interactive');
const lang = resolveLanguage(globalConfig.language);
const prompts = getInteractivePrompts(lang, pieceContext);
if (!globalConfig.provider) {
throw new Error('Provider is not configured.');
}
const providerType = globalConfig.provider as ProviderType;
const provider = getProvider(providerType);
const model = (globalConfig.model as string | undefined);
const history: ConversationMessage[] = []; displayAndClearSessionState(cwd, ctx.lang);
const personaName = 'interactive';
const savedSessions = loadPersonaSessions(cwd, providerType);
let sessionId: string | undefined = savedSessions[personaName];
// Load and display previous task state const hasPreview = !!pieceContext?.movementPreviews?.length;
const sessionState = loadSessionState(cwd); const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, {
if (sessionState) { hasPiecePreview: hasPreview,
const statusLabel = formatSessionStatus(sessionState, lang); pieceStructure: pieceContext?.pieceStructure ?? '',
info(statusLabel); movementDetails: hasPreview ? formatMovementPreviews(pieceContext!.movementPreviews!, ctx.lang) : '',
blankLine(); });
clearSessionState(cwd); const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {});
} const ui = getLabelObject<InteractiveUIText>('interactive.ui', ctx.lang);
info(prompts.ui.intro);
if (sessionId) {
info(prompts.ui.resume);
}
blankLine();
/** Call AI with automatic retry on session error (stale/invalid session ID). */
async function callAIWithRetry(prompt: string, systemPrompt: string): Promise<CallAIResult | null> {
const display = new StreamDisplay('assistant', isQuietMode());
try {
const result = await callAI(
provider,
prompt,
cwd,
model,
sessionId,
display,
systemPrompt,
);
// If session failed, clear it and retry without session
if (!result.success && sessionId) {
log.info('Session invalid, retrying without session');
sessionId = undefined;
const retryDisplay = new StreamDisplay('assistant', isQuietMode());
const retry = await callAI(
provider,
prompt,
cwd,
model,
undefined,
retryDisplay,
systemPrompt,
);
if (retry.sessionId) {
sessionId = retry.sessionId;
updatePersonaSession(cwd, personaName, sessionId, providerType);
}
return retry;
}
if (result.sessionId) {
sessionId = result.sessionId;
updatePersonaSession(cwd, personaName, sessionId, providerType);
}
return result;
} catch (e) {
const msg = getErrorMessage(e);
log.error('AI call failed', { error: msg });
error(msg);
blankLine();
return null;
}
}
/** /**
* Inject policy into user message for AI call. * Inject policy into user message for AI call.
* Follows the same pattern as piece execution (perform_phase1_message.md). * Follows the same pattern as piece execution (perform_phase1_message.md).
*/ */
function injectPolicy(userMessage: string): string { function injectPolicy(userMessage: string): string {
const policyIntro = lang === 'ja' const policyIntro = ctx.lang === 'ja'
? '以下のポリシーは行動規範です。必ず遵守してください。' ? '以下のポリシーは行動規範です。必ず遵守してください。'
: 'The following policy defines behavioral guidelines. Please follow them.'; : 'The following policy defines behavioral guidelines. Please follow them.';
const reminderLabel = lang === 'ja' const reminderLabel = ctx.lang === 'ja'
? '上記の Policy セクションで定義されたポリシー規範を遵守してください。' ? '上記の Policy セクションで定義されたポリシー規範を遵守してください。'
: 'Please follow the policy guidelines defined in the Policy section above.'; : 'Please follow the policy guidelines defined in the Policy section above.';
return `## Policy\n${policyIntro}\n\n${prompts.policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`; return `## Policy\n${policyIntro}\n\n${policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`;
} }
// Process initial input if provided (e.g. from `takt a`) return runConversationLoop(cwd, ctx, {
if (initialInput) { systemPrompt,
history.push({ role: 'user', content: initialInput }); allowedTools: DEFAULT_INTERACTIVE_TOOLS,
log.debug('Processing initial input', { initialInput, sessionId }); transformPrompt: injectPolicy,
introMessage: ui.intro,
const promptWithPolicy = injectPolicy(initialInput); }, pieceContext, initialInput);
const result = await callAIWithRetry(promptWithPolicy, prompts.systemPrompt);
if (result) {
if (!result.success) {
error(result.content);
blankLine();
return { action: 'cancel', task: '' };
}
history.push({ role: 'assistant', content: result.content });
blankLine();
} else {
history.pop();
}
}
while (true) {
const input = await readMultilineInput(chalk.green('> '));
// EOF (Ctrl+D)
if (input === null) {
blankLine();
info('Cancelled');
return { action: 'cancel', task: '' };
}
const trimmed = input.trim();
// Empty input — skip
if (!trimmed) {
continue;
}
// Handle slash commands
if (trimmed.startsWith('/play')) {
const task = trimmed.slice(5).trim();
if (!task) {
info(prompts.ui.playNoTask);
continue;
}
log.info('Play command', { task });
return { action: 'execute', task };
}
if (trimmed.startsWith('/go')) {
const userNote = trimmed.slice(3).trim();
let summaryPrompt = buildSummaryPrompt(
history,
!!sessionId,
prompts.lang,
prompts.noTranscript,
prompts.conversationLabel,
prompts.pieceContext,
);
if (!summaryPrompt) {
info(prompts.ui.noConversation);
continue;
}
if (userNote) {
summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`;
}
const summaryResult = await callAIWithRetry(summaryPrompt, summaryPrompt);
if (!summaryResult) {
info(prompts.ui.summarizeFailed);
continue;
}
if (!summaryResult.success) {
error(summaryResult.content);
blankLine();
return { action: 'cancel', task: '' };
}
const task = summaryResult.content.trim();
const selectedAction = await selectPostSummaryAction(task, prompts.ui.proposed, prompts.ui);
if (selectedAction === 'continue' || selectedAction === null) {
info(prompts.ui.continuePrompt);
continue;
}
log.info('Interactive mode action selected', { action: selectedAction, messageCount: history.length });
return { action: selectedAction, task };
}
if (trimmed === '/cancel') {
info(prompts.ui.cancelled);
return { action: 'cancel', task: '' };
}
// Regular input — send to AI
history.push({ role: 'user', content: trimmed });
log.debug('Sending to AI', { messageCount: history.length, sessionId });
process.stdin.pause();
const promptWithPolicy = injectPolicy(trimmed);
const result = await callAIWithRetry(promptWithPolicy, prompts.systemPrompt);
if (result) {
if (!result.success) {
error(result.content);
blankLine();
history.pop();
return { action: 'cancel', task: '' };
}
history.push({ role: 'assistant', content: result.content });
blankLine();
} else {
history.pop();
}
}
} }

View File

@ -250,46 +250,103 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
// --- Buffer position helpers --- // --- Buffer position helpers ---
function getLineStart(): number {
const lastNl = buffer.lastIndexOf('\n', cursorPos - 1);
return lastNl + 1;
}
function getLineEnd(): number {
const nextNl = buffer.indexOf('\n', cursorPos);
return nextNl >= 0 ? nextNl : buffer.length;
}
function getLineStartAt(pos: number): number { function getLineStartAt(pos: number): number {
const lastNl = buffer.lastIndexOf('\n', pos - 1); const lastNl = buffer.lastIndexOf('\n', pos - 1);
return lastNl + 1; return lastNl + 1;
} }
function getLineStart(): number {
return getLineStartAt(cursorPos);
}
function getLineEndAt(pos: number): number { function getLineEndAt(pos: number): number {
const nextNl = buffer.indexOf('\n', pos); const nextNl = buffer.indexOf('\n', pos);
return nextNl >= 0 ? nextNl : buffer.length; return nextNl >= 0 ? nextNl : buffer.length;
} }
/** Display width from line start to cursor */ function getLineEnd(): number {
function getDisplayColumn(): number { return getLineEndAt(cursorPos);
return getDisplayWidth(buffer.slice(getLineStart(), cursorPos));
} }
const promptWidth = getDisplayWidth(stripAnsi(prompt)); const promptWidth = getDisplayWidth(stripAnsi(prompt));
/** Terminal column (1-based) for a given buffer position */ // --- Display row helpers (soft-wrap awareness) ---
function getTerminalColumn(pos: number): number {
const lineStart = getLineStartAt(pos); function getTermWidth(): number {
const col = getDisplayWidth(buffer.slice(lineStart, pos)); return process.stdout.columns || 80;
const isFirstLine = lineStart === 0;
return isFirstLine ? promptWidth + col + 1 : col + 1;
} }
/** Find the buffer position in a line that matches a target display column */ /** Buffer position of the display row start that contains `pos` */
function findPositionByDisplayColumn(lineStart: number, lineEnd: number, targetDisplayCol: number): number { function getDisplayRowStart(pos: number): number {
const logicalStart = getLineStartAt(pos);
const termWidth = getTermWidth();
const isFirstLogicalLine = logicalStart === 0;
let firstRowWidth = isFirstLogicalLine ? termWidth - promptWidth : termWidth;
if (firstRowWidth <= 0) firstRowWidth = 1;
let rowStart = logicalStart;
let accumulated = 0;
let available = firstRowWidth;
let i = logicalStart;
for (const ch of buffer.slice(logicalStart, pos)) {
const w = getDisplayWidth(ch);
if (accumulated + w > available) {
rowStart = i;
accumulated = w;
available = termWidth;
} else {
accumulated += w;
// Row exactly filled — next position starts a new display row
if (accumulated === available) {
rowStart = i + ch.length;
accumulated = 0;
available = termWidth;
}
}
i += ch.length;
}
return rowStart;
}
/** Buffer position of the display row end that contains `pos` */
function getDisplayRowEnd(pos: number): number {
const logicalEnd = getLineEndAt(pos);
const rowStart = getDisplayRowStart(pos);
const termWidth = getTermWidth();
// The first display row of the first logical line has reduced width
const isFirstDisplayRow = rowStart === 0;
const available = isFirstDisplayRow ? termWidth - promptWidth : termWidth;
let accumulated = 0;
let i = rowStart;
for (const ch of buffer.slice(rowStart, logicalEnd)) {
const w = getDisplayWidth(ch);
if (accumulated + w > available) return i;
accumulated += w;
i += ch.length;
}
return logicalEnd;
}
/** Display column (0-based) within the display row that contains `pos` */
function getDisplayRowColumn(pos: number): number {
return getDisplayWidth(buffer.slice(getDisplayRowStart(pos), pos));
}
/** Terminal column (1-based) for a given buffer position */
function getTerminalColumn(pos: number): number {
const displayRowStart = getDisplayRowStart(pos);
const col = getDisplayWidth(buffer.slice(displayRowStart, pos));
// Only the first display row of the first logical line has the prompt offset
const isFirstDisplayRow = displayRowStart === 0;
return isFirstDisplayRow ? promptWidth + col + 1 : col + 1;
}
/** Find the buffer position in a range that matches a target display column */
function findPositionByDisplayColumn(rangeStart: number, rangeEnd: number, targetDisplayCol: number): number {
let displayCol = 0; let displayCol = 0;
let pos = lineStart; let pos = rangeStart;
for (const ch of buffer.slice(lineStart, lineEnd)) { for (const ch of buffer.slice(rangeStart, rangeEnd)) {
const w = getDisplayWidth(ch); const w = getDisplayWidth(ch);
if (displayCol + w > targetDisplayCol) break; if (displayCol + w > targetDisplayCol) break;
displayCol += w; displayCol += w;
@ -322,23 +379,77 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
// --- Cursor movement --- // --- Cursor movement ---
function moveCursorToLineStart(): void { function moveCursorToDisplayRowStart(): void {
const displayOffset = getDisplayColumn(); const displayRowStart = getDisplayRowStart(cursorPos);
const displayOffset = getDisplayRowColumn(cursorPos);
if (displayOffset > 0) { if (displayOffset > 0) {
cursorPos = getLineStart(); cursorPos = displayRowStart;
process.stdout.write(`\x1B[${displayOffset}D`); process.stdout.write(`\x1B[${displayOffset}D`);
} }
} }
function moveCursorToLineEnd(): void { function moveCursorToDisplayRowEnd(): void {
const lineEnd = getLineEnd(); const displayRowEnd = getDisplayRowEnd(cursorPos);
const displayOffset = getDisplayWidth(buffer.slice(cursorPos, lineEnd)); const displayOffset = getDisplayWidth(buffer.slice(cursorPos, displayRowEnd));
if (displayOffset > 0) { if (displayOffset > 0) {
cursorPos = lineEnd; cursorPos = displayRowEnd;
process.stdout.write(`\x1B[${displayOffset}C`); process.stdout.write(`\x1B[${displayOffset}C`);
} }
} }
/** Move cursor to a target display row, positioning at the given display column */
function moveCursorToDisplayRow(
targetRowStart: number,
targetRowEnd: number,
displayCol: number,
direction: 'A' | 'B',
): void {
cursorPos = findPositionByDisplayColumn(targetRowStart, targetRowEnd, displayCol);
const termCol = getTerminalColumn(cursorPos);
process.stdout.write(`\x1B[${direction}`);
process.stdout.write(`\x1B[${termCol}G`);
}
/** Count how many display rows lie between two buffer positions in the same logical line */
function countDisplayRowsBetween(from: number, to: number): number {
if (from === to) return 0;
const start = Math.min(from, to);
const end = Math.max(from, to);
let count = 0;
let pos = start;
while (pos < end) {
const nextRowStart = getDisplayRowEnd(pos);
if (nextRowStart >= end) break;
pos = nextRowStart;
count++;
}
return count;
}
function moveCursorToLogicalLineStart(): void {
const lineStart = getLineStart();
if (cursorPos === lineStart) return;
const rowDiff = countDisplayRowsBetween(lineStart, cursorPos);
cursorPos = lineStart;
if (rowDiff > 0) {
process.stdout.write(`\x1B[${rowDiff}A`);
}
const termCol = getTerminalColumn(cursorPos);
process.stdout.write(`\x1B[${termCol}G`);
}
function moveCursorToLogicalLineEnd(): void {
const lineEnd = getLineEnd();
if (cursorPos === lineEnd) return;
const rowDiff = countDisplayRowsBetween(cursorPos, lineEnd);
cursorPos = lineEnd;
if (rowDiff > 0) {
process.stdout.write(`\x1B[${rowDiff}B`);
}
const termCol = getTerminalColumn(cursorPos);
process.stdout.write(`\x1B[${termCol}G`);
}
// --- Buffer editing --- // --- Buffer editing ---
function insertAt(pos: number, text: string): void { function insertAt(pos: number, text: string): void {
@ -461,27 +572,40 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
}, },
onArrowUp() { onArrowUp() {
if (state !== 'normal') return; if (state !== 'normal') return;
const lineStart = getLineStart(); const logicalLineStart = getLineStart();
if (lineStart === 0) return; const displayRowStart = getDisplayRowStart(cursorPos);
const displayCol = getDisplayColumn(); const displayCol = getDisplayRowColumn(cursorPos);
const prevLineStart = getLineStartAt(lineStart - 1);
const prevLineEnd = lineStart - 1; if (displayRowStart > logicalLineStart) {
cursorPos = findPositionByDisplayColumn(prevLineStart, prevLineEnd, displayCol); // Move to previous display row within the same logical line
const termCol = getTerminalColumn(cursorPos); const prevRowStart = getDisplayRowStart(displayRowStart - 1);
process.stdout.write('\x1B[A'); const prevRowEnd = getDisplayRowEnd(displayRowStart - 1);
process.stdout.write(`\x1B[${termCol}G`); moveCursorToDisplayRow(prevRowStart, prevRowEnd, displayCol, 'A');
} else if (logicalLineStart > 0) {
// Move to the last display row of the previous logical line
const prevLogicalLineEnd = logicalLineStart - 1;
const prevRowStart = getDisplayRowStart(prevLogicalLineEnd);
const prevRowEnd = getDisplayRowEnd(prevLogicalLineEnd);
moveCursorToDisplayRow(prevRowStart, prevRowEnd, displayCol, 'A');
}
}, },
onArrowDown() { onArrowDown() {
if (state !== 'normal') return; if (state !== 'normal') return;
const lineEnd = getLineEnd(); const logicalLineEnd = getLineEnd();
if (lineEnd >= buffer.length) return; const displayRowEnd = getDisplayRowEnd(cursorPos);
const displayCol = getDisplayColumn(); const displayCol = getDisplayRowColumn(cursorPos);
const nextLineStart = lineEnd + 1;
const nextLineEnd = getLineEndAt(nextLineStart); if (displayRowEnd < logicalLineEnd) {
cursorPos = findPositionByDisplayColumn(nextLineStart, nextLineEnd, displayCol); // Move to next display row within the same logical line
const termCol = getTerminalColumn(cursorPos); const nextRowStart = displayRowEnd;
process.stdout.write('\x1B[B'); const nextRowEnd = getDisplayRowEnd(displayRowEnd);
process.stdout.write(`\x1B[${termCol}G`); moveCursorToDisplayRow(nextRowStart, nextRowEnd, displayCol, 'B');
} else if (logicalLineEnd < buffer.length) {
// Move to the first display row of the next logical line
const nextLineStart = logicalLineEnd + 1;
const nextRowEnd = getDisplayRowEnd(nextLineStart);
moveCursorToDisplayRow(nextLineStart, nextRowEnd, displayCol, 'B');
}
}, },
onWordLeft() { onWordLeft() {
if (state !== 'normal') return; if (state !== 'normal') return;
@ -507,11 +631,11 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
}, },
onHome() { onHome() {
if (state !== 'normal') return; if (state !== 'normal') return;
moveCursorToLineStart(); moveCursorToLogicalLineStart();
}, },
onEnd() { onEnd() {
if (state !== 'normal') return; if (state !== 'normal') return;
moveCursorToLineEnd(); moveCursorToLogicalLineEnd();
}, },
onChar(ch: string) { onChar(ch: string) {
if (state === 'paste') { if (state === 'paste') {
@ -543,8 +667,8 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
} }
// Editing // Editing
if (ch === '\x7F' || ch === '\x08') { deleteCharBefore(); return; } if (ch === '\x7F' || ch === '\x08') { deleteCharBefore(); return; }
if (ch === '\x01') { moveCursorToLineStart(); return; } if (ch === '\x01') { moveCursorToDisplayRowStart(); return; }
if (ch === '\x05') { moveCursorToLineEnd(); return; } if (ch === '\x05') { moveCursorToDisplayRowEnd(); return; }
if (ch === '\x0B') { deleteToLineEnd(); return; } if (ch === '\x0B') { deleteToLineEnd(); return; }
if (ch === '\x15') { deleteToLineStart(); return; } if (ch === '\x15') { deleteToLineStart(); return; }
if (ch === '\x17') { deleteWord(); return; } if (ch === '\x17') { deleteWord(); return; }

View File

@ -0,0 +1,35 @@
/**
* Interactive mode selection UI.
*
* Presents the four interactive mode options after piece selection
* and returns the user's choice.
*/
import type { InteractiveMode } from '../../core/models/index.js';
import { DEFAULT_INTERACTIVE_MODE, INTERACTIVE_MODES } from '../../core/models/index.js';
import { selectOptionWithDefault } from '../../shared/prompt/index.js';
import { getLabel } from '../../shared/i18n/index.js';
/**
* Prompt the user to select an interactive mode.
*
* @param lang - Display language
* @param pieceDefault - Piece-level default mode (overrides user default)
* @returns Selected mode, or null if cancelled
*/
export async function selectInteractiveMode(
lang: 'en' | 'ja',
pieceDefault?: InteractiveMode,
): Promise<InteractiveMode | null> {
const defaultMode = pieceDefault ?? DEFAULT_INTERACTIVE_MODE;
const options: { label: string; value: InteractiveMode; description: string }[] = INTERACTIVE_MODES.map((mode) => ({
label: getLabel(`interactive.modeSelection.${mode}`, lang),
value: mode,
description: getLabel(`interactive.modeSelection.${mode}Description`, lang),
}));
const prompt = getLabel('interactive.modeSelection.prompt', lang);
return selectOptionWithDefault<InteractiveMode>(prompt, options, defaultMode);
}

View File

@ -0,0 +1,50 @@
/**
* Passthrough interactive mode.
*
* Passes user input directly as the task string without any
* AI-assisted instruction generation or system prompt injection.
*/
import chalk from 'chalk';
import { info, blankLine } from '../../shared/ui/index.js';
import { getLabel } from '../../shared/i18n/index.js';
import { readMultilineInput } from './lineEditor.js';
import type { InteractiveModeResult } from './interactive.js';
/**
* Run passthrough mode: collect user input and return it as-is.
*
* If initialInput is provided, it is used directly as the task.
* Otherwise, prompts the user for input.
*
* @param lang - Display language
* @param initialInput - Pre-filled input (e.g., from issue reference)
* @returns Result with the raw user input as task
*/
export async function passthroughMode(
lang: 'en' | 'ja',
initialInput?: string,
): Promise<InteractiveModeResult> {
if (initialInput) {
return { action: 'execute', task: initialInput };
}
info(getLabel('interactive.ui.intro', lang));
blankLine();
const input = await readMultilineInput(chalk.green('> '));
if (input === null) {
blankLine();
info(getLabel('interactive.ui.cancelled', lang));
return { action: 'cancel', task: '' };
}
const trimmed = input.trim();
if (!trimmed) {
info(getLabel('interactive.ui.cancelled', lang));
return { action: 'cancel', task: '' };
}
return { action: 'execute', task: trimmed };
}

View File

@ -0,0 +1,58 @@
/**
* Persona interactive mode.
*
* Uses the first movement's persona and tools for the interactive
* conversation. The persona acts as the conversational agent,
* performing code exploration and analysis while discussing the task.
* The conversation result is passed as the task to the piece.
*/
import type { FirstMovementInfo } from '../../infra/config/index.js';
import { getLabel } from '../../shared/i18n/index.js';
import {
type PieceContext,
type InteractiveModeResult,
DEFAULT_INTERACTIVE_TOOLS,
} from './interactive.js';
import {
initializeSession,
displayAndClearSessionState,
runConversationLoop,
} from './conversationLoop.js';
/**
* Run persona mode: converse as the first movement's persona.
*
* The persona's system prompt is used for all AI calls.
* The first movement's allowed tools are made available.
* After the conversation, the result is summarized as a task.
*
* @param cwd - Working directory
* @param firstMovement - First movement's persona and tool info
* @param initialInput - Pre-filled input
* @param pieceContext - Piece context for summary generation
* @returns Result with conversation-derived task
*/
export async function personaMode(
cwd: string,
firstMovement: FirstMovementInfo,
initialInput?: string,
pieceContext?: PieceContext,
): Promise<InteractiveModeResult> {
const ctx = initializeSession(cwd, 'persona-interactive');
displayAndClearSessionState(cwd, ctx.lang);
const allowedTools = firstMovement.allowedTools.length > 0
? firstMovement.allowedTools
: DEFAULT_INTERACTIVE_TOOLS;
const introMessage = `${getLabel('interactive.ui.intro', ctx.lang)} [${firstMovement.personaDisplayName}]`;
return runConversationLoop(cwd, ctx, {
systemPrompt: firstMovement.personaContent,
allowedTools,
transformPrompt: (msg) => msg,
introMessage,
}, pieceContext, initialInput);
}

View File

@ -0,0 +1,111 @@
/**
* Quiet interactive mode.
*
* Generates task instructions without asking clarifying questions.
* Uses the same summarization logic as assistant mode but skips
* the conversational loop goes directly to summary generation.
*/
import chalk from 'chalk';
import { createLogger } from '../../shared/utils/index.js';
import { info, error, blankLine } from '../../shared/ui/index.js';
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
import { readMultilineInput } from './lineEditor.js';
import {
type PieceContext,
type InteractiveModeResult,
type InteractiveUIText,
type ConversationMessage,
DEFAULT_INTERACTIVE_TOOLS,
buildSummaryPrompt,
selectPostSummaryAction,
} from './interactive.js';
import {
initializeSession,
callAIWithRetry,
} from './conversationLoop.js';
const log = createLogger('quiet-mode');
/**
* Run quiet mode: collect user input and generate instructions without questions.
*
* Flow:
* 1. If initialInput is provided, use it; otherwise prompt for input
* 2. Build summary prompt from the user input
* 3. Call AI to generate task instructions (best-effort, no questions)
* 4. Present the result and let user choose action
*
* @param cwd - Working directory
* @param initialInput - Pre-filled input (e.g., from issue reference)
* @param pieceContext - Piece context for template rendering
* @returns Result with generated task instructions
*/
export async function quietMode(
cwd: string,
initialInput?: string,
pieceContext?: PieceContext,
): Promise<InteractiveModeResult> {
const ctx = initializeSession(cwd, 'interactive');
let userInput = initialInput;
if (!userInput) {
info(getLabel('interactive.ui.intro', ctx.lang));
blankLine();
const input = await readMultilineInput(chalk.green('> '));
if (input === null) {
blankLine();
info(getLabel('interactive.ui.cancelled', ctx.lang));
return { action: 'cancel', task: '' };
}
const trimmed = input.trim();
if (!trimmed) {
info(getLabel('interactive.ui.cancelled', ctx.lang));
return { action: 'cancel', task: '' };
}
userInput = trimmed;
}
const history: ConversationMessage[] = [
{ role: 'user', content: userInput },
];
const conversationLabel = getLabel('interactive.conversationLabel', ctx.lang);
const noTranscript = getLabel('interactive.noTranscript', ctx.lang);
const summaryPrompt = buildSummaryPrompt(
history, !!ctx.sessionId, ctx.lang, noTranscript, conversationLabel, pieceContext,
);
if (!summaryPrompt) {
info(getLabel('interactive.ui.noConversation', ctx.lang));
return { action: 'cancel', task: '' };
}
const { result } = await callAIWithRetry(
summaryPrompt, summaryPrompt, DEFAULT_INTERACTIVE_TOOLS, cwd, ctx,
);
if (!result) {
return { action: 'cancel', task: '' };
}
if (!result.success) {
error(result.content);
blankLine();
return { action: 'cancel', task: '' };
}
const task = result.content.trim();
const ui = getLabelObject<InteractiveUIText>('interactive.ui', ctx.lang);
const selectedAction = await selectPostSummaryAction(task, ui.proposed, ui);
if (selectedAction === 'continue' || selectedAction === null) {
return { action: 'cancel', task: '' };
}
log.info('Quiet mode action selected', { action: selectedAction });
return { action: selectedAction, task };
}

View File

@ -5,19 +5,75 @@
* available task as soon as it finishes the current one, maximizing slot * available task as soon as it finishes the current one, maximizing slot
* utilization. Works for both sequential (concurrency=1) and parallel * utilization. Works for both sequential (concurrency=1) and parallel
* (concurrency>1) execution through the same code path. * (concurrency>1) execution through the same code path.
*
* Polls for newly added tasks at a configurable interval so that tasks
* added to .takt/tasks/ during execution are picked up without waiting
* for an active task to complete.
*/ */
import type { TaskRunner, TaskInfo } from '../../../infra/task/index.js'; import type { TaskRunner, TaskInfo } from '../../../infra/task/index.js';
import { info, blankLine } from '../../../shared/ui/index.js'; import { info, blankLine } from '../../../shared/ui/index.js';
import { TaskPrefixWriter } from '../../../shared/ui/TaskPrefixWriter.js';
import { createLogger } from '../../../shared/utils/index.js';
import { executeAndCompleteTask } from './taskExecution.js'; import { executeAndCompleteTask } from './taskExecution.js';
import { installSigIntHandler } from './sigintHandler.js'; import { installSigIntHandler } from './sigintHandler.js';
import type { TaskExecutionOptions } from './types.js'; import type { TaskExecutionOptions } from './types.js';
const log = createLogger('worker-pool');
export interface WorkerPoolResult { export interface WorkerPoolResult {
success: number; success: number;
fail: number; fail: number;
} }
type RaceResult =
| { type: 'completion'; promise: Promise<boolean>; result: boolean }
| { type: 'poll' };
interface PollTimer {
promise: Promise<RaceResult>;
cancel: () => void;
}
function createPollTimer(intervalMs: number, signal: AbortSignal): PollTimer {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const promise = new Promise<RaceResult>((resolve) => {
if (signal.aborted) {
resolve({ type: 'poll' });
return;
}
onAbort = () => {
if (timeoutId !== undefined) clearTimeout(timeoutId);
resolve({ type: 'poll' });
};
timeoutId = setTimeout(() => {
signal.removeEventListener('abort', onAbort!);
onAbort = undefined;
resolve({ type: 'poll' });
}, intervalMs);
signal.addEventListener('abort', onAbort, { once: true });
});
return {
promise,
cancel: () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
if (onAbort) {
signal.removeEventListener('abort', onAbort);
onAbort = undefined;
}
},
};
}
/** /**
* Run tasks using a worker pool with the given concurrency. * Run tasks using a worker pool with the given concurrency.
* *
@ -25,9 +81,10 @@ export interface WorkerPoolResult {
* 1. Create a shared AbortController * 1. Create a shared AbortController
* 2. Maintain a queue of pending tasks and a set of active promises * 2. Maintain a queue of pending tasks and a set of active promises
* 3. Fill available slots from the queue * 3. Fill available slots from the queue
* 4. Wait for any active task to complete (Promise.race) * 4. Wait for any active task to complete OR a poll timer to fire (Promise.race)
* 5. Record result, fill freed slot from queue * 5. On task completion: record result
* 6. Repeat until queue is empty and all active tasks complete * 6. On poll tick or completion: claim new tasks and fill freed slots
* 7. Repeat until queue is empty and all active tasks complete
*/ */
export async function runWithWorkerPool( export async function runWithWorkerPool(
taskRunner: TaskRunner, taskRunner: TaskRunner,
@ -35,7 +92,8 @@ export async function runWithWorkerPool(
concurrency: number, concurrency: number,
cwd: string, cwd: string,
pieceName: string, pieceName: string,
options?: TaskExecutionOptions, options: TaskExecutionOptions | undefined,
pollIntervalMs: number,
): Promise<WorkerPoolResult> { ): Promise<WorkerPoolResult> {
const abortController = new AbortController(); const abortController = new AbortController();
const { cleanup } = installSigIntHandler(() => abortController.abort()); const { cleanup } = installSigIntHandler(() => abortController.abort());
@ -45,6 +103,7 @@ export async function runWithWorkerPool(
const queue = [...initialTasks]; const queue = [...initialTasks];
const active = new Map<Promise<boolean>, TaskInfo>(); const active = new Map<Promise<boolean>, TaskInfo>();
const colorCounter = { value: 0 };
try { try {
while (queue.length > 0 || active.size > 0) { while (queue.length > 0 || active.size > 0) {
@ -52,33 +111,50 @@ export async function runWithWorkerPool(
break; break;
} }
fillSlots(queue, active, concurrency, taskRunner, cwd, pieceName, options, abortController); fillSlots(queue, active, concurrency, taskRunner, cwd, pieceName, options, abortController, colorCounter);
if (active.size === 0) { if (active.size === 0) {
break; break;
} }
const settled = await Promise.race( const pollTimer = createPollTimer(pollIntervalMs, abortController.signal);
[...active.keys()].map((p) => p.then(
(result) => ({ promise: p, result }), const completionPromises: Promise<RaceResult>[] = [...active.keys()].map((p) =>
() => ({ promise: p, result: false }), p.then(
)), (result): RaceResult => ({ type: 'completion', promise: p, result }),
(): RaceResult => ({ type: 'completion', promise: p, result: false }),
),
); );
const task = active.get(settled.promise); const settled = await Promise.race([...completionPromises, pollTimer.promise]);
active.delete(settled.promise);
if (task) { pollTimer.cancel();
if (settled.result) {
successCount++; if (settled.type === 'completion') {
} else { const task = active.get(settled.promise);
failCount++; active.delete(settled.promise);
if (task) {
if (settled.result) {
successCount++;
} else {
failCount++;
}
} }
} }
if (!abortController.signal.aborted && queue.length === 0) { if (!abortController.signal.aborted) {
const nextTasks = taskRunner.claimNextTasks(concurrency - active.size); const freeSlots = concurrency - active.size;
queue.push(...nextTasks); if (freeSlots > 0) {
const newTasks = taskRunner.claimNextTasks(freeSlots);
log.debug('poll_tick', { active: active.size, queued: queue.length, freeSlots });
if (newTasks.length > 0) {
log.debug('poll_new_tasks', { count: newTasks.length });
queue.push(...newTasks);
} else {
log.debug('no_new_tasks');
}
}
} }
} }
} finally { } finally {
@ -97,17 +173,25 @@ function fillSlots(
pieceName: string, pieceName: string,
options: TaskExecutionOptions | undefined, options: TaskExecutionOptions | undefined,
abortController: AbortController, abortController: AbortController,
colorCounter: { value: number },
): void { ): void {
while (active.size < concurrency && queue.length > 0) { while (active.size < concurrency && queue.length > 0) {
const task = queue.shift()!; const task = queue.shift()!;
const isParallel = concurrency > 1; const isParallel = concurrency > 1;
const colorIndex = colorCounter.value++;
blankLine(); if (isParallel) {
info(`=== Task: ${task.name} ===`); const writer = new TaskPrefixWriter({ taskName: task.name, colorIndex });
writer.writeLine(`=== Task: ${task.name} ===`);
} else {
blankLine();
info(`=== Task: ${task.name} ===`);
}
const promise = executeAndCompleteTask(task, taskRunner, cwd, pieceName, options, { const promise = executeAndCompleteTask(task, taskRunner, cwd, pieceName, options, {
abortSignal: isParallel ? abortController.signal : undefined, abortSignal: isParallel ? abortController.signal : undefined,
taskPrefix: isParallel ? task.name : undefined, taskPrefix: isParallel ? task.name : undefined,
taskColorIndex: isParallel ? colorIndex : undefined,
}); });
active.set(promise, task); active.set(promise, task);
} }

View File

@ -21,15 +21,16 @@ import {
} from '../../../infra/config/index.js'; } from '../../../infra/config/index.js';
import { isQuietMode } from '../../../shared/context.js'; import { isQuietMode } from '../../../shared/context.js';
import { import {
header, header as rawHeader,
info, info as rawInfo,
warn, warn as rawWarn,
error, error as rawError,
success, success as rawSuccess,
status, status as rawStatus,
blankLine, blankLine as rawBlankLine,
StreamDisplay, StreamDisplay,
} from '../../../shared/ui/index.js'; } from '../../../shared/ui/index.js';
import { TaskPrefixWriter } from '../../../shared/ui/TaskPrefixWriter.js';
import { import {
generateSessionId, generateSessionId,
createSessionLog, createSessionLog,
@ -62,6 +63,91 @@ import { installSigIntHandler } from './sigintHandler.js';
const log = createLogger('piece'); const log = createLogger('piece');
/**
* Output facade routes through TaskPrefixWriter when task prefix is active,
* or falls through to the raw module functions for single-task execution.
*/
interface OutputFns {
header: (title: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
success: (message: string) => void;
status: (label: string, value: string, color?: 'green' | 'yellow' | 'red') => void;
blankLine: () => void;
logLine: (text: string) => void;
}
function assertTaskPrefixPair(
taskPrefix: string | undefined,
taskColorIndex: number | undefined
): void {
const hasTaskPrefix = taskPrefix != null;
const hasTaskColorIndex = taskColorIndex != null;
if (hasTaskPrefix !== hasTaskColorIndex) {
throw new Error('taskPrefix and taskColorIndex must be provided together');
}
}
function createOutputFns(prefixWriter: TaskPrefixWriter | undefined): OutputFns {
if (!prefixWriter) {
return {
header: rawHeader,
info: rawInfo,
warn: rawWarn,
error: rawError,
success: rawSuccess,
status: rawStatus,
blankLine: rawBlankLine,
logLine: (text: string) => console.log(text),
};
}
return {
header: (title: string) => prefixWriter.writeLine(`=== ${title} ===`),
info: (message: string) => prefixWriter.writeLine(`[INFO] ${message}`),
warn: (message: string) => prefixWriter.writeLine(`[WARN] ${message}`),
error: (message: string) => prefixWriter.writeLine(`[ERROR] ${message}`),
success: (message: string) => prefixWriter.writeLine(message),
status: (label: string, value: string) => prefixWriter.writeLine(`${label}: ${value}`),
blankLine: () => prefixWriter.writeLine(''),
logLine: (text: string) => prefixWriter.writeLine(text),
};
}
/**
* Create a stream handler that routes all stream events through TaskPrefixWriter.
* Text and tool_output are line-buffered; block events are output per-line with prefix.
*/
function createPrefixedStreamHandler(
writer: TaskPrefixWriter,
): (event: Parameters<ReturnType<StreamDisplay['createHandler']>>[0]) => void {
return (event) => {
switch (event.type) {
case 'text':
writer.writeChunk(event.data.text);
break;
case 'tool_use':
writer.writeLine(`[tool] ${event.data.tool}`);
break;
case 'tool_result': {
const label = event.data.isError ? '✗' : '✓';
writer.writeLine(` ${label} ${event.data.content}`);
break;
}
case 'tool_output':
writer.writeChunk(event.data.output);
break;
case 'thinking':
writer.writeChunk(event.data.thinking);
break;
case 'init':
case 'result':
case 'error':
break;
}
};
}
/** /**
* Truncate string to maximum length * Truncate string to maximum length
*/ */
@ -106,11 +192,18 @@ export async function executePiece(
// projectCwd is where .takt/ lives (project root, not the clone) // projectCwd is where .takt/ lives (project root, not the clone)
const projectCwd = options.projectCwd; const projectCwd = options.projectCwd;
assertTaskPrefixPair(options.taskPrefix, options.taskColorIndex);
// When taskPrefix is set (parallel execution), route all output through TaskPrefixWriter
const prefixWriter = options.taskPrefix != null
? new TaskPrefixWriter({ taskName: options.taskPrefix, colorIndex: options.taskColorIndex! })
: undefined;
const out = createOutputFns(prefixWriter);
// Always continue from previous sessions (use /clear to reset) // Always continue from previous sessions (use /clear to reset)
log.debug('Continuing session (use /clear to reset)'); log.debug('Continuing session (use /clear to reset)');
header(`${headerPrefix} ${pieceConfig.name}`); out.header(`${headerPrefix} ${pieceConfig.name}`);
const pieceSessionId = generateSessionId(); const pieceSessionId = generateSessionId();
let sessionLog = createSessionLog(task, projectCwd, pieceConfig.name); let sessionLog = createSessionLog(task, projectCwd, pieceConfig.name);
@ -139,14 +232,16 @@ export async function executePiece(
// Track current display for streaming // Track current display for streaming
const displayRef: { current: StreamDisplay | null } = { current: null }; const displayRef: { current: StreamDisplay | null } = { current: null };
// Create stream handler that delegates to UI display // Create stream handler — when prefixWriter is active, use it for line-buffered
const streamHandler = ( // output to prevent mid-line interleaving between concurrent tasks.
event: Parameters<ReturnType<StreamDisplay['createHandler']>>[0] // When not in parallel mode, delegate to StreamDisplay as before.
): void => { const streamHandler = prefixWriter
if (!displayRef.current) return; ? createPrefixedStreamHandler(prefixWriter)
if (event.type === 'result') return; : (event: Parameters<ReturnType<StreamDisplay['createHandler']>>[0]): void => {
displayRef.current.createHandler()(event); if (!displayRef.current) return;
}; if (event.type === 'result') return;
displayRef.current.createHandler()(event);
};
// Load saved agent sessions for continuity (from project root or clone-specific storage) // Load saved agent sessions for continuity (from project root or clone-specific storage)
const isWorktree = cwd !== projectCwd; const isWorktree = cwd !== projectCwd;
@ -180,14 +275,14 @@ export async function executePiece(
displayRef.current = null; displayRef.current = null;
} }
blankLine(); out.blankLine();
warn( out.warn(
getLabel('piece.iterationLimit.maxReached', undefined, { getLabel('piece.iterationLimit.maxReached', undefined, {
currentIteration: String(request.currentIteration), currentIteration: String(request.currentIteration),
maxIterations: String(request.maxIterations), maxIterations: String(request.maxIterations),
}) })
); );
info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement })); out.info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement }));
if (shouldNotify) { if (shouldNotify) {
playWarningSound(); playWarningSound();
@ -214,11 +309,11 @@ export async function executePiece(
const additionalIterations = Number.parseInt(input, 10); const additionalIterations = Number.parseInt(input, 10);
if (Number.isInteger(additionalIterations) && additionalIterations > 0) { if (Number.isInteger(additionalIterations) && additionalIterations > 0) {
pieceConfig.maxIterations += additionalIterations; pieceConfig.maxIterations = request.maxIterations + additionalIterations;
return additionalIterations; return additionalIterations;
} }
warn(getLabel('piece.iterationLimit.invalidInput')); out.warn(getLabel('piece.iterationLimit.invalidInput'));
} }
}; };
@ -228,14 +323,15 @@ export async function executePiece(
displayRef.current.flush(); displayRef.current.flush();
displayRef.current = null; displayRef.current = null;
} }
blankLine(); out.blankLine();
info(request.prompt.trim()); out.info(request.prompt.trim());
const input = await promptInput(getLabel('piece.iterationLimit.userInputPrompt')); const input = await promptInput(getLabel('piece.iterationLimit.userInputPrompt'));
return input && input.trim() ? input.trim() : null; return input && input.trim() ? input.trim() : null;
} }
: undefined; : undefined;
const engine = new PieceEngine(pieceConfig, cwd, task, { const engine = new PieceEngine(pieceConfig, cwd, task, {
abortSignal: options.abortSignal,
onStream: streamHandler, onStream: streamHandler,
onUserInput, onUserInput,
initialSessions: savedSessions, initialSessions: savedSessions,
@ -245,11 +341,14 @@ export async function executePiece(
language: options.language, language: options.language,
provider: options.provider, provider: options.provider,
model: options.model, model: options.model,
personaProviders: options.personaProviders,
interactive: interactiveUserInput, interactive: interactiveUserInput,
detectRuleIndex, detectRuleIndex,
callAiJudge, callAiJudge,
startMovement: options.startMovement, startMovement: options.startMovement,
retryNote: options.retryNote, retryNote: options.retryNote,
taskPrefix: options.taskPrefix,
taskColorIndex: options.taskColorIndex,
}); });
let abortReason: string | undefined; let abortReason: string | undefined;
@ -257,6 +356,7 @@ export async function executePiece(
let lastMovementName: string | undefined; let lastMovementName: string | undefined;
let currentIteration = 0; let currentIteration = 0;
const phasePrompts = new Map<string, string>(); const phasePrompts = new Map<string, string>();
const movementIterations = new Map<string, number>();
engine.on('phase:start', (step, phase, phaseName, instruction) => { engine.on('phase:start', (step, phase, phaseName, instruction) => {
log.debug('Phase starting', { step: step.name, phase, phaseName }); log.debug('Phase starting', { step: step.name, phase, phaseName });
@ -311,7 +411,15 @@ export async function executePiece(
engine.on('movement:start', (step, iteration, instruction) => { engine.on('movement:start', (step, iteration, instruction) => {
log.debug('Movement starting', { step: step.name, persona: step.personaDisplayName, iteration }); log.debug('Movement starting', { step: step.name, persona: step.personaDisplayName, iteration });
currentIteration = iteration; currentIteration = iteration;
info(`[${iteration}/${pieceConfig.maxIterations}] ${step.name} (${step.personaDisplayName})`); const movementIteration = (movementIterations.get(step.name) ?? 0) + 1;
movementIterations.set(step.name, movementIteration);
prefixWriter?.setMovementContext({
movementName: step.name,
iteration,
maxIterations: pieceConfig.maxIterations,
movementIteration,
});
out.info(`[${iteration}/${pieceConfig.maxIterations}] ${step.name} (${step.personaDisplayName})`);
// Log prompt content for debugging // Log prompt content for debugging
if (instruction) { if (instruction) {
@ -322,15 +430,18 @@ export async function executePiece(
const movementIndex = pieceConfig.movements.findIndex((m) => m.name === step.name); const movementIndex = pieceConfig.movements.findIndex((m) => m.name === step.name);
const totalMovements = pieceConfig.movements.length; const totalMovements = pieceConfig.movements.length;
const quiet = isQuietMode(); // In parallel mode, StreamDisplay is not used (prefixWriter handles output).
const prefix = options.taskPrefix; // In single mode, StreamDisplay renders stream events directly.
const agentLabel = prefix ? `${prefix}:${step.personaDisplayName}` : step.personaDisplayName; if (!prefixWriter) {
displayRef.current = new StreamDisplay(agentLabel, quiet, { const quiet = isQuietMode();
iteration, const agentLabel = step.personaDisplayName;
maxIterations: pieceConfig.maxIterations, displayRef.current = new StreamDisplay(agentLabel, quiet, {
movementIndex: movementIndex >= 0 ? movementIndex : 0, iteration,
totalMovements, maxIterations: pieceConfig.maxIterations,
}); movementIndex: movementIndex >= 0 ? movementIndex : 0,
totalMovements,
});
}
// Write step_start record to NDJSON log // Write step_start record to NDJSON log
const record: NdjsonStepStart = { const record: NdjsonStepStart = {
@ -364,25 +475,26 @@ export async function executePiece(
displayRef.current.flush(); displayRef.current.flush();
displayRef.current = null; displayRef.current = null;
} }
blankLine(); prefixWriter?.flush();
out.blankLine();
if (response.matchedRuleIndex != null && step.rules) { if (response.matchedRuleIndex != null && step.rules) {
const rule = step.rules[response.matchedRuleIndex]; const rule = step.rules[response.matchedRuleIndex];
if (rule) { if (rule) {
const methodLabel = response.matchedRuleMethod ? ` (${response.matchedRuleMethod})` : ''; const methodLabel = response.matchedRuleMethod ? ` (${response.matchedRuleMethod})` : '';
status('Status', `${rule.condition}${methodLabel}`); out.status('Status', `${rule.condition}${methodLabel}`);
} else { } else {
status('Status', response.status); out.status('Status', response.status);
} }
} else { } else {
status('Status', response.status); out.status('Status', response.status);
} }
if (response.error) { if (response.error) {
error(`Error: ${response.error}`); out.error(`Error: ${response.error}`);
} }
if (response.sessionId) { if (response.sessionId) {
status('Session', response.sessionId); out.status('Session', response.sessionId);
} }
// Write step_complete record to NDJSON log // Write step_complete record to NDJSON log
@ -408,8 +520,8 @@ export async function executePiece(
engine.on('movement:report', (_step, filePath, fileName) => { engine.on('movement:report', (_step, filePath, fileName) => {
const content = readFileSync(filePath, 'utf-8'); const content = readFileSync(filePath, 'utf-8');
console.log(`\n📄 Report: ${fileName}\n`); out.logLine(`\n📄 Report: ${fileName}\n`);
console.log(content); out.logLine(content);
}); });
engine.on('piece:complete', (state) => { engine.on('piece:complete', (state) => {
@ -445,8 +557,8 @@ export async function executePiece(
: ''; : '';
const elapsedDisplay = elapsed ? `, ${elapsed}` : ''; const elapsedDisplay = elapsed ? `, ${elapsed}` : '';
success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`); out.success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`);
info(`Session log: ${ndjsonLogPath}`); out.info(`Session log: ${ndjsonLogPath}`);
if (shouldNotify) { if (shouldNotify) {
notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) })); notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) }));
} }
@ -459,6 +571,7 @@ export async function executePiece(
displayRef.current.flush(); displayRef.current.flush();
displayRef.current = null; displayRef.current = null;
} }
prefixWriter?.flush();
abortReason = reason; abortReason = reason;
sessionLog = finalizeSessionLog(sessionLog, 'aborted'); sessionLog = finalizeSessionLog(sessionLog, 'aborted');
@ -492,8 +605,8 @@ export async function executePiece(
: ''; : '';
const elapsedDisplay = elapsed ? ` (${elapsed})` : ''; const elapsedDisplay = elapsed ? ` (${elapsed})` : '';
error(`Piece aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`); out.error(`Piece aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`);
info(`Session log: ${ndjsonLogPath}`); out.info(`Session log: ${ndjsonLogPath}`);
if (shouldNotify) { if (shouldNotify) {
notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason })); notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason }));
} }
@ -536,6 +649,7 @@ export async function executePiece(
reason: abortReason, reason: abortReason,
}; };
} finally { } finally {
prefixWriter?.flush();
sigintCleanup?.(); sigintCleanup?.();
if (onAbortSignal && options.abortSignal) { if (onAbortSignal && options.abortSignal) {
options.abortSignal.removeEventListener('abort', onAbortSignal); options.abortSignal.removeEventListener('abort', onAbortSignal);

View File

@ -52,7 +52,7 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType<typeof fe
* Execute a single task with piece. * Execute a single task with piece.
*/ */
export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> { export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> {
const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, abortSignal, taskPrefix } = options; const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, abortSignal, taskPrefix, taskColorIndex } = options;
const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd); const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd);
if (!pieceConfig) { if (!pieceConfig) {
@ -77,12 +77,14 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<boolean>
language: globalConfig.language, language: globalConfig.language,
provider: agentOverrides?.provider, provider: agentOverrides?.provider,
model: agentOverrides?.model, model: agentOverrides?.model,
personaProviders: globalConfig.personaProviders,
interactiveUserInput, interactiveUserInput,
interactiveMetadata, interactiveMetadata,
startMovement, startMovement,
retryNote, retryNote,
abortSignal, abortSignal,
taskPrefix, taskPrefix,
taskColorIndex,
}); });
return result.success; return result.success;
} }
@ -101,16 +103,31 @@ export async function executeAndCompleteTask(
cwd: string, cwd: string,
pieceName: string, pieceName: string,
options?: TaskExecutionOptions, options?: TaskExecutionOptions,
parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string }, parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number },
): Promise<boolean> { ): Promise<boolean> {
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const executionLog: string[] = []; const executionLog: string[] = [];
const taskAbortController = new AbortController();
const externalAbortSignal = parallelOptions?.abortSignal;
const taskAbortSignal = externalAbortSignal ? taskAbortController.signal : undefined;
const onExternalAbort = (): void => {
taskAbortController.abort();
};
if (externalAbortSignal) {
if (externalAbortSignal.aborted) {
taskAbortController.abort();
} else {
externalAbortSignal.addEventListener('abort', onExternalAbort, { once: true });
}
}
try { try {
const { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName); const { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName);
// cwd is always the project root; pass it as projectCwd so reports/sessions go there // cwd is always the project root; pass it as projectCwd so reports/sessions go there
const taskSuccess = await executeTask({ const taskRunPromise = executeTask({
task: task.content, task: task.content,
cwd: execCwd, cwd: execCwd,
pieceIdentifier: execPiece, pieceIdentifier: execPiece,
@ -118,9 +135,12 @@ export async function executeAndCompleteTask(
agentOverrides: options, agentOverrides: options,
startMovement, startMovement,
retryNote, retryNote,
abortSignal: parallelOptions?.abortSignal, abortSignal: taskAbortSignal,
taskPrefix: parallelOptions?.taskPrefix, taskPrefix: parallelOptions?.taskPrefix,
taskColorIndex: parallelOptions?.taskColorIndex,
}); });
const taskSuccess = await taskRunPromise;
const completedAt = new Date().toISOString(); const completedAt = new Date().toISOString();
if (taskSuccess && isWorktree) { if (taskSuccess && isWorktree) {
@ -189,6 +209,10 @@ export async function executeAndCompleteTask(
error(`Task "${task.name}" error: ${getErrorMessage(err)}`); error(`Task "${task.name}" error: ${getErrorMessage(err)}`);
return false; return false;
} finally {
if (externalAbortSignal) {
externalAbortSignal.removeEventListener('abort', onExternalAbort);
}
} }
} }
@ -220,7 +244,7 @@ export async function runAllTasks(
info(`Concurrency: ${concurrency}`); info(`Concurrency: ${concurrency}`);
} }
const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options); const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs);
const totalCount = result.success + result.fail; const totalCount = result.success + result.fail;
blankLine(); blankLine();

View File

@ -30,6 +30,8 @@ export interface PieceExecutionOptions {
language?: Language; language?: Language;
provider?: ProviderType; provider?: ProviderType;
model?: string; model?: string;
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
personaProviders?: Record<string, ProviderType>;
/** Enable interactive user input during step transitions */ /** Enable interactive user input during step transitions */
interactiveUserInput?: boolean; interactiveUserInput?: boolean;
/** Interactive mode result metadata for NDJSON logging */ /** Interactive mode result metadata for NDJSON logging */
@ -42,6 +44,8 @@ export interface PieceExecutionOptions {
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
/** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */
taskPrefix?: string; taskPrefix?: string;
/** Color index for task prefix (cycled mod 4 across concurrent tasks) */
taskColorIndex?: number;
} }
export interface TaskExecutionOptions { export interface TaskExecutionOptions {
@ -72,6 +76,8 @@ export interface ExecuteTaskOptions {
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
/** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */
taskPrefix?: string; taskPrefix?: string;
/** Color index for task prefix (cycled mod 4 across concurrent tasks) */
taskColorIndex?: number;
} }
export interface PipelineExecutionOptions { export interface PipelineExecutionOptions {

View File

@ -23,6 +23,8 @@ import {
export type { CodexCallOptions } from './types.js'; export type { CodexCallOptions } from './types.js';
const log = createLogger('codex-sdk'); const log = createLogger('codex-sdk');
const CODEX_STREAM_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
const CODEX_STREAM_ABORTED_MESSAGE = 'Codex execution aborted';
/** /**
* Client for Codex SDK agent interactions. * Client for Codex SDK agent interactions.
@ -55,6 +57,34 @@ export class CodexClient {
? `${options.systemPrompt}\n\n${prompt}` ? `${options.systemPrompt}\n\n${prompt}`
: prompt; : prompt;
let idleTimeoutId: ReturnType<typeof setTimeout> | undefined;
const streamAbortController = new AbortController();
const timeoutMessage = `Codex stream timed out after ${Math.floor(CODEX_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`;
let abortCause: 'timeout' | 'external' | undefined;
const resetIdleTimeout = (): void => {
if (idleTimeoutId !== undefined) {
clearTimeout(idleTimeoutId);
}
idleTimeoutId = setTimeout(() => {
abortCause = 'timeout';
streamAbortController.abort();
}, CODEX_STREAM_IDLE_TIMEOUT_MS);
};
const onExternalAbort = (): void => {
abortCause = 'external';
streamAbortController.abort();
};
if (options.abortSignal) {
if (options.abortSignal.aborted) {
streamAbortController.abort();
} else {
options.abortSignal.addEventListener('abort', onExternalAbort, { once: true });
}
}
try { try {
log.debug('Executing Codex thread', { log.debug('Executing Codex thread', {
agentType, agentType,
@ -62,7 +92,10 @@ export class CodexClient {
hasSystemPrompt: !!options.systemPrompt, hasSystemPrompt: !!options.systemPrompt,
}); });
const { events } = await thread.runStreamed(fullPrompt); const { events } = await thread.runStreamed(fullPrompt, {
signal: streamAbortController.signal,
});
resetIdleTimeout();
let content = ''; let content = '';
const contentOffsets = new Map<string, number>(); const contentOffsets = new Map<string, number>();
let success = true; let success = true;
@ -70,6 +103,7 @@ export class CodexClient {
const state = createStreamTrackingState(); const state = createStreamTrackingState();
for await (const event of events as AsyncGenerator<CodexEvent>) { for await (const event of events as AsyncGenerator<CodexEvent>) {
resetIdleTimeout();
if (event.type === 'thread.started') { if (event.type === 'thread.started') {
threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId; threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId;
emitInit(options.onStream, options.model, threadId); emitInit(options.onStream, options.model, threadId);
@ -172,15 +206,27 @@ export class CodexClient {
}; };
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
emitResult(options.onStream, false, message, threadId); const errorMessage = streamAbortController.signal.aborted
? abortCause === 'timeout'
? timeoutMessage
: CODEX_STREAM_ABORTED_MESSAGE
: message;
emitResult(options.onStream, false, errorMessage, threadId);
return { return {
persona: agentType, persona: agentType,
status: 'blocked', status: 'blocked',
content: message, content: errorMessage,
timestamp: new Date(), timestamp: new Date(),
sessionId: threadId, sessionId: threadId,
}; };
} finally {
if (idleTimeoutId !== undefined) {
clearTimeout(idleTimeoutId);
}
if (options.abortSignal) {
options.abortSignal.removeEventListener('abort', onExternalAbort);
}
} }
} }

View File

@ -21,6 +21,7 @@ export function mapToCodexSandboxMode(mode: PermissionMode): CodexSandboxMode {
/** Options for calling Codex */ /** Options for calling Codex */
export interface CodexCallOptions { export interface CodexCallOptions {
cwd: string; cwd: string;
abortSignal?: AbortSignal;
sessionId?: string; sessionId?: string;
model?: string; model?: string;
systemPrompt?: string; systemPrompt?: string;

View File

@ -37,6 +37,7 @@ function createDefaultGlobalConfig(): GlobalConfig {
enableBuiltinPieces: true, enableBuiltinPieces: true,
interactivePreviewMovements: 3, interactivePreviewMovements: 3,
concurrency: 1, concurrency: 1,
taskPollIntervalMs: 500,
}; };
} }
@ -105,11 +106,13 @@ export class GlobalConfigManager {
minimalOutput: parsed.minimal_output, minimalOutput: parsed.minimal_output,
bookmarksFile: parsed.bookmarks_file, bookmarksFile: parsed.bookmarks_file,
pieceCategoriesFile: parsed.piece_categories_file, pieceCategoriesFile: parsed.piece_categories_file,
personaProviders: parsed.persona_providers,
branchNameStrategy: parsed.branch_name_strategy, branchNameStrategy: parsed.branch_name_strategy,
preventSleep: parsed.prevent_sleep, preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound, notificationSound: parsed.notification_sound,
interactivePreviewMovements: parsed.interactive_preview_movements, interactivePreviewMovements: parsed.interactive_preview_movements,
concurrency: parsed.concurrency, concurrency: parsed.concurrency,
taskPollIntervalMs: parsed.task_poll_interval_ms,
}; };
validateProviderModelCompatibility(config.provider, config.model); validateProviderModelCompatibility(config.provider, config.model);
this.cachedConfig = config; this.cachedConfig = config;
@ -170,6 +173,9 @@ export class GlobalConfigManager {
if (config.pieceCategoriesFile) { if (config.pieceCategoriesFile) {
raw.piece_categories_file = config.pieceCategoriesFile; raw.piece_categories_file = config.pieceCategoriesFile;
} }
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
raw.persona_providers = config.personaProviders;
}
if (config.branchNameStrategy) { if (config.branchNameStrategy) {
raw.branch_name_strategy = config.branchNameStrategy; raw.branch_name_strategy = config.branchNameStrategy;
} }
@ -185,6 +191,9 @@ export class GlobalConfigManager {
if (config.concurrency !== undefined && config.concurrency > 1) { if (config.concurrency !== undefined && config.concurrency > 1) {
raw.concurrency = config.concurrency; raw.concurrency = config.concurrency;
} }
if (config.taskPollIntervalMs !== undefined && config.taskPollIntervalMs !== 500) {
raw.task_poll_interval_ms = config.taskPollIntervalMs;
}
writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
this.invalidateCache(); this.invalidateCache();
} }

View File

@ -13,6 +13,7 @@ export {
listPieces, listPieces,
listPieceEntries, listPieceEntries,
type MovementPreview, type MovementPreview,
type FirstMovementInfo,
type PieceDirEntry, type PieceDirEntry,
type PieceSource, type PieceSource,
type PieceWithSource, type PieceWithSource,

View File

@ -21,6 +21,7 @@ export {
listPieces, listPieces,
listPieceEntries, listPieceEntries,
type MovementPreview, type MovementPreview,
type FirstMovementInfo,
type PieceDirEntry, type PieceDirEntry,
type PieceSource, type PieceSource,
type PieceWithSource, type PieceWithSource,

View File

@ -280,6 +280,7 @@ export function normalizePieceConfig(
maxIterations: parsed.max_iterations, maxIterations: parsed.max_iterations,
loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir, sections, context), loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir, sections, context),
answerAgent: parsed.answer_agent, answerAgent: parsed.answer_agent,
interactiveMode: parsed.interactive_mode,
}; };
} }

View File

@ -8,7 +8,7 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { join, resolve, isAbsolute } from 'node:path'; import { join, resolve, isAbsolute } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import type { PieceConfig, PieceMovement } from '../../../core/models/index.js'; import type { PieceConfig, PieceMovement, InteractiveMode } from '../../../core/models/index.js';
import { getGlobalPiecesDir, getBuiltinPiecesDir, getProjectConfigDir } from '../paths.js'; import { getGlobalPiecesDir, getBuiltinPiecesDir, getProjectConfigDir } from '../paths.js';
import { getLanguage, getDisabledBuiltins, getBuiltinPiecesEnabled } from '../global/globalConfig.js'; import { getLanguage, getDisabledBuiltins, getBuiltinPiecesEnabled } from '../global/globalConfig.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
@ -219,24 +219,10 @@ function buildMovementPreviews(piece: PieceConfig, maxCount: number): MovementPr
const movement = movementMap.get(currentName); const movement = movementMap.get(currentName);
if (!movement) break; if (!movement) break;
let personaContent = '';
if (movement.personaPath) {
try {
personaContent = readFileSync(movement.personaPath, 'utf-8');
} catch (err) {
log.debug('Failed to read persona file for preview', {
path: movement.personaPath,
error: getErrorMessage(err),
});
}
} else if (movement.persona) {
personaContent = movement.persona;
}
previews.push({ previews.push({
name: movement.name, name: movement.name,
personaDisplayName: movement.personaDisplayName, personaDisplayName: movement.personaDisplayName,
personaContent, personaContent: readMovementPersona(movement),
instructionContent: movement.instructionTemplate, instructionContent: movement.instructionTemplate,
allowedTools: movement.allowedTools ?? [], allowedTools: movement.allowedTools ?? [],
canEdit: movement.edit === true, canEdit: movement.edit === true,
@ -250,26 +236,86 @@ function buildMovementPreviews(piece: PieceConfig, maxCount: number): MovementPr
return previews; return previews;
} }
/**
* Read persona content from a movement.
* When personaPath is set, reads from file (returns empty on failure).
* Otherwise uses inline persona string.
*/
function readMovementPersona(movement: PieceMovement): string {
if (movement.personaPath) {
try {
return readFileSync(movement.personaPath, 'utf-8');
} catch (err) {
log.debug('Failed to read persona file', {
path: movement.personaPath,
error: getErrorMessage(err),
});
return '';
}
}
return movement.persona ?? '';
}
/** First movement info for persona mode */
export interface FirstMovementInfo {
/** Persona prompt content */
personaContent: string;
/** Persona display name */
personaDisplayName: string;
/** Allowed tools for this movement */
allowedTools: string[];
}
/** /**
* Get piece description by identifier. * Get piece description by identifier.
* Returns the piece name, description, workflow structure, and optional movement previews. * Returns the piece name, description, workflow structure, optional movement previews,
* piece-level interactive mode default, and first movement info for persona mode.
*/ */
export function getPieceDescription( export function getPieceDescription(
identifier: string, identifier: string,
projectCwd: string, projectCwd: string,
previewCount?: number, previewCount?: number,
): { name: string; description: string; pieceStructure: string; movementPreviews: MovementPreview[] } { ): {
name: string;
description: string;
pieceStructure: string;
movementPreviews: MovementPreview[];
interactiveMode?: InteractiveMode;
firstMovement?: FirstMovementInfo;
} {
const piece = loadPieceByIdentifier(identifier, projectCwd); const piece = loadPieceByIdentifier(identifier, projectCwd);
if (!piece) { if (!piece) {
return { name: identifier, description: '', pieceStructure: '', movementPreviews: [] }; return { name: identifier, description: '', pieceStructure: '', movementPreviews: [] };
} }
const previews = previewCount && previewCount > 0
? buildMovementPreviews(piece, previewCount)
: [];
const firstMovement = buildFirstMovementInfo(piece);
return { return {
name: piece.name, name: piece.name,
description: piece.description ?? '', description: piece.description ?? '',
pieceStructure: buildWorkflowString(piece.movements), pieceStructure: buildWorkflowString(piece.movements),
movementPreviews: previewCount && previewCount > 0 movementPreviews: previews,
? buildMovementPreviews(piece, previewCount) interactiveMode: piece.interactiveMode,
: [], firstMovement,
};
}
/**
* Build first movement info for persona mode.
* Reads persona content from the initial movement.
*/
function buildFirstMovementInfo(piece: PieceConfig): FirstMovementInfo | undefined {
const movement = piece.movements.find((m) => m.name === piece.initialMovement);
if (!movement) return undefined;
return {
personaContent: readMovementPersona(movement),
personaDisplayName: movement.personaDisplayName,
allowedTools: movement.allowedTools ?? [],
}; };
} }

View File

@ -27,6 +27,7 @@ function isInsideGitRepo(cwd: string): boolean {
function toCodexOptions(options: ProviderCallOptions): CodexCallOptions { function toCodexOptions(options: ProviderCallOptions): CodexCallOptions {
return { return {
cwd: options.cwd, cwd: options.cwd,
abortSignal: options.abortSignal,
sessionId: options.sessionId, sessionId: options.sessionId,
model: options.model, model: options.model,
permissionMode: options.permissionMode, permissionMode: options.permissionMode,

View File

@ -20,6 +20,7 @@ export interface AgentSetup {
/** Runtime options passed at call time */ /** Runtime options passed at call time */
export interface ProviderCallOptions { export interface ProviderCallOptions {
cwd: string; cwd: string;
abortSignal?: AbortSignal;
sessionId?: string; sessionId?: string;
model?: string; model?: string;
allowedTools?: string[]; allowedTools?: string[];

View File

@ -24,6 +24,17 @@ interactive:
continue: "Continue editing" continue: "Continue editing"
cancelled: "Cancelled" cancelled: "Cancelled"
playNoTask: "Please specify task content: /play <task>" playNoTask: "Please specify task content: /play <task>"
personaFallback: "No persona available for the first movement. Falling back to assistant mode."
modeSelection:
prompt: "Select interactive mode:"
assistant: "Assistant"
assistantDescription: "Ask clarifying questions before generating instructions"
persona: "Persona"
personaDescription: "Converse as the first agent's persona"
quiet: "Quiet"
quietDescription: "Generate instructions without asking questions"
passthrough: "Passthrough"
passthroughDescription: "Pass your input directly as task text"
previousTask: previousTask:
success: "✅ Previous task completed successfully" success: "✅ Previous task completed successfully"
error: "❌ Previous task failed: {error}" error: "❌ Previous task failed: {error}"

View File

@ -24,6 +24,17 @@ interactive:
continue: "会話を続ける" continue: "会話を続ける"
cancelled: "キャンセルしました" cancelled: "キャンセルしました"
playNoTask: "タスク内容を指定してください: /play <タスク内容>" playNoTask: "タスク内容を指定してください: /play <タスク内容>"
personaFallback: "先頭ムーブメントにペルソナがありません。アシスタントモードにフォールバックします。"
modeSelection:
prompt: "対話モードを選択してください:"
assistant: "アシスタント"
assistantDescription: "確認質問をしてから指示書を作成"
persona: "ペルソナ"
personaDescription: "先頭エージェントのペルソナで対話"
quiet: "クワイエット"
quietDescription: "質問なしでベストエフォートの指示書を生成"
passthrough: "パススルー"
passthroughDescription: "入力をそのままタスクとして渡す"
previousTask: previousTask:
success: "✅ 前回のタスクは正常に完了しました" success: "✅ 前回のタスクは正常に完了しました"
error: "❌ 前回のタスクはエラーで終了しました: {error}" error: "❌ 前回のタスクはエラーで終了しました: {error}"

View File

@ -0,0 +1,118 @@
/**
* Line-buffered, prefixed writer for task-level parallel execution.
*
* When multiple tasks run concurrently (takt run --concurrency N), each task's
* output must be identifiable and line-aligned to prevent mid-line interleaving.
* This class wraps process.stdout.write with line buffering and a colored
* `[taskName]` prefix on every non-empty line.
*
* Design mirrors ParallelLogger (movement-level) but targets task-level output:
* - Regular log lines (info, header, status) get the prefix
* - Stream output gets line-buffered then prefixed
* - Empty lines are passed through without prefix
*/
import { stripAnsi } from '../utils/text.js';
/** ANSI color codes for task prefixes (cycled by task index) */
const TASK_COLORS = ['\x1b[36m', '\x1b[33m', '\x1b[35m', '\x1b[32m'] as const;
const RESET = '\x1b[0m';
export interface TaskPrefixWriterOptions {
/** Task name used in the prefix */
taskName: string;
/** Color index for the prefix (cycled mod 4) */
colorIndex: number;
/** Override process.stdout.write for testing */
writeFn?: (text: string) => void;
}
export interface MovementPrefixContext {
movementName: string;
iteration: number;
maxIterations: number;
movementIteration: number;
}
/**
* Prefixed line writer for a single parallel task.
*
* All output goes through `writeLine` (complete lines) or `writeChunk`
* (buffered partial lines). The prefix `[taskName]` is prepended to every
* non-empty output line.
*/
export class TaskPrefixWriter {
private readonly taskPrefix: string;
private readonly writeFn: (text: string) => void;
private movementContext: MovementPrefixContext | undefined;
private lineBuffer = '';
constructor(options: TaskPrefixWriterOptions) {
const color = TASK_COLORS[options.colorIndex % TASK_COLORS.length];
const taskLabel = options.taskName.slice(0, 4);
this.taskPrefix = `${color}[${taskLabel}]${RESET}`;
this.writeFn = options.writeFn ?? ((text: string) => process.stdout.write(text));
}
setMovementContext(context: MovementPrefixContext): void {
this.movementContext = context;
}
private buildPrefix(): string {
if (!this.movementContext) {
return `${this.taskPrefix} `;
}
const { movementName, iteration, maxIterations, movementIteration } = this.movementContext;
return `${this.taskPrefix}[${movementName}](${iteration}/${maxIterations})(${movementIteration}) `;
}
/**
* Write a complete line with prefix.
* Multi-line text is split and each non-empty line gets the prefix.
*/
writeLine(text: string): void {
const cleaned = stripAnsi(text);
const lines = cleaned.split('\n');
for (const line of lines) {
if (line === '') {
this.writeFn('\n');
} else {
this.writeFn(`${this.buildPrefix()}${line}\n`);
}
}
}
/**
* Write a chunk of streaming text with line buffering.
* Partial lines are buffered until a newline arrives, then output with prefix.
*/
writeChunk(text: string): void {
const cleaned = stripAnsi(text);
const combined = this.lineBuffer + cleaned;
const parts = combined.split('\n');
const remainder = parts.pop() ?? '';
this.lineBuffer = remainder;
for (const line of parts) {
if (line === '') {
this.writeFn('\n');
} else {
this.writeFn(`${this.buildPrefix()}${line}\n`);
}
}
}
/**
* Flush any remaining buffered content.
*/
flush(): void {
if (this.lineBuffer !== '') {
this.writeFn(`${this.buildPrefix()}${this.lineBuffer}\n`);
this.lineBuffer = '';
}
}
}

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