diff --git a/CHANGELOG.md b/CHANGELOG.md index 4321142..14ff812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/). +## [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: 100–5000ms) +- **`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 ### Added diff --git a/README.md b/README.md index 59c39ba..602309b 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,25 @@ takt 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:** 1. Select piece -2. Refine task content through conversation with AI -3. Finalize task instructions with `/go` (you can also add additional instructions like `/go additional instructions`), or use `/play ` to execute a task immediately -4. Execute (create worktree, run piece, create PR) +2. Select interactive mode (assistant / persona / quiet / passthrough) +3. Refine task content through conversation with AI +4. Finalize task instructions with `/go` (you can also add additional instructions like `/go additional instructions`), or use `/play ` 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 @@ -451,8 +463,10 @@ TAKT includes multiple builtin pieces: | `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. | | `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. @@ -475,6 +489,7 @@ Use `takt switch` to switch pieces. | **research-planner** | Research task planning and scope definition | | **research-digger** | Deep investigation and information gathering | | **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 | ## 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) notification_sound: true # Enable/disable notification sounds 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) +# 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) # Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY anthropic_api_key: sk-ant-... # For Claude (Anthropic) diff --git a/builtins/en/config.yaml b/builtins/en/config.yaml index 9299c89..b23915d 100644 --- a/builtins/en/config.yaml +++ b/builtins/en/config.yaml @@ -1,34 +1,85 @@ # 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 -# Default piece to use when no piece is specified +# Default piece when no piece is specified default_piece: default -# Log level: debug, info, warn, error +# Log level (debug | info | warn | error) log_level: info -# Provider runtime: claude or codex +# ── Provider & Model ── + +# Provider runtime (claude | codex) provider: claude -# Builtin pieces (resources/global/{lang}/pieces) -# enable_builtin_pieces: true - # 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. # model: sonnet -# Anthropic API key (optional, overridden by TAKT_ANTHROPIC_API_KEY env var) -# anthropic_api_key: "" +# Per-persona provider override (optional) +# 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: "" -# Pipeline execution settings (optional) -# Customize branch naming, commit messages, and PR body for pipeline mode (--task). +# ── Execution ── + +# 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: # default_branch_prefix: "takt/" # commit_message_template: "feat: {title} (#{issue})" @@ -37,10 +88,14 @@ provider: claude # {issue_body} # Closes #{issue} -# Notification sounds (true: enabled, false: disabled, default: true) -# notification_sound: true +# ── Preferences ── + +# Custom paths for preference files +# bookmarks_file: ~/.takt/preferences/bookmarks.yaml +# piece_categories_file: ~/.takt/preferences/piece-categories.yaml + +# ── Debug ── -# Debug settings (optional) # debug: # enabled: false # log_file: ~/.takt/logs/debug.log diff --git a/builtins/en/instructions/implement-test.md b/builtins/en/instructions/implement-test.md new file mode 100644 index 0000000..eee5ac3 --- /dev/null +++ b/builtins/en/instructions/implement-test.md @@ -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} diff --git a/builtins/en/instructions/plan-test.md b/builtins/en/instructions/plan-test.md new file mode 100644 index 0000000..275c214 --- /dev/null +++ b/builtins/en/instructions/plan-test.md @@ -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 diff --git a/builtins/en/instructions/review-arch.md b/builtins/en/instructions/review-arch.md index f0a6036..45a244c 100644 --- a/builtins/en/instructions/review-arch.md +++ b/builtins/en/instructions/review-arch.md @@ -9,8 +9,14 @@ Do not review AI-specific issues (already covered by the ai_review movement). - Dead code - 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 -1. Review the change diff and detect issues based on the architecture and design criteria above -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 +1. First, extract previous open findings and preliminarily classify as `new / persists / resolved` +2. Review the change diff and detect issues based on the architecture and design criteria above +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 diff --git a/builtins/en/instructions/review-qa.md b/builtins/en/instructions/review-qa.md index 457d51e..f980afc 100644 --- a/builtins/en/instructions/review-qa.md +++ b/builtins/en/instructions/review-qa.md @@ -7,8 +7,14 @@ Review the changes from a quality assurance perspective. - Logging and monitoring - 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 -1. Review the change diff and detect issues based on the quality assurance criteria above -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 +1. First, extract previous open findings and preliminarily classify as `new / persists / resolved` +2. Review the change diff and detect issues based on the quality assurance criteria above +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 diff --git a/builtins/en/instructions/review-test.md b/builtins/en/instructions/review-test.md new file mode 100644 index 0000000..fbaac61 --- /dev/null +++ b/builtins/en/instructions/review-test.md @@ -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 diff --git a/builtins/en/output-contracts/architecture-review.md b/builtins/en/output-contracts/architecture-review.md index 2525bf2..c2b3aa2 100644 --- a/builtins/en/output-contracts/architecture-review.md +++ b/builtins/en/output-contracts/architecture-review.md @@ -14,10 +14,15 @@ - [x] Dead code - [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) -| # | Scope | Location | Issue | Fix Suggestion | -|---|-------|----------|-------|----------------| -| 1 | In-scope | `src/file.ts:42` | Issue description | Fix approach | +| # | finding_id | Status (new/persists) | Scope | Location | Issue | Fix Suggestion | +|---|------------|-----------------------|-------|----------|-------|----------------| +| 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) diff --git a/builtins/en/output-contracts/qa-review.md b/builtins/en/output-contracts/qa-review.md index 170b254..d977b87 100644 --- a/builtins/en/output-contracts/qa-review.md +++ b/builtins/en/output-contracts/qa-review.md @@ -15,8 +15,13 @@ | Documentation | ✅ | - | | 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) -| # | Category | Issue | Fix Suggestion | -|---|----------|-------|----------------| -| 1 | Testing | Issue description | Fix approach | +| # | finding_id | Status (new/persists) | Category | Issue | Fix Suggestion | +|---|------------|-----------------------|----------|-------|----------------| +| 1 | QA-EXAMPLE-src-file-L42 | new | Testing | Issue description | Fix approach | ``` diff --git a/builtins/en/output-contracts/test-plan.md b/builtins/en/output-contracts/test-plan.md new file mode 100644 index 0000000..7d6b515 --- /dev/null +++ b/builtins/en/output-contracts/test-plan.md @@ -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} +``` diff --git a/builtins/en/personas/test-planner.md b/builtins/en/personas/test-planner.md new file mode 100644 index 0000000..213e004 --- /dev/null +++ b/builtins/en/personas/test-planner.md @@ -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 diff --git a/builtins/en/piece-categories.yaml b/builtins/en/piece-categories.yaml index 54bd219..8d400a6 100644 --- a/builtins/en/piece-categories.yaml +++ b/builtins/en/piece-categories.yaml @@ -10,6 +10,7 @@ piece_categories: pieces: - review-fix-minimal - review-only + - unit-test 🎨 Frontend: {} ⚙️ Backend: {} 🔧 Expert: @@ -17,20 +18,9 @@ piece_categories: pieces: - expert - expert-cqrs - 🔀 Hybrid (Codex Coding): - 🚀 Quick Start: + Refactoring: pieces: - - coding-hybrid-codex - - 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 + - structural-reform Others: pieces: - research diff --git a/builtins/en/pieces/coding-hybrid-codex.yaml b/builtins/en/pieces/coding-hybrid-codex.yaml deleted file mode 100644 index 5b564f0..0000000 --- a/builtins/en/pieces/coding-hybrid-codex.yaml +++ /dev/null @@ -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 diff --git a/builtins/en/pieces/expert-cqrs-hybrid-codex.yaml b/builtins/en/pieces/expert-cqrs-hybrid-codex.yaml deleted file mode 100644 index f778285..0000000 --- a/builtins/en/pieces/expert-cqrs-hybrid-codex.yaml +++ /dev/null @@ -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 diff --git a/builtins/en/pieces/expert-hybrid-codex.yaml b/builtins/en/pieces/expert-hybrid-codex.yaml deleted file mode 100644 index d04f76c..0000000 --- a/builtins/en/pieces/expert-hybrid-codex.yaml +++ /dev/null @@ -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 diff --git a/builtins/en/pieces/minimal-hybrid-codex.yaml b/builtins/en/pieces/minimal-hybrid-codex.yaml deleted file mode 100644 index ace4007..0000000 --- a/builtins/en/pieces/minimal-hybrid-codex.yaml +++ /dev/null @@ -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 diff --git a/builtins/en/pieces/passthrough-hybrid-codex.yaml b/builtins/en/pieces/passthrough-hybrid-codex.yaml deleted file mode 100644 index ac1f20a..0000000 --- a/builtins/en/pieces/passthrough-hybrid-codex.yaml +++ /dev/null @@ -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 diff --git a/builtins/en/pieces/review-fix-minimal-hybrid-codex.yaml b/builtins/en/pieces/review-fix-minimal-hybrid-codex.yaml deleted file mode 100644 index 291fe0e..0000000 --- a/builtins/en/pieces/review-fix-minimal-hybrid-codex.yaml +++ /dev/null @@ -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 diff --git a/builtins/en/pieces/structural-reform.yaml b/builtins/en/pieces/structural-reform.yaml new file mode 100644 index 0000000..07f8abc --- /dev/null +++ b/builtins/en/pieces/structural-reform.yaml @@ -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 diff --git a/builtins/en/pieces/default-hybrid-codex.yaml b/builtins/en/pieces/unit-test.yaml similarity index 68% rename from builtins/en/pieces/default-hybrid-codex.yaml rename to builtins/en/pieces/unit-test.yaml index 955770c..dbae293 100644 --- a/builtins/en/pieces/default-hybrid-codex.yaml +++ b/builtins/en/pieces/unit-test.yaml @@ -1,37 +1,32 @@ -# Auto-generated from default.yaml by tools/generate-hybrid-codex.mjs -# Do not edit manually. Edit the source piece and re-run the generator. - -name: default-hybrid-codex -description: Standard development piece with planning and specialized reviews -max_iterations: 30 +name: unit-test +description: Unit test focused piece (test analysis → test implementation → review → fix) +max_iterations: 20 +policies: + coding: ../policies/coding.md + review: ../policies/review.md + testing: ../policies/testing.md + ai-antipattern: ../policies/ai-antipattern.md + qa: ../policies/qa.md knowledge: - backend: ../knowledge/backend.md architecture: ../knowledge/architecture.md + backend: ../knowledge/backend.md personas: - planner: ../personas/planner.md + test-planner: ../personas/test-planner.md coder: ../personas/coder.md ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md architecture-reviewer: ../personas/architecture-reviewer.md qa-reviewer: ../personas/qa-reviewer.md supervisor: ../personas/supervisor.md instructions: - plan: ../instructions/plan.md - implement: ../instructions/implement.md + plan-test: ../instructions/plan-test.md + implement-test: ../instructions/implement-test.md ai-review: ../instructions/ai-review.md ai-fix: ../instructions/ai-fix.md arbitrate: ../instructions/arbitrate.md - review-arch: ../instructions/review-arch.md - review-qa: ../instructions/review-qa.md + review-test: ../instructions/review-test.md fix: ../instructions/fix.md supervise: ../instructions/supervise.md -report_formats: - 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 +initial_movement: plan_test loop_monitors: - cycle: - ai_review @@ -56,12 +51,15 @@ loop_monitors: - condition: Healthy (making progress) next: ai_review - condition: Unproductive (no improvement) - next: reviewers + next: review_test movements: - - name: plan + - name: plan_test edit: false - persona: planner - knowledge: architecture + persona: test-planner + policy: testing + knowledge: + - architecture + - backend allowed_tools: - Read - Glob @@ -70,9 +68,9 @@ movements: - WebSearch - WebFetch rules: - - condition: Requirements are clear and implementable - next: implement - - condition: User is asking a question (not an implementation task) + - condition: Test plan complete + next: implement_test + - condition: User is asking a question (not a test task) next: COMPLETE - condition: Requirements unclear, insufficient info next: ABORT @@ -80,15 +78,15 @@ movements: Clarifications needed: - {Question 1} - {Question 2} - instruction: plan + instruction: plan-test output_contracts: report: - - name: 00-plan.md - format: plan - - name: implement + - name: 00-test-plan.md + format: test-plan + + - name: implement_test edit: true persona: coder - provider: codex policy: - coding - testing @@ -107,21 +105,22 @@ movements: - WebFetch permission_mode: edit rules: - - condition: Implementation complete + - condition: Test implementation complete next: ai_review - condition: No implementation (report only) next: ai_review - condition: Cannot proceed, insufficient info next: ai_review - condition: User input required - next: implement + next: implement_test requires_user_input: true interactive_only: true - instruction: implement + instruction: implement-test output_contracts: report: - Scope: 02-coder-scope.md - Decisions: 03-coder-decisions.md + - name: ai_review edit: false persona: ai-antipattern-reviewer @@ -136,7 +135,7 @@ movements: - WebFetch rules: - condition: No AI-specific issues - next: reviewers + next: review_test - condition: AI-specific issues found next: ai_fix instruction: ai-review @@ -144,10 +143,10 @@ movements: report: - name: 04-ai-review.md format: ai-review + - name: ai_fix edit: true persona: coder - provider: codex policy: - coding - testing @@ -173,6 +172,7 @@ movements: - condition: Cannot proceed, insufficient info next: ai_no_fix instruction: ai-fix + - name: ai_no_fix edit: false persona: architecture-reviewer @@ -185,63 +185,39 @@ movements: - condition: ai_review's findings are valid (fix required) next: ai_fix - condition: ai_fix's judgment is valid (no fix needed) - next: reviewers + next: review_test 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: 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 + + - name: review_test + edit: false + persona: qa-reviewer + policy: + - review + - qa + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch rules: - - condition: all("approved") + - condition: approved next: supervise - - condition: any("needs_fix") + - condition: needs_fix next: fix + instruction: review-test + output_contracts: + report: + - name: 05-qa-review.md + format: qa-review + - name: fix edit: true persona: coder - provider: codex policy: - coding - testing + session: refresh knowledge: - backend - architecture @@ -257,10 +233,11 @@ movements: permission_mode: edit rules: - condition: Fix complete - next: reviewers + next: review_test - condition: Cannot proceed, insufficient info - next: plan + next: plan_test instruction: fix + - name: supervise edit: false persona: supervisor @@ -276,15 +253,15 @@ movements: - condition: All checks passed next: COMPLETE - condition: Requirements unmet, tests failing, build errors - next: plan + next: plan_test instruction: supervise output_contracts: report: - - Validation: 07-supervisor-validation.md + - Validation: 06-supervisor-validation.md - Summary: summary.md -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md - qa: ../policies/qa.md +report_formats: + test-plan: ../output-contracts/test-plan.md + ai-review: ../output-contracts/ai-review.md + qa-review: ../output-contracts/qa-review.md + validation: ../output-contracts/validation.md + summary: ../output-contracts/summary.md diff --git a/builtins/en/policies/review.md b/builtins/en/policies/review.md index fd09b7b..3d36f1f 100644 --- a/builtins/en/policies/review.md +++ b/builtins/en/policies/review.md @@ -86,6 +86,18 @@ Every issue raised must include the following. 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 Leave it better than you found it. diff --git a/builtins/ja/config.yaml b/builtins/ja/config.yaml index 4ea789b..2cbf381 100644 --- a/builtins/ja/config.yaml +++ b/builtins/ja/config.yaml @@ -1,34 +1,85 @@ # TAKT グローバル設定 -# takt のデフォルト設定ファイルです。 +# 配置場所: ~/.takt/config.yaml -# 言語設定 (en または ja) +# ── 基本設定 ── + +# 言語 (en | ja) language: ja -# デフォルトのピース - 指定がない場合に使用します +# デフォルトピース(指定なし時に使用) default_piece: default -# ログレベル: debug, info, warn, error +# ログレベル (debug | info | warn | error) log_level: info -# プロバイダー: claude または codex +# ── プロバイダー & モデル ── + +# プロバイダー (claude | codex) provider: claude -# ビルトインピースの読み込み (resources/global/{lang}/pieces) -# enable_builtin_pieces: true - -# デフォルトモデル (オプション) -# Claude: opus, sonnet, haiku, opusplan, default, またはフルモデル名 +# デフォルトモデル(オプション) +# Claude: opus, sonnet, haiku # Codex: gpt-5.2-codex, gpt-5.1-codex など # 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: "" -# パイプライン実行設定 (オプション) -# パイプラインモード (--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 + +# 新規タスクのポーリング間隔 ms(100-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: # default_branch_prefix: "takt/" # commit_message_template: "feat: {title} (#{issue})" @@ -37,10 +88,14 @@ provider: claude # {issue_body} # Closes #{issue} -# 通知音 (true: 有効 / false: 無効、デフォルト: true) -# notification_sound: true +# ── プリファレンス ── + +# プリファレンスファイルのカスタムパス +# bookmarks_file: ~/.takt/preferences/bookmarks.yaml +# piece_categories_file: ~/.takt/preferences/piece-categories.yaml + +# ── デバッグ ── -# デバッグ設定 (オプション) # debug: # enabled: false # log_file: ~/.takt/logs/debug.log diff --git a/builtins/ja/instructions/implement-test.md b/builtins/ja/instructions/implement-test.md new file mode 100644 index 0000000..7ec3b34 --- /dev/null +++ b/builtins/ja/instructions/implement-test.md @@ -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. {決定内容} +- **背景**: {なぜ決定が必要だったか} +- **検討した選択肢**: {選択肢リスト} +- **理由**: {選んだ理由} +``` + +**必須出力(見出しを含める)** +## 作業結果 +- {実施内容の要約} +## 変更内容 +- {変更内容の要約} +## テスト結果 +- {実行コマンドと結果} diff --git a/builtins/ja/instructions/plan-test.md b/builtins/ja/instructions/plan-test.md new file mode 100644 index 0000000..6902c73 --- /dev/null +++ b/builtins/ja/instructions/plan-test.md @@ -0,0 +1,11 @@ +対象コードを分析し、不足している単体テストを洗い出してください。 + +**注意:** Previous Responseがある場合は差し戻しのため、 +その内容を踏まえてテスト計画を見直してください。 + +**やること:** +1. 対象モジュールのソースコードを読み、振る舞い・分岐・状態遷移を理解する +2. 既存テストを読み、カバーされている観点を把握する +3. 不足しているテストケース(正常系・異常系・境界値・エッジケース)を洗い出す +4. テスト方針(モック戦略、既存テストヘルパーの活用、フィクスチャ設計)を決める +5. テスト実装者向けの具体的なガイドラインを出す diff --git a/builtins/ja/instructions/review-arch.md b/builtins/ja/instructions/review-arch.md index 03fd8e9..313be9a 100644 --- a/builtins/ja/instructions/review-arch.md +++ b/builtins/ja/instructions/review-arch.md @@ -9,8 +9,14 @@ AI特有の問題はレビューしないでください(ai_reviewムーブメ - デッドコード - 呼び出しチェーン検証 +**前回指摘の追跡(必須):** +- まず「Previous Response」から前回の open findings を抽出する +- 各 finding に `finding_id` を付け、今回の状態を `new / persists / resolved` で判定する +- `persists` と判定する場合は、未解決である根拠(ファイル/行)を必ず示す + ## 判定手順 -1. 変更差分を確認し、構造・設計の観点に基づいて問題を検出する -2. 検出した問題ごとに、Policyのスコープ判定表と判定ルールに基づいてブロッキング/非ブロッキングを分類する -3. ブロッキング問題が1件でもあればREJECTと判定する +1. まず前回open findingsを抽出し、`new / persists / resolved` を仮判定する +2. 変更差分を確認し、構造・設計の観点に基づいて問題を検出する +3. 検出した問題ごとに、Policyのスコープ判定表と判定ルールに基づいてブロッキング/非ブロッキングを分類する +4. ブロッキング問題(`new` または `persists`)が1件でもあればREJECTと判定する diff --git a/builtins/ja/instructions/review-qa.md b/builtins/ja/instructions/review-qa.md index 1c8bf02..dc080e5 100644 --- a/builtins/ja/instructions/review-qa.md +++ b/builtins/ja/instructions/review-qa.md @@ -7,8 +7,14 @@ - ログとモニタリング - 保守性 +**前回指摘の追跡(必須):** +- まず「Previous Response」から前回の open findings を抽出する +- 各 finding に `finding_id` を付け、今回の状態を `new / persists / resolved` で判定する +- `persists` と判定する場合は、未解決である根拠(ファイル/行)を必ず示す + ## 判定手順 -1. 変更差分を確認し、品質保証の観点に基づいて問題を検出する -2. 検出した問題ごとに、Policyのスコープ判定表と判定ルールに基づいてブロッキング/非ブロッキングを分類する -3. ブロッキング問題が1件でもあればREJECTと判定する +1. まず前回open findingsを抽出し、`new / persists / resolved` を仮判定する +2. 変更差分を確認し、品質保証の観点に基づいて問題を検出する +3. 検出した問題ごとに、Policyのスコープ判定表と判定ルールに基づいてブロッキング/非ブロッキングを分類する +4. ブロッキング問題(`new` または `persists`)が1件でもあればREJECTと判定する diff --git a/builtins/ja/instructions/review-test.md b/builtins/ja/instructions/review-test.md new file mode 100644 index 0000000..c9c4d3b --- /dev/null +++ b/builtins/ja/instructions/review-test.md @@ -0,0 +1,14 @@ +テスト品質の観点から変更をレビューしてください。 + +**レビュー観点:** +- テスト計画の観点がすべてカバーされているか +- テスト品質(Given-When-Then構造、独立性、再現性) +- テスト命名規約 +- 過不足(不要なテスト、足りないケース) +- モック・フィクスチャの適切さ + +## 判定手順 + +1. テスト計画レポート({report:00-test-plan.md})と実装されたテストを突合する +2. 検出した問題ごとに、Policyのスコープ判定表と判定ルールに基づいてブロッキング/非ブロッキングを分類する +3. ブロッキング問題が1件でもあればREJECTと判定する diff --git a/builtins/ja/output-contracts/architecture-review.md b/builtins/ja/output-contracts/architecture-review.md index 1a017e0..d6429ae 100644 --- a/builtins/ja/output-contracts/architecture-review.md +++ b/builtins/ja/output-contracts/architecture-review.md @@ -14,10 +14,15 @@ - [x] デッドコード - [x] 呼び出しチェーン検証 +## 前回Open Findings +| finding_id | 前回状態 | 今回状態(new/persists/resolved) | 根拠 | +|------------|----------|----------------------------------|------| +| ARCH-EXAMPLE-src-file-L42 | open | persists | `src/file.ts:42` | + ## 問題点(REJECTの場合) -| # | スコープ | 場所 | 問題 | 修正案 | -|---|---------|------|------|--------| -| 1 | スコープ内 | `src/file.ts:42` | 問題の説明 | 修正方法 | +| # | finding_id | 状態(new/persists) | スコープ | 場所 | 問題 | 修正案 | +|---|------------|--------------------|---------|------|------|--------| +| 1 | ARCH-EXAMPLE-src-file-L42 | new | スコープ内 | `src/file.ts:42` | 問題の説明 | 修正方法 | スコープ: 「スコープ内」(今回修正可能)/ 「スコープ外」(既存問題・非ブロッキング) diff --git a/builtins/ja/output-contracts/qa-review.md b/builtins/ja/output-contracts/qa-review.md index fddb75d..84dc0d1 100644 --- a/builtins/ja/output-contracts/qa-review.md +++ b/builtins/ja/output-contracts/qa-review.md @@ -15,8 +15,13 @@ | ドキュメント | ✅ | - | | 保守性 | ✅ | - | +## 前回Open Findings +| finding_id | 前回状態 | 今回状態(new/persists/resolved) | 根拠 | +|------------|----------|----------------------------------|------| +| QA-EXAMPLE-src-file-L42 | open | persists | `src/file.ts:42` | + ## 問題点(REJECTの場合) -| # | カテゴリ | 問題 | 修正案 | -|---|---------|------|--------| -| 1 | テスト | 問題の説明 | 修正方法 | +| # | finding_id | 状態(new/persists) | カテゴリ | 問題 | 修正案 | +|---|------------|--------------------|---------|------|--------| +| 1 | QA-EXAMPLE-src-file-L42 | new | テスト | 問題の説明 | 修正方法 | ``` diff --git a/builtins/ja/output-contracts/test-plan.md b/builtins/ja/output-contracts/test-plan.md new file mode 100644 index 0000000..13b4fe1 --- /dev/null +++ b/builtins/ja/output-contracts/test-plan.md @@ -0,0 +1,24 @@ +```markdown +# テスト計画 + +## 対象モジュール +{分析対象のモジュール一覧} + +## 既存テストの分析 +| モジュール | 既存テスト | カバレッジ状況 | +|-----------|-----------|--------------| +| `src/xxx.ts` | `xxx.test.ts` | {カバー状況} | + +## 不足テストケース +| # | 対象 | テストケース | 優先度 | 理由 | +|---|------|------------|--------|------| +| 1 | `src/xxx.ts` | {テストケース概要} | 高/中/低 | {理由} | + +## テスト方針 +- {モック戦略} +- {フィクスチャ設計} +- {テストヘルパー活用} + +## 実装ガイドライン +- {テスト実装者向けの具体的指示} +``` diff --git a/builtins/ja/personas/test-planner.md b/builtins/ja/personas/test-planner.md new file mode 100644 index 0000000..0d46331 --- /dev/null +++ b/builtins/ja/personas/test-planner.md @@ -0,0 +1,25 @@ +# Test Planner + +あなたはテスト分析と計画の専門家です。対象コードの振る舞いを理解し、既存テストのカバレッジを分析して、不足しているテストケースを体系的に洗い出す。 + +## 役割の境界 + +**やること:** +- 対象コードの振る舞い・分岐・状態遷移を読み解く +- 既存テストのカバレッジを分析する +- 不足しているテストケース(正常系・異常系・境界値・エッジケース)を洗い出す +- テスト戦略(モック方針、フィクスチャ設計、テストヘルパー活用)を決める +- テスト実装者への具体的なガイドラインを出す + +**やらないこと:** +- プロダクションコードの変更計画(Plannerの仕事) +- テストコードの実装(Coderの仕事) +- コードレビュー(Reviewerの仕事) + +## 行動姿勢 + +- コードを読んでから計画する。推測でテストケースを列挙しない +- 既存テストを必ず確認する。カバー済みの観点を重複して計画しない +- テスト優先度を付ける。ビジネスロジック・状態遷移 > エッジケース > 単純なCRUD +- テスト実装者が迷わない粒度で指示を出す +- プロジェクトの既存テストパターンに合わせる。独自の書き方を提案しない diff --git a/builtins/ja/piece-categories.yaml b/builtins/ja/piece-categories.yaml index 94263bb..f1dbf8c 100644 --- a/builtins/ja/piece-categories.yaml +++ b/builtins/ja/piece-categories.yaml @@ -10,26 +10,17 @@ piece_categories: pieces: - review-fix-minimal - review-only + - unit-test 🎨 フロントエンド: {} ⚙️ バックエンド: {} - 🔧 フルスタック: - pieces: - - expert - - expert-cqrs - 🔀 ハイブリッド (Codex Coding): - 🚀 クイックスタート: + 🔧 エキスパート: + フルスタック: pieces: - - coding-hybrid-codex - - default-hybrid-codex - - minimal-hybrid-codex - - passthrough-hybrid-codex - 🔧 フルスタック: + - expert + - expert-cqrs + リファクタリング: pieces: - - expert-cqrs-hybrid-codex - - expert-hybrid-codex - 🔍 レビュー&修正: - pieces: - - review-fix-minimal-hybrid-codex + - structural-reform その他: pieces: - research diff --git a/builtins/ja/pieces/coding-hybrid-codex.yaml b/builtins/ja/pieces/coding-hybrid-codex.yaml deleted file mode 100644 index 56106ca..0000000 --- a/builtins/ja/pieces/coding-hybrid-codex.yaml +++ /dev/null @@ -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 diff --git a/builtins/ja/pieces/expert-cqrs-hybrid-codex.yaml b/builtins/ja/pieces/expert-cqrs-hybrid-codex.yaml deleted file mode 100644 index ae78e99..0000000 --- a/builtins/ja/pieces/expert-cqrs-hybrid-codex.yaml +++ /dev/null @@ -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 diff --git a/builtins/ja/pieces/expert-hybrid-codex.yaml b/builtins/ja/pieces/expert-hybrid-codex.yaml deleted file mode 100644 index 3ed7a70..0000000 --- a/builtins/ja/pieces/expert-hybrid-codex.yaml +++ /dev/null @@ -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 diff --git a/builtins/ja/pieces/minimal-hybrid-codex.yaml b/builtins/ja/pieces/minimal-hybrid-codex.yaml deleted file mode 100644 index aaa011a..0000000 --- a/builtins/ja/pieces/minimal-hybrid-codex.yaml +++ /dev/null @@ -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 diff --git a/builtins/ja/pieces/passthrough-hybrid-codex.yaml b/builtins/ja/pieces/passthrough-hybrid-codex.yaml deleted file mode 100644 index 8ebc7ac..0000000 --- a/builtins/ja/pieces/passthrough-hybrid-codex.yaml +++ /dev/null @@ -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 diff --git a/builtins/ja/pieces/review-fix-minimal-hybrid-codex.yaml b/builtins/ja/pieces/review-fix-minimal-hybrid-codex.yaml deleted file mode 100644 index 3cb2f1a..0000000 --- a/builtins/ja/pieces/review-fix-minimal-hybrid-codex.yaml +++ /dev/null @@ -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 diff --git a/builtins/ja/pieces/structural-reform.yaml b/builtins/ja/pieces/structural-reform.yaml new file mode 100644 index 0000000..2445b6d --- /dev/null +++ b/builtins/ja/pieces/structural-reform.yaml @@ -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 + - 検出された問題: N(Critical: 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 diff --git a/builtins/ja/pieces/default-hybrid-codex.yaml b/builtins/ja/pieces/unit-test.yaml similarity index 69% rename from builtins/ja/pieces/default-hybrid-codex.yaml rename to builtins/ja/pieces/unit-test.yaml index 68ea3cc..ea8ad38 100644 --- a/builtins/ja/pieces/default-hybrid-codex.yaml +++ b/builtins/ja/pieces/unit-test.yaml @@ -1,37 +1,32 @@ -# Auto-generated from default.yaml by tools/generate-hybrid-codex.mjs -# Do not edit manually. Edit the source piece and re-run the generator. - -name: default-hybrid-codex -description: Standard development piece with planning and specialized reviews -max_iterations: 30 +name: unit-test +description: 単体テスト追加に特化したピース(テスト分析→テスト実装→レビュー→修正) +max_iterations: 20 +policies: + coding: ../policies/coding.md + review: ../policies/review.md + testing: ../policies/testing.md + ai-antipattern: ../policies/ai-antipattern.md + qa: ../policies/qa.md knowledge: architecture: ../knowledge/architecture.md backend: ../knowledge/backend.md personas: - planner: ../personas/planner.md + test-planner: ../personas/test-planner.md coder: ../personas/coder.md ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md architecture-reviewer: ../personas/architecture-reviewer.md qa-reviewer: ../personas/qa-reviewer.md supervisor: ../personas/supervisor.md instructions: - plan: ../instructions/plan.md - implement: ../instructions/implement.md + plan-test: ../instructions/plan-test.md + implement-test: ../instructions/implement-test.md ai-review: ../instructions/ai-review.md ai-fix: ../instructions/ai-fix.md arbitrate: ../instructions/arbitrate.md - review-arch: ../instructions/review-arch.md - review-qa: ../instructions/review-qa.md + review-test: ../instructions/review-test.md fix: ../instructions/fix.md supervise: ../instructions/supervise.md -report_formats: - 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 +initial_movement: plan_test loop_monitors: - cycle: - ai_review @@ -56,12 +51,15 @@ loop_monitors: - condition: 健全(進捗あり) next: ai_review - condition: 非生産的(改善なし) - next: reviewers + next: review_test movements: - - name: plan + - name: plan_test edit: false - persona: planner - knowledge: architecture + persona: test-planner + policy: testing + knowledge: + - architecture + - backend allowed_tools: - Read - Glob @@ -70,9 +68,9 @@ movements: - WebSearch - WebFetch rules: - - condition: 要件が明確で実装可能 - next: implement - - condition: ユーザーが質問をしている(実装タスクではない) + - condition: テスト計画が完了 + next: implement_test + - condition: ユーザーが質問をしている(テスト追加タスクではない) next: COMPLETE - condition: 要件が不明確、情報不足 next: ABORT @@ -80,15 +78,15 @@ movements: 確認事項: - {質問1} - {質問2} - instruction: plan + instruction: plan-test output_contracts: report: - - name: 00-plan.md - format: plan - - name: implement + - name: 00-test-plan.md + format: test-plan + + - name: implement_test edit: true persona: coder - provider: codex policy: - coding - testing @@ -107,21 +105,22 @@ movements: - WebFetch permission_mode: edit rules: - - condition: 実装完了 + - condition: テスト実装完了 next: ai_review - condition: 実装未着手(レポートのみ) next: ai_review - condition: 判断できない、情報不足 next: ai_review - condition: ユーザー入力が必要 - next: implement + next: implement_test requires_user_input: true interactive_only: true - instruction: implement + instruction: implement-test output_contracts: report: - Scope: 02-coder-scope.md - Decisions: 03-coder-decisions.md + - name: ai_review edit: false persona: ai-antipattern-reviewer @@ -136,7 +135,7 @@ movements: - WebFetch rules: - condition: AI特有の問題なし - next: reviewers + next: review_test - condition: AI特有の問題あり next: ai_fix instruction: ai-review @@ -144,10 +143,10 @@ movements: report: - name: 04-ai-review.md format: ai-review + - name: ai_fix edit: true persona: coder - provider: codex policy: - coding - testing @@ -173,6 +172,7 @@ movements: - condition: 判断できない、情報不足 next: ai_no_fix instruction: ai-fix + - name: ai_no_fix edit: false persona: architecture-reviewer @@ -185,63 +185,39 @@ movements: - condition: ai_reviewの指摘が妥当(修正すべき) next: ai_fix - condition: ai_fixの判断が妥当(修正不要) - next: reviewers + next: review_test 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: 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 + + - name: review_test + edit: false + persona: qa-reviewer + policy: + - review + - qa + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch rules: - - condition: all("approved") + - condition: approved next: supervise - - condition: any("needs_fix") + - condition: needs_fix next: fix + instruction: review-test + output_contracts: + report: + - name: 05-qa-review.md + format: qa-review + - name: fix edit: true persona: coder - provider: codex policy: - coding - testing + session: refresh knowledge: - backend - architecture @@ -257,10 +233,11 @@ movements: permission_mode: edit rules: - condition: 修正完了 - next: reviewers + next: review_test - condition: 判断できない、情報不足 - next: plan + next: plan_test instruction: fix + - name: supervise edit: false persona: supervisor @@ -276,15 +253,15 @@ movements: - condition: すべて問題なし next: COMPLETE - condition: 要求未達成、テスト失敗、ビルドエラー - next: plan + next: plan_test instruction: supervise output_contracts: report: - - Validation: 07-supervisor-validation.md + - Validation: 06-supervisor-validation.md - Summary: summary.md -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md - qa: ../policies/qa.md +report_formats: + test-plan: ../output-contracts/test-plan.md + ai-review: ../output-contracts/ai-review.md + qa-review: ../output-contracts/qa-review.md + validation: ../output-contracts/validation.md + summary: ../output-contracts/summary.md diff --git a/builtins/ja/policies/review.md b/builtins/ja/policies/review.md index 3eed9bd..77d91b8 100644 --- a/builtins/ja/policies/review.md +++ b/builtins/ja/policies/review.md @@ -86,6 +86,18 @@ 共通関数に抽出してください」 ``` +## 指摘ID管理(finding_id) + +同じ指摘の堂々巡りを防ぐため、指摘をIDで追跡する。 + +- REJECT時に挙げる各問題には `finding_id` を必須で付ける +- 同じ問題を再指摘する場合は、同じ `finding_id` を再利用する +- 再指摘時は状態を `persists` とし、未解決である根拠(ファイル/行)を必ず示す +- 新規指摘は状態 `new` とする +- 解消済みは状態 `resolved` として一覧化する +- `finding_id` のない指摘は無効(判定根拠として扱わない) +- REJECTは `new` または `persists` の問題が1件以上ある場合のみ許可する + ## ボーイスカウトルール 来たときよりも美しく。 diff --git a/docs/README.ja.md b/docs/README.ja.md index 8121444..e492d77 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -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 と相談しながら内容を整理したい場合に便利です。 @@ -88,13 +92,25 @@ takt takt hello ``` -**注意:** Issue 参照(`#6`)や `--task` / `--issue` オプションを指定すると、対話モードをスキップして直接タスク実行されます。それ以外の入力(スペースを含む文字列を含む)はすべて対話モードに入ります。 +**注意:** `--task` オプションを指定すると対話モードをスキップして直接タスク実行されます。Issue 参照(`#6`、`--issue`)は対話モードの初期入力として使用されます。 **フロー:** 1. ピース選択 -2. AI との会話でタスク内容を整理 -3. `/go` でタスク指示を確定(`/go 追加の指示` のように指示を追加することも可能)、または `/play <タスク>` で即座に実行 -4. 実行(worktree 作成、ピース実行、PR 作成) +2. 対話モード選択(assistant / persona / quiet / passthrough) +3. AI との会話でタスク内容を整理 +4. `/go` でタスク指示を確定(`/go 追加の指示` のように指示を追加することも可能)、または `/play <タスク>` で即座に実行 +5. 実行(worktree 作成、ピース実行、PR 作成) + +#### 対話モードの種類 + +| モード | 説明 | +|--------|------| +| `assistant` | デフォルト。AI が質問を通じてタスク要件を明確にしてから指示を生成。 | +| `persona` | 最初のムーブメントのペルソナとの会話(ペルソナのシステムプロンプトとツールを使用)。 | +| `quiet` | 質問なしでタスク指示を生成(ベストエフォート)。 | +| `passthrough` | ユーザー入力をそのままタスクテキストとして使用。AI 処理なし。 | + +ピースの `interactive_mode` フィールドでデフォルトモードを設定可能。 #### 実行例 @@ -447,8 +463,10 @@ TAKTには複数のビルトインピースが同梱されています: | `passthrough` | 最小構成。タスクをそのまま coder に渡す薄いラッパー。レビューなし。 | | `compound-eye` | マルチモデルレビュー: Claude と Codex に同じ指示を同時送信し、両方の回答を統合。 | | `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` でピースを切り替えられます。 @@ -471,6 +489,7 @@ TAKTには複数のビルトインピースが同梱されています: | **research-planner** | リサーチタスクの計画・スコープ定義 | | **research-digger** | 深掘り調査と情報収集 | | **research-supervisor** | リサーチ品質の検証と網羅性の評価 | +| **test-planner** | テスト戦略分析と包括的なテスト計画 | | **pr-commenter** | レビュー結果を GitHub PR にコメントとして投稿 | ## カスタムペルソナ @@ -539,8 +558,15 @@ branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)ま prevent_sleep: false # macOS の実行中スリープ防止(caffeinate) notification_sound: true # 通知音の有効/無効 concurrency: 1 # takt run の並列タスク数(1-10、デフォルト: 1 = 逐次実行) +task_poll_interval_ms: 500 # takt run 中の新タスク検出ポーリング間隔(100-5000、デフォルト: 500) interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数(0-10、デフォルト: 3) +# ペルソナ別プロバイダー設定(オプション) +# ピースを複製せずに特定のペルソナを異なるプロバイダーにルーティング +# persona_providers: +# coder: codex # coder を Codex で実行 +# ai-antipattern-reviewer: claude # レビュアーは Claude のまま + # API Key 設定(オプション) # 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY で上書き可能 anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合 diff --git a/docs/implements/retry-and-session.ja.md b/docs/implements/retry-and-session.ja.md new file mode 100644 index 0000000..c43c0c2 --- /dev/null +++ b/docs/implements/retry-and-session.ja.md @@ -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` に再試行理由と前提を明記する。 diff --git a/e2e/helpers/test-repo.ts b/e2e/helpers/test-repo.ts index 6b07aa8..8c57f4e 100644 --- a/e2e/helpers/test-repo.ts +++ b/e2e/helpers/test-repo.ts @@ -11,6 +11,11 @@ export interface TestRepo { cleanup: () => void; } +export interface CreateTestRepoOptions { + /** Skip creating a test branch (stay on default branch). Use for pipeline tests. */ + skipBranch?: boolean; +} + function getGitHubUser(): string { const user = execFileSync('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf-8', @@ -33,7 +38,7 @@ function getGitHubUser(): string { * 2. Close any PRs created during the test * 3. Delete local directory */ -export function createTestRepo(): TestRepo { +export function createTestRepo(options?: CreateTestRepoOptions): TestRepo { const user = getGitHubUser(); const repoName = `${user}/takt-testing`; @@ -56,49 +61,80 @@ export function createTestRepo(): TestRepo { stdio: 'pipe', }); - // Create test branch - const testBranch = `e2e-test-${Date.now()}`; - execFileSync('git', ['checkout', '-b', testBranch], { - cwd: repoPath, - stdio: 'pipe', - }); + // Create test branch (unless skipped for pipeline tests) + const testBranch = options?.skipBranch + ? undefined + : `e2e-test-${Date.now()}`; + 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 { path: repoPath, repoName, - branch: testBranch, + branch: currentBranch, cleanup: () => { - // 1. Delete remote branch (best-effort) - try { - execFileSync( - '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)) { + if (testBranch) { + // 1. Delete remote branch (best-effort) + try { execFileSync( - 'gh', - ['pr', 'close', prNumber, '--repo', repoName, '--delete-branch'], - { stdio: 'pipe' }, + '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( + '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 { rmSync(repoPath, { recursive: true, force: true }); } catch { diff --git a/e2e/specs/github-issue.e2e.ts b/e2e/specs/github-issue.e2e.ts index 2ed1995..86ee439 100644 --- a/e2e/specs/github-issue.e2e.ts +++ b/e2e/specs/github-issue.e2e.ts @@ -17,7 +17,7 @@ describe('E2E: GitHub Issue processing', () => { beforeEach(() => { isolatedEnv = createIsolatedEnv(); - testRepo = createTestRepo(); + testRepo = createTestRepo({ skipBranch: true }); // Create a test issue const createOutput = execFileSync( diff --git a/e2e/specs/pipeline.e2e.ts b/e2e/specs/pipeline.e2e.ts index 1143953..3eb9b74 100644 --- a/e2e/specs/pipeline.e2e.ts +++ b/e2e/specs/pipeline.e2e.ts @@ -16,7 +16,7 @@ describe('E2E: Pipeline mode (--pipeline --auto-pr)', () => { beforeEach(() => { isolatedEnv = createIsolatedEnv(); - testRepo = createTestRepo(); + testRepo = createTestRepo({ skipBranch: true }); }); afterEach(() => { diff --git a/package-lock.json b/package-lock.json index f0ab485..4e2d30a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "takt", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.9.0", + "version": "0.10.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.37", diff --git a/package.json b/package.json index 46bd391..5c03c7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "takt", - "version": "0.9.0", + "version": "0.10.0", "description": "TAKT: Task Agent Koordination Tool - AI Agent Piece Orchestration", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index b622913..41c53b1 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -45,6 +45,11 @@ vi.mock('../features/pipeline/index.js', () => ({ vi.mock('../features/interactive/index.js', () => ({ 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', () => ({ diff --git a/src/__tests__/engine-parallel.test.ts b/src/__tests__/engine-parallel.test.ts index d9e9bbb..11b88cf 100644 --- a/src/__tests__/engine-parallel.test.ts +++ b/src/__tests__/engine-parallel.test.ts @@ -160,4 +160,71 @@ describe('PieceEngine Integration: Parallel Movement Aggregation', () => { expect(calledAgents).toContain('../personas/arch-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>([ + ['../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'); + }); }); diff --git a/src/__tests__/engine-persona-providers.test.ts b/src/__tests__/engine-persona-providers.test.ts new file mode 100644 index 0000000..afb371c --- /dev/null +++ b/src/__tests__/engine-persona-providers.test.ts @@ -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>()), + 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'); + }); +}); diff --git a/src/__tests__/facet-resolution.test.ts b/src/__tests__/facet-resolution.test.ts index c863338..3070ecc 100644 --- a/src/__tests__/facet-resolution.test.ts +++ b/src/__tests__/facet-resolution.test.ts @@ -158,6 +158,21 @@ describe('resolveRefToContent with layer resolution', () => { // No context, no file — returns the spec as-is (inline content behavior) 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', () => { diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 484c419..3c62adf 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -336,6 +336,63 @@ describe('loadGlobalConfig', () => { 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', () => { it('should throw when provider is codex but model is a Claude alias (opus)', () => { const taktDir = join(testHomeDir, '.takt'); diff --git a/src/__tests__/interactive-mode.test.ts b/src/__tests__/interactive-mode.test.ts new file mode 100644 index 0000000..93c1028 --- /dev/null +++ b/src/__tests__/interactive-mode.test.ts @@ -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>()), + 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>()), + 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 }; + 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 }; + 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 }; + 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 }; + 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.'); + }); +}); diff --git a/src/__tests__/it-piece-loader.test.ts b/src/__tests__/it-piece-loader.test.ts index 53b7efa..5037bc3 100644 --- a/src/__tests__/it-piece-loader.test.ts +++ b/src/__tests__/it-piece-loader.test.ts @@ -25,6 +25,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({ // --- Imports (after mocks) --- import { loadPiece } from '../infra/config/index.js'; +import { listBuiltinPieceNames } from '../infra/config/loaders/pieceResolver.js'; // --- Test helpers --- @@ -45,7 +46,7 @@ describe('Piece Loader IT: builtin piece loading', () => { 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) { 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', () => { let testDir: string; diff --git a/src/__tests__/lineEditor.test.ts b/src/__tests__/lineEditor.test.ts index d081a50..ac7459c 100644 --- a/src/__tests__/lineEditor.test.ts +++ b/src/__tests__/lineEditor.test.ts @@ -131,9 +131,11 @@ describe('readMultilineInput cursor navigation', () => { let savedStdinRemoveListener: typeof process.stdin.removeListener; let savedStdinResume: typeof process.stdin.resume; let savedStdinPause: typeof process.stdin.pause; + let savedColumns: number | undefined; + let columnsOverridden = false; let stdoutCalls: string[]; - function setupRawStdin(rawInputs: string[]): void { + function setupRawStdin(rawInputs: string[], termColumns?: number): void { savedIsTTY = process.stdin.isTTY; savedIsRaw = process.stdin.isRaw; savedSetRawMode = process.stdin.setRawMode; @@ -142,6 +144,13 @@ describe('readMultilineInput cursor navigation', () => { savedStdinRemoveListener = process.stdin.removeListener; savedStdinResume = process.stdin.resume; 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, 'isRaw', { value: false, configurable: true, writable: true }); @@ -197,6 +206,10 @@ describe('readMultilineInput cursor navigation', () => { if (savedStdinRemoveListener) process.stdin.removeListener = savedStdinRemoveListener; if (savedStdinResume) process.stdin.resume = savedStdinResume; if (savedStdinPause) process.stdin.pause = savedStdinPause; + if (columnsOverridden) { + Object.defineProperty(process.stdout, 'columns', { value: savedColumns, configurable: true, writable: true }); + columnsOverridden = false; + } } beforeEach(() => { @@ -611,4 +624,338 @@ describe('readMultilineInput cursor navigation', () => { 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'); + }); + }); }); diff --git a/src/__tests__/parallel-logger.test.ts b/src/__tests__/parallel-logger.test.ts index 36af15f..5a4113c 100644 --- a/src/__tests__/parallel-logger.test.ts +++ b/src/__tests__/parallel-logger.test.ts @@ -67,6 +67,29 @@ describe('ParallelLogger', () => { // No padding needed (0 spaces) 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', () => { diff --git a/src/__tests__/pieceExecution-debug-prompts.test.ts b/src/__tests__/pieceExecution-debug-prompts.test.ts index 7d9ca04..c99bc19 100644 --- a/src/__tests__/pieceExecution-debug-prompts.test.ts +++ b/src/__tests__/pieceExecution-debug-prompts.test.ts @@ -14,10 +14,12 @@ const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted(( class MockPieceEngine extends EE { 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(); this.config = config; + this.task = task; } abort(): void {} @@ -26,6 +28,7 @@ const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted(( const step = this.config.movements[0]!; 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('phase:start', step, 1, 'execute', 'phase prompt'); this.emit('phase:complete', step, 1, 'execute', 'phase response', 'done'); @@ -40,9 +43,23 @@ const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted(( }, '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 }); - 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(); }); + + 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'); + }); }); diff --git a/src/__tests__/pieceResolver.test.ts b/src/__tests__/pieceResolver.test.ts index c58da23..a6b1c4a 100644 --- a/src/__tests__/pieceResolver.test.ts +++ b/src/__tests__/pieceResolver.test.ts @@ -563,3 +563,215 @@ movements: 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); + } + }); +}); diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 258890b..1bc7ccd 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -14,6 +14,7 @@ vi.mock('../infra/config/index.js', () => ({ defaultPiece: 'default', logLevel: 'info', concurrency: 1, + taskPollIntervalMs: 500, })), })); @@ -142,6 +143,7 @@ describe('runAllTasks concurrency', () => { defaultPiece: 'default', logLevel: 'info', concurrency: 1, + taskPollIntervalMs: 500, }); }); @@ -182,6 +184,7 @@ describe('runAllTasks concurrency', () => { defaultPiece: 'default', logLevel: 'info', concurrency: 3, + taskPollIntervalMs: 500, }); }); @@ -209,13 +212,25 @@ describe('runAllTasks concurrency', () => { .mockReturnValueOnce([task1, task2, task3]) .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 await runAllTasks('/project'); + writeSpy.mockRestore(); - // Then: Task names displayed - expect(mockInfo).toHaveBeenCalledWith('=== Task: task-1 ==='); - expect(mockInfo).toHaveBeenCalledWith('=== Task: task-2 ==='); - expect(mockInfo).toHaveBeenCalledWith('=== Task: task-3 ==='); + // Then: Task names displayed with prefix in stdout + const allOutput = stdoutChunks.join(''); + expect(allOutput).toContain('[task]'); + 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'); }); @@ -245,6 +260,7 @@ describe('runAllTasks concurrency', () => { defaultPiece: 'default', logLevel: 'info', concurrency: 1, + taskPollIntervalMs: 500, }); const task1 = createTask('task-1'); @@ -277,6 +293,7 @@ describe('runAllTasks concurrency', () => { defaultPiece: 'default', logLevel: 'info', concurrency: 3, + taskPollIntervalMs: 500, }); // Return a valid piece config so executeTask reaches executePiece mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never); @@ -323,6 +340,7 @@ describe('runAllTasks concurrency', () => { defaultPiece: 'default', logLevel: 'info', concurrency: 2, + taskPollIntervalMs: 500, }); const task1 = createTask('fast'); @@ -412,6 +430,7 @@ describe('runAllTasks concurrency', () => { defaultPiece: 'default', logLevel: 'info', concurrency: 1, + taskPollIntervalMs: 500, }); const task1 = createTask('sequential-task'); diff --git a/src/__tests__/task-prefix-writer.test.ts b/src/__tests__/task-prefix-writer.test.ts new file mode 100644 index 0000000..8cc4fdb --- /dev/null +++ b/src/__tests__/task-prefix-writer.test.ts @@ -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'); + }); + }); + +}); diff --git a/src/__tests__/workerPool.test.ts b/src/__tests__/workerPool.test.ts index 4624d18..c1b2896 100644 --- a/src/__tests__/workerPool.test.ts +++ b/src/__tests__/workerPool.test.ts @@ -23,6 +23,15 @@ vi.mock('../shared/i18n/index.js', () => ({ getLabel: vi.fn((key: string) => key), })); +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + const mockExecuteAndCompleteTask = vi.fn(); vi.mock('../features/tasks/execute/taskExecution.js', () => ({ @@ -34,6 +43,8 @@ import { info } from '../shared/ui/index.js'; const mockInfo = vi.mocked(info); +const TEST_POLL_INTERVAL_MS = 50; + function createTask(name: string): TaskInfo { return { name, @@ -68,7 +79,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // 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 expect(result).toEqual({ success: 2, fail: 0 }); @@ -85,23 +96,32 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // 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 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 const tasks = [createTask('alpha'), createTask('beta')]; const runner = createMockTaskRunner([]); + const stdoutChunks: string[] = []; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + stdoutChunks.push(String(chunk)); + return true; + }); // 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 - expect(mockInfo).toHaveBeenCalledWith('=== Task: alpha ==='); - expect(mockInfo).toHaveBeenCalledWith('=== Task: beta ==='); + // Then: Task names appear in prefixed stdout output + writeSpy.mockRestore(); + 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 () => { @@ -110,7 +130,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // 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 expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); @@ -118,6 +138,7 @@ describe('runWithWorkerPool', () => { expect(parallelOpts).toEqual({ abortSignal: expect.any(AbortSignal), taskPrefix: 'my-task', + taskColorIndex: 0, }); }); @@ -127,7 +148,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // 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 expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); @@ -135,6 +156,7 @@ describe('runWithWorkerPool', () => { expect(parallelOpts).toEqual({ abortSignal: undefined, taskPrefix: undefined, + taskColorIndex: undefined, }); }); @@ -145,7 +167,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([[task2]]); // 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 expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(2); @@ -173,7 +195,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // 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 expect(maxActive).toBeLessThanOrEqual(2); @@ -192,7 +214,7 @@ describe('runWithWorkerPool', () => { }); // 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 expect(receivedSignals).toHaveLength(3); @@ -208,7 +230,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // 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 expect(result).toEqual({ success: 0, fail: 0 }); @@ -222,9 +244,107 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // 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 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 }); + }); + }); }); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 92cf323..cb54c93 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -100,6 +100,7 @@ export class AgentRunner { ): ProviderCallOptions { return { cwd: options.cwd, + abortSignal: options.abortSignal, sessionId: options.sessionId, allowedTools: options.allowedTools ?? agentConfig?.allowedTools, mcpServers: options.mcpServers, diff --git a/src/agents/types.ts b/src/agents/types.ts index e739c84..cdfd2a6 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -10,6 +10,7 @@ export type { StreamCallback }; /** Common options for running agents */ export interface RunAgentOptions { cwd: string; + abortSignal?: AbortSignal; sessionId?: string; model?: string; provider?: 'claude' | 'codex' | 'mock'; diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 279be15..53dff76 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -7,10 +7,19 @@ import { info, error } from '../../shared/ui/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 { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/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 { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; import { program, resolvedCwd, pipelineMode } from './program.js'; @@ -118,16 +127,57 @@ export async function executeDefaultAction(task?: string): Promise { } // All paths below go through interactive mode + const globalConfig = loadGlobalConfig(); + const lang = resolveLanguage(globalConfig.language); + const pieceId = await determinePiece(resolvedCwd, selectOptions.piece); if (pieceId === null) { - info('Cancelled'); + info(getLabel('interactive.ui.cancelled', lang)); return; } - const globalConfig = loadGlobalConfig(); const previewCount = globalConfig.interactivePreviewMovements; - const pieceContext = getPieceDescription(pieceId, resolvedCwd, previewCount); - const result = await interactiveMode(resolvedCwd, initialInput, pieceContext); + const pieceDesc = getPieceDescription(pieceId, resolvedCwd, previewCount); + + // 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) { case 'execute': diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 4ca9f75..26a7b43 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -61,6 +61,8 @@ export interface GlobalConfig { bookmarksFile?: string; /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ pieceCategoriesFile?: string; + /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ + personaProviders?: Record; /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branchNameStrategy?: 'romaji' | 'ai'; /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ @@ -71,6 +73,8 @@ export interface GlobalConfig { interactivePreviewMovements?: number; /** Number of tasks to run concurrently in takt run (default: 1 = sequential) */ concurrency: number; + /** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */ + taskPollIntervalMs: number; } /** Project-level configuration */ diff --git a/src/core/models/index.ts b/src/core/models/index.ts index 17abed1..166becd 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -35,6 +35,9 @@ export * from './config.js'; // Re-export from schemas.ts 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) export { createSessionState, diff --git a/src/core/models/interactive-mode.ts b/src/core/models/interactive-mode.ts new file mode 100644 index 0000000..733b477 --- /dev/null +++ b/src/core/models/interactive-mode.ts @@ -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'; diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index 9fb954e..aec8147 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -4,6 +4,7 @@ import type { PermissionMode } from './status.js'; import type { AgentResponse } from './response.js'; +import type { InteractiveMode } from './interactive-mode.js'; /** Rule-based transition configuration (unified format) */ export interface PieceRule { @@ -184,6 +185,8 @@ export interface PieceConfig { * instead of prompting the user interactively. */ answerAgent?: string; + /** Default interactive mode for this piece (overrides user default) */ + interactiveMode?: InteractiveMode; } /** Runtime state of a piece execution */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 7ca1f7f..9b0dcbe 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -7,6 +7,7 @@ import { z } from 'zod/v4'; import { DEFAULT_LANGUAGE } from '../../shared/constants.js'; import { McpServersSchema } from './mcp-schemas.js'; +import { INTERACTIVE_MODES } from './interactive-mode.js'; export { McpServerConfigSchema, McpServersSchema } from './mcp-schemas.js'; @@ -218,6 +219,9 @@ export const LoopMonitorSchema = z.object({ judge: LoopMonitorJudgeSchema, }); +/** Interactive mode schema for piece-level default */ +export const InteractiveModeSchema = z.enum(INTERACTIVE_MODES); + /** Piece configuration schema - raw YAML format */ export const PieceConfigRawSchema = z.object({ name: z.string().min(1), @@ -237,6 +241,8 @@ export const PieceConfigRawSchema = z.object({ max_iterations: z.number().int().positive().optional().default(10), loop_monitors: z.array(LoopMonitorSchema).optional(), answer_agent: z.string().optional(), + /** Default interactive mode for this piece (overrides user default) */ + interactive_mode: InteractiveModeSchema.optional(), }); /** Custom agent configuration schema */ @@ -312,6 +318,8 @@ export const GlobalConfigSchema = z.object({ bookmarks_file: z.string().optional(), /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ 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_strategy: z.enum(['romaji', 'ai']).optional(), /** 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), /** 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), + /** 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 */ diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index ffdcb34..9114ca0 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -33,8 +33,9 @@ export class OptionsBuilder { return { cwd: this.getCwd(), + abortSignal: this.engineOptions.abortSignal, 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, permissionMode: step.permissionMode, language: this.getLanguage(), diff --git a/src/core/piece/engine/ParallelRunner.ts b/src/core/piece/engine/ParallelRunner.ts index 6cdf8f1..a6f9ba5 100644 --- a/src/core/piece/engine/ParallelRunner.ts +++ b/src/core/piece/engine/ParallelRunner.ts @@ -20,6 +20,7 @@ import { buildSessionKey } from '../session-key.js'; import type { OptionsBuilder } from './OptionsBuilder.js'; import type { MovementExecutor } from './MovementExecutor.js'; import type { PieceEngineOptions, PhaseName } from '../types.js'; +import type { ParallelLoggerOptions } from './parallel-logger.js'; const log = createLogger('parallel-runner'); @@ -69,14 +70,7 @@ export class ParallelRunner { // Create parallel logger for prefixed output (only when streaming is enabled) const parallelLogger = this.deps.engineOptions.onStream - ? new ParallelLogger({ - subMovementNames: subMovements.map((s) => s.name), - parentOnStream: this.deps.engineOptions.onStream, - progressInfo: { - iteration: state.iteration, - maxIterations, - }, - }) + ? new ParallelLogger(this.buildParallelLoggerOptions(step.name, movementIteration, subMovements.map((s) => s.name), state.iteration, maxIterations)) : undefined; const ruleCtx = { @@ -202,4 +196,33 @@ export class ParallelRunner { 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; + } + } diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index 849294d..d04c984 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -69,6 +69,7 @@ export class PieceEngine extends EventEmitter { constructor(config: PieceConfig, cwd: string, task: string, options: PieceEngineOptions) { super(); + this.assertTaskPrefixPair(options.taskPrefix, options.taskColorIndex); this.config = config; this.projectCwd = options.projectCwd; 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) */ private ensureReportDirExists(): void { const reportDirPath = join(this.cwd, this.reportDir); diff --git a/src/core/piece/engine/parallel-logger.ts b/src/core/piece/engine/parallel-logger.ts index e3f2fd4..9994ef5 100644 --- a/src/core/piece/engine/parallel-logger.ts +++ b/src/core/piece/engine/parallel-logger.ts @@ -30,6 +30,14 @@ export interface ParallelLoggerOptions { writeFn?: (text: string) => void; /** Progress information for display */ 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 progressInfo?: ParallelProgressInfo; private readonly totalSubMovements: number; + private readonly taskLabel?: string; + private readonly taskColorIndex?: number; + private readonly parentMovementName?: string; + private readonly movementIteration?: number; constructor(options: ParallelLoggerOptions) { 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.progressInfo = options.progressInfo; 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) { this.lineBuffers.set(name, ''); @@ -65,6 +81,12 @@ export class ParallelLogger { * Format: `\x1b[COLORm[name](iteration/max) step index/total\x1b[0m` + padding spaces */ 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 padding = ' '.repeat(this.maxNameLength - name.length); diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index af132f1..82b681c 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -153,6 +153,7 @@ export type IterationLimitCallback = (request: IterationLimitRequest) => Promise /** Options for piece engine */ export interface PieceEngineOptions { + abortSignal?: AbortSignal; /** Callback for streaming real-time output */ onStream?: StreamCallback; /** Callback for requesting user input when an agent is blocked */ @@ -177,6 +178,8 @@ export interface PieceEngineOptions { language?: Language; provider?: ProviderType; model?: string; + /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ + personaProviders?: Record; /** Enable interactive-only rules and user-input transitions */ interactive?: boolean; /** Rule tag index detector (required for rules evaluation) */ @@ -187,6 +190,10 @@ export interface PieceEngineOptions { startMovement?: string; /** Retry note explaining why task is being retried */ 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 */ diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts new file mode 100644 index 0000000..45c3989 --- /dev/null +++ b/src/features/interactive/conversationLoop.ts @@ -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; + 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 { + const history: ConversationMessage[] = []; + let sessionId = ctx.sessionId; + const ui = getLabelObject('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 { + 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(); + } + } +} diff --git a/src/features/interactive/index.ts b/src/features/interactive/index.ts index fc5c54b..66b5e9d 100644 --- a/src/features/interactive/index.ts +++ b/src/features/interactive/index.ts @@ -4,7 +4,17 @@ export { interactiveMode, + resolveLanguage, + buildSummaryPrompt, + selectPostSummaryAction, + formatMovementPreviews, + formatSessionStatus, type PieceContext, type InteractiveModeResult, type InteractiveModeAction, } from './interactive.js'; + +export { selectInteractiveMode } from './modeSelection.js'; +export { passthroughMode } from './passthroughMode.js'; +export { quietMode } from './quietMode.js'; +export { personaMode } from './personaMode.js'; diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 5afe470..0e67737 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -10,29 +10,23 @@ * /cancel - Cancel and exit */ -import chalk from 'chalk'; import type { Language } from '../../core/models/index.js'; import { - loadGlobalConfig, - loadPersonaSessions, - updatePersonaSession, - loadSessionState, - clearSessionState, type SessionState, type MovementPreview, } 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 { createLogger, getErrorMessage } from '../../shared/utils/index.js'; -import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; +import { info, blankLine } from '../../shared/ui/index.js'; import { loadTemplate } from '../../shared/prompts/index.js'; import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; -import { readMultilineInput } from './lineEditor.js'; -const log = createLogger('interactive'); +import { + initializeSession, + displayAndClearSessionState, + runConversationLoop, +} from './conversationLoop.js'; /** Shape of interactive UI text */ -interface InteractiveUIText { +export interface InteractiveUIText { intro: string; resume: string; noConversation: string; @@ -53,7 +47,7 @@ interface InteractiveUIText { /** * 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[] = []; // Status line @@ -87,7 +81,7 @@ function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): string { return lines.join('\n'); } -function resolveLanguage(lang?: Language): 'en' | 'ja' { +export function resolveLanguage(lang?: Language): 'en' | 'ja' { return lang === 'ja' ? 'ja' : 'en'; } @@ -122,37 +116,11 @@ export function formatMovementPreviews(previews: MovementPreview[], lang: 'en' | }).join('\n\n'); } -function getInteractivePrompts(lang: 'en' | 'ja', pieceContext?: PieceContext) { - 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('interactive.ui', lang), - }; -} - -interface ConversationMessage { +export interface ConversationMessage { role: 'user' | 'assistant'; content: string; } -interface CallAIResult { - content: string; - sessionId?: string; - success: boolean; -} - /** * 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. * Returns empty string if there is no conversation to summarize. */ -function buildSummaryPrompt( +export function buildSummaryPrompt( history: ConversationMessage[], hasSession: boolean, 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, proposedLabel: string, 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, - prompt: string, - cwd: string, - model: string | undefined, - sessionId: string | undefined, - display: StreamDisplay, - systemPrompt: string, -): Promise { - 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 interface InteractiveModeResult { @@ -266,6 +206,8 @@ export interface PieceContext { movementPreviews?: MovementPreview[]; } +export const DEFAULT_INTERACTIVE_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; + /** * Run the interactive task input mode. * @@ -280,206 +222,37 @@ export async function interactiveMode( initialInput?: string, pieceContext?: PieceContext, ): Promise { - const globalConfig = loadGlobalConfig(); - 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 ctx = initializeSession(cwd, 'interactive'); - const history: ConversationMessage[] = []; - const personaName = 'interactive'; - const savedSessions = loadPersonaSessions(cwd, providerType); - let sessionId: string | undefined = savedSessions[personaName]; + displayAndClearSessionState(cwd, ctx.lang); - // Load and display previous task state - const sessionState = loadSessionState(cwd); - if (sessionState) { - const statusLabel = formatSessionStatus(sessionState, lang); - info(statusLabel); - blankLine(); - clearSessionState(cwd); - } - - 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 { - 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; - } - } + const hasPreview = !!pieceContext?.movementPreviews?.length; + const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, { + hasPiecePreview: hasPreview, + pieceStructure: pieceContext?.pieceStructure ?? '', + movementDetails: hasPreview ? formatMovementPreviews(pieceContext!.movementPreviews!, ctx.lang) : '', + }); + const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {}); + const ui = getLabelObject('interactive.ui', ctx.lang); /** * Inject policy into user message for AI call. * Follows the same pattern as piece execution (perform_phase1_message.md). */ function injectPolicy(userMessage: string): string { - const policyIntro = lang === 'ja' + const policyIntro = ctx.lang === 'ja' ? '以下のポリシーは行動規範です。必ず遵守してください。' : 'The following policy defines behavioral guidelines. Please follow them.'; - const reminderLabel = lang === 'ja' + const reminderLabel = ctx.lang === 'ja' ? '上記の Policy セクションで定義されたポリシー規範を遵守してください。' : '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`) - if (initialInput) { - history.push({ role: 'user', content: initialInput }); - log.debug('Processing initial input', { initialInput, sessionId }); - - const promptWithPolicy = injectPolicy(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(); - } - } + return runConversationLoop(cwd, ctx, { + systemPrompt, + allowedTools: DEFAULT_INTERACTIVE_TOOLS, + transformPrompt: injectPolicy, + introMessage: ui.intro, + }, pieceContext, initialInput); } diff --git a/src/features/interactive/lineEditor.ts b/src/features/interactive/lineEditor.ts index d622493..2d52355 100644 --- a/src/features/interactive/lineEditor.ts +++ b/src/features/interactive/lineEditor.ts @@ -250,46 +250,103 @@ export function readMultilineInput(prompt: string): Promise { // --- 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 { const lastNl = buffer.lastIndexOf('\n', pos - 1); return lastNl + 1; } + function getLineStart(): number { + return getLineStartAt(cursorPos); + } + function getLineEndAt(pos: number): number { const nextNl = buffer.indexOf('\n', pos); return nextNl >= 0 ? nextNl : buffer.length; } - /** Display width from line start to cursor */ - function getDisplayColumn(): number { - return getDisplayWidth(buffer.slice(getLineStart(), cursorPos)); + function getLineEnd(): number { + return getLineEndAt(cursorPos); } const promptWidth = getDisplayWidth(stripAnsi(prompt)); - /** Terminal column (1-based) for a given buffer position */ - function getTerminalColumn(pos: number): number { - const lineStart = getLineStartAt(pos); - const col = getDisplayWidth(buffer.slice(lineStart, pos)); - const isFirstLine = lineStart === 0; - return isFirstLine ? promptWidth + col + 1 : col + 1; + // --- Display row helpers (soft-wrap awareness) --- + + function getTermWidth(): number { + return process.stdout.columns || 80; } - /** Find the buffer position in a line that matches a target display column */ - function findPositionByDisplayColumn(lineStart: number, lineEnd: number, targetDisplayCol: number): number { + /** Buffer position of the display row start that contains `pos` */ + 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 pos = lineStart; - for (const ch of buffer.slice(lineStart, lineEnd)) { + let pos = rangeStart; + for (const ch of buffer.slice(rangeStart, rangeEnd)) { const w = getDisplayWidth(ch); if (displayCol + w > targetDisplayCol) break; displayCol += w; @@ -322,23 +379,77 @@ export function readMultilineInput(prompt: string): Promise { // --- Cursor movement --- - function moveCursorToLineStart(): void { - const displayOffset = getDisplayColumn(); + function moveCursorToDisplayRowStart(): void { + const displayRowStart = getDisplayRowStart(cursorPos); + const displayOffset = getDisplayRowColumn(cursorPos); if (displayOffset > 0) { - cursorPos = getLineStart(); + cursorPos = displayRowStart; process.stdout.write(`\x1B[${displayOffset}D`); } } - function moveCursorToLineEnd(): void { - const lineEnd = getLineEnd(); - const displayOffset = getDisplayWidth(buffer.slice(cursorPos, lineEnd)); + function moveCursorToDisplayRowEnd(): void { + const displayRowEnd = getDisplayRowEnd(cursorPos); + const displayOffset = getDisplayWidth(buffer.slice(cursorPos, displayRowEnd)); if (displayOffset > 0) { - cursorPos = lineEnd; + cursorPos = displayRowEnd; 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 --- function insertAt(pos: number, text: string): void { @@ -461,27 +572,40 @@ export function readMultilineInput(prompt: string): Promise { }, onArrowUp() { if (state !== 'normal') return; - const lineStart = getLineStart(); - if (lineStart === 0) return; - const displayCol = getDisplayColumn(); - const prevLineStart = getLineStartAt(lineStart - 1); - const prevLineEnd = lineStart - 1; - cursorPos = findPositionByDisplayColumn(prevLineStart, prevLineEnd, displayCol); - const termCol = getTerminalColumn(cursorPos); - process.stdout.write('\x1B[A'); - process.stdout.write(`\x1B[${termCol}G`); + const logicalLineStart = getLineStart(); + const displayRowStart = getDisplayRowStart(cursorPos); + const displayCol = getDisplayRowColumn(cursorPos); + + if (displayRowStart > logicalLineStart) { + // Move to previous display row within the same logical line + const prevRowStart = getDisplayRowStart(displayRowStart - 1); + const prevRowEnd = getDisplayRowEnd(displayRowStart - 1); + 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() { if (state !== 'normal') return; - const lineEnd = getLineEnd(); - if (lineEnd >= buffer.length) return; - const displayCol = getDisplayColumn(); - const nextLineStart = lineEnd + 1; - const nextLineEnd = getLineEndAt(nextLineStart); - cursorPos = findPositionByDisplayColumn(nextLineStart, nextLineEnd, displayCol); - const termCol = getTerminalColumn(cursorPos); - process.stdout.write('\x1B[B'); - process.stdout.write(`\x1B[${termCol}G`); + const logicalLineEnd = getLineEnd(); + const displayRowEnd = getDisplayRowEnd(cursorPos); + const displayCol = getDisplayRowColumn(cursorPos); + + if (displayRowEnd < logicalLineEnd) { + // Move to next display row within the same logical line + const nextRowStart = displayRowEnd; + const nextRowEnd = getDisplayRowEnd(displayRowEnd); + 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() { if (state !== 'normal') return; @@ -507,11 +631,11 @@ export function readMultilineInput(prompt: string): Promise { }, onHome() { if (state !== 'normal') return; - moveCursorToLineStart(); + moveCursorToLogicalLineStart(); }, onEnd() { if (state !== 'normal') return; - moveCursorToLineEnd(); + moveCursorToLogicalLineEnd(); }, onChar(ch: string) { if (state === 'paste') { @@ -543,8 +667,8 @@ export function readMultilineInput(prompt: string): Promise { } // Editing if (ch === '\x7F' || ch === '\x08') { deleteCharBefore(); return; } - if (ch === '\x01') { moveCursorToLineStart(); return; } - if (ch === '\x05') { moveCursorToLineEnd(); return; } + if (ch === '\x01') { moveCursorToDisplayRowStart(); return; } + if (ch === '\x05') { moveCursorToDisplayRowEnd(); return; } if (ch === '\x0B') { deleteToLineEnd(); return; } if (ch === '\x15') { deleteToLineStart(); return; } if (ch === '\x17') { deleteWord(); return; } diff --git a/src/features/interactive/modeSelection.ts b/src/features/interactive/modeSelection.ts new file mode 100644 index 0000000..291809a --- /dev/null +++ b/src/features/interactive/modeSelection.ts @@ -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 { + 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(prompt, options, defaultMode); +} diff --git a/src/features/interactive/passthroughMode.ts b/src/features/interactive/passthroughMode.ts new file mode 100644 index 0000000..15343b7 --- /dev/null +++ b/src/features/interactive/passthroughMode.ts @@ -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 { + 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 }; +} diff --git a/src/features/interactive/personaMode.ts b/src/features/interactive/personaMode.ts new file mode 100644 index 0000000..da4eb96 --- /dev/null +++ b/src/features/interactive/personaMode.ts @@ -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 { + 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); +} diff --git a/src/features/interactive/quietMode.ts b/src/features/interactive/quietMode.ts new file mode 100644 index 0000000..13a8093 --- /dev/null +++ b/src/features/interactive/quietMode.ts @@ -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 { + 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('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 }; +} diff --git a/src/features/tasks/execute/parallelExecution.ts b/src/features/tasks/execute/parallelExecution.ts index 67130ef..67a3c4c 100644 --- a/src/features/tasks/execute/parallelExecution.ts +++ b/src/features/tasks/execute/parallelExecution.ts @@ -5,19 +5,75 @@ * available task as soon as it finishes the current one, maximizing slot * utilization. Works for both sequential (concurrency=1) and parallel * (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 { 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 { installSigIntHandler } from './sigintHandler.js'; import type { TaskExecutionOptions } from './types.js'; +const log = createLogger('worker-pool'); + export interface WorkerPoolResult { success: number; fail: number; } +type RaceResult = + | { type: 'completion'; promise: Promise; result: boolean } + | { type: 'poll' }; + +interface PollTimer { + promise: Promise; + cancel: () => void; +} + +function createPollTimer(intervalMs: number, signal: AbortSignal): PollTimer { + let timeoutId: ReturnType | undefined; + let onAbort: (() => void) | undefined; + + const promise = new Promise((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. * @@ -25,9 +81,10 @@ export interface WorkerPoolResult { * 1. Create a shared AbortController * 2. Maintain a queue of pending tasks and a set of active promises * 3. Fill available slots from the queue - * 4. Wait for any active task to complete (Promise.race) - * 5. Record result, fill freed slot from queue - * 6. Repeat until queue is empty and all active tasks complete + * 4. Wait for any active task to complete OR a poll timer to fire (Promise.race) + * 5. On task completion: record result + * 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( taskRunner: TaskRunner, @@ -35,7 +92,8 @@ export async function runWithWorkerPool( concurrency: number, cwd: string, pieceName: string, - options?: TaskExecutionOptions, + options: TaskExecutionOptions | undefined, + pollIntervalMs: number, ): Promise { const abortController = new AbortController(); const { cleanup } = installSigIntHandler(() => abortController.abort()); @@ -45,6 +103,7 @@ export async function runWithWorkerPool( const queue = [...initialTasks]; const active = new Map, TaskInfo>(); + const colorCounter = { value: 0 }; try { while (queue.length > 0 || active.size > 0) { @@ -52,33 +111,50 @@ export async function runWithWorkerPool( break; } - fillSlots(queue, active, concurrency, taskRunner, cwd, pieceName, options, abortController); + fillSlots(queue, active, concurrency, taskRunner, cwd, pieceName, options, abortController, colorCounter); if (active.size === 0) { break; } - const settled = await Promise.race( - [...active.keys()].map((p) => p.then( - (result) => ({ promise: p, result }), - () => ({ promise: p, result: false }), - )), + const pollTimer = createPollTimer(pollIntervalMs, abortController.signal); + + const completionPromises: Promise[] = [...active.keys()].map((p) => + p.then( + (result): RaceResult => ({ type: 'completion', promise: p, result }), + (): RaceResult => ({ type: 'completion', promise: p, result: false }), + ), ); - const task = active.get(settled.promise); - active.delete(settled.promise); + const settled = await Promise.race([...completionPromises, pollTimer.promise]); - if (task) { - if (settled.result) { - successCount++; - } else { - failCount++; + pollTimer.cancel(); + + if (settled.type === 'completion') { + const task = active.get(settled.promise); + active.delete(settled.promise); + + if (task) { + if (settled.result) { + successCount++; + } else { + failCount++; + } } } - if (!abortController.signal.aborted && queue.length === 0) { - const nextTasks = taskRunner.claimNextTasks(concurrency - active.size); - queue.push(...nextTasks); + if (!abortController.signal.aborted) { + const freeSlots = concurrency - active.size; + 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 { @@ -97,17 +173,25 @@ function fillSlots( pieceName: string, options: TaskExecutionOptions | undefined, abortController: AbortController, + colorCounter: { value: number }, ): void { while (active.size < concurrency && queue.length > 0) { const task = queue.shift()!; const isParallel = concurrency > 1; + const colorIndex = colorCounter.value++; - blankLine(); - info(`=== Task: ${task.name} ===`); + if (isParallel) { + 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, { abortSignal: isParallel ? abortController.signal : undefined, taskPrefix: isParallel ? task.name : undefined, + taskColorIndex: isParallel ? colorIndex : undefined, }); active.set(promise, task); } diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index f6f5156..b0defab 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -21,15 +21,16 @@ import { } from '../../../infra/config/index.js'; import { isQuietMode } from '../../../shared/context.js'; import { - header, - info, - warn, - error, - success, - status, - blankLine, + header as rawHeader, + info as rawInfo, + warn as rawWarn, + error as rawError, + success as rawSuccess, + status as rawStatus, + blankLine as rawBlankLine, StreamDisplay, } from '../../../shared/ui/index.js'; +import { TaskPrefixWriter } from '../../../shared/ui/TaskPrefixWriter.js'; import { generateSessionId, createSessionLog, @@ -62,6 +63,91 @@ import { installSigIntHandler } from './sigintHandler.js'; 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>[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 */ @@ -106,11 +192,18 @@ export async function executePiece( // projectCwd is where .takt/ lives (project root, not the clone) 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) log.debug('Continuing session (use /clear to reset)'); - header(`${headerPrefix} ${pieceConfig.name}`); + out.header(`${headerPrefix} ${pieceConfig.name}`); const pieceSessionId = generateSessionId(); let sessionLog = createSessionLog(task, projectCwd, pieceConfig.name); @@ -139,14 +232,16 @@ export async function executePiece( // Track current display for streaming const displayRef: { current: StreamDisplay | null } = { current: null }; - // Create stream handler that delegates to UI display - const streamHandler = ( - event: Parameters>[0] - ): void => { - if (!displayRef.current) return; - if (event.type === 'result') return; - displayRef.current.createHandler()(event); - }; + // Create stream handler — when prefixWriter is active, use it for line-buffered + // output to prevent mid-line interleaving between concurrent tasks. + // When not in parallel mode, delegate to StreamDisplay as before. + const streamHandler = prefixWriter + ? createPrefixedStreamHandler(prefixWriter) + : (event: Parameters>[0]): void => { + 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) const isWorktree = cwd !== projectCwd; @@ -180,14 +275,14 @@ export async function executePiece( displayRef.current = null; } - blankLine(); - warn( + out.blankLine(); + out.warn( getLabel('piece.iterationLimit.maxReached', undefined, { currentIteration: String(request.currentIteration), 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) { playWarningSound(); @@ -214,11 +309,11 @@ export async function executePiece( const additionalIterations = Number.parseInt(input, 10); if (Number.isInteger(additionalIterations) && additionalIterations > 0) { - pieceConfig.maxIterations += additionalIterations; + pieceConfig.maxIterations = request.maxIterations + 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 = null; } - blankLine(); - info(request.prompt.trim()); + out.blankLine(); + out.info(request.prompt.trim()); const input = await promptInput(getLabel('piece.iterationLimit.userInputPrompt')); return input && input.trim() ? input.trim() : null; } : undefined; const engine = new PieceEngine(pieceConfig, cwd, task, { + abortSignal: options.abortSignal, onStream: streamHandler, onUserInput, initialSessions: savedSessions, @@ -245,11 +341,14 @@ export async function executePiece( language: options.language, provider: options.provider, model: options.model, + personaProviders: options.personaProviders, interactive: interactiveUserInput, detectRuleIndex, callAiJudge, startMovement: options.startMovement, retryNote: options.retryNote, + taskPrefix: options.taskPrefix, + taskColorIndex: options.taskColorIndex, }); let abortReason: string | undefined; @@ -257,6 +356,7 @@ export async function executePiece( let lastMovementName: string | undefined; let currentIteration = 0; const phasePrompts = new Map(); + const movementIterations = new Map(); engine.on('phase:start', (step, phase, phaseName, instruction) => { log.debug('Phase starting', { step: step.name, phase, phaseName }); @@ -311,7 +411,15 @@ export async function executePiece( engine.on('movement:start', (step, iteration, instruction) => { log.debug('Movement starting', { step: step.name, persona: step.personaDisplayName, 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 if (instruction) { @@ -322,15 +430,18 @@ export async function executePiece( const movementIndex = pieceConfig.movements.findIndex((m) => m.name === step.name); const totalMovements = pieceConfig.movements.length; - const quiet = isQuietMode(); - const prefix = options.taskPrefix; - const agentLabel = prefix ? `${prefix}:${step.personaDisplayName}` : step.personaDisplayName; - displayRef.current = new StreamDisplay(agentLabel, quiet, { - iteration, - maxIterations: pieceConfig.maxIterations, - movementIndex: movementIndex >= 0 ? movementIndex : 0, - totalMovements, - }); + // In parallel mode, StreamDisplay is not used (prefixWriter handles output). + // In single mode, StreamDisplay renders stream events directly. + if (!prefixWriter) { + const quiet = isQuietMode(); + const agentLabel = step.personaDisplayName; + displayRef.current = new StreamDisplay(agentLabel, quiet, { + iteration, + maxIterations: pieceConfig.maxIterations, + movementIndex: movementIndex >= 0 ? movementIndex : 0, + totalMovements, + }); + } // Write step_start record to NDJSON log const record: NdjsonStepStart = { @@ -364,25 +475,26 @@ export async function executePiece( displayRef.current.flush(); displayRef.current = null; } - blankLine(); + prefixWriter?.flush(); + out.blankLine(); if (response.matchedRuleIndex != null && step.rules) { const rule = step.rules[response.matchedRuleIndex]; if (rule) { const methodLabel = response.matchedRuleMethod ? ` (${response.matchedRuleMethod})` : ''; - status('Status', `${rule.condition}${methodLabel}`); + out.status('Status', `${rule.condition}${methodLabel}`); } else { - status('Status', response.status); + out.status('Status', response.status); } } else { - status('Status', response.status); + out.status('Status', response.status); } if (response.error) { - error(`Error: ${response.error}`); + out.error(`Error: ${response.error}`); } if (response.sessionId) { - status('Session', response.sessionId); + out.status('Session', response.sessionId); } // Write step_complete record to NDJSON log @@ -408,8 +520,8 @@ export async function executePiece( engine.on('movement:report', (_step, filePath, fileName) => { const content = readFileSync(filePath, 'utf-8'); - console.log(`\n📄 Report: ${fileName}\n`); - console.log(content); + out.logLine(`\n📄 Report: ${fileName}\n`); + out.logLine(content); }); engine.on('piece:complete', (state) => { @@ -445,8 +557,8 @@ export async function executePiece( : ''; const elapsedDisplay = elapsed ? `, ${elapsed}` : ''; - success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`); - info(`Session log: ${ndjsonLogPath}`); + out.success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`); + out.info(`Session log: ${ndjsonLogPath}`); if (shouldNotify) { notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) })); } @@ -459,6 +571,7 @@ export async function executePiece( displayRef.current.flush(); displayRef.current = null; } + prefixWriter?.flush(); abortReason = reason; sessionLog = finalizeSessionLog(sessionLog, 'aborted'); @@ -492,8 +605,8 @@ export async function executePiece( : ''; const elapsedDisplay = elapsed ? ` (${elapsed})` : ''; - error(`Piece aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`); - info(`Session log: ${ndjsonLogPath}`); + out.error(`Piece aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`); + out.info(`Session log: ${ndjsonLogPath}`); if (shouldNotify) { notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason })); } @@ -536,6 +649,7 @@ export async function executePiece( reason: abortReason, }; } finally { + prefixWriter?.flush(); sigintCleanup?.(); if (onAbortSignal && options.abortSignal) { options.abortSignal.removeEventListener('abort', onAbortSignal); diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index b385a5a..219711c 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -52,7 +52,7 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType { - 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); if (!pieceConfig) { @@ -77,12 +77,14 @@ export async function executeTask(options: ExecuteTaskOptions): Promise language: globalConfig.language, provider: agentOverrides?.provider, model: agentOverrides?.model, + personaProviders: globalConfig.personaProviders, interactiveUserInput, interactiveMetadata, startMovement, retryNote, abortSignal, taskPrefix, + taskColorIndex, }); return result.success; } @@ -101,16 +103,31 @@ export async function executeAndCompleteTask( cwd: string, pieceName: string, options?: TaskExecutionOptions, - parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string }, + parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number }, ): Promise { const startedAt = new Date().toISOString(); 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 { 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 - const taskSuccess = await executeTask({ + const taskRunPromise = executeTask({ task: task.content, cwd: execCwd, pieceIdentifier: execPiece, @@ -118,9 +135,12 @@ export async function executeAndCompleteTask( agentOverrides: options, startMovement, retryNote, - abortSignal: parallelOptions?.abortSignal, + abortSignal: taskAbortSignal, taskPrefix: parallelOptions?.taskPrefix, + taskColorIndex: parallelOptions?.taskColorIndex, }); + + const taskSuccess = await taskRunPromise; const completedAt = new Date().toISOString(); if (taskSuccess && isWorktree) { @@ -189,6 +209,10 @@ export async function executeAndCompleteTask( error(`Task "${task.name}" error: ${getErrorMessage(err)}`); return false; + } finally { + if (externalAbortSignal) { + externalAbortSignal.removeEventListener('abort', onExternalAbort); + } } } @@ -220,7 +244,7 @@ export async function runAllTasks( 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; blankLine(); diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index d450607..55cf547 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -30,6 +30,8 @@ export interface PieceExecutionOptions { language?: Language; provider?: ProviderType; model?: string; + /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ + personaProviders?: Record; /** Enable interactive user input during step transitions */ interactiveUserInput?: boolean; /** Interactive mode result metadata for NDJSON logging */ @@ -42,6 +44,8 @@ export interface PieceExecutionOptions { abortSignal?: AbortSignal; /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ taskPrefix?: string; + /** Color index for task prefix (cycled mod 4 across concurrent tasks) */ + taskColorIndex?: number; } export interface TaskExecutionOptions { @@ -72,6 +76,8 @@ export interface ExecuteTaskOptions { abortSignal?: AbortSignal; /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ taskPrefix?: string; + /** Color index for task prefix (cycled mod 4 across concurrent tasks) */ + taskColorIndex?: number; } export interface PipelineExecutionOptions { diff --git a/src/infra/codex/client.ts b/src/infra/codex/client.ts index 14b84ab..aed7248 100644 --- a/src/infra/codex/client.ts +++ b/src/infra/codex/client.ts @@ -23,6 +23,8 @@ import { export type { CodexCallOptions } from './types.js'; 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. @@ -55,6 +57,34 @@ export class CodexClient { ? `${options.systemPrompt}\n\n${prompt}` : prompt; + let idleTimeoutId: ReturnType | 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 { log.debug('Executing Codex thread', { agentType, @@ -62,7 +92,10 @@ export class CodexClient { hasSystemPrompt: !!options.systemPrompt, }); - const { events } = await thread.runStreamed(fullPrompt); + const { events } = await thread.runStreamed(fullPrompt, { + signal: streamAbortController.signal, + }); + resetIdleTimeout(); let content = ''; const contentOffsets = new Map(); let success = true; @@ -70,6 +103,7 @@ export class CodexClient { const state = createStreamTrackingState(); for await (const event of events as AsyncGenerator) { + resetIdleTimeout(); if (event.type === 'thread.started') { threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId; emitInit(options.onStream, options.model, threadId); @@ -172,15 +206,27 @@ export class CodexClient { }; } catch (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 { persona: agentType, status: 'blocked', - content: message, + content: errorMessage, timestamp: new Date(), sessionId: threadId, }; + } finally { + if (idleTimeoutId !== undefined) { + clearTimeout(idleTimeoutId); + } + if (options.abortSignal) { + options.abortSignal.removeEventListener('abort', onExternalAbort); + } } } diff --git a/src/infra/codex/types.ts b/src/infra/codex/types.ts index 6c6aa9a..1834167 100644 --- a/src/infra/codex/types.ts +++ b/src/infra/codex/types.ts @@ -21,6 +21,7 @@ export function mapToCodexSandboxMode(mode: PermissionMode): CodexSandboxMode { /** Options for calling Codex */ export interface CodexCallOptions { cwd: string; + abortSignal?: AbortSignal; sessionId?: string; model?: string; systemPrompt?: string; diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 1f8aa00..32f4280 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -37,6 +37,7 @@ function createDefaultGlobalConfig(): GlobalConfig { enableBuiltinPieces: true, interactivePreviewMovements: 3, concurrency: 1, + taskPollIntervalMs: 500, }; } @@ -105,11 +106,13 @@ export class GlobalConfigManager { minimalOutput: parsed.minimal_output, bookmarksFile: parsed.bookmarks_file, pieceCategoriesFile: parsed.piece_categories_file, + personaProviders: parsed.persona_providers, branchNameStrategy: parsed.branch_name_strategy, preventSleep: parsed.prevent_sleep, notificationSound: parsed.notification_sound, interactivePreviewMovements: parsed.interactive_preview_movements, concurrency: parsed.concurrency, + taskPollIntervalMs: parsed.task_poll_interval_ms, }; validateProviderModelCompatibility(config.provider, config.model); this.cachedConfig = config; @@ -170,6 +173,9 @@ export class GlobalConfigManager { if (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) { raw.branch_name_strategy = config.branchNameStrategy; } @@ -185,6 +191,9 @@ export class GlobalConfigManager { if (config.concurrency !== undefined && config.concurrency > 1) { raw.concurrency = config.concurrency; } + if (config.taskPollIntervalMs !== undefined && config.taskPollIntervalMs !== 500) { + raw.task_poll_interval_ms = config.taskPollIntervalMs; + } writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); this.invalidateCache(); } diff --git a/src/infra/config/loaders/index.ts b/src/infra/config/loaders/index.ts index 4bd9f54..dca855a 100644 --- a/src/infra/config/loaders/index.ts +++ b/src/infra/config/loaders/index.ts @@ -13,6 +13,7 @@ export { listPieces, listPieceEntries, type MovementPreview, + type FirstMovementInfo, type PieceDirEntry, type PieceSource, type PieceWithSource, diff --git a/src/infra/config/loaders/pieceLoader.ts b/src/infra/config/loaders/pieceLoader.ts index 115adae..10fd8a3 100644 --- a/src/infra/config/loaders/pieceLoader.ts +++ b/src/infra/config/loaders/pieceLoader.ts @@ -21,6 +21,7 @@ export { listPieces, listPieceEntries, type MovementPreview, + type FirstMovementInfo, type PieceDirEntry, type PieceSource, type PieceWithSource, diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 7359f82..9b17bd6 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -280,6 +280,7 @@ export function normalizePieceConfig( maxIterations: parsed.max_iterations, loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir, sections, context), answerAgent: parsed.answer_agent, + interactiveMode: parsed.interactive_mode, }; } diff --git a/src/infra/config/loaders/pieceResolver.ts b/src/infra/config/loaders/pieceResolver.ts index 7709969..7a60f9d 100644 --- a/src/infra/config/loaders/pieceResolver.ts +++ b/src/infra/config/loaders/pieceResolver.ts @@ -8,7 +8,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join, resolve, isAbsolute } from 'node:path'; 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 { getLanguage, getDisabledBuiltins, getBuiltinPiecesEnabled } from '../global/globalConfig.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); 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({ name: movement.name, personaDisplayName: movement.personaDisplayName, - personaContent, + personaContent: readMovementPersona(movement), instructionContent: movement.instructionTemplate, allowedTools: movement.allowedTools ?? [], canEdit: movement.edit === true, @@ -250,26 +236,86 @@ function buildMovementPreviews(piece: PieceConfig, maxCount: number): MovementPr 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. - * 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( identifier: string, projectCwd: string, 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); if (!piece) { return { name: identifier, description: '', pieceStructure: '', movementPreviews: [] }; } + + const previews = previewCount && previewCount > 0 + ? buildMovementPreviews(piece, previewCount) + : []; + + const firstMovement = buildFirstMovementInfo(piece); + return { name: piece.name, description: piece.description ?? '', pieceStructure: buildWorkflowString(piece.movements), - movementPreviews: previewCount && previewCount > 0 - ? buildMovementPreviews(piece, previewCount) - : [], + movementPreviews: previews, + 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 ?? [], }; } diff --git a/src/infra/providers/codex.ts b/src/infra/providers/codex.ts index 3ccdd46..4dcbdcd 100644 --- a/src/infra/providers/codex.ts +++ b/src/infra/providers/codex.ts @@ -27,6 +27,7 @@ function isInsideGitRepo(cwd: string): boolean { function toCodexOptions(options: ProviderCallOptions): CodexCallOptions { return { cwd: options.cwd, + abortSignal: options.abortSignal, sessionId: options.sessionId, model: options.model, permissionMode: options.permissionMode, diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index f0af97b..df688d9 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -20,6 +20,7 @@ export interface AgentSetup { /** Runtime options passed at call time */ export interface ProviderCallOptions { cwd: string; + abortSignal?: AbortSignal; sessionId?: string; model?: string; allowedTools?: string[]; diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 00387a3..1fdbba1 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -24,6 +24,17 @@ interactive: continue: "Continue editing" cancelled: "Cancelled" playNoTask: "Please specify task content: /play " + 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: success: "✅ Previous task completed successfully" error: "❌ Previous task failed: {error}" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index da7c810..21af472 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -24,6 +24,17 @@ interactive: continue: "会話を続ける" cancelled: "キャンセルしました" playNoTask: "タスク内容を指定してください: /play <タスク内容>" + personaFallback: "先頭ムーブメントにペルソナがありません。アシスタントモードにフォールバックします。" + modeSelection: + prompt: "対話モードを選択してください:" + assistant: "アシスタント" + assistantDescription: "確認質問をしてから指示書を作成" + persona: "ペルソナ" + personaDescription: "先頭エージェントのペルソナで対話" + quiet: "クワイエット" + quietDescription: "質問なしでベストエフォートの指示書を生成" + passthrough: "パススルー" + passthroughDescription: "入力をそのままタスクとして渡す" previousTask: success: "✅ 前回のタスクは正常に完了しました" error: "❌ 前回のタスクはエラーで終了しました: {error}" diff --git a/src/shared/ui/TaskPrefixWriter.ts b/src/shared/ui/TaskPrefixWriter.ts new file mode 100644 index 0000000..188ea88 --- /dev/null +++ b/src/shared/ui/TaskPrefixWriter.ts @@ -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 = ''; + } + } + +} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 22df098..faec026 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -29,3 +29,5 @@ export { export { Spinner } from './Spinner.js'; export { StreamDisplay, type ProgressInfo } from './StreamDisplay.js'; + +export { TaskPrefixWriter } from './TaskPrefixWriter.js'; diff --git a/tools/generate-hybrid-codex.mjs b/tools/generate-hybrid-codex.mjs deleted file mode 100644 index 326f612..0000000 --- a/tools/generate-hybrid-codex.mjs +++ /dev/null @@ -1,260 +0,0 @@ -#!/usr/bin/env node -/** - * Generate hybrid-codex piece variants from standard pieces. - * - * For each standard piece (not already -hybrid-codex, not in skip list): - * 1. Parse the YAML - * 2. Add `provider: codex` to all coder movements (including parallel sub-movements) - * 3. Change name to {name}-hybrid-codex - * 4. Write the hybrid-codex YAML file - * 5. Update piece-categories.yaml to include generated hybrids - * - * Usage: - * node tools/generate-hybrid-codex.mjs # Generate all - * node tools/generate-hybrid-codex.mjs --dry-run # Preview only - */ - -import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; -import { join, basename, dirname } from 'node:path'; -import { parse, stringify } from 'yaml'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(__dirname, '..'); -const BUILTINS = join(ROOT, 'builtins'); -const LANGUAGES = ['en', 'ja']; - -/** Pieces that should NOT get hybrid variants (no coder involvement or special purpose) */ -const SKIP_PIECES = new Set(['magi', 'research', 'review-only']); - -const CODER_PERSONA = 'coder'; -const dryRun = process.argv.includes('--dry-run'); - -// ───────────────────────────────────────── -// Movement transformation -// ───────────────────────────────────────── - -function hasCoderPersona(movement) { - if (movement.persona === CODER_PERSONA) return true; - if (movement.parallel) return movement.parallel.some(sub => sub.persona === CODER_PERSONA); - return false; -} - -/** - * Insert a field into an object after specified anchor fields (preserves key order). - * If anchor not found, appends at end. - */ -function insertFieldAfter(obj, key, value, anchorFields) { - if (obj[key] === value) return obj; - const result = {}; - let inserted = false; - for (const [k, v] of Object.entries(obj)) { - if (k === key) continue; // Remove existing (will re-insert) - result[k] = v; - if (!inserted && anchorFields.includes(k)) { - result[key] = value; - inserted = true; - } - } - if (!inserted) result[key] = value; - return result; -} - -/** - * Add `provider: codex` to all coder movements (recursively handles parallel). - */ -function addCodexToCoders(movements) { - return movements.map(m => { - if (m.parallel) { - return { ...m, parallel: addCodexToCoders(m.parallel) }; - } - if (m.persona === CODER_PERSONA) { - return insertFieldAfter(m, 'provider', 'codex', ['knowledge', 'stance', 'persona']); - } - return m; - }); -} - -// ───────────────────────────────────────── -// Hybrid piece builder -// ───────────────────────────────────────── - -/** Top-level field order for readable output */ -const TOP_FIELD_ORDER = [ - 'name', 'description', 'max_iterations', - 'stances', 'knowledge', 'personas', 'instructions', 'report_formats', - 'initial_movement', 'loop_monitors', 'answer_agent', 'movements', -]; - -function buildHybrid(parsed) { - const hybrid = {}; - for (const field of TOP_FIELD_ORDER) { - if (field === 'name') { - hybrid.name = `${parsed.name}-hybrid-codex`; - } else if (field === 'movements') { - hybrid.movements = addCodexToCoders(parsed.movements); - } else if (parsed[field] != null) { - hybrid[field] = parsed[field]; - } - } - // Carry over any extra top-level fields not in the order list - for (const key of Object.keys(parsed)) { - if (!(key in hybrid) && key !== 'name') { - hybrid[key] = parsed[key]; - } - } - return hybrid; -} - -function generateHeader(sourceFile) { - return [ - `# Auto-generated from ${sourceFile} by tools/generate-hybrid-codex.mjs`, - '# Do not edit manually. Edit the source piece and re-run the generator.', - '', - '', - ].join('\n'); -} - -// ───────────────────────────────────────── -// Category handling -// ───────────────────────────────────────── - -/** Recursively collect all piece names from a category tree */ -function collectPieces(obj) { - const pieces = []; - if (!obj || typeof obj !== 'object') return pieces; - if (Array.isArray(obj.pieces)) pieces.push(...obj.pieces); - for (const [key, val] of Object.entries(obj)) { - if (key === 'pieces') continue; - if (typeof val === 'object' && val !== null && !Array.isArray(val)) { - pieces.push(...collectPieces(val)); - } - } - return pieces; -} - -/** Find the key for the hybrid top-level category */ -function findHybridTopKey(categories) { - for (const key of Object.keys(categories)) { - if (key.includes('Hybrid') || key.includes('ハイブリッド')) return key; - } - return null; -} - -/** - * Build mapping: standard piece name → top-level category key. - * Excludes the hybrid category and "Others" category. - */ -function getTopLevelMapping(categories, hybridKey, othersKey) { - const map = new Map(); - for (const [key, val] of Object.entries(categories)) { - if (key === hybridKey) continue; - if (othersKey && key === othersKey) continue; - if (typeof val !== 'object' || val === null) continue; - const pieces = collectPieces(val); - for (const p of pieces) map.set(p, key); - } - return map; -} - -/** - * Build the hybrid category section by mirroring standard categories. - */ -function buildHybridCategories(generatedNames, topMap) { - // Group hybrids by their source piece's top-level category - const grouped = new Map(); - for (const hybridName of generatedNames) { - const sourceName = hybridName.replace('-hybrid-codex', ''); - const topCat = topMap.get(sourceName); - if (!topCat) continue; - if (!grouped.has(topCat)) grouped.set(topCat, []); - grouped.get(topCat).push(hybridName); - } - - const section = {}; - for (const [catKey, hybrids] of grouped) { - section[catKey] = { pieces: hybrids.sort() }; - } - return section; -} - -// ───────────────────────────────────────── -// Main -// ───────────────────────────────────────── - -console.log('=== Generating hybrid-codex pieces ===\n'); - -for (const lang of LANGUAGES) { - console.log(`[${lang}]`); - const generatedNames = []; - - const piecesDir = join(BUILTINS, lang, 'pieces'); - const files = readdirSync(piecesDir) - .filter(f => f.endsWith('.yaml') && !f.includes('-hybrid-codex')) - .sort(); - - for (const file of files) { - const name = basename(file, '.yaml'); - if (SKIP_PIECES.has(name)) { - console.log(` Skip: ${name} (in skip list)`); - continue; - } - - const content = readFileSync(join(piecesDir, file), 'utf-8'); - const parsed = parse(content); - - if (!parsed.movements?.some(hasCoderPersona)) { - console.log(` Skip: ${name} (no coder movements)`); - continue; - } - - const hybrid = buildHybrid(parsed); - const header = generateHeader(file); - const yamlOutput = stringify(hybrid, { lineWidth: 120, indent: 2 }); - const outputPath = join(piecesDir, `${name}-hybrid-codex.yaml`); - - if (dryRun) { - console.log(` Would generate: ${name}-hybrid-codex.yaml`); - } else { - writeFileSync(outputPath, header + yamlOutput, 'utf-8'); - console.log(` Generated: ${name}-hybrid-codex.yaml`); - } - - generatedNames.push(`${name}-hybrid-codex`); - } - - // ─── Update piece-categories.yaml ─── - const catPath = join(BUILTINS, lang, 'piece-categories.yaml'); - const catRaw = readFileSync(catPath, 'utf-8'); - const catParsed = parse(catRaw); - const cats = catParsed.piece_categories; - - if (cats) { - const hybridKey = findHybridTopKey(cats); - const othersKey = Object.keys(cats).find(k => - k === 'Others' || k === 'その他' - ); - - if (hybridKey) { - const topMap = getTopLevelMapping(cats, hybridKey, othersKey); - const newSection = buildHybridCategories(generatedNames, topMap); - cats[hybridKey] = newSection; - - if (dryRun) { - console.log(` Would update: piece-categories.yaml`); - console.log(` Hybrid pieces: ${generatedNames.join(', ')}`); - } else { - const catOut = stringify(catParsed, { lineWidth: 120, indent: 2 }); - writeFileSync(catPath, catOut, 'utf-8'); - console.log(` Updated: piece-categories.yaml`); - } - } else { - console.log(` Warning: No hybrid category found in piece-categories.yaml`); - } - } - - console.log(); -} - -console.log('Done!'); -if (dryRun) console.log('(dry-run mode, no files were written)');