diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c561b..ff05686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ 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.20.0] - 2026-02-19 + +### Added + +- **Faceted Prompting module** (`src/faceted-prompting/`): Standalone library for facet composition, resolution, template rendering, and truncation — zero dependencies on TAKT internals. Includes `DataEngine` interface with `FileDataEngine` and `CompositeDataEngine` implementations for pluggable facet storage +- **Analytics module** (`src/features/analytics/`): Local-only review quality metrics collection — event types (review findings, fix actions, movement results), JSONL writer with date-based rotation, report parser, and metrics computation +- **`takt metrics review` command**: Display review quality metrics (re-report counts, round-trip ratio, resolution iterations, REJECT counts by rule, rebuttal resolution ratio) with configurable time window (`--since`) +- **`takt purge` command**: Purge old analytics event files with configurable retention period (`--retention-days`) +- **`takt reset config` command**: Reset global config to builtin template with automatic backup of the existing config +- **PR duplicate prevention**: When a PR already exists for the current branch, push and comment on the existing PR instead of creating a duplicate (#304) +- Retry mode now positions the cursor on the failed movement when selecting which movement to retry +- E2E tests for run-recovery and config-priority scenarios + +### Changed + +- **README overhaul**: Compressed from ~950 lines to ~270 lines — details split into dedicated docs (`docs/configuration.md`, `docs/cli-reference.md`, `docs/task-management.md`, `docs/ci-cd.md`, `docs/builtin-catalog.md`) with Japanese equivalents. Redefined product concept around 4 value axes: batteries included, practical, reproducible, multi-agent +- **Config system refactored**: Unified configuration resolution to `resolveConfigValue()` and `loadConfig()`, eliminating scattered config access patterns across the codebase +- **`takt config` command removed**: Replaced by `takt reset config` for resetting to defaults +- Builtin config templates refreshed with updated comments and structure +- `@anthropic-ai/claude-agent-sdk` updated to v0.2.47 +- Instruct mode prompt improvements for task re-instruction + +### Fixed + +- Fixed issue where builtin piece file references used absolute path instead of relative (#304) +- Removed unused imports and variables across multiple files + +### Internal + +- Unified `loadConfig`, `resolveConfigValue`, piece config resolution, and config priority paths +- Added E2E tests for config priority and run recovery scenarios +- Added `postExecution.test.ts` for PR creation flow testing +- Cleaned up unused imports and variables + ## [0.19.0] - 2026-02-18 ### Added diff --git a/README.md b/README.md index 9c7bfda..3a7016b 100644 --- a/README.md +++ b/README.md @@ -2,117 +2,42 @@ 🇯🇵 [日本語ドキュメント](./docs/README.ja.md) -**T**AKT **A**gent **K**oordination **T**opology - Define how AI agents coordinate, where humans intervene, and what gets recorded — in YAML +**T**AKT **A**gent **K**oordination **T**opology — Give your AI coding agents structured review loops, managed prompts, and guardrails — so they deliver quality code, not just code. -TAKT runs multiple AI agents (Claude Code, Codex, OpenCode) through YAML-defined workflows. Each step — who runs, what they see, what's allowed, what happens on failure — is declared in a piece file, not left to the agent. +TAKT runs AI agents (Claude Code, Codex, OpenCode) through YAML-defined workflows with built-in review cycles. You talk to AI to define what you want, queue tasks, and let TAKT handle the execution — planning, implementation, multi-stage review, and fix loops — all governed by declarative piece files. TAKT is built with TAKT itself (dogfooding). -## Metaphor - -TAKT uses a music metaphor to describe orchestration: - -- **Piece**: A task execution definition (what to do and how agents coordinate) -- **Movement**: A step inside a piece (a single stage in the flow) -- **Orchestration**: The engine that coordinates agents across movements - -You can read every term as standard workflow language (piece = workflow, movement = step), but the metaphor is used to keep the system conceptually consistent. - ## Why TAKT -- AI agents are powerful but non-deterministic — TAKT makes their decisions visible and replayable -- Multi-agent coordination needs structure — pieces define who does what, in what order, with what permissions -- CI/CD integration needs guardrails — pipeline mode runs agents non-interactively with full audit logs +**Batteries included** — Architecture, security, and AI antipattern review criteria are built in. Ship code that meets a quality bar from day one. -## What TAKT Controls and Manages +**Practical** — A tool for daily development, not demos. Talk to AI to refine requirements, queue tasks, and run them. Automatic worktree isolation, PR creation, and retry on failure. -TAKT **controls** agent execution and **manages** prompt components. +**Reproducible** — Execution paths are declared in YAML, keeping results consistent. Pieces are shareable — a workflow built by one team member can be used by anyone else to run the same quality process. Every step is logged in NDJSON for full traceability from task to PR. -| | Concern | Description | -|---|---------|-------------| -| Control | **Routing** | State transition rules (who runs when) | -| Control | **Tools & Permissions** | Readonly, edit, full access (what's allowed) | -| Control | **Recording** | Session logs, reports (what gets captured) | -| Manage | **Personas** | Agent roles and expertise (who they act as) | -| Manage | **Policies** | Coding standards, quality criteria, prohibitions (what to uphold) | -| Manage | **Knowledge** | Domain knowledge, architecture info (what to reference) | - -Personas, policies, and knowledge are managed as independent files and freely combined across workflows ([Faceted Prompting](./docs/faceted-prompting.md)). Change a policy in one file and every workflow using it gets the update. - -## What TAKT is NOT - -- **Not an autonomous engineer** — TAKT coordinates agents but doesn't decide what to build. You provide the task, TAKT governs the execution. -- **Not a Skill or Swarm replacement** — Skills extend a single agent's knowledge. Swarm parallelizes agents. TAKT defines the workflow structure across agents — which agent runs, in what order, with what rules. -- **Not fully automatic by default** — Every step can require human approval. Automation is opt-in (pipeline mode), not the default. +**Multi-agent** — Orchestrate multiple agents with different personas, permissions, and review criteria. Run parallel reviewers, route failures back to implementers, aggregate results with declarative rules. Prompts are managed as independent facets (persona, policy, knowledge, instruction) that compose freely across workflows ([Faceted Prompting](./docs/faceted-prompting.md)). ## Requirements Choose one: -- **Use provider CLIs**: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), or [OpenCode](https://opencode.ai) installed -- **Use direct API**: **Anthropic API Key**, **OpenAI API Key**, or **OpenCode API Key** (no CLI required) +- **Provider CLIs**: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), or [OpenCode](https://opencode.ai) installed +- **Direct API**: Anthropic / OpenAI / OpenCode API Key (no CLI required) -Additionally required: +Optional: -- [GitHub CLI](https://cli.github.com/) (`gh`) — Only needed for `takt #N` (GitHub Issue execution) +- [GitHub CLI](https://cli.github.com/) (`gh`) — for `takt #N` (GitHub Issue tasks) -**Pricing Note**: When using API Keys, TAKT directly calls the Claude API (Anthropic), OpenAI API, or OpenCode API. The pricing structure is the same as using the respective CLI tools. Be mindful of costs, especially when running automated tasks in CI/CD environments, as API usage can accumulate. +## Quick Start -## Installation +### Install ```bash npm install -g takt ``` -## Quick Start - -```bash -# Interactive mode - refine task requirements with AI, then execute -takt - -# Execute GitHub Issue as task (both work the same) -takt #6 -takt --issue 6 - -# Pipeline execution (non-interactive, for scripts/CI) -takt --pipeline --task "Fix the bug" --auto-pr -``` - -## Usage - -### Interactive Mode - -A mode where you refine task content through conversation with AI before execution. Useful when task requirements are ambiguous or when you want to clarify content while consulting with AI. - -```bash -# Start interactive mode (no arguments) -takt - -# Specify initial message (short word only) -takt hello -``` - -**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. 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 +### Talk to AI, then execute ``` $ takt @@ -121,748 +46,190 @@ Select piece: ❯ 🎼 default (current) 📁 Development/ 📁 Research/ - Cancel -Interactive mode - Enter task content. Commands: /go (execute), /cancel (exit) +> Add user authentication with JWT -> I want to add user authentication feature - -[AI confirms and organizes requirements] +[AI clarifies requirements and organizes the task] > /go - -Proposed task instructions: -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Implement user authentication feature. - -Requirements: -- Login with email address and password -- JWT token-based authentication -- Password hashing (bcrypt) -- Login/logout API endpoints -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -Proceed with these task instructions? (Y/n) y - -? Create worktree? (Y/n) y - -[Piece execution starts...] ``` -### Direct Task Execution +TAKT creates an isolated worktree, runs the piece (plan → implement → review → fix loop), and offers to create a PR when done. -Use the `--task` option to skip interactive mode and execute directly. +### Queue tasks, then batch execute + +Use `takt` to queue multiple tasks, then execute them all at once: ```bash -# Specify task content with --task option -takt --task "Fix bug" +# Queue tasks through conversation +takt +> Refactor the auth module +> /go # queues the task -# Specify piece -takt --task "Add authentication" --piece expert +# Or queue from GitHub Issues +takt add #6 +takt add #12 -# Auto-create PR -takt --task "Fix bug" --auto-pr -``` - -**Note:** Passing a string as an argument (e.g., `takt "Add login feature"`) enters interactive mode with it as the initial message. - -### GitHub Issue Tasks - -You can execute GitHub Issues directly as tasks. Issue title, body, labels, and comments are automatically incorporated as task content. - -```bash -# Execute by specifying issue number -takt #6 -takt --issue 6 - -# Issue + piece specification -takt #6 --piece expert - -# Issue + auto-create PR -takt #6 --auto-pr -``` - -**Requirements:** [GitHub CLI](https://cli.github.com/) (`gh`) must be installed and authenticated. - -### Task Management (add / run / watch / list) - -Batch processing using `.takt/tasks.yaml` with task directories under `.takt/tasks/{slug}/`. Useful for accumulating multiple tasks and executing them together later. - -#### Add Task (`takt add`) - -```bash -# Refine task requirements through AI conversation, then add task -takt add - -# Add task from GitHub Issue (issue number reflected in branch name) -takt add #28 -``` - -#### Execute Tasks (`takt run`) - -```bash -# Execute all pending tasks in .takt/tasks.yaml +# Execute all pending tasks takt run ``` -#### Watch Tasks (`takt watch`) +### Manage results ```bash -# Monitor .takt/tasks.yaml and auto-execute tasks (resident process) -takt watch -``` - -#### List Task Branches (`takt list`) - -```bash -# List task branches (merge/delete) +# List completed/failed task branches — merge, retry, or delete takt list - -# Non-interactive mode (for CI/scripts) -takt list --non-interactive -takt list --non-interactive --action diff --branch takt/my-branch -takt list --non-interactive --action delete --branch takt/my-branch --yes -takt list --non-interactive --format json ``` -#### Task Directory Workflow (Create / Run / Verify) +## How It Works -1. Run `takt add` and confirm a pending record is created in `.takt/tasks.yaml`. -2. Open the generated `.takt/tasks/{slug}/order.md` and add detailed specifications/references as needed. -3. Run `takt run` (or `takt watch`) to execute pending tasks from `tasks.yaml`. -4. Verify outputs in `.takt/runs/{slug}/reports/` using the same slug as `task_dir`. +TAKT uses a music metaphor: **piece** = workflow, **movement** = step. -### Pipeline Mode (for CI/Automation) - -Specifying `--pipeline` enables non-interactive pipeline mode. Automatically creates branch → runs piece → commits & pushes. Suitable for CI/CD automation. - -```bash -# Execute task in pipeline mode -takt --pipeline --task "Fix bug" - -# Pipeline execution + auto-create PR -takt --pipeline --task "Fix bug" --auto-pr - -# Link issue information -takt --pipeline --issue 99 --auto-pr - -# Specify piece and branch -takt --pipeline --task "Fix bug" -w magi -b feat/fix-bug - -# Specify repository (for PR creation) -takt --pipeline --task "Fix bug" --auto-pr --repo owner/repo - -# Piece execution only (skip branch creation, commit, push) -takt --pipeline --task "Fix bug" --skip-git - -# Minimal output mode (for CI) -takt --pipeline --task "Fix bug" --quiet -``` - -In pipeline mode, PRs are not created unless `--auto-pr` is specified. - -**GitHub Integration:** When using TAKT in GitHub Actions, see [takt-action](https://github.com/nrslib/takt-action). You can automate PR reviews and task execution. Refer to the [CI/CD Integration](#cicd-integration) section for details. - -### Other Commands - -```bash -# Interactively switch pieces -takt switch - -# Copy builtin pieces/personas to project .takt/ for customization -takt eject - -# Copy to ~/.takt/ (global) instead -takt eject --global - -# Clear agent conversation sessions -takt clear - -# Deploy builtin pieces/personas as Claude Code Skill -takt export-cc - -# List available facets across layers -takt catalog -takt catalog personas - -# Eject a specific facet for customization -takt eject persona coder -takt eject instruction plan --global - -# Preview assembled prompts for each movement and phase -takt prompt [piece] - -# Configure permission mode -takt config - -# Reset piece categories to builtin defaults -takt reset categories -``` - -### Recommended Pieces - -| Piece | Recommended Use | -|----------|-----------------| -| `default` | Serious development tasks. Used for TAKT's own development. Multi-stage review with parallel reviews (architect + security). | -| `default-mini` | Simple fixes and straightforward tasks. Lightweight piece with AI antipattern review + supervisor. | -| `review-fix-minimal` | Review & fix piece. Specialized for iterative improvement based on review feedback. | -| `research` | Investigation and research. Autonomously executes research without asking questions. | - -### Main Options - -| Option | Description | -|--------|-------------| -| `--pipeline` | **Enable pipeline (non-interactive) mode** — Required for CI/automation | -| `-t, --task ` | Task content (alternative to GitHub Issue) | -| `-i, --issue ` | GitHub issue number (same as `#N` in interactive mode) | -| `-w, --piece ` | Piece name or path to piece YAML file | -| `-b, --branch ` | Specify branch name (auto-generated if omitted) | -| `--auto-pr` | Create PR (interactive: skip confirmation, pipeline: enable PR) | -| `--skip-git` | Skip branch creation, commit, and push (pipeline mode, piece-only) | -| `--repo ` | Specify repository (for PR creation) | -| `--create-worktree ` | Skip worktree confirmation prompt | -| `-q, --quiet` | Minimal output mode: suppress AI output (for CI) | -| `--provider ` | Override agent provider (claude\|codex\|opencode\|mock) | -| `--model ` | Override agent model | - -## Pieces - -TAKT uses YAML-based piece definitions and rule-based routing. Builtin pieces are embedded in the package, with user pieces in `~/.takt/pieces/` taking priority. Use `takt eject` to copy builtins to `~/.takt/` for customization. - -> **Note (v0.4.0)**: Internal terminology has changed from "step" to "movement" for piece components. User-facing piece files remain compatible, but if you customize pieces, you may see `movements:` instead of `steps:` in YAML files. The functionality remains the same. - -### Piece Example +A piece defines a sequence of movements. Each movement specifies a persona (who), permissions (what's allowed), and rules (what happens next). Here's a minimal example: ```yaml -name: default -max_movements: 10 +name: simple initial_movement: plan -# Section maps — key: file path (relative to this YAML) personas: planner: ../personas/planner.md coder: ../personas/coder.md reviewer: ../personas/architecture-reviewer.md -policies: - coding: ../policies/coding.md - -knowledge: - architecture: ../knowledge/architecture.md - movements: - name: plan persona: planner - model: opus edit: false rules: - condition: Planning complete next: implement - instruction_template: | - Analyze the request and create an implementation plan. - name: implement persona: coder - policy: coding - knowledge: architecture edit: true - required_permission_mode: edit rules: - condition: Implementation complete next: review - - condition: Blocked - next: ABORT - instruction_template: | - Implement based on the plan. - name: review persona: reviewer - knowledge: architecture edit: false rules: - condition: Approved next: COMPLETE - condition: Needs fix - next: implement - instruction_template: | - Review the implementation from architecture and code quality perspectives. + next: implement # ← fix loop ``` -### Persona-less Movements +Rules determine the next movement. `COMPLETE` ends the piece successfully, `ABORT` ends with failure. See the [Piece Guide](./docs/pieces.md) for the full schema, parallel movements, and rule condition types. -The `persona` field is optional. When omitted, the movement executes using only the `instruction_template` without a system prompt. This is useful for simple tasks that don't require persona customization. +## Recommended Pieces -```yaml - - name: summarize - # No persona specified — uses instruction_template only - edit: false - rules: - - condition: Summary complete - next: COMPLETE - instruction_template: | - Read the report and provide a concise summary. -``` +| Piece | Use Case | +|-------|----------| +| `default-mini` | Quick fixes. Lightweight plan → implement → parallel review → fix loop. | +| `frontend-mini` | Frontend-focused mini configuration. | +| `backend-mini` | Backend-focused mini configuration. | +| `expert-mini` | Expert-level mini configuration. | +| `default` | Serious development. Multi-stage review with parallel reviewers. Used for TAKT's own development. | -You can also write an inline system prompt as the `persona` value (if the specified file doesn't exist): +See the [Builtin Catalog](./docs/builtin-catalog.md) for all pieces and personas. -```yaml - - name: review - persona: "You are a code reviewer. Focus on readability and maintainability." - edit: false - instruction_template: | - Review code quality. -``` +## Key Commands -### Parallel Movements - -Execute sub-movements in parallel within a movement and evaluate with aggregate conditions: - -```yaml - - name: reviewers - parallel: - - name: arch-review - persona: reviewer - rules: - - condition: approved - - condition: needs_fix - instruction_template: | - Review architecture and code quality. - - name: security-review - persona: security-reviewer - rules: - - condition: approved - - condition: needs_fix - instruction_template: | - Review for security vulnerabilities. - rules: - - condition: all("approved") - next: supervise - - condition: any("needs_fix") - next: fix -``` - -- `all("X")`: true if ALL sub-movements matched condition X -- `any("X")`: true if ANY sub-movement matched condition X -- Sub-movement `rules` define possible outcomes, but `next` is optional (parent controls transition) - -### Rule Condition Types - -| Type | Syntax | Description | -|------|--------|-------------| -| Tag-based | `"condition text"` | Agent outputs `[MOVEMENTNAME:N]` tag, matched by index | -| AI judge | `ai("condition text")` | AI evaluates condition against agent output | -| Aggregate | `all("X")` / `any("X")` | Aggregates parallel sub-movement matched conditions | - -## Builtin Pieces - -TAKT includes multiple builtin pieces: - -| Piece | Description | -|----------|-------------| -| `default` | Full development piece: plan → implement → AI review → parallel review (architect + QA) → supervisor approval. Includes fix loops at each review stage. | -| `default-mini` | Mini development piece: plan → implement → parallel review (AI antipattern + supervisor) → fix if needed. Lightweight with review. | -| `frontend-mini` | Mini frontend piece: plan → implement → parallel review (AI antipattern + supervisor) with frontend knowledge injection. | -| `backend-mini` | Mini backend piece: plan → implement → parallel review (AI antipattern + supervisor) with backend knowledge injection. | -| `backend-cqrs-mini` | Mini CQRS+ES piece: plan → implement → parallel review (AI antipattern + supervisor) with CQRS+ES knowledge injection. | -| `review-fix-minimal` | Review-focused piece: review → fix → supervisor. For iterative improvement based on review feedback. | -| `research` | Research piece: planner → digger → supervisor. Autonomously executes research without asking questions. | -| `deep-research` | Deep research piece: plan → dig → analyze → supervise. Discovery-driven investigation that follows emerging questions with multi-perspective analysis. | -| `expert` | Full-stack development piece: architecture, frontend, security, QA reviews with fix loops. | -| `expert-mini` | Mini expert piece: plan → implement → parallel review (AI antipattern + expert supervisor) with full-stack knowledge injection. | -| `expert-cqrs` | Full-stack development piece (CQRS+ES specialized): CQRS+ES, frontend, security, QA reviews with fix loops. | -| `expert-cqrs-mini` | Mini CQRS+ES expert piece: plan → implement → parallel review (AI antipattern + expert supervisor) with CQRS+ES knowledge injection. | -| `magi` | Deliberation system inspired by Evangelion. Three AI personas (MELCHIOR, BALTHASAR, CASPER) analyze and vote. | -| `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. | -| `e2e-test` | E2E test focused piece: E2E analysis → E2E implementation → review → fix (Vitest-based E2E flow). | -| `frontend` | Frontend-specialized development piece with React/Next.js focused reviews and knowledge injection. | -| `backend` | Backend-specialized development piece with backend, security, and QA expert reviews. | -| `backend-cqrs` | CQRS+ES-specialized backend development piece with CQRS+ES, security, and QA expert reviews. | - -**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. - -## Builtin Personas - -| Persona | Description | +| Command | Description | |---------|-------------| -| **planner** | Task analysis, spec investigation, implementation planning | -| **architect-planner** | Task analysis and design planning: investigates code, resolves unknowns, creates implementation plans | -| **coder** | Feature implementation, bug fixing | -| **ai-antipattern-reviewer** | AI-specific antipattern review (non-existent APIs, incorrect assumptions, scope creep) | -| **architecture-reviewer** | Architecture and code quality review, spec compliance verification | -| **frontend-reviewer** | Frontend (React/Next.js) code quality and best practices review | -| **cqrs-es-reviewer** | CQRS+Event Sourcing architecture and implementation review | -| **qa-reviewer** | Test coverage and quality assurance review | -| **security-reviewer** | Security vulnerability assessment | -| **conductor** | Phase 3 judgment specialist: reads reports/responses and outputs status tags | -| **supervisor** | Final validation, approval | -| **expert-supervisor** | Expert-level final validation with comprehensive review integration | -| **research-planner** | Research task planning and scope definition | -| **research-analyzer** | Research result interpretation and additional investigation planning | -| **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 | +| `takt` | Talk to AI, refine requirements, execute or queue tasks | +| `takt run` | Execute all pending tasks | +| `takt list` | Manage task branches (merge, retry, instruct, delete) | +| `takt #N` | Execute GitHub Issue as task | +| `takt switch` | Switch active piece | +| `takt eject` | Copy builtin pieces/personas for customization | -## Custom Personas +See the [CLI Reference](./docs/cli-reference.md) for all commands and options. -Create persona prompts in Markdown files: +## Configuration + +Minimal `~/.takt/config.yaml`: + +```yaml +provider: claude # claude, codex, or opencode +model: sonnet # passed directly to provider +language: en # en or ja +``` + +Or use API keys directly (no CLI installation required): + +```bash +export TAKT_ANTHROPIC_API_KEY=sk-ant-... +``` + +See the [Configuration Guide](./docs/configuration.md) for all options, provider profiles, and model resolution. + +## Customization + +### Custom pieces + +```bash +takt eject default # Copy builtin to ~/.takt/pieces/ and edit +``` + +### Custom personas + +Create a Markdown file in `~/.takt/personas/`: ```markdown # ~/.takt/personas/my-reviewer.md - You are a code reviewer specialized in security. - -## Role -- Check for security vulnerabilities -- Verify input validation -- Review authentication logic ``` -## Model Selection +Reference it in your piece: `persona: my-reviewer` -The `model` field (in piece movements, agent config, or global config) is passed directly to the provider (Claude Code CLI / Codex SDK). TAKT does not resolve model aliases. +See the [Piece Guide](./docs/pieces.md) and [Agent Guide](./docs/agents.md) for details. -### Claude Code +## CI/CD -Claude Code supports aliases (`opus`, `sonnet`, `haiku`, `opusplan`, `default`) and full model names (e.g., `claude-sonnet-4-5-20250929`). Refer to the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code) for available models. +TAKT provides [takt-action](https://github.com/nrslib/takt-action) for GitHub Actions: -### Codex +```yaml +- uses: nrslib/takt-action@main + with: + anthropic_api_key: ${{ secrets.TAKT_ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} +``` -The model string is passed to the Codex SDK. If unspecified, defaults to `codex`. Refer to Codex documentation for available models. +For other CI systems, use pipeline mode: + +```bash +takt --pipeline --task "Fix the bug" --auto-pr +``` + +See the [CI/CD Guide](./docs/ci-cd.md) for full setup instructions. ## Project Structure ``` -~/.takt/ # Global configuration directory -├── config.yaml # Global config (provider, model, piece, etc.) -├── pieces/ # User piece definitions (override builtins) -│ └── custom.yaml -└── personas/ # User persona prompt files (.md) - └── my-persona.md +~/.takt/ # Global config +├── config.yaml # Provider, model, language, etc. +├── pieces/ # User piece definitions +└── personas/ # User persona prompts -.takt/ # Project-level configuration -├── config.yaml # Project config (current piece, etc.) -├── tasks/ # Task input directories (.takt/tasks/{slug}/order.md, etc.) -├── tasks.yaml # Pending tasks metadata (task_dir, piece, worktree, etc.) -└── runs/ # Run-scoped artifacts - └── {slug}/ - ├── reports/ # Execution reports (auto-generated) - ├── context/ # knowledge/policy/previous_response snapshots - ├── logs/ # NDJSON session logs for this run - └── meta.json # Run metadata +.takt/ # Project-level +├── config.yaml # Project config +├── tasks.yaml # Pending tasks +├── tasks/ # Task specifications +└── runs/ # Execution reports, logs, context ``` -Builtin resources are embedded in the npm package (`builtins/`). User files in `~/.takt/` take priority. - -### Global Configuration - -Configure default provider and model in `~/.takt/config.yaml`: - -```yaml -# ~/.takt/config.yaml -language: en -default_piece: default -log_level: info -provider: claude # Default provider: claude, codex, or opencode -model: sonnet # Default model (optional) -branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow) -prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate) -notification_sound: true # Enable/disable notification sounds -notification_sound_events: # Optional per-event toggles - iteration_limit: false - piece_complete: true - piece_abort: true - run_complete: true # Enabled by default; set false to disable - run_abort: true # Enabled by default; set false to disable -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) - -# Runtime environment defaults (applies to all pieces unless piece_config.runtime overrides) -# runtime: -# prepare: -# - gradle # Prepare Gradle cache/config in .runtime/ -# - node # Prepare npm cache in .runtime/ - -# 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 - -# Provider-specific permission profiles (optional) -# Priority: project override → global override → project default → global default → required_permission_mode (floor) -# provider_profiles: -# codex: -# default_permission_mode: full -# movement_permission_overrides: -# ai_review: readonly -# claude: -# default_permission_mode: edit - -# API Key configuration (optional) -# Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY -anthropic_api_key: sk-ant-... # For Claude (Anthropic) -# openai_api_key: sk-... # For Codex (OpenAI) -# opencode_api_key: ... # For OpenCode - -# Codex CLI path override (optional) -# Override the Codex CLI binary used by the Codex SDK (must be an absolute path to an executable file) -# Can be overridden by TAKT_CODEX_CLI_PATH environment variable -# codex_cli_path: /usr/local/bin/codex - -# Builtin piece filtering (optional) -# builtin_pieces_enabled: true # Set false to disable all builtins -# disabled_builtins: [magi, passthrough] # Disable specific builtin pieces - -# Pipeline execution configuration (optional) -# Customize branch names, commit messages, and PR body. -# pipeline: -# default_branch_prefix: "takt/" -# commit_message_template: "feat: {title} (#{issue})" -# pr_body_template: | -# ## Summary -# {issue_body} -# Closes #{issue} -``` - -**Note:** The Codex SDK requires running inside a Git repository. `--skip-git-repo-check` is only available in the Codex CLI. - -**API Key Configuration Methods:** - -1. **Set via environment variables**: - ```bash - export TAKT_ANTHROPIC_API_KEY=sk-ant-... # For Claude - export TAKT_OPENAI_API_KEY=sk-... # For Codex - export TAKT_OPENCODE_API_KEY=... # For OpenCode - ``` - -2. **Set in config file**: - Write `anthropic_api_key`, `openai_api_key`, or `opencode_api_key` in `~/.takt/config.yaml` as shown above - -Priority: Environment variables > `config.yaml` settings - -**Notes:** -- If you set an API Key, installing Claude Code, Codex, or OpenCode is not necessary. TAKT directly calls the respective API. -- **Security**: If you write API Keys in `config.yaml`, be careful not to commit this file to Git. Consider using environment variables or adding `~/.takt/config.yaml` to `.gitignore`. - -**Pipeline Template Variables:** - -| Variable | Available In | Description | -|----------|-------------|-------------| -| `{title}` | Commit message | Issue title | -| `{issue}` | Commit message, PR body | Issue number | -| `{issue_body}` | PR body | Issue body | -| `{report}` | PR body | Piece execution report | - -**Model Resolution Priority:** -1. Piece movement `model` (highest priority) -2. Custom agent `model` -3. Global config `model` -4. Provider default (Claude: sonnet, Codex: codex, OpenCode: provider default) - -## Detailed Guides - -### Task Directory Format - -TAKT stores task metadata in `.takt/tasks.yaml`, and each task's long specification in `.takt/tasks/{slug}/`. - -**Recommended layout**: - -```text -.takt/ - tasks/ - 20260201-015714-foptng/ - order.md - schema.sql - wireframe.png - tasks.yaml - runs/ - 20260201-015714-foptng/ - reports/ -``` - -**tasks.yaml record**: - -```yaml -tasks: - - name: add-auth-feature - status: pending - task_dir: .takt/tasks/20260201-015714-foptng - piece: default - created_at: "2026-02-01T01:57:14.000Z" - started_at: null - completed_at: null -``` - -`takt add` creates `.takt/tasks/{slug}/order.md` automatically and saves `task_dir` to `tasks.yaml`. - -#### Isolated Execution with Shared Clone - -Specifying `worktree` in YAML task files executes each task in an isolated clone created with `git clone --shared`, keeping your main working directory clean: - -- `worktree: true` - Auto-create shared clone in adjacent directory (or location specified by `worktree_dir` config) -- `worktree: "/path/to/dir"` - Create at specified path -- `branch: "feat/xxx"` - Use specified branch (auto-generated as `takt/{timestamp}-{slug}` if omitted) -- Omit `worktree` - Execute in current directory (default) - -> **Note**: The YAML field name remains `worktree` for backward compatibility. Internally, it uses `git clone --shared` instead of `git worktree`. Git worktrees have a `.git` file containing `gitdir:` pointing to the main repository, which Claude Code follows to recognize the main repository as the project root. Shared clones have an independent `.git` directory, preventing this issue. - -Clones are ephemeral. After task completion, they auto-commit + push, then delete the clone. Branches are the only persistent artifacts. Use `takt list` to list, merge, or delete branches. - -### Session Logs - -TAKT writes session logs in NDJSON (`.jsonl`) format to `.takt/runs/{slug}/logs/`. Each record is atomically appended, so partial logs are preserved even if the process crashes, and you can track in real-time with `tail -f`. - -- `.takt/runs/{slug}/logs/{sessionId}.jsonl` - NDJSON session log per piece execution -- `.takt/runs/{slug}/meta.json` - Run metadata (`task`, `piece`, `start/end`, `status`, etc.) - -Record types: `piece_start`, `step_start`, `step_complete`, `piece_complete`, `piece_abort` - -The latest previous response is stored at `.takt/runs/{slug}/context/previous_responses/latest.md` and inherited automatically. - -### Adding Custom Pieces - -Add YAML files to `~/.takt/pieces/` or customize builtins with `takt eject`: - -```bash -# Copy default piece to ~/.takt/pieces/ and edit -takt eject default -``` - -```yaml -# ~/.takt/pieces/my-piece.yaml -name: my-piece -description: Custom piece -max_movements: 5 -initial_movement: analyze - -personas: - analyzer: ~/.takt/personas/analyzer.md - coder: ../personas/coder.md - -movements: - - name: analyze - persona: analyzer - edit: false - rules: - - condition: Analysis complete - next: implement - instruction_template: | - Thoroughly analyze this request. - - - name: implement - persona: coder - edit: true - required_permission_mode: edit - pass_previous_response: true - rules: - - condition: Complete - next: COMPLETE - instruction_template: | - Implement based on the analysis. -``` - -> **Note**: `{task}`, `{previous_response}`, `{user_inputs}` are automatically injected into instructions. Explicit placeholders are only needed if you want to control their position in the template. - -### Specifying Personas by Path - -Map keys to file paths in section maps, then reference keys from movements: - -```yaml -# Section maps (relative to piece file) -personas: - coder: ../personas/coder.md - reviewer: ~/.takt/personas/my-reviewer.md -``` - -### Piece Variables - -Variables available in `instruction_template`: - -| Variable | Description | -|----------|-------------| -| `{task}` | Original user request (auto-injected if not in template) | -| `{iteration}` | Piece-wide turn count (total steps executed) | -| `{max_movements}` | Maximum iteration count | -| `{movement_iteration}` | Per-movement iteration count (times this movement has been executed) | -| `{previous_response}` | Output from previous movement (auto-injected if not in template) | -| `{user_inputs}` | Additional user inputs during piece (auto-injected if not in template) | -| `{report_dir}` | Report directory path (e.g., `.takt/runs/20250126-143052-task-summary/reports`) | -| `{report:filename}` | Expands to `{report_dir}/filename` (e.g., `{report:00-plan.md}`) | - -### Piece Design - -Elements needed for each piece movement: - -**1. Persona** - Referenced by section map key (used as system prompt): - -```yaml -persona: coder # Key from personas section map -persona_name: coder # Display name (optional) -``` - -**2. Rules** - Define routing from movement to next movement. The instruction builder auto-injects status output rules, so agents know which tags to output: - -```yaml -rules: - - condition: "Implementation complete" - next: review - - condition: "Blocked" - next: ABORT -``` - -Special `next` values: `COMPLETE` (success), `ABORT` (failure) - -**3. Movement Options:** - -| Option | Default | Description | -|--------|---------|-------------| -| `edit` | - | Whether movement can edit project files (`true`/`false`) | -| `pass_previous_response` | `true` | Pass previous movement output to `{previous_response}` | -| `allowed_tools` | - | List of tools agent can use (Read, Glob, Grep, Edit, Write, Bash, etc.) | -| `provider` | - | Override provider for this movement (`claude`, `codex`, or `opencode`) | -| `model` | - | Override model for this movement | -| `required_permission_mode` | - | Required minimum permission mode: `readonly`, `edit`, `full` (acts as floor; actual mode resolved via `provider_profiles`) | -| `provider_options` | - | Provider-specific options (e.g. `codex.network_access`, `opencode.network_access`) | -| `output_contracts` | - | Output contract definitions for report files | -| `quality_gates` | - | AI directives for movement completion requirements | -| `mcp_servers` | - | MCP (Model Context Protocol) server configuration (stdio/SSE/HTTP) | - -Piece-level defaults can be set with `piece_config.provider_options`, and movement-level `provider_options` overrides them. - -```yaml -piece_config: - provider_options: - codex: - network_access: true - opencode: - network_access: true - runtime: - prepare: - - gradle - - node -``` - -Runtime `prepare` entries can be builtin presets (`gradle`, `node`) or paths to custom shell scripts. Scripts receive `TAKT_RUNTIME_ROOT` and related env vars, and can export additional variables via stdout. - -## API Usage Example +## API Usage ```typescript -import { PieceEngine, loadPiece } from 'takt'; // npm install takt +import { PieceEngine, loadPiece } from 'takt'; const config = loadPiece('default'); -if (!config) { - throw new Error('Piece not found'); -} -const engine = new PieceEngine(config, process.cwd(), 'My task'); +if (!config) throw new Error('Piece not found'); +const engine = new PieceEngine(config, process.cwd(), 'My task'); engine.on('step:complete', (step, response) => { console.log(`${step.name}: ${response.status}`); }); @@ -870,81 +237,25 @@ engine.on('step:complete', (step, response) => { await engine.run(); ``` -## Contributing - -See [CONTRIBUTING.md](../CONTRIBUTING.md) for details. - -## CI/CD Integration - -### GitHub Actions - -TAKT provides a GitHub Action for automating PR reviews and task execution. See [takt-action](https://github.com/nrslib/takt-action) for details. - -**Piece example** (see [.github/workflows/takt-action.yml](../.github/workflows/takt-action.yml) in this repository): - -```yaml -name: TAKT - -on: - issue_comment: - types: [created] - -jobs: - takt: - if: contains(github.event.comment.body, '@takt') - runs-on: ubuntu-latest - permissions: - contents: write - issues: write - pull-requests: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Run TAKT - uses: nrslib/takt-action@main - with: - anthropic_api_key: ${{ secrets.TAKT_ANTHROPIC_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} -``` - -**Cost Warning**: TAKT uses AI APIs (Claude or OpenAI), which can incur significant costs, especially when tasks are auto-executed in CI/CD environments. Monitor API usage and set up billing alerts. - -### Other CI Systems - -For CI systems other than GitHub, use pipeline mode: - -```bash -# Install takt -npm install -g takt - -# Run in pipeline mode -takt --pipeline --task "Fix bug" --auto-pr --repo owner/repo -``` - -For authentication, set `TAKT_ANTHROPIC_API_KEY`, `TAKT_OPENAI_API_KEY`, or `TAKT_OPENCODE_API_KEY` environment variables (TAKT-specific prefix). - -```bash -# For Claude (Anthropic) -export TAKT_ANTHROPIC_API_KEY=sk-ant-... - -# For Codex (OpenAI) -export TAKT_OPENAI_API_KEY=sk-... - -# For OpenCode -export TAKT_OPENCODE_API_KEY=... -``` - ## Documentation -- [Faceted Prompting](./docs/faceted-prompting.md) - Separation of Concerns for AI prompts (Persona, Policy, Instruction, Knowledge, Output Contract) -- [Piece Guide](./docs/pieces.md) - Creating and customizing pieces -- [Agent Guide](./docs/agents.md) - Configuring custom agents -- [Changelog](../CHANGELOG.md) ([日本語](./docs/CHANGELOG.ja.md)) - Version history -- [Security Policy](../SECURITY.md) - Vulnerability reporting -- [Blog: TAKT - AI Agent Orchestration](https://zenn.dev/nrs/articles/c6842288a526d7) - Design philosophy and practical usage guide (Japanese) +| Document | Description | +|----------|-------------| +| [CLI Reference](./docs/cli-reference.md) | All commands and options | +| [Configuration](./docs/configuration.md) | Global and project settings | +| [Piece Guide](./docs/pieces.md) | Creating and customizing pieces | +| [Agent Guide](./docs/agents.md) | Custom agent configuration | +| [Builtin Catalog](./docs/builtin-catalog.md) | All builtin pieces and personas | +| [Faceted Prompting](./docs/faceted-prompting.md) | Prompt design methodology | +| [Task Management](./docs/task-management.md) | Task queuing, execution, isolation | +| [CI/CD Integration](./docs/ci-cd.md) | GitHub Actions and pipeline mode | +| [Changelog](./CHANGELOG.md) ([日本語](./docs/CHANGELOG.ja.md)) | Version history | +| [Security Policy](./SECURITY.md) | Vulnerability reporting | + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for details. ## License -MIT - See [LICENSE](../LICENSE) for details. +MIT — See [LICENSE](./LICENSE) for details. diff --git a/builtins/en/config.yaml b/builtins/en/config.yaml index 9358150..d4bee1d 100644 --- a/builtins/en/config.yaml +++ b/builtins/en/config.yaml @@ -1,105 +1,91 @@ # TAKT global configuration sample # Location: ~/.takt/config.yaml -# ---- Core ---- -language: en -default_piece: default -log_level: info +# ===================================== +# General settings (piece-independent) +# ===================================== +language: en # UI language: en | ja +log_level: info # Log level: debug | info | warn | error +provider: claude # Default provider: claude | codex | opencode | mock +# model: sonnet # Optional model name passed to provider -# ---- Provider ---- -# provider: claude | codex | opencode | mock -provider: claude +# Execution control +# worktree_dir: ~/takt-worktrees # Base directory for shared clone execution +# auto_pr: false # Auto-create PR after worktree execution +branch_name_strategy: ai # Branch strategy: romaji | ai +concurrency: 2 # Concurrent task execution for takt run (1-10) +# task_poll_interval_ms: 500 # Polling interval in ms during takt run (100-5000) +# prevent_sleep: false # Prevent macOS idle sleep while running -# Model (optional) -# Claude examples: opus, sonnet, haiku -# Codex examples: gpt-5.2-codex, gpt-5.1-codex -# OpenCode format: provider/model -# model: sonnet - -# Per-persona provider override -# persona_providers: -# coder: codex -# reviewer: claude - -# Provider-specific movement permission policy -# Priority: -# 1) project provider_profiles override -# 2) global provider_profiles override -# 3) project provider_profiles default -# 4) global provider_profiles default -# 5) movement.required_permission_mode (minimum floor) -# provider_profiles: -# codex: -# default_permission_mode: full -# movement_permission_overrides: -# ai_review: readonly -# claude: -# default_permission_mode: edit - -# Provider-specific runtime options -# provider_options: -# codex: -# network_access: true -# claude: -# sandbox: -# allow_unsandboxed_commands: true - -# ---- API Keys ---- -# Environment variables take priority: -# TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY -# anthropic_api_key: "" -# openai_api_key: "" -# opencode_api_key: "" - -# ---- Runtime ---- -# Global runtime preparation (piece_config.runtime overrides this) -# runtime: -# prepare: -# - gradle -# - node - -# ---- Execution ---- -# worktree_dir: ~/takt-worktrees -# auto_pr: false -# prevent_sleep: false - -# ---- Run Loop ---- -# concurrency: 1 -# task_poll_interval_ms: 500 -# interactive_preview_movements: 3 -# branch_name_strategy: romaji - -# ---- Output ---- -# minimal_output: false -# notification_sound: true -# notification_sound_events: +# Output / notifications +# minimal_output: false # Minimized output for CI logs +# verbose: false # Verbose output mode +# notification_sound: true # Master switch for sounds +# notification_sound_events: # Per-event sound toggle (unset means true) # iteration_limit: true # piece_complete: true # piece_abort: true # run_complete: true # run_abort: true # observability: -# provider_events: true +# provider_events: false # Persist provider stream events -# ---- Builtins ---- -# enable_builtin_pieces: true -# disabled_builtins: -# - magi +# Credentials (environment variables take priority) +# anthropic_api_key: "sk-ant-..." # Claude API key +# openai_api_key: "sk-..." # Codex/OpenAI API key +# opencode_api_key: "..." # OpenCode API key +# codex_cli_path: "/absolute/path/to/codex" # Absolute path to Codex CLI -# ---- Pipeline ---- +# Pipeline # pipeline: -# default_branch_prefix: "takt/" -# commit_message_template: "feat: {title} (#{issue})" -# pr_body_template: | +# default_branch_prefix: "takt/" # Prefix for pipeline-created branches +# commit_message_template: "feat: {title} (#{issue})" # Commit template +# pr_body_template: | # PR body template # ## Summary # {issue_body} # Closes #{issue} -# ---- Preferences ---- -# bookmarks_file: ~/.takt/preferences/bookmarks.yaml -# piece_categories_file: ~/.takt/preferences/piece-categories.yaml +# Misc +# bookmarks_file: ~/.takt/preferences/bookmarks.yaml # Bookmark file location -# ---- Debug ---- -# debug: -# enabled: false -# log_file: ~/.takt/logs/debug.log +# ===================================== +# Piece-related settings (global defaults) +# ===================================== +# 1) Route provider per persona +# persona_providers: +# coder: codex # Run coder persona on codex +# reviewer: claude # Run reviewer persona on claude + +# 2) Provider options (global < project < piece) +# provider_options: +# codex: +# network_access: true # Allow network access for Codex +# opencode: +# network_access: true # Allow network access for OpenCode +# claude: +# sandbox: +# allow_unsandboxed_commands: false # true allows unsandboxed execution for listed commands +# excluded_commands: +# - "npm publish" # Commands excluded from sandbox + +# 3) Movement permission policy +# provider_profiles: +# codex: +# default_permission_mode: full # Base permission: readonly | edit | full +# movement_permission_overrides: +# ai_review: readonly # Per-movement override +# claude: +# default_permission_mode: edit + +# 4) Runtime preparation before execution (recommended: enabled) +runtime: + prepare: + - gradle # Prepare Gradle cache/env under .runtime + - node # Prepare npm cache/env under .runtime + +# 5) Piece list / categories +# enable_builtin_pieces: true # Enable built-in pieces from builtins/{lang}/pieces +# disabled_builtins: +# - magi # Built-in piece names to disable +# piece_categories_file: ~/.takt/preferences/piece-categories.yaml # Category definition file +# interactive_preview_movements: 3 # Preview movement count in interactive mode (0-10) diff --git a/builtins/ja/config.yaml b/builtins/ja/config.yaml index 7c86fec..323f511 100644 --- a/builtins/ja/config.yaml +++ b/builtins/ja/config.yaml @@ -1,105 +1,91 @@ # TAKT グローバル設定サンプル # 配置場所: ~/.takt/config.yaml -# ---- 基本 ---- -language: ja -default_piece: default -log_level: info +# ===================================== +# 通常設定(ピース非依存) +# ===================================== +language: ja # 表示言語: ja | en +log_level: info # ログレベル: debug | info | warn | error +provider: claude # デフォルト実行プロバイダー: claude | codex | opencode | mock +# model: sonnet # 省略可。providerに渡すモデル名 -# ---- プロバイダー ---- -# provider: claude | codex | opencode | mock -provider: claude +# 実行制御 +# worktree_dir: ~/takt-worktrees # 共有clone作成先ディレクトリ +# auto_pr: false # worktree実行後に自動PR作成するか +branch_name_strategy: ai # ブランチ名生成: romaji | ai +concurrency: 2 # takt run の同時実行数(1-10) +# task_poll_interval_ms: 500 # takt run のタスク監視間隔ms(100-5000) +# prevent_sleep: false # macOS実行中のスリープ防止(caffeinate) -# モデル(任意) -# Claude 例: opus, sonnet, haiku -# Codex 例: gpt-5.2-codex, gpt-5.1-codex -# OpenCode 形式: provider/model -# model: sonnet - -# ペルソナ別プロバイダー上書き -# persona_providers: -# coder: codex -# reviewer: claude - -# プロバイダー別 movement 権限ポリシー -# 優先順: -# 1) project provider_profiles override -# 2) global provider_profiles override -# 3) project provider_profiles default -# 4) global provider_profiles default -# 5) movement.required_permission_mode(下限補正) -# provider_profiles: -# codex: -# default_permission_mode: full -# movement_permission_overrides: -# ai_review: readonly -# claude: -# default_permission_mode: edit - -# プロバイダー別ランタイムオプション -# provider_options: -# codex: -# network_access: true -# claude: -# sandbox: -# allow_unsandboxed_commands: true - -# ---- API キー ---- -# 環境変数が優先: -# TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY -# anthropic_api_key: "" -# openai_api_key: "" -# opencode_api_key: "" - -# ---- ランタイム ---- -# グローバルなランタイム準備(piece_config.runtime があればそちらを優先) -# runtime: -# prepare: -# - gradle -# - node - -# ---- 実行 ---- -# worktree_dir: ~/takt-worktrees -# auto_pr: false -# prevent_sleep: false - -# ---- Run Loop ---- -# concurrency: 1 -# task_poll_interval_ms: 500 -# interactive_preview_movements: 3 -# branch_name_strategy: romaji - -# ---- 出力 ---- -# minimal_output: false -# notification_sound: true -# notification_sound_events: +# 出力・通知 +# minimal_output: false # 出力を最小化(CI向け) +# verbose: false # 詳細ログを有効化 +# notification_sound: true # 通知音全体のON/OFF +# notification_sound_events: # イベント別通知音(未指定はtrue扱い) # iteration_limit: true # piece_complete: true # piece_abort: true # run_complete: true # run_abort: true # observability: -# provider_events: true +# provider_events: false # providerイベントログを記録 -# ---- Builtins ---- -# enable_builtin_pieces: true -# disabled_builtins: -# - magi +# 認証情報(環境変数優先) +# anthropic_api_key: "sk-ant-..." # Claude APIキー +# openai_api_key: "sk-..." # Codex APIキー +# opencode_api_key: "..." # OpenCode APIキー +# codex_cli_path: "/absolute/path/to/codex" # Codex CLI絶対パス -# ---- Pipeline ---- +# パイプライン # pipeline: -# default_branch_prefix: "takt/" -# commit_message_template: "feat: {title} (#{issue})" -# pr_body_template: | +# default_branch_prefix: "takt/" # pipeline作成ブランチの接頭辞 +# commit_message_template: "feat: {title} (#{issue})" # コミット文テンプレート +# pr_body_template: | # PR本文テンプレート # ## Summary # {issue_body} # Closes #{issue} -# ---- Preferences ---- -# bookmarks_file: ~/.takt/preferences/bookmarks.yaml -# piece_categories_file: ~/.takt/preferences/piece-categories.yaml +# その他 +# bookmarks_file: ~/.takt/preferences/bookmarks.yaml # ブックマーク保存先 -# ---- Debug ---- -# debug: -# enabled: false -# log_file: ~/.takt/logs/debug.log +# ===================================== +# ピースにも関わる設定(global defaults) +# ===================================== +# 1) ペルソナ単位でプロバイダーを切り替える +# persona_providers: +# coder: codex # coderペルソナはcodexで実行 +# reviewer: claude # reviewerペルソナはclaudeで実行 + +# 2) provider 固有オプション(global < project < piece) +# provider_options: +# codex: +# network_access: true # Codex実行時のネットワークアクセス許可 +# opencode: +# network_access: true # OpenCode実行時のネットワークアクセス許可 +# claude: +# sandbox: +# allow_unsandboxed_commands: false # trueで対象コマンドを非サンドボックス実行 +# excluded_commands: +# - "npm publish" # 非サンドボックス対象コマンド + +# 3) movement の権限ポリシー +# provider_profiles: +# codex: +# default_permission_mode: full # 既定権限: readonly | edit | full +# movement_permission_overrides: +# ai_review: readonly # movement単位の上書き +# claude: +# default_permission_mode: edit + +# 4) 実行前のランタイム準備(推奨: 有効化) +runtime: + prepare: + - gradle # Gradleキャッシュ/環境を .runtime 配下に準備 + - node # npmキャッシュ/環境を .runtime 配下に準備 + +# 5) ピース一覧/カテゴリ +# enable_builtin_pieces: true # builtins/{lang}/pieces を有効化 +# disabled_builtins: +# - magi # 無効化するビルトインピース名 +# piece_categories_file: ~/.takt/preferences/piece-categories.yaml # カテゴリ定義ファイル +# interactive_preview_movements: 3 # 対話モードのプレビュー件数(0-10) diff --git a/docs/CHANGELOG.ja.md b/docs/CHANGELOG.ja.md index 47c7b1e..8a67ad0 100644 --- a/docs/CHANGELOG.ja.md +++ b/docs/CHANGELOG.ja.md @@ -6,6 +6,40 @@ フォーマットは [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) に基づいています。 +## [0.20.0] - 2026-02-19 + +### Added + +- **Faceted Prompting モジュール** (`src/faceted-prompting/`): ファセット合成・解決・テンプレートレンダリング・トランケーションのスタンドアロンライブラリ — TAKT 内部への依存ゼロ。プラガブルなファセットストレージのための `DataEngine` インターフェースと `FileDataEngine`、`CompositeDataEngine` 実装を含む +- **Analytics モジュール** (`src/features/analytics/`): ローカル専用のレビュー品質メトリクス収集 — イベント型(レビュー指摘、修正アクション、ムーブメント結果)、日付ローテーション付き JSONL ライター、レポートパーサー、メトリクス計算 +- **`takt metrics review` コマンド**: レビュー品質メトリクスを表示(再報告カウント、ラウンドトリップ率、解決イテレーション数、ルール別 REJECT カウント、反論解決率)。`--since` で時間枠を設定可能 +- **`takt purge` コマンド**: 古いアナリティクスイベントファイルを削除。`--retention-days` で保持期間を設定可能 +- **`takt reset config` コマンド**: グローバル設定をビルトインテンプレートにリセット(既存設定の自動バックアップ付き) +- **PR 重複防止**: 現在のブランチに既に PR が存在する場合、新規作成ではなく既存 PR へのプッシュとコメント追加で対応 (#304) +- リトライ時のムーブメント選択で失敗箇所にカーソルを初期配置 +- run-recovery と config-priority シナリオの E2E テストを追加 + +### Changed + +- **README を大幅改訂**: 約950行から約270行に圧縮 — 詳細情報を専用ドキュメント(`docs/configuration.md`、`docs/cli-reference.md`、`docs/task-management.md`、`docs/ci-cd.md`、`docs/builtin-catalog.md`)に分離し、日本語版も作成。プロダクトコンセプトを4軸(すぐ始められる、実用的、再現可能、マルチエージェント)で再定義 +- **設定システムのリファクタリング**: 設定解決を `resolveConfigValue()` と `loadConfig()` に統一し、コードベース全体に散在していた設定アクセスパターンを解消 +- **`takt config` コマンド削除**: デフォルトへのリセットを行う `takt reset config` に置き換え +- ビルトイン設定テンプレートのコメントと構造を刷新 +- `@anthropic-ai/claude-agent-sdk` を v0.2.47 に更新 +- タスク再指示のインストラクトモードプロンプトを改善 + +### Fixed + +- ビルトインピースのファイル参照が相対パスではなく絶対パスを使用していた問題を修正 (#304) +- 複数ファイルにまたがる未使用 import・変数を削除 + +### Internal + +- `loadConfig`、`resolveConfigValue`、ピース設定解決、設定優先順位パスの統一 +- config-priority と run-recovery シナリオの E2E テストを追加 +- PR 作成フローテスト用の `postExecution.test.ts` を追加 +- 未使用 import・変数のクリーンアップ + ## [0.19.0] - 2026-02-18 ### Added diff --git a/docs/README.ja.md b/docs/README.ja.md index c99c158..a18ac77 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -1,868 +1,246 @@ # TAKT -**T**AKT **A**gent **K**oordination **T**opology - AIエージェントの協調手順・人の介入ポイント・記録をYAMLで定義する +[English](../README.md) -TAKTは複数のAIエージェント(Claude Code、Codex、OpenCode)をYAMLで定義されたワークフローに従って実行します。各ステップで誰が実行し、何を見て、何を許可し、失敗時にどうするかはピースファイルに宣言され、エージェント任せにしません。 +**T**AKT **A**gent **K**oordination **T**opology — AI コーディングエージェントにレビューループ・プロンプト管理・ガードレールを与え、「とりあえず動くコード」ではなく「品質の高いコード」を出させるツールです。 -TAKTはTAKT自身で開発されています(ドッグフーディング)。 +AI と会話してやりたいことを決め、タスクとして積み、`takt run` で実行します。計画・実装・レビュー・修正のループは YAML の piece ファイルで定義されており、エージェント任せにはしません。Claude Code、Codex、OpenCode に対応しています。 -## メタファ +TAKT は TAKT 自身で開発しています(ドッグフーディング)。 -TAKTはオーケストラをイメージした音楽メタファで用語を統一しています。 +## なぜ TAKT か -- **Piece**: タスク実行定義(何をどう協調させるか) -- **Movement**: ピース内の1ステップ(実行フローの1段階) -- **Orchestration**: ムーブメント間でエージェントを協調させるエンジン +**すぐ始められる** — アーキテクチャ、セキュリティ、AI アンチパターンなどのレビュー観点をビルトインで備えています。インストールしたその日から、一定以上の品質のコードを出せます。 -## なぜTAKTか +**実用的** — 日々の開発で使うためのツールです。AI と相談して要件を固め、タスクを積んで実行します。worktree の自動隔離、PR 作成、失敗時のリトライまで面倒を見てくれます。 -- AIエージェントは強力だが非決定的 — TAKTはエージェントの判断を可視化し、再現可能にする -- マルチエージェントの協調には構造が必要 — ピースが誰が何をどの順序でどの権限で行うかを定義する -- CI/CD連携にはガードレールが必要 — パイプラインモードが非対話でエージェントを実行し、完全な監査ログを残す +**再現可能** — 実行パスを YAML で宣言するから、結果のブレを抑えられます。piece は共有できるので、チームの誰かが作ったワークフローを他のメンバーがそのまま使って同じ品質プロセスを回せます。すべてのステップは NDJSON でログに残るため、タスクから PR まで追跡もできます。 -## TAKTが制御・管理するもの +**マルチエージェント** — 異なるペルソナ・権限・レビュー基準を持つ複数のエージェントを協調させます。並列レビュー、失敗時の差し戻し、ルールによる結果の集約に対応しています。プロンプトは persona・policy・knowledge・instruction の独立したファセットとして管理し、ワークフロー間で自由に組み合わせられます([Faceted Prompting](./faceted-prompting.ja.md))。 -TAKTはエージェントの実行を**制御**し、プロンプトの構成要素を**管理**します。 +## 必要なもの -| | 対象 | 説明 | -|---|------|------| -| 制御 | **ルーティング** | 状態遷移ルール(誰がいつ動くか) | -| 制御 | **ツール・権限** | 読み取り専用・編集可・フルアクセス(何を許可するか) | -| 制御 | **記録** | セッションログ・レポート(何を残すか) | -| 管理 | **ペルソナ** | エージェントの役割・専門性(誰として振る舞うか) | -| 管理 | **ポリシー** | コーディング規約・品質基準・禁止事項(何を守るか) | -| 管理 | **ナレッジ** | ドメイン知識・アーキテクチャ情報(何を参照するか) | +次のいずれかが必要です。 -ペルソナ・ポリシー・ナレッジは独立したファイルとして管理され、ワークフロー間で自由に組み合わせられます([Faceted Prompting](./faceted-prompting.ja.md))。ポリシーを1ファイル変更すれば、それを使うすべてのワークフローに反映されます。 +- **プロバイダー CLI**: [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Codex](https://github.com/openai/codex)、[OpenCode](https://opencode.ai) のいずれか +- **API Key 直接利用**: Anthropic / OpenAI / OpenCode の API Key があれば CLI は不要です -## TAKTとは何でないか +任意: -- **自律型AIエンジニアではない** — TAKTはエージェントを協調させるが、何を作るかは決めない。タスクを与えるのはあなたで、TAKTは実行を統制する。 -- **SkillやSwarmの代替ではない** — Skillは単一エージェントの知識を拡張する。Swarmはエージェントを並列化する。TAKTはエージェント間のワークフロー構造を定義する — 誰がどの順序でどのルールで実行するか。 -- **デフォルトで全自動ではない** — すべてのステップで人の承認を要求できる。自動化はオプトイン(パイプラインモード)であり、デフォルトではない。 +- [GitHub CLI](https://cli.github.com/) (`gh`) — `takt #N` で GitHub Issue を使う場合に必要です -## 必要条件 +## クイックスタート -次のいずれかを選択してください。 - -- **プロバイダーCLIを使用**: [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Codex](https://github.com/openai/codex)、または [OpenCode](https://opencode.ai) をインストール -- **API直接利用**: **Anthropic API Key**、**OpenAI API Key**、または **OpenCode API Key**(CLI不要) - -追加で必要なもの: - -- [GitHub CLI](https://cli.github.com/) (`gh`) — `takt #N`(GitHub Issue実行)を使う場合のみ必要 - -**料金について**: API Key を使用する場合、TAKT は Claude API(Anthropic)、OpenAI API、または OpenCode API を直接呼び出します。料金体系は各 CLI ツールを使った場合と同じです。特に CI/CD で自動実行する場合、API 使用量が増えるため、コストに注意してください。 - -## インストール +### インストール ```bash npm install -g takt ``` -## クイックスタート - -```bash -# 対話モードでAIとタスク要件を詰めてから実行 -takt - -# GitHub Issueをタスクとして実行(どちらも同じ) -takt #6 -takt --issue 6 - -# パイプライン実行(非対話・スクリプト/CI向け) -takt --pipeline --task "バグを修正して" --auto-pr -``` - -## 使い方 - -### 対話モード - -AI との会話でタスク内容を詰めてから実行するモード。タスクの要件が曖昧な場合や、AI と相談しながら内容を整理したい場合に便利です。 - -```bash -# 対話モードを開始(引数なし) -takt - -# 最初のメッセージを指定(短い単語のみ) -takt hello -``` - -**注意:** `--task` オプションを指定すると対話モードをスキップして直接タスク実行されます。Issue 参照(`#6`、`--issue`)は対話モードの初期入力として使用されます。 - -対話開始時には `takt list` の履歴を自動取得し、`failed` / `interrupted` / `completed` の実行結果を `pieceContext` に注入して会話要約へ反映します。要約では `Worktree ID`、`開始/終了時刻`、`最終結果`、`失敗要約`、`ログ参照キー` を参照できます。`takt list` の取得に失敗しても対話は継続されます。 - -**フロー:** -1. ピース選択 -2. 対話モード選択(assistant / persona / quiet / passthrough) -3. AI との会話でタスク内容を整理 -4. `/go` でタスク指示を確定(`/go 追加の指示` のように指示を追加することも可能)、または `/play <タスク>` で即座に実行 -5. 実行(worktree 作成、ピース実行、PR 作成) - -#### 対話モードの種類 - -| モード | 説明 | -|--------|------| -| `assistant` | デフォルト。AI が質問を通じてタスク要件を明確にしてから指示を生成。 | -| `persona` | 最初のムーブメントのペルソナとの会話(ペルソナのシステムプロンプトとツールを使用)。 | -| `quiet` | 質問なしでタスク指示を生成(ベストエフォート)。 | -| `passthrough` | ユーザー入力をそのままタスクテキストとして使用。AI 処理なし。 | - -ピースの `interactive_mode` フィールドでデフォルトモードを設定可能。 - -#### 実行例 +### AI と相談して実行する ``` $ takt Select piece: - ❯ 🎼 default (current) - 📁 Development/ - 📁 Research/ - Cancel + > 🎼 default (current) + 📁 🚀 クイックスタート/ + 📁 🎨 フロントエンド/ + 📁 ⚙️ バックエンド/ -対話モード - タスク内容を入力してください。コマンド: /go(実行), /cancel(終了) +対話モード - タスク内容を入力してください。 +コマンド: /go(実行), /cancel(終了) -> ユーザー認証機能を追加したい +> ユーザー認証を JWT で追加して -[AI が要件を確認・整理] +[AI が要件を整理してくれます] > /go 提案されたタスク指示: -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -ユーザー認証機能を実装する。 + ... -要件: -- メールアドレスとパスワードによるログイン機能 -- JWT トークンを使った認証 -- パスワードのハッシュ化(bcrypt) -- ログイン・ログアウト API エンドポイント -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -このタスク指示で進めますか? (Y/n) y - -? Create worktree? (Y/n) y - -[ピース実行開始...] +どうしますか? + > 実行する + GitHub Issueを建てる + タスクにつむ + 会話を続ける ``` -### 直接タスク実行 +TAKT が隔離された worktree を作り、piece を実行(計画 → 実装 → レビュー → 修正ループ)します。終わったら PR を作成するか聞いてきます。 -`--task` オプションを使うと、対話モードをスキップして直接実行できます。 +### タスクを積んでまとめて実行する ```bash -# --task オプションでタスク内容を指定 -takt --task "バグを修正" +# 会話でタスクを積みます +takt +> auth モジュールをリファクタリングして +> /go +# → どうしますか? → 「タスクにつむ」を選択 -# ピース指定 -takt --task "認証機能を追加" --piece expert +# GitHub Issue から積むこともできます +takt add #6 +takt add #12 -# PR 自動作成 -takt --task "バグを修正" --auto-pr -``` - -**注意:** `takt "ログイン機能を追加する"` のように引数として文字列を渡した場合は、対話モードの初期メッセージとして使用されます。 - -### GitHub Issue タスク - -GitHub Issue を直接タスクとして実行できます。Issue のタイトル、本文、ラベル、コメントが自動的にタスク内容として取り込まれます。 - -```bash -# Issue番号を指定して実行 -takt #6 -takt --issue 6 - -# Issue + ピース指定 -takt #6 --piece expert - -# Issue + PR自動作成 -takt #6 --auto-pr -``` - -**必要条件:** [GitHub CLI](https://cli.github.com/) (`gh`) がインストールされ、認証済みであること。 - -### タスク管理(add / run / watch / list) - -`.takt/tasks.yaml` と `.takt/tasks/{slug}/` を使ったバッチ処理。複数のタスクを積んでおいて、後でまとめて実行する使い方に便利です。 - -#### タスクを追加(`takt add`) - -```bash -# AI会話でタスクの要件を詰めてからタスクを追加 -takt add - -# GitHub IssueからタスクAdd(Issue番号がブランチ名に反映される) -takt add #28 -``` - -#### タスクを実行(`takt run`) - -```bash -# .takt/tasks.yaml の保留中タスクをすべて実行 +# まとめて実行します takt run ``` -#### タスクを監視(`takt watch`) +### 結果を管理する ```bash -# .takt/tasks.yaml を監視してタスクを自動実行(常駐プロセス) -takt watch -``` - -#### タスクブランチを一覧表示(`takt list`) - -```bash -# タスクブランチ一覧(マージ・削除) +# 完了・失敗したタスクブランチの一覧を確認し、マージ、リトライ、削除ができます takt list - -# 非対話モード(CI/スクリプト向け) -takt list --non-interactive -takt list --non-interactive --action diff --branch takt/my-branch -takt list --non-interactive --action delete --branch takt/my-branch --yes -takt list --non-interactive --format json ``` -対話モードでは、上記の実行履歴(`failed` / `interrupted` / `completed`)を起動時に再利用し、失敗事例や中断済み実行を再作業対象として特定しやすくします。 +## 仕組み -#### タスクディレクトリ運用(作成・実行・確認) +TAKT は音楽のメタファーを使っています。**piece** がワークフロー、**movement** が各ステップにあたります。 -1. `takt add` を実行して `.takt/tasks.yaml` に pending レコードが作られることを確認する。 -2. 生成された `.takt/tasks/{slug}/order.md` を開き、必要なら仕様や参考資料を追記する。 -3. `takt run`(または `takt watch`)で `tasks.yaml` の pending タスクを実行する。 -4. `task_dir` と同じスラッグの `.takt/runs/{slug}/reports/` を確認する。 - -### パイプラインモード(CI/自動化向け) - -`--pipeline` を指定すると非対話のパイプラインモードに入ります。ブランチ作成 → ピース実行 → commit & push を自動で行います。CI/CD での自動化に適しています。 - -```bash -# タスクをパイプライン実行 -takt --pipeline --task "バグを修正" - -# パイプライン実行 + PR自動作成 -takt --pipeline --task "バグを修正" --auto-pr - -# Issue情報を紐付け -takt --pipeline --issue 99 --auto-pr - -# ピース・ブランチ指定 -takt --pipeline --task "バグを修正" -w magi -b feat/fix-bug - -# リポジトリ指定(PR作成時) -takt --pipeline --task "バグを修正" --auto-pr --repo owner/repo - -# ピース実行のみ(ブランチ作成・commit・pushをスキップ) -takt --pipeline --task "バグを修正" --skip-git - -# 最小限の出力モード(CI向け) -takt --pipeline --task "バグを修正" --quiet -``` - -パイプラインモードでは `--auto-pr` を指定しない限り PR は作成されません。 - -**GitHub との統合:** GitHub Actions で TAKT を使う場合は、[takt-action](https://github.com/nrslib/takt-action) を参照してください。PR レビューやタスク実行を自動化できます。詳細は [CI/CD 連携](#cicd連携) セクションを参照してください。 - -### その他のコマンド - -```bash -# ピースを対話的に切り替え -takt switch - -# ビルトインのピース/エージェントをプロジェクト .takt/ にコピーしてカスタマイズ -takt eject - -# ~/.takt/(グローバル)にコピー -takt eject --global - -# エージェントの会話セッションをクリア -takt clear - -# ビルトインピース・エージェントを Claude Code Skill としてデプロイ -takt export-cc - -# 利用可能なファセットをレイヤー別に一覧表示 -takt catalog -takt catalog personas - -# 特定のファセットをカスタマイズ用にコピー -takt eject persona coder -takt eject instruction plan --global - -# 各ムーブメント・フェーズの組み立て済みプロンプトをプレビュー -takt prompt [piece] - -# パーミッションモードを設定 -takt config - -# ピースカテゴリをビルトインのデフォルトにリセット -takt reset categories -``` - -### おすすめピース - -| ピース | おすすめ用途 | -|------------|------------| -| `default` | 本格的な開発タスク。TAKT自身の開発で使用。アーキテクト+セキュリティの並列レビュー付き多段階レビュー。 | -| `default-mini` | 簡単な修正やシンプルなタスク。AI アンチパターンレビュー+スーパーバイザー付きの軽量ピース。 | -| `review-fix-minimal` | レビュー&修正ピース。レビューフィードバックに基づく反復的な改善に特化。 | -| `research` | 調査・リサーチ。質問せずに自律的にリサーチを実行。 | - -### 主要なオプション - -| オプション | 説明 | -|-----------|------| -| `--pipeline` | **パイプライン(非対話)モードを有効化** — CI/自動化に必須 | -| `-t, --task ` | タスク内容(GitHub Issueの代わり) | -| `-i, --issue ` | GitHub Issue番号(対話モードでは `#N` と同じ) | -| `-w, --piece ` | ピース名、またはピースYAMLファイルのパス | -| `-b, --branch ` | ブランチ名指定(省略時は自動生成) | -| `--auto-pr` | PR作成(対話: 確認スキップ、パイプライン: PR有効化) | -| `--skip-git` | ブランチ作成・commit・pushをスキップ(パイプラインモード、ピース実行のみ) | -| `--repo ` | リポジトリ指定(PR作成時) | -| `--create-worktree ` | worktree確認プロンプトをスキップ | -| `-q, --quiet` | 最小限の出力モード: AIの出力を抑制(CI向け) | -| `--provider ` | エージェントプロバイダーを上書き(claude\|codex\|opencode\|mock) | -| `--model ` | エージェントモデルを上書き | - -## ピース - -TAKTはYAMLベースのピース定義とルールベースルーティングを使用します。ビルトインピースはパッケージに埋め込まれており、`~/.takt/pieces/` のユーザーピースが優先されます。`takt eject` でビルトインを`~/.takt/`にコピーしてカスタマイズできます。 - -> **注記 (v0.4.0)**: ピースコンポーネントの内部用語が "step" から "movement" に変更されました。ユーザー向けのピースファイルは引き続き互換性がありますが、ピースをカスタマイズする場合、YAMLファイルで `movements:` の代わりに `movements:` が使用されることがあります。機能は同じです。 - -### ピースの例 +piece は movement の並びを定義します。各 movement では persona(誰が実行するか)、権限(何を許可するか)、ルール(次にどこへ進むか)を指定します。 ```yaml -name: default -max_movements: 10 +name: simple initial_movement: plan -# セクションマップ — キー: ファイルパス(このYAMLからの相対パス) personas: planner: ../personas/planner.md coder: ../personas/coder.md reviewer: ../personas/architecture-reviewer.md -policies: - coding: ../policies/coding.md - -knowledge: - architecture: ../knowledge/architecture.md - movements: - name: plan persona: planner - model: opus edit: false rules: - - condition: 計画完了 + - condition: Planning complete next: implement - instruction_template: | - リクエストを分析し、実装計画を作成してください。 - name: implement persona: coder - policy: coding - knowledge: architecture edit: true - required_permission_mode: edit rules: - - condition: 実装完了 + - condition: Implementation complete next: review - - condition: 進行不可 - next: ABORT - instruction_template: | - 計画に基づいて実装してください。 - name: review persona: reviewer - knowledge: architecture edit: false rules: - - condition: 承認 + - condition: Approved next: COMPLETE - - condition: 修正が必要 - next: implement - instruction_template: | - アーキテクチャとコード品質の観点で実装をレビューしてください。 + - condition: Needs fix + next: implement # <- 修正ループ ``` -### ペルソナレスムーブメント +ルールが次の movement を決めます。`COMPLETE` で成功終了、`ABORT` で失敗終了です。並列 movement やルール条件の詳細は [Piece Guide](./pieces.md) を参照してください。 -`persona` フィールドは省略可能です。省略した場合、ムーブメントはシステムプロンプトなしで `instruction_template` のみを使って実行されます。これはペルソナのカスタマイズが不要なシンプルなタスクに便利です。 +## おすすめ piece -```yaml - - name: summarize - # persona未指定 — instruction_templateのみを使用 - edit: false - rules: - - condition: 要約完了 - next: COMPLETE - instruction_template: | - レポートを読んで簡潔な要約を提供してください。 -``` +| Piece | 用途 | +|-------|------| +| `default-mini` | ちょっとした修正向けです。計画 → 実装 → 並列レビュー → 修正の軽量構成です。 | +| `frontend-mini` | フロントエンド向けの mini 構成です。 | +| `backend-mini` | バックエンド向けの mini 構成です。 | +| `expert-mini` | エキスパート向けの mini 構成です。 | +| `default` | 本格的な開発向けです。並列レビュアーによる多段階レビューが付いています。TAKT 自身の開発にも使用しています。 | +全ピース・ペルソナの一覧は [Builtin Catalog](./builtin-catalog.ja.md) を参照してください。 -また、`persona` の値としてインラインシステムプロンプトを記述することもできます(指定されたファイルが存在しない場合): +## 主要コマンド -```yaml - - name: review - persona: "あなたはコードレビュアーです。可読性と保守性に焦点を当ててください。" - edit: false - instruction_template: | - コード品質をレビューしてください。 -``` - -### パラレルムーブメント - -ムーブメント内でサブムーブメントを並列実行し、集約条件で評価できます: - -```yaml - - name: reviewers - parallel: - - name: arch-review - persona: reviewer - rules: - - condition: approved - - condition: needs_fix - instruction_template: | - アーキテクチャとコード品質をレビューしてください。 - - name: security-review - persona: security-reviewer - rules: - - condition: approved - - condition: needs_fix - instruction_template: | - セキュリティ脆弱性をレビューしてください。 - rules: - - condition: all("approved") - next: supervise - - condition: any("needs_fix") - next: fix -``` - -- `all("X")`: すべてのサブムーブメントが条件Xにマッチした場合にtrue -- `any("X")`: いずれかのサブムーブメントが条件Xにマッチした場合にtrue -- サブムーブメントの `rules` は可能な結果を定義しますが、`next` は省略可能(親が遷移を制御) - -### ルール条件の種類 - -| 種類 | 構文 | 説明 | -|------|------|------| -| タグベース | `"条件テキスト"` | エージェントが `[MOVEMENTNAME:N]` タグを出力し、インデックスでマッチ | -| AI判定 | `ai("条件テキスト")` | AIが条件をエージェント出力に対して評価 | -| 集約 | `all("X")` / `any("X")` | パラレルサブムーブメントの結果を集約 | - -## ビルトインピース - -TAKTには複数のビルトインピースが同梱されています: - -| ピース | 説明 | -|------------|------| -| `default` | フル開発ピース: 計画 → 実装 → AI レビュー → 並列レビュー(アーキテクト+QA)→ スーパーバイザー承認。各レビュー段階に修正ループあり。 | -| `default-mini` | ミニ開発ピース: 計画 → 実装 → 並列レビュー(AI アンチパターン+スーパーバイザー)→ 修正。レビュー付きの軽量構成。 | -| `frontend-mini` | ミニフロントエンドピース: 計画 → 実装 → 並列レビュー(AI アンチパターン+スーパーバイザー)。フロントエンドナレッジ注入付き。 | -| `backend-mini` | ミニバックエンドピース: 計画 → 実装 → 並列レビュー(AI アンチパターン+スーパーバイザー)。バックエンドナレッジ注入付き。 | -| `backend-cqrs-mini` | ミニ CQRS+ES ピース: 計画 → 実装 → 並列レビュー(AI アンチパターン+スーパーバイザー)。CQRS+ES ナレッジ注入付き。 | -| `review-fix-minimal` | レビュー重視ピース: レビュー → 修正 → スーパーバイザー。レビューフィードバックに基づく反復改善向け。 | -| `research` | リサーチピース: プランナー → ディガー → スーパーバイザー。質問せずに自律的にリサーチを実行。 | -| `deep-research` | ディープリサーチピース: 計画 → 深掘り → 分析 → 統括。発見駆動型の調査で、多角的な分析を行う。 | -| `expert` | フルスタック開発ピース: アーキテクチャ、フロントエンド、セキュリティ、QA レビューと修正ループ。 | -| `expert-mini` | ミニエキスパートピース: 計画 → 実装 → 並列レビュー(AI アンチパターン+エキスパートスーパーバイザー)。フルスタックナレッジ注入付き。 | -| `expert-cqrs` | フルスタック開発ピース(CQRS+ES特化): CQRS+ES、フロントエンド、セキュリティ、QA レビューと修正ループ。 | -| `expert-cqrs-mini` | ミニ CQRS+ES エキスパートピース: 計画 → 実装 → 並列レビュー(AI アンチパターン+エキスパートスーパーバイザー)。CQRS+ES ナレッジ注入付き。 | -| `magi` | エヴァンゲリオンにインスパイアされた審議システム。3つの AI ペルソナ(MELCHIOR、BALTHASAR、CASPER)が分析し投票。 | -| `passthrough` | 最小構成。タスクをそのまま coder に渡す薄いラッパー。レビューなし。 | -| `compound-eye` | マルチモデルレビュー: Claude と Codex に同じ指示を同時送信し、両方の回答を統合。 | -| `review-only` | 変更を加えない読み取り専用のコードレビューピース。 | -| `structural-reform` | プロジェクト全体の構造改革: 段階的なファイル分割を伴う反復的なコードベース再構成。 | -| `unit-test` | ユニットテスト重視ピース: テスト分析 → テスト実装 → レビュー → 修正。 | -| `e2e-test` | E2Eテスト重視ピース: E2E分析 → E2E実装 → レビュー → 修正(VitestベースのE2Eフロー)。 | -| `frontend` | フロントエンド特化開発ピース: React/Next.js 向けのレビューとナレッジ注入。 | -| `backend` | バックエンド特化開発ピース: バックエンド、セキュリティ、QA の専門家レビュー。 | -| `backend-cqrs` | CQRS+ES 特化バックエンド開発ピース: CQRS+ES、セキュリティ、QA の専門家レビュー。 | - -**ペルソナ別プロバイダー設定:** 設定ファイルの `persona_providers` で、特定のペルソナを異なるプロバイダーにルーティングできます(例: coder は Codex、レビュアーは Claude)。ピースを複製する必要はありません。 - -`takt switch` でピースを切り替えられます。 - -## ビルトインペルソナ - -| ペルソナ | 説明 | +| コマンド | 説明 | |---------|------| -| **planner** | タスク分析、仕様調査、実装計画 | -| **architect-planner** | タスク分析と設計計画: コード調査、不明点の解決、実装計画の作成 | -| **coder** | 機能の実装、バグ修正 | -| **ai-antipattern-reviewer** | AI特有のアンチパターンレビュー(存在しないAPI、誤った仮定、スコープクリープ) | -| **architecture-reviewer** | アーキテクチャとコード品質のレビュー、仕様準拠の検証 | -| **frontend-reviewer** | フロントエンド(React/Next.js)のコード品質とベストプラクティスのレビュー | -| **cqrs-es-reviewer** | CQRS+Event Sourcingアーキテクチャと実装のレビュー | -| **qa-reviewer** | テストカバレッジと品質保証のレビュー | -| **security-reviewer** | セキュリティ脆弱性の評価 | -| **conductor** | Phase 3 判定専用: レポートやレスポンスを読み取り、ステータスタグを出力 | -| **supervisor** | 最終検証、バリデーション、承認 | -| **expert-supervisor** | 包括的なレビュー統合による専門レベルの最終検証 | -| **research-planner** | リサーチタスクの計画・スコープ定義 | -| **research-analyzer** | リサーチ結果の解釈と追加調査の計画 | -| **research-digger** | 深掘り調査と情報収集 | -| **research-supervisor** | リサーチ品質の検証と網羅性の評価 | -| **test-planner** | テスト戦略分析と包括的なテスト計画 | -| **pr-commenter** | レビュー結果を GitHub PR にコメントとして投稿 | +| `takt` | AI と相談して、タスクを実行または積みます | +| `takt run` | 積まれたタスクをまとめて実行します | +| `takt list` | タスクブランチを管理します(マージ、リトライ、追加指示、削除) | +| `takt #N` | GitHub Issue をタスクとして実行します | +| `takt switch` | 使う piece を切り替えます | +| `takt eject` | ビルトインの piece/persona をコピーしてカスタマイズできます | -## カスタムペルソナ +全コマンド・オプションは [CLI Reference](./cli-reference.ja.md) を参照してください。 -Markdown ファイルでペルソナプロンプトを作成: +## 設定 + +最小限の `~/.takt/config.yaml` は次の通りです。 + +```yaml +provider: claude # claude, codex, or opencode +model: sonnet # プロバイダーにそのまま渡されます +language: ja # en or ja +``` + +API Key を直接使う場合は、CLI のインストールは不要です。 + +```bash +export TAKT_ANTHROPIC_API_KEY=sk-ant-... +``` + +全設定項目・プロバイダープロファイル・モデル解決の詳細は [Configuration Guide](./configuration.ja.md) を参照してください。 + +## カスタマイズ + +### カスタム piece + +```bash +takt eject default # ビルトインを ~/.takt/pieces/ にコピーして編集できます +``` + +### カスタム persona + +`~/.takt/personas/` に Markdown ファイルを置きます。 ```markdown # ~/.takt/personas/my-reviewer.md - -あなたはセキュリティに特化したコードレビュアーです。 - -## 役割 -- セキュリティ脆弱性をチェック -- 入力バリデーションを検証 -- 認証ロジックをレビュー +You are a code reviewer specialized in security. ``` -## モデル選択 +piece から `persona: my-reviewer` で参照できます。 -`model` フィールド(ピースのムーブメント、エージェント設定、グローバル設定)はプロバイダー(Claude Code CLI / Codex SDK)にそのまま渡されます。TAKTはモデルエイリアスの解決を行いません。 +詳細は [Piece Guide](./pieces.md) と [Agent Guide](./agents.md) を参照してください。 -### Claude Code +## CI/CD -Claude Code はエイリアス(`opus`、`sonnet`、`haiku`、`opusplan`、`default`)およびフルモデル名(例: `claude-sonnet-4-5-20250929`)をサポートしています。利用可能なモデルは [Claude Code ドキュメント](https://docs.anthropic.com/en/docs/claude-code)を参照してください。 +GitHub Actions 向けに [takt-action](https://github.com/nrslib/takt-action) を提供しています。 -### Codex +```yaml +- uses: nrslib/takt-action@main + with: + anthropic_api_key: ${{ secrets.TAKT_ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} +``` -モデル文字列はCodex SDKに渡されます。未指定の場合は `codex` がデフォルトです。利用可能なモデルはCodexのドキュメントを参照してください。 +他の CI ではパイプラインモードを使います。 + +```bash +takt --pipeline --task "バグを修正して" --auto-pr +``` + +セットアップの詳細は [CI/CD Guide](./ci-cd.ja.md) を参照してください。 ## プロジェクト構造 ``` -~/.takt/ # グローバル設定ディレクトリ -├── config.yaml # グローバル設定(プロバイダー、モデル、ピース等) -├── pieces/ # ユーザーピース定義(ビルトインを上書き) -│ └── custom.yaml -└── personas/ # ユーザーペルソナプロンプトファイル(.md) - └── my-persona.md +~/.takt/ # グローバル設定 +├── config.yaml # プロバイダー、モデル、言語など +├── pieces/ # ユーザー定義の piece +└── personas/ # ユーザー定義の persona -.takt/ # プロジェクトレベルの設定 -├── config.yaml # プロジェクト設定(現在のピース等) -├── tasks/ # タスク入力ディレクトリ(.takt/tasks/{slug}/order.md など) -├── tasks.yaml # 保留中タスクのメタデータ(task_dir, piece, worktree など) -└── runs/ # 実行単位の成果物 - └── {slug}/ - ├── reports/ # 実行レポート(自動生成) - ├── context/ # knowledge/policy/previous_response のスナップショット - ├── logs/ # この実行専用の NDJSON セッションログ - └── meta.json # run メタデータ +.takt/ # プロジェクトレベル +├── config.yaml # プロジェクト設定 +├── tasks.yaml # 積まれたタスク +├── tasks/ # タスクの仕様書 +└── runs/ # 実行レポート、ログ、コンテキスト ``` -ビルトインリソースはnpmパッケージ(`builtins/`)に埋め込まれています。`~/.takt/` のユーザーファイルが優先されます。 - -### グローバル設定 - -デフォルトのプロバイダーとモデルを `~/.takt/config.yaml` で設定: - -```yaml -# ~/.takt/config.yaml -language: ja -default_piece: default -log_level: info -provider: claude # デフォルトプロバイダー: claude、codex、または opencode -model: sonnet # デフォルトモデル(オプション) -branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速) -prevent_sleep: false # macOS の実行中スリープ防止(caffeinate) -notification_sound: true # 通知音の有効/無効 -notification_sound_events: # タイミング別の通知音制御 - iteration_limit: false - piece_complete: true - piece_abort: true - run_complete: true # 未設定時は有効。false を指定すると無効 - run_abort: true # 未設定時は有効。false を指定すると無効 -concurrency: 1 # takt run の並列タスク数(1-10、デフォルト: 1 = 逐次実行) -task_poll_interval_ms: 500 # takt run 中の新タスク検出ポーリング間隔(100-5000、デフォルト: 500) -interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数(0-10、デフォルト: 3) - -# ランタイム環境デフォルト(piece_config.runtime で上書き可能) -# runtime: -# prepare: -# - gradle # Gradle のキャッシュ/設定を .runtime/ に準備 -# - node # npm キャッシュを .runtime/ に準備 - -# ペルソナ別プロバイダー設定(オプション) -# ピースを複製せずに特定のペルソナを異なるプロバイダーにルーティング -# persona_providers: -# coder: codex # coder を Codex で実行 -# ai-antipattern-reviewer: claude # レビュアーは Claude のまま - -# プロバイダー別パーミッションプロファイル(オプション) -# 優先順: project override → global override → project default → global default → required_permission_mode(下限) -# provider_profiles: -# codex: -# default_permission_mode: full -# movement_permission_overrides: -# ai_review: readonly -# claude: -# default_permission_mode: edit - -# API Key 設定(オプション) -# 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY で上書き可能 -anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合 -# openai_api_key: sk-... # Codex (OpenAI) を使う場合 -# opencode_api_key: ... # OpenCode を使う場合 - -# Codex CLI パスの上書き(オプション) -# Codex SDK が使用する CLI バイナリを上書き(実行可能ファイルの絶対パスを指定) -# 環境変数 TAKT_CODEX_CLI_PATH で上書き可能 -# codex_cli_path: /usr/local/bin/codex - -# ビルトインピースのフィルタリング(オプション) -# builtin_pieces_enabled: true # false でビルトイン全体を無効化 -# disabled_builtins: [magi, passthrough] # 特定のビルトインピースを無効化 - -# パイプライン実行設定(オプション) -# ブランチ名、コミットメッセージ、PRの本文をカスタマイズできます。 -# pipeline: -# default_branch_prefix: "takt/" -# commit_message_template: "feat: {title} (#{issue})" -# pr_body_template: | -# ## Summary -# {issue_body} -# Closes #{issue} -``` - -**注意:** Codex SDK は Git 管理下のディレクトリでのみ動作します。`--skip-git-repo-check` は Codex CLI 専用です。 - -**API Key の設定方法:** - -1. **環境変数で設定**: - ```bash - export TAKT_ANTHROPIC_API_KEY=sk-ant-... # Claude の場合 - export TAKT_OPENAI_API_KEY=sk-... # Codex の場合 - export TAKT_OPENCODE_API_KEY=... # OpenCode の場合 - ``` - -2. **設定ファイルで設定**: - 上記の `~/.takt/config.yaml` に `anthropic_api_key`、`openai_api_key`、または `opencode_api_key` を記述 - -優先順位: 環境変数 > `config.yaml` の設定 - -**注意事項:** -- API Key を設定した場合、Claude Code、Codex、OpenCode のインストールは不要です。TAKT が直接各 API を呼び出します。 -- **セキュリティ**: `config.yaml` に API Key を記述した場合、このファイルを Git にコミットしないよう注意してください。環境変数での設定を使うか、`.gitignore` に `~/.takt/config.yaml` を追加することを検討してください。 - -**パイプラインテンプレート変数:** - -| 変数 | 使用可能箇所 | 説明 | -|------|-------------|------| -| `{title}` | コミットメッセージ | Issueタイトル | -| `{issue}` | コミットメッセージ、PR本文 | Issue番号 | -| `{issue_body}` | PR本文 | Issue本文 | -| `{report}` | PR本文 | ピース実行レポート | - -**モデル解決の優先順位:** -1. ピースのムーブメントの `model`(最優先) -2. カスタムエージェントの `model` -3. グローバル設定の `model` -4. プロバイダーデフォルト(Claude: sonnet、Codex: codex、OpenCode: プロバイダーデフォルト) - -## 詳細ガイド - -### タスクディレクトリ形式 - -TAKT は `.takt/tasks.yaml` にタスクのメタデータを保存し、長文仕様は `.takt/tasks/{slug}/` に分離して管理します。 - -**推奨構成**: - -```text -.takt/ - tasks/ - 20260201-015714-foptng/ - order.md - schema.sql - wireframe.png - tasks.yaml - runs/ - 20260201-015714-foptng/ - reports/ -``` - -**tasks.yaml レコード例**: - -```yaml -tasks: - - name: add-auth-feature - status: pending - task_dir: .takt/tasks/20260201-015714-foptng - piece: default - created_at: "2026-02-01T01:57:14.000Z" - started_at: null - completed_at: null -``` - -`takt add` は `.takt/tasks/{slug}/order.md` を自動生成し、`tasks.yaml` には `task_dir` を保存します。 - -#### 共有クローンによる隔離実行 - -YAMLタスクファイルで`worktree`を指定すると、各タスクを`git clone --shared`で作成した隔離クローンで実行し、メインの作業ディレクトリをクリーンに保てます: - -- `worktree: true` - 隣接ディレクトリ(または`worktree_dir`設定で指定した場所)に共有クローンを自動作成 -- `worktree: "/path/to/dir"` - 指定パスに作成 -- `branch: "feat/xxx"` - 指定ブランチを使用(省略時は`takt/{timestamp}-{slug}`で自動生成) -- `worktree`省略 - カレントディレクトリで実行(デフォルト) - -> **Note**: YAMLフィールド名は後方互換のため`worktree`のままです。内部的には`git worktree`ではなく`git clone --shared`を使用しています。git worktreeの`.git`ファイルには`gitdir:`でメインリポジトリへのパスが記載されており、Claude Codeがそれを辿ってメインリポジトリをプロジェクトルートと認識してしまうためです。共有クローンは独立した`.git`ディレクトリを持つため、この問題が発生しません。 - -クローンは使い捨てです。タスク完了後に自動的にコミット+プッシュし、クローンを削除します。ブランチが唯一の永続的な成果物です。`takt list` でブランチの一覧表示・マージ・削除ができます。 - -### セッションログ - -TAKTはセッションログをNDJSON(`.jsonl`)形式で`.takt/runs/{slug}/logs/`に書き込みます。各レコードはアトミックに追記されるため、プロセスが途中でクラッシュしても部分的なログが保持され、`tail -f`でリアルタイムに追跡できます。 - -- `.takt/runs/{slug}/logs/{sessionId}.jsonl` - ピース実行ごとのNDJSONセッションログ -- `.takt/runs/{slug}/meta.json` - run メタデータ(`task`, `piece`, `start/end`, `status` など) - -レコード種別: `piece_start`, `step_start`, `step_complete`, `piece_complete`, `piece_abort` - -最新の previous response は `.takt/runs/{slug}/context/previous_responses/latest.md` に保存され、実行時に自動的に引き継がれます。 - -### カスタムピースの追加 - -`~/.takt/pieces/` に YAML ファイルを追加するか、`takt eject` でビルトインをカスタマイズします: - -```bash -# defaultピースを~/.takt/pieces/にコピーして編集 -takt eject default -``` - -```yaml -# ~/.takt/pieces/my-piece.yaml -name: my-piece -description: カスタムピース -max_movements: 5 -initial_movement: analyze - -personas: - analyzer: ~/.takt/personas/analyzer.md - coder: ../personas/coder.md - -movements: - - name: analyze - persona: analyzer - edit: false - rules: - - condition: 分析完了 - next: implement - instruction_template: | - このリクエストを徹底的に分析してください。 - - - name: implement - persona: coder - edit: true - required_permission_mode: edit - pass_previous_response: true - rules: - - condition: 完了 - next: COMPLETE - instruction_template: | - 分析に基づいて実装してください。 -``` - -> **Note**: `{task}`、`{previous_response}`、`{user_inputs}` は自動的にインストラクションに注入されます。テンプレート内での位置を制御したい場合のみ、明示的なプレースホルダーが必要です。 - -### ペルソナをパスで指定する - -セクションマップでキーとファイルパスを対応付け、ムーブメントからキーで参照します: - -```yaml -# セクションマップ(ピースファイルからの相対パス) -personas: - coder: ../personas/coder.md - reviewer: ~/.takt/personas/my-reviewer.md -``` - -### ピース変数 - -`instruction_template`で使用可能な変数: - -| 変数 | 説明 | -|------|------| -| `{task}` | 元のユーザーリクエスト(テンプレートになければ自動注入) | -| `{iteration}` | ピース全体のターン数(実行された全ムーブメント数) | -| `{max_movements}` | 最大イテレーション数 | -| `{movement_iteration}` | ムーブメントごとのイテレーション数(このムーブメントが実行された回数) | -| `{previous_response}` | 前のムーブメントの出力(テンプレートになければ自動注入) | -| `{user_inputs}` | ピース中の追加ユーザー入力(テンプレートになければ自動注入) | -| `{report_dir}` | レポートディレクトリパス(例: `.takt/runs/20250126-143052-task-summary/reports`) | -| `{report:filename}` | `{report_dir}/filename` に展開(例: `{report:00-plan.md}`) | - -### ピースの設計 - -各ピースのムーブメントに必要な要素: - -**1. ペルソナ** - セクションマップのキーで参照(system promptとして使用): - -```yaml -persona: coder # personas セクションマップのキー -persona_name: coder # 表示名(オプション) -``` - -**2. ルール** - ムーブメントから次のムーブメントへのルーティングを定義。インストラクションビルダーがステータス出力ルールを自動注入するため、エージェントはどのタグを出力すべきか把握できます: - -```yaml -rules: - - condition: "実装完了" - next: review - - condition: "進行不可" - next: ABORT -``` - -特殊な `next` 値: `COMPLETE`(成功)、`ABORT`(失敗) - -**3. ムーブメントオプション:** - -| オプション | デフォルト | 説明 | -|-----------|-----------|------| -| `edit` | - | ムーブメントがプロジェクトファイルを編集できるか(`true`/`false`) | -| `pass_previous_response` | `true` | 前のムーブメントの出力を`{previous_response}`に渡す | -| `allowed_tools` | - | エージェントが使用できるツール一覧(Read, Glob, Grep, Edit, Write, Bash等) | -| `provider` | - | このムーブメントのプロバイダーを上書き(`claude`、`codex`、または`opencode`) | -| `model` | - | このムーブメントのモデルを上書き | -| `required_permission_mode` | - | 必要最小パーミッションモード: `readonly`、`edit`、`full`(下限として機能; 実際のモードは `provider_profiles` で解決) | -| `provider_options` | - | プロバイダー固有オプション(例: `codex.network_access`、`opencode.network_access`) | -| `output_contracts` | - | レポートファイルの出力契約定義 | -| `quality_gates` | - | ムーブメント完了要件のAIディレクティブ | -| `mcp_servers` | - | MCP(Model Context Protocol)サーバー設定(stdio/SSE/HTTP) | - -ピース全体のデフォルトは `piece_config.provider_options` で設定でき、ムーブメント側 `provider_options` で上書きできます。 - -```yaml -piece_config: - provider_options: - codex: - network_access: true - opencode: - network_access: true - runtime: - prepare: - - gradle - - node -``` - -`runtime.prepare` にはビルトインプリセット(`gradle`、`node`)またはカスタムシェルスクリプトのパスを指定できます。スクリプトは `TAKT_RUNTIME_ROOT` などの環境変数を受け取り、stdout で追加の環境変数をエクスポートできます。 - -## API使用例 +## API ```typescript -import { PieceEngine, loadPiece } from 'takt'; // npm install takt +import { PieceEngine, loadPiece } from 'takt'; const config = loadPiece('default'); -if (!config) { - throw new Error('Piece not found'); -} -const engine = new PieceEngine(config, process.cwd(), 'My task'); +if (!config) throw new Error('Piece not found'); +const engine = new PieceEngine(config, process.cwd(), 'My task'); engine.on('step:complete', (step, response) => { console.log(`${step.name}: ${response.status}`); }); @@ -870,82 +248,25 @@ engine.on('step:complete', (step, response) => { await engine.run(); ``` -## コントリビュート - -詳細は[CONTRIBUTING.md](../CONTRIBUTING.md)を参照。 - -## CI/CD連携 - -### GitHub Actions - -TAKTはPRレビューやタスク実行を自動化するGitHub Actionを提供しています。詳細は [takt-action](https://github.com/nrslib/takt-action) を参照してください。 - -**ピース例** (このリポジトリの [.github/workflows/takt-action.yml](../.github/workflows/takt-action.yml) を参照): - -```yaml -name: TAKT - -on: - issue_comment: - types: [created] - -jobs: - takt: - if: contains(github.event.comment.body, '@takt') - runs-on: ubuntu-latest - permissions: - contents: write - issues: write - pull-requests: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Run TAKT - uses: nrslib/takt-action@main - with: - anthropic_api_key: ${{ secrets.TAKT_ANTHROPIC_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} -``` - -**コスト警告**: TAKTはAI API(ClaudeまたはOpenAI)を使用するため、特にCI/CD環境でタスクが自動実行される場合、かなりのコストが発生する可能性があります。API使用量を監視し、請求アラートを設定してください。 - -### その他のCIシステム - -GitHub以外のCIシステムでは、パイプラインモードを使用します: - -```bash -# taktをインストール -npm install -g takt - -# パイプラインモードで実行 -takt --pipeline --task "バグ修正" --auto-pr --repo owner/repo -``` - -認証には `TAKT_ANTHROPIC_API_KEY`、`TAKT_OPENAI_API_KEY`、または `TAKT_OPENCODE_API_KEY` 環境変数を設定してください(TAKT 独自のプレフィックス付き)。 - -```bash -# Claude (Anthropic) を使う場合 -export TAKT_ANTHROPIC_API_KEY=sk-ant-... - -# Codex (OpenAI) を使う場合 -export TAKT_OPENAI_API_KEY=sk-... - -# OpenCode を使う場合 -export TAKT_OPENCODE_API_KEY=... -``` - ## ドキュメント -- [Faceted Prompting](./faceted-prompting.ja.md) - AIプロンプトへの関心の分離(Persona, Policy, Instruction, Knowledge, Output Contract) -- [Piece Guide](./pieces.md) - ピースの作成とカスタマイズ -- [Agent Guide](./agents.md) - カスタムエージェントの設定 -- [Retry and Session](./implements/retry-and-session.ja.md) - failed タスクの retry とセッション再開 -- [Changelog](../CHANGELOG.md) ([日本語](./CHANGELOG.ja.md)) - バージョン履歴 -- [Security Policy](../SECURITY.md) - 脆弱性報告 -- [ブログ: TAKT - AIエージェントオーケストレーション](https://zenn.dev/nrs/articles/c6842288a526d7) - 設計思想と実践的な使い方ガイド +| ドキュメント | 内容 | +|-------------|------| +| [CLI Reference](./cli-reference.ja.md) | 全コマンド・オプション | +| [Configuration](./configuration.ja.md) | グローバル設定・プロジェクト設定 | +| [Piece Guide](./pieces.md) | piece の作成・カスタマイズ | +| [Agent Guide](./agents.md) | カスタムエージェントの設定 | +| [Builtin Catalog](./builtin-catalog.ja.md) | ビルトイン piece・persona の一覧 | +| [Faceted Prompting](./faceted-prompting.ja.md) | プロンプト設計の方法論 | +| [Task Management](./task-management.ja.md) | タスクの追加・実行・隔離 | +| [CI/CD Integration](./ci-cd.ja.md) | GitHub Actions・パイプラインモード | +| [Changelog](../CHANGELOG.md) ([日本語](./CHANGELOG.ja.md)) | バージョン履歴 | +| [Security Policy](../SECURITY.md) | 脆弱性の報告 | + +## コントリビュート + +[CONTRIBUTING.md](../CONTRIBUTING.md) を参照してください。 ## ライセンス -MIT - 詳細は[LICENSE](../LICENSE)をご覧ください。 +MIT — [LICENSE](../LICENSE) を参照してください。 diff --git a/docs/builtin-catalog.ja.md b/docs/builtin-catalog.ja.md new file mode 100644 index 0000000..2ca4c58 --- /dev/null +++ b/docs/builtin-catalog.ja.md @@ -0,0 +1,109 @@ +# ビルトインカタログ + +[English](./builtin-catalog.md) + +TAKT に同梱されているすべてのビルトイン piece と persona の総合カタログです。 + +## おすすめ Piece + +| Piece | 推奨用途 | +|----------|-----------------| +| `default-mini` | ちょっとした修正向けです。計画 → 実装 → 並列レビュー → 修正の軽量構成です。 | +| `frontend-mini` | フロントエンド向けの mini 構成です。 | +| `backend-mini` | バックエンド向けの mini 構成です。 | +| `expert-mini` | エキスパート向けの mini 構成です。 | +| `default` | 本格的な開発向けです。並列レビュアーによる多段階レビューが付いています。TAKT 自身の開発にも使用しています。 | + +## 全ビルトイン Piece 一覧 + +カテゴリ順に並べています。 + +| カテゴリ | Piece | 説明 | +|---------|----------|-------------| +| 🚀 クイックスタート | `default-mini` | ミニ開発 piece: plan -> implement -> 並列レビュー (AI antipattern + supervisor) -> 必要に応じて修正。レビュー付き軽量版。 | +| | `frontend-mini` | ミニフロントエンド piece: plan -> implement -> 並列レビュー (AI antipattern + supervisor)。フロントエンドナレッジ注入付き。 | +| | `backend-mini` | ミニバックエンド piece: plan -> implement -> 並列レビュー (AI antipattern + supervisor)。バックエンドナレッジ注入付き。 | +| | `default` | フル開発 piece: plan -> implement -> AI review -> 並列レビュー (architect + QA) -> supervisor 承認。各レビュー段階に修正ループあり。 | +| | `compound-eye` | マルチモデルレビュー: 同じ指示を Claude と Codex に同時送信し、両方のレスポンスを統合。 | +| ⚡ Mini | `backend-cqrs-mini` | ミニ CQRS+ES piece: plan -> implement -> 並列レビュー (AI antipattern + supervisor)。CQRS+ES ナレッジ注入付き。 | +| | `expert-mini` | ミニエキスパート piece: plan -> implement -> 並列レビュー (AI antipattern + expert supervisor)。フルスタックナレッジ注入付き。 | +| | `expert-cqrs-mini` | ミニ CQRS+ES エキスパート piece: plan -> implement -> 並列レビュー (AI antipattern + expert supervisor)。CQRS+ES ナレッジ注入付き。 | +| 🎨 フロントエンド | `frontend` | フロントエンド特化開発 piece。React/Next.js に焦点を当てたレビューとナレッジ注入付き。 | +| ⚙️ バックエンド | `backend` | バックエンド特化開発 piece。バックエンド、セキュリティ、QA エキスパートレビュー付き。 | +| | `backend-cqrs` | CQRS+ES 特化バックエンド開発 piece。CQRS+ES、セキュリティ、QA エキスパートレビュー付き。 | +| 🔧 エキスパート | `expert` | フルスタック開発 piece: architecture、frontend、security、QA レビューと修正ループ付き。 | +| | `expert-cqrs` | フルスタック開発 piece (CQRS+ES 特化): CQRS+ES、frontend、security、QA レビューと修正ループ付き。 | +| 🛠️ リファクタリング | `structural-reform` | プロジェクト全体のレビューと構造改革: 段階的なファイル分割による反復的なコードベース再構築。 | +| 🔍 レビュー | `review-fix-minimal` | レビュー特化 piece: review -> fix -> supervisor。レビューフィードバックに基づく反復改善向け。 | +| | `review-only` | 変更を加えない読み取り専用のコードレビュー piece。 | +| 🧪 テスト | `unit-test` | ユニットテスト特化 piece: テスト分析 -> テスト実装 -> レビュー -> 修正。 | +| | `e2e-test` | E2E テスト特化 piece: E2E 分析 -> E2E 実装 -> レビュー -> 修正 (Vitest ベースの E2E フロー)。 | +| その他 | `research` | リサーチ piece: planner -> digger -> supervisor。質問せずに自律的にリサーチを実行。 | +| | `deep-research` | ディープリサーチ piece: plan -> dig -> analyze -> supervise。発見駆動型の調査で、浮上した疑問を多角的に分析。 | +| | `magi` | エヴァンゲリオンにインスパイアされた合議システム。3つの AI persona (MELCHIOR, BALTHASAR, CASPER) が分析・投票。 | +| | `passthrough` | 最薄ラッパー。タスクを coder にそのまま渡す。レビューなし。 | + +`takt switch` で piece をインタラクティブに切り替えできます。 + +## ビルトイン Persona 一覧 + +| Persona | 説明 | +|---------|-------------| +| **planner** | タスク分析、仕様調査、実装計画 | +| **architect-planner** | タスク分析と設計計画: コード調査、不明点の解消、実装計画の作成 | +| **coder** | 機能実装、バグ修正 | +| **ai-antipattern-reviewer** | AI 固有のアンチパターンレビュー(存在しない API、誤った前提、スコープクリープ) | +| **architecture-reviewer** | アーキテクチャとコード品質のレビュー、仕様準拠の検証 | +| **frontend-reviewer** | フロントエンド (React/Next.js) のコード品質とベストプラクティスのレビュー | +| **cqrs-es-reviewer** | CQRS+Event Sourcing のアーキテクチャと実装のレビュー | +| **qa-reviewer** | テストカバレッジと品質保証のレビュー | +| **security-reviewer** | セキュリティ脆弱性の評価 | +| **conductor** | Phase 3 判定スペシャリスト: レポート/レスポンスを読み取りステータスタグを出力 | +| **supervisor** | 最終検証、承認 | +| **expert-supervisor** | エキスパートレベルの最終検証と包括的なレビュー統合 | +| **research-planner** | リサーチタスクの計画とスコープ定義 | +| **research-analyzer** | リサーチ結果の解釈と追加調査計画 | +| **research-digger** | 深掘り調査と情報収集 | +| **research-supervisor** | リサーチ品質の検証と完全性の評価 | +| **test-planner** | テスト戦略の分析と包括的なテスト計画 | +| **pr-commenter** | レビュー結果を GitHub PR コメントとして投稿 | + +## カスタム Persona + +`~/.takt/personas/` に Markdown ファイルとして persona プロンプトを作成できます。 + +```markdown +# ~/.takt/personas/my-reviewer.md + +You are a code reviewer specialized in security. + +## Role +- Check for security vulnerabilities +- Verify input validation +- Review authentication logic +``` + +piece YAML の `personas` セクションマップからカスタム persona を参照します。 + +```yaml +personas: + my-reviewer: ~/.takt/personas/my-reviewer.md + +movements: + - name: review + persona: my-reviewer + # ... +``` + +## Persona 別 Provider オーバーライド + +`~/.takt/config.yaml` の `persona_providers` を使用して、piece を複製せずに特定の persona を異なる provider にルーティングできます。これにより、例えばコーディングは Codex で実行し、レビューアーは Claude に維持するといった構成が可能になります。 + +```yaml +# ~/.takt/config.yaml +persona_providers: + coder: codex # coder を Codex で実行 + ai-antipattern-reviewer: claude # レビューアーは Claude を維持 +``` + +この設定はすべての piece にグローバルに適用されます。指定された persona を使用する movement は、実行中の piece に関係なく、対応する provider にルーティングされます。 diff --git a/docs/builtin-catalog.md b/docs/builtin-catalog.md new file mode 100644 index 0000000..b800e76 --- /dev/null +++ b/docs/builtin-catalog.md @@ -0,0 +1,109 @@ +# Builtin Catalog + +[日本語](./builtin-catalog.ja.md) + +A comprehensive catalog of all builtin pieces and personas included with TAKT. + +## Recommended Pieces + +| Piece | Recommended Use | +|----------|-----------------| +| `default-mini` | Quick fixes. Lightweight plan → implement → parallel review → fix loop. | +| `frontend-mini` | Frontend-focused mini configuration. | +| `backend-mini` | Backend-focused mini configuration. | +| `expert-mini` | Expert-level mini configuration. | +| `default` | Serious development. Multi-stage review with parallel reviewers. Used for TAKT's own development. | + +## All Builtin Pieces + +Organized by category. + +| Category | Piece | Description | +|----------|----------|-------------| +| 🚀 Quick Start | `default-mini` | Mini development piece: plan -> implement -> parallel review (AI antipattern + supervisor) -> fix if needed. Lightweight with review. | +| | `frontend-mini` | Mini frontend piece: plan -> implement -> parallel review (AI antipattern + supervisor) with frontend knowledge injection. | +| | `backend-mini` | Mini backend piece: plan -> implement -> parallel review (AI antipattern + supervisor) with backend knowledge injection. | +| | `default` | Full development piece: plan -> implement -> AI review -> parallel review (architect + QA) -> supervisor approval. Includes fix loops at each review stage. | +| | `compound-eye` | Multi-model review: sends the same instruction to Claude and Codex simultaneously, then synthesizes both responses. | +| ⚡ Mini | `backend-cqrs-mini` | Mini CQRS+ES piece: plan -> implement -> parallel review (AI antipattern + supervisor) with CQRS+ES knowledge injection. | +| | `expert-mini` | Mini expert piece: plan -> implement -> parallel review (AI antipattern + expert supervisor) with full-stack knowledge injection. | +| | `expert-cqrs-mini` | Mini CQRS+ES expert piece: plan -> implement -> parallel review (AI antipattern + expert supervisor) with CQRS+ES knowledge injection. | +| 🎨 Frontend | `frontend` | Frontend-specialized development piece with React/Next.js focused reviews and knowledge injection. | +| ⚙️ Backend | `backend` | Backend-specialized development piece with backend, security, and QA expert reviews. | +| | `backend-cqrs` | CQRS+ES-specialized backend development piece with CQRS+ES, security, and QA expert reviews. | +| 🔧 Expert | `expert` | Full-stack development piece: architecture, frontend, security, QA reviews with fix loops. | +| | `expert-cqrs` | Full-stack development piece (CQRS+ES specialized): CQRS+ES, frontend, security, QA reviews with fix loops. | +| 🛠️ Refactoring | `structural-reform` | Full project review and structural reform: iterative codebase restructuring with staged file splits. | +| 🔍 Review | `review-fix-minimal` | Review-focused piece: review -> fix -> supervisor. For iterative improvement based on review feedback. | +| | `review-only` | Read-only code review piece that makes no changes. | +| 🧪 Testing | `unit-test` | Unit test focused piece: test analysis -> test implementation -> review -> fix. | +| | `e2e-test` | E2E test focused piece: E2E analysis -> E2E implementation -> review -> fix (Vitest-based E2E flow). | +| Others | `research` | Research piece: planner -> digger -> supervisor. Autonomously executes research without asking questions. | +| | `deep-research` | Deep research piece: plan -> dig -> analyze -> supervise. Discovery-driven investigation that follows emerging questions with multi-perspective analysis. | +| | `magi` | Deliberation system inspired by Evangelion. Three AI personas (MELCHIOR, BALTHASAR, CASPER) analyze and vote. | +| | `passthrough` | Thinnest wrapper. Pass task directly to coder as-is. No review. | + +Use `takt switch` to switch pieces interactively. + +## Builtin Personas + +| Persona | Description | +|---------|-------------| +| **planner** | Task analysis, spec investigation, implementation planning | +| **architect-planner** | Task analysis and design planning: investigates code, resolves unknowns, creates implementation plans | +| **coder** | Feature implementation, bug fixing | +| **ai-antipattern-reviewer** | AI-specific antipattern review (non-existent APIs, incorrect assumptions, scope creep) | +| **architecture-reviewer** | Architecture and code quality review, spec compliance verification | +| **frontend-reviewer** | Frontend (React/Next.js) code quality and best practices review | +| **cqrs-es-reviewer** | CQRS+Event Sourcing architecture and implementation review | +| **qa-reviewer** | Test coverage and quality assurance review | +| **security-reviewer** | Security vulnerability assessment | +| **conductor** | Phase 3 judgment specialist: reads reports/responses and outputs status tags | +| **supervisor** | Final validation, approval | +| **expert-supervisor** | Expert-level final validation with comprehensive review integration | +| **research-planner** | Research task planning and scope definition | +| **research-analyzer** | Research result interpretation and additional investigation planning | +| **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 + +Create persona prompts as Markdown files in `~/.takt/personas/`: + +```markdown +# ~/.takt/personas/my-reviewer.md + +You are a code reviewer specialized in security. + +## Role +- Check for security vulnerabilities +- Verify input validation +- Review authentication logic +``` + +Reference custom personas from piece YAML via the `personas` section map: + +```yaml +personas: + my-reviewer: ~/.takt/personas/my-reviewer.md + +movements: + - name: review + persona: my-reviewer + # ... +``` + +## Per-persona Provider Overrides + +Use `persona_providers` in `~/.takt/config.yaml` to route specific personas to different providers without duplicating pieces. This allows you to run, for example, coding on Codex while keeping reviewers on Claude. + +```yaml +# ~/.takt/config.yaml +persona_providers: + coder: codex # Run coder on Codex + ai-antipattern-reviewer: claude # Keep reviewers on Claude +``` + +This configuration applies globally to all pieces. Any movement using the specified persona will be routed to the corresponding provider, regardless of which piece is being executed. diff --git a/docs/ci-cd.ja.md b/docs/ci-cd.ja.md new file mode 100644 index 0000000..eb2cfdd --- /dev/null +++ b/docs/ci-cd.ja.md @@ -0,0 +1,178 @@ +[English](./ci-cd.md) + +# CI/CD 連携 + +TAKT は CI/CD パイプラインに統合して、タスク実行、PR レビュー、コード生成を自動化できます。このガイドでは GitHub Actions のセットアップ、pipeline モードのオプション、その他の CI システムでの設定について説明します。 + +## GitHub Actions + +TAKT は GitHub Actions 連携用の公式アクション [takt-action](https://github.com/nrslib/takt-action) を提供しています。 + +### 完全なワークフロー例 + +```yaml +name: TAKT + +on: + issue_comment: + types: [created] + +jobs: + takt: + if: contains(github.event.comment.body, '@takt') + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run TAKT + uses: nrslib/takt-action@main + with: + anthropic_api_key: ${{ secrets.TAKT_ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} +``` + +### パーミッション + +`takt-action` が正しく機能するには次のパーミッションが必要です。 + +| パーミッション | 用途 | +|-------------|------| +| `contents: write` | ブランチの作成、コミット、コードのプッシュ | +| `issues: write` | Issue の読み取りとコメント | +| `pull-requests: write` | PR の作成と更新 | + +## Pipeline モード + +`--pipeline` を指定すると、非インタラクティブな pipeline モードが有効になります。ブランチの作成、piece の実行、コミット、プッシュを自動的に行います。このモードは人的操作が不可能な CI/CD 自動化向けに設計されています。 + +Pipeline モードでは、`--auto-pr` を明示的に指定しない限り PR は作成**されません**。 + +### Pipeline の全オプション + +| オプション | 説明 | +|-----------|------| +| `--pipeline` | **pipeline(非インタラクティブ)モードを有効化** -- CI/自動化に必要 | +| `-t, --task ` | タスク内容(GitHub Issue の代替) | +| `-i, --issue ` | GitHub Issue 番号(インタラクティブモードでの `#N` と同等) | +| `-w, --piece ` | Piece 名または piece YAML ファイルのパス | +| `-b, --branch ` | ブランチ名を指定(省略時は自動生成) | +| `--auto-pr` | PR を作成(インタラクティブ: 確認スキップ、pipeline: PR 有効化) | +| `--skip-git` | ブランチ作成、コミット、プッシュをスキップ(pipeline モード、piece のみ実行) | +| `--repo ` | リポジトリを指定(PR 作成用) | +| `-q, --quiet` | 最小出力モード: AI 出力を抑制(CI 向け) | +| `--provider ` | エージェント provider を上書き(claude\|codex\|opencode\|mock) | +| `--model ` | エージェントモデルを上書き | + +### コマンド例 + +**基本的な pipeline 実行** + +```bash +takt --pipeline --task "Fix bug" +``` + +**PR 自動作成付きの pipeline 実行** + +```bash +takt --pipeline --task "Fix bug" --auto-pr +``` + +**GitHub Issue をリンクして PR を作成** + +```bash +takt --pipeline --issue 99 --auto-pr +``` + +**Piece とブランチ名を指定** + +```bash +takt --pipeline --task "Fix bug" -w magi -b feat/fix-bug +``` + +**PR 作成用にリポジトリを指定** + +```bash +takt --pipeline --task "Fix bug" --auto-pr --repo owner/repo +``` + +**Piece のみ実行(ブランチ作成、コミット、プッシュをスキップ)** + +```bash +takt --pipeline --task "Fix bug" --skip-git +``` + +**最小出力モード(CI ログ向けに AI 出力を抑制)** + +```bash +takt --pipeline --task "Fix bug" --quiet +``` + +## Pipeline テンプレート変数 + +`~/.takt/config.yaml` の pipeline 設定では、コミットメッセージと PR 本文をカスタマイズするためのテンプレート変数をサポートしています。 + +```yaml +pipeline: + default_branch_prefix: "takt/" + commit_message_template: "feat: {title} (#{issue})" + pr_body_template: | + ## Summary + {issue_body} + Closes #{issue} +``` + +| 変数 | 使用可能な場所 | 説明 | +|------|--------------|------| +| `{title}` | コミットメッセージ | Issue タイトル | +| `{issue}` | コミットメッセージ、PR 本文 | Issue 番号 | +| `{issue_body}` | PR 本文 | Issue 本文 | +| `{report}` | PR 本文 | Piece 実行レポート | + +## その他の CI システム + +GitHub Actions 以外の CI システムでは、TAKT をグローバルにインストールして pipeline モードを直接使用します。 + +```bash +# takt のインストール +npm install -g takt + +# pipeline モードで実行 +takt --pipeline --task "Fix bug" --auto-pr --repo owner/repo +``` + +このアプローチは Node.js をサポートする任意の CI システムで動作します。GitLab CI、CircleCI、Jenkins、Azure DevOps などが含まれます。 + +## 環境変数 + +CI 環境での認証には、適切な API キー環境変数を設定してください。これらは他のツールとの衝突を避けるため TAKT 固有のプレフィックスを使用しています。 + +```bash +# Claude(Anthropic)用 +export TAKT_ANTHROPIC_API_KEY=sk-ant-... + +# Codex(OpenAI)用 +export TAKT_OPENAI_API_KEY=sk-... + +# OpenCode 用 +export TAKT_OPENCODE_API_KEY=... +``` + +優先順位: 環境変数は `config.yaml` の設定よりも優先されます。 + +> **注意**: 環境変数で API キーを設定すれば、Claude Code、Codex、OpenCode CLI のインストールは不要です。TAKT が対応する API を直接呼び出します。 + +## コストに関する注意 + +TAKT は AI API(Claude または OpenAI)を使用するため、特に CI/CD 環境でタスクが自動実行される場合、大きなコストが発生する可能性があります。次の点に注意してください。 + +- **API 使用量の監視**: 予期しない請求を避けるため、AI provider で課金アラートを設定してください。 +- **`--quiet` モードの使用**: 出力量は削減されますが、API 呼び出し回数は減りません。 +- **適切な piece の選択**: シンプルな piece(例: `default-mini`)はマルチステージの piece(例: 並列レビュー付きの `default`)よりも API 呼び出しが少なくなります。 +- **CI トリガーの制限**: 意図しない実行を防ぐため、条件付きトリガー(例: `if: contains(github.event.comment.body, '@takt')`)を使用してください。 +- **`--provider mock` でのテスト**: CI パイプラインの開発中は mock provider を使用して、実際の API コストを回避してください。 diff --git a/docs/ci-cd.md b/docs/ci-cd.md new file mode 100644 index 0000000..e9a53cd --- /dev/null +++ b/docs/ci-cd.md @@ -0,0 +1,178 @@ +[日本語](./ci-cd.ja.md) + +# CI/CD Integration + +TAKT can be integrated into CI/CD pipelines to automate task execution, PR reviews, and code generation. This guide covers GitHub Actions setup, pipeline mode options, and configuration for other CI systems. + +## GitHub Actions + +TAKT provides the official [takt-action](https://github.com/nrslib/takt-action) for GitHub Actions integration. + +### Complete Workflow Example + +```yaml +name: TAKT + +on: + issue_comment: + types: [created] + +jobs: + takt: + if: contains(github.event.comment.body, '@takt') + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run TAKT + uses: nrslib/takt-action@main + with: + anthropic_api_key: ${{ secrets.TAKT_ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} +``` + +### Permissions + +The following permissions are required for `takt-action` to function correctly: + +| Permission | Required For | +|------------|-------------| +| `contents: write` | Creating branches, committing, and pushing code | +| `issues: write` | Reading and commenting on issues | +| `pull-requests: write` | Creating and updating pull requests | + +## Pipeline Mode + +Specifying `--pipeline` enables non-interactive pipeline mode. It automatically creates a branch, runs the piece, commits, and pushes. This mode is designed for CI/CD automation where no human interaction is available. + +In pipeline mode, PRs are **not** created unless `--auto-pr` is explicitly specified. + +### All Pipeline Options + +| Option | Description | +|--------|-------------| +| `--pipeline` | **Enable pipeline (non-interactive) mode** -- Required for CI/automation | +| `-t, --task ` | Task content (alternative to GitHub Issue) | +| `-i, --issue ` | GitHub issue number (same as `#N` in interactive mode) | +| `-w, --piece ` | Piece name or path to piece YAML file | +| `-b, --branch ` | Specify branch name (auto-generated if omitted) | +| `--auto-pr` | Create PR (interactive: skip confirmation, pipeline: enable PR) | +| `--skip-git` | Skip branch creation, commit, and push (pipeline mode, piece-only) | +| `--repo ` | Specify repository (for PR creation) | +| `-q, --quiet` | Minimal output mode: suppress AI output (for CI) | +| `--provider ` | Override agent provider (claude\|codex\|opencode\|mock) | +| `--model ` | Override agent model | + +### Command Examples + +**Basic pipeline execution:** + +```bash +takt --pipeline --task "Fix bug" +``` + +**Pipeline execution with automatic PR creation:** + +```bash +takt --pipeline --task "Fix bug" --auto-pr +``` + +**Link a GitHub issue and create a PR:** + +```bash +takt --pipeline --issue 99 --auto-pr +``` + +**Specify piece and branch name:** + +```bash +takt --pipeline --task "Fix bug" -w magi -b feat/fix-bug +``` + +**Specify repository for PR creation:** + +```bash +takt --pipeline --task "Fix bug" --auto-pr --repo owner/repo +``` + +**Piece execution only (skip branch creation, commit, push):** + +```bash +takt --pipeline --task "Fix bug" --skip-git +``` + +**Minimal output mode (suppress AI output for CI logs):** + +```bash +takt --pipeline --task "Fix bug" --quiet +``` + +## Pipeline Template Variables + +Pipeline configuration in `~/.takt/config.yaml` supports template variables for customizing commit messages and PR bodies: + +```yaml +pipeline: + default_branch_prefix: "takt/" + commit_message_template: "feat: {title} (#{issue})" + pr_body_template: | + ## Summary + {issue_body} + Closes #{issue} +``` + +| Variable | Available In | Description | +|----------|-------------|-------------| +| `{title}` | Commit message | Issue title | +| `{issue}` | Commit message, PR body | Issue number | +| `{issue_body}` | PR body | Issue body | +| `{report}` | PR body | Piece execution report | + +## Other CI Systems + +For CI systems other than GitHub Actions, install TAKT globally and use pipeline mode directly: + +```bash +# Install takt +npm install -g takt + +# Run in pipeline mode +takt --pipeline --task "Fix bug" --auto-pr --repo owner/repo +``` + +This approach works with any CI system that supports Node.js, including GitLab CI, CircleCI, Jenkins, Azure DevOps, and others. + +## Environment Variables + +For authentication in CI environments, set the appropriate API key environment variable. These use TAKT-specific prefixes to avoid conflicts with other tools. + +```bash +# For Claude (Anthropic) +export TAKT_ANTHROPIC_API_KEY=sk-ant-... + +# For Codex (OpenAI) +export TAKT_OPENAI_API_KEY=sk-... + +# For OpenCode +export TAKT_OPENCODE_API_KEY=... +``` + +Priority: Environment variables take precedence over `config.yaml` settings. + +> **Note**: If you set an API key via environment variable, installing Claude Code, Codex, or OpenCode CLI is not necessary. TAKT directly calls the respective API. + +## Cost Considerations + +TAKT uses AI APIs (Claude or OpenAI), which can incur significant costs, especially when tasks are auto-executed in CI/CD environments. Take the following precautions: + +- **Monitor API usage**: Set up billing alerts with your AI provider to avoid unexpected charges. +- **Use `--quiet` mode**: Reduces output volume but does not reduce API calls. +- **Choose appropriate pieces**: Simpler pieces (e.g., `default-mini`) use fewer API calls than multi-stage pieces (e.g., `default` with parallel reviews). +- **Limit CI triggers**: Use conditional triggers (e.g., `if: contains(github.event.comment.body, '@takt')`) to prevent unintended executions. +- **Test with `--provider mock`**: Use mock provider during CI pipeline development to avoid real API costs. diff --git a/docs/cli-reference.ja.md b/docs/cli-reference.ja.md new file mode 100644 index 0000000..8f195ca --- /dev/null +++ b/docs/cli-reference.ja.md @@ -0,0 +1,310 @@ +# CLI リファレンス + +[English](./cli-reference.md) + +このドキュメントは TAKT CLI の全コマンドとオプションの完全なリファレンスです。 + +## グローバルオプション + +| オプション | 説明 | +|-----------|------| +| `--pipeline` | pipeline(非インタラクティブ)モードを有効化 -- CI/自動化に必要 | +| `-t, --task ` | タスク内容(GitHub Issue の代替) | +| `-i, --issue ` | GitHub Issue 番号(インタラクティブモードでの `#N` と同等) | +| `-w, --piece ` | Piece 名または piece YAML ファイルのパス | +| `-b, --branch ` | ブランチ名を指定(省略時は自動生成) | +| `--auto-pr` | PR を作成(インタラクティブ: 確認スキップ、pipeline: PR 有効化) | +| `--skip-git` | ブランチ作成、コミット、プッシュをスキップ(pipeline モード、piece のみ実行) | +| `--repo ` | リポジトリを指定(PR 作成用) | +| `--create-worktree ` | worktree 確認プロンプトをスキップ | +| `-q, --quiet` | 最小出力モード: AI 出力を抑制(CI 向け) | +| `--provider ` | エージェント provider を上書き(claude\|codex\|opencode\|mock) | +| `--model ` | エージェントモデルを上書き | +| `--config ` | グローバル設定ファイルのパス(デフォルト: `~/.takt/config.yaml`) | + +## インタラクティブモード + +AI との会話を通じてタスク内容を精緻化してから実行するモードです。タスクの要件が曖昧な場合や、AI と相談しながら内容を詰めたい場合に便利です。 + +```bash +# インタラクティブモードを開始(引数なし) +takt + +# 初期メッセージを指定(短い単語のみ) +takt hello +``` + +**注意:** `--task` オプションを指定するとインタラクティブモードをスキップして直接実行します。Issue 参照(`#6`、`--issue`)はインタラクティブモードの初期入力として使用されます。 + +### フロー + +1. Piece を選択 +2. インタラクティブモードを選択(assistant / persona / quiet / passthrough) +3. AI との会話でタスク内容を精緻化 +4. `/go` でタスク指示を確定(`/go 追加の指示` のように追記も可能)、または `/play ` でタスクを即座に実行 +5. 実行(worktree 作成、piece 実行、PR 作成) + +### インタラクティブモードの種類 + +| モード | 説明 | +|--------|------| +| `assistant` | デフォルト。AI がタスク指示を生成する前に明確化のための質問を行う。 | +| `persona` | 最初の movement の persona と会話(そのシステムプロンプトとツールを使用)。 | +| `quiet` | 質問なしでタスク指示を生成(ベストエフォート)。 | +| `passthrough` | AI 処理なしでユーザー入力をそのままタスクテキストとして使用。 | + +Piece は YAML の `interactive_mode` フィールドでデフォルトモードを設定できます。 + +### 実行例 + +``` +$ takt + +Select piece: + > default (current) + Development/ + Research/ + Cancel + +Interactive mode - Enter task content. Commands: /go (execute), /cancel (exit) + +> I want to add user authentication feature + +[AI が要件を確認・整理] + +> /go + +Proposed task instructions: +--- +Implement user authentication feature. + +Requirements: +- Login with email address and password +- JWT token-based authentication +- Password hashing (bcrypt) +- Login/logout API endpoints +--- + +Proceed with these task instructions? (Y/n) y + +? Create worktree? (Y/n) y + +[Piece の実行を開始...] +``` + +## 直接タスク実行 + +`--task` オプションを使用して、インタラクティブモードをスキップして直接実行できます。 + +```bash +# --task オプションでタスク内容を指定 +takt --task "Fix bug" + +# piece を指定 +takt --task "Add authentication" --piece expert + +# PR を自動作成 +takt --task "Fix bug" --auto-pr +``` + +**注意:** 引数として文字列を渡す場合(例: `takt "Add login feature"`)は、初期メッセージとしてインタラクティブモードに入ります。 + +## GitHub Issue タスク + +GitHub Issue を直接タスクとして実行できます。Issue のタイトル、本文、ラベル、コメントがタスク内容として自動的に取り込まれます。 + +```bash +# Issue 番号を指定して実行 +takt #6 +takt --issue 6 + +# Issue + piece 指定 +takt #6 --piece expert + +# Issue + PR 自動作成 +takt #6 --auto-pr +``` + +**要件:** [GitHub CLI](https://cli.github.com/)(`gh`)がインストールされ、認証済みである必要があります。 + +## タスク管理コマンド + +`.takt/tasks.yaml` と `.takt/tasks/{slug}/` 配下のタスクディレクトリを使ったバッチ処理です。複数のタスクを蓄積し、後でまとめて実行するのに便利です。 + +### takt add + +AI との会話でタスク要件を精緻化し、`.takt/tasks.yaml` にタスクを追加します。 + +```bash +# AI との会話でタスク要件を精緻化し、タスクを追加 +takt add + +# GitHub Issue からタスクを追加(Issue 番号がブランチ名に反映される) +takt add #28 +``` + +### takt run + +`.takt/tasks.yaml` のすべての pending タスクを実行します。 + +```bash +# .takt/tasks.yaml の pending タスクをすべて実行 +takt run +``` + +### takt watch + +`.takt/tasks.yaml` を監視し、タスクが追加されると自動実行する常駐プロセスです。 + +```bash +# .takt/tasks.yaml を監視してタスクを自動実行(常駐プロセス) +takt watch +``` + +### takt list + +タスクブランチの一覧表示と操作(マージ、削除など)を行います。 + +```bash +# タスクブランチの一覧表示(マージ/削除) +takt list + +# 非インタラクティブモード(CI/スクリプト向け) +takt list --non-interactive +takt list --non-interactive --action diff --branch takt/my-branch +takt list --non-interactive --action delete --branch takt/my-branch --yes +takt list --non-interactive --format json +``` + +### タスクディレクトリワークフロー(作成 / 実行 / 確認) + +1. `takt add` を実行し、`.takt/tasks.yaml` に pending レコードが作成されたことを確認。 +2. 生成された `.takt/tasks/{slug}/order.md` を開き、必要に応じて詳細な仕様や参考資料を追記。 +3. `takt run`(または `takt watch`)を実行して `tasks.yaml` の pending タスクを実行。 +4. `task_dir` と同じ slug の `.takt/runs/{slug}/reports/` で出力を確認。 + +## Pipeline モード + +`--pipeline` を指定すると、非インタラクティブな pipeline モードが有効になります。ブランチの作成、piece の実行、コミットとプッシュを自動的に行います。CI/CD 自動化に適しています。 + +```bash +# pipeline モードでタスクを実行 +takt --pipeline --task "Fix bug" + +# pipeline 実行 + PR 自動作成 +takt --pipeline --task "Fix bug" --auto-pr + +# Issue 情報をリンク +takt --pipeline --issue 99 --auto-pr + +# piece とブランチを指定 +takt --pipeline --task "Fix bug" -w magi -b feat/fix-bug + +# リポジトリを指定(PR 作成用) +takt --pipeline --task "Fix bug" --auto-pr --repo owner/repo + +# piece のみ実行(ブランチ作成、コミット、プッシュをスキップ) +takt --pipeline --task "Fix bug" --skip-git + +# 最小出力モード(CI 向け) +takt --pipeline --task "Fix bug" --quiet +``` + +Pipeline モードでは、`--auto-pr` を指定しない限り PR は作成されません。 + +**GitHub 連携:** GitHub Actions で TAKT を使用する場合は [takt-action](https://github.com/nrslib/takt-action) を参照してください。PR レビューやタスク実行を自動化できます。 + +## ユーティリティコマンド + +### takt switch + +アクティブな piece をインタラクティブに切り替えます。 + +```bash +takt switch +``` + +### takt eject + +ビルトインの piece/persona をローカルディレクトリにコピーしてカスタマイズします。 + +```bash +# ビルトインの piece/persona をプロジェクト .takt/ にコピー +takt eject + +# ~/.takt/(グローバル)にコピー +takt eject --global + +# 特定のファセットをカスタマイズ用にエジェクト +takt eject persona coder +takt eject instruction plan --global +``` + +### takt clear + +エージェントの会話セッションをクリア(状態のリセット)します。 + +```bash +takt clear +``` + +### takt export-cc + +ビルトインの piece/persona を Claude Code Skill としてデプロイします。 + +```bash +takt export-cc +``` + +### takt catalog + +レイヤー間で利用可能なファセットの一覧を表示します。 + +```bash +takt catalog +takt catalog personas +``` + +### takt prompt + +各 movement とフェーズの組み立て済みプロンプトをプレビューします。 + +```bash +takt prompt [piece] +``` + +### takt reset + +設定をデフォルトにリセットします。 + +```bash +# グローバル設定をビルトインテンプレートにリセット(バックアップ付き) +takt reset config + +# Piece カテゴリをビルトインのデフォルトにリセット +takt reset categories +``` + +### takt metrics + +アナリティクスメトリクスを表示します。 + +```bash +# レビュー品質メトリクスを表示(デフォルト: 直近30日) +takt metrics review + +# 時間枠を指定 +takt metrics review --since 7d +``` + +### takt purge + +古いアナリティクスイベントファイルを削除します。 + +```bash +# 30日以上前のファイルを削除(デフォルト) +takt purge + +# 保持期間を指定 +takt purge --retention-days 14 +``` diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 0000000..22c4cb3 --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,310 @@ +# CLI Reference + +[日本語](./cli-reference.ja.md) + +This document provides a complete reference for all TAKT CLI commands and options. + +## Global Options + +| Option | Description | +|--------|-------------| +| `--pipeline` | Enable pipeline (non-interactive) mode -- required for CI/automation | +| `-t, --task ` | Task content (alternative to GitHub Issue) | +| `-i, --issue ` | GitHub issue number (same as `#N` in interactive mode) | +| `-w, --piece ` | Piece name or path to piece YAML file | +| `-b, --branch ` | Specify branch name (auto-generated if omitted) | +| `--auto-pr` | Create PR (interactive: skip confirmation, pipeline: enable PR) | +| `--skip-git` | Skip branch creation, commit, and push (pipeline mode, piece-only) | +| `--repo ` | Specify repository (for PR creation) | +| `--create-worktree ` | Skip worktree confirmation prompt | +| `-q, --quiet` | Minimal output mode: suppress AI output (for CI) | +| `--provider ` | Override agent provider (claude\|codex\|opencode\|mock) | +| `--model ` | Override agent model | +| `--config ` | Path to global config file (default: `~/.takt/config.yaml`) | + +## Interactive Mode + +A mode where you refine task content through conversation with AI before execution. Useful when task requirements are ambiguous or when you want to clarify content while consulting with AI. + +```bash +# Start interactive mode (no arguments) +takt + +# Specify initial message (short word only) +takt hello +``` + +**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. 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 + +``` +$ takt + +Select piece: + > default (current) + Development/ + Research/ + Cancel + +Interactive mode - Enter task content. Commands: /go (execute), /cancel (exit) + +> I want to add user authentication feature + +[AI confirms and organizes requirements] + +> /go + +Proposed task instructions: +--- +Implement user authentication feature. + +Requirements: +- Login with email address and password +- JWT token-based authentication +- Password hashing (bcrypt) +- Login/logout API endpoints +--- + +Proceed with these task instructions? (Y/n) y + +? Create worktree? (Y/n) y + +[Piece execution starts...] +``` + +## Direct Task Execution + +Use the `--task` option to skip interactive mode and execute directly. + +```bash +# Specify task content with --task option +takt --task "Fix bug" + +# Specify piece +takt --task "Add authentication" --piece expert + +# Auto-create PR +takt --task "Fix bug" --auto-pr +``` + +**Note:** Passing a string as an argument (e.g., `takt "Add login feature"`) enters interactive mode with it as the initial message. + +## GitHub Issue Tasks + +You can execute GitHub Issues directly as tasks. Issue title, body, labels, and comments are automatically incorporated as task content. + +```bash +# Execute by specifying issue number +takt #6 +takt --issue 6 + +# Issue + piece specification +takt #6 --piece expert + +# Issue + auto-create PR +takt #6 --auto-pr +``` + +**Requirements:** [GitHub CLI](https://cli.github.com/) (`gh`) must be installed and authenticated. + +## Task Management Commands + +Batch processing using `.takt/tasks.yaml` with task directories under `.takt/tasks/{slug}/`. Useful for accumulating multiple tasks and executing them together later. + +### takt add + +Refine task requirements through AI conversation, then add a task to `.takt/tasks.yaml`. + +```bash +# Refine task requirements through AI conversation, then add task +takt add + +# Add task from GitHub Issue (issue number reflected in branch name) +takt add #28 +``` + +### takt run + +Execute all pending tasks from `.takt/tasks.yaml`. + +```bash +# Execute all pending tasks in .takt/tasks.yaml +takt run +``` + +### takt watch + +Monitor `.takt/tasks.yaml` and auto-execute tasks as a resident process. + +```bash +# Monitor .takt/tasks.yaml and auto-execute tasks (resident process) +takt watch +``` + +### takt list + +List task branches and perform actions (merge, delete, etc.). + +```bash +# List task branches (merge/delete) +takt list + +# Non-interactive mode (for CI/scripts) +takt list --non-interactive +takt list --non-interactive --action diff --branch takt/my-branch +takt list --non-interactive --action delete --branch takt/my-branch --yes +takt list --non-interactive --format json +``` + +### Task Directory Workflow (Create / Run / Verify) + +1. Run `takt add` and confirm a pending record is created in `.takt/tasks.yaml`. +2. Open the generated `.takt/tasks/{slug}/order.md` and add detailed specifications/references as needed. +3. Run `takt run` (or `takt watch`) to execute pending tasks from `tasks.yaml`. +4. Verify outputs in `.takt/runs/{slug}/reports/` using the same slug as `task_dir`. + +## Pipeline Mode + +Specifying `--pipeline` enables non-interactive pipeline mode. Automatically creates branch, runs piece, commits and pushes. Suitable for CI/CD automation. + +```bash +# Execute task in pipeline mode +takt --pipeline --task "Fix bug" + +# Pipeline execution + auto-create PR +takt --pipeline --task "Fix bug" --auto-pr + +# Link issue information +takt --pipeline --issue 99 --auto-pr + +# Specify piece and branch +takt --pipeline --task "Fix bug" -w magi -b feat/fix-bug + +# Specify repository (for PR creation) +takt --pipeline --task "Fix bug" --auto-pr --repo owner/repo + +# Piece execution only (skip branch creation, commit, push) +takt --pipeline --task "Fix bug" --skip-git + +# Minimal output mode (for CI) +takt --pipeline --task "Fix bug" --quiet +``` + +In pipeline mode, PRs are not created unless `--auto-pr` is specified. + +**GitHub Integration:** When using TAKT in GitHub Actions, see [takt-action](https://github.com/nrslib/takt-action). You can automate PR reviews and task execution. + +## Utility Commands + +### takt switch + +Interactively switch the active piece. + +```bash +takt switch +``` + +### takt eject + +Copy builtin pieces/personas to your local directory for customization. + +```bash +# Copy builtin pieces/personas to project .takt/ for customization +takt eject + +# Copy to ~/.takt/ (global) instead +takt eject --global + +# Eject a specific facet for customization +takt eject persona coder +takt eject instruction plan --global +``` + +### takt clear + +Clear agent conversation sessions (reset state). + +```bash +takt clear +``` + +### takt export-cc + +Deploy builtin pieces/personas as a Claude Code Skill. + +```bash +takt export-cc +``` + +### takt catalog + +List available facets across layers. + +```bash +takt catalog +takt catalog personas +``` + +### takt prompt + +Preview assembled prompts for each movement and phase. + +```bash +takt prompt [piece] +``` + +### takt reset + +Reset settings to defaults. + +```bash +# Reset global config to builtin template (with backup) +takt reset config + +# Reset piece categories to builtin defaults +takt reset categories +``` + +### takt metrics + +Show analytics metrics. + +```bash +# Show review quality metrics (default: last 30 days) +takt metrics review + +# Specify time window +takt metrics review --since 7d +``` + +### takt purge + +Purge old analytics event files. + +```bash +# Purge files older than 30 days (default) +takt purge + +# Specify retention period +takt purge --retention-days 14 +``` diff --git a/docs/configuration.ja.md b/docs/configuration.ja.md new file mode 100644 index 0000000..260a7ed --- /dev/null +++ b/docs/configuration.ja.md @@ -0,0 +1,400 @@ +# 設定 + +[English](./configuration.md) + +このドキュメントは TAKT の全設定オプションのリファレンスです。クイックスタートについては [README](../README.md) を参照してください。 + +## グローバル設定 + +`~/.takt/config.yaml` で TAKT のデフォルト設定を行います。このファイルは初回実行時に自動作成されます。すべてのフィールドは省略可能です。 + +```yaml +# ~/.takt/config.yaml +language: en # UI 言語: 'en' または 'ja' +default_piece: default # 新規プロジェクトのデフォルト piece +log_level: info # ログレベル: debug, info, warn, error +provider: claude # デフォルト provider: claude, codex, または opencode +model: sonnet # デフォルトモデル(省略可、provider にそのまま渡される) +branch_name_strategy: romaji # ブランチ名生成方式: 'romaji'(高速)または 'ai'(低速) +prevent_sleep: false # 実行中に macOS のアイドルスリープを防止(caffeinate) +notification_sound: true # 通知音の有効/無効 +notification_sound_events: # イベントごとの通知音切り替え(省略可) + iteration_limit: false + piece_complete: true + piece_abort: true + run_complete: true # デフォルト有効。false で無効化 + run_abort: true # デフォルト有効。false で無効化 +concurrency: 1 # takt run の並列タスク数(1-10、デフォルト: 1 = 逐次実行) +task_poll_interval_ms: 500 # takt run での新規タスクポーリング間隔(100-5000、デフォルト: 500) +interactive_preview_movements: 3 # インタラクティブモードでの movement プレビュー数(0-10、デフォルト: 3) + +# ランタイム環境デフォルト(piece_config.runtime で上書きしない限りすべての piece に適用) +# runtime: +# prepare: +# - gradle # .runtime/ に Gradle キャッシュ/設定を準備 +# - node # .runtime/ に npm キャッシュを準備 + +# persona ごとの provider 上書き(省略可) +# piece を複製せずに特定の persona を別の provider にルーティング +# persona_providers: +# coder: codex # coder を Codex で実行 +# ai-antipattern-reviewer: claude # レビュアーは Claude のまま + +# provider 固有のパーミッションプロファイル(省略可) +# 優先順位: プロジェクト上書き > グローバル上書き > プロジェクトデフォルト > グローバルデフォルト > required_permission_mode(下限) +# provider_profiles: +# codex: +# default_permission_mode: full +# movement_permission_overrides: +# ai_review: readonly +# claude: +# default_permission_mode: edit + +# API キー設定(省略可) +# 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY で上書き可能 +# anthropic_api_key: sk-ant-... # Claude(Anthropic)用 +# openai_api_key: sk-... # Codex(OpenAI)用 +# opencode_api_key: ... # OpenCode 用 + +# Codex CLI パス上書き(省略可) +# Codex SDK が使用する Codex CLI バイナリを上書き(実行可能ファイルの絶対パスが必要) +# 環境変数 TAKT_CODEX_CLI_PATH で上書き可能 +# codex_cli_path: /usr/local/bin/codex + +# ビルトイン piece フィルタリング(省略可) +# builtin_pieces_enabled: true # false ですべてのビルトインを無効化 +# disabled_builtins: [magi, passthrough] # 特定のビルトイン piece を無効化 + +# pipeline 実行設定(省略可) +# ブランチ名、コミットメッセージ、PR 本文をカスタマイズ +# pipeline: +# default_branch_prefix: "takt/" +# commit_message_template: "feat: {title} (#{issue})" +# pr_body_template: | +# ## Summary +# {issue_body} +# Closes #{issue} +``` + +### グローバル設定フィールドリファレンス + +| フィールド | 型 | デフォルト | 説明 | +|-----------|------|---------|------| +| `language` | `"en"` \| `"ja"` | `"en"` | UI 言語 | +| `default_piece` | string | `"default"` | 新規プロジェクトのデフォルト piece | +| `log_level` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` | ログレベル | +| `provider` | `"claude"` \| `"codex"` \| `"opencode"` | `"claude"` | デフォルト AI provider | +| `model` | string | - | デフォルトモデル名(provider にそのまま渡される) | +| `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | ブランチ名生成方式 | +| `prevent_sleep` | boolean | `false` | macOS アイドルスリープ防止(caffeinate) | +| `notification_sound` | boolean | `true` | 通知音の有効化 | +| `notification_sound_events` | object | - | イベントごとの通知音切り替え | +| `concurrency` | number (1-10) | `1` | `takt run` の並列タスク数 | +| `task_poll_interval_ms` | number (100-5000) | `500` | 新規タスクのポーリング間隔 | +| `interactive_preview_movements` | number (0-10) | `3` | インタラクティブモードでの movement プレビュー数 | +| `worktree_dir` | string | - | 共有クローンのディレクトリ(デフォルトは `../{clone-name}`) | +| `auto_pr` | boolean | - | worktree 実行後に PR を自動作成 | +| `verbose` | boolean | - | 詳細出力モード | +| `minimal_output` | boolean | `false` | AI 出力を抑制(CI 向け) | +| `runtime` | object | - | ランタイム環境デフォルト(例: `prepare: [gradle, node]`) | +| `persona_providers` | object | - | persona ごとの provider 上書き(例: `coder: codex`) | +| `provider_options` | object | - | グローバルな provider 固有オプション | +| `provider_profiles` | object | - | provider 固有のパーミッションプロファイル | +| `anthropic_api_key` | string | - | Claude 用 Anthropic API キー | +| `openai_api_key` | string | - | Codex 用 OpenAI API キー | +| `opencode_api_key` | string | - | OpenCode API キー | +| `codex_cli_path` | string | - | Codex CLI バイナリパス上書き(絶対パス) | +| `enable_builtin_pieces` | boolean | `true` | ビルトイン piece の有効化 | +| `disabled_builtins` | string[] | `[]` | 無効化する特定のビルトイン piece | +| `pipeline` | object | - | pipeline テンプレート設定 | +| `bookmarks_file` | string | - | ブックマークファイルのパス | +| `piece_categories_file` | string | - | piece カテゴリファイルのパス | + +## プロジェクト設定 + +`.takt/config.yaml` でプロジェクト固有の設定を行います。このファイルはプロジェクトディレクトリで初めて TAKT を使用した際に作成されます。 + +```yaml +# .takt/config.yaml +piece: default # このプロジェクトの現在の piece +provider: claude # このプロジェクトの provider 上書き +auto_pr: true # worktree 実行後に PR を自動作成 +verbose: false # 詳細出力モード + +# provider 固有オプション(グローバルを上書き、piece/movement で上書き可能) +# provider_options: +# codex: +# network_access: true + +# provider 固有パーミッションプロファイル(プロジェクトレベルの上書き) +# provider_profiles: +# codex: +# default_permission_mode: full +# movement_permission_overrides: +# ai_review: readonly +``` + +### プロジェクト設定フィールドリファレンス + +| フィールド | 型 | デフォルト | 説明 | +|-----------|------|---------|------| +| `piece` | string | `"default"` | このプロジェクトの現在の piece 名 | +| `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"mock"` | - | provider 上書き | +| `auto_pr` | boolean | - | worktree 実行後に PR を自動作成 | +| `verbose` | boolean | - | 詳細出力モード | +| `provider_options` | object | - | provider 固有オプション | +| `provider_profiles` | object | - | provider 固有のパーミッションプロファイル | + +プロジェクト設定の値は、両方が設定されている場合にグローバル設定を上書きします。 + +## API キー設定 + +TAKT は3つの provider をサポートしており、それぞれに API キーが必要です。API キーは環境変数または `~/.takt/config.yaml` で設定できます。 + +### 環境変数(推奨) + +```bash +# Claude(Anthropic)用 +export TAKT_ANTHROPIC_API_KEY=sk-ant-... + +# Codex(OpenAI)用 +export TAKT_OPENAI_API_KEY=sk-... + +# OpenCode 用 +export TAKT_OPENCODE_API_KEY=... +``` + +### 設定ファイル + +```yaml +# ~/.takt/config.yaml +anthropic_api_key: sk-ant-... # Claude 用 +openai_api_key: sk-... # Codex 用 +opencode_api_key: ... # OpenCode 用 +``` + +### 優先順位 + +環境変数は `config.yaml` の設定よりも優先されます。 + +| Provider | 環境変数 | 設定キー | +|----------|---------|---------| +| Claude (Anthropic) | `TAKT_ANTHROPIC_API_KEY` | `anthropic_api_key` | +| Codex (OpenAI) | `TAKT_OPENAI_API_KEY` | `openai_api_key` | +| OpenCode | `TAKT_OPENCODE_API_KEY` | `opencode_api_key` | + +### セキュリティ + +- `config.yaml` に API キーを記載する場合、このファイルを Git にコミットしないよう注意してください。 +- 環境変数の使用を検討してください。 +- 必要に応じて `~/.takt/config.yaml` をグローバル `.gitignore` に追加してください。 +- API キーを設定すれば、対応する CLI ツール(Claude Code、Codex、OpenCode)のインストールは不要です。TAKT が対応する API を直接呼び出します。 + +### Codex CLI パス上書き + +Codex CLI バイナリパスは環境変数または設定ファイルで上書きできます。 + +```bash +export TAKT_CODEX_CLI_PATH=/usr/local/bin/codex +``` + +```yaml +# ~/.takt/config.yaml +codex_cli_path: /usr/local/bin/codex +``` + +パスは実行可能ファイルの絶対パスである必要があります。`TAKT_CODEX_CLI_PATH` は設定ファイルの値よりも優先されます。 + +## モデル解決 + +各 movement で使用されるモデルは、次の優先順位(高い順)で解決されます。 + +1. **Piece movement の `model`** - piece YAML の movement 定義で指定 +2. **カスタムエージェントの `model`** - `.takt/agents.yaml` のエージェントレベルのモデル +3. **グローバル設定の `model`** - `~/.takt/config.yaml` のデフォルトモデル +4. **Provider デフォルト** - provider のビルトインデフォルトにフォールバック(Claude: `sonnet`、Codex: `codex`、OpenCode: provider デフォルト) + +### Provider 固有のモデルに関する注意 + +**Claude Code** はエイリアス(`opus`、`sonnet`、`haiku`、`opusplan`、`default`)と完全なモデル名(例: `claude-sonnet-4-5-20250929`)をサポートしています。`model` フィールドは provider CLI にそのまま渡されます。利用可能なモデルについては [Claude Code ドキュメント](https://docs.anthropic.com/en/docs/claude-code) を参照してください。 + +**Codex** は Codex SDK を通じてモデル文字列をそのまま使用します。未指定の場合、デフォルトは `codex` です。利用可能なモデルについては Codex のドキュメントを参照してください。 + +**OpenCode** は `provider/model` 形式のモデル(例: `opencode/big-pickle`)が必要です。OpenCode provider でモデルを省略すると設定エラーになります。 + +### 設定例 + +```yaml +# ~/.takt/config.yaml +provider: claude +model: opus # すべての movement のデフォルトモデル(上書きされない限り) +``` + +```yaml +# piece.yaml - movement レベルの上書きが最高優先 +movements: + - name: plan + model: opus # この movement はグローバル設定に関係なく opus を使用 + ... + - name: implement + # model 未指定 - グローバル設定(opus)にフォールバック + ... +``` + +## Provider プロファイル + +Provider プロファイルを使用すると、各 provider にデフォルトのパーミッションモードと movement ごとのパーミッション上書きを設定できます。異なる provider を異なるセキュリティポリシーで運用する場合に便利です。 + +### パーミッションモード + +TAKT は provider 非依存の3つのパーミッションモードを使用します。 + +| モード | 説明 | Claude | Codex | OpenCode | +|--------|------|--------|-------|----------| +| `readonly` | 読み取り専用、ファイル変更不可 | `default` | `read-only` | `read-only` | +| `edit` | 確認付きでファイル編集を許可 | `acceptEdits` | `workspace-write` | `workspace-write` | +| `full` | すべてのパーミッションチェックをバイパス | `bypassPermissions` | `danger-full-access` | `danger-full-access` | + +### 設定方法 + +Provider プロファイルはグローバルレベルとプロジェクトレベルの両方で設定できます。 + +```yaml +# ~/.takt/config.yaml(グローバル)または .takt/config.yaml(プロジェクト) +provider_profiles: + codex: + default_permission_mode: full + movement_permission_overrides: + ai_review: readonly + claude: + default_permission_mode: edit + movement_permission_overrides: + implement: full +``` + +### パーミッション解決の優先順位 + +パーミッションモードは次の順序で解決されます(最初にマッチしたものが適用)。 + +1. **プロジェクト** `provider_profiles..movement_permission_overrides.` +2. **グローバル** `provider_profiles..movement_permission_overrides.` +3. **プロジェクト** `provider_profiles..default_permission_mode` +4. **グローバル** `provider_profiles..default_permission_mode` +5. **Movement** `required_permission_mode`(最低限の下限として機能) + +movement の `required_permission_mode` は最低限の下限を設定します。provider プロファイルから解決されたモードが要求モードよりも低い場合、要求モードが使用されます。たとえば、movement が `edit` を要求しているがプロファイルが `readonly` に解決される場合、実効モードは `edit` になります。 + +### Persona Provider + +piece を複製せずに、特定の persona を別の provider にルーティングできます。 + +```yaml +# ~/.takt/config.yaml +persona_providers: + coder: codex # coder persona を Codex で実行 + ai-antipattern-reviewer: claude # レビュアーは Claude のまま +``` + +これにより、単一の piece 内で provider を混在させることができます。persona 名は movement 定義の `persona` キーに対してマッチされます。 + +## Piece カテゴリ + +`takt switch` や piece 選択プロンプトでの UI 表示を改善するために、piece をカテゴリに整理できます。 + +### 設定方法 + +カテゴリは次の場所で設定できます。 +- `builtins/{lang}/piece-categories.yaml` - デフォルトのビルトインカテゴリ +- `~/.takt/config.yaml` または `piece_categories_file` で指定した別のカテゴリファイル + +```yaml +# ~/.takt/config.yaml または専用カテゴリファイル +piece_categories: + Development: + pieces: [default, simple] + children: + Backend: + pieces: [expert-cqrs] + Frontend: + pieces: [expert] + Research: + pieces: [research, magi] + +show_others_category: true # 未分類の piece を表示(デフォルト: true) +others_category_name: "Other Pieces" # 未分類カテゴリの名前 +``` + +### カテゴリ機能 + +- **ネストされたカテゴリ** - 階層的な整理のための無制限の深さ +- **カテゴリごとの piece リスト** - 特定のカテゴリに piece を割り当て +- **その他カテゴリ** - 未分類の piece を自動収集(`show_others_category: false` で無効化可能) +- **ビルトイン piece フィルタリング** - `enable_builtin_pieces: false` ですべてのビルトインを無効化、または `disabled_builtins: [name1, name2]` で選択的に無効化 + +### カテゴリのリセット + +piece カテゴリをビルトインのデフォルトにリセットできます。 + +```bash +takt reset categories +``` + +## Pipeline テンプレート + +Pipeline モード(`--pipeline`)では、ブランチ名、コミットメッセージ、PR 本文をカスタマイズするテンプレートをサポートしています。 + +### 設定方法 + +```yaml +# ~/.takt/config.yaml +pipeline: + default_branch_prefix: "takt/" + commit_message_template: "feat: {title} (#{issue})" + pr_body_template: | + ## Summary + {issue_body} + Closes #{issue} +``` + +### テンプレート変数 + +| 変数 | 使用可能な場所 | 説明 | +|------|--------------|------| +| `{title}` | コミットメッセージ | Issue タイトル | +| `{issue}` | コミットメッセージ、PR 本文 | Issue 番号 | +| `{issue_body}` | PR 本文 | Issue 本文 | +| `{report}` | PR 本文 | Piece 実行レポート | + +### Pipeline CLI オプション + +| オプション | 説明 | +|-----------|------| +| `--pipeline` | pipeline(非インタラクティブ)モードを有効化 | +| `--auto-pr` | 実行後に PR を作成 | +| `--skip-git` | ブランチ作成、コミット、プッシュをスキップ(piece のみ実行) | +| `--repo ` | PR 作成用のリポジトリを指定 | +| `-q, --quiet` | 最小出力モード(AI 出力を抑制) | + +## デバッグ + +### デバッグログ + +`~/.takt/config.yaml` で `debug_enabled: true` を設定するか、`.takt/debug.yaml` ファイルを作成してデバッグログを有効化できます。 + +```yaml +# .takt/debug.yaml +enabled: true +``` + +デバッグログは `.takt/logs/debug.log` に NDJSON 形式で出力されます。 + +### 詳細モード + +空の `.takt/verbose` ファイルを作成すると、詳細なコンソール出力が有効になります。これにより、デバッグログも自動的に有効化されます。 + +または、設定ファイルで `verbose: true` を設定することもできます。 + +```yaml +# ~/.takt/config.yaml または .takt/config.yaml +verbose: true +``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..a6dbf50 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,400 @@ +# Configuration + +[日本語](./configuration.ja.md) + +This document is a reference for all TAKT configuration options. For a quick start, see the main [README](../README.md). + +## Global Configuration + +Configure TAKT defaults in `~/.takt/config.yaml`. This file is created automatically on first run. All fields are optional. + +```yaml +# ~/.takt/config.yaml +language: en # UI language: 'en' or 'ja' +default_piece: default # Default piece for new projects +log_level: info # Log level: debug, info, warn, error +provider: claude # Default provider: claude, codex, or opencode +model: sonnet # Default model (optional, passed to provider as-is) +branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow) +prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate) +notification_sound: true # Enable/disable notification sounds +notification_sound_events: # Optional per-event toggles + iteration_limit: false + piece_complete: true + piece_abort: true + run_complete: true # Enabled by default; set false to disable + run_abort: true # Enabled by default; set false to disable +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) + +# Runtime environment defaults (applies to all pieces unless piece_config.runtime overrides) +# runtime: +# prepare: +# - gradle # Prepare Gradle cache/config in .runtime/ +# - node # Prepare npm cache in .runtime/ + +# 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 + +# Provider-specific permission profiles (optional) +# Priority: project override > global override > project default > global default > required_permission_mode (floor) +# provider_profiles: +# codex: +# default_permission_mode: full +# movement_permission_overrides: +# ai_review: readonly +# claude: +# default_permission_mode: edit + +# API Key configuration (optional) +# Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY +# anthropic_api_key: sk-ant-... # For Claude (Anthropic) +# openai_api_key: sk-... # For Codex (OpenAI) +# opencode_api_key: ... # For OpenCode + +# Codex CLI path override (optional) +# Override the Codex CLI binary used by the Codex SDK (must be an absolute path to an executable file) +# Can be overridden by TAKT_CODEX_CLI_PATH environment variable +# codex_cli_path: /usr/local/bin/codex + +# Builtin piece filtering (optional) +# builtin_pieces_enabled: true # Set false to disable all builtins +# disabled_builtins: [magi, passthrough] # Disable specific builtin pieces + +# Pipeline execution configuration (optional) +# Customize branch names, commit messages, and PR body. +# pipeline: +# default_branch_prefix: "takt/" +# commit_message_template: "feat: {title} (#{issue})" +# pr_body_template: | +# ## Summary +# {issue_body} +# Closes #{issue} +``` + +### Global Config Field Reference + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `language` | `"en"` \| `"ja"` | `"en"` | UI language | +| `default_piece` | string | `"default"` | Default piece for new projects | +| `log_level` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` | Log level | +| `provider` | `"claude"` \| `"codex"` \| `"opencode"` | `"claude"` | Default AI provider | +| `model` | string | - | Default model name (passed to provider as-is) | +| `branch_name_strategy` | `"romaji"` \| `"ai"` | `"romaji"` | Branch name generation strategy | +| `prevent_sleep` | boolean | `false` | Prevent macOS idle sleep (caffeinate) | +| `notification_sound` | boolean | `true` | Enable notification sounds | +| `notification_sound_events` | object | - | Per-event notification sound toggles | +| `concurrency` | number (1-10) | `1` | Parallel task count for `takt run` | +| `task_poll_interval_ms` | number (100-5000) | `500` | Polling interval for new tasks | +| `interactive_preview_movements` | number (0-10) | `3` | Movement previews in interactive mode | +| `worktree_dir` | string | - | Directory for shared clones (defaults to `../{clone-name}`) | +| `auto_pr` | boolean | - | Auto-create PR after worktree execution | +| `verbose` | boolean | - | Verbose output mode | +| `minimal_output` | boolean | `false` | Suppress AI output (for CI) | +| `runtime` | object | - | Runtime environment defaults (e.g., `prepare: [gradle, node]`) | +| `persona_providers` | object | - | Per-persona provider overrides (e.g., `coder: codex`) | +| `provider_options` | object | - | Global provider-specific options | +| `provider_profiles` | object | - | Provider-specific permission profiles | +| `anthropic_api_key` | string | - | Anthropic API key for Claude | +| `openai_api_key` | string | - | OpenAI API key for Codex | +| `opencode_api_key` | string | - | OpenCode API key | +| `codex_cli_path` | string | - | Codex CLI binary path override (absolute) | +| `enable_builtin_pieces` | boolean | `true` | Enable builtin pieces | +| `disabled_builtins` | string[] | `[]` | Specific builtin pieces to disable | +| `pipeline` | object | - | Pipeline template settings | +| `bookmarks_file` | string | - | Path to bookmarks file | +| `piece_categories_file` | string | - | Path to piece categories file | + +## Project Configuration + +Configure project-specific settings in `.takt/config.yaml`. This file is created when you first use TAKT in a project directory. + +```yaml +# .takt/config.yaml +piece: default # Current piece for this project +provider: claude # Override provider for this project +auto_pr: true # Auto-create PR after worktree execution +verbose: false # Verbose output mode + +# Provider-specific options (overrides global, overridden by piece/movement) +# provider_options: +# codex: +# network_access: true + +# Provider-specific permission profiles (project-level override) +# provider_profiles: +# codex: +# default_permission_mode: full +# movement_permission_overrides: +# ai_review: readonly +``` + +### Project Config Field Reference + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `piece` | string | `"default"` | Current piece name for this project | +| `provider` | `"claude"` \| `"codex"` \| `"opencode"` \| `"mock"` | - | Override provider | +| `auto_pr` | boolean | - | Auto-create PR after worktree execution | +| `verbose` | boolean | - | Verbose output mode | +| `provider_options` | object | - | Provider-specific options | +| `provider_profiles` | object | - | Provider-specific permission profiles | + +Project config values override global config when both are set. + +## API Key Configuration + +TAKT supports three providers, each with its own API key. API keys can be configured via environment variables or `~/.takt/config.yaml`. + +### Environment Variables (Recommended) + +```bash +# For Claude (Anthropic) +export TAKT_ANTHROPIC_API_KEY=sk-ant-... + +# For Codex (OpenAI) +export TAKT_OPENAI_API_KEY=sk-... + +# For OpenCode +export TAKT_OPENCODE_API_KEY=... +``` + +### Config File + +```yaml +# ~/.takt/config.yaml +anthropic_api_key: sk-ant-... # For Claude +openai_api_key: sk-... # For Codex +opencode_api_key: ... # For OpenCode +``` + +### Priority + +Environment variables take precedence over `config.yaml` settings. + +| Provider | Environment Variable | Config Key | +|----------|---------------------|------------| +| Claude (Anthropic) | `TAKT_ANTHROPIC_API_KEY` | `anthropic_api_key` | +| Codex (OpenAI) | `TAKT_OPENAI_API_KEY` | `openai_api_key` | +| OpenCode | `TAKT_OPENCODE_API_KEY` | `opencode_api_key` | + +### Security + +- If you write API keys in `config.yaml`, be careful not to commit this file to Git. +- Consider using environment variables instead. +- Add `~/.takt/config.yaml` to your global `.gitignore` if needed. +- If you set an API key, installing the corresponding CLI tool (Claude Code, Codex, OpenCode) is not necessary. TAKT directly calls the respective API. + +### Codex CLI Path Override + +You can override the Codex CLI binary path using either an environment variable or config: + +```bash +export TAKT_CODEX_CLI_PATH=/usr/local/bin/codex +``` + +```yaml +# ~/.takt/config.yaml +codex_cli_path: /usr/local/bin/codex +``` + +The path must be an absolute path to an executable file. `TAKT_CODEX_CLI_PATH` takes precedence over the config file value. + +## Model Resolution + +The model used for each movement is resolved with the following priority order (highest first): + +1. **Piece movement `model`** - Specified in the movement definition in piece YAML +2. **Custom agent `model`** - Agent-level model in `.takt/agents.yaml` +3. **Global config `model`** - Default model in `~/.takt/config.yaml` +4. **Provider default** - Falls back to the provider's built-in default (Claude: `sonnet`, Codex: `codex`, OpenCode: provider default) + +### Provider-specific Model Notes + +**Claude Code** supports aliases (`opus`, `sonnet`, `haiku`, `opusplan`, `default`) and full model names (e.g., `claude-sonnet-4-5-20250929`). The `model` field is passed directly to the provider CLI. Refer to the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code) for available models. + +**Codex** uses the model string as-is via the Codex SDK. If unspecified, defaults to `codex`. Refer to Codex documentation for available models. + +**OpenCode** requires a model in `provider/model` format (e.g., `opencode/big-pickle`). Omitting the model for the OpenCode provider will result in a configuration error. + +### Example + +```yaml +# ~/.takt/config.yaml +provider: claude +model: opus # Default model for all movements (unless overridden) +``` + +```yaml +# piece.yaml - movement-level override takes highest priority +movements: + - name: plan + model: opus # This movement uses opus regardless of global config + ... + - name: implement + # No model specified - falls back to global config (opus) + ... +``` + +## Provider Profiles + +Provider profiles allow you to set default permission modes and per-movement permission overrides for each provider. This is useful when running different providers with different security postures. + +### Permission Modes + +TAKT uses three provider-independent permission modes: + +| Mode | Description | Claude | Codex | OpenCode | +|------|-------------|--------|-------|----------| +| `readonly` | Read-only access, no file modifications | `default` | `read-only` | `read-only` | +| `edit` | Allow file edits with confirmation | `acceptEdits` | `workspace-write` | `workspace-write` | +| `full` | Bypass all permission checks | `bypassPermissions` | `danger-full-access` | `danger-full-access` | + +### Configuration + +Provider profiles can be set at both global and project levels: + +```yaml +# ~/.takt/config.yaml (global) or .takt/config.yaml (project) +provider_profiles: + codex: + default_permission_mode: full + movement_permission_overrides: + ai_review: readonly + claude: + default_permission_mode: edit + movement_permission_overrides: + implement: full +``` + +### Permission Resolution Priority + +Permission mode is resolved in the following order (first match wins): + +1. **Project** `provider_profiles..movement_permission_overrides.` +2. **Global** `provider_profiles..movement_permission_overrides.` +3. **Project** `provider_profiles..default_permission_mode` +4. **Global** `provider_profiles..default_permission_mode` +5. **Movement** `required_permission_mode` (acts as a minimum floor) + +The `required_permission_mode` on a movement sets the minimum floor. If the resolved mode from provider profiles is lower than the required mode, the required mode is used instead. For example, if a movement requires `edit` but the profile resolves to `readonly`, the effective mode will be `edit`. + +### Persona Providers + +Route specific personas to different providers without duplicating pieces: + +```yaml +# ~/.takt/config.yaml +persona_providers: + coder: codex # Run coder persona on Codex + ai-antipattern-reviewer: claude # Keep reviewers on Claude +``` + +This allows mixing providers within a single piece. The persona name is matched against the `persona` key in the movement definition. + +## Piece Categories + +Organize pieces into categories for better UI presentation in `takt switch` and piece selection prompts. + +### Configuration + +Categories can be configured in: +- `builtins/{lang}/piece-categories.yaml` - Default builtin categories +- `~/.takt/config.yaml` or a separate categories file specified by `piece_categories_file` + +```yaml +# ~/.takt/config.yaml or dedicated categories file +piece_categories: + Development: + pieces: [default, simple] + children: + Backend: + pieces: [expert-cqrs] + Frontend: + pieces: [expert] + Research: + pieces: [research, magi] + +show_others_category: true # Show uncategorized pieces (default: true) +others_category_name: "Other Pieces" # Name for uncategorized category +``` + +### Category Features + +- **Nested categories** - Unlimited depth for hierarchical organization +- **Per-category piece lists** - Assign pieces to specific categories +- **Others category** - Automatically collects uncategorized pieces (can be disabled via `show_others_category: false`) +- **Builtin piece filtering** - Disable all builtins via `enable_builtin_pieces: false`, or selectively via `disabled_builtins: [name1, name2]` + +### Resetting Categories + +Reset piece categories to builtin defaults: + +```bash +takt reset categories +``` + +## Pipeline Templates + +Pipeline mode (`--pipeline`) supports customizable templates for branch names, commit messages, and PR bodies. + +### Configuration + +```yaml +# ~/.takt/config.yaml +pipeline: + default_branch_prefix: "takt/" + commit_message_template: "feat: {title} (#{issue})" + pr_body_template: | + ## Summary + {issue_body} + Closes #{issue} +``` + +### Template Variables + +| Variable | Available In | Description | +|----------|-------------|-------------| +| `{title}` | Commit message | Issue title | +| `{issue}` | Commit message, PR body | Issue number | +| `{issue_body}` | PR body | Issue body | +| `{report}` | PR body | Piece execution report | + +### Pipeline CLI Options + +| Option | Description | +|--------|-------------| +| `--pipeline` | Enable pipeline (non-interactive) mode | +| `--auto-pr` | Create PR after execution | +| `--skip-git` | Skip branch creation, commit, and push (piece-only) | +| `--repo ` | Repository for PR creation | +| `-q, --quiet` | Minimal output mode (suppress AI output) | + +## Debugging + +### Debug Logging + +Enable debug logging by setting `debug_enabled: true` in `~/.takt/config.yaml` or by creating a `.takt/debug.yaml` file: + +```yaml +# .takt/debug.yaml +enabled: true +``` + +Debug logs are written to `.takt/logs/debug.log` in NDJSON format. + +### Verbose Mode + +Create an empty `.takt/verbose` file to enable verbose console output. This automatically enables debug logging. + +Alternatively, set `verbose: true` in your config: + +```yaml +# ~/.takt/config.yaml or .takt/config.yaml +verbose: true +``` diff --git a/docs/plan.md b/docs/plan.md deleted file mode 100644 index 131ed4d..0000000 --- a/docs/plan.md +++ /dev/null @@ -1,42 +0,0 @@ -- perform_phase1_message.md - - ここから status Rule を排除する(phase3に書けばいい) -- perform_phase2_message.md - - 「上記のReport Directory内のファイルのみ使用してください。** 他のレポートディレクトリは検索/参照しないでください。」は上記ってのがいらないのではないか - - 「**このフェーズではツールは使えません。レポート内容をテキストとして直接回答してください。**」が重複することがあるので削除せよ。 - - JSON形式について触れる必要はない。 -- perform_phase3_message.md - - status Rule を追加する聞く -- perform_agent_system_prompt.md - - これ、エージェントのデータを挿入してないの……? -- 全体的に - - 音楽にひもづける - - つまり、piecesをやめて pieces にする - - 現pieceファイルにあるstepsもmovementsにする(全ファイルの修正) - - stepという言葉はmovementになる。phaseもmovementが適しているだろう(これは interactive における phase のことをいっていない) - - _language パラメータは消せ - - ピースを指定すると実際に送られるプロンプトを組み立てて表示する機能かツールを作れるか - - メタ領域を用意して説明、どこで利用されるかの説明、使えるテンプレートとその説明をかいて、その他必要な情報あれば入れて。 - - 英語と日本語が共通でもかならずファイルはわけて同じ文章を書いておく - - 無駄な空行とか消してほしい - ``` - {{#if hasPreviousResponse}} - - ## Previous Response - {{previousResponse}} - {{/if}} - {{#if hasUserInputs}} - - ## Additional User Inputs - {{userInputs}} - ``` - これは↓のがいいんじゃない? - ``` - {{#if hasPreviousResponse}} - ## Previous Response - {{previousResponse}} - {{/if}} - - {{#if hasUserInputs}} - ## Additional User Inputs - {{userInputs}} - ``` \ No newline at end of file diff --git a/docs/task-management.ja.md b/docs/task-management.ja.md new file mode 100644 index 0000000..489c195 --- /dev/null +++ b/docs/task-management.ja.md @@ -0,0 +1,323 @@ +[English](./task-management.md) + +# タスク管理 + +## 概要 + +TAKT は複数のタスクを蓄積してバッチ実行するためのタスク管理ワークフローを提供します。基本的な流れは次の通りです。 + +1. **`takt add`** -- AI との会話でタスク要件を精緻化し、`.takt/tasks.yaml` に保存 +2. **タスクの蓄積** -- `order.md` ファイルを編集し、参考資料を添付 +3. **`takt run`** -- すべての pending タスクを一括実行(逐次または並列) +4. **`takt list`** -- 結果を確認し、ブランチのマージ、失敗のリトライ、指示の追加 + +各タスクは隔離された共有クローン(オプション)で実行され、レポートを生成し、`takt list` でマージまたは破棄できるブランチを作成します。 + +## タスクの追加(`takt add`) + +`takt add` を使用して `.takt/tasks.yaml` に新しいタスクエントリを作成します。 + +```bash +# インラインテキストでタスクを追加 +takt add "Implement user authentication" + +# GitHub Issue からタスクを追加 +takt add #28 +``` + +タスク追加時に次の項目を確認されます。 + +- **Piece** -- 実行に使用する piece(ワークフロー) +- **Worktree パス** -- 隔離クローンの作成場所(Enter で自動、またはパスを指定) +- **ブランチ名** -- カスタムブランチ名(Enter で `takt/{timestamp}-{slug}` が自動生成) +- **Auto-PR** -- 実行成功後に PR を自動作成するかどうか + +### GitHub Issue 連携 + +Issue 参照(例: `#28`)を渡すと、TAKT は GitHub CLI(`gh`)を介して Issue のタイトル、本文、ラベル、コメントを取得し、タスク内容として使用します。Issue 番号は `tasks.yaml` に記録され、ブランチ名にも反映されます。 + +**要件:** [GitHub CLI](https://cli.github.com/)(`gh`)がインストールされ、認証済みである必要があります。 + +### インタラクティブモードからのタスク保存 + +インタラクティブモードからもタスクを保存できます。会話で要件を精緻化した後、`/save`(またはプロンプト時の save アクション)を使用して、即座に実行する代わりに `tasks.yaml` にタスクを永続化できます。 + +## タスクディレクトリ形式 + +TAKT はタスクのメタデータを `.takt/tasks.yaml` に、各タスクの詳細仕様を `.takt/tasks/{slug}/` に保存します。 + +### `tasks.yaml` スキーマ + +```yaml +tasks: + - name: add-auth-feature + status: pending + task_dir: .takt/tasks/20260201-015714-foptng + piece: default + created_at: "2026-02-01T01:57:14.000Z" + started_at: null + completed_at: null +``` + +フィールドの説明は次の通りです。 + +| フィールド | 説明 | +|-----------|------| +| `name` | AI が生成したタスクスラグ | +| `status` | `pending`、`running`、`completed`、または `failed` | +| `task_dir` | `order.md` を含むタスクディレクトリのパス | +| `piece` | 実行に使用する piece 名 | +| `worktree` | `true`(自動)、パス文字列、または省略(カレントディレクトリで実行) | +| `branch` | ブランチ名(省略時は自動生成) | +| `auto_pr` | 実行後に PR を自動作成するかどうか | +| `issue` | GitHub Issue 番号(該当する場合) | +| `created_at` | ISO 8601 タイムスタンプ | +| `started_at` | ISO 8601 タイムスタンプ(実行開始時に設定) | +| `completed_at` | ISO 8601 タイムスタンプ(実行完了時に設定) | + +### タスクディレクトリのレイアウト + +```text +.takt/ + tasks/ + 20260201-015714-foptng/ + order.md # タスク仕様(自動生成、編集可能) + schema.sql # 添付の参考資料(任意) + wireframe.png # 添付の参考資料(任意) + tasks.yaml # タスクメタデータレコード + runs/ + 20260201-015714-foptng/ + reports/ # 実行レポート(自動生成) + logs/ # NDJSON セッションログ + context/ # スナップショット(previous_responses など) + meta.json # 実行メタデータ +``` + +`takt add` は `.takt/tasks/{slug}/order.md` を自動作成し、`task_dir` への参照を `tasks.yaml` に保存します。実行前に `order.md` を自由に編集したり、タスクディレクトリに補足ファイル(SQL スキーマ、ワイヤーフレーム、API 仕様など)を追加したりできます。 + +## タスクの実行(`takt run`) + +`.takt/tasks.yaml` のすべての pending タスクを実行します。 + +```bash +takt run +``` + +`run` コマンドは pending タスクを取得して、設定された piece を通じて実行します。各タスクは次の処理を経ます。 + +1. クローン作成(`worktree` が設定されている場合) +2. クローン/プロジェクトディレクトリでの piece 実行 +3. 自動コミットとプッシュ(worktree 実行の場合) +4. 実行後フロー(`auto_pr` 設定時は PR 作成) +5. `tasks.yaml` のステータス更新(`completed` または `failed`) + +### 並列実行(Concurrency) + +デフォルトではタスクは逐次実行されます(`concurrency: 1`)。`~/.takt/config.yaml` で並列実行を設定できます。 + +```yaml +concurrency: 3 # 最大3タスクを並列実行(1-10) +task_poll_interval_ms: 500 # 新規タスクのポーリング間隔(100-5000ms) +``` + +concurrency が 1 より大きい場合、TAKT はワーカープールを使用して次のように動作します。 + +- 最大 N タスクを同時実行 +- 設定された間隔で新規タスクをポーリング +- ワーカーが空き次第、新しいタスクを取得 +- タスクごとに色分けされたプレフィックス付き出力で読みやすさを確保 +- Ctrl+C でのグレースフルシャットダウン(実行中タスクの完了を待機) + +### 中断されたタスクの復旧 + +`takt run` が中断された場合(プロセスクラッシュ、Ctrl+C など)、`running` ステータスのまま残ったタスクは次回の `takt run` または `takt watch` 起動時に自動的に `pending` に復旧されます。 + +## タスクの監視(`takt watch`) + +`.takt/tasks.yaml` を監視し、タスクが追加されると自動実行する常駐プロセスを起動します。 + +```bash +takt watch +``` + +watch コマンドの動作は次の通りです。 + +- Ctrl+C(SIGINT)まで実行を継続 +- `tasks.yaml` の新しい `pending` タスクを監視 +- タスクが現れるたびに実行 +- 起動時に中断された `running` タスクを復旧 +- 終了時に合計/成功/失敗タスク数のサマリを表示 + +これは「プロデューサー-コンシューマー」ワークフローに便利です。一方のターミナルで `takt add` でタスクを追加し、もう一方で `takt watch` がそれらを自動実行します。 + +## タスクブランチの管理(`takt list`) + +タスクブランチの一覧表示とインタラクティブな管理を行います。 + +```bash +takt list +``` + +リストビューでは、すべてのタスクがステータス別(pending、running、completed、failed)に作成日とサマリ付きで表示されます。タスクを選択すると、そのステータスに応じた操作が表示されます。 + +### 完了タスクの操作 + +| 操作 | 説明 | +|------|------| +| **View diff** | デフォルトブランチとの差分をページャで表示 | +| **Instruct** | AI との会話で追加指示を作成し、再実行 | +| **Try merge** | スカッシュマージ(コミットせずにステージング、手動レビュー用) | +| **Merge & cleanup** | スカッシュマージしてブランチを削除 | +| **Delete** | すべての変更を破棄してブランチを削除 | + +### 失敗タスクの操作 + +| 操作 | 説明 | +|------|------| +| **Retry** | 失敗コンテキスト付きのリトライ会話を開き、再実行 | +| **Delete** | 失敗したタスクレコードを削除 | + +### Pending タスクの操作 + +| 操作 | 説明 | +|------|------| +| **Delete** | `tasks.yaml` から pending タスクを削除 | + +### Instruct モード + +完了タスクで **Instruct** を選択すると、TAKT は AI とのインタラクティブな会話ループを開きます。会話には次の情報がプリロードされます。 + +- ブランチコンテキスト(デフォルトブランチとの差分統計、コミット履歴) +- 前回の実行セッションデータ(movement ログ、レポート) +- Piece 構造と movement プレビュー +- 前回の order 内容 + +どのような追加変更が必要かを議論し、AI が指示の精緻化を支援します。準備ができたら次の操作を選択できます。 + +- **Execute** -- 新しい指示でタスクを即座に再実行 +- **Save task** -- 新しい指示でタスクを `pending` として再キューイングし、後で実行 +- **Cancel** -- 破棄してリストに戻る + +### Retry モード + +失敗タスクで **Retry** を選択すると、TAKT は次の処理を行います。 + +1. 失敗の詳細を表示(失敗した movement、エラーメッセージ、最後のエージェントメッセージ) +2. Piece の選択を促す +3. どの movement から開始するかの選択を促す(デフォルトは失敗した movement) +4. 失敗コンテキスト、実行セッションデータ、piece 構造がプリロードされたリトライ会話を開く +5. AI の支援で指示を精緻化 + +リトライ会話は Instruct モードと同じ操作(実行、タスク保存、キャンセル)をサポートします。リトライのメモは複数のリトライ試行にわたってタスクレコードに蓄積されます。 + +### 非インタラクティブモード(`--non-interactive`) + +CI/CD スクリプト向けの非インタラクティブモードを使用できます。 + +```bash +# すべてのタスクをテキストで一覧表示 +takt list --non-interactive + +# すべてのタスクを JSON で一覧表示 +takt list --non-interactive --format json + +# 特定ブランチの差分統計を表示 +takt list --non-interactive --action diff --branch takt/my-branch + +# 特定ブランチをマージ +takt list --non-interactive --action merge --branch takt/my-branch + +# ブランチを削除(--yes が必要) +takt list --non-interactive --action delete --branch takt/my-branch --yes + +# Try merge(コミットせずにステージング) +takt list --non-interactive --action try --branch takt/my-branch +``` + +利用可能なアクションは `diff`、`try`、`merge`、`delete` です。 + +## タスクディレクトリワークフロー + +推奨されるエンドツーエンドのワークフローは次の通りです。 + +1. **`takt add`** -- タスクを作成。`.takt/tasks.yaml` に pending レコードが追加され、`.takt/tasks/{slug}/` に `order.md` が生成される。 +2. **`order.md` を編集** -- 生成されたファイルを開き、必要に応じて詳細な仕様、参考資料、補足ファイルを追加。 +3. **`takt run`**(または `takt watch`)-- `tasks.yaml` の pending タスクを実行。各タスクは設定された piece ワークフローを通じて実行される。 +4. **出力を確認** -- `.takt/runs/{slug}/reports/` の実行レポートを確認(slug はタスクディレクトリと一致)。 +5. **`takt list`** -- 結果を確認し、成功したブランチのマージ、失敗のリトライ、追加指示を行う。 + +## 隔離実行(共有クローン) + +タスク設定で `worktree` を指定すると、各タスクは `git clone --shared` で作成された隔離クローン内で実行され、メインの作業ディレクトリをクリーンに保ちます。 + +### 設定オプション + +| 設定 | 説明 | +|------|------| +| `worktree: true` | 隣接ディレクトリ(または `worktree_dir` 設定で指定した場所)に共有クローンを自動作成 | +| `worktree: "/path/to/dir"` | 指定パスにクローンを作成 | +| `branch: "feat/xxx"` | 指定ブランチを使用(省略時は `takt/{timestamp}-{slug}` が自動生成) | +| *(worktree を省略)* | カレントディレクトリで実行(デフォルト) | + +### 仕組み + +TAKT は `git worktree` の代わりに `git clone --shared` を使用して、独立した `.git` ディレクトリを持つ軽量クローンを作成します。これが重要な理由は次の通りです。 + +- **独立した `.git`**: 共有クローンは独自の `.git` ディレクトリを持ち、エージェントツール(Claude Code など)が `gitdir:` 参照をたどってメインリポジトリに戻ることを防ぎます。 +- **完全な隔離**: エージェントはクローンディレクトリ内でのみ作業し、メインリポジトリを認識しません。 + +> **注意**: YAML フィールド名は後方互換性のため `worktree` のままです。内部的には `git worktree` ではなく `git clone --shared` を使用しています。 + +### エフェメラルなライフサイクル + +クローンはエフェメラルなライフサイクルに従います。 + +1. **作成** -- タスク実行前にクローンを作成 +2. **実行** -- クローンディレクトリ内でタスクを実行 +3. **コミット & プッシュ** -- 成功時に変更を自動コミットしてブランチにプッシュ +4. **保持** -- 実行後もクローンを保持(instruct/retry 操作用) +5. **クリーンアップ** -- ブランチが永続的な成果物。`takt list` でマージまたは削除 + +### デュアルワーキングディレクトリ + +worktree 実行中、TAKT は2つのディレクトリ参照を管理します。 + +| ディレクトリ | 用途 | +|------------|------| +| `cwd`(クローンパス) | エージェントの実行場所、レポートの書き込み先 | +| `projectCwd`(プロジェクトルート) | ログとセッションデータの保存先 | + +レポートは `cwd/.takt/runs/{slug}/reports/`(クローン内)に書き込まれ、エージェントがメインリポジトリのパスを発見することを防ぎます。`cwd !== projectCwd` の場合、クロスディレクトリ汚染を避けるためセッション再開はスキップされます。 + +## セッションログ + +TAKT は NDJSON(改行区切り JSON、`.jsonl`)形式でセッションログを書き込みます。各レコードはアトミックに追加されるため、プロセスがクラッシュしても部分的なログは保存されます。 + +### ログの場所 + +```text +.takt/runs/{slug}/ + logs/{sessionId}.jsonl # piece 実行ごとの NDJSON セッションログ + meta.json # 実行メタデータ(タスク、piece、開始/終了、ステータスなど) + context/ + previous_responses/ + latest.md # 最新の previous response(自動継承) +``` + +### レコードタイプ + +| レコードタイプ | 説明 | +|--------------|------| +| `piece_start` | タスクと piece 名による piece の初期化 | +| `step_start` | Movement の実行開始 | +| `step_complete` | ステータス、内容、マッチしたルール情報を含む movement 結果 | +| `piece_complete` | Piece の正常完了 | +| `piece_abort` | 理由を伴う中断 | + +### リアルタイム監視 + +実行中にログをリアルタイムで監視できます。 + +```bash +tail -f .takt/runs/{slug}/logs/{sessionId}.jsonl +``` diff --git a/docs/task-management.md b/docs/task-management.md new file mode 100644 index 0000000..d826aeb --- /dev/null +++ b/docs/task-management.md @@ -0,0 +1,323 @@ +[日本語](./task-management.ja.md) + +# Task Management + +## Overview + +TAKT provides a task management workflow for accumulating multiple tasks and executing them in batch. The basic flow is: + +1. **`takt add`** -- Refine task requirements through AI conversation and save to `.takt/tasks.yaml` +2. **Tasks accumulate** -- Edit `order.md` files, attach reference materials +3. **`takt run`** -- Execute all pending tasks at once (sequential or parallel) +4. **`takt list`** -- Review results, merge branches, retry failures, or add instructions + +Each task executes in an isolated shared clone (optional), produces reports, and creates a branch that can be merged or discarded via `takt list`. + +## Adding Tasks (`takt add`) + +Use `takt add` to create a new task entry in `.takt/tasks.yaml`. + +```bash +# Add a task with inline text +takt add "Implement user authentication" + +# Add a task from a GitHub Issue +takt add #28 +``` + +When adding a task, you are prompted for: + +- **Piece** -- Which piece (workflow) to use for execution +- **Worktree path** -- Where to create the isolated clone (Enter for auto, or specify a path) +- **Branch name** -- Custom branch name (Enter for auto-generated `takt/{timestamp}-{slug}`) +- **Auto-PR** -- Whether to automatically create a pull request after successful execution + +### GitHub Issue Integration + +When you pass an issue reference (e.g., `#28`), TAKT fetches the issue title, body, labels, and comments via the GitHub CLI (`gh`) and uses them as the task content. The issue number is recorded in `tasks.yaml` and reflected in the branch name. + +**Requirement:** [GitHub CLI](https://cli.github.com/) (`gh`) must be installed and authenticated. + +### Saving Tasks from Interactive Mode + +You can also save tasks from interactive mode. After refining requirements through conversation, use `/save` (or the save action when prompted) to persist the task to `tasks.yaml` instead of executing immediately. + +## Task Directory Format + +TAKT stores task metadata in `.takt/tasks.yaml` and each task's detailed specification in `.takt/tasks/{slug}/`. + +### `tasks.yaml` Schema + +```yaml +tasks: + - name: add-auth-feature + status: pending + task_dir: .takt/tasks/20260201-015714-foptng + piece: default + created_at: "2026-02-01T01:57:14.000Z" + started_at: null + completed_at: null +``` + +Fields: + +| Field | Description | +|-------|-------------| +| `name` | AI-generated task slug | +| `status` | `pending`, `running`, `completed`, or `failed` | +| `task_dir` | Path to the task directory containing `order.md` | +| `piece` | Piece name to use for execution | +| `worktree` | `true` (auto), a path string, or omitted (run in current directory) | +| `branch` | Branch name (auto-generated if omitted) | +| `auto_pr` | Whether to auto-create a PR after execution | +| `issue` | GitHub Issue number (if applicable) | +| `created_at` | ISO 8601 timestamp | +| `started_at` | ISO 8601 timestamp (set when execution begins) | +| `completed_at` | ISO 8601 timestamp (set when execution finishes) | + +### Task Directory Layout + +```text +.takt/ + tasks/ + 20260201-015714-foptng/ + order.md # Task specification (auto-generated, editable) + schema.sql # Attached reference materials (optional) + wireframe.png # Attached reference materials (optional) + tasks.yaml # Task metadata records + runs/ + 20260201-015714-foptng/ + reports/ # Execution reports (auto-generated) + logs/ # NDJSON session logs + context/ # Snapshots (previous_responses, etc.) + meta.json # Run metadata +``` + +`takt add` creates `.takt/tasks/{slug}/order.md` automatically and saves the `task_dir` reference to `tasks.yaml`. You can freely edit `order.md` and add supplementary files (SQL schemas, wireframes, API specs, etc.) to the task directory before execution. + +## Executing Tasks (`takt run`) + +Execute all pending tasks from `.takt/tasks.yaml`: + +```bash +takt run +``` + +The `run` command claims pending tasks and executes them through the configured piece. Each task goes through: + +1. Clone creation (if `worktree` is set) +2. Piece execution in the clone/project directory +3. Auto-commit and push (if worktree execution) +4. Post-execution flow (PR creation if `auto_pr` is set) +5. Status update in `tasks.yaml` (`completed` or `failed`) + +### Parallel Execution (Concurrency) + +By default, tasks run sequentially (`concurrency: 1`). Configure parallel execution in `~/.takt/config.yaml`: + +```yaml +concurrency: 3 # Run up to 3 tasks in parallel (1-10) +task_poll_interval_ms: 500 # Polling interval for new tasks (100-5000ms) +``` + +When concurrency is greater than 1, TAKT uses a worker pool that: + +- Runs up to N tasks simultaneously +- Polls for newly added tasks at the configured interval +- Picks up new tasks as workers become available +- Displays color-coded prefixed output per task for readability +- Supports graceful shutdown on Ctrl+C (waits for in-flight tasks to complete) + +### Interrupted Task Recovery + +If `takt run` is interrupted (e.g., process crash, Ctrl+C), tasks left in `running` status are automatically recovered to `pending` on the next `takt run` or `takt watch` invocation. + +## Watching Tasks (`takt watch`) + +Run a resident process that monitors `.takt/tasks.yaml` and auto-executes tasks as they appear: + +```bash +takt watch +``` + +The watch command: + +- Stays running until Ctrl+C (SIGINT) +- Monitors `tasks.yaml` for new `pending` tasks +- Executes each task as it appears +- Recovers interrupted `running` tasks on startup +- Displays a summary of total/success/failed tasks on exit + +This is useful for a "producer-consumer" workflow where you add tasks with `takt add` in one terminal and let `takt watch` execute them automatically in another. + +## Managing Task Branches (`takt list`) + +List and manage task branches interactively: + +```bash +takt list +``` + +The list view shows all tasks organized by status (pending, running, completed, failed) with creation dates and summaries. Selecting a task shows available actions depending on its status. + +### Actions for Completed Tasks + +| Action | Description | +|--------|-------------| +| **View diff** | Show full diff against the default branch in a pager | +| **Instruct** | Open an AI conversation to craft additional instructions, then re-execute | +| **Try merge** | Squash merge (stages changes without committing, for manual review) | +| **Merge & cleanup** | Squash merge and delete the branch | +| **Delete** | Discard all changes and delete the branch | + +### Actions for Failed Tasks + +| Action | Description | +|--------|-------------| +| **Retry** | Open a retry conversation with failure context, then re-execute | +| **Delete** | Remove the failed task record | + +### Actions for Pending Tasks + +| Action | Description | +|--------|-------------| +| **Delete** | Remove the pending task from `tasks.yaml` | + +### Instruct Mode + +When you select **Instruct** on a completed task, TAKT opens an interactive conversation loop with the AI. The conversation is pre-loaded with: + +- Branch context (diff stat against default branch, commit history) +- Previous run session data (movement logs, reports) +- Piece structure and movement previews +- Previous order content + +You can discuss what additional changes are needed, and the AI helps refine the instructions. When ready, choose: + +- **Execute** -- Re-execute the task immediately with the new instructions +- **Save task** -- Requeue the task as `pending` with the new instructions for later execution +- **Cancel** -- Discard and return to the list + +### Retry Mode + +When you select **Retry** on a failed task, TAKT: + +1. Displays failure details (failed movement, error message, last agent message) +2. Prompts you to select a piece +3. Prompts you to select which movement to start from (defaults to the failed movement) +4. Opens a retry conversation pre-loaded with failure context, run session data, and piece structure +5. Lets you refine instructions with AI assistance + +The retry conversation supports the same actions as Instruct mode (execute, save task, cancel). Retry notes are appended to the task record, accumulating across multiple retry attempts. + +### Non-Interactive Mode (`--non-interactive`) + +For CI/CD scripts, use non-interactive mode: + +```bash +# List all tasks as text +takt list --non-interactive + +# List all tasks as JSON +takt list --non-interactive --format json + +# Show diff stat for a specific branch +takt list --non-interactive --action diff --branch takt/my-branch + +# Merge a specific branch +takt list --non-interactive --action merge --branch takt/my-branch + +# Delete a branch (requires --yes) +takt list --non-interactive --action delete --branch takt/my-branch --yes + +# Try merge (stage without commit) +takt list --non-interactive --action try --branch takt/my-branch +``` + +Available actions: `diff`, `try`, `merge`, `delete`. + +## Task Directory Workflow + +The recommended end-to-end workflow: + +1. **`takt add`** -- Create a task. A pending record is added to `.takt/tasks.yaml` and `order.md` is generated in `.takt/tasks/{slug}/`. +2. **Edit `order.md`** -- Open the generated file and add detailed specifications, reference materials, or supplementary files as needed. +3. **`takt run`** (or `takt watch`) -- Execute pending tasks from `tasks.yaml`. Each task runs through the configured piece workflow. +4. **Verify outputs** -- Check execution reports in `.takt/runs/{slug}/reports/` (the slug matches the task directory). +5. **`takt list`** -- Review results, merge successful branches, retry failures, or add further instructions. + +## Isolated Execution (Shared Clone) + +Specifying `worktree` in task configuration executes each task in an isolated clone created with `git clone --shared`, keeping your main working directory clean. + +### Configuration Options + +| Setting | Description | +|---------|-------------| +| `worktree: true` | Auto-create shared clone in adjacent directory (or location specified by `worktree_dir` config) | +| `worktree: "/path/to/dir"` | Create clone at the specified path | +| `branch: "feat/xxx"` | Use specified branch (auto-generated as `takt/{timestamp}-{slug}` if omitted) | +| *(omit `worktree`)* | Execute in current directory (default) | + +### How It Works + +TAKT uses `git clone --shared` instead of `git worktree` to create lightweight clones with an independent `.git` directory. This is important because: + +- **Independent `.git`**: Shared clones have their own `.git` directory, preventing agent tools (like Claude Code) from traversing `gitdir:` references back to the main repository. +- **Full isolation**: Agents work entirely within the clone directory, unaware of the main repository. + +> **Note**: The YAML field name remains `worktree` for backward compatibility. Internally, it uses `git clone --shared` instead of `git worktree`. + +### Ephemeral Lifecycle + +Clones follow an ephemeral lifecycle: + +1. **Create** -- Clone is created before task execution +2. **Execute** -- Task runs inside the clone directory +3. **Commit & Push** -- On success, changes are auto-committed and pushed to the branch +4. **Preserve** -- Clone is preserved after execution (for instruct/retry operations) +5. **Cleanup** -- Branches are the persistent artifacts; use `takt list` to merge or delete + +### Dual Working Directory + +During worktree execution, TAKT maintains two directory references: + +| Directory | Purpose | +|-----------|---------| +| `cwd` (clone path) | Where agents run, where reports are written | +| `projectCwd` (project root) | Where logs and session data are stored | + +Reports are written to `cwd/.takt/runs/{slug}/reports/` (inside the clone) to prevent agents from discovering the main repository path. Session resume is skipped when `cwd !== projectCwd` to avoid cross-directory contamination. + +## Session Logs + +TAKT writes session logs in NDJSON (Newline-Delimited JSON, `.jsonl`) format. Each record is atomically appended, so partial logs are preserved even if the process crashes. + +### Log Location + +```text +.takt/runs/{slug}/ + logs/{sessionId}.jsonl # NDJSON session log per piece execution + meta.json # Run metadata (task, piece, start/end, status, etc.) + context/ + previous_responses/ + latest.md # Latest previous response (inherited automatically) +``` + +### Record Types + +| Record Type | Description | +|-------------|-------------| +| `piece_start` | Piece initialization with task and piece name | +| `step_start` | Movement execution start | +| `step_complete` | Movement result with status, content, matched rule info | +| `piece_complete` | Successful piece completion | +| `piece_abort` | Abort with reason | + +### Real-Time Monitoring + +You can monitor logs in real-time during execution: + +```bash +tail -f .takt/runs/{slug}/logs/{sessionId}.jsonl +``` diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index 01ed20c..af12867 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -103,6 +103,20 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - `.takt/tasks.yaml` に pending タスクを追加する(`piece` に `e2e/fixtures/pieces/mock-single-step.yaml` を指定)。 - 出力に `Task "watch-task" completed` が含まれることを確認する。 - `Ctrl+C` で終了する。 +- Run recovery and high-priority run flows(`e2e/specs/run-recovery.e2e.ts`) + - 目的: 高優先度ユースケース(異常終了リカバリー、並列実行、初期化〜add〜run)をまとめて確認。 + - LLM: 呼び出さない(`--provider mock` 固定) + - 手順(ユーザー行動/コマンド): + - 異常終了リカバリー: + - `.takt/tasks.yaml` に pending タスク2件を投入し、`takt run --provider mock` 実行中にプロセスを強制終了する。 + - 再度 `takt run --provider mock` を実行し、`Recovered 1 interrupted running task(s) to pending.` が出力されることを確認する。 + - 復旧対象を含む全タスクが完了し、`.takt/tasks.yaml` が空になることを確認する。 + - 高並列実行: + - `concurrency: 10` を設定し、pending タスク12件を投入して `takt run --provider mock` を実行する。 + - 出力に `Concurrency: 10` と `Tasks Summary` が含まれること、および `.takt/tasks.yaml` が空になることを確認する。 + - 初期化〜add〜run: + - グローバル `config.yaml` 不在の環境で `takt add` を2回実行し、`takt run --provider mock` を実行する。 + - タスク実行完了後に `.takt/tasks/` 配下の2タスクディレクトリ生成、`.takt/.gitignore` 生成、`.takt/tasks.yaml` の空状態を確認する。 - Run tasks graceful shutdown on SIGINT(`e2e/specs/run-sigint-graceful.e2e.ts`) - 目的: `takt run` を並列実行中に `Ctrl+C` した際、新規クローン投入を止めてグレースフルに終了することを確認。 - LLM: 呼び出さない(`--provider mock` 固定) @@ -130,14 +144,6 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - `takt list --non-interactive --action diff --branch ` で差分統計が出力されることを確認する。 - `takt list --non-interactive --action try --branch ` で変更がステージされることを確認する。 - `takt list --non-interactive --action merge --branch ` でブランチがマージされ削除されることを確認する。 -- Config permission mode(`e2e/specs/cli-config.e2e.ts`) - - 目的: `takt config` でパーミッションモードの切り替えと永続化を確認。 - - LLM: 呼び出さない(LLM不使用の操作のみ) - - 手順(ユーザー行動/コマンド): - - `takt config default` を実行し、`Switched to: default` が出力されることを確認する。 - - `takt config sacrifice-my-pc` を実行し、`Switched to: sacrifice-my-pc` が出力されることを確認する。 - - `takt config sacrifice-my-pc` 実行後、`.takt/config.yaml` に `permissionMode: sacrifice-my-pc` が保存されていることを確認する。 - - `takt config invalid-mode` を実行し、`Invalid mode` が出力されることを確認する。 - Reset categories(`e2e/specs/cli-reset-categories.e2e.ts`) - 目的: `takt reset categories` でカテゴリオーバーレイのリセットを確認。 - LLM: 呼び出さない(LLM不使用の操作のみ) @@ -145,6 +151,15 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - `takt reset categories` を実行する。 - 出力に `reset` を含むことを確認する。 - `$TAKT_CONFIG_DIR/preferences/piece-categories.yaml` が存在し `piece_categories: {}` を含むことを確認する。 +- Reset config(`e2e/specs/cli-reset-config.e2e.ts`) + - 目的: `takt reset config` でグローバル設定をテンプレートへ戻し、旧設定をバックアップすることを確認。 + - LLM: 呼び出さない(LLM不使用の操作のみ) + - 手順(ユーザー行動/コマンド): + - `$TAKT_CONFIG_DIR/config.yaml` に任意の設定を書き込む(例: `language: ja`, `provider: mock`)。 + - `takt reset config` を実行する。 + - 出力に `reset` と `backup:` を含むことを確認する。 + - `$TAKT_CONFIG_DIR/config.yaml` がテンプレート内容(例: `branch_name_strategy: ai`, `concurrency: 2`)に置き換わっていることを確認する。 + - `$TAKT_CONFIG_DIR/` 直下に `config.yaml.YYYYMMDD-HHmmss.old` 形式のバックアップファイルが1件作成されることを確認する。 - Export Claude Code Skill(`e2e/specs/cli-export-cc.e2e.ts`) - 目的: `takt export-cc` でClaude Code Skillのデプロイを確認。 - LLM: 呼び出さない(LLM不使用の操作のみ) @@ -154,3 +169,53 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - 出力に `ファイルをデプロイしました` を含むことを確認する。 - `$HOME/.claude/skills/takt/SKILL.md` が存在することを確認する。 - `$HOME/.claude/skills/takt/pieces/` および `$HOME/.claude/skills/takt/personas/` ディレクトリが存在し、それぞれ少なくとも1ファイルを含むことを確認する。 + +## 追記シナリオ(2026-02-19) +過去にドキュメント未反映だったシナリオを以下に追記する。 + +- Config priority(`e2e/specs/config-priority.e2e.ts`) + - 目的: `piece` と `auto_pr` の優先順位(config/env/CLI)を検証。 + - 手順(要約): + - `--pipeline` で `--piece` 未指定時に設定値の `piece` が使われることを確認。 + - `auto_pr` 未設定時は確認デフォルト `true` が反映されることを確認。 + - `config` と `TAKT_AUTO_PR` の優先を確認。 +- Pipeline --skip-git on local/non-git directories(`e2e/specs/pipeline-local-repo.e2e.ts`) + - 目的: ローカルGitリポジトリおよび非Gitディレクトリで `--pipeline --skip-git` が動作することを確認。 +- Task content_file reference(`e2e/specs/task-content-file.e2e.ts`) + - 目的: `tasks.yaml` の `content_file` 参照が解決されること、および不正参照時エラーを確認。 +- Task status persistence(`e2e/specs/task-status-persistence.e2e.ts`) + - 目的: 成功時/失敗時の `tasks.yaml` 状態遷移(完了消込・失敗記録)を確認。 +- Run multiple tasks(`e2e/specs/run-multiple-tasks.e2e.ts`) + - 目的: 複数pendingタスクの連続実行、途中失敗時継続、タスク空時の終了挙動を確認。 +- Session NDJSON log output(`e2e/specs/session-log.e2e.ts`) + - 目的: NDJSONログの主要イベント(`piece_complete` / `piece_abort` 等)出力を確認。 +- Structured output rule matching(`e2e/specs/structured-output.e2e.ts`) + - 目的: structured output によるルール判定(Phase 3)を確認。 +- Piece error handling(`e2e/specs/piece-error-handling.e2e.ts`) + - 目的: エージェントエラー、最大反復到達、前回応答受け渡しの挙動を確認。 +- Multi-step with parallel movements(`e2e/specs/multi-step-parallel.e2e.ts`) + - 目的: 並列ムーブメントを含む複数ステップ遷移を確認。 +- Sequential multi-step session log transitions(`e2e/specs/multi-step-sequential.e2e.ts`) + - 目的: 逐次ステップでのセッションログ遷移を確認。 +- Cycle detection via loop_monitors(`e2e/specs/cycle-detection.e2e.ts`) + - 目的: ループ監視設定による abort/continue の境界を確認。 +- Provider error handling(`e2e/specs/provider-error.e2e.ts`) + - 目的: provider上書き、mockシナリオ不足時の挙動、シナリオ不在時エラーを確認。 +- Model override(`e2e/specs/model-override.e2e.ts`) + - 目的: `--model` オプションが通常実行/`--pipeline --skip-git` で反映されることを確認。 +- Error handling edge cases(`e2e/specs/error-handling.e2e.ts`) + - 目的: 不正引数・存在しないpiece・不正YAMLなど代表エラーケースを確認。 +- Quiet mode(`e2e/specs/quiet-mode.e2e.ts`) + - 目的: `--quiet` でAIストリーム出力が抑制されることを確認。 +- Catalog command(`e2e/specs/cli-catalog.e2e.ts`) + - 目的: `takt catalog` の一覧表示・型指定・不正型エラーを確認。 +- Prompt preview command(`e2e/specs/cli-prompt.e2e.ts`) + - 目的: `takt prompt` のプレビュー出力と不正piece時エラーを確認。 +- Switch piece command(`e2e/specs/cli-switch.e2e.ts`) + - 目的: `takt switch` の切替成功・不正piece時エラーを確認。 +- Clear sessions command(`e2e/specs/cli-clear.e2e.ts`) + - 目的: `takt clear` でセッション情報が削除されることを確認。 +- Help command(`e2e/specs/cli-help.e2e.ts`) + - 目的: `takt --help` と `takt run --help` の表示内容を確認。 +- Eject builtin pieces(`e2e/specs/eject.e2e.ts`) + - 目的: `takt eject` のproject/global出力、既存時スキップ、facet個別ejectを確認。 diff --git a/e2e/fixtures/config.e2e.yaml b/e2e/fixtures/config.e2e.yaml index fca15ce..4cbdd6e 100644 --- a/e2e/fixtures/config.e2e.yaml +++ b/e2e/fixtures/config.e2e.yaml @@ -1,7 +1,6 @@ provider: claude language: en log_level: info -default_piece: default notification_sound: false notification_sound_events: iteration_limit: false diff --git a/e2e/specs/cli-config.e2e.ts b/e2e/specs/cli-config.e2e.ts deleted file mode 100644 index 19a6433..0000000 --- a/e2e/specs/cli-config.e2e.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; -import { runTakt } from '../helpers/takt-runner'; -import { createLocalRepo, type LocalRepo } from '../helpers/test-repo'; - -// E2E更新時は docs/testing/e2e.md も更新すること -describe('E2E: Config command (takt config)', () => { - let isolatedEnv: IsolatedEnv; - let repo: LocalRepo; - - beforeEach(() => { - isolatedEnv = createIsolatedEnv(); - repo = createLocalRepo(); - }); - - afterEach(() => { - try { repo.cleanup(); } catch { /* best-effort */ } - try { isolatedEnv.cleanup(); } catch { /* best-effort */ } - }); - - it('should switch to default mode with explicit argument', () => { - // Given: a local repo with isolated env - - // When: running takt config default - const result = runTakt({ - args: ['config', 'default'], - cwd: repo.path, - env: isolatedEnv.env, - }); - - // Then: exits successfully and outputs switched message - expect(result.exitCode).toBe(0); - const output = result.stdout; - expect(output).toMatch(/Switched to: default/); - }); - - it('should switch to sacrifice-my-pc mode with explicit argument', () => { - // Given: a local repo with isolated env - - // When: running takt config sacrifice-my-pc - const result = runTakt({ - args: ['config', 'sacrifice-my-pc'], - cwd: repo.path, - env: isolatedEnv.env, - }); - - // Then: exits successfully and outputs switched message - expect(result.exitCode).toBe(0); - const output = result.stdout; - expect(output).toMatch(/Switched to: sacrifice-my-pc/); - }); - - it('should persist permission mode to project config', () => { - // Given: a local repo with isolated env - - // When: running takt config sacrifice-my-pc - runTakt({ - args: ['config', 'sacrifice-my-pc'], - cwd: repo.path, - env: isolatedEnv.env, - }); - - // Then: .takt/config.yaml contains permissionMode: sacrifice-my-pc - const configPath = join(repo.path, '.takt', 'config.yaml'); - const content = readFileSync(configPath, 'utf-8'); - expect(content).toMatch(/permissionMode:\s*sacrifice-my-pc/); - }); - - it('should report error for invalid mode name', () => { - // Given: a local repo with isolated env - - // When: running takt config with an invalid mode - const result = runTakt({ - args: ['config', 'invalid-mode'], - cwd: repo.path, - env: isolatedEnv.env, - }); - - // Then: output contains invalid mode message - const combined = result.stdout + result.stderr; - expect(combined).toMatch(/Invalid mode/); - }); -}); diff --git a/e2e/specs/cli-reset-config.e2e.ts b/e2e/specs/cli-reset-config.e2e.ts new file mode 100644 index 0000000..e7f7807 --- /dev/null +++ b/e2e/specs/cli-reset-config.e2e.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; +import { createLocalRepo, type LocalRepo } from '../helpers/test-repo'; + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Reset config command (takt reset config)', () => { + let isolatedEnv: IsolatedEnv; + let repo: LocalRepo; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should backup current config and replace with builtin template', () => { + const configPath = join(isolatedEnv.taktDir, 'config.yaml'); + writeFileSync(configPath, ['language: ja', 'provider: mock'].join('\n'), 'utf-8'); + + const result = runTakt({ + args: ['reset', 'config'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + const output = result.stdout; + expect(output).toMatch(/reset/i); + expect(output).toMatch(/backup:/i); + + const config = readFileSync(configPath, 'utf-8'); + expect(config).toContain('language: ja'); + expect(config).toContain('branch_name_strategy: ai'); + expect(config).toContain('concurrency: 2'); + + const backups = readdirSync(isolatedEnv.taktDir).filter((name) => + /^config\.yaml\.\d{8}-\d{6}\.old(\.\d+)?$/.test(name), + ); + expect(backups.length).toBe(1); + }); +}); diff --git a/e2e/specs/config-priority.e2e.ts b/e2e/specs/config-priority.e2e.ts new file mode 100644 index 0000000..a74ded6 --- /dev/null +++ b/e2e/specs/config-priority.e2e.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { parse as parseYaml } from 'yaml'; +import { createIsolatedEnv, updateIsolatedConfig, type IsolatedEnv } from '../helpers/isolated-env'; +import { createTestRepo, type TestRepo } from '../helpers/test-repo'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function readFirstTask(repoPath: string): Record { + const tasksPath = join(repoPath, '.takt', 'tasks.yaml'); + const raw = readFileSync(tasksPath, 'utf-8'); + const parsed = parseYaml(raw) as { tasks?: Array> } | null; + const first = parsed?.tasks?.[0]; + if (!first) { + throw new Error(`No task record found in ${tasksPath}`); + } + return first; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Config priority (piece / autoPr)', () => { + let isolatedEnv: IsolatedEnv; + let testRepo: TestRepo; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + testRepo = createTestRepo(); + }); + + afterEach(() => { + try { + testRepo.cleanup(); + } catch { + // best-effort + } + try { + isolatedEnv.cleanup(); + } catch { + // best-effort + } + }); + + it('should use configured piece in pipeline when --piece is omitted', () => { + const configuredPiecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + const projectConfigDir = join(testRepo.path, '.takt'); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync( + join(projectConfigDir, 'config.yaml'), + `piece: ${JSON.stringify(configuredPiecePath)}\n`, + 'utf-8', + ); + + const result = runTakt({ + args: [ + '--pipeline', + '--task', 'Pipeline run should resolve piece from config', + '--skip-git', + '--provider', 'mock', + ], + cwd: testRepo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`Running piece: ${configuredPiecePath}`); + expect(result.stdout).toContain(`Piece '${configuredPiecePath}' completed`); + }, 240_000); + + it('should default auto_pr to true when unset in config/env', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + + const result = runTakt({ + args: [ + '--task', 'Auto PR default behavior', + '--piece', piecePath, + '--create-worktree', 'yes', + '--provider', 'mock', + ], + cwd: testRepo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + const task = readFirstTask(testRepo.path); + expect(task['auto_pr']).toBe(true); + }, 240_000); + + it('should use auto_pr from config when set', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + updateIsolatedConfig(isolatedEnv.taktDir, { auto_pr: false }); + + const result = runTakt({ + args: [ + '--task', 'Auto PR from config', + '--piece', piecePath, + '--create-worktree', 'yes', + '--provider', 'mock', + ], + cwd: testRepo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + const task = readFirstTask(testRepo.path); + expect(task['auto_pr']).toBe(false); + }, 240_000); + + it('should prioritize env auto_pr over config', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + updateIsolatedConfig(isolatedEnv.taktDir, { auto_pr: false }); + + const result = runTakt({ + args: [ + '--task', 'Auto PR from env override', + '--piece', piecePath, + '--create-worktree', 'yes', + '--provider', 'mock', + ], + cwd: testRepo.path, + env: { + ...isolatedEnv.env, + TAKT_AUTO_PR: 'true', + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + const task = readFirstTask(testRepo.path); + expect(task['auto_pr']).toBe(true); + }, 240_000); +}); diff --git a/e2e/specs/run-recovery.e2e.ts b/e2e/specs/run-recovery.e2e.ts new file mode 100644 index 0000000..aced013 --- /dev/null +++ b/e2e/specs/run-recovery.e2e.ts @@ -0,0 +1,325 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, execFileSync } from 'node:child_process'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + rmSync, + existsSync, + readdirSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +interface LocalRepo { + path: string; + cleanup: () => void; +} + +interface TaskRecord { + name: string; + status: 'pending' | 'running' | 'failed' | 'completed'; + owner_pid?: number | null; + piece?: string; +} + +function createLocalRepo(): LocalRepo { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-run-recovery-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + rmSync(repoPath, { recursive: true, force: true }); + }, + }; +} + +function readTasks(tasksFile: string): TaskRecord[] { + const raw = readFileSync(tasksFile, 'utf-8'); + const parsed = parseYaml(raw) as { tasks?: TaskRecord[] }; + return parsed.tasks ?? []; +} + +function waitFor( + predicate: () => boolean, + timeoutMs: number, + intervalMs: number, +): Promise { + return new Promise((resolvePromise) => { + const startedAt = Date.now(); + const timer = setInterval(() => { + if (predicate()) { + clearInterval(timer); + resolvePromise(true); + return; + } + if (Date.now() - startedAt >= timeoutMs) { + clearInterval(timer); + resolvePromise(false); + } + }, intervalMs); + }); +} + +function createPendingTasksYaml( + count: number, + piecePath: string, + prefix: string, +): string { + const now = new Date().toISOString(); + const tasks = Array.from({ length: count }, (_, index) => ({ + name: `${prefix}-${String(index + 1)}`, + status: 'pending' as const, + content: `${prefix} task ${String(index + 1)}`, + piece: piecePath, + created_at: now, + started_at: null, + completed_at: null, + owner_pid: null, + })); + return stringifyYaml({ tasks }); +} + +function createEnvWithoutGlobalConfig(): { + env: NodeJS.ProcessEnv; + cleanup: () => void; + globalConfigPath: string; +} { + const baseDir = mkdtempSync(join(tmpdir(), 'takt-e2e-init-flow-')); + const globalConfigDir = join(baseDir, '.takt-global'); + const globalGitConfigPath = join(baseDir, '.gitconfig'); + const globalConfigPath = join(globalConfigDir, 'config.yaml'); + + writeFileSync( + globalGitConfigPath, + ['[user]', ' name = TAKT E2E Test', ' email = e2e@example.com'].join('\n'), + ); + + return { + env: { + ...process.env, + TAKT_CONFIG_DIR: globalConfigDir, + GIT_CONFIG_GLOBAL: globalGitConfigPath, + TAKT_NO_TTY: '1', + }, + globalConfigPath, + cleanup: () => { + rmSync(baseDir, { recursive: true, force: true }); + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Run interrupted task recovery and high-priority run flows', () => { + let isolatedEnv: IsolatedEnv; + let repo: LocalRepo; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + repo.cleanup(); + isolatedEnv.cleanup(); + }); + + it('should recover stale running task generated by forced process termination', async () => { + // Given: 2 pending tasks exist, then first run is force-killed while task is running + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + model: 'mock-model', + concurrency: 1, + task_poll_interval_ms: 50, + }); + + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-slow-multi-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/run-sigint-parallel.json'); + const tasksFile = join(repo.path, '.takt', 'tasks.yaml'); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + writeFileSync(tasksFile, createPendingTasksYaml(2, piecePath, 'recovery-target'), 'utf-8'); + + const binPath = resolve(__dirname, '../../bin/takt'); + const child = spawn('node', [binPath, 'run', '--provider', 'mock'], { + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let firstStdout = ''; + let firstStderr = ''; + child.stdout?.on('data', (chunk) => { + firstStdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk) => { + firstStderr += chunk.toString(); + }); + + const runningObserved = await waitFor(() => { + if (!existsSync(tasksFile)) { + return false; + } + const tasks = readTasks(tasksFile); + return tasks.some((task) => task.status === 'running'); + }, 30_000, 20); + + expect(runningObserved, `stdout:\n${firstStdout}\n\nstderr:\n${firstStderr}`).toBe(true); + + child.kill('SIGKILL'); + + await new Promise((resolvePromise) => { + child.once('close', () => { + resolvePromise(); + }); + }); + + const staleTasks = readTasks(tasksFile); + const runningTask = staleTasks.find((task) => task.status === 'running'); + expect(runningTask).toBeDefined(); + expect(runningTask?.owner_pid).toBeTypeOf('number'); + + // When: run is executed again + const rerunResult = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: stale running task is recovered and all tasks complete + expect(rerunResult.exitCode).toBe(0); + const combined = rerunResult.stdout + rerunResult.stderr; + expect(combined).toContain('Recovered 1 interrupted running task(s) to pending.'); + expect(combined).toContain('recovery-target-1'); + expect(combined).toContain('recovery-target-2'); + + const finalTasks = readTasks(tasksFile); + expect(finalTasks).toEqual([]); + }, 240_000); + + it('should process high-concurrency batch without leaving inconsistent task state', () => { + // Given: 12 pending tasks with concurrency=10 + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + model: 'mock-model', + concurrency: 10, + task_poll_interval_ms: 50, + }); + + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + const tasksFile = join(repo.path, '.takt', 'tasks.yaml'); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + writeFileSync(tasksFile, createPendingTasksYaml(12, piecePath, 'parallel-load'), 'utf-8'); + + // When: run all tasks + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: all tasks complete and queue becomes empty + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Concurrency: 10'); + expect(result.stdout).toContain('Tasks Summary'); + const finalTasks = readTasks(tasksFile); + expect(finalTasks).toEqual([]); + }, 240_000); + + it('should initialize project dirs and execute tasks after add+run when global config is absent', () => { + const envWithoutConfig = createEnvWithoutGlobalConfig(); + + try { + // Given: global config.yaml is absent and project config points to a mock piece path + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + const projectConfigDir = join(repo.path, '.takt'); + const projectConfigPath = join(projectConfigDir, 'config.yaml'); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(projectConfigPath, `piece: ${piecePath}\npermissionMode: default\n`, 'utf-8'); + + expect(existsSync(envWithoutConfig.globalConfigPath)).toBe(false); + + // When: add 2 tasks and run once + const addResult1 = runTakt({ + args: ['--provider', 'mock', 'add', 'Initialize flow task 1'], + cwd: repo.path, + env: { + ...envWithoutConfig.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + const addResult2 = runTakt({ + args: ['--provider', 'mock', 'add', 'Initialize flow task 2'], + cwd: repo.path, + env: { + ...envWithoutConfig.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + const runResult = runTakt({ + args: ['--provider', 'mock', 'run'], + cwd: repo.path, + env: { + ...envWithoutConfig.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: tasks are persisted/executed correctly and project init artifacts exist + expect(addResult1.exitCode).toBe(0); + expect(addResult2.exitCode).toBe(0); + expect(runResult.exitCode).toBe(0); + + const tasksFile = join(repo.path, '.takt', 'tasks.yaml'); + const parsedFinal = parseYaml(readFileSync(tasksFile, 'utf-8')) as { tasks?: TaskRecord[] }; + expect(parsedFinal.tasks).toEqual([]); + + const taskDirsRoot = join(repo.path, '.takt', 'tasks'); + const taskDirs = readdirSync(taskDirsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + expect(taskDirs.length).toBe(2); + + expect(existsSync(join(projectConfigDir, '.gitignore'))).toBe(true); + expect(existsSync(envWithoutConfig.globalConfigPath)).toBe(false); + } finally { + envWithoutConfig.cleanup(); + } + }, 240_000); +}); diff --git a/package-lock.json b/package-lock.json index 960e5f9..2909798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "takt", - "version": "0.19.0", + "version": "0.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.19.0", + "version": "0.20.0", "license": "MIT", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.37", + "@anthropic-ai/claude-agent-sdk": "^0.2.47", "@openai/codex-sdk": "^0.103.0", "@opencode-ai/sdk": "^1.1.53", "chalk": "^5.3.0", @@ -40,22 +40,23 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.37", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.37.tgz", - "integrity": "sha512-0TCAUuGXiWYV2JK+j2SiakGzPA7aoR5DNRxZ0EA571loGIqN3FRfiO1kipeBpEc+cRQ03a/4Kt5YAjMx0KBW+A==", + "version": "0.2.47", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.47.tgz", + "integrity": "sha512-tcptBQwLnaUv6f5KiiUUtGduiLUhwV/xT0kPxVG+K2Wws1T/2MLViwIoti3AkJuNJ2qZ5FOwl1YQLHPMeHlYVQ==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.33.5", - "@img/sharp-darwin-x64": "^0.33.5", - "@img/sharp-linux-arm": "^0.33.5", - "@img/sharp-linux-arm64": "^0.33.5", - "@img/sharp-linux-x64": "^0.33.5", - "@img/sharp-linuxmusl-arm64": "^0.33.5", - "@img/sharp-linuxmusl-x64": "^0.33.5", - "@img/sharp-win32-x64": "^0.33.5" + "@img/sharp-darwin-arm64": "^0.34.2", + "@img/sharp-darwin-x64": "^0.34.2", + "@img/sharp-linux-arm": "^0.34.2", + "@img/sharp-linux-arm64": "^0.34.2", + "@img/sharp-linux-x64": "^0.34.2", + "@img/sharp-linuxmusl-arm64": "^0.34.2", + "@img/sharp-linuxmusl-x64": "^0.34.2", + "@img/sharp-win32-arm64": "^0.34.2", + "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" @@ -653,12 +654,13 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], + "license": "Apache-2.0", "optional": true, "os": [ "darwin" @@ -670,16 +672,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], + "license": "Apache-2.0", "optional": true, "os": [ "darwin" @@ -691,16 +694,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" @@ -710,12 +714,13 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" @@ -725,12 +730,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -740,12 +746,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -755,12 +762,13 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -770,12 +778,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -785,12 +794,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -800,12 +810,13 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -817,16 +828,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -838,16 +850,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -859,16 +872,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -880,16 +894,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -901,16 +916,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" diff --git a/package.json b/package.json index d6d786e..b7a42fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "takt", - "version": "0.19.0", + "version": "0.20.0", "description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -60,7 +60,7 @@ "builtins/" ], "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.37", + "@anthropic-ai/claude-agent-sdk": "^0.2.47", "@openai/codex-sdk": "^0.103.0", "@opencode-ai/sdk": "^1.1.53", "chalk": "^5.3.0", diff --git a/src/__tests__/analytics-cli-commands.test.ts b/src/__tests__/analytics-cli-commands.test.ts new file mode 100644 index 0000000..3f408f0 --- /dev/null +++ b/src/__tests__/analytics-cli-commands.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for analytics CLI command logic — metrics review and purge. + * + * Tests the command action logic by calling the underlying functions + * with appropriate parameters, verifying the integration between + * config loading, eventsDir resolution, and the analytics functions. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + computeReviewMetrics, + formatReviewMetrics, + parseSinceDuration, + purgeOldEvents, +} from '../features/analytics/index.js'; +import type { ReviewFindingEvent } from '../features/analytics/index.js'; + +describe('metrics review command logic', () => { + let eventsDir: string; + + beforeEach(() => { + eventsDir = join(tmpdir(), `takt-test-cli-metrics-${Date.now()}`); + mkdirSync(eventsDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(eventsDir, { recursive: true, force: true }); + }); + + it('should compute and format metrics from resolved eventsDir', () => { + const events: ReviewFindingEvent[] = [ + { + type: 'review_finding', findingId: 'f-001', status: 'new', ruleId: 'r-1', + severity: 'error', decision: 'reject', file: 'a.ts', line: 1, iteration: 1, + runId: 'r', timestamp: '2026-02-18T10:00:00.000Z', + }, + ]; + writeFileSync( + join(eventsDir, '2026-02-18.jsonl'), + events.map((e) => JSON.stringify(e)).join('\n') + '\n', + 'utf-8', + ); + + const durationMs = parseSinceDuration('30d'); + const sinceMs = new Date('2026-02-18T00:00:00Z').getTime(); + const result = computeReviewMetrics(eventsDir, sinceMs); + const output = formatReviewMetrics(result); + + expect(output).toContain('Review Metrics'); + expect(result.rejectCountsByRule.get('r-1')).toBe(1); + }); + + it('should parse since duration and compute correct time window', () => { + const durationMs = parseSinceDuration('7d'); + const now = new Date('2026-02-18T12:00:00Z').getTime(); + const sinceMs = now - durationMs; + + expect(sinceMs).toBe(new Date('2026-02-11T12:00:00Z').getTime()); + }); +}); + +describe('purge command logic', () => { + let eventsDir: string; + + beforeEach(() => { + eventsDir = join(tmpdir(), `takt-test-cli-purge-${Date.now()}`); + mkdirSync(eventsDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(eventsDir, { recursive: true, force: true }); + }); + + it('should purge files using eventsDir from config and retentionDays from config', () => { + writeFileSync(join(eventsDir, '2025-12-01.jsonl'), '{}', 'utf-8'); + writeFileSync(join(eventsDir, '2026-02-18.jsonl'), '{}', 'utf-8'); + + const retentionDays = 30; + const deleted = purgeOldEvents(eventsDir, retentionDays, new Date('2026-02-18T12:00:00Z')); + + expect(deleted).toContain('2025-12-01.jsonl'); + expect(deleted).not.toContain('2026-02-18.jsonl'); + }); + + it('should fallback to CLI retentionDays when config has no retentionDays', () => { + writeFileSync(join(eventsDir, '2025-01-01.jsonl'), '{}', 'utf-8'); + + const cliRetentionDays = parseInt('30', 10); + const configRetentionDays = undefined; + const retentionDays = configRetentionDays ?? cliRetentionDays; + const deleted = purgeOldEvents(eventsDir, retentionDays, new Date('2026-02-18T12:00:00Z')); + + expect(deleted).toContain('2025-01-01.jsonl'); + }); + + it('should use config retentionDays when specified', () => { + writeFileSync(join(eventsDir, '2026-02-10.jsonl'), '{}', 'utf-8'); + writeFileSync(join(eventsDir, '2026-02-18.jsonl'), '{}', 'utf-8'); + + const cliRetentionDays = parseInt('30', 10); + const configRetentionDays = 5; + const retentionDays = configRetentionDays ?? cliRetentionDays; + const deleted = purgeOldEvents(eventsDir, retentionDays, new Date('2026-02-18T12:00:00Z')); + + expect(deleted).toContain('2026-02-10.jsonl'); + expect(deleted).not.toContain('2026-02-18.jsonl'); + }); +}); diff --git a/src/__tests__/analytics-events.test.ts b/src/__tests__/analytics-events.test.ts new file mode 100644 index 0000000..24ffb20 --- /dev/null +++ b/src/__tests__/analytics-events.test.ts @@ -0,0 +1,132 @@ +/** + * Tests for analytics event type definitions. + * + * Validates that event objects conform to the expected shape. + */ + +import { describe, it, expect } from 'vitest'; +import type { + ReviewFindingEvent, + FixActionEvent, + MovementResultEvent, + AnalyticsEvent, +} from '../features/analytics/index.js'; + +describe('analytics event types', () => { + it('should create a valid ReviewFindingEvent', () => { + const event: ReviewFindingEvent = { + type: 'review_finding', + findingId: 'f-001', + status: 'new', + ruleId: 'no-console-log', + severity: 'warning', + decision: 'reject', + file: 'src/main.ts', + line: 42, + iteration: 1, + runId: 'run-abc', + timestamp: '2026-02-18T10:00:00.000Z', + }; + + expect(event.type).toBe('review_finding'); + expect(event.findingId).toBe('f-001'); + expect(event.status).toBe('new'); + expect(event.severity).toBe('warning'); + expect(event.decision).toBe('reject'); + expect(event.file).toBe('src/main.ts'); + expect(event.line).toBe(42); + }); + + it('should create a valid FixActionEvent with fixed action', () => { + const event: FixActionEvent = { + type: 'fix_action', + findingId: 'f-001', + action: 'fixed', + iteration: 2, + runId: 'run-abc', + timestamp: '2026-02-18T10:01:00.000Z', + }; + + expect(event.type).toBe('fix_action'); + expect(event.action).toBe('fixed'); + expect(event.findingId).toBe('f-001'); + }); + + it('should create a valid FixActionEvent with rebutted action', () => { + const event: FixActionEvent = { + type: 'fix_action', + findingId: 'f-002', + action: 'rebutted', + iteration: 3, + runId: 'run-abc', + timestamp: '2026-02-18T10:02:00.000Z', + }; + + expect(event.type).toBe('fix_action'); + expect(event.action).toBe('rebutted'); + expect(event.findingId).toBe('f-002'); + }); + + it('should create a valid MovementResultEvent', () => { + const event: MovementResultEvent = { + type: 'movement_result', + movement: 'implement', + provider: 'claude', + model: 'sonnet', + decisionTag: 'approved', + iteration: 3, + runId: 'run-abc', + timestamp: '2026-02-18T10:02:00.000Z', + }; + + expect(event.type).toBe('movement_result'); + expect(event.movement).toBe('implement'); + expect(event.provider).toBe('claude'); + expect(event.decisionTag).toBe('approved'); + }); + + it('should discriminate event types via the type field', () => { + const events: AnalyticsEvent[] = [ + { + type: 'review_finding', + findingId: 'f-001', + status: 'new', + ruleId: 'r-1', + severity: 'error', + decision: 'reject', + file: 'a.ts', + line: 1, + iteration: 1, + runId: 'r', + timestamp: '2026-01-01T00:00:00.000Z', + }, + { + type: 'fix_action', + findingId: 'f-001', + action: 'fixed', + iteration: 2, + runId: 'r', + timestamp: '2026-01-01T00:01:00.000Z', + }, + { + type: 'movement_result', + movement: 'plan', + provider: 'claude', + model: 'opus', + decisionTag: 'done', + iteration: 1, + runId: 'r', + timestamp: '2026-01-01T00:02:00.000Z', + }, + ]; + + const reviewEvents = events.filter((e) => e.type === 'review_finding'); + expect(reviewEvents).toHaveLength(1); + + const fixEvents = events.filter((e) => e.type === 'fix_action'); + expect(fixEvents).toHaveLength(1); + + const movementEvents = events.filter((e) => e.type === 'movement_result'); + expect(movementEvents).toHaveLength(1); + }); +}); diff --git a/src/__tests__/analytics-metrics.test.ts b/src/__tests__/analytics-metrics.test.ts new file mode 100644 index 0000000..8c7ac89 --- /dev/null +++ b/src/__tests__/analytics-metrics.test.ts @@ -0,0 +1,344 @@ +/** + * Tests for analytics metrics computation. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + computeReviewMetrics, + formatReviewMetrics, + parseSinceDuration, +} from '../features/analytics/index.js'; +import type { + ReviewFindingEvent, + FixActionEvent, + MovementResultEvent, +} from '../features/analytics/index.js'; + +describe('analytics metrics', () => { + let eventsDir: string; + + beforeEach(() => { + eventsDir = join(tmpdir(), `takt-test-analytics-metrics-${Date.now()}`); + mkdirSync(eventsDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(eventsDir, { recursive: true, force: true }); + }); + + function writeEvents(date: string, events: Array): void { + const lines = events.map((e) => JSON.stringify(e)).join('\n') + '\n'; + writeFileSync(join(eventsDir, `${date}.jsonl`), lines, 'utf-8'); + } + + describe('computeReviewMetrics', () => { + it('should return empty metrics when no events exist', () => { + const sinceMs = new Date('2026-01-01T00:00:00Z').getTime(); + const metrics = computeReviewMetrics(eventsDir, sinceMs); + + expect(metrics.reReportCounts.size).toBe(0); + expect(metrics.roundTripRatio).toBe(0); + expect(metrics.averageResolutionIterations).toBe(0); + expect(metrics.rejectCountsByRule.size).toBe(0); + expect(metrics.rebuttalResolvedRatio).toBe(0); + }); + + it('should return empty metrics when directory does not exist', () => { + const nonExistent = join(eventsDir, 'does-not-exist'); + const sinceMs = new Date('2026-01-01T00:00:00Z').getTime(); + const metrics = computeReviewMetrics(nonExistent, sinceMs); + + expect(metrics.reReportCounts.size).toBe(0); + }); + + it('should compute re-report counts for findings appearing 2+ times', () => { + const events: ReviewFindingEvent[] = [ + { + type: 'review_finding', + findingId: 'f-001', + status: 'new', + ruleId: 'r-1', + severity: 'error', + decision: 'reject', + file: 'a.ts', + line: 1, + iteration: 1, + runId: 'run-1', + timestamp: '2026-02-18T10:00:00.000Z', + }, + { + type: 'review_finding', + findingId: 'f-001', + status: 'persists', + ruleId: 'r-1', + severity: 'error', + decision: 'reject', + file: 'a.ts', + line: 1, + iteration: 3, + runId: 'run-1', + timestamp: '2026-02-18T11:00:00.000Z', + }, + { + type: 'review_finding', + findingId: 'f-002', + status: 'new', + ruleId: 'r-2', + severity: 'warning', + decision: 'approve', + file: 'b.ts', + line: 5, + iteration: 1, + runId: 'run-1', + timestamp: '2026-02-18T10:01:00.000Z', + }, + ]; + + writeEvents('2026-02-18', events); + + const sinceMs = new Date('2026-02-18T00:00:00Z').getTime(); + const metrics = computeReviewMetrics(eventsDir, sinceMs); + + expect(metrics.reReportCounts.size).toBe(1); + expect(metrics.reReportCounts.get('f-001')).toBe(2); + }); + + it('should compute round-trip ratio correctly', () => { + const events: ReviewFindingEvent[] = [ + // f-001: appears in iterations 1 and 3 → multi-iteration + { + type: 'review_finding', findingId: 'f-001', status: 'new', ruleId: 'r-1', severity: 'error', + decision: 'reject', file: 'a.ts', line: 1, iteration: 1, runId: 'r', timestamp: '2026-02-18T10:00:00.000Z', + }, + { + type: 'review_finding', findingId: 'f-001', status: 'persists', ruleId: 'r-1', severity: 'error', + decision: 'reject', file: 'a.ts', line: 1, iteration: 3, runId: 'r', timestamp: '2026-02-18T11:00:00.000Z', + }, + // f-002: appears only in iteration 1 → single-iteration + { + type: 'review_finding', findingId: 'f-002', status: 'new', ruleId: 'r-2', severity: 'warning', + decision: 'approve', file: 'b.ts', line: 5, iteration: 1, runId: 'r', timestamp: '2026-02-18T10:01:00.000Z', + }, + ]; + + writeEvents('2026-02-18', events); + + const sinceMs = new Date('2026-02-18T00:00:00Z').getTime(); + const metrics = computeReviewMetrics(eventsDir, sinceMs); + + // 1 out of 2 unique findings had multi-iteration → 50% + expect(metrics.roundTripRatio).toBe(0.5); + }); + + it('should compute average resolution iterations', () => { + const events: ReviewFindingEvent[] = [ + // f-001: first in iteration 1, resolved in iteration 3 → 3 iterations + { + type: 'review_finding', findingId: 'f-001', status: 'new', ruleId: 'r-1', severity: 'error', + decision: 'reject', file: 'a.ts', line: 1, iteration: 1, runId: 'r', timestamp: '2026-02-18T10:00:00.000Z', + }, + { + type: 'review_finding', findingId: 'f-001', status: 'resolved', ruleId: 'r-1', severity: 'error', + decision: 'approve', file: 'a.ts', line: 1, iteration: 3, runId: 'r', timestamp: '2026-02-18T12:00:00.000Z', + }, + // f-002: first in iteration 2, resolved in iteration 2 → 1 iteration + { + type: 'review_finding', findingId: 'f-002', status: 'new', ruleId: 'r-2', severity: 'warning', + decision: 'reject', file: 'b.ts', line: 5, iteration: 2, runId: 'r', timestamp: '2026-02-18T11:00:00.000Z', + }, + { + type: 'review_finding', findingId: 'f-002', status: 'resolved', ruleId: 'r-2', severity: 'warning', + decision: 'approve', file: 'b.ts', line: 5, iteration: 2, runId: 'r', timestamp: '2026-02-18T11:30:00.000Z', + }, + ]; + + writeEvents('2026-02-18', events); + + const sinceMs = new Date('2026-02-18T00:00:00Z').getTime(); + const metrics = computeReviewMetrics(eventsDir, sinceMs); + + // (3 + 1) / 2 = 2.0 + expect(metrics.averageResolutionIterations).toBe(2); + }); + + it('should compute reject counts by rule', () => { + const events: ReviewFindingEvent[] = [ + { + type: 'review_finding', findingId: 'f-001', status: 'new', ruleId: 'no-any', + severity: 'error', decision: 'reject', file: 'a.ts', line: 1, iteration: 1, + runId: 'r', timestamp: '2026-02-18T10:00:00.000Z', + }, + { + type: 'review_finding', findingId: 'f-002', status: 'new', ruleId: 'no-any', + severity: 'error', decision: 'reject', file: 'b.ts', line: 2, iteration: 1, + runId: 'r', timestamp: '2026-02-18T10:01:00.000Z', + }, + { + type: 'review_finding', findingId: 'f-003', status: 'new', ruleId: 'no-console', + severity: 'warning', decision: 'reject', file: 'c.ts', line: 3, iteration: 1, + runId: 'r', timestamp: '2026-02-18T10:02:00.000Z', + }, + { + type: 'review_finding', findingId: 'f-004', status: 'new', ruleId: 'no-any', + severity: 'error', decision: 'approve', file: 'd.ts', line: 4, iteration: 2, + runId: 'r', timestamp: '2026-02-18T10:03:00.000Z', + }, + ]; + + writeEvents('2026-02-18', events); + + const sinceMs = new Date('2026-02-18T00:00:00Z').getTime(); + const metrics = computeReviewMetrics(eventsDir, sinceMs); + + expect(metrics.rejectCountsByRule.get('no-any')).toBe(2); + expect(metrics.rejectCountsByRule.get('no-console')).toBe(1); + }); + + it('should compute rebuttal resolved ratio', () => { + const events: Array = [ + // f-001: rebutted, then resolved → counts toward resolved + { + type: 'fix_action', findingId: 'AA-NEW-f001', action: 'rebutted', + iteration: 2, runId: 'r', timestamp: '2026-02-18T10:00:00.000Z', + }, + { + type: 'review_finding', findingId: 'AA-NEW-f001', status: 'resolved', ruleId: 'r-1', + severity: 'warning', decision: 'approve', file: 'a.ts', line: 1, + iteration: 3, runId: 'r', timestamp: '2026-02-18T11:00:00.000Z', + }, + // f-002: rebutted, never resolved → not counted + { + type: 'fix_action', findingId: 'AA-NEW-f002', action: 'rebutted', + iteration: 2, runId: 'r', timestamp: '2026-02-18T10:01:00.000Z', + }, + { + type: 'review_finding', findingId: 'AA-NEW-f002', status: 'persists', ruleId: 'r-2', + severity: 'error', decision: 'reject', file: 'b.ts', line: 5, + iteration: 3, runId: 'r', timestamp: '2026-02-18T11:01:00.000Z', + }, + // f-003: fixed (not rebutted), resolved → does not affect rebuttal metric + { + type: 'fix_action', findingId: 'AA-NEW-f003', action: 'fixed', + iteration: 2, runId: 'r', timestamp: '2026-02-18T10:02:00.000Z', + }, + { + type: 'review_finding', findingId: 'AA-NEW-f003', status: 'resolved', ruleId: 'r-3', + severity: 'warning', decision: 'approve', file: 'c.ts', line: 10, + iteration: 3, runId: 'r', timestamp: '2026-02-18T11:02:00.000Z', + }, + ]; + + writeEvents('2026-02-18', events); + + const sinceMs = new Date('2026-02-18T00:00:00Z').getTime(); + const metrics = computeReviewMetrics(eventsDir, sinceMs); + + // 1 out of 2 rebutted findings was resolved → 50% + expect(metrics.rebuttalResolvedRatio).toBe(0.5); + }); + + it('should return 0 rebuttal resolved ratio when no rebutted events exist', () => { + const events: ReviewFindingEvent[] = [ + { + type: 'review_finding', findingId: 'f-001', status: 'new', ruleId: 'r-1', + severity: 'error', decision: 'reject', file: 'a.ts', line: 1, iteration: 1, + runId: 'r', timestamp: '2026-02-18T10:00:00.000Z', + }, + ]; + + writeEvents('2026-02-18', events); + + const sinceMs = new Date('2026-02-18T00:00:00Z').getTime(); + const metrics = computeReviewMetrics(eventsDir, sinceMs); + + expect(metrics.rebuttalResolvedRatio).toBe(0); + }); + + it('should only include events after the since timestamp', () => { + const events: ReviewFindingEvent[] = [ + { + type: 'review_finding', findingId: 'f-old', status: 'new', ruleId: 'r-1', + severity: 'error', decision: 'reject', file: 'old.ts', line: 1, iteration: 1, + runId: 'r', timestamp: '2026-02-10T10:00:00.000Z', + }, + { + type: 'review_finding', findingId: 'f-new', status: 'new', ruleId: 'r-1', + severity: 'error', decision: 'reject', file: 'new.ts', line: 1, iteration: 1, + runId: 'r', timestamp: '2026-02-18T10:00:00.000Z', + }, + ]; + + // Write both events to the same date file for simplicity (old event in same file) + writeEvents('2026-02-10', [events[0]]); + writeEvents('2026-02-18', [events[1]]); + + // Since Feb 15 — should only include f-new + const sinceMs = new Date('2026-02-15T00:00:00Z').getTime(); + const metrics = computeReviewMetrics(eventsDir, sinceMs); + + expect(metrics.rejectCountsByRule.get('r-1')).toBe(1); + }); + }); + + describe('formatReviewMetrics', () => { + it('should format empty metrics', () => { + const metrics = computeReviewMetrics(eventsDir, 0); + const output = formatReviewMetrics(metrics); + + expect(output).toContain('=== Review Metrics ==='); + expect(output).toContain('(none)'); + expect(output).toContain('Round-trip ratio'); + expect(output).toContain('Average resolution iterations'); + expect(output).toContain('Rebuttal'); + }); + + it('should format metrics with data', () => { + const events: ReviewFindingEvent[] = [ + { + type: 'review_finding', findingId: 'f-001', status: 'new', ruleId: 'r-1', + severity: 'error', decision: 'reject', file: 'a.ts', line: 1, iteration: 1, + runId: 'r', timestamp: '2026-02-18T10:00:00.000Z', + }, + { + type: 'review_finding', findingId: 'f-001', status: 'persists', ruleId: 'r-1', + severity: 'error', decision: 'reject', file: 'a.ts', line: 1, iteration: 3, + runId: 'r', timestamp: '2026-02-18T11:00:00.000Z', + }, + ]; + writeEvents('2026-02-18', events); + + const sinceMs = new Date('2026-02-18T00:00:00Z').getTime(); + const metrics = computeReviewMetrics(eventsDir, sinceMs); + const output = formatReviewMetrics(metrics); + + expect(output).toContain('f-001: 2'); + expect(output).toContain('r-1: 2'); + }); + }); + + describe('parseSinceDuration', () => { + it('should parse "7d" to 7 days in milliseconds', () => { + const ms = parseSinceDuration('7d'); + expect(ms).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it('should parse "30d" to 30 days in milliseconds', () => { + const ms = parseSinceDuration('30d'); + expect(ms).toBe(30 * 24 * 60 * 60 * 1000); + }); + + it('should parse "1d" to 1 day in milliseconds', () => { + const ms = parseSinceDuration('1d'); + expect(ms).toBe(24 * 60 * 60 * 1000); + }); + + it('should throw on invalid format', () => { + expect(() => parseSinceDuration('7h')).toThrow('Invalid duration format'); + expect(() => parseSinceDuration('abc')).toThrow('Invalid duration format'); + expect(() => parseSinceDuration('')).toThrow('Invalid duration format'); + }); + }); +}); diff --git a/src/__tests__/analytics-pieceExecution.test.ts b/src/__tests__/analytics-pieceExecution.test.ts new file mode 100644 index 0000000..cb3576a --- /dev/null +++ b/src/__tests__/analytics-pieceExecution.test.ts @@ -0,0 +1,205 @@ +/** + * Tests for analytics integration in pieceExecution. + * + * Validates the analytics initialization logic (analytics.enabled gate) + * and event firing for review_finding and fix_action events. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resetAnalyticsWriter } from '../features/analytics/writer.js'; +import { + initAnalyticsWriter, + isAnalyticsEnabled, + writeAnalyticsEvent, +} from '../features/analytics/index.js'; +import type { + MovementResultEvent, + ReviewFindingEvent, + FixActionEvent, +} from '../features/analytics/index.js'; + +describe('pieceExecution analytics initialization', () => { + let testDir: string; + + beforeEach(() => { + resetAnalyticsWriter(); + testDir = join(tmpdir(), `takt-test-analytics-init-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + resetAnalyticsWriter(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should enable analytics when analytics.enabled=true', () => { + const analyticsEnabled = true; + initAnalyticsWriter(analyticsEnabled, testDir); + expect(isAnalyticsEnabled()).toBe(true); + }); + + it('should disable analytics when analytics.enabled=false', () => { + const analyticsEnabled = false; + initAnalyticsWriter(analyticsEnabled, testDir); + expect(isAnalyticsEnabled()).toBe(false); + }); + + it('should disable analytics when analytics is undefined', () => { + const analytics = undefined; + const analyticsEnabled = analytics?.enabled === true; + initAnalyticsWriter(analyticsEnabled, testDir); + expect(isAnalyticsEnabled()).toBe(false); + }); +}); + +describe('movement_result event assembly', () => { + let testDir: string; + + beforeEach(() => { + resetAnalyticsWriter(); + testDir = join(tmpdir(), `takt-test-mvt-result-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + resetAnalyticsWriter(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should write movement_result event with correct fields', () => { + initAnalyticsWriter(true, testDir); + + const event: MovementResultEvent = { + type: 'movement_result', + movement: 'ai_review', + provider: 'claude', + model: 'sonnet', + decisionTag: 'REJECT', + iteration: 3, + runId: 'test-run', + timestamp: '2026-02-18T10:00:00.000Z', + }; + + writeAnalyticsEvent(event); + + const filePath = join(testDir, '2026-02-18.jsonl'); + expect(existsSync(filePath)).toBe(true); + + const content = readFileSync(filePath, 'utf-8').trim(); + const parsed = JSON.parse(content) as MovementResultEvent; + + expect(parsed.type).toBe('movement_result'); + expect(parsed.movement).toBe('ai_review'); + expect(parsed.decisionTag).toBe('REJECT'); + expect(parsed.iteration).toBe(3); + expect(parsed.runId).toBe('test-run'); + }); +}); + +describe('review_finding event writing', () => { + let testDir: string; + + beforeEach(() => { + resetAnalyticsWriter(); + testDir = join(tmpdir(), `takt-test-review-finding-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + resetAnalyticsWriter(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should write review_finding events to JSONL', () => { + initAnalyticsWriter(true, testDir); + + const event: ReviewFindingEvent = { + type: 'review_finding', + findingId: 'AA-001', + status: 'new', + ruleId: 'AA-001', + severity: 'warning', + decision: 'reject', + file: 'src/foo.ts', + line: 42, + iteration: 2, + runId: 'test-run', + timestamp: '2026-02-18T10:00:00.000Z', + }; + + writeAnalyticsEvent(event); + + const filePath = join(testDir, '2026-02-18.jsonl'); + const content = readFileSync(filePath, 'utf-8').trim(); + const parsed = JSON.parse(content) as ReviewFindingEvent; + + expect(parsed.type).toBe('review_finding'); + expect(parsed.findingId).toBe('AA-001'); + expect(parsed.status).toBe('new'); + expect(parsed.decision).toBe('reject'); + }); +}); + +describe('fix_action event writing', () => { + let testDir: string; + + beforeEach(() => { + resetAnalyticsWriter(); + testDir = join(tmpdir(), `takt-test-fix-action-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + resetAnalyticsWriter(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should write fix_action events with fixed action to JSONL', () => { + initAnalyticsWriter(true, testDir); + + const event: FixActionEvent = { + type: 'fix_action', + findingId: 'AA-001', + action: 'fixed', + iteration: 3, + runId: 'test-run', + timestamp: '2026-02-18T11:00:00.000Z', + }; + + writeAnalyticsEvent(event); + + const filePath = join(testDir, '2026-02-18.jsonl'); + const content = readFileSync(filePath, 'utf-8').trim(); + const parsed = JSON.parse(content) as FixActionEvent; + + expect(parsed.type).toBe('fix_action'); + expect(parsed.findingId).toBe('AA-001'); + expect(parsed.action).toBe('fixed'); + }); + + it('should write fix_action events with rebutted action to JSONL', () => { + initAnalyticsWriter(true, testDir); + + const event: FixActionEvent = { + type: 'fix_action', + findingId: 'AA-002', + action: 'rebutted', + iteration: 4, + runId: 'test-run', + timestamp: '2026-02-18T12:00:00.000Z', + }; + + writeAnalyticsEvent(event); + + const filePath = join(testDir, '2026-02-18.jsonl'); + const content = readFileSync(filePath, 'utf-8').trim(); + const parsed = JSON.parse(content) as FixActionEvent; + + expect(parsed.type).toBe('fix_action'); + expect(parsed.findingId).toBe('AA-002'); + expect(parsed.action).toBe('rebutted'); + }); +}); diff --git a/src/__tests__/analytics-purge.test.ts b/src/__tests__/analytics-purge.test.ts new file mode 100644 index 0000000..8de1126 --- /dev/null +++ b/src/__tests__/analytics-purge.test.ts @@ -0,0 +1,108 @@ +/** + * Tests for analytics purge — retention-based cleanup of JSONL files. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { purgeOldEvents } from '../features/analytics/index.js'; + +describe('purgeOldEvents', () => { + let eventsDir: string; + + beforeEach(() => { + eventsDir = join(tmpdir(), `takt-test-analytics-purge-${Date.now()}`); + mkdirSync(eventsDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(eventsDir, { recursive: true, force: true }); + }); + + it('should delete files older than retention period', () => { + // Given: Files from different dates + writeFileSync(join(eventsDir, '2026-01-01.jsonl'), '{}', 'utf-8'); + writeFileSync(join(eventsDir, '2026-01-15.jsonl'), '{}', 'utf-8'); + writeFileSync(join(eventsDir, '2026-02-10.jsonl'), '{}', 'utf-8'); + writeFileSync(join(eventsDir, '2026-02-18.jsonl'), '{}', 'utf-8'); + + // When: Purge with 30-day retention from Feb 18 + const now = new Date('2026-02-18T12:00:00Z'); + const deleted = purgeOldEvents(eventsDir, 30, now); + + // Then: Only files before Jan 19 should be deleted + expect(deleted).toContain('2026-01-01.jsonl'); + expect(deleted).toContain('2026-01-15.jsonl'); + expect(deleted).not.toContain('2026-02-10.jsonl'); + expect(deleted).not.toContain('2026-02-18.jsonl'); + + expect(existsSync(join(eventsDir, '2026-01-01.jsonl'))).toBe(false); + expect(existsSync(join(eventsDir, '2026-01-15.jsonl'))).toBe(false); + expect(existsSync(join(eventsDir, '2026-02-10.jsonl'))).toBe(true); + expect(existsSync(join(eventsDir, '2026-02-18.jsonl'))).toBe(true); + }); + + it('should return empty array when no files to purge', () => { + writeFileSync(join(eventsDir, '2026-02-18.jsonl'), '{}', 'utf-8'); + + const now = new Date('2026-02-18T12:00:00Z'); + const deleted = purgeOldEvents(eventsDir, 30, now); + + expect(deleted).toEqual([]); + }); + + it('should return empty array when directory does not exist', () => { + const nonExistent = join(eventsDir, 'does-not-exist'); + const deleted = purgeOldEvents(nonExistent, 30, new Date()); + + expect(deleted).toEqual([]); + }); + + it('should delete all files when retention is 0', () => { + writeFileSync(join(eventsDir, '2026-02-17.jsonl'), '{}', 'utf-8'); + writeFileSync(join(eventsDir, '2026-02-18.jsonl'), '{}', 'utf-8'); + + const now = new Date('2026-02-18T12:00:00Z'); + const deleted = purgeOldEvents(eventsDir, 0, now); + + expect(deleted).toContain('2026-02-17.jsonl'); + // The cutoff date is Feb 18, and '2026-02-18' is not < '2026-02-18' + expect(deleted).not.toContain('2026-02-18.jsonl'); + }); + + it('should ignore non-jsonl files', () => { + writeFileSync(join(eventsDir, '2025-01-01.jsonl'), '{}', 'utf-8'); + writeFileSync(join(eventsDir, 'README.md'), '# test', 'utf-8'); + writeFileSync(join(eventsDir, 'data.json'), '{}', 'utf-8'); + + const now = new Date('2026-02-18T12:00:00Z'); + const deleted = purgeOldEvents(eventsDir, 30, now); + + expect(deleted).toContain('2025-01-01.jsonl'); + expect(deleted).not.toContain('README.md'); + expect(deleted).not.toContain('data.json'); + + // Non-jsonl files should still exist + expect(existsSync(join(eventsDir, 'README.md'))).toBe(true); + expect(existsSync(join(eventsDir, 'data.json'))).toBe(true); + }); + + it('should handle 7-day retention correctly', () => { + writeFileSync(join(eventsDir, '2026-02-10.jsonl'), '{}', 'utf-8'); + writeFileSync(join(eventsDir, '2026-02-11.jsonl'), '{}', 'utf-8'); + writeFileSync(join(eventsDir, '2026-02-12.jsonl'), '{}', 'utf-8'); + writeFileSync(join(eventsDir, '2026-02-17.jsonl'), '{}', 'utf-8'); + writeFileSync(join(eventsDir, '2026-02-18.jsonl'), '{}', 'utf-8'); + + const now = new Date('2026-02-18T12:00:00Z'); + const deleted = purgeOldEvents(eventsDir, 7, now); + + // Cutoff: Feb 11 + expect(deleted).toContain('2026-02-10.jsonl'); + expect(deleted).not.toContain('2026-02-11.jsonl'); + expect(deleted).not.toContain('2026-02-12.jsonl'); + expect(deleted).not.toContain('2026-02-17.jsonl'); + expect(deleted).not.toContain('2026-02-18.jsonl'); + }); +}); diff --git a/src/__tests__/analytics-report-parser.test.ts b/src/__tests__/analytics-report-parser.test.ts new file mode 100644 index 0000000..c90cd87 --- /dev/null +++ b/src/__tests__/analytics-report-parser.test.ts @@ -0,0 +1,350 @@ +/** + * Tests for analytics report parser — extracting findings from review markdown. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + parseFindingsFromReport, + extractDecisionFromReport, + inferSeverity, + emitFixActionEvents, + emitRebuttalEvents, +} from '../features/analytics/report-parser.js'; +import { initAnalyticsWriter } from '../features/analytics/writer.js'; +import { resetAnalyticsWriter } from '../features/analytics/writer.js'; +import type { FixActionEvent } from '../features/analytics/events.js'; + +describe('parseFindingsFromReport', () => { + it('should extract new findings from a review report', () => { + const report = [ + '# Review Report', + '', + '## Result: REJECT', + '', + '## Current Iteration Findings (new)', + '| # | finding_id | Category | Location | Issue | Fix Suggestion |', + '|---|------------|---------|------|------|--------|', + '| 1 | AA-001 | DRY | `src/foo.ts:42` | Duplication | Extract helper |', + '| 2 | AA-002 | Export | `src/bar.ts:10` | Unused export | Remove |', + '', + ].join('\n'); + + const findings = parseFindingsFromReport(report); + + expect(findings).toHaveLength(2); + expect(findings[0].findingId).toBe('AA-001'); + expect(findings[0].status).toBe('new'); + expect(findings[0].ruleId).toBe('DRY'); + expect(findings[0].file).toBe('src/foo.ts'); + expect(findings[0].line).toBe(42); + expect(findings[1].findingId).toBe('AA-002'); + expect(findings[1].status).toBe('new'); + expect(findings[1].ruleId).toBe('Export'); + expect(findings[1].file).toBe('src/bar.ts'); + expect(findings[1].line).toBe(10); + }); + + it('should extract persists findings', () => { + const report = [ + '## Carry-over Findings (persists)', + '| # | finding_id | Previous Evidence | Current Evidence | Issue | Fix Suggestion |', + '|---|------------|----------|----------|------|--------|', + '| 1 | ARCH-001 | `src/a.ts:5` was X | `src/a.ts:5` still X | Still bad | Fix it |', + '', + ].join('\n'); + + const findings = parseFindingsFromReport(report); + + expect(findings).toHaveLength(1); + expect(findings[0].findingId).toBe('ARCH-001'); + expect(findings[0].status).toBe('persists'); + }); + + it('should extract resolved findings', () => { + const report = [ + '## Resolved Findings (resolved)', + '| finding_id | Resolution Evidence |', + '|------------|---------------------|', + '| QA-003 | Fixed in src/c.ts |', + '', + ].join('\n'); + + const findings = parseFindingsFromReport(report); + + expect(findings).toHaveLength(1); + expect(findings[0].findingId).toBe('QA-003'); + expect(findings[0].status).toBe('resolved'); + }); + + it('should handle mixed sections in one report', () => { + const report = [ + '## 今回の指摘(new)', + '| # | finding_id | カテゴリ | 場所 | 問題 | 修正案 |', + '|---|------------|---------|------|------|--------|', + '| 1 | AA-001 | DRY | `src/foo.ts:1` | Dup | Fix |', + '', + '## 継続指摘(persists)', + '| # | finding_id | 前回根拠 | 今回根拠 | 問題 | 修正案 |', + '|---|------------|----------|----------|------|--------|', + '| 1 | AA-002 | Was bad | Still bad | Issue | Fix |', + '', + '## 解消済み(resolved)', + '| finding_id | 解消根拠 |', + '|------------|---------|', + '| AA-003 | Fixed |', + '', + ].join('\n'); + + const findings = parseFindingsFromReport(report); + + expect(findings).toHaveLength(3); + expect(findings[0]).toEqual(expect.objectContaining({ findingId: 'AA-001', status: 'new' })); + expect(findings[1]).toEqual(expect.objectContaining({ findingId: 'AA-002', status: 'persists' })); + expect(findings[2]).toEqual(expect.objectContaining({ findingId: 'AA-003', status: 'resolved' })); + }); + + it('should return empty array when no finding sections exist', () => { + const report = [ + '# Report', + '', + '## Summary', + 'Everything looks good.', + '', + ].join('\n'); + + const findings = parseFindingsFromReport(report); + + expect(findings).toEqual([]); + }); + + it('should stop collecting findings when a new non-finding section starts', () => { + const report = [ + '## Current Iteration Findings (new)', + '| # | finding_id | Category | Location | Issue | Fix |', + '|---|------------|---------|------|------|-----|', + '| 1 | F-001 | Bug | `src/a.ts` | Bad | Fix |', + '', + '## REJECT判定条件', + '| Condition | Result |', + '|-----------|--------|', + '| Has findings | Yes |', + '', + ].join('\n'); + + const findings = parseFindingsFromReport(report); + + expect(findings).toHaveLength(1); + expect(findings[0].findingId).toBe('F-001'); + }); + + it('should skip header rows in tables', () => { + const report = [ + '## Current Iteration Findings (new)', + '| # | finding_id | Category | Location | Issue | Fix |', + '|---|------------|---------|------|------|-----|', + '| 1 | X-001 | Cat | `file.ts:5` | Problem | Solution |', + '', + ].join('\n'); + + const findings = parseFindingsFromReport(report); + + expect(findings).toHaveLength(1); + expect(findings[0].findingId).toBe('X-001'); + }); + + it('should parse location with line number from backtick-wrapped paths', () => { + const report = [ + '## Current Iteration Findings (new)', + '| # | finding_id | Category | Location | Issue | Fix |', + '|---|------------|---------|------|------|-----|', + '| 1 | F-001 | Bug | `src/features/analytics/writer.ts:27` | Comment | Remove |', + '', + ].join('\n'); + + const findings = parseFindingsFromReport(report); + + expect(findings[0].file).toBe('src/features/analytics/writer.ts'); + expect(findings[0].line).toBe(27); + }); + + it('should handle location with multiple line references', () => { + const report = [ + '## Current Iteration Findings (new)', + '| # | finding_id | Category | Location | Issue | Fix |', + '|---|------------|---------|------|------|-----|', + '| 1 | F-001 | Bug | `src/a.ts:10, src/b.ts:20` | Multiple | Fix |', + '', + ].join('\n'); + + const findings = parseFindingsFromReport(report); + + expect(findings[0].file).toBe('src/a.ts'); + expect(findings[0].line).toBe(10); + }); +}); + +describe('extractDecisionFromReport', () => { + it('should return reject when report says REJECT', () => { + const report = '## 結果: REJECT\n\nSome content'; + expect(extractDecisionFromReport(report)).toBe('reject'); + }); + + it('should return approve when report says APPROVE', () => { + const report = '## Result: APPROVE\n\nSome content'; + expect(extractDecisionFromReport(report)).toBe('approve'); + }); + + it('should return null when no result section is found', () => { + const report = '# Report\n\nNo result section here.'; + expect(extractDecisionFromReport(report)).toBeNull(); + }); +}); + +describe('inferSeverity', () => { + it('should return error for security-related finding IDs', () => { + expect(inferSeverity('SEC-001')).toBe('error'); + expect(inferSeverity('SEC-NEW-xss')).toBe('error'); + }); + + it('should return warning for other finding IDs', () => { + expect(inferSeverity('AA-001')).toBe('warning'); + expect(inferSeverity('QA-001')).toBe('warning'); + expect(inferSeverity('ARCH-NEW-dry')).toBe('warning'); + }); +}); + +describe('emitFixActionEvents', () => { + let testDir: string; + + beforeEach(() => { + resetAnalyticsWriter(); + testDir = join(tmpdir(), `takt-test-emit-fix-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + initAnalyticsWriter(true, testDir); + }); + + afterEach(() => { + resetAnalyticsWriter(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should emit fix_action events for each finding ID in response', () => { + const timestamp = new Date('2026-02-18T12:00:00.000Z'); + + emitFixActionEvents('Fixed AA-001 and ARCH-002-barrel', 3, 'run-xyz', timestamp); + + const filePath = join(testDir, '2026-02-18.jsonl'); + const lines = readFileSync(filePath, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + + const event1 = JSON.parse(lines[0]) as FixActionEvent; + expect(event1.type).toBe('fix_action'); + expect(event1.findingId).toBe('AA-001'); + expect(event1.action).toBe('fixed'); + expect(event1.iteration).toBe(3); + expect(event1.runId).toBe('run-xyz'); + expect(event1.timestamp).toBe('2026-02-18T12:00:00.000Z'); + + const event2 = JSON.parse(lines[1]) as FixActionEvent; + expect(event2.type).toBe('fix_action'); + expect(event2.findingId).toBe('ARCH-002-barrel'); + expect(event2.action).toBe('fixed'); + }); + + it('should not emit events when response contains no finding IDs', () => { + const timestamp = new Date('2026-02-18T12:00:00.000Z'); + + emitFixActionEvents('No issues found, all good.', 1, 'run-abc', timestamp); + + const filePath = join(testDir, '2026-02-18.jsonl'); + expect(() => readFileSync(filePath, 'utf-8')).toThrow(); + }); + + it('should deduplicate repeated finding IDs', () => { + const timestamp = new Date('2026-02-18T12:00:00.000Z'); + + emitFixActionEvents( + 'Fixed QA-001, confirmed QA-001 is resolved, also QA-001 again', + 2, + 'run-dedup', + timestamp, + ); + + const filePath = join(testDir, '2026-02-18.jsonl'); + const lines = readFileSync(filePath, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(1); + + const event = JSON.parse(lines[0]) as FixActionEvent; + expect(event.findingId).toBe('QA-001'); + }); + + it('should match various finding ID formats', () => { + const timestamp = new Date('2026-02-18T12:00:00.000Z'); + const response = [ + 'Resolved AA-001 simple ID', + 'Fixed ARCH-NEW-dry with NEW segment', + 'Addressed SEC-002-xss with suffix', + ].join('\n'); + + emitFixActionEvents(response, 1, 'run-formats', timestamp); + + const filePath = join(testDir, '2026-02-18.jsonl'); + const lines = readFileSync(filePath, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(3); + + const ids = lines.map((line) => (JSON.parse(line) as FixActionEvent).findingId); + expect(ids).toContain('AA-001'); + expect(ids).toContain('ARCH-NEW-dry'); + expect(ids).toContain('SEC-002-xss'); + }); +}); + +describe('emitRebuttalEvents', () => { + let testDir: string; + + beforeEach(() => { + resetAnalyticsWriter(); + testDir = join(tmpdir(), `takt-test-emit-rebuttal-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + initAnalyticsWriter(true, testDir); + }); + + afterEach(() => { + resetAnalyticsWriter(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should emit fix_action events with rebutted action for finding IDs', () => { + const timestamp = new Date('2026-02-18T12:00:00.000Z'); + + emitRebuttalEvents('Rebutting AA-001 and ARCH-002-barrel', 3, 'run-xyz', timestamp); + + const filePath = join(testDir, '2026-02-18.jsonl'); + const lines = readFileSync(filePath, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + + const event1 = JSON.parse(lines[0]) as FixActionEvent; + expect(event1.type).toBe('fix_action'); + expect(event1.findingId).toBe('AA-001'); + expect(event1.action).toBe('rebutted'); + expect(event1.iteration).toBe(3); + expect(event1.runId).toBe('run-xyz'); + + const event2 = JSON.parse(lines[1]) as FixActionEvent; + expect(event2.type).toBe('fix_action'); + expect(event2.findingId).toBe('ARCH-002-barrel'); + expect(event2.action).toBe('rebutted'); + }); + + it('should not emit events when response contains no finding IDs', () => { + const timestamp = new Date('2026-02-18T12:00:00.000Z'); + + emitRebuttalEvents('No findings mentioned here.', 1, 'run-abc', timestamp); + + const filePath = join(testDir, '2026-02-18.jsonl'); + expect(() => readFileSync(filePath, 'utf-8')).toThrow(); + }); +}); diff --git a/src/__tests__/analytics-writer.test.ts b/src/__tests__/analytics-writer.test.ts new file mode 100644 index 0000000..8db5023 --- /dev/null +++ b/src/__tests__/analytics-writer.test.ts @@ -0,0 +1,220 @@ +/** + * Tests for AnalyticsWriter — JSONL append, date rotation, ON/OFF toggle. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, readFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resetAnalyticsWriter } from '../features/analytics/writer.js'; +import { + initAnalyticsWriter, + isAnalyticsEnabled, + writeAnalyticsEvent, +} from '../features/analytics/index.js'; +import type { MovementResultEvent, ReviewFindingEvent } from '../features/analytics/index.js'; + +describe('AnalyticsWriter', () => { + let testDir: string; + + beforeEach(() => { + resetAnalyticsWriter(); + testDir = join(tmpdir(), `takt-test-analytics-writer-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + resetAnalyticsWriter(); + rmSync(testDir, { recursive: true, force: true }); + }); + + describe('ON/OFF toggle', () => { + it('should not be enabled by default', () => { + expect(isAnalyticsEnabled()).toBe(false); + }); + + it('should be enabled when initialized with enabled=true', () => { + initAnalyticsWriter(true, testDir); + expect(isAnalyticsEnabled()).toBe(true); + }); + + it('should not be enabled when initialized with enabled=false', () => { + initAnalyticsWriter(false, testDir); + expect(isAnalyticsEnabled()).toBe(false); + }); + + it('should not write when disabled', () => { + initAnalyticsWriter(false, testDir); + + const event: MovementResultEvent = { + type: 'movement_result', + movement: 'plan', + provider: 'claude', + model: 'sonnet', + decisionTag: 'done', + iteration: 1, + runId: 'run-1', + timestamp: '2026-02-18T10:00:00.000Z', + }; + + writeAnalyticsEvent(event); + + const expectedFile = join(testDir, '2026-02-18.jsonl'); + expect(existsSync(expectedFile)).toBe(false); + }); + }); + + describe('event writing', () => { + it('should append event to date-based JSONL file', () => { + initAnalyticsWriter(true, testDir); + + const event: MovementResultEvent = { + type: 'movement_result', + movement: 'implement', + provider: 'claude', + model: 'sonnet', + decisionTag: 'approved', + iteration: 2, + runId: 'run-abc', + timestamp: '2026-02-18T14:30:00.000Z', + }; + + writeAnalyticsEvent(event); + + const filePath = join(testDir, '2026-02-18.jsonl'); + expect(existsSync(filePath)).toBe(true); + + const content = readFileSync(filePath, 'utf-8').trim(); + const parsed = JSON.parse(content) as MovementResultEvent; + expect(parsed.type).toBe('movement_result'); + expect(parsed.movement).toBe('implement'); + expect(parsed.provider).toBe('claude'); + expect(parsed.decisionTag).toBe('approved'); + }); + + it('should append multiple events to the same file', () => { + initAnalyticsWriter(true, testDir); + + const event1: MovementResultEvent = { + type: 'movement_result', + movement: 'plan', + provider: 'claude', + model: 'sonnet', + decisionTag: 'done', + iteration: 1, + runId: 'run-1', + timestamp: '2026-02-18T10:00:00.000Z', + }; + + const event2: MovementResultEvent = { + type: 'movement_result', + movement: 'implement', + provider: 'codex', + model: 'o3', + decisionTag: 'needs_fix', + iteration: 2, + runId: 'run-1', + timestamp: '2026-02-18T11:00:00.000Z', + }; + + writeAnalyticsEvent(event1); + writeAnalyticsEvent(event2); + + const filePath = join(testDir, '2026-02-18.jsonl'); + const lines = readFileSync(filePath, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + + const parsed1 = JSON.parse(lines[0]) as MovementResultEvent; + const parsed2 = JSON.parse(lines[1]) as MovementResultEvent; + expect(parsed1.movement).toBe('plan'); + expect(parsed2.movement).toBe('implement'); + }); + + it('should create separate files for different dates', () => { + initAnalyticsWriter(true, testDir); + + const event1: MovementResultEvent = { + type: 'movement_result', + movement: 'plan', + provider: 'claude', + model: 'sonnet', + decisionTag: 'done', + iteration: 1, + runId: 'run-1', + timestamp: '2026-02-17T23:59:00.000Z', + }; + + const event2: MovementResultEvent = { + type: 'movement_result', + movement: 'implement', + provider: 'claude', + model: 'sonnet', + decisionTag: 'done', + iteration: 2, + runId: 'run-1', + timestamp: '2026-02-18T00:01:00.000Z', + }; + + writeAnalyticsEvent(event1); + writeAnalyticsEvent(event2); + + expect(existsSync(join(testDir, '2026-02-17.jsonl'))).toBe(true); + expect(existsSync(join(testDir, '2026-02-18.jsonl'))).toBe(true); + }); + + it('should write review_finding events correctly', () => { + initAnalyticsWriter(true, testDir); + + const event: ReviewFindingEvent = { + type: 'review_finding', + findingId: 'f-001', + status: 'new', + ruleId: 'no-any', + severity: 'error', + decision: 'reject', + file: 'src/index.ts', + line: 10, + iteration: 1, + runId: 'run-1', + timestamp: '2026-03-01T08:00:00.000Z', + }; + + writeAnalyticsEvent(event); + + const filePath = join(testDir, '2026-03-01.jsonl'); + const content = readFileSync(filePath, 'utf-8').trim(); + const parsed = JSON.parse(content) as ReviewFindingEvent; + expect(parsed.type).toBe('review_finding'); + expect(parsed.findingId).toBe('f-001'); + expect(parsed.ruleId).toBe('no-any'); + }); + }); + + describe('directory creation', () => { + it('should create events directory when enabled and dir does not exist', () => { + const nestedDir = join(testDir, 'nested', 'analytics', 'events'); + expect(existsSync(nestedDir)).toBe(false); + + initAnalyticsWriter(true, nestedDir); + + expect(existsSync(nestedDir)).toBe(true); + }); + + it('should not create directory when disabled', () => { + const nestedDir = join(testDir, 'disabled-dir', 'events'); + initAnalyticsWriter(false, nestedDir); + + expect(existsSync(nestedDir)).toBe(false); + }); + }); + + describe('resetInstance', () => { + it('should reset to disabled state', () => { + initAnalyticsWriter(true, testDir); + expect(isAnalyticsEnabled()).toBe(true); + + resetAnalyticsWriter(); + expect(isAnalyticsEnabled()).toBe(false); + }); + }); +}); diff --git a/src/__tests__/catalog.test.ts b/src/__tests__/catalog.test.ts index af9863b..514ea9d 100644 --- a/src/__tests__/catalog.test.ts +++ b/src/__tests__/catalog.test.ts @@ -17,8 +17,17 @@ import { // Mock external dependencies to isolate unit tests vi.mock('../infra/config/global/globalConfig.js', () => ({ - getLanguage: () => 'en', - getBuiltinPiecesEnabled: () => true, + loadGlobalConfig: () => ({}), +})); + +vi.mock('../infra/config/loadConfig.js', () => ({ + loadConfig: () => ({ + global: { + language: 'en', + enableBuiltinPieces: true, + }, + project: {}, + }), })); const mockLogError = vi.fn(); diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index b29af62..7eed4f1 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -15,7 +15,6 @@ vi.mock('../shared/ui/index.js', () => ({ })); vi.mock('../shared/prompt/index.js', () => ({ - confirm: vi.fn(() => true), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ @@ -51,7 +50,6 @@ vi.mock('../features/pipeline/index.js', () => ({ vi.mock('../features/interactive/index.js', () => ({ interactiveMode: vi.fn(), selectInteractiveMode: vi.fn(() => 'assistant'), - selectRecentSession: vi.fn(() => null), passthroughMode: vi.fn(), quietMode: vi.fn(), personaMode: vi.fn(), @@ -76,7 +74,9 @@ vi.mock('../infra/task/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({ getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })), - loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: 3 })), + resolveConfigValue: vi.fn((_: string, key: string) => (key === 'piece' ? 'default' : false)), + resolveConfigValues: vi.fn(() => ({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' })), + loadPersonaSessions: vi.fn(() => ({})), })); vi.mock('../shared/constants.js', () => ({ @@ -106,11 +106,11 @@ vi.mock('../app/cli/helpers.js', () => ({ import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; import { selectAndExecuteTask, determinePiece, createIssueFromTask, saveTaskFromInteractive } from '../features/tasks/index.js'; -import { interactiveMode, selectRecentSession } from '../features/interactive/index.js'; -import { loadGlobalConfig } from '../infra/config/index.js'; -import { confirm } from '../shared/prompt/index.js'; +import { interactiveMode } from '../features/interactive/index.js'; +import { resolveConfigValues, loadPersonaSessions } from '../infra/config/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; import { executeDefaultAction } from '../app/cli/routing.js'; +import { info } from '../shared/ui/index.js'; import type { GitHubIssue } from '../infra/github/types.js'; const mockCheckGhCli = vi.mocked(checkGhCli); @@ -122,10 +122,10 @@ const mockDeterminePiece = vi.mocked(determinePiece); const mockCreateIssueFromTask = vi.mocked(createIssueFromTask); const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive); const mockInteractiveMode = vi.mocked(interactiveMode); -const mockSelectRecentSession = vi.mocked(selectRecentSession); -const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); -const mockConfirm = vi.mocked(confirm); +const mockLoadPersonaSessions = vi.mocked(loadPersonaSessions); +const mockResolveConfigValues = vi.mocked(resolveConfigValues); const mockIsDirectTask = vi.mocked(isDirectTask); +const mockInfo = vi.mocked(info); const mockTaskRunnerListAllTaskItems = vi.mocked(mockListAllTaskItems); function createMockIssue(number: number): GitHubIssue { @@ -147,7 +147,6 @@ beforeEach(() => { // Default setup mockDeterminePiece.mockResolvedValue('default'); mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' }); - mockConfirm.mockResolvedValue(true); mockIsDirectTask.mockReturnValue(false); mockParseIssueNumbers.mockReturnValue([]); mockTaskRunnerListAllTaskItems.mockReturnValue([]); @@ -480,41 +479,43 @@ describe('Issue resolution in routing', () => { }); }); - describe('session selection with provider=claude', () => { - it('should pass selected session ID to interactiveMode when provider is claude', async () => { + describe('--continue option', () => { + it('should load saved session and pass to interactiveMode when --continue is specified', async () => { // Given - mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); - mockConfirm.mockResolvedValue(true); - mockSelectRecentSession.mockResolvedValue('session-xyz'); + mockOpts.continue = true; + mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' }); + mockLoadPersonaSessions.mockReturnValue({ interactive: 'saved-session-123' }); // When await executeDefaultAction(); - // Then: selectRecentSession should be called - expect(mockSelectRecentSession).toHaveBeenCalledWith('/test/cwd', 'en'); + // Then: loadPersonaSessions should be called with provider + expect(mockLoadPersonaSessions).toHaveBeenCalledWith('/test/cwd', 'claude'); - // Then: interactiveMode should receive the session ID as 4th argument + // Then: interactiveMode should receive the saved session ID expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, expect.anything(), - 'session-xyz', + 'saved-session-123', ); - - expect(mockConfirm).toHaveBeenCalledWith('Choose a previous session?', false); }); - it('should not call selectRecentSession when user selects no in confirmation', async () => { + it('should show message and start new session when --continue has no saved session', async () => { // Given - mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); - mockConfirm.mockResolvedValue(false); + mockOpts.continue = true; + mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' }); + mockLoadPersonaSessions.mockReturnValue({}); // When await executeDefaultAction(); - // Then - expect(mockConfirm).toHaveBeenCalledWith('Choose a previous session?', false); - expect(mockSelectRecentSession).not.toHaveBeenCalled(); + // Then: info message about no session + expect(mockInfo).toHaveBeenCalledWith( + 'No previous assistant session found. Starting a new session.', + ); + + // Then: interactiveMode should be called with undefined session ID expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, @@ -523,15 +524,12 @@ describe('Issue resolution in routing', () => { ); }); - it('should not call selectRecentSession when provider is not claude', async () => { - // Given - mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'openai' }); - + it('should not load persona sessions when --continue is not specified', async () => { // When await executeDefaultAction(); - // Then: selectRecentSession should NOT be called - expect(mockSelectRecentSession).not.toHaveBeenCalled(); + // Then: loadPersonaSessions should NOT be called + expect(mockLoadPersonaSessions).not.toHaveBeenCalled(); // Then: interactiveMode should be called with undefined session ID expect(mockInteractiveMode).toHaveBeenCalledWith( @@ -543,14 +541,11 @@ describe('Issue resolution in routing', () => { }); }); - describe('run session reference', () => { - it('should not prompt run session reference in default interactive flow', async () => { + describe('default assistant mode (no --continue)', () => { + it('should start new session without loading saved sessions', async () => { await executeDefaultAction(); - expect(mockConfirm).not.toHaveBeenCalledWith( - "Reference a previous run's results?", - false, - ); + expect(mockLoadPersonaSessions).not.toHaveBeenCalled(); expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, diff --git a/src/__tests__/cli-worktree.test.ts b/src/__tests__/cli-worktree.test.ts index 24fc270..405f939 100644 --- a/src/__tests__/cli-worktree.test.ts +++ b/src/__tests__/cli-worktree.test.ts @@ -66,7 +66,6 @@ vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/paths.js', () => ({ clearPersonaSessions: vi.fn(), - getCurrentPiece: vi.fn(() => 'default'), isVerboseMode: vi.fn(() => false), })); diff --git a/src/__tests__/config-env-overrides.test.ts b/src/__tests__/config-env-overrides.test.ts new file mode 100644 index 0000000..3a2ce1a --- /dev/null +++ b/src/__tests__/config-env-overrides.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + applyGlobalConfigEnvOverrides, + applyProjectConfigEnvOverrides, + envVarNameFromPath, +} from '../infra/config/env/config-env-overrides.js'; + +describe('config env overrides', () => { + const envBackup = { ...process.env }; + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in envBackup)) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(envBackup)) { + process.env[key] = value; + } + }); + + it('should convert dotted and camelCase paths to TAKT env variable names', () => { + expect(envVarNameFromPath('verbose')).toBe('TAKT_VERBOSE'); + expect(envVarNameFromPath('provider_options.claude.sandbox.allow_unsandboxed_commands')) + .toBe('TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS'); + }); + + it('should apply global env overrides from generated env names', () => { + process.env.TAKT_LOG_LEVEL = 'debug'; + process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS = 'true'; + + const raw: Record = {}; + applyGlobalConfigEnvOverrides(raw); + + expect(raw.log_level).toBe('debug'); + expect(raw.provider_options).toEqual({ + claude: { + sandbox: { + allow_unsandboxed_commands: true, + }, + }, + }); + }); + + it('should apply project env overrides from generated env names', () => { + process.env.TAKT_VERBOSE = 'true'; + + const raw: Record = {}; + applyProjectConfigEnvOverrides(raw); + + expect(raw.verbose).toBe(true); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 012ec05..7e5284b 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,5 +1,5 @@ /** - * Tests for takt config functions + * Tests for config functions */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -13,7 +13,6 @@ import { loadPiece, listPieces, loadPersonaPromptFromPath, - getCurrentPiece, setCurrentPiece, getProjectConfigDir, getBuiltinPersonasDir, @@ -35,17 +34,19 @@ import { updateWorktreeSession, getLanguage, loadProjectConfig, + isVerboseMode, + invalidateGlobalConfigCache, } from '../infra/config/index.js'; describe('getBuiltinPiece', () => { it('should return builtin piece when it exists in resources', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); expect(piece).not.toBeNull(); expect(piece!.name).toBe('default'); }); it('should resolve builtin instruction_template without projectCwd', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); expect(piece).not.toBeNull(); const planMovement = piece!.movements.find((movement) => movement.name === 'plan'); @@ -54,15 +55,15 @@ describe('getBuiltinPiece', () => { }); it('should return null for non-existent piece names', () => { - expect(getBuiltinPiece('nonexistent-piece')).toBeNull(); - expect(getBuiltinPiece('unknown')).toBeNull(); - expect(getBuiltinPiece('')).toBeNull(); + expect(getBuiltinPiece('nonexistent-piece', process.cwd())).toBeNull(); + expect(getBuiltinPiece('unknown', process.cwd())).toBeNull(); + expect(getBuiltinPiece('', process.cwd())).toBeNull(); }); }); describe('default piece parallel reviewers movement', () => { it('should have a reviewers movement with parallel sub-movements', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); expect(piece).not.toBeNull(); const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers'); @@ -72,7 +73,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have arch-review and qa-review as parallel sub-movements', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!; const subMovementNames = reviewersMovement.parallel!.map((s) => s.name); @@ -81,7 +82,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have aggregate conditions on the reviewers parent movement', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!; expect(reviewersMovement.rules).toBeDefined(); @@ -99,7 +100,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have matching conditions on sub-movements for aggregation', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!; for (const subMovement of reviewersMovement.parallel!) { @@ -111,7 +112,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have ai_review transitioning to reviewers movement', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const aiReviewMovement = piece!.movements.find((s) => s.name === 'ai_review')!; const approveRule = aiReviewMovement.rules!.find((r) => r.next === 'reviewers'); @@ -119,7 +120,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have ai_fix transitioning to ai_review movement', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const aiFixMovement = piece!.movements.find((s) => s.name === 'ai_fix')!; const fixedRule = aiFixMovement.rules!.find((r) => r.next === 'ai_review'); @@ -127,7 +128,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have fix movement transitioning back to reviewers', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const fixMovement = piece!.movements.find((s) => s.name === 'fix')!; const fixedRule = fixMovement.rules!.find((r) => r.next === 'reviewers'); @@ -135,7 +136,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should not have old separate review/security_review/improve movements', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const movementNames = piece!.movements.map((s) => s.name); expect(movementNames).not.toContain('review'); @@ -145,7 +146,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have sub-movements with correct agents', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!; const archReview = reviewersMovement.parallel!.find((s) => s.name === 'arch-review')!; @@ -156,7 +157,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have output contracts configured on sub-movements', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!; const archReview = reviewersMovement.parallel!.find((s) => s.name === 'arch-review')!; @@ -288,54 +289,13 @@ describe('loadPersonaPromptFromPath (builtin paths)', () => { const personaPath = join(builtinPersonasDir, 'coder.md'); if (existsSync(personaPath)) { - const prompt = loadPersonaPromptFromPath(personaPath); + const prompt = loadPersonaPromptFromPath(personaPath, process.cwd()); expect(prompt).toBeTruthy(); expect(typeof prompt).toBe('string'); } }); }); -describe('getCurrentPiece', () => { - let testDir: string; - - beforeEach(() => { - testDir = join(tmpdir(), `takt-test-${randomUUID()}`); - mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - if (existsSync(testDir)) { - rmSync(testDir, { recursive: true, force: true }); - } - }); - - it('should return default when no config exists', () => { - const piece = getCurrentPiece(testDir); - - expect(piece).toBe('default'); - }); - - it('should return saved piece name from config.yaml', () => { - const configDir = getProjectConfigDir(testDir); - mkdirSync(configDir, { recursive: true }); - writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n'); - - const piece = getCurrentPiece(testDir); - - expect(piece).toBe('default'); - }); - - it('should return default for empty config', () => { - const configDir = getProjectConfigDir(testDir); - mkdirSync(configDir, { recursive: true }); - writeFileSync(join(configDir, 'config.yaml'), ''); - - const piece = getCurrentPiece(testDir); - - expect(piece).toBe('default'); - }); -}); - describe('setCurrentPiece', () => { let testDir: string; @@ -371,12 +331,160 @@ describe('setCurrentPiece', () => { setCurrentPiece(testDir, 'first'); setCurrentPiece(testDir, 'second'); - const piece = getCurrentPiece(testDir); + const piece = loadProjectConfig(testDir).piece; expect(piece).toBe('second'); }); }); +describe('loadProjectConfig provider_options', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should normalize provider_options into providerOptions (camelCase)', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), [ + 'piece: default', + 'provider_options:', + ' codex:', + ' network_access: true', + ' claude:', + ' sandbox:', + ' allow_unsandboxed_commands: true', + ].join('\n')); + + const config = loadProjectConfig(testDir); + + expect(config.providerOptions).toEqual({ + codex: { networkAccess: true }, + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }); + }); + + it('should apply TAKT_PROVIDER_OPTIONS_* env overrides for project config', () => { + const original = process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = 'false'; + + const config = loadProjectConfig(testDir); + expect(config.providerOptions).toEqual({ + codex: { networkAccess: false }, + }); + + if (original === undefined) { + delete process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + } else { + process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = original; + } + }); +}); + +describe('isVerboseMode', () => { + let testDir: string; + let originalTaktConfigDir: string | undefined; + let originalTaktVerbose: string | undefined; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + originalTaktConfigDir = process.env.TAKT_CONFIG_DIR; + originalTaktVerbose = process.env.TAKT_VERBOSE; + process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt'); + delete process.env.TAKT_VERBOSE; + invalidateGlobalConfigCache(); + }); + + afterEach(() => { + if (originalTaktConfigDir === undefined) { + delete process.env.TAKT_CONFIG_DIR; + } else { + process.env.TAKT_CONFIG_DIR = originalTaktConfigDir; + } + if (originalTaktVerbose === undefined) { + delete process.env.TAKT_VERBOSE; + } else { + process.env.TAKT_VERBOSE = originalTaktVerbose; + } + + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should return project verbose when project config has verbose: true', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: true\n'); + + const globalConfigDir = process.env.TAKT_CONFIG_DIR!; + mkdirSync(globalConfigDir, { recursive: true }); + writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: false\n'); + + expect(isVerboseMode(testDir)).toBe(true); + }); + + it('should return project verbose when project config has verbose: false', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: false\n'); + + const globalConfigDir = process.env.TAKT_CONFIG_DIR!; + mkdirSync(globalConfigDir, { recursive: true }); + writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: true\n'); + + expect(isVerboseMode(testDir)).toBe(false); + }); + + it('should fallback to global verbose when project verbose is not set', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\n'); + + const globalConfigDir = process.env.TAKT_CONFIG_DIR!; + mkdirSync(globalConfigDir, { recursive: true }); + writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: true\n'); + + expect(isVerboseMode(testDir)).toBe(true); + }); + + it('should return false when neither project nor global verbose is set', () => { + expect(isVerboseMode(testDir)).toBe(false); + }); + + it('should prioritize TAKT_VERBOSE over project and global config', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: false\n'); + + const globalConfigDir = process.env.TAKT_CONFIG_DIR!; + mkdirSync(globalConfigDir, { recursive: true }); + writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: false\n'); + + process.env.TAKT_VERBOSE = 'true'; + expect(isVerboseMode(testDir)).toBe(true); + }); + + it('should throw on TAKT_VERBOSE=0', () => { + process.env.TAKT_VERBOSE = '0'; + expect(() => isVerboseMode(testDir)).toThrow('TAKT_VERBOSE must be one of: true, false'); + }); + + it('should throw on invalid TAKT_VERBOSE value', () => { + process.env.TAKT_VERBOSE = 'yes'; + expect(() => isVerboseMode(testDir)).toThrow('TAKT_VERBOSE must be one of: true, false'); + }); +}); + describe('loadInputHistory', () => { let testDir: string; diff --git a/src/__tests__/conversationLoop-resume.test.ts b/src/__tests__/conversationLoop-resume.test.ts new file mode 100644 index 0000000..381d2c7 --- /dev/null +++ b/src/__tests__/conversationLoop-resume.test.ts @@ -0,0 +1,215 @@ +/** + * Tests for /resume command and initializeSession changes. + * + * Verifies: + * - initializeSession returns sessionId: undefined (no implicit auto-load) + * - /resume command calls selectRecentSession and updates sessionId + * - /resume with cancel does not change sessionId + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + setupRawStdin, + restoreStdin, + toRawInputs, + createMockProvider, + createScenarioProvider, + type MockProviderCapture, +} from './helpers/stdinSimulator.js'; + +// --- Infrastructure 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().mockResolvedValue('execute'), +})); + +const mockSelectRecentSession = vi.fn<(cwd: string, lang: 'en' | 'ja') => Promise>(); + +vi.mock('../features/interactive/sessionSelector.js', () => ({ + selectRecentSession: (...args: [string, 'en' | 'ja']) => mockSelectRecentSession(...args), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'), + getLabelObject: vi.fn(() => ({ + intro: 'Intro', + resume: 'Resume', + noConversation: 'No conversation', + summarizeFailed: 'Summarize failed', + continuePrompt: 'Continue?', + proposed: 'Proposed:', + actionPrompt: 'What next?', + playNoTask: 'No task for /play', + cancelled: 'Cancelled', + actions: { execute: 'Execute', saveTask: 'Save', continue: 'Continue' }, + })), +})); + +// --- Imports (after mocks) --- + +import { getProvider } from '../infra/providers/index.js'; +import { selectOption } from '../shared/prompt/index.js'; +import { info as logInfo } from '../shared/ui/index.js'; +import { initializeSession, runConversationLoop, type SessionContext } from '../features/interactive/conversationLoop.js'; + +const mockGetProvider = vi.mocked(getProvider); +const mockSelectOption = vi.mocked(selectOption); +const mockLogInfo = vi.mocked(logInfo); + +// --- Helpers --- + +function setupProvider(responses: string[]): MockProviderCapture { + const { provider, capture } = createMockProvider(responses); + mockGetProvider.mockReturnValue(provider); + return capture; +} + +function createSessionContext(overrides: Partial = {}): SessionContext { + const { provider } = createMockProvider([]); + mockGetProvider.mockReturnValue(provider); + return { + provider: provider as SessionContext['provider'], + providerType: 'mock' as SessionContext['providerType'], + model: undefined, + lang: 'en', + personaName: 'interactive', + sessionId: undefined, + ...overrides, + }; +} + +const defaultStrategy = { + systemPrompt: 'test system prompt', + allowedTools: ['Read'], + transformPrompt: (msg: string) => msg, + introMessage: 'Test intro', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockSelectOption.mockResolvedValue('execute'); + mockSelectRecentSession.mockResolvedValue(null); +}); + +afterEach(() => { + restoreStdin(); +}); + +// ================================================================= +// initializeSession: no implicit session auto-load +// ================================================================= +describe('initializeSession', () => { + it('should return sessionId as undefined (no implicit auto-load)', () => { + const ctx = initializeSession('/test/cwd', 'interactive'); + + expect(ctx.sessionId).toBeUndefined(); + expect(ctx.personaName).toBe('interactive'); + }); +}); + +// ================================================================= +// /resume command +// ================================================================= +describe('/resume command', () => { + it('should call selectRecentSession and update sessionId when session selected', async () => { + // Given: /resume → select session → /cancel + setupRawStdin(toRawInputs(['/resume', '/cancel'])); + setupProvider([]); + mockSelectRecentSession.mockResolvedValue('selected-session-abc'); + + const ctx = createSessionContext(); + + // When + const result = await runConversationLoop('/test', ctx, defaultStrategy, undefined, undefined); + + // Then: selectRecentSession called + expect(mockSelectRecentSession).toHaveBeenCalledWith('/test', 'en'); + + // Then: info about loaded session displayed + expect(mockLogInfo).toHaveBeenCalledWith('Mock label'); + + // Then: cancelled at the end + expect(result.action).toBe('cancel'); + }); + + it('should not change sessionId when user cancels session selection', async () => { + // Given: /resume → cancel selection → /cancel + setupRawStdin(toRawInputs(['/resume', '/cancel'])); + setupProvider([]); + mockSelectRecentSession.mockResolvedValue(null); + + const ctx = createSessionContext(); + + // When + const result = await runConversationLoop('/test', ctx, defaultStrategy, undefined, undefined); + + // Then: selectRecentSession called but returned null + expect(mockSelectRecentSession).toHaveBeenCalledWith('/test', 'en'); + + // Then: cancelled + expect(result.action).toBe('cancel'); + }); + + it('should use resumed session for subsequent AI calls', async () => { + // Given: /resume → select session → send message → /cancel + setupRawStdin(toRawInputs(['/resume', 'hello world', '/cancel'])); + mockSelectRecentSession.mockResolvedValue('resumed-session-xyz'); + + const { provider, capture } = createScenarioProvider([ + { content: 'AI response' }, + ]); + + const ctx: SessionContext = { + provider: provider as SessionContext['provider'], + providerType: 'mock' as SessionContext['providerType'], + model: undefined, + lang: 'en', + personaName: 'interactive', + sessionId: undefined, + }; + + // When + const result = await runConversationLoop('/test', ctx, defaultStrategy, undefined, undefined); + + // Then: AI call should use the resumed session ID + expect(capture.sessionIds[0]).toBe('resumed-session-xyz'); + expect(result.action).toBe('cancel'); + }); +}); diff --git a/src/__tests__/e2e-helpers.test.ts b/src/__tests__/e2e-helpers.test.ts index f7b25d6..62a5254 100644 --- a/src/__tests__/e2e-helpers.test.ts +++ b/src/__tests__/e2e-helpers.test.ts @@ -85,7 +85,6 @@ describe('createIsolatedEnv', () => { expect(config.language).toBe('en'); expect(config.log_level).toBe('info'); - expect(config.default_piece).toBe('default'); expect(config.notification_sound).toBe(false); expect(config.notification_sound_events).toEqual({ iteration_limit: false, @@ -173,7 +172,6 @@ describe('createIsolatedEnv', () => { [ 'language: en', 'log_level: info', - 'default_piece: default', 'notification_sound: true', 'notification_sound_events: true', ].join('\n'), diff --git a/src/__tests__/engine-provider-options.test.ts b/src/__tests__/engine-provider-options.test.ts new file mode 100644 index 0000000..c17b3af --- /dev/null +++ b/src/__tests__/engine-provider-options.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { rmSync } from 'node:fs'; + +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 { + applyDefaultMocks, + cleanupPieceEngine, + createTestTmpDir, + makeMovement, + makeResponse, + makeRule, + mockDetectMatchedRuleSequence, + mockRunAgentSequence, +} from './engine-test-helpers.js'; +import type { PieceConfig } from '../core/models/index.js'; + +describe('PieceEngine provider_options resolution', () => { + let tmpDir: string; + let engine: PieceEngine | undefined; + + beforeEach(() => { + vi.resetAllMocks(); + applyDefaultMocks(); + tmpDir = createTestTmpDir(); + }); + + afterEach(() => { + if (engine) { + cleanupPieceEngine(engine); + engine = undefined; + } + if (tmpDir) { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should merge provider_options in order: global < project < movement', async () => { + const movement = makeMovement('implement', { + providerOptions: { + codex: { networkAccess: false }, + claude: { sandbox: { excludedCommands: ['./gradlew'] } }, + }, + rules: [makeRule('done', 'COMPLETE')], + }); + + const config: PieceConfig = { + name: 'provider-options-priority', + movements: [movement], + initialMovement: 'implement', + maxMovements: 1, + }; + + mockRunAgentSequence([ + makeResponse({ persona: movement.persona, content: 'done' }), + ]); + mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + provider: 'claude', + providerOptions: { + codex: { networkAccess: true }, + claude: { sandbox: { allowUnsandboxedCommands: false } }, + opencode: { networkAccess: true }, + }, + }); + + await engine.run(); + + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: false }, + opencode: { networkAccess: true }, + claude: { + sandbox: { + allowUnsandboxedCommands: false, + excludedCommands: ['./gradlew'], + }, + }, + }); + }); + + it('should pass global provider_options when project and movement options are absent', async () => { + const movement = makeMovement('implement', { + rules: [makeRule('done', 'COMPLETE')], + }); + + const config: PieceConfig = { + name: 'provider-options-global-only', + movements: [movement], + initialMovement: 'implement', + maxMovements: 1, + }; + + mockRunAgentSequence([ + makeResponse({ persona: movement.persona, content: 'done' }), + ]); + mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + provider: 'claude', + providerOptions: { + codex: { networkAccess: true }, + }, + }); + + await engine.run(); + + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: true }, + }); + }); +}); diff --git a/src/__tests__/faceted-prompting/compose.test.ts b/src/__tests__/faceted-prompting/compose.test.ts new file mode 100644 index 0000000..a4f4ee2 --- /dev/null +++ b/src/__tests__/faceted-prompting/compose.test.ts @@ -0,0 +1,158 @@ +/** + * Unit tests for faceted-prompting compose module. + */ + +import { describe, it, expect } from 'vitest'; +import { compose } from '../../faceted-prompting/index.js'; +import type { FacetSet, ComposeOptions } from '../../faceted-prompting/index.js'; + +const defaultOptions: ComposeOptions = { contextMaxChars: 2000 }; + +describe('compose', () => { + it('should place persona in systemPrompt', () => { + const facets: FacetSet = { + persona: { body: 'You are a helpful assistant.' }, + }; + + const result = compose(facets, defaultOptions); + + expect(result.systemPrompt).toBe('You are a helpful assistant.'); + expect(result.userMessage).toBe(''); + }); + + it('should place instruction in userMessage', () => { + const facets: FacetSet = { + instruction: { body: 'Implement feature X.' }, + }; + + const result = compose(facets, defaultOptions); + + expect(result.systemPrompt).toBe(''); + expect(result.userMessage).toBe('Implement feature X.'); + }); + + it('should place policy in userMessage with conflict notice', () => { + const facets: FacetSet = { + policies: [{ body: 'Follow clean code principles.' }], + }; + + const result = compose(facets, defaultOptions); + + expect(result.systemPrompt).toBe(''); + expect(result.userMessage).toContain('Follow clean code principles.'); + expect(result.userMessage).toContain('If prompt content conflicts with source files'); + }); + + it('should place knowledge in userMessage with conflict notice', () => { + const facets: FacetSet = { + knowledge: [{ body: 'Architecture documentation.' }], + }; + + const result = compose(facets, defaultOptions); + + expect(result.systemPrompt).toBe(''); + expect(result.userMessage).toContain('Architecture documentation.'); + expect(result.userMessage).toContain('If prompt content conflicts with source files'); + }); + + it('should compose all facets in correct order: policy, knowledge, instruction', () => { + const facets: FacetSet = { + persona: { body: 'You are a coder.' }, + policies: [{ body: 'POLICY' }], + knowledge: [{ body: 'KNOWLEDGE' }], + instruction: { body: 'INSTRUCTION' }, + }; + + const result = compose(facets, defaultOptions); + + expect(result.systemPrompt).toBe('You are a coder.'); + + const policyIdx = result.userMessage.indexOf('POLICY'); + const knowledgeIdx = result.userMessage.indexOf('KNOWLEDGE'); + const instructionIdx = result.userMessage.indexOf('INSTRUCTION'); + + expect(policyIdx).toBeLessThan(knowledgeIdx); + expect(knowledgeIdx).toBeLessThan(instructionIdx); + }); + + it('should join multiple policies with separator', () => { + const facets: FacetSet = { + policies: [ + { body: 'Policy A' }, + { body: 'Policy B' }, + ], + }; + + const result = compose(facets, defaultOptions); + + expect(result.userMessage).toContain('Policy A'); + expect(result.userMessage).toContain('---'); + expect(result.userMessage).toContain('Policy B'); + }); + + it('should join multiple knowledge items with separator', () => { + const facets: FacetSet = { + knowledge: [ + { body: 'Knowledge A' }, + { body: 'Knowledge B' }, + ], + }; + + const result = compose(facets, defaultOptions); + + expect(result.userMessage).toContain('Knowledge A'); + expect(result.userMessage).toContain('---'); + expect(result.userMessage).toContain('Knowledge B'); + }); + + it('should truncate policy content exceeding contextMaxChars', () => { + const longPolicy = 'x'.repeat(3000); + const facets: FacetSet = { + policies: [{ body: longPolicy, sourcePath: '/path/policy.md' }], + }; + + const result = compose(facets, { contextMaxChars: 2000 }); + + expect(result.userMessage).toContain('...TRUNCATED...'); + expect(result.userMessage).toContain('Policy is authoritative'); + }); + + it('should truncate knowledge content exceeding contextMaxChars', () => { + const longKnowledge = 'y'.repeat(3000); + const facets: FacetSet = { + knowledge: [{ body: longKnowledge, sourcePath: '/path/knowledge.md' }], + }; + + const result = compose(facets, { contextMaxChars: 2000 }); + + expect(result.userMessage).toContain('...TRUNCATED...'); + expect(result.userMessage).toContain('Knowledge is truncated'); + }); + + it('should handle empty facet set', () => { + const result = compose({}, defaultOptions); + + expect(result.systemPrompt).toBe(''); + expect(result.userMessage).toBe(''); + }); + + it('should include source path for single policy', () => { + const facets: FacetSet = { + policies: [{ body: 'Policy text', sourcePath: '/policies/coding.md' }], + }; + + const result = compose(facets, defaultOptions); + + expect(result.userMessage).toContain('Policy Source: /policies/coding.md'); + }); + + it('should include source path for single knowledge', () => { + const facets: FacetSet = { + knowledge: [{ body: 'Knowledge text', sourcePath: '/knowledge/arch.md' }], + }; + + const result = compose(facets, defaultOptions); + + expect(result.userMessage).toContain('Knowledge Source: /knowledge/arch.md'); + }); +}); diff --git a/src/__tests__/faceted-prompting/data-engine.test.ts b/src/__tests__/faceted-prompting/data-engine.test.ts new file mode 100644 index 0000000..ac03efc --- /dev/null +++ b/src/__tests__/faceted-prompting/data-engine.test.ts @@ -0,0 +1,174 @@ +/** + * Unit tests for faceted-prompting DataEngine implementations. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { FileDataEngine, CompositeDataEngine } from '../../faceted-prompting/index.js'; +import type { DataEngine, FacetKind } from '../../faceted-prompting/index.js'; + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + readdirSync: vi.fn(), +})); + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockReaddirSync = vi.mocked(readdirSync); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('FileDataEngine', () => { + const engine = new FileDataEngine('/root'); + + describe('resolve', () => { + it('should return FacetContent when file exists', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('persona body'); + + const result = await engine.resolve('personas', 'coder'); + expect(result).toEqual({ + body: 'persona body', + sourcePath: '/root/personas/coder.md', + }); + }); + + it('should return undefined when file does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await engine.resolve('policies', 'missing'); + expect(result).toBeUndefined(); + }); + + it('should resolve correct directory for each facet kind', async () => { + const kinds: FacetKind[] = ['personas', 'policies', 'knowledge', 'instructions', 'output-contracts']; + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('content'); + + for (const kind of kinds) { + const result = await engine.resolve(kind, 'test'); + expect(result?.sourcePath).toBe(`/root/${kind}/test.md`); + } + }); + }); + + describe('list', () => { + it('should return facet keys from directory', async () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue(['coder.md', 'architect.md', 'readme.txt'] as unknown as ReturnType); + + const result = await engine.list('personas'); + expect(result).toEqual(['coder', 'architect']); + }); + + it('should return empty array when directory does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await engine.list('policies'); + expect(result).toEqual([]); + }); + + it('should filter non-.md files', async () => { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue(['a.md', 'b.txt', 'c.md'] as unknown as ReturnType); + + const result = await engine.list('knowledge'); + expect(result).toEqual(['a', 'c']); + }); + }); +}); + +describe('CompositeDataEngine', () => { + it('should throw when constructed with empty engines array', () => { + expect(() => new CompositeDataEngine([])).toThrow( + 'CompositeDataEngine requires at least one engine', + ); + }); + + describe('resolve', () => { + it('should return result from first engine that resolves', async () => { + const engine1: DataEngine = { + resolve: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([]), + }; + const engine2: DataEngine = { + resolve: vi.fn().mockResolvedValue({ body: 'from engine2', sourcePath: '/e2/p.md' }), + list: vi.fn().mockResolvedValue([]), + }; + + const composite = new CompositeDataEngine([engine1, engine2]); + const result = await composite.resolve('personas', 'coder'); + + expect(result).toEqual({ body: 'from engine2', sourcePath: '/e2/p.md' }); + expect(engine1.resolve).toHaveBeenCalledWith('personas', 'coder'); + expect(engine2.resolve).toHaveBeenCalledWith('personas', 'coder'); + }); + + it('should return first match (first-wins)', async () => { + const engine1: DataEngine = { + resolve: vi.fn().mockResolvedValue({ body: 'from engine1' }), + list: vi.fn().mockResolvedValue([]), + }; + const engine2: DataEngine = { + resolve: vi.fn().mockResolvedValue({ body: 'from engine2' }), + list: vi.fn().mockResolvedValue([]), + }; + + const composite = new CompositeDataEngine([engine1, engine2]); + const result = await composite.resolve('personas', 'coder'); + + expect(result?.body).toBe('from engine1'); + expect(engine2.resolve).not.toHaveBeenCalled(); + }); + + it('should return undefined when no engine resolves', async () => { + const engine1: DataEngine = { + resolve: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([]), + }; + + const composite = new CompositeDataEngine([engine1]); + const result = await composite.resolve('policies', 'missing'); + + expect(result).toBeUndefined(); + }); + }); + + describe('list', () => { + it('should return deduplicated keys from all engines', async () => { + const engine1: DataEngine = { + resolve: vi.fn(), + list: vi.fn().mockResolvedValue(['a', 'b']), + }; + const engine2: DataEngine = { + resolve: vi.fn(), + list: vi.fn().mockResolvedValue(['b', 'c']), + }; + + const composite = new CompositeDataEngine([engine1, engine2]); + const result = await composite.list('personas'); + + expect(result).toEqual(['a', 'b', 'c']); + }); + + it('should preserve order with first-seen priority', async () => { + const engine1: DataEngine = { + resolve: vi.fn(), + list: vi.fn().mockResolvedValue(['x', 'y']), + }; + const engine2: DataEngine = { + resolve: vi.fn(), + list: vi.fn().mockResolvedValue(['y', 'z']), + }; + + const composite = new CompositeDataEngine([engine1, engine2]); + const result = await composite.list('knowledge'); + + expect(result).toEqual(['x', 'y', 'z']); + }); + }); +}); diff --git a/src/__tests__/faceted-prompting/escape.test.ts b/src/__tests__/faceted-prompting/escape.test.ts new file mode 100644 index 0000000..2fa6acd --- /dev/null +++ b/src/__tests__/faceted-prompting/escape.test.ts @@ -0,0 +1,30 @@ +/** + * Unit tests for faceted-prompting escape module. + */ + +import { describe, it, expect } from 'vitest'; +import { escapeTemplateChars } from '../../faceted-prompting/index.js'; + +describe('escapeTemplateChars', () => { + it('should replace curly braces with full-width equivalents', () => { + expect(escapeTemplateChars('{hello}')).toBe('\uff5bhello\uff5d'); + }); + + it('should handle multiple braces', () => { + expect(escapeTemplateChars('{{nested}}')).toBe('\uff5b\uff5bnested\uff5d\uff5d'); + }); + + it('should return unchanged string when no braces', () => { + expect(escapeTemplateChars('no braces here')).toBe('no braces here'); + }); + + it('should handle empty string', () => { + expect(escapeTemplateChars('')).toBe(''); + }); + + it('should handle braces in code snippets', () => { + const input = 'function foo() { return { a: 1 }; }'; + const expected = 'function foo() \uff5b return \uff5b a: 1 \uff5d; \uff5d'; + expect(escapeTemplateChars(input)).toBe(expected); + }); +}); diff --git a/src/__tests__/faceted-prompting/resolve.test.ts b/src/__tests__/faceted-prompting/resolve.test.ts new file mode 100644 index 0000000..13b4a08 --- /dev/null +++ b/src/__tests__/faceted-prompting/resolve.test.ts @@ -0,0 +1,287 @@ +/** + * Unit tests for faceted-prompting resolve module. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + isResourcePath, + resolveFacetPath, + resolveFacetByName, + resolveResourcePath, + resolveResourceContent, + resolveRefToContent, + resolveRefList, + resolveSectionMap, + extractPersonaDisplayName, + resolvePersona, +} from '../../faceted-prompting/index.js'; + +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('isResourcePath', () => { + it('should return true for relative path with ./', () => { + expect(isResourcePath('./file.md')).toBe(true); + }); + + it('should return true for parent-relative path', () => { + expect(isResourcePath('../file.md')).toBe(true); + }); + + it('should return true for absolute path', () => { + expect(isResourcePath('/absolute/path.md')).toBe(true); + }); + + it('should return true for home-relative path', () => { + expect(isResourcePath('~/file.md')).toBe(true); + }); + + it('should return true for .md extension', () => { + expect(isResourcePath('some-file.md')).toBe(true); + }); + + it('should return false for a plain facet name', () => { + expect(isResourcePath('coding')).toBe(false); + }); + + it('should return false for a name with dots but not .md', () => { + expect(isResourcePath('my.config')).toBe(false); + }); +}); + +describe('resolveFacetPath', () => { + it('should return the first existing file path', () => { + mockExistsSync.mockImplementation((p) => p === '/dir1/coding.md'); + + const result = resolveFacetPath('coding', ['/dir1', '/dir2']); + expect(result).toBe('/dir1/coding.md'); + }); + + it('should skip non-existing directories and find in later ones', () => { + mockExistsSync.mockImplementation((p) => p === '/dir2/coding.md'); + + const result = resolveFacetPath('coding', ['/dir1', '/dir2']); + expect(result).toBe('/dir2/coding.md'); + }); + + it('should return undefined when not found in any directory', () => { + mockExistsSync.mockReturnValue(false); + + const result = resolveFacetPath('missing', ['/dir1', '/dir2']); + expect(result).toBeUndefined(); + }); + + it('should return undefined for empty candidate list', () => { + const result = resolveFacetPath('anything', []); + expect(result).toBeUndefined(); + }); +}); + +describe('resolveFacetByName', () => { + it('should return file content when facet exists', () => { + mockExistsSync.mockImplementation((p) => p === '/dir/coder.md'); + mockReadFileSync.mockReturnValue('You are a coder.'); + + const result = resolveFacetByName('coder', ['/dir']); + expect(result).toBe('You are a coder.'); + }); + + it('should return undefined when facet does not exist', () => { + mockExistsSync.mockReturnValue(false); + + const result = resolveFacetByName('missing', ['/dir']); + expect(result).toBeUndefined(); + }); +}); + +describe('resolveResourcePath', () => { + it('should resolve ./ relative to pieceDir', () => { + const result = resolveResourcePath('./policies/coding.md', '/project/pieces'); + expect(result).toBe(join('/project/pieces', 'policies/coding.md')); + }); + + it('should resolve ~ relative to homedir', () => { + const result = resolveResourcePath('~/policies/coding.md', '/project'); + expect(result).toBe(join(homedir(), 'policies/coding.md')); + }); + + it('should return absolute path unchanged', () => { + const result = resolveResourcePath('/absolute/path.md', '/project'); + expect(result).toBe('/absolute/path.md'); + }); + + it('should resolve plain name relative to pieceDir', () => { + const result = resolveResourcePath('coding.md', '/project/pieces'); + expect(result).toBe(join('/project/pieces', 'coding.md')); + }); +}); + +describe('resolveResourceContent', () => { + it('should return undefined for null/undefined spec', () => { + expect(resolveResourceContent(undefined, '/dir')).toBeUndefined(); + expect(resolveResourceContent(null as unknown as string | undefined, '/dir')).toBeUndefined(); + }); + + it('should read file content for .md spec when file exists', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('file content'); + + const result = resolveResourceContent('./policy.md', '/dir'); + expect(result).toBe('file content'); + }); + + it('should return spec as-is for .md spec when file does not exist', () => { + mockExistsSync.mockReturnValue(false); + + const result = resolveResourceContent('./policy.md', '/dir'); + expect(result).toBe('./policy.md'); + }); + + it('should return spec as-is for non-.md spec', () => { + const result = resolveResourceContent('inline content', '/dir'); + expect(result).toBe('inline content'); + }); +}); + +describe('resolveRefToContent', () => { + it('should return mapped content when found in resolvedMap', () => { + const result = resolveRefToContent('coding', { coding: 'mapped content' }, '/dir'); + expect(result).toBe('mapped content'); + }); + + it('should resolve resource path when ref is a resource path', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('file content'); + + const result = resolveRefToContent('./policy.md', undefined, '/dir'); + expect(result).toBe('file content'); + }); + + it('should try facet resolution via candidateDirs when ref is a name', () => { + mockExistsSync.mockImplementation((p) => p === '/facets/coding.md'); + mockReadFileSync.mockReturnValue('facet content'); + + const result = resolveRefToContent('coding', undefined, '/dir', ['/facets']); + expect(result).toBe('facet content'); + }); + + it('should fall back to resolveResourceContent when not found elsewhere', () => { + mockExistsSync.mockReturnValue(false); + + const result = resolveRefToContent('inline text', undefined, '/dir'); + expect(result).toBe('inline text'); + }); +}); + +describe('resolveRefList', () => { + it('should return undefined for null/undefined refs', () => { + expect(resolveRefList(undefined, undefined, '/dir')).toBeUndefined(); + }); + + it('should handle single string ref', () => { + const result = resolveRefList('inline', { inline: 'content' }, '/dir'); + expect(result).toEqual(['content']); + }); + + it('should handle array of refs', () => { + const result = resolveRefList( + ['a', 'b'], + { a: 'content A', b: 'content B' }, + '/dir', + ); + expect(result).toEqual(['content A', 'content B']); + }); + + it('should return undefined when no refs resolve', () => { + mockExistsSync.mockReturnValue(false); + const result = resolveRefList(['nonexistent.md'], undefined, '/dir'); + // 'nonexistent.md' ends with .md, file doesn't exist, falls back to spec + // But the spec is 'nonexistent.md' which is treated as inline + expect(result).toEqual(['nonexistent.md']); + }); +}); + +describe('resolveSectionMap', () => { + it('should return undefined for undefined input', () => { + expect(resolveSectionMap(undefined, '/dir')).toBeUndefined(); + }); + + it('should resolve each entry in the map', () => { + const result = resolveSectionMap( + { key1: 'inline value', key2: 'another value' }, + '/dir', + ); + expect(result).toEqual({ + key1: 'inline value', + key2: 'another value', + }); + }); +}); + +describe('extractPersonaDisplayName', () => { + it('should extract name from .md path', () => { + expect(extractPersonaDisplayName('coder.md')).toBe('coder'); + }); + + it('should extract name from full path', () => { + expect(extractPersonaDisplayName('/path/to/architect.md')).toBe('architect'); + }); + + it('should return name unchanged if no .md extension', () => { + expect(extractPersonaDisplayName('coder')).toBe('coder'); + }); +}); + +describe('resolvePersona', () => { + it('should return empty object for undefined persona', () => { + expect(resolvePersona(undefined, {}, '/dir')).toEqual({}); + }); + + it('should use section mapping when available', () => { + mockExistsSync.mockReturnValue(true); + + const result = resolvePersona( + 'coder', + { personas: { coder: './personas/coder.md' } }, + '/dir', + ); + expect(result.personaSpec).toBe('./personas/coder.md'); + expect(result.personaPath).toBeDefined(); + }); + + it('should resolve path-based persona directly', () => { + mockExistsSync.mockReturnValue(true); + + const result = resolvePersona('./coder.md', {}, '/dir'); + expect(result.personaSpec).toBe('./coder.md'); + expect(result.personaPath).toBeDefined(); + }); + + it('should try candidate directories for name-based persona', () => { + mockExistsSync.mockImplementation((p) => p === '/facets/coder.md'); + + const result = resolvePersona('coder', {}, '/dir', ['/facets']); + expect(result.personaSpec).toBe('coder'); + expect(result.personaPath).toBe('/facets/coder.md'); + }); + + it('should fall back to pieceDir resolution when no candidateDirs match', () => { + mockExistsSync.mockImplementation((p) => p === join('/dir', 'coder')); + + const result = resolvePersona('coder', {}, '/dir'); + expect(result.personaSpec).toBe('coder'); + }); +}); diff --git a/src/__tests__/faceted-prompting/template.test.ts b/src/__tests__/faceted-prompting/template.test.ts new file mode 100644 index 0000000..8e558a2 --- /dev/null +++ b/src/__tests__/faceted-prompting/template.test.ts @@ -0,0 +1,108 @@ +/** + * Unit tests for faceted-prompting template engine. + */ + +import { describe, it, expect } from 'vitest'; +import { renderTemplate } from '../../faceted-prompting/index.js'; +import { + processConditionals, + substituteVariables, +} from '../../faceted-prompting/template.js'; + +describe('processConditionals', () => { + it('should include truthy block content', () => { + const template = '{{#if showGreeting}}Hello!{{/if}}'; + const result = processConditionals(template, { showGreeting: true }); + expect(result).toBe('Hello!'); + }); + + it('should exclude falsy block content', () => { + const template = '{{#if showGreeting}}Hello!{{/if}}'; + const result = processConditionals(template, { showGreeting: false }); + expect(result).toBe(''); + }); + + it('should handle else branch when truthy', () => { + const template = '{{#if isAdmin}}Admin panel{{else}}User panel{{/if}}'; + const result = processConditionals(template, { isAdmin: true }); + expect(result).toBe('Admin panel'); + }); + + it('should handle else branch when falsy', () => { + const template = '{{#if isAdmin}}Admin panel{{else}}User panel{{/if}}'; + const result = processConditionals(template, { isAdmin: false }); + expect(result).toBe('User panel'); + }); + + it('should treat non-empty string as truthy', () => { + const template = '{{#if name}}Name: provided{{/if}}'; + const result = processConditionals(template, { name: 'Alice' }); + expect(result).toBe('Name: provided'); + }); + + it('should treat empty string as falsy', () => { + const template = '{{#if name}}Name: provided{{/if}}'; + const result = processConditionals(template, { name: '' }); + expect(result).toBe(''); + }); + + it('should treat undefined variable as falsy', () => { + const template = '{{#if missing}}exists{{else}}missing{{/if}}'; + const result = processConditionals(template, {}); + expect(result).toBe('missing'); + }); + + it('should handle multiline content in blocks', () => { + const template = '{{#if hasContent}}line1\nline2\nline3{{/if}}'; + const result = processConditionals(template, { hasContent: true }); + expect(result).toBe('line1\nline2\nline3'); + }); +}); + +describe('substituteVariables', () => { + it('should replace variable with string value', () => { + const result = substituteVariables('Hello {{name}}!', { name: 'World' }); + expect(result).toBe('Hello World!'); + }); + + it('should replace true with string "true"', () => { + const result = substituteVariables('Value: {{flag}}', { flag: true }); + expect(result).toBe('Value: true'); + }); + + it('should replace false with empty string', () => { + const result = substituteVariables('Value: {{flag}}', { flag: false }); + expect(result).toBe('Value: '); + }); + + it('should replace undefined variable with empty string', () => { + const result = substituteVariables('Value: {{missing}}', {}); + expect(result).toBe('Value: '); + }); + + it('should handle multiple variables', () => { + const result = substituteVariables('{{greeting}} {{name}}!', { + greeting: 'Hello', + name: 'World', + }); + expect(result).toBe('Hello World!'); + }); +}); + +describe('renderTemplate', () => { + it('should process conditionals and then substitute variables', () => { + const template = '{{#if hasName}}Name: {{name}}{{else}}Anonymous{{/if}}'; + const result = renderTemplate(template, { hasName: true, name: 'Alice' }); + expect(result).toBe('Name: Alice'); + }); + + it('should handle template with no conditionals', () => { + const result = renderTemplate('Hello {{name}}!', { name: 'World' }); + expect(result).toBe('Hello World!'); + }); + + it('should handle template with no variables', () => { + const result = renderTemplate('Static text', {}); + expect(result).toBe('Static text'); + }); +}); diff --git a/src/__tests__/faceted-prompting/truncation.test.ts b/src/__tests__/faceted-prompting/truncation.test.ts new file mode 100644 index 0000000..9aa3a1a --- /dev/null +++ b/src/__tests__/faceted-prompting/truncation.test.ts @@ -0,0 +1,100 @@ +/** + * Unit tests for faceted-prompting truncation module. + */ + +import { describe, it, expect } from 'vitest'; +import { + trimContextContent, + renderConflictNotice, + prepareKnowledgeContent, + preparePolicyContent, +} from '../../faceted-prompting/index.js'; + +describe('trimContextContent', () => { + it('should return content unchanged when under limit', () => { + const result = trimContextContent('short content', 100); + expect(result.content).toBe('short content'); + expect(result.truncated).toBe(false); + }); + + it('should truncate content exceeding limit', () => { + const longContent = 'a'.repeat(150); + const result = trimContextContent(longContent, 100); + expect(result.content).toBe('a'.repeat(100) + '\n...TRUNCATED...'); + expect(result.truncated).toBe(true); + }); + + it('should not truncate content at exact limit', () => { + const exactContent = 'b'.repeat(100); + const result = trimContextContent(exactContent, 100); + expect(result.content).toBe(exactContent); + expect(result.truncated).toBe(false); + }); +}); + +describe('renderConflictNotice', () => { + it('should return the standard conflict notice', () => { + const notice = renderConflictNotice(); + expect(notice).toBe('If prompt content conflicts with source files, source files take precedence.'); + }); +}); + +describe('prepareKnowledgeContent', () => { + it('should append conflict notice without sourcePath', () => { + const result = prepareKnowledgeContent('knowledge text', 2000); + expect(result).toContain('knowledge text'); + expect(result).toContain('If prompt content conflicts with source files'); + expect(result).not.toContain('Knowledge Source:'); + }); + + it('should append source path when provided', () => { + const result = prepareKnowledgeContent('knowledge text', 2000, '/path/to/knowledge.md'); + expect(result).toContain('Knowledge Source: /path/to/knowledge.md'); + expect(result).toContain('If prompt content conflicts with source files'); + }); + + it('should append truncation notice when truncated with sourcePath', () => { + const longContent = 'x'.repeat(3000); + const result = prepareKnowledgeContent(longContent, 2000, '/path/to/knowledge.md'); + expect(result).toContain('...TRUNCATED...'); + expect(result).toContain('Knowledge is truncated. You MUST consult the source files before making decisions.'); + expect(result).toContain('Knowledge Source: /path/to/knowledge.md'); + }); + + it('should not include truncation notice when truncated without sourcePath', () => { + const longContent = 'x'.repeat(3000); + const result = prepareKnowledgeContent(longContent, 2000); + expect(result).toContain('...TRUNCATED...'); + expect(result).not.toContain('Knowledge is truncated'); + }); +}); + +describe('preparePolicyContent', () => { + it('should append conflict notice without sourcePath', () => { + const result = preparePolicyContent('policy text', 2000); + expect(result).toContain('policy text'); + expect(result).toContain('If prompt content conflicts with source files'); + expect(result).not.toContain('Policy Source:'); + }); + + it('should append source path when provided', () => { + const result = preparePolicyContent('policy text', 2000, '/path/to/policy.md'); + expect(result).toContain('Policy Source: /path/to/policy.md'); + expect(result).toContain('If prompt content conflicts with source files'); + }); + + it('should append authoritative notice when truncated with sourcePath', () => { + const longContent = 'y'.repeat(3000); + const result = preparePolicyContent(longContent, 2000, '/path/to/policy.md'); + expect(result).toContain('...TRUNCATED...'); + expect(result).toContain('Policy is authoritative. If truncated, you MUST read the full policy file and follow it strictly.'); + expect(result).toContain('Policy Source: /path/to/policy.md'); + }); + + it('should not include authoritative notice when truncated without sourcePath', () => { + const longContent = 'y'.repeat(3000); + const result = preparePolicyContent(longContent, 2000); + expect(result).toContain('...TRUNCATED...'); + expect(result).not.toContain('Policy is authoritative'); + }); +}); diff --git a/src/__tests__/faceted-prompting/types.test.ts b/src/__tests__/faceted-prompting/types.test.ts new file mode 100644 index 0000000..6ad7c29 --- /dev/null +++ b/src/__tests__/faceted-prompting/types.test.ts @@ -0,0 +1,87 @@ +/** + * Unit tests for faceted-prompting type definitions. + * + * Verifies that types are correctly exported and usable. + */ + +import { describe, it, expect } from 'vitest'; +import type { + FacetKind, + FacetContent, + FacetSet, + ComposedPrompt, + ComposeOptions, +} from '../../faceted-prompting/index.js'; + +describe('FacetKind type', () => { + it('should accept valid facet kinds', () => { + const kinds: FacetKind[] = [ + 'personas', + 'policies', + 'knowledge', + 'instructions', + 'output-contracts', + ]; + expect(kinds).toHaveLength(5); + }); +}); + +describe('FacetContent interface', () => { + it('should accept body with sourcePath', () => { + const content: FacetContent = { + body: 'You are a helpful assistant.', + sourcePath: '/path/to/persona.md', + }; + expect(content.body).toBe('You are a helpful assistant.'); + expect(content.sourcePath).toBe('/path/to/persona.md'); + }); + + it('should accept body without sourcePath', () => { + const content: FacetContent = { + body: 'Inline content', + }; + expect(content.body).toBe('Inline content'); + expect(content.sourcePath).toBeUndefined(); + }); +}); + +describe('FacetSet interface', () => { + it('should accept a complete facet set', () => { + const set: FacetSet = { + persona: { body: 'You are a coder.' }, + policies: [{ body: 'Follow clean code.' }], + knowledge: [{ body: 'Architecture docs.' }], + instruction: { body: 'Implement the feature.' }, + }; + expect(set.persona?.body).toBe('You are a coder.'); + expect(set.policies).toHaveLength(1); + }); + + it('should accept a partial facet set', () => { + const set: FacetSet = { + instruction: { body: 'Do the task.' }, + }; + expect(set.persona).toBeUndefined(); + expect(set.instruction?.body).toBe('Do the task.'); + }); +}); + +describe('ComposedPrompt interface', () => { + it('should hold systemPrompt and userMessage', () => { + const prompt: ComposedPrompt = { + systemPrompt: 'You are a coder.', + userMessage: 'Implement feature X.', + }; + expect(prompt.systemPrompt).toBe('You are a coder.'); + expect(prompt.userMessage).toBe('Implement feature X.'); + }); +}); + +describe('ComposeOptions interface', () => { + it('should hold contextMaxChars', () => { + const options: ComposeOptions = { + contextMaxChars: 2000, + }; + expect(options.contextMaxChars).toBe(2000); + }); +}); diff --git a/src/__tests__/github-pr.test.ts b/src/__tests__/github-pr.test.ts index 2e640d4..895dd76 100644 --- a/src/__tests__/github-pr.test.ts +++ b/src/__tests__/github-pr.test.ts @@ -1,14 +1,64 @@ /** * Tests for github/pr module * - * Tests buildPrBody formatting. - * createPullRequest/pushBranch call `gh`/`git` CLI, not unit-tested here. + * Tests buildPrBody formatting and findExistingPr logic. + * createPullRequest/pushBranch/commentOnPr call `gh`/`git` CLI, not unit-tested here. */ -import { describe, it, expect } from 'vitest'; -import { buildPrBody } from '../infra/github/pr.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockExecFileSync = vi.fn(); +vi.mock('node:child_process', () => ({ + execFileSync: (...args: unknown[]) => mockExecFileSync(...args), +})); + +vi.mock('../infra/github/issue.js', () => ({ + checkGhCli: vi.fn().mockReturnValue({ available: true }), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), + getErrorMessage: (e: unknown) => String(e), +})); + +import { buildPrBody, findExistingPr } from '../infra/github/pr.js'; import type { GitHubIssue } from '../infra/github/types.js'; +describe('findExistingPr', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('オープンな PR がある場合はその PR を返す', () => { + mockExecFileSync.mockReturnValue(JSON.stringify([{ number: 42, url: 'https://github.com/org/repo/pull/42' }])); + + const result = findExistingPr('/project', 'task/fix-bug'); + + expect(result).toEqual({ number: 42, url: 'https://github.com/org/repo/pull/42' }); + }); + + it('PR がない場合は undefined を返す', () => { + mockExecFileSync.mockReturnValue(JSON.stringify([])); + + const result = findExistingPr('/project', 'task/fix-bug'); + + expect(result).toBeUndefined(); + }); + + it('gh CLI が失敗した場合は undefined を返す', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('gh: command not found'); }); + + const result = findExistingPr('/project', 'task/fix-bug'); + + expect(result).toBeUndefined(); + }); +}); + describe('buildPrBody', () => { it('should build body with single issue and report', () => { const issue: GitHubIssue = { diff --git a/src/__tests__/global-pieceCategories.test.ts b/src/__tests__/global-pieceCategories.test.ts index 286ac22..642759f 100644 --- a/src/__tests__/global-pieceCategories.test.ts +++ b/src/__tests__/global-pieceCategories.test.ts @@ -7,14 +7,35 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const loadGlobalConfigMock = vi.hoisted(() => vi.fn()); +const loadConfigMock = vi.hoisted(() => vi.fn()); vi.mock('../infra/config/paths.js', () => ({ getGlobalConfigDir: () => '/tmp/.takt', })); -vi.mock('../infra/config/global/globalConfig.js', () => ({ - loadGlobalConfig: loadGlobalConfigMock, +vi.mock('../infra/config/loadConfig.js', () => ({ + loadConfig: loadConfigMock, +})); + +vi.mock('../infra/config/resolvePieceConfigValue.js', () => ({ + resolvePieceConfigValue: (_projectDir: string, key: string) => { + const loaded = loadConfigMock() as Record>; + const global = loaded?.global ?? {}; + const project = loaded?.project ?? {}; + const merged: Record = { ...global, ...project }; + return merged[key]; + }, + resolvePieceConfigValues: (_projectDir: string, keys: readonly string[]) => { + const loaded = loadConfigMock() as Record>; + const global = loaded?.global ?? {}; + const project = loaded?.project ?? {}; + const merged: Record = { ...global, ...project }; + const result: Record = {}; + for (const key of keys) { + result[key] = merged[key]; + } + return result; + }, })); const { getPieceCategoriesPath, resetPieceCategories } = await import( @@ -28,17 +49,18 @@ function createTempCategoriesPath(): string { describe('getPieceCategoriesPath', () => { beforeEach(() => { - loadGlobalConfigMock.mockReset(); + loadConfigMock.mockReset(); }); it('should return configured path when pieceCategoriesFile is set', () => { // Given - loadGlobalConfigMock.mockReturnValue({ - pieceCategoriesFile: '/custom/piece-categories.yaml', + loadConfigMock.mockReturnValue({ + global: { pieceCategoriesFile: '/custom/piece-categories.yaml' }, + project: {}, }); // When - const path = getPieceCategoriesPath(); + const path = getPieceCategoriesPath(process.cwd()); // Then expect(path).toBe('/custom/piece-categories.yaml'); @@ -46,10 +68,10 @@ describe('getPieceCategoriesPath', () => { it('should return default path when pieceCategoriesFile is not set', () => { // Given - loadGlobalConfigMock.mockReturnValue({}); + loadConfigMock.mockReturnValue({ global: {}, project: {} }); // When - const path = getPieceCategoriesPath(); + const path = getPieceCategoriesPath(process.cwd()); // Then expect(path).toBe('/tmp/.takt/preferences/piece-categories.yaml'); @@ -57,12 +79,12 @@ describe('getPieceCategoriesPath', () => { it('should rethrow when global config loading fails', () => { // Given - loadGlobalConfigMock.mockImplementation(() => { + loadConfigMock.mockImplementation(() => { throw new Error('invalid global config'); }); // When / Then - expect(() => getPieceCategoriesPath()).toThrow('invalid global config'); + expect(() => getPieceCategoriesPath(process.cwd())).toThrow('invalid global config'); }); }); @@ -70,7 +92,7 @@ describe('resetPieceCategories', () => { const tempRoots: string[] = []; beforeEach(() => { - loadGlobalConfigMock.mockReset(); + loadConfigMock.mockReset(); }); afterEach(() => { @@ -84,12 +106,13 @@ describe('resetPieceCategories', () => { // Given const categoriesPath = createTempCategoriesPath(); tempRoots.push(dirname(dirname(categoriesPath))); - loadGlobalConfigMock.mockReturnValue({ - pieceCategoriesFile: categoriesPath, + loadConfigMock.mockReturnValue({ + global: { pieceCategoriesFile: categoriesPath }, + project: {}, }); // When - resetPieceCategories(); + resetPieceCategories(process.cwd()); // Then expect(existsSync(dirname(categoriesPath))).toBe(true); @@ -102,14 +125,15 @@ describe('resetPieceCategories', () => { const categoriesDir = dirname(categoriesPath); const tempRoot = dirname(categoriesDir); tempRoots.push(tempRoot); - loadGlobalConfigMock.mockReturnValue({ - pieceCategoriesFile: categoriesPath, + loadConfigMock.mockReturnValue({ + global: { pieceCategoriesFile: categoriesPath }, + project: {}, }); mkdirSync(categoriesDir, { recursive: true }); writeFileSync(categoriesPath, 'piece_categories:\n old:\n - stale-piece\n', 'utf-8'); // When - resetPieceCategories(); + resetPieceCategories(process.cwd()); // Then expect(readFileSync(categoriesPath, 'utf-8')).toBe('piece_categories: {}\n'); diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index d02f0ff..d34f896 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -39,7 +39,6 @@ describe('loadGlobalConfig', () => { const config = loadGlobalConfig(); expect(config.language).toBe('en'); - expect(config.defaultPiece).toBe('default'); expect(config.logLevel).toBe('info'); expect(config.provider).toBe('claude'); expect(config.model).toBeUndefined(); @@ -79,6 +78,23 @@ describe('loadGlobalConfig', () => { expect(config.logLevel).toBe('debug'); }); + it('should apply env override for nested provider_options key', () => { + const original = process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS; + try { + process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS = 'true'; + invalidateGlobalConfigCache(); + + const config = loadGlobalConfig(); + expect(config.providerOptions?.claude?.sandbox?.allowUnsandboxedCommands).toBe(true); + } finally { + if (original === undefined) { + delete process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS; + } else { + process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS = original; + } + } + }); + it('should load pipeline config from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); diff --git a/src/__tests__/globalConfig-resolvers.test.ts b/src/__tests__/globalConfig-resolvers.test.ts index 9f211cc..8314198 100644 --- a/src/__tests__/globalConfig-resolvers.test.ts +++ b/src/__tests__/globalConfig-resolvers.test.ts @@ -97,7 +97,6 @@ describe('GlobalConfig load/save with API keys', () => { it('should load config with API keys from YAML', () => { const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', 'anthropic_api_key: sk-ant-from-yaml', @@ -113,7 +112,6 @@ describe('GlobalConfig load/save with API keys', () => { it('should load config without API keys', () => { const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', ].join('\n'); @@ -128,7 +126,6 @@ describe('GlobalConfig load/save with API keys', () => { // Write initial config const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', ].join('\n'); @@ -147,7 +144,6 @@ describe('GlobalConfig load/save with API keys', () => { it('should not persist API keys when not set', () => { const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', ].join('\n'); @@ -183,7 +179,6 @@ describe('resolveAnthropicApiKey', () => { process.env['TAKT_ANTHROPIC_API_KEY'] = 'sk-ant-from-env'; const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', 'anthropic_api_key: sk-ant-from-yaml', @@ -198,7 +193,6 @@ describe('resolveAnthropicApiKey', () => { delete process.env['TAKT_ANTHROPIC_API_KEY']; const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', 'anthropic_api_key: sk-ant-from-yaml', @@ -213,7 +207,6 @@ describe('resolveAnthropicApiKey', () => { delete process.env['TAKT_ANTHROPIC_API_KEY']; const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', ].join('\n'); @@ -254,7 +247,6 @@ describe('resolveOpenaiApiKey', () => { process.env['TAKT_OPENAI_API_KEY'] = 'sk-openai-from-env'; const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', 'openai_api_key: sk-openai-from-yaml', @@ -269,7 +261,6 @@ describe('resolveOpenaiApiKey', () => { delete process.env['TAKT_OPENAI_API_KEY']; const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', 'openai_api_key: sk-openai-from-yaml', @@ -284,7 +275,6 @@ describe('resolveOpenaiApiKey', () => { delete process.env['TAKT_OPENAI_API_KEY']; const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', ].join('\n'); @@ -318,7 +308,6 @@ describe('resolveCodexCliPath', () => { process.env['TAKT_CODEX_CLI_PATH'] = envCodexPath; const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: codex', `codex_cli_path: ${configCodexPath}`, @@ -334,7 +323,6 @@ describe('resolveCodexCliPath', () => { const configCodexPath = createExecutableFile('config-codex'); const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: codex', `codex_cli_path: ${configCodexPath}`, @@ -349,7 +337,6 @@ describe('resolveCodexCliPath', () => { delete process.env['TAKT_CODEX_CLI_PATH']; const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: codex', ].join('\n'); @@ -395,7 +382,6 @@ describe('resolveCodexCliPath', () => { delete process.env['TAKT_CODEX_CLI_PATH']; const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: codex', `codex_cli_path: ${join(testDir, 'missing-codex-from-config')}`, @@ -427,7 +413,6 @@ describe('resolveOpencodeApiKey', () => { process.env['TAKT_OPENCODE_API_KEY'] = 'sk-opencode-from-env'; const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', 'opencode_api_key: sk-opencode-from-yaml', @@ -442,7 +427,6 @@ describe('resolveOpencodeApiKey', () => { delete process.env['TAKT_OPENCODE_API_KEY']; const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', 'opencode_api_key: sk-opencode-from-yaml', @@ -457,7 +441,6 @@ describe('resolveOpencodeApiKey', () => { delete process.env['TAKT_OPENCODE_API_KEY']; const yaml = [ 'language: en', - 'default_piece: default', 'log_level: info', 'provider: claude', ].join('\n'); diff --git a/src/__tests__/instructMode.test.ts b/src/__tests__/instructMode.test.ts index 5ebb1a3..bcd200d 100644 --- a/src/__tests__/instructMode.test.ts +++ b/src/__tests__/instructMode.test.ts @@ -149,9 +149,10 @@ describe('runInstructMode', () => { expect(result.action).toBe('cancel'); }); - it('should use custom action selector without create_issue option', async () => { + it('should exclude execute from action selector options', async () => { setupRawStdin(toRawInputs(['task', '/go'])); setupMockProvider(['response', 'Task summary.']); + mockSelectOption.mockResolvedValue('save_task'); await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', ''); @@ -161,7 +162,7 @@ describe('runInstructMode', () => { expect(selectCall).toBeDefined(); const options = selectCall![1] as Array<{ value: string }>; const values = options.map((o) => o.value); - expect(values).toContain('execute'); + expect(values).not.toContain('execute'); expect(values).toContain('save_task'); expect(values).toContain('continue'); expect(values).not.toContain('create_issue'); @@ -215,4 +216,63 @@ describe('runInstructMode', () => { }), ); }); + + it('should inject previousOrderContent into template variables when provided', async () => { + setupRawStdin(toRawInputs(['/cancel'])); + setupMockProvider([]); + + await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', '', undefined, undefined, '# Previous Order\nDo the thing'); + + expect(mockLoadTemplate).toHaveBeenCalledWith( + 'score_instruct_system_prompt', + 'en', + expect.objectContaining({ + hasOrderContent: true, + orderContent: '# Previous Order\nDo the thing', + }), + ); + }); + + it('should set hasOrderContent=false when previousOrderContent is null', async () => { + setupRawStdin(toRawInputs(['/cancel'])); + setupMockProvider([]); + + await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', '', undefined, undefined, null); + + expect(mockLoadTemplate).toHaveBeenCalledWith( + 'score_instruct_system_prompt', + 'en', + expect.objectContaining({ + hasOrderContent: false, + orderContent: '', + }), + ); + }); + + it('should return execute with previous order content on /replay when previousOrderContent is set', async () => { + setupRawStdin(toRawInputs(['/replay'])); + setupMockProvider([]); + + const previousOrder = '# Previous Order\nDo the thing'; + const result = await runInstructMode( + '/project', 'branch context', 'feature-branch', 'my-task', 'Do something', '', + undefined, undefined, previousOrder, + ); + + expect(result.action).toBe('execute'); + expect(result.task).toBe(previousOrder); + }); + + it('should show error and continue when /replay is used without previousOrderContent', async () => { + setupRawStdin(toRawInputs(['/replay', '/cancel'])); + setupMockProvider([]); + + const result = await runInstructMode( + '/project', 'branch context', 'feature-branch', 'my-task', 'Do something', '', + undefined, undefined, null, + ); + + expect(result.action).toBe('cancel'); + expect(mockInfo).toHaveBeenCalledWith('Mock label'); + }); }); diff --git a/src/__tests__/interactive-summary.test.ts b/src/__tests__/interactive-summary.test.ts index b999491..310ea09 100644 --- a/src/__tests__/interactive-summary.test.ts +++ b/src/__tests__/interactive-summary.test.ts @@ -6,8 +6,10 @@ import { describe, expect, it } from 'vitest'; import { buildSummaryPrompt, + buildSummaryActionOptions, formatTaskHistorySummary, type PieceContext, + type SummaryActionLabels, type TaskHistorySummaryItem, } from '../features/interactive/interactive.js'; @@ -100,3 +102,54 @@ describe('buildSummaryPrompt', () => { expect(summary).toContain('User: Improve parser'); }); }); + +describe('buildSummaryActionOptions', () => { + const labels: SummaryActionLabels = { + execute: 'Execute now', + saveTask: 'Save as Task', + continue: 'Continue editing', + }; + + it('should include all base actions when no exclude is given', () => { + const options = buildSummaryActionOptions(labels); + const values = options.map((o) => o.value); + + expect(values).toEqual(['execute', 'save_task', 'continue']); + }); + + it('should exclude specified actions', () => { + const options = buildSummaryActionOptions(labels, [], ['execute']); + const values = options.map((o) => o.value); + + expect(values).toEqual(['save_task', 'continue']); + expect(values).not.toContain('execute'); + }); + + it('should exclude multiple actions', () => { + const options = buildSummaryActionOptions(labels, [], ['execute', 'continue']); + const values = options.map((o) => o.value); + + expect(values).toEqual(['save_task']); + }); + + it('should handle append and exclude together', () => { + const labelsWithIssue: SummaryActionLabels = { + ...labels, + createIssue: 'Create Issue', + }; + const options = buildSummaryActionOptions(labelsWithIssue, ['create_issue'], ['execute']); + const values = options.map((o) => o.value); + + expect(values).toEqual(['save_task', 'continue', 'create_issue']); + expect(values).not.toContain('execute'); + }); + + it('should return empty exclude by default (backward compatible)', () => { + const options = buildSummaryActionOptions(labels, []); + const values = options.map((o) => o.value); + + expect(values).toContain('execute'); + expect(values).toContain('save_task'); + expect(values).toContain('continue'); + }); +}); diff --git a/src/__tests__/it-config-provider-options.test.ts b/src/__tests__/it-config-provider-options.test.ts new file mode 100644 index 0000000..5659f53 --- /dev/null +++ b/src/__tests__/it-config-provider-options.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; + +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../agents/ai-judge.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + callAiJudge: vi.fn().mockResolvedValue(-1), + }; +}); + +vi.mock('../core/piece/phase-runner.js', () => ({ + needsStatusJudgmentPhase: vi.fn().mockReturnValue(false), + runReportPhase: vi.fn().mockResolvedValue(undefined), + runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), +})); + +import { runAgent } from '../agents/runner.js'; +import { executeTask } from '../features/tasks/execute/taskExecution.js'; +import { invalidateGlobalConfigCache } from '../infra/config/index.js'; + +interface TestEnv { + projectDir: string; + globalDir: string; +} + +function createEnv(): TestEnv { + const root = join(tmpdir(), `takt-it-config-${randomUUID()}`); + const projectDir = join(root, 'project'); + const globalDir = join(root, 'global'); + + mkdirSync(projectDir, { recursive: true }); + mkdirSync(join(projectDir, '.takt', 'pieces', 'personas'), { recursive: true }); + mkdirSync(globalDir, { recursive: true }); + + writeFileSync( + join(projectDir, '.takt', 'pieces', 'config-it.yaml'), + [ + 'name: config-it', + 'description: config provider options integration test', + 'max_movements: 3', + 'initial_movement: plan', + 'movements:', + ' - name: plan', + ' persona: ./personas/planner.md', + ' instruction: "{task}"', + ' rules:', + ' - condition: done', + ' next: COMPLETE', + ].join('\n'), + 'utf-8', + ); + writeFileSync(join(projectDir, '.takt', 'pieces', 'personas', 'planner.md'), 'You are planner.', 'utf-8'); + + return { projectDir, globalDir }; +} + +function setGlobalConfig(globalDir: string, body: string): void { + writeFileSync(join(globalDir, 'config.yaml'), body, 'utf-8'); +} + +function setProjectConfig(projectDir: string, body: string): void { + writeFileSync(join(projectDir, '.takt', 'config.yaml'), body, 'utf-8'); +} + +function makeDoneResponse() { + return { + persona: 'planner', + status: 'done', + content: '[PLAN:1]\ndone', + timestamp: new Date(), + sessionId: 'session-it', + }; +} + +describe('IT: config provider_options reflection', () => { + let env: TestEnv; + let originalConfigDir: string | undefined; + let originalEnvCodex: string | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + env = createEnv(); + originalConfigDir = process.env.TAKT_CONFIG_DIR; + originalEnvCodex = process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + + process.env.TAKT_CONFIG_DIR = env.globalDir; + delete process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + invalidateGlobalConfigCache(); + + vi.mocked(runAgent).mockResolvedValue(makeDoneResponse()); + }); + + afterEach(() => { + if (originalConfigDir === undefined) { + delete process.env.TAKT_CONFIG_DIR; + } else { + process.env.TAKT_CONFIG_DIR = originalConfigDir; + } + if (originalEnvCodex === undefined) { + delete process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + } else { + process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = originalEnvCodex; + } + invalidateGlobalConfigCache(); + rmSync(join(env.projectDir, '..'), { recursive: true, force: true }); + }); + + it('global provider_options should be passed to runAgent', async () => { + setGlobalConfig( + env.globalDir, + [ + 'provider_options:', + ' codex:', + ' network_access: true', + ].join('\n'), + ); + + const ok = await executeTask({ + task: 'test task', + cwd: env.projectDir, + projectCwd: env.projectDir, + pieceIdentifier: 'config-it', + }); + + expect(ok).toBe(true); + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: true }, + }); + }); + + it('project provider_options should override global provider_options', async () => { + setGlobalConfig( + env.globalDir, + [ + 'provider_options:', + ' codex:', + ' network_access: true', + ].join('\n'), + ); + setProjectConfig( + env.projectDir, + [ + 'provider_options:', + ' codex:', + ' network_access: false', + ].join('\n'), + ); + + const ok = await executeTask({ + task: 'test task', + cwd: env.projectDir, + projectCwd: env.projectDir, + pieceIdentifier: 'config-it', + }); + + expect(ok).toBe(true); + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: false }, + }); + }); + + it('env provider_options should override yaml provider_options', async () => { + setGlobalConfig( + env.globalDir, + [ + 'provider_options:', + ' codex:', + ' network_access: true', + ].join('\n'), + ); + process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = 'false'; + invalidateGlobalConfigCache(); + + const ok = await executeTask({ + task: 'test task', + cwd: env.projectDir, + projectCwd: env.projectDir, + pieceIdentifier: 'config-it', + }); + + expect(ok).toBe(true); + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: false }, + }); + }); +}); + diff --git a/src/__tests__/it-notification-sound.test.ts b/src/__tests__/it-notification-sound.test.ts index 5f4d4a0..0959182 100644 --- a/src/__tests__/it-notification-sound.test.ts +++ b/src/__tests__/it-notification-sound.test.ts @@ -118,6 +118,19 @@ vi.mock('../infra/config/index.js', () => ({ loadWorktreeSessions: vi.fn().mockReturnValue({}), updateWorktreeSession: vi.fn(), loadGlobalConfig: mockLoadGlobalConfig, + loadConfig: vi.fn().mockImplementation(() => ({ + global: mockLoadGlobalConfig(), + project: {}, + })), + resolvePieceConfigValues: (_projectDir: string, keys: readonly string[]) => { + const global = mockLoadGlobalConfig() as Record; + const config = { ...global, piece: 'default', provider: global.provider ?? 'claude', verbose: false }; + const result: Record = {}; + for (const key of keys) { + result[key] = config[key]; + } + return result; + }, saveSessionState: vi.fn(), ensureDir: vi.fn(), writeFileAtomic: vi.fn(), diff --git a/src/__tests__/it-piece-loader.test.ts b/src/__tests__/it-piece-loader.test.ts index 1572c14..448b6dd 100644 --- a/src/__tests__/it-piece-loader.test.ts +++ b/src/__tests__/it-piece-loader.test.ts @@ -4,7 +4,7 @@ * Tests the 3-tier piece resolution (project-local → user → builtin) * and YAML parsing including special rule syntax (ai(), all(), any()). * - * Mocked: globalConfig (for language/builtins) + * Mocked: loadConfig (for language/builtins) * Not mocked: loadPiece, parsePiece, rule parsing */ @@ -18,9 +18,24 @@ const languageState = vi.hoisted(() => ({ value: 'en' as 'en' | 'ja' })); vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), - getLanguage: vi.fn(() => languageState.value), - getDisabledBuiltins: vi.fn().mockReturnValue([]), - getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), +})); + +vi.mock('../infra/config/resolveConfigValue.js', () => ({ + resolveConfigValue: vi.fn((_cwd: string, key: string) => { + if (key === 'language') return languageState.value; + if (key === 'enableBuiltinPieces') return true; + if (key === 'disabledBuiltins') return []; + return undefined; + }), + resolveConfigValues: vi.fn((_cwd: string, keys: readonly string[]) => { + const result: Record = {}; + for (const key of keys) { + if (key === 'language') result[key] = languageState.value; + if (key === 'enableBuiltinPieces') result[key] = true; + if (key === 'disabledBuiltins') result[key] = []; + } + return result; + }), })); // --- Imports (after mocks) --- @@ -38,6 +53,7 @@ function createTestDir(): string { describe('Piece Loader IT: builtin piece loading', () => { let testDir: string; + const builtinNames = listBuiltinPieceNames(process.cwd(), { includeDisabled: true }); beforeEach(() => { testDir = createTestDir(); @@ -48,8 +64,6 @@ describe('Piece Loader IT: builtin piece loading', () => { rmSync(testDir, { recursive: true, force: true }); }); - const builtinNames = listBuiltinPieceNames({ includeDisabled: true }); - for (const name of builtinNames) { it(`should load builtin piece: ${name}`, () => { const config = loadPiece(name, testDir); @@ -85,7 +99,7 @@ describe('Piece Loader IT: builtin piece loading', () => { it('should load e2e-test as a builtin piece in ja locale', () => { languageState.value = 'ja'; - const jaBuiltinNames = listBuiltinPieceNames({ includeDisabled: true }); + const jaBuiltinNames = listBuiltinPieceNames(testDir, { includeDisabled: true }); expect(jaBuiltinNames).toContain('e2e-test'); const config = loadPiece('e2e-test', testDir); diff --git a/src/__tests__/it-piece-patterns.test.ts b/src/__tests__/it-piece-patterns.test.ts index 9c1fb65..c320cca 100644 --- a/src/__tests__/it-piece-patterns.test.ts +++ b/src/__tests__/it-piece-patterns.test.ts @@ -57,6 +57,24 @@ vi.mock('../infra/config/project/projectConfig.js', () => ({ loadProjectConfig: vi.fn().mockReturnValue({}), })); +vi.mock('../infra/config/resolveConfigValue.js', () => ({ + resolveConfigValue: vi.fn((_cwd: string, key: string) => { + if (key === 'language') return 'en'; + if (key === 'enableBuiltinPieces') return true; + if (key === 'disabledBuiltins') return []; + return undefined; + }), + resolveConfigValues: vi.fn((_cwd: string, keys: readonly string[]) => { + const result: Record = {}; + for (const key of keys) { + if (key === 'language') result[key] = 'en'; + if (key === 'enableBuiltinPieces') result[key] = true; + if (key === 'disabledBuiltins') result[key] = []; + } + return result; + }), +})); + // --- Imports (after mocks) --- import { PieceEngine } from '../core/piece/index.js'; diff --git a/src/__tests__/it-pipeline-modes.test.ts b/src/__tests__/it-pipeline-modes.test.ts index ee9314b..b419cf6 100644 --- a/src/__tests__/it-pipeline-modes.test.ts +++ b/src/__tests__/it-pipeline-modes.test.ts @@ -109,7 +109,6 @@ vi.mock('../infra/config/paths.js', async (importOriginal) => { updatePersonaSession: vi.fn(), loadWorktreeSessions: vi.fn().mockReturnValue({}), updateWorktreeSession: vi.fn(), - getCurrentPiece: vi.fn().mockReturnValue('default'), getProjectConfigDir: vi.fn().mockImplementation((cwd: string) => join(cwd, '.takt')), }; }); @@ -118,7 +117,11 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { const original = await importOriginal(); return { ...original, - loadGlobalConfig: vi.fn().mockReturnValue({}), + loadGlobalConfig: vi.fn().mockReturnValue({ + language: 'en', + enableBuiltinPieces: true, + disabledBuiltins: [], + }), getLanguage: vi.fn().mockReturnValue('en'), getDisabledBuiltins: vi.fn().mockReturnValue([]), }; diff --git a/src/__tests__/it-pipeline.test.ts b/src/__tests__/it-pipeline.test.ts index 1262957..2410ec2 100644 --- a/src/__tests__/it-pipeline.test.ts +++ b/src/__tests__/it-pipeline.test.ts @@ -91,7 +91,6 @@ vi.mock('../infra/config/paths.js', async (importOriginal) => { updatePersonaSession: vi.fn(), loadWorktreeSessions: vi.fn().mockReturnValue({}), updateWorktreeSession: vi.fn(), - getCurrentPiece: vi.fn().mockReturnValue('default'), getProjectConfigDir: vi.fn().mockImplementation((cwd: string) => join(cwd, '.takt')), }; }); @@ -100,7 +99,11 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { const original = await importOriginal(); return { ...original, - loadGlobalConfig: vi.fn().mockReturnValue({}), + loadGlobalConfig: vi.fn().mockReturnValue({ + language: 'en', + enableBuiltinPieces: true, + disabledBuiltins: [], + }), getLanguage: vi.fn().mockReturnValue('en'), }; }); diff --git a/src/__tests__/it-retry-mode.test.ts b/src/__tests__/it-retry-mode.test.ts index bd4cac7..87f6b0f 100644 --- a/src/__tests__/it-retry-mode.test.ts +++ b/src/__tests__/it-retry-mode.test.ts @@ -191,6 +191,7 @@ describe('E2E: Retry mode with failure context injection', () => { const retryContext: RetryContext = { failure: { taskName: 'implement-auth', + taskContent: 'Implement authentication feature', createdAt: '2026-02-15T10:00:00Z', failedMovement: 'review', error: 'Timeout after 300s', @@ -205,9 +206,10 @@ describe('E2E: Retry mode with failure context injection', () => { movementPreviews: [], }, run: null, + previousOrderContent: null, }; - const result = await runRetryMode(tmpDir, retryContext); + const result = await runRetryMode(tmpDir, retryContext, null); // Verify: system prompt contains failure information expect(capture.systemPrompts.length).toBeGreaterThan(0); @@ -252,6 +254,7 @@ describe('E2E: Retry mode with failure context injection', () => { const retryContext: RetryContext = { failure: { taskName: 'build-login', + taskContent: 'Build login page with OAuth2', createdAt: '2026-02-15T14:00:00Z', failedMovement: 'implement', error: 'CSS compilation failed', @@ -274,9 +277,10 @@ describe('E2E: Retry mode with failure context injection', () => { movementLogs: formatted.runMovementLogs, reports: formatted.runReports, }, + previousOrderContent: null, }; - const result = await runRetryMode(tmpDir, retryContext); + const result = await runRetryMode(tmpDir, retryContext, null); // Verify: system prompt contains BOTH failure info and run session data const systemPrompt = capture.systemPrompts[0]!; @@ -314,6 +318,7 @@ describe('E2E: Retry mode with failure context injection', () => { const retryContext: RetryContext = { failure: { taskName: 'fix-tests', + taskContent: 'Fix failing test suite', createdAt: '2026-02-15T16:00:00Z', failedMovement: '', error: 'Test suite failed', @@ -328,9 +333,10 @@ describe('E2E: Retry mode with failure context injection', () => { movementPreviews: [], }, run: null, + previousOrderContent: null, }; - await runRetryMode(tmpDir, retryContext); + await runRetryMode(tmpDir, retryContext, null); const systemPrompt = capture.systemPrompts[0]!; expect(systemPrompt).toContain('Existing Retry Note'); @@ -348,6 +354,7 @@ describe('E2E: Retry mode with failure context injection', () => { const retryContext: RetryContext = { failure: { taskName: 'some-task', + taskContent: 'Complete some task', createdAt: '2026-02-15T12:00:00Z', failedMovement: 'plan', error: 'Unknown error', @@ -362,9 +369,10 @@ describe('E2E: Retry mode with failure context injection', () => { movementPreviews: [], }, run: null, + previousOrderContent: null, }; - const result = await runRetryMode(tmpDir, retryContext); + const result = await runRetryMode(tmpDir, retryContext, null); expect(result.action).toBe('cancel'); expect(result.task).toBe(''); @@ -385,6 +393,7 @@ describe('E2E: Retry mode with failure context injection', () => { const retryContext: RetryContext = { failure: { taskName: 'optimize-review', + taskContent: 'Optimize the review step', createdAt: '2026-02-15T18:00:00Z', failedMovement: 'review', error: 'Timeout', @@ -399,9 +408,10 @@ describe('E2E: Retry mode with failure context injection', () => { movementPreviews: [], }, run: null, + previousOrderContent: null, }; - const result = await runRetryMode(tmpDir, retryContext); + const result = await runRetryMode(tmpDir, retryContext, null); expect(result.action).toBe('execute'); expect(result.task).toBe('Increase review timeout to 600s and add retry logic.'); diff --git a/src/__tests__/it-run-config-provider-options.test.ts b/src/__tests__/it-run-config-provider-options.test.ts new file mode 100644 index 0000000..4572f9a --- /dev/null +++ b/src/__tests__/it-run-config-provider-options.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; + +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../agents/ai-judge.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + callAiJudge: vi.fn().mockResolvedValue(-1), + }; +}); + +vi.mock('../core/piece/phase-runner.js', () => ({ + needsStatusJudgmentPhase: vi.fn().mockReturnValue(false), + runReportPhase: vi.fn().mockResolvedValue(undefined), + runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), + notifySuccess: vi.fn(), + notifyError: vi.fn(), + sendSlackNotification: vi.fn(), + getSlackWebhookUrl: vi.fn(() => undefined), +})); + +import { runAllTasks } from '../features/tasks/index.js'; +import { TaskRunner } from '../infra/task/index.js'; +import { runAgent } from '../agents/runner.js'; +import { invalidateGlobalConfigCache } from '../infra/config/index.js'; + +interface TestEnv { + root: string; + projectDir: string; + globalDir: string; +} + +function createEnv(): TestEnv { + const root = join(tmpdir(), `takt-it-run-config-${randomUUID()}`); + const projectDir = join(root, 'project'); + const globalDir = join(root, 'global'); + + mkdirSync(join(projectDir, '.takt', 'pieces', 'personas'), { recursive: true }); + mkdirSync(globalDir, { recursive: true }); + + writeFileSync( + join(projectDir, '.takt', 'pieces', 'run-config-it.yaml'), + [ + 'name: run-config-it', + 'description: run config provider options integration test', + 'max_movements: 3', + 'initial_movement: plan', + 'movements:', + ' - name: plan', + ' persona: ./personas/planner.md', + ' instruction: "{task}"', + ' rules:', + ' - condition: done', + ' next: COMPLETE', + ].join('\n'), + 'utf-8', + ); + writeFileSync(join(projectDir, '.takt', 'pieces', 'personas', 'planner.md'), 'You are planner.', 'utf-8'); + + return { root, projectDir, globalDir }; +} + +function setGlobalConfig(globalDir: string, body: string): void { + writeFileSync(join(globalDir, 'config.yaml'), body, 'utf-8'); +} + +function setProjectConfig(projectDir: string, body: string): void { + writeFileSync(join(projectDir, '.takt', 'config.yaml'), body, 'utf-8'); +} + +function mockDoneResponse() { + return { + persona: 'planner', + status: 'done', + content: '[PLAN:1]\ndone', + timestamp: new Date(), + sessionId: 'session-it', + }; +} + +describe('IT: runAllTasks provider_options reflection', () => { + let env: TestEnv; + let originalConfigDir: string | undefined; + let originalEnvCodex: string | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + env = createEnv(); + originalConfigDir = process.env.TAKT_CONFIG_DIR; + originalEnvCodex = process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + process.env.TAKT_CONFIG_DIR = env.globalDir; + delete process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + invalidateGlobalConfigCache(); + + vi.mocked(runAgent).mockResolvedValue(mockDoneResponse()); + + const runner = new TaskRunner(env.projectDir); + runner.addTask('test task'); + }); + + afterEach(() => { + if (originalConfigDir === undefined) { + delete process.env.TAKT_CONFIG_DIR; + } else { + process.env.TAKT_CONFIG_DIR = originalConfigDir; + } + if (originalEnvCodex === undefined) { + delete process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + } else { + process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = originalEnvCodex; + } + invalidateGlobalConfigCache(); + rmSync(env.root, { recursive: true, force: true }); + }); + + it('project provider_options should override global in runAllTasks flow', async () => { + setGlobalConfig(env.globalDir, [ + 'provider_options:', + ' codex:', + ' network_access: true', + ].join('\n')); + setProjectConfig(env.projectDir, [ + 'provider_options:', + ' codex:', + ' network_access: false', + ].join('\n')); + + await runAllTasks(env.projectDir, 'run-config-it'); + + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: false }, + }); + }); + + it('env provider_options should override yaml in runAllTasks flow', async () => { + setGlobalConfig(env.globalDir, [ + 'provider_options:', + ' codex:', + ' network_access: false', + ].join('\n')); + setProjectConfig(env.projectDir, [ + 'provider_options:', + ' codex:', + ' network_access: false', + ].join('\n')); + process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = 'true'; + invalidateGlobalConfigCache(); + + await runAllTasks(env.projectDir, 'run-config-it'); + + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: true }, + }); + }); +}); + diff --git a/src/__tests__/it-sigint-interrupt.test.ts b/src/__tests__/it-sigint-interrupt.test.ts index e15226b..398b46c 100644 --- a/src/__tests__/it-sigint-interrupt.test.ts +++ b/src/__tests__/it-sigint-interrupt.test.ts @@ -89,6 +89,18 @@ vi.mock('../infra/config/index.js', () => ({ loadWorktreeSessions: vi.fn().mockReturnValue({}), updateWorktreeSession: vi.fn(), loadGlobalConfig: vi.fn().mockReturnValue({ provider: 'claude' }), + loadConfig: vi.fn().mockReturnValue({ + global: { provider: 'claude' }, + project: {}, + }), + resolvePieceConfigValues: (_projectDir: string, keys: readonly string[]) => { + const config: Record = { provider: 'claude', piece: 'default', verbose: false }; + const result: Record = {}; + for (const key of keys) { + result[key] = config[key]; + } + return result; + }, saveSessionState: vi.fn(), ensureDir: vi.fn(), writeFileAtomic: vi.fn(), diff --git a/src/__tests__/loadPreviousOrderContent.test.ts b/src/__tests__/loadPreviousOrderContent.test.ts new file mode 100644 index 0000000..be72890 --- /dev/null +++ b/src/__tests__/loadPreviousOrderContent.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for loadPreviousOrderContent utility function. + * + * Verifies order.md loading from run directories, + * including happy path, missing slug, and missing file cases. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { loadPreviousOrderContent } from '../features/interactive/runSessionReader.js'; + +function createTmpDir(): string { + const dir = join(tmpdir(), `takt-order-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function createRunWithOrder(cwd: string, slug: string, taskContent: string, orderContent: string): void { + const runDir = join(cwd, '.takt', 'runs', slug); + mkdirSync(join(runDir, 'context', 'task'), { recursive: true }); + + const meta = { + task: taskContent, + piece: 'default', + status: 'completed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: `.takt/runs/${slug}/logs`, + reportDirectory: `.takt/runs/${slug}/reports`, + runSlug: slug, + }; + writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta), 'utf-8'); + writeFileSync(join(runDir, 'context', 'task', 'order.md'), orderContent, 'utf-8'); +} + +function createRunWithoutOrder(cwd: string, slug: string, taskContent: string): void { + const runDir = join(cwd, '.takt', 'runs', slug); + mkdirSync(runDir, { recursive: true }); + + const meta = { + task: taskContent, + piece: 'default', + status: 'completed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: `.takt/runs/${slug}/logs`, + reportDirectory: `.takt/runs/${slug}/reports`, + runSlug: slug, + }; + writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta), 'utf-8'); +} + +describe('loadPreviousOrderContent', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTmpDir(); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return order.md content when run and file exist', () => { + const taskContent = 'Implement feature X'; + const orderContent = '# Task\n\nImplement feature X with tests.'; + createRunWithOrder(tmpDir, 'run-feature-x', taskContent, orderContent); + + const result = loadPreviousOrderContent(tmpDir, taskContent); + + expect(result).toBe(orderContent); + }); + + it('should return null when no matching run exists', () => { + const result = loadPreviousOrderContent(tmpDir, 'Non-existent task'); + + expect(result).toBeNull(); + }); + + it('should return null when run exists but order.md is missing', () => { + const taskContent = 'Task without order'; + createRunWithoutOrder(tmpDir, 'run-no-order', taskContent); + + const result = loadPreviousOrderContent(tmpDir, taskContent); + + expect(result).toBeNull(); + }); + + it('should return null when .takt/runs directory does not exist', () => { + const emptyDir = join(tmpdir(), `takt-empty-${Date.now()}`); + mkdirSync(emptyDir, { recursive: true }); + + const result = loadPreviousOrderContent(emptyDir, 'any task'); + + expect(result).toBeNull(); + rmSync(emptyDir, { recursive: true, force: true }); + }); + + it('should match the correct run among multiple runs', () => { + createRunWithOrder(tmpDir, 'run-a', 'Task A', '# Order A'); + createRunWithOrder(tmpDir, 'run-b', 'Task B', '# Order B'); + + expect(loadPreviousOrderContent(tmpDir, 'Task A')).toBe('# Order A'); + expect(loadPreviousOrderContent(tmpDir, 'Task B')).toBe('# Order B'); + }); +}); diff --git a/src/__tests__/models.test.ts b/src/__tests__/models.test.ts index 1c93dcd..e336ba7 100644 --- a/src/__tests__/models.test.ts +++ b/src/__tests__/models.test.ts @@ -495,7 +495,6 @@ describe('GlobalConfigSchema', () => { const config = {}; const result = GlobalConfigSchema.parse(config); - expect(result.default_piece).toBe('default'); expect(result.log_level).toBe('info'); expect(result.provider).toBe('claude'); expect(result.observability).toBeUndefined(); @@ -503,7 +502,6 @@ describe('GlobalConfigSchema', () => { it('should accept valid config', () => { const config = { - default_piece: 'custom', log_level: 'debug' as const, observability: { provider_events: false, diff --git a/src/__tests__/naming.test.ts b/src/__tests__/naming.test.ts index d6bedbf..7cad07b 100644 --- a/src/__tests__/naming.test.ts +++ b/src/__tests__/naming.test.ts @@ -1,11 +1,11 @@ /** * Unit tests for task naming utilities * - * Tests nowIso, firstLine, and sanitizeTaskName functions. + * Tests nowIso and firstLine functions. */ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { nowIso, firstLine, sanitizeTaskName } from '../infra/task/naming.js'; +import { nowIso, firstLine } from '../infra/task/naming.js'; describe('nowIso', () => { afterEach(() => { @@ -54,34 +54,3 @@ describe('firstLine', () => { expect(firstLine(' \n ')).toBe(''); }); }); - -describe('sanitizeTaskName', () => { - it('should lowercase the input', () => { - expect(sanitizeTaskName('Hello World')).toBe('hello-world'); - }); - - it('should replace special characters with spaces then hyphens', () => { - expect(sanitizeTaskName('task@name#123')).toBe('task-name-123'); - }); - - it('should collapse multiple hyphens', () => { - expect(sanitizeTaskName('a---b')).toBe('a-b'); - }); - - it('should trim leading/trailing whitespace', () => { - expect(sanitizeTaskName(' hello ')).toBe('hello'); - }); - - it('should handle typical task names', () => { - expect(sanitizeTaskName('Fix: login bug (#42)')).toBe('fix-login-bug-42'); - }); - - it('should generate fallback name for empty result', () => { - const result = sanitizeTaskName('!@#$%'); - expect(result).toMatch(/^task-\d+$/); - }); - - it('should preserve numbers and lowercase letters', () => { - expect(sanitizeTaskName('abc123def')).toBe('abc123def'); - }); -}); diff --git a/src/__tests__/option-resolution-order.test.ts b/src/__tests__/option-resolution-order.test.ts index fc71099..bc90bfc 100644 --- a/src/__tests__/option-resolution-order.test.ts +++ b/src/__tests__/option-resolution-order.test.ts @@ -2,8 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { getProviderMock, - loadProjectConfigMock, - loadGlobalConfigMock, + loadConfigMock, loadCustomAgentsMock, loadAgentPromptMock, loadTemplateMock, @@ -15,8 +14,7 @@ const { return { getProviderMock: vi.fn(() => ({ setup: providerSetup })), - loadProjectConfigMock: vi.fn(), - loadGlobalConfigMock: vi.fn(), + loadConfigMock: vi.fn(), loadCustomAgentsMock: vi.fn(), loadAgentPromptMock: vi.fn(), loadTemplateMock: vi.fn(), @@ -30,10 +28,21 @@ vi.mock('../infra/providers/index.js', () => ({ })); vi.mock('../infra/config/index.js', () => ({ - loadProjectConfig: loadProjectConfigMock, - loadGlobalConfig: loadGlobalConfigMock, + loadConfig: loadConfigMock, loadCustomAgents: loadCustomAgentsMock, loadAgentPrompt: loadAgentPromptMock, + resolveConfigValues: (_projectDir: string, keys: readonly string[]) => { + const loaded = loadConfigMock() as Record; + const global = (loaded.global ?? {}) as Record; + const project = (loaded.project ?? {}) as Record; + const provider = (project.provider ?? global.provider ?? 'claude') as string; + const config: Record = { ...global, ...project, provider, piece: project.piece ?? 'default', verbose: false }; + const result: Record = {}; + for (const key of keys) { + result[key] = config[key]; + } + return result; + }, })); vi.mock('../shared/prompts/index.js', () => ({ @@ -47,17 +56,18 @@ describe('option resolution order', () => { vi.clearAllMocks(); providerCallMock.mockResolvedValue({ content: 'ok' }); - loadProjectConfigMock.mockReturnValue({}); - loadGlobalConfigMock.mockReturnValue({}); + loadConfigMock.mockReturnValue({ global: {}, project: {} }); loadCustomAgentsMock.mockReturnValue(new Map()); loadAgentPromptMock.mockReturnValue('prompt'); loadTemplateMock.mockReturnValue('template'); }); - it('should resolve provider in order: CLI > Local > Piece(step) > Global', async () => { + it('should resolve provider in order: CLI > Config(project??global) > stepProvider > default', async () => { // Given - loadProjectConfigMock.mockReturnValue({ provider: 'opencode' }); - loadGlobalConfigMock.mockReturnValue({ provider: 'mock' }); + loadConfigMock.mockReturnValue({ + project: { provider: 'opencode' }, + global: { provider: 'mock' }, + }); // When: CLI provider が指定される await runAgent(undefined, 'task', { @@ -69,7 +79,7 @@ describe('option resolution order', () => { // Then expect(getProviderMock).toHaveBeenLastCalledWith('codex'); - // When: CLI 指定なし(Local が有効) + // When: CLI 指定なし(project provider が有効: resolveConfigValues は project.provider ?? global.provider を返す) await runAgent(undefined, 'task', { cwd: '/repo', stepProvider: 'claude', @@ -78,17 +88,20 @@ describe('option resolution order', () => { // Then expect(getProviderMock).toHaveBeenLastCalledWith('opencode'); - // When: Local なし(Piece が有効) - loadProjectConfigMock.mockReturnValue({}); + // When: project なし → resolveConfigValues は global.provider を返す(フラットマージ) + loadConfigMock.mockReturnValue({ + project: {}, + global: { provider: 'mock' }, + }); await runAgent(undefined, 'task', { cwd: '/repo', stepProvider: 'claude', }); - // Then - expect(getProviderMock).toHaveBeenLastCalledWith('claude'); + // Then: resolveConfigValues returns 'mock' (global fallback), so stepProvider is not reached + expect(getProviderMock).toHaveBeenLastCalledWith('mock'); - // When: Piece なし(Global が有効) + // When: stepProvider もなし → 同様に global.provider await runAgent(undefined, 'task', { cwd: '/repo' }); // Then @@ -97,8 +110,10 @@ describe('option resolution order', () => { it('should resolve model in order: CLI > Piece(step) > Global(matching provider)', async () => { // Given - loadProjectConfigMock.mockReturnValue({ provider: 'claude' }); - loadGlobalConfigMock.mockReturnValue({ provider: 'claude', model: 'global-model' }); + loadConfigMock.mockReturnValue({ + project: { provider: 'claude' }, + global: { provider: 'claude', model: 'global-model' }, + }); // When: CLI model あり await runAgent(undefined, 'task', { @@ -135,13 +150,16 @@ describe('option resolution order', () => { ); }); - it('should ignore global model when global provider does not match resolved provider', async () => { - // Given - loadProjectConfigMock.mockReturnValue({ provider: 'codex' }); - loadGlobalConfigMock.mockReturnValue({ provider: 'claude', model: 'global-model' }); + it('should ignore global model when resolved provider does not match config provider', async () => { + // Given: CLI provider overrides config provider, causing mismatch with config.model + loadConfigMock.mockReturnValue({ + project: {}, + global: { provider: 'claude', model: 'global-model' }, + }); - // When - await runAgent(undefined, 'task', { cwd: '/repo' }); + // When: CLI provider='codex' overrides config provider='claude' + // resolveModel compares config.provider ('claude') with resolvedProvider ('codex') → mismatch → model ignored + await runAgent(undefined, 'task', { cwd: '/repo', provider: 'codex' }); // Then expect(providerCallMock).toHaveBeenLastCalledWith( @@ -160,16 +178,15 @@ describe('option resolution order', () => { }, }; - loadProjectConfigMock.mockReturnValue({ - provider: 'claude', - provider_options: { - claude: { sandbox: { allow_unsandboxed_commands: true } }, + loadConfigMock.mockReturnValue({ + project: { + provider: 'claude', }, - }); - loadGlobalConfigMock.mockReturnValue({ - provider: 'claude', - providerOptions: { - claude: { sandbox: { allowUnsandboxedCommands: true } }, + global: { + provider: 'claude', + providerOptions: { + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }, }, }); @@ -187,8 +204,11 @@ describe('option resolution order', () => { ); }); - it('should use custom agent provider/model when higher-priority values are absent', async () => { - // Given + it('should use custom agent model and prompt when higher-priority values are absent', async () => { + // Given: custom agent with provider/model, but no CLI/config override + // Note: resolveConfigValues returns provider='claude' by default (loadConfig merges project ?? global ?? 'claude'), + // so agentConfig.provider is not reached in resolveProvider (config.provider is always truthy). + // However, custom agent model IS used because resolveModel checks agentConfig.model before config. const customAgents = new Map([ ['custom', { name: 'custom', prompt: 'agent prompt', provider: 'opencode', model: 'agent-model' }], ]); @@ -197,12 +217,14 @@ describe('option resolution order', () => { // When await runAgent('custom', 'task', { cwd: '/repo' }); - // Then - expect(getProviderMock).toHaveBeenLastCalledWith('opencode'); + // Then: provider falls back to config default ('claude'), not agentConfig.provider + expect(getProviderMock).toHaveBeenLastCalledWith('claude'); + // Agent model is used (resolved before config.model in resolveModel) expect(providerCallMock).toHaveBeenLastCalledWith( 'task', expect.objectContaining({ model: 'agent-model' }), ); + // Agent prompt is still used expect(providerSetupMock).toHaveBeenLastCalledWith( expect.objectContaining({ systemPrompt: 'prompt' }), ); diff --git a/src/__tests__/options-builder.test.ts b/src/__tests__/options-builder.test.ts index 0a6d1f7..c9e2998 100644 --- a/src/__tests__/options-builder.test.ts +++ b/src/__tests__/options-builder.test.ts @@ -16,8 +16,8 @@ function createMovement(overrides: Partial = {}): PieceMovement { function createBuilder(step: PieceMovement, engineOverrides: Partial = {}): OptionsBuilder { const engineOptions: PieceEngineOptions = { projectCwd: '/project', - globalProvider: 'codex', - globalProviderProfiles: { + provider: 'codex', + providerProfiles: { codex: { defaultPermissionMode: 'full', }, @@ -60,15 +60,57 @@ describe('OptionsBuilder.buildBaseOptions', () => { it('uses default profile when provider_profiles are not provided', () => { const step = createMovement(); const builder = createBuilder(step, { - globalProvider: undefined, - globalProviderProfiles: undefined, - projectProvider: undefined, provider: undefined, + providerProfiles: undefined, }); const options = builder.buildBaseOptions(step); expect(options.permissionMode).toBe('edit'); }); + + it('merges provider options with precedence: global < project < movement', () => { + const step = createMovement({ + providerOptions: { + codex: { networkAccess: false }, + claude: { sandbox: { excludedCommands: ['./gradlew'] } }, + }, + }); + const builder = createBuilder(step, { + providerOptions: { + codex: { networkAccess: true }, + claude: { sandbox: { allowUnsandboxedCommands: true } }, + opencode: { networkAccess: true }, + }, + }); + + const options = builder.buildBaseOptions(step); + + expect(options.providerOptions).toEqual({ + codex: { networkAccess: false }, + opencode: { networkAccess: true }, + claude: { + sandbox: { + allowUnsandboxedCommands: true, + excludedCommands: ['./gradlew'], + }, + }, + }); + }); + + it('falls back to global/project provider options when movement has none', () => { + const step = createMovement(); + const builder = createBuilder(step, { + providerOptions: { + codex: { networkAccess: false }, + }, + }); + + const options = builder.buildBaseOptions(step); + + expect(options.providerOptions).toEqual({ + codex: { networkAccess: false }, + }); + }); }); describe('OptionsBuilder.buildResumeOptions', () => { diff --git a/src/__tests__/orderReader.test.ts b/src/__tests__/orderReader.test.ts new file mode 100644 index 0000000..8001021 --- /dev/null +++ b/src/__tests__/orderReader.test.ts @@ -0,0 +1,104 @@ +/** + * Unit tests for orderReader: findPreviousOrderContent + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { findPreviousOrderContent } from '../features/interactive/orderReader.js'; + +const TEST_DIR = join(process.cwd(), 'tmp-test-order-reader'); + +function createRunWithOrder(slug: string, content: string): void { + const orderDir = join(TEST_DIR, '.takt', 'runs', slug, 'context', 'task'); + mkdirSync(orderDir, { recursive: true }); + writeFileSync(join(orderDir, 'order.md'), content, 'utf-8'); +} + +function createRunWithoutOrder(slug: string): void { + const runDir = join(TEST_DIR, '.takt', 'runs', slug); + mkdirSync(runDir, { recursive: true }); +} + +beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); +}); + +afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +describe('findPreviousOrderContent', () => { + it('should return order content when slug is specified and order.md exists', () => { + createRunWithOrder('20260218-run1', '# Task Order\nDo something'); + + const result = findPreviousOrderContent(TEST_DIR, '20260218-run1'); + + expect(result).toBe('# Task Order\nDo something'); + }); + + it('should return null when slug is specified but order.md does not exist', () => { + createRunWithoutOrder('20260218-run1'); + + const result = findPreviousOrderContent(TEST_DIR, '20260218-run1'); + + expect(result).toBeNull(); + }); + + it('should return null when slug is specified but run directory does not exist', () => { + mkdirSync(join(TEST_DIR, '.takt', 'runs'), { recursive: true }); + + const result = findPreviousOrderContent(TEST_DIR, 'nonexistent-slug'); + + expect(result).toBeNull(); + }); + + it('should return null for empty order.md content', () => { + createRunWithOrder('20260218-run1', ''); + + const result = findPreviousOrderContent(TEST_DIR, '20260218-run1'); + + expect(result).toBeNull(); + }); + + it('should return null for whitespace-only order.md content', () => { + createRunWithOrder('20260218-run1', ' \n '); + + const result = findPreviousOrderContent(TEST_DIR, '20260218-run1'); + + expect(result).toBeNull(); + }); + + it('should find order from latest run when slug is null', () => { + createRunWithOrder('20260218-run-a', 'First order'); + createRunWithOrder('20260219-run-b', 'Second order'); + + const result = findPreviousOrderContent(TEST_DIR, null); + + expect(result).toBe('Second order'); + }); + + it('should skip runs without order.md when searching latest', () => { + createRunWithOrder('20260218-run-a', 'First order'); + createRunWithoutOrder('20260219-run-b'); + + const result = findPreviousOrderContent(TEST_DIR, null); + + expect(result).toBe('First order'); + }); + + it('should return null when no runs have order.md', () => { + createRunWithoutOrder('20260218-run-a'); + createRunWithoutOrder('20260219-run-b'); + + const result = findPreviousOrderContent(TEST_DIR, null); + + expect(result).toBeNull(); + }); + + it('should return null when .takt/runs directory does not exist', () => { + const result = findPreviousOrderContent(TEST_DIR, null); + + expect(result).toBeNull(); + }); +}); diff --git a/src/__tests__/piece-builtin-toggle.test.ts b/src/__tests__/piece-builtin-toggle.test.ts index 8ae0242..65bb36c 100644 --- a/src/__tests__/piece-builtin-toggle.test.ts +++ b/src/__tests__/piece-builtin-toggle.test.ts @@ -17,6 +17,24 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { }; }); +vi.mock('../infra/config/resolveConfigValue.js', () => ({ + resolveConfigValue: (_cwd: string, key: string) => { + if (key === 'language') return 'en'; + if (key === 'enableBuiltinPieces') return false; + if (key === 'disabledBuiltins') return []; + return undefined; + }, + resolveConfigValues: (_cwd: string, keys: readonly string[]) => { + const result: Record = {}; + for (const key of keys) { + if (key === 'language') result[key] = 'en'; + if (key === 'enableBuiltinPieces') result[key] = false; + if (key === 'disabledBuiltins') result[key] = []; + } + return result; + }, +})); + const { listPieces } = await import('../infra/config/loaders/pieceLoader.js'); const SAMPLE_PIECE = `name: test-piece diff --git a/src/__tests__/piece-category-config.test.ts b/src/__tests__/piece-category-config.test.ts index 1a164ff..123f10f 100644 --- a/src/__tests__/piece-category-config.test.ts +++ b/src/__tests__/piece-category-config.test.ts @@ -22,12 +22,28 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { const original = await importOriginal() as Record; return { ...original, - getLanguage: () => languageState.value, - getBuiltinPiecesEnabled: () => true, - getDisabledBuiltins: () => [], + loadGlobalConfig: () => ({}), }; }); +vi.mock('../infra/config/resolveConfigValue.js', () => ({ + resolveConfigValue: (_cwd: string, key: string) => { + if (key === 'language') return languageState.value; + if (key === 'enableBuiltinPieces') return true; + if (key === 'disabledBuiltins') return []; + return undefined; + }, + resolveConfigValues: (_cwd: string, keys: readonly string[]) => { + const result: Record = {}; + for (const key of keys) { + if (key === 'language') result[key] = languageState.value; + if (key === 'enableBuiltinPieces') result[key] = true; + if (key === 'disabledBuiltins') result[key] = []; + } + return result; + }, +})); + vi.mock('../infra/resources/index.js', async (importOriginal) => { const original = await importOriginal() as Record; return { @@ -45,6 +61,7 @@ vi.mock('../infra/config/global/pieceCategories.js', async (importOriginal) => { }); const { + BUILTIN_CATEGORY_NAME, getPieceCategories, loadDefaultCategories, buildCategorizedPieces, @@ -92,7 +109,7 @@ describe('piece category config loading', () => { }); it('should return null when builtin categories file is missing', () => { - const config = getPieceCategories(); + const config = getPieceCategories(testDir); expect(config).toBeNull(); }); @@ -104,7 +121,7 @@ piece_categories: - default `); - const config = loadDefaultCategories(); + const config = loadDefaultCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'Quick Start', pieces: ['default'], children: [] }, @@ -113,6 +130,7 @@ piece_categories: { name: 'Quick Start', pieces: ['default'], children: [] }, ]); expect(config!.userPieceCategories).toEqual([]); + expect(config!.hasUserCategories).toBe(false); }); it('should use builtin categories when user overlay file is missing', () => { @@ -125,17 +143,18 @@ show_others_category: true others_category_name: Others `); - const config = getPieceCategories(); + const config = getPieceCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'Main', pieces: ['default'], children: [] }, ]); expect(config!.userPieceCategories).toEqual([]); + expect(config!.hasUserCategories).toBe(false); expect(config!.showOthersCategory).toBe(true); expect(config!.othersCategoryName).toBe('Others'); }); - it('should merge user overlay categories with builtin categories', () => { + it('should separate user categories from builtin categories with builtin wrapper', () => { writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` piece_categories: Main: @@ -165,18 +184,25 @@ show_others_category: false others_category_name: Unclassified `); - const config = getPieceCategories(); + const config = getPieceCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ + { name: 'Main', pieces: ['custom'], children: [] }, + { name: 'My Team', pieces: ['team-flow'], children: [] }, { - name: 'Main', - pieces: ['custom'], + name: BUILTIN_CATEGORY_NAME, + pieces: [], children: [ - { name: 'Child', pieces: ['nested'], children: [] }, + { + name: 'Main', + pieces: ['default', 'coding'], + children: [ + { name: 'Child', pieces: ['nested'], children: [] }, + ], + }, + { name: 'Review', pieces: ['review-only', 'e2e-test'], children: [] }, ], }, - { name: 'Review', pieces: ['review-only', 'e2e-test'], children: [] }, - { name: 'My Team', pieces: ['team-flow'], children: [] }, ]); expect(config!.builtinPieceCategories).toEqual([ { @@ -192,6 +218,7 @@ others_category_name: Unclassified { name: 'Main', pieces: ['custom'], children: [] }, { name: 'My Team', pieces: ['team-flow'], children: [] }, ]); + expect(config!.hasUserCategories).toBe(true); expect(config!.showOthersCategory).toBe(false); expect(config!.othersCategoryName).toBe('Unclassified'); }); @@ -207,7 +234,7 @@ piece_categories: - e2e-test `); - const config = getPieceCategories(); + const config = getPieceCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'レビュー', pieces: ['review-only', 'e2e-test'], children: [] }, @@ -232,7 +259,7 @@ show_others_category: false others_category_name: Unclassified `); - const config = getPieceCategories(); + const config = getPieceCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'Main', pieces: ['default'], children: [] }, @@ -243,6 +270,7 @@ others_category_name: Unclassified { name: 'Review', pieces: ['review-only'], children: [] }, ]); expect(config!.userPieceCategories).toEqual([]); + expect(config!.hasUserCategories).toBe(false); expect(config!.showOthersCategory).toBe(false); expect(config!.othersCategoryName).toBe('Unclassified'); }); @@ -274,11 +302,12 @@ describe('buildCategorizedPieces', () => { userPieceCategories: [ { name: 'My Team', pieces: ['missing-user-piece'], children: [] }, ], + hasUserCategories: true, showOthersCategory: true, othersCategoryName: 'Others', }; - const categorized = buildCategorizedPieces(allPieces, config); + const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); expect(categorized.categories).toEqual([ { name: 'Main', @@ -306,11 +335,12 @@ describe('buildCategorizedPieces', () => { { name: 'Main', pieces: ['default'], children: [] }, ], userPieceCategories: [], + hasUserCategories: false, showOthersCategory: true, othersCategoryName: 'Others', }; - const categorized = buildCategorizedPieces(allPieces, config); + const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); expect(categorized.categories).toEqual([ { name: 'Main', pieces: ['default'], children: [] }, { name: 'Others', pieces: ['extra'], children: [] }, @@ -330,13 +360,60 @@ describe('buildCategorizedPieces', () => { { name: 'Main', pieces: ['default'], children: [] }, ], userPieceCategories: [], + hasUserCategories: false, showOthersCategory: false, othersCategoryName: 'Others', }; + const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); + expect(categorized.categories).toEqual([ + { name: 'Main', pieces: ['default'], children: [] }, + ]); + }); + + it('should categorize pieces through builtin wrapper node', () => { + const allPieces = createPieceMap([ + { name: 'custom', source: 'user' }, + { name: 'default', source: 'builtin' }, + { name: 'review-only', source: 'builtin' }, + { name: 'extra', source: 'builtin' }, + ]); + const config = { + pieceCategories: [ + { name: 'My Team', pieces: ['custom'], children: [] }, + { + name: BUILTIN_CATEGORY_NAME, + pieces: [], + children: [ + { name: 'Quick Start', pieces: ['default'], children: [] }, + { name: 'Review', pieces: ['review-only'], children: [] }, + ], + }, + ], + builtinPieceCategories: [ + { name: 'Quick Start', pieces: ['default'], children: [] }, + { name: 'Review', pieces: ['review-only'], children: [] }, + ], + userPieceCategories: [ + { name: 'My Team', pieces: ['custom'], children: [] }, + ], + hasUserCategories: true, + showOthersCategory: true, + othersCategoryName: 'Others', + }; + const categorized = buildCategorizedPieces(allPieces, config); expect(categorized.categories).toEqual([ - { name: 'Main', pieces: ['default'], children: [] }, + { name: 'My Team', pieces: ['custom'], children: [] }, + { + name: BUILTIN_CATEGORY_NAME, + pieces: [], + children: [ + { name: 'Quick Start', pieces: ['default'], children: [] }, + { name: 'Review', pieces: ['review-only'], children: [] }, + ], + }, + { name: 'Others', pieces: ['extra'], children: [] }, ]); }); diff --git a/src/__tests__/piece-selection.test.ts b/src/__tests__/piece-selection.test.ts index 94ab056..688bd61 100644 --- a/src/__tests__/piece-selection.test.ts +++ b/src/__tests__/piece-selection.test.ts @@ -40,7 +40,7 @@ const configMock = vi.hoisted(() => ({ getPieceCategories: vi.fn(), buildCategorizedPieces: vi.fn(), getCurrentPiece: vi.fn(), - findPieceCategories: vi.fn(() => []), + resolveConfigValue: vi.fn(), })); vi.mock('../infra/config/index.js', () => configMock); @@ -242,6 +242,65 @@ describe('selectPieceFromCategorizedPieces', () => { // Should NOT contain the parent category again expect(labels.some((l) => l.includes('Dev'))).toBe(false); }); + + it('should navigate into builtin wrapper category and select a piece', async () => { + const categorized: CategorizedPieces = { + categories: [ + { name: 'My Team', pieces: ['custom'], children: [] }, + { + name: 'builtin', + pieces: [], + children: [ + { name: 'Quick Start', pieces: ['default'], children: [] }, + ], + }, + ], + allPieces: createPieceMap([ + { name: 'custom', source: 'user' }, + { name: 'default', source: 'builtin' }, + ]), + missingPieces: [], + }; + + // Select builtin category → Quick Start subcategory → piece + selectOptionMock + .mockResolvedValueOnce('__custom_category__:builtin') + .mockResolvedValueOnce('__category__:Quick Start') + .mockResolvedValueOnce('default'); + + const selected = await selectPieceFromCategorizedPieces(categorized, ''); + expect(selected).toBe('default'); + expect(selectOptionMock).toHaveBeenCalledTimes(3); + }); + + it('should show builtin wrapper as a folder in top-level options', async () => { + const categorized: CategorizedPieces = { + categories: [ + { name: 'My Team', pieces: ['custom'], children: [] }, + { + name: 'builtin', + pieces: [], + children: [ + { name: 'Quick Start', pieces: ['default'], children: [] }, + ], + }, + ], + allPieces: createPieceMap([ + { name: 'custom', source: 'user' }, + { name: 'default', source: 'builtin' }, + ]), + missingPieces: [], + }; + + selectOptionMock.mockResolvedValueOnce(null); + + await selectPieceFromCategorizedPieces(categorized, ''); + + const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[]; + const labels = firstCallOptions.map((o) => o.label); + expect(labels.some((l) => l.includes('My Team'))).toBe(true); + expect(labels.some((l) => l.includes('builtin'))).toBe(true); + }); }); describe('selectPiece', () => { @@ -258,13 +317,13 @@ describe('selectPiece', () => { configMock.loadAllPiecesWithSources.mockReset(); configMock.getPieceCategories.mockReset(); configMock.buildCategorizedPieces.mockReset(); - configMock.getCurrentPiece.mockReset(); + configMock.resolveConfigValue.mockReset(); }); it('should return default piece when no pieces found and fallbackToDefault is true', async () => { configMock.getPieceCategories.mockReturnValue(null); configMock.listPieces.mockReturnValue([]); - configMock.getCurrentPiece.mockReturnValue('default'); + configMock.resolveConfigValue.mockReturnValue('default'); const result = await selectPiece('/cwd'); @@ -274,7 +333,7 @@ describe('selectPiece', () => { it('should return null when no pieces found and fallbackToDefault is false', async () => { configMock.getPieceCategories.mockReturnValue(null); configMock.listPieces.mockReturnValue([]); - configMock.getCurrentPiece.mockReturnValue('default'); + configMock.resolveConfigValue.mockReturnValue('default'); const result = await selectPiece('/cwd', { fallbackToDefault: false }); @@ -287,7 +346,7 @@ describe('selectPiece', () => { configMock.listPieceEntries.mockReturnValue([ { name: 'only-piece', path: '/tmp/only-piece.yaml', source: 'user' }, ]); - configMock.getCurrentPiece.mockReturnValue('only-piece'); + configMock.resolveConfigValue.mockReturnValue('only-piece'); selectOptionMock.mockResolvedValueOnce('only-piece'); const result = await selectPiece('/cwd'); @@ -307,7 +366,7 @@ describe('selectPiece', () => { configMock.getPieceCategories.mockReturnValue({ categories: ['Dev'] }); configMock.loadAllPiecesWithSources.mockReturnValue(pieceMap); configMock.buildCategorizedPieces.mockReturnValue(categorized); - configMock.getCurrentPiece.mockReturnValue('my-piece'); + configMock.resolveConfigValue.mockReturnValue('my-piece'); selectOptionMock.mockResolvedValueOnce('__current__'); @@ -321,7 +380,7 @@ describe('selectPiece', () => { configMock.getPieceCategories.mockReturnValue(null); configMock.listPieces.mockReturnValue(['piece-a', 'piece-b']); configMock.listPieceEntries.mockReturnValue(entries); - configMock.getCurrentPiece.mockReturnValue('piece-a'); + configMock.resolveConfigValue.mockReturnValue('piece-a'); selectOptionMock .mockResolvedValueOnce('custom') diff --git a/src/__tests__/pieceExecution-debug-prompts.test.ts b/src/__tests__/pieceExecution-debug-prompts.test.ts index 5fb8402..93b9119 100644 --- a/src/__tests__/pieceExecution-debug-prompts.test.ts +++ b/src/__tests__/pieceExecution-debug-prompts.test.ts @@ -90,7 +90,15 @@ vi.mock('../infra/config/index.js', () => ({ updatePersonaSession: vi.fn(), loadWorktreeSessions: vi.fn().mockReturnValue({}), updateWorktreeSession: vi.fn(), - loadGlobalConfig: vi.fn().mockReturnValue({ provider: 'claude' }), + resolvePieceConfigValues: vi.fn().mockReturnValue({ + notificationSound: true, + notificationSoundEvents: {}, + provider: 'claude', + runtime: undefined, + preventSleep: false, + model: undefined, + observability: undefined, + }), saveSessionState: vi.fn(), ensureDir: vi.fn(), writeFileAtomic: vi.fn(), diff --git a/src/__tests__/pieceExecution-session-loading.test.ts b/src/__tests__/pieceExecution-session-loading.test.ts index e6402da..daae53d 100644 --- a/src/__tests__/pieceExecution-session-loading.test.ts +++ b/src/__tests__/pieceExecution-session-loading.test.ts @@ -59,7 +59,15 @@ vi.mock('../infra/config/index.js', () => ({ updatePersonaSession: vi.fn(), loadWorktreeSessions: mockLoadWorktreeSessions, updateWorktreeSession: vi.fn(), - loadGlobalConfig: vi.fn().mockReturnValue({ provider: 'claude' }), + resolvePieceConfigValues: vi.fn().mockReturnValue({ + notificationSound: true, + notificationSoundEvents: {}, + provider: 'claude', + runtime: undefined, + preventSleep: false, + model: undefined, + observability: undefined, + }), saveSessionState: vi.fn(), ensureDir: vi.fn(), writeFileAtomic: vi.fn(), diff --git a/src/__tests__/postExecution.test.ts b/src/__tests__/postExecution.test.ts new file mode 100644 index 0000000..d1d6adf --- /dev/null +++ b/src/__tests__/postExecution.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for postExecution.ts + * + * Verifies branching logic: existing PR → comment, no PR → create. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockAutoCommitAndPush, mockPushBranch, mockFindExistingPr, mockCommentOnPr, mockCreatePullRequest, mockBuildPrBody } = + vi.hoisted(() => ({ + mockAutoCommitAndPush: vi.fn(), + mockPushBranch: vi.fn(), + mockFindExistingPr: vi.fn(), + mockCommentOnPr: vi.fn(), + mockCreatePullRequest: vi.fn(), + mockBuildPrBody: vi.fn(() => 'pr-body'), + })); + +vi.mock('../infra/task/index.js', () => ({ + autoCommitAndPush: (...args: unknown[]) => mockAutoCommitAndPush(...args), +})); + +vi.mock('../infra/github/index.js', () => ({ + pushBranch: (...args: unknown[]) => mockPushBranch(...args), + findExistingPr: (...args: unknown[]) => mockFindExistingPr(...args), + commentOnPr: (...args: unknown[]) => mockCommentOnPr(...args), + createPullRequest: (...args: unknown[]) => mockCreatePullRequest(...args), + buildPrBody: (...args: unknown[]) => mockBuildPrBody(...args), +})); + +vi.mock('../infra/config/index.js', () => ({ + resolvePieceConfigValue: vi.fn(), +})); + +vi.mock('../shared/prompt/index.js', () => ({ + confirm: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +import { postExecutionFlow } from '../features/tasks/execute/postExecution.js'; + +const baseOptions = { + execCwd: '/clone', + projectCwd: '/project', + task: 'Fix the bug', + branch: 'task/fix-the-bug', + baseBranch: 'main', + shouldCreatePr: true, + pieceIdentifier: 'default', +}; + +describe('postExecutionFlow', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAutoCommitAndPush.mockReturnValue({ success: true, commitHash: 'abc123' }); + mockPushBranch.mockReturnValue(undefined); + mockCommentOnPr.mockReturnValue({ success: true }); + mockCreatePullRequest.mockReturnValue({ success: true, url: 'https://github.com/org/repo/pull/1' }); + }); + + it('既存PRがない場合は createPullRequest を呼ぶ', async () => { + mockFindExistingPr.mockReturnValue(undefined); + + await postExecutionFlow(baseOptions); + + expect(mockCreatePullRequest).toHaveBeenCalledTimes(1); + expect(mockCommentOnPr).not.toHaveBeenCalled(); + }); + + it('既存PRがある場合は commentOnPr を呼び createPullRequest は呼ばない', async () => { + mockFindExistingPr.mockReturnValue({ number: 42, url: 'https://github.com/org/repo/pull/42' }); + + await postExecutionFlow(baseOptions); + + expect(mockCommentOnPr).toHaveBeenCalledWith('/project', 42, 'pr-body'); + expect(mockCreatePullRequest).not.toHaveBeenCalled(); + }); + + it('shouldCreatePr が false の場合は PR 関連処理をスキップする', async () => { + await postExecutionFlow({ ...baseOptions, shouldCreatePr: false }); + + expect(mockFindExistingPr).not.toHaveBeenCalled(); + expect(mockCommentOnPr).not.toHaveBeenCalled(); + expect(mockCreatePullRequest).not.toHaveBeenCalled(); + }); + + it('commit がない場合は PR 関連処理をスキップする', async () => { + mockAutoCommitAndPush.mockReturnValue({ success: true, commitHash: undefined }); + + await postExecutionFlow(baseOptions); + + expect(mockFindExistingPr).not.toHaveBeenCalled(); + expect(mockCreatePullRequest).not.toHaveBeenCalled(); + }); + + it('branch がない場合は PR 関連処理をスキップする', async () => { + await postExecutionFlow({ ...baseOptions, branch: undefined }); + + expect(mockFindExistingPr).not.toHaveBeenCalled(); + expect(mockCreatePullRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/prompt.test.ts b/src/__tests__/prompt.test.ts index e6abb59..6f240fa 100644 --- a/src/__tests__/prompt.test.ts +++ b/src/__tests__/prompt.test.ts @@ -2,9 +2,10 @@ * Tests for prompt module (cursor-based interactive menu) */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Readable } from 'node:stream'; import chalk from 'chalk'; +import { setupRawStdin, restoreStdin } from './helpers/stdinSimulator.js'; import type { SelectOptionItem, KeyInputResult } from '../shared/prompt/index.js'; import { renderMenu, @@ -331,6 +332,74 @@ describe('prompt', () => { }); }); + describe('selectOptionWithDefault (stdin E2E)', () => { + afterEach(() => { + restoreStdin(); + }); + + it('should place cursor on default value and confirm it with Enter', async () => { + // Enter key only — confirms whatever the cursor is on + setupRawStdin(['\r']); + + const { selectOptionWithDefault } = await import('../shared/prompt/index.js'); + const options = [ + { label: 'plan', value: 'plan' }, + { label: 'implement', value: 'implement' }, + { label: 'review', value: 'review' }, + ]; + + const result = await selectOptionWithDefault('Start from:', options, 'review'); + + // If cursor starts at 'review' (index 2), Enter should select it + expect(result).toBe('review'); + }); + + it('should place cursor on first item when default is the first option', async () => { + setupRawStdin(['\r']); + + const { selectOptionWithDefault } = await import('../shared/prompt/index.js'); + const options = [ + { label: 'plan', value: 'plan' }, + { label: 'implement', value: 'implement' }, + { label: 'review', value: 'review' }, + ]; + + const result = await selectOptionWithDefault('Start from:', options, 'plan'); + + expect(result).toBe('plan'); + }); + + it('should place cursor on middle item when default is the middle option', async () => { + setupRawStdin(['\r']); + + const { selectOptionWithDefault } = await import('../shared/prompt/index.js'); + const options = [ + { label: 'plan', value: 'plan' }, + { label: 'implement', value: 'implement' }, + { label: 'review', value: 'review' }, + ]; + + const result = await selectOptionWithDefault('Start from:', options, 'implement'); + + expect(result).toBe('implement'); + }); + + it('should fall back to first item when default value does not exist', async () => { + setupRawStdin(['\r']); + + const { selectOptionWithDefault } = await import('../shared/prompt/index.js'); + const options = [ + { label: 'plan', value: 'plan' }, + { label: 'implement', value: 'implement' }, + ]; + + const result = await selectOptionWithDefault('Start from:', options, 'nonexistent'); + + // defaultValue not found → falls back to index 0 + expect(result).toBe('plan'); + }); + }); + describe('isFullWidth', () => { it('should return true for CJK ideographs', () => { expect(isFullWidth('漢'.codePointAt(0)!)).toBe(true); diff --git a/src/__tests__/reportDir.test.ts b/src/__tests__/reportDir.test.ts index 1163eaf..59013fc 100644 --- a/src/__tests__/reportDir.test.ts +++ b/src/__tests__/reportDir.test.ts @@ -37,12 +37,13 @@ describe('generateReportDir', () => { vi.useRealTimers(); }); - it('should preserve Japanese characters in summary', () => { + it('should strip CJK characters from summary', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2025-06-01T12:00:00.000Z')); const result = generateReportDir('タスク指示書の実装'); - expect(result).toContain('タスク指示書の実装'); + // CJK characters are removed by slugify, leaving empty → falls back to 'task' + expect(result).toBe('20250601-120000-task'); vi.useRealTimers(); }); @@ -53,7 +54,7 @@ describe('generateReportDir', () => { const result = generateReportDir('Fix: bug (#42)'); const slug = result.replace(/^20250101-000000-/, ''); - expect(slug).not.toMatch(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf-]/); + expect(slug).not.toMatch(/[^a-z0-9-]/); vi.useRealTimers(); }); diff --git a/src/__tests__/reset-global-config.test.ts b/src/__tests__/reset-global-config.test.ts new file mode 100644 index 0000000..ec19ad3 --- /dev/null +++ b/src/__tests__/reset-global-config.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resetGlobalConfigToTemplate } from '../infra/config/global/resetConfig.js'; + +describe('resetGlobalConfigToTemplate', () => { + const originalEnv = process.env; + let testRoot: string; + let taktDir: string; + let configPath: string; + + beforeEach(() => { + testRoot = mkdtempSync(join(tmpdir(), 'takt-reset-config-')); + taktDir = join(testRoot, '.takt'); + mkdirSync(taktDir, { recursive: true }); + configPath = join(taktDir, 'config.yaml'); + process.env = { ...originalEnv, TAKT_CONFIG_DIR: taktDir }; + }); + + afterEach(() => { + process.env = originalEnv; + rmSync(testRoot, { recursive: true, force: true }); + }); + + it('should backup existing config and replace with language-matched template', () => { + writeFileSync(configPath, ['language: ja', 'provider: mock'].join('\n'), 'utf-8'); + + const result = resetGlobalConfigToTemplate(new Date('2026-02-19T12:00:00Z')); + + expect(result.language).toBe('ja'); + expect(result.backupPath).toBeDefined(); + expect(existsSync(result.backupPath!)).toBe(true); + expect(readFileSync(result.backupPath!, 'utf-8')).toContain('provider: mock'); + + const newConfig = readFileSync(configPath, 'utf-8'); + expect(newConfig).toContain('language: ja'); + expect(newConfig).toContain('branch_name_strategy: ai'); + expect(newConfig).toContain('concurrency: 2'); + }); + + it('should create config from default language template when config does not exist', () => { + rmSync(configPath, { force: true }); + + const result = resetGlobalConfigToTemplate(new Date('2026-02-19T12:00:00Z')); + + expect(result.backupPath).toBeUndefined(); + expect(result.language).toBe('en'); + expect(existsSync(configPath)).toBe(true); + const newConfig = readFileSync(configPath, 'utf-8'); + expect(newConfig).toContain('language: en'); + expect(newConfig).toContain('branch_name_strategy: ai'); + }); +}); diff --git a/src/__tests__/resetCategories.test.ts b/src/__tests__/resetCategories.test.ts index 6ab7577..1623955 100644 --- a/src/__tests__/resetCategories.test.ts +++ b/src/__tests__/resetCategories.test.ts @@ -31,13 +31,14 @@ describe('resetCategoriesToDefault', () => { it('should reset user category overlay and show updated message', async () => { // Given + const cwd = '/tmp/test-cwd'; // When - await resetCategoriesToDefault(); + await resetCategoriesToDefault(cwd); // Then expect(mockHeader).toHaveBeenCalledWith('Reset Categories'); - expect(mockResetPieceCategories).toHaveBeenCalledTimes(1); + expect(mockResetPieceCategories).toHaveBeenCalledWith(cwd); expect(mockSuccess).toHaveBeenCalledWith('User category overlay reset.'); expect(mockInfo).toHaveBeenCalledWith(' /tmp/user-piece-categories.yaml'); }); diff --git a/src/__tests__/resolveTask.test.ts b/src/__tests__/resolveTask.test.ts new file mode 100644 index 0000000..ac9c736 --- /dev/null +++ b/src/__tests__/resolveTask.test.ts @@ -0,0 +1,86 @@ +/** + * Tests for task execution resolution. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { TaskInfo } from '../infra/task/index.js'; +import { resolveTaskExecution } from '../features/tasks/execute/resolveTask.js'; + +const tempRoots = new Set(); + +afterEach(() => { + for (const root of tempRoots) { + fs.rmSync(root, { recursive: true, force: true }); + } + tempRoots.clear(); +}); + +function createTempProjectDir(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-resolve-task-test-')); + tempRoots.add(root); + return root; +} + +function createTask(overrides: Partial): TaskInfo { + return { + filePath: '/tasks/task.yaml', + name: 'task-name', + content: 'Run task', + createdAt: '2026-01-01T00:00:00.000Z', + status: 'pending', + data: { task: 'Run task' }, + ...overrides, + }; +} + +describe('resolveTaskExecution', () => { + it('should return defaults when task data is null', async () => { + const root = createTempProjectDir(); + const task = createTask({ data: null }); + + const result = await resolveTaskExecution(task, root, 'default'); + + expect(result).toEqual({ + execCwd: root, + execPiece: 'default', + isWorktree: false, + autoPr: false, + }); + }); + + it('should generate report context and copy issue-bearing task spec', async () => { + const root = createTempProjectDir(); + const taskDir = '.takt/tasks/issue-task-123'; + const sourceTaskDir = path.join(root, taskDir); + const sourceOrderPath = path.join(sourceTaskDir, 'order.md'); + fs.mkdirSync(sourceTaskDir, { recursive: true }); + fs.writeFileSync(sourceOrderPath, '# task instruction'); + + const task = createTask({ + taskDir, + data: { + task: 'Run issue task', + issue: 12345, + auto_pr: true, + }, + }); + + const result = await resolveTaskExecution(task, root, 'default'); + const expectedReportOrderPath = path.join(root, '.takt', 'runs', 'issue-task-123', 'context', 'task', 'order.md'); + + expect(result).toMatchObject({ + execCwd: root, + execPiece: 'default', + isWorktree: false, + autoPr: true, + reportDirName: 'issue-task-123', + issueNumber: 12345, + taskPrompt: expect.stringContaining('Primary spec: `.takt/runs/issue-task-123/context/task/order.md`'), + }); + expect(fs.existsSync(expectedReportOrderPath)).toBe(true); + expect(fs.readFileSync(expectedReportOrderPath, 'utf-8')).toBe('# task instruction'); + }); +}); diff --git a/src/__tests__/retryMode.test.ts b/src/__tests__/retryMode.test.ts index 4ef3dbe..039a562 100644 --- a/src/__tests__/retryMode.test.ts +++ b/src/__tests__/retryMode.test.ts @@ -9,6 +9,7 @@ function createRetryContext(overrides?: Partial): RetryContext { return { failure: { taskName: 'my-task', + taskContent: 'Do something', createdAt: '2026-02-15T10:00:00Z', failedMovement: 'review', error: 'Timeout', @@ -23,6 +24,7 @@ function createRetryContext(overrides?: Partial): RetryContext { movementPreviews: [], }, run: null, + previousOrderContent: null, ...overrides, }; } @@ -44,6 +46,7 @@ describe('buildRetryTemplateVars', () => { const ctx = createRetryContext({ failure: { taskName: 'task', + taskContent: 'Do something', createdAt: '2026-01-01T00:00:00Z', failedMovement: '', error: 'Error', @@ -129,10 +132,27 @@ describe('buildRetryTemplateVars', () => { expect(vars.movementDetails).toContain('Architect'); }); + it('should set hasOrderContent=false and empty orderContent when previousOrderContent is null (via ctx)', () => { + const ctx = createRetryContext({ previousOrderContent: null }); + const vars = buildRetryTemplateVars(ctx, 'en'); + + expect(vars.hasOrderContent).toBe(false); + expect(vars.orderContent).toBe(''); + }); + + it('should set hasOrderContent=true and populate orderContent when provided via parameter', () => { + const ctx = createRetryContext(); + const vars = buildRetryTemplateVars(ctx, 'en', '# Order content'); + + expect(vars.hasOrderContent).toBe(true); + expect(vars.orderContent).toBe('# Order content'); + }); + it('should include retryNote when present', () => { const ctx = createRetryContext({ failure: { taskName: 'task', + taskContent: 'Do something', createdAt: '2026-01-01T00:00:00Z', failedMovement: '', error: 'Error', @@ -144,4 +164,28 @@ describe('buildRetryTemplateVars', () => { expect(vars.retryNote).toBe('Added more specific error handling'); }); + + it('should set hasOrderContent=false when previousOrderContent is null', () => { + const ctx = createRetryContext(); + const vars = buildRetryTemplateVars(ctx, 'en', null); + + expect(vars.hasOrderContent).toBe(false); + expect(vars.orderContent).toBe(''); + }); + + it('should set hasOrderContent=true and populate orderContent when provided', () => { + const ctx = createRetryContext(); + const vars = buildRetryTemplateVars(ctx, 'en', '# Previous Order\nDo the thing'); + + expect(vars.hasOrderContent).toBe(true); + expect(vars.orderContent).toBe('# Previous Order\nDo the thing'); + }); + + it('should default hasOrderContent to false when previousOrderContent is omitted', () => { + const ctx = createRetryContext(); + const vars = buildRetryTemplateVars(ctx, 'en'); + + expect(vars.hasOrderContent).toBe(false); + expect(vars.orderContent).toBe(''); + }); }); diff --git a/src/__tests__/retrySlashCommand.test.ts b/src/__tests__/retrySlashCommand.test.ts new file mode 100644 index 0000000..3a2f4cd --- /dev/null +++ b/src/__tests__/retrySlashCommand.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for /retry slash command in the conversation loop. + * + * Verifies: + * - /retry with previousOrderContent returns execute action with order content + * - /retry without previousOrderContent shows error and continues loop + * - /retry in retry mode with order.md context in system prompt + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + setupRawStdin, + restoreStdin, + toRawInputs, + createMockProvider, + type MockProviderCapture, +} from './helpers/stdinSimulator.js'; + +// --- Mocks (infrastructure only) --- + +vi.mock('../infra/fs/session.js', () => ({ + loadNdjsonLog: vi.fn(), +})); + +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().mockResolvedValue('execute'), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'), + getLabelObject: vi.fn(() => ({ + intro: 'Retry intro', + resume: 'Resume', + noConversation: 'No conversation', + summarizeFailed: 'Summarize failed', + continuePrompt: 'Continue?', + proposed: 'Proposed:', + actionPrompt: 'What next?', + playNoTask: 'No task', + cancelled: 'Cancelled', + retryNoOrder: 'No previous order found.', + actions: { execute: 'Execute', saveTask: 'Save', continue: 'Continue' }, + })), +})); + +// --- Imports (after mocks) --- + +import { getProvider } from '../infra/providers/index.js'; +import { runRetryMode, type RetryContext } from '../features/interactive/retryMode.js'; +import { info } from '../shared/ui/index.js'; + +const mockGetProvider = vi.mocked(getProvider); +const mockInfo = vi.mocked(info); + +function createTmpDir(): string { + const dir = join(tmpdir(), `takt-retry-cmd-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function setupProvider(responses: string[]): MockProviderCapture { + const { provider, capture } = createMockProvider(responses); + mockGetProvider.mockReturnValue(provider); + return capture; +} + +function buildRetryContext(overrides?: Partial): RetryContext { + return { + failure: { + taskName: 'test-task', + taskContent: 'Test task content', + createdAt: '2026-02-15T10:00:00Z', + failedMovement: 'implement', + error: 'Some error', + lastMessage: '', + retryNote: '', + }, + branchName: 'takt/test-task', + pieceContext: { + name: 'default', + description: '', + pieceStructure: '', + movementPreviews: [], + }, + run: null, + previousOrderContent: null, + ...overrides, + }; +} + +// --- Tests --- + +describe('/retry slash command', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTmpDir(); + vi.clearAllMocks(); + }); + + afterEach(() => { + restoreStdin(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should execute with previous order content when /retry is used', async () => { + const orderContent = '# Task Order\n\nImplement feature X with tests.'; + setupRawStdin(toRawInputs(['/retry'])); + setupProvider([]); + + const retryContext = buildRetryContext({ previousOrderContent: orderContent }); + const result = await runRetryMode(tmpDir, retryContext, orderContent); + + expect(result.action).toBe('execute'); + expect(result.task).toBe(orderContent); + }); + + it('should show error and continue when /retry is used without order', async () => { + setupRawStdin(toRawInputs(['/retry', '/cancel'])); + setupProvider([]); + + const retryContext = buildRetryContext({ previousOrderContent: null }); + const result = await runRetryMode(tmpDir, retryContext, null); + + expect(mockInfo).toHaveBeenCalledWith('No previous order found.'); + expect(result.action).toBe('cancel'); + }); + + it('should inject order.md content into retry system prompt', async () => { + const orderContent = '# Build login page\n\nWith OAuth2 support.'; + setupRawStdin(toRawInputs(['check the order', '/cancel'])); + const capture = setupProvider(['I see the order content.']); + + const retryContext = buildRetryContext({ previousOrderContent: orderContent }); + await runRetryMode(tmpDir, retryContext, orderContent); + + expect(capture.systemPrompts.length).toBeGreaterThan(0); + const systemPrompt = capture.systemPrompts[0]!; + expect(systemPrompt).toContain('Previous Order'); + expect(systemPrompt).toContain(orderContent); + }); + + it('should not include order section when no order content', async () => { + setupRawStdin(toRawInputs(['check the order', '/cancel'])); + const capture = setupProvider(['No order found.']); + + const retryContext = buildRetryContext({ previousOrderContent: null }); + await runRetryMode(tmpDir, retryContext, null); + + expect(capture.systemPrompts.length).toBeGreaterThan(0); + const systemPrompt = capture.systemPrompts[0]!; + expect(systemPrompt).not.toContain('Previous Order'); + }); +}); diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 86c7d60..b33c98a 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -5,25 +5,44 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { TaskInfo } from '../infra/task/index.js'; -// Mock dependencies before importing the module under test -vi.mock('../infra/config/index.js', () => ({ - loadPieceByIdentifier: vi.fn(), - isPiecePath: vi.fn(() => false), - loadGlobalConfig: vi.fn(() => ({ +const { mockLoadConfigRaw } = vi.hoisted(() => ({ + mockLoadConfigRaw: vi.fn(() => ({ language: 'en', defaultPiece: 'default', logLevel: 'info', concurrency: 1, taskPollIntervalMs: 500, })), - loadProjectConfig: vi.fn(() => ({ - piece: 'default', - permissionMode: 'default', - })), })); -import { loadGlobalConfig } from '../infra/config/index.js'; -const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); +// Mock dependencies before importing the module under test +vi.mock('../infra/config/index.js', () => ({ + loadPieceByIdentifier: vi.fn(), + isPiecePath: vi.fn(() => false), + loadConfig: (...args: unknown[]) => { + const raw = mockLoadConfigRaw(...args) as Record; + if ('global' in raw && 'project' in raw) { + return raw; + } + return { + global: raw, + project: { piece: 'default' }, + }; + }, + resolvePieceConfigValues: (_projectDir: string, keys: readonly string[]) => { + const raw = mockLoadConfigRaw() as Record; + const config = ('global' in raw && 'project' in raw) + ? { ...raw.global as Record, ...raw.project as Record } + : { ...raw, piece: 'default', provider: 'claude', verbose: false }; + const result: Record = {}; + for (const key of keys) { + result[key] = config[key]; + } + return result; + }, +})); + +const mockLoadConfig = mockLoadConfigRaw; const { mockClaimNextTasks, @@ -167,7 +186,7 @@ beforeEach(() => { describe('runAllTasks concurrency', () => { describe('sequential execution (concurrency=1)', () => { beforeEach(() => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -210,7 +229,7 @@ describe('runAllTasks concurrency', () => { describe('parallel execution (concurrency>1)', () => { beforeEach(() => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -288,7 +307,7 @@ describe('runAllTasks concurrency', () => { describe('default concurrency', () => { it('should default to sequential when concurrency is not set', async () => { // Given: Config without explicit concurrency (defaults to 1) - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -324,7 +343,7 @@ describe('runAllTasks concurrency', () => { }; beforeEach(() => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -371,7 +390,7 @@ describe('runAllTasks concurrency', () => { it('should fill slots immediately when a task completes (no batch waiting)', async () => { // Given: 3 tasks, concurrency=2, task1 finishes quickly, task2 takes longer - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -413,7 +432,7 @@ describe('runAllTasks concurrency', () => { it('should count partial failures correctly', async () => { // Given: 3 tasks, 1 fails, 2 succeed - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -495,7 +514,7 @@ describe('runAllTasks concurrency', () => { it('should pass abortSignal but not taskPrefix in sequential mode', async () => { // Given: Sequential mode - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -525,7 +544,7 @@ describe('runAllTasks concurrency', () => { }); it('should only notify once at run completion when multiple tasks succeed', async () => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -550,7 +569,7 @@ describe('runAllTasks concurrency', () => { }); it('should not notify run completion when runComplete is explicitly false', async () => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -572,7 +591,7 @@ describe('runAllTasks concurrency', () => { }); it('should notify run completion by default when notification_sound_events is not set', async () => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -594,7 +613,7 @@ describe('runAllTasks concurrency', () => { }); it('should notify run abort by default when notification_sound_events is not set', async () => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -617,7 +636,7 @@ describe('runAllTasks concurrency', () => { }); it('should not notify run abort when runAbort is explicitly false', async () => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -640,7 +659,7 @@ describe('runAllTasks concurrency', () => { }); it('should notify run abort and rethrow when worker pool throws', async () => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -675,7 +694,7 @@ describe('runAllTasks concurrency', () => { }; beforeEach(() => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 9da02c7..bcb33a5 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -4,6 +4,18 @@ import * as path from 'node:path'; import { tmpdir } from 'node:os'; import { parse as parseYaml } from 'yaml'; +vi.mock('../infra/task/summarize.js', () => ({ + summarizeTaskName: vi.fn().mockImplementation((content: string) => { + const slug = content.split('\n')[0]! + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 30) + .replace(/-+$/, ''); + return Promise.resolve(slug || 'task'); + }), +})); + vi.mock('../shared/ui/index.js', () => ({ success: vi.fn(), info: vi.fn(), @@ -66,6 +78,8 @@ describe('saveTaskFile', () => { expect(tasks).toHaveLength(1); expect(tasks[0]?.content).toBeUndefined(); expect(tasks[0]?.task_dir).toBeTypeOf('string'); + expect(tasks[0]?.slug).toBeTypeOf('string'); + expect(tasks[0]?.summary).toBe('Implement feature X'); const taskDir = path.join(testDir, String(tasks[0]?.task_dir)); expect(fs.existsSync(path.join(taskDir, 'order.md'))).toBe(true); expect(fs.readFileSync(path.join(taskDir, 'order.md'), 'utf-8')).toContain('Implement feature X'); diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index 2224f78..3c9157d 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -9,6 +9,7 @@ const { mockCompleteTask, mockFailTask, mockExecuteTask, + mockResolvePieceConfigValue, } = vi.hoisted(() => ({ mockAddTask: vi.fn(() => ({ name: 'test-task', @@ -21,6 +22,7 @@ const { mockCompleteTask: vi.fn(), mockFailTask: vi.fn(), mockExecuteTask: vi.fn(), + mockResolvePieceConfigValue: vi.fn((_: string, key: string) => (key === 'autoPr' ? undefined : 'default')), })); vi.mock('../shared/prompt/index.js', () => ({ @@ -28,11 +30,10 @@ vi.mock('../shared/prompt/index.js', () => ({ })); vi.mock('../infra/config/index.js', () => ({ - getCurrentPiece: vi.fn(), + resolvePieceConfigValue: (...args: unknown[]) => mockResolvePieceConfigValue(...args), listPieces: vi.fn(() => ['default']), listPieceEntries: vi.fn(() => []), isPiecePath: vi.fn(() => false), - loadGlobalConfig: vi.fn(() => ({})), })); vi.mock('../infra/task/index.js', () => ({ @@ -102,7 +103,7 @@ beforeEach(() => { describe('resolveAutoPr default in selectAndExecuteTask', () => { it('should call auto-PR confirm with default true when no CLI option or config', async () => { - // Given: worktree is enabled via override, no autoPr option, no global config autoPr + // Given: worktree is enabled via override, no autoPr option, no config autoPr mockConfirm.mockResolvedValue(true); mockSummarizeTaskName.mockResolvedValue('test-task'); mockCreateSharedClone.mockReturnValue({ @@ -121,10 +122,7 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { createWorktree: true, }); - // Then: the 'Create pull request?' confirm is called with default true - const autoPrCall = mockConfirm.mock.calls.find( - (call) => call[0] === 'Create pull request?', - ); + const autoPrCall = mockConfirm.mock.calls.find((call) => call[0] === 'Create pull request?'); expect(autoPrCall).toBeDefined(); expect(autoPrCall![1]).toBe(true); }); diff --git a/src/__tests__/slug.test.ts b/src/__tests__/slug.test.ts index fd9ef78..8538809 100644 --- a/src/__tests__/slug.test.ts +++ b/src/__tests__/slug.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for slugify utility * - * Tests URL/filename-safe slug generation with CJK support. + * Tests URL/filename-safe slug generation (a-z 0-9 hyphen, max 30 chars). */ import { describe, it, expect } from 'vitest'; @@ -25,17 +25,17 @@ describe('slugify', () => { expect(slugify(' hello ')).toBe('hello'); }); - it('should truncate to 50 characters', () => { + it('should truncate to 30 characters', () => { const long = 'a'.repeat(100); - expect(slugify(long).length).toBeLessThanOrEqual(50); + expect(slugify(long).length).toBeLessThanOrEqual(30); }); - it('should preserve CJK characters', () => { - expect(slugify('タスク指示書')).toBe('タスク指示書'); + it('should strip CJK characters', () => { + expect(slugify('タスク指示書')).toBe(''); }); it('should handle mixed ASCII and CJK', () => { - expect(slugify('Add タスク Feature')).toBe('add-タスク-feature'); + expect(slugify('Add タスク Feature')).toBe('add-feature'); }); it('should handle numbers', () => { @@ -43,11 +43,18 @@ describe('slugify', () => { }); it('should handle empty result after stripping', () => { - // All special characters → becomes empty string expect(slugify('!@#$%')).toBe(''); }); it('should handle typical GitHub issue titles', () => { expect(slugify('Fix: login not working (#42)')).toBe('fix-login-not-working-42'); }); + + it('should strip trailing hyphen after truncation', () => { + // 30 chars of slug that ends with a hyphen after slice + const input = 'abcdefghijklmnopqrstuvwxyz-abc-xyz'; + const result = slugify(input); + expect(result.length).toBeLessThanOrEqual(30); + expect(result).not.toMatch(/-$/); + }); }); diff --git a/src/__tests__/switchPiece.test.ts b/src/__tests__/switchPiece.test.ts index 4f1857c..266c936 100644 --- a/src/__tests__/switchPiece.test.ts +++ b/src/__tests__/switchPiece.test.ts @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../infra/config/index.js', () => ({ loadPiece: vi.fn(() => null), - getCurrentPiece: vi.fn(() => 'default'), + resolveConfigValue: vi.fn(() => 'default'), setCurrentPiece: vi.fn(), })); @@ -20,11 +20,11 @@ vi.mock('../shared/ui/index.js', () => ({ error: vi.fn(), })); -import { getCurrentPiece, loadPiece, setCurrentPiece } from '../infra/config/index.js'; +import { resolveConfigValue, loadPiece, setCurrentPiece } from '../infra/config/index.js'; import { selectPiece } from '../features/pieceSelection/index.js'; import { switchPiece } from '../features/config/switchPiece.js'; -const mockGetCurrentPiece = vi.mocked(getCurrentPiece); +const mockResolveConfigValue = vi.mocked(resolveConfigValue); const mockLoadPiece = vi.mocked(loadPiece); const mockSetCurrentPiece = vi.mocked(setCurrentPiece); const mockSelectPiece = vi.mocked(selectPiece); @@ -32,6 +32,7 @@ const mockSelectPiece = vi.mocked(selectPiece); describe('switchPiece', () => { beforeEach(() => { vi.clearAllMocks(); + mockResolveConfigValue.mockReturnValue('default'); }); it('should call selectPiece with fallbackToDefault: false', async () => { diff --git a/src/__tests__/task-prefix-writer.test.ts b/src/__tests__/task-prefix-writer.test.ts index cf03865..7425498 100644 --- a/src/__tests__/task-prefix-writer.test.ts +++ b/src/__tests__/task-prefix-writer.test.ts @@ -15,6 +15,16 @@ describe('TaskPrefixWriter', () => { }); describe('constructor', () => { + it('should use issue number when provided', () => { + const writer = new TaskPrefixWriter({ taskName: 'my-task', colorIndex: 0, issue: 123, writeFn }); + + writer.writeLine('Issue task'); + + expect(output).toHaveLength(1); + expect(output[0]).toContain('[#123]'); + expect(output[0]).not.toContain('[my-t]'); + }); + 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 }); @@ -27,6 +37,16 @@ describe('TaskPrefixWriter', () => { expect(output[1]).toContain('\x1b[36m'); }); + it('should use display label when provided', () => { + const writer = new TaskPrefixWriter({ taskName: 'my-task', colorIndex: 0, displayLabel: '#12345', writeFn }); + + writer.writeLine('Hello World'); + + expect(output).toHaveLength(1); + expect(output[0]).toContain('[#12345]'); + expect(output[0]).not.toContain('[my-t]'); + }); + it('should assign correct colors in order', () => { const writers = [0, 1, 2, 3].map( (i) => new TaskPrefixWriter({ taskName: `t${i}`, colorIndex: i, writeFn }), diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 2ce647e..7e17aeb 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -1,66 +1,55 @@ /** - * Tests for resolveTaskExecution + * Tests for execute task option propagation. */ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { TaskInfo } from '../infra/task/index.js'; + +const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockResolvePieceConfigValues, mockBuildTaskResult, mockPersistTaskResult, mockPersistTaskError, mockPostExecutionFlow } = + vi.hoisted(() => ({ + mockResolveTaskExecution: vi.fn(), + mockExecutePiece: vi.fn(), + mockLoadPieceByIdentifier: vi.fn(), + mockResolvePieceConfigValues: vi.fn(), + mockBuildTaskResult: vi.fn(), + mockPersistTaskResult: vi.fn(), + mockPersistTaskError: vi.fn(), + mockPostExecutionFlow: vi.fn(), + })); + +vi.mock('../features/tasks/execute/resolveTask.js', () => ({ + resolveTaskExecution: (...args: unknown[]) => mockResolveTaskExecution(...args), +})); + +vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ + executePiece: (...args: unknown[]) => mockExecutePiece(...args), +})); + +vi.mock('../features/tasks/execute/taskResultHandler.js', () => ({ + buildTaskResult: (...args: unknown[]) => mockBuildTaskResult(...args), + persistTaskResult: (...args: unknown[]) => mockPersistTaskResult(...args), + persistTaskError: (...args: unknown[]) => mockPersistTaskError(...args), +})); + +vi.mock('../features/tasks/execute/postExecution.js', () => ({ + postExecutionFlow: (...args: unknown[]) => mockPostExecutionFlow(...args), +})); -// Mock dependencies before importing the module under test vi.mock('../infra/config/index.js', () => ({ - loadPieceByIdentifier: vi.fn(), - isPiecePath: vi.fn(() => false), - loadGlobalConfig: vi.fn(() => ({})), + loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args), + isPiecePath: () => false, + resolvePieceConfigValues: (...args: unknown[]) => mockResolvePieceConfigValues(...args), })); -import { loadGlobalConfig } from '../infra/config/index.js'; -const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); - -vi.mock('../infra/task/index.js', async (importOriginal) => ({ - ...(await importOriginal>()), - TaskRunner: vi.fn(), +vi.mock('../shared/ui/index.js', () => ({ + header: vi.fn(), + info: vi.fn(), + error: vi.fn(), + status: vi.fn(), + success: vi.fn(), + blankLine: vi.fn(), })); -vi.mock('../infra/task/clone.js', async (importOriginal) => ({ - ...(await importOriginal>()), - createSharedClone: vi.fn(), - removeClone: vi.fn(), -})); - -vi.mock('../infra/task/git.js', async (importOriginal) => ({ - ...(await importOriginal>()), - getCurrentBranch: vi.fn(() => 'main'), -})); - -vi.mock('../infra/task/autoCommit.js', async (importOriginal) => ({ - ...(await importOriginal>()), - autoCommitAndPush: vi.fn(), -})); - -vi.mock('../infra/task/summarize.js', async (importOriginal) => ({ - ...(await importOriginal>()), - summarizeTaskName: vi.fn(), -})); - -vi.mock('../shared/ui/index.js', () => { - const info = vi.fn(); - return { - header: vi.fn(), - info, - error: vi.fn(), - success: vi.fn(), - status: vi.fn(), - blankLine: vi.fn(), - withProgress: vi.fn(async (start, done, operation) => { - info(start); - const result = await operation(); - info(typeof done === 'function' ? done(result) : done); - return result; - }), - }; -}); - vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), createLogger: () => ({ @@ -68,560 +57,89 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ debug: vi.fn(), error: vi.fn(), }), - getErrorMessage: vi.fn((e) => e.message), + getErrorMessage: vi.fn((error: unknown) => String(error)), })); -vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ - executePiece: vi.fn(), +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((key: string) => key), })); -vi.mock('../shared/context.js', () => ({ - isQuietMode: vi.fn(() => false), -})); +import { executeAndCompleteTask } from '../features/tasks/execute/taskExecution.js'; -vi.mock('../shared/constants.js', () => ({ - DEFAULT_PIECE_NAME: 'default', - DEFAULT_LANGUAGE: 'en', -})); - -import { createSharedClone } from '../infra/task/clone.js'; -import { getCurrentBranch } from '../infra/task/git.js'; -import { summarizeTaskName } from '../infra/task/summarize.js'; -import { info } from '../shared/ui/index.js'; -import { resolveTaskExecution } from '../features/tasks/index.js'; -import type { TaskInfo } from '../infra/task/index.js'; - -const mockCreateSharedClone = vi.mocked(createSharedClone); -const mockGetCurrentBranch = vi.mocked(getCurrentBranch); -const mockSummarizeTaskName = vi.mocked(summarizeTaskName); -const mockInfo = vi.mocked(info); - -beforeEach(() => { - vi.clearAllMocks(); +const createTask = (name: string): TaskInfo => ({ + name, + content: `Task: ${name}`, + filePath: `/tasks/${name}.yaml`, + createdAt: '2026-02-16T00:00:00.000Z', + status: 'pending', + data: { task: `Task: ${name}` }, }); -describe('resolveTaskExecution', () => { - it('should return defaults when task has no data', async () => { - // Given: Task without structured data - const task: TaskInfo = { - name: 'simple-task', - content: 'Simple task content', - filePath: '/tasks/simple-task.yaml', - createdAt: '2026-02-09T00:00:00.000Z', - status: 'pending', - data: null, - }; +describe('executeAndCompleteTask', () => { + beforeEach(() => { + vi.clearAllMocks(); - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result).toEqual({ + mockLoadPieceByIdentifier.mockReturnValue({ + name: 'default', + movements: [], + }); + mockResolvePieceConfigValues.mockReturnValue({ + language: 'en', + provider: 'claude', + model: undefined, + personaProviders: {}, + providerProfiles: {}, + providerOptions: { + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }, + notificationSound: true, + notificationSoundEvents: {}, + concurrency: 1, + taskPollIntervalMs: 500, + }); + mockBuildTaskResult.mockReturnValue({ success: true }); + mockResolveTaskExecution.mockResolvedValue({ execCwd: '/project', execPiece: 'default', isWorktree: false, autoPr: false, - }); - expect(mockSummarizeTaskName).not.toHaveBeenCalled(); - expect(mockCreateSharedClone).not.toHaveBeenCalled(); - }); - - it('should return defaults when data has no worktree option', async () => { - // Given: Task with data but no worktree - const task: TaskInfo = { - name: 'task-with-data', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.isWorktree).toBe(false); - expect(mockSummarizeTaskName).not.toHaveBeenCalled(); - }); - - it('should create shared clone with AI-summarized slug when worktree option is true', async () => { - // Given: Task with worktree option - const task: TaskInfo = { - name: 'japanese-task', - content: '認証機能を追加する', - filePath: '/tasks/japanese-task.yaml', - data: { - task: '認証機能を追加する', - worktree: true, - }, - }; - - mockSummarizeTaskName.mockResolvedValue('add-auth'); - mockCreateSharedClone.mockReturnValue({ - path: '/project/../20260128T0504-add-auth', - branch: 'takt/20260128T0504-add-auth', - }); - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(mockSummarizeTaskName).toHaveBeenCalledWith('認証機能を追加する', { cwd: '/project' }); - expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', { - worktree: true, + taskPrompt: undefined, + reportDirName: undefined, branch: undefined, - taskSlug: 'add-auth', - }); - expect(mockGetCurrentBranch).toHaveBeenCalledWith('/project'); - expect(result).toEqual({ - execCwd: '/project/../20260128T0504-add-auth', - execPiece: 'default', - isWorktree: true, - autoPr: false, - branch: 'takt/20260128T0504-add-auth', - worktreePath: '/project/../20260128T0504-add-auth', - baseBranch: 'main', + worktreePath: undefined, + baseBranch: undefined, + startMovement: undefined, + retryNote: undefined, + issueNumber: undefined, }); + mockExecutePiece.mockResolvedValue({ success: true }); }); - it('should display generating message before AI call', async () => { - // Given: Task with worktree - const task: TaskInfo = { - name: 'test-task', - content: 'Test task', - filePath: '/tasks/test.yaml', - data: { - task: 'Test task', - worktree: true, - }, - }; + it('should pass taskDisplayLabel from parallel options into executePiece', async () => { + // Given: Parallel execution passes an issue-style taskDisplayLabel. + const task = createTask('task-with-issue'); + const taskDisplayLabel = '#12345'; + const abortController = new AbortController(); - mockSummarizeTaskName.mockResolvedValue('test-task'); - mockCreateSharedClone.mockReturnValue({ - path: '/project/../test-task', - branch: 'takt/test-task', + // When + await executeAndCompleteTask(task, {} as never, '/project', 'default', undefined, { + abortSignal: abortController.signal, + taskPrefix: taskDisplayLabel, + taskColorIndex: 0, + taskDisplayLabel, }); - // When - await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(mockInfo).toHaveBeenCalledWith('Generating branch name...'); - expect(mockInfo).toHaveBeenCalledWith('Branch name generated: test-task'); - }); - - it('should use task content (not name) for AI summarization', async () => { - // Given: Task where name differs from content - const task: TaskInfo = { - name: 'old-file-name', - content: 'New feature implementation details', - filePath: '/tasks/old-file-name.yaml', - data: { - task: 'New feature implementation details', - worktree: true, - }, + // Then: executePiece receives the propagated display label. + expect(mockExecutePiece).toHaveBeenCalledTimes(1); + const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as { + taskDisplayLabel?: string; + taskPrefix?: string; + providerOptions?: unknown; }; - - mockSummarizeTaskName.mockResolvedValue('new-feature'); - mockCreateSharedClone.mockReturnValue({ - path: '/project/../new-feature', - branch: 'takt/new-feature', + expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel); + expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel); + expect(pieceExecutionOptions?.providerOptions).toEqual({ + claude: { sandbox: { allowUnsandboxedCommands: true } }, }); - - // When - await resolveTaskExecution(task, '/project', 'default'); - - // Then: Should use content, not file name - expect(mockSummarizeTaskName).toHaveBeenCalledWith('New feature implementation details', { cwd: '/project' }); - }); - - it('should use piece override from task data', async () => { - // Given: Task with piece override - const task: TaskInfo = { - name: 'task-with-piece', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - piece: 'custom-piece', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.execPiece).toBe('custom-piece'); - }); - - it('should pass branch option to createSharedClone when specified', async () => { - // Given: Task with custom branch - const task: TaskInfo = { - name: 'task-with-branch', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - worktree: true, - branch: 'feature/custom-branch', - }, - }; - - mockSummarizeTaskName.mockResolvedValue('custom-task'); - mockCreateSharedClone.mockReturnValue({ - path: '/project/../custom-task', - branch: 'feature/custom-branch', - }); - - // When - await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', { - worktree: true, - branch: 'feature/custom-branch', - taskSlug: 'custom-task', - }); - }); - - it('should display clone creation info', async () => { - // Given: Task with worktree - const task: TaskInfo = { - name: 'info-task', - content: 'Info task', - filePath: '/tasks/info.yaml', - data: { - task: 'Info task', - worktree: true, - }, - }; - - mockSummarizeTaskName.mockResolvedValue('info-task'); - mockCreateSharedClone.mockReturnValue({ - path: '/project/../20260128-info-task', - branch: 'takt/20260128-info-task', - }); - - // When - await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(mockInfo).toHaveBeenCalledWith( - 'Clone created: /project/../20260128-info-task (branch: takt/20260128-info-task)' - ); - }); - - it('should return autoPr from task YAML when specified', async () => { - // Given: Task with auto_pr option - const task: TaskInfo = { - name: 'task-with-auto-pr', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - auto_pr: true, - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(true); - }); - - it('should return autoPr: false from task YAML when specified as false', async () => { - // Given: Task with auto_pr: false - const task: TaskInfo = { - name: 'task-no-auto-pr', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - auto_pr: false, - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(false); - }); - - it('should fall back to global config autoPr when task YAML does not specify', async () => { - // Given: Task without auto_pr, global config has autoPr - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - autoPr: true, - }); - - const task: TaskInfo = { - name: 'task-no-auto-pr-setting', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(true); - }); - - it('should return false autoPr when neither task nor config specifies', async () => { - // Given: Neither task nor config has autoPr - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - }); - - const task: TaskInfo = { - name: 'task-default', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(false); - }); - - it('should prioritize task YAML auto_pr over global config', async () => { - // Given: Task has auto_pr: false, global config has autoPr: true - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - autoPr: true, - }); - - const task: TaskInfo = { - name: 'task-override', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - auto_pr: false, - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(false); - }); - - it('should capture baseBranch from getCurrentBranch when worktree is used', async () => { - // Given: Task with worktree, on 'develop' branch - mockGetCurrentBranch.mockReturnValue('develop'); - const task: TaskInfo = { - name: 'task-on-develop', - content: 'Task on develop branch', - filePath: '/tasks/task.yaml', - data: { - task: 'Task on develop branch', - worktree: true, - }, - }; - - mockSummarizeTaskName.mockResolvedValue('task-develop'); - mockCreateSharedClone.mockReturnValue({ - path: '/project/../task-develop', - branch: 'takt/task-develop', - }); - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(mockGetCurrentBranch).toHaveBeenCalledWith('/project'); - expect(result.baseBranch).toBe('develop'); - }); - - it('should not set baseBranch when worktree is not used', async () => { - // Given: Task without worktree - const task: TaskInfo = { - name: 'task-no-worktree', - content: 'Task without worktree', - filePath: '/tasks/task.yaml', - data: { - task: 'Task without worktree', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(mockGetCurrentBranch).not.toHaveBeenCalled(); - expect(result.baseBranch).toBeUndefined(); - }); - - it('should return issueNumber from task data when specified', async () => { - // Given: Task with issue number - const task: TaskInfo = { - name: 'task-with-issue', - content: 'Fix authentication bug', - filePath: '/tasks/task.yaml', - data: { - task: 'Fix authentication bug', - issue: 131, - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.issueNumber).toBe(131); - }); - - it('should return undefined issueNumber when task data has no issue', async () => { - // Given: Task without issue - const task: TaskInfo = { - name: 'task-no-issue', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.issueNumber).toBeUndefined(); - }); - - it('should not start clone creation when abortSignal is already aborted', async () => { - // Given: Worktree task with pre-aborted signal - const task: TaskInfo = { - name: 'aborted-before-clone', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - worktree: true, - }, - }; - const controller = new AbortController(); - controller.abort(); - - // When / Then - await expect(resolveTaskExecution(task, '/project', 'default', controller.signal)).rejects.toThrow('Task execution aborted'); - expect(mockSummarizeTaskName).not.toHaveBeenCalled(); - expect(mockCreateSharedClone).not.toHaveBeenCalled(); - }); - - it('should stage task_dir spec into run context and return reportDirName', async () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-taskdir-normal-')); - const projectDir = path.join(tmpRoot, 'project'); - fs.mkdirSync(path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng'), { recursive: true }); - const sourceOrder = path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng', 'order.md'); - fs.writeFileSync(sourceOrder, '# normal task spec\n', 'utf-8'); - - const task: TaskInfo = { - name: 'task-with-dir', - content: 'Task content', - taskDir: '.takt/tasks/20260201-015714-foptng', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - const result = await resolveTaskExecution(task, projectDir, 'default'); - - expect(result.reportDirName).toBe('20260201-015714-foptng'); - expect(result.execCwd).toBe(projectDir); - const stagedOrder = path.join(projectDir, '.takt', 'runs', '20260201-015714-foptng', 'context', 'task', 'order.md'); - expect(fs.existsSync(stagedOrder)).toBe(true); - expect(fs.readFileSync(stagedOrder, 'utf-8')).toContain('normal task spec'); - expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260201-015714-foptng/context/task/order.md`.'); - expect(result.taskPrompt).not.toContain(projectDir); - }); - - it('should throw when taskDir format is invalid', async () => { - const task: TaskInfo = { - name: 'task-with-invalid-dir', - content: 'Task content', - taskDir: '.takt/reports/20260201-015714-foptng', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - await expect(resolveTaskExecution(task, '/project', 'default')).rejects.toThrow( - 'Invalid task_dir format: .takt/reports/20260201-015714-foptng', - ); - }); - - it('should throw when taskDir contains parent directory segment', async () => { - const task: TaskInfo = { - name: 'task-with-parent-dir', - content: 'Task content', - taskDir: '.takt/tasks/..', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - await expect(resolveTaskExecution(task, '/project', 'default')).rejects.toThrow( - 'Invalid task_dir format: .takt/tasks/..', - ); - }); - - it('should stage task_dir spec into worktree run context and return run-scoped task prompt', async () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-taskdir-')); - const projectDir = path.join(tmpRoot, 'project'); - const cloneDir = path.join(tmpRoot, 'clone'); - fs.mkdirSync(path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng'), { recursive: true }); - fs.mkdirSync(cloneDir, { recursive: true }); - const sourceOrder = path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng', 'order.md'); - fs.writeFileSync(sourceOrder, '# webhook task\n', 'utf-8'); - - const task: TaskInfo = { - name: 'task-with-taskdir-worktree', - content: 'Task content', - taskDir: '.takt/tasks/20260201-015714-foptng', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - worktree: true, - }, - }; - - mockSummarizeTaskName.mockResolvedValue('webhook-task'); - mockCreateSharedClone.mockReturnValue({ - path: cloneDir, - branch: 'takt/webhook-task', - }); - - const result = await resolveTaskExecution(task, projectDir, 'default'); - - const stagedOrder = path.join(cloneDir, '.takt', 'runs', '20260201-015714-foptng', 'context', 'task', 'order.md'); - expect(fs.existsSync(stagedOrder)).toBe(true); - expect(fs.readFileSync(stagedOrder, 'utf-8')).toContain('webhook task'); - - expect(result.taskPrompt).toContain('Implement using only the files in `.takt/runs/20260201-015714-foptng/context/task`.'); - expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260201-015714-foptng/context/task/order.md`.'); - expect(result.taskPrompt).not.toContain(projectDir); }); }); diff --git a/src/__tests__/taskInstructionActions.test.ts b/src/__tests__/taskInstructionActions.test.ts index 83d12e6..816ee08 100644 --- a/src/__tests__/taskInstructionActions.test.ts +++ b/src/__tests__/taskInstructionActions.test.ts @@ -48,7 +48,7 @@ vi.mock('../infra/task/index.js', () => ({ })); vi.mock('../infra/config/index.js', () => ({ - loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: 3, language: 'en' })), + resolvePieceConfigValues: vi.fn(() => ({ interactivePreviewMovements: 3, language: 'en' })), getPieceDescription: vi.fn(() => ({ name: 'default', description: 'desc', @@ -82,6 +82,8 @@ vi.mock('../features/interactive/index.js', () => ({ listRecentRuns: (...args: unknown[]) => mockListRecentRuns(...args), selectRun: (...args: unknown[]) => mockSelectRun(...args), loadRunSessionContext: (...args: unknown[]) => mockLoadRunSessionContext(...args), + findRunForTask: vi.fn(() => null), + findPreviousOrderContent: vi.fn(() => null), })); vi.mock('../features/tasks/execute/taskExecution.js', () => ({ @@ -191,6 +193,7 @@ describe('instructBranch direct execution flow', () => { '', expect.anything(), undefined, + null, ); }); @@ -227,6 +230,7 @@ describe('instructBranch direct execution flow', () => { '', expect.anything(), runContext, + null, ); }); diff --git a/src/__tests__/taskRetryActions.test.ts b/src/__tests__/taskRetryActions.test.ts index b65f54d..ea172fb 100644 --- a/src/__tests__/taskRetryActions.test.ts +++ b/src/__tests__/taskRetryActions.test.ts @@ -3,8 +3,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const { mockExistsSync, mockSelectPiece, - mockSelectOption, - mockLoadGlobalConfig, + mockSelectOptionWithDefault, + mockResolvePieceConfigValue, mockLoadPieceByIdentifier, mockGetPieceDescription, mockRunRetryMode, @@ -15,8 +15,8 @@ const { } = vi.hoisted(() => ({ mockExistsSync: vi.fn(() => true), mockSelectPiece: vi.fn(), - mockSelectOption: vi.fn(), - mockLoadGlobalConfig: vi.fn(), + mockSelectOptionWithDefault: vi.fn(), + mockResolvePieceConfigValue: vi.fn(), mockLoadPieceByIdentifier: vi.fn(), mockGetPieceDescription: vi.fn(() => ({ name: 'default', @@ -41,7 +41,7 @@ vi.mock('../features/pieceSelection/index.js', () => ({ })); vi.mock('../shared/prompt/index.js', () => ({ - selectOption: (...args: unknown[]) => mockSelectOption(...args), + selectOptionWithDefault: (...args: unknown[]) => mockSelectOptionWithDefault(...args), })); vi.mock('../shared/ui/index.js', () => ({ @@ -60,7 +60,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ })); vi.mock('../infra/config/index.js', () => ({ - loadGlobalConfig: (...args: unknown[]) => mockLoadGlobalConfig(...args), + resolvePieceConfigValue: (...args: unknown[]) => mockResolvePieceConfigValue(...args), loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args), getPieceDescription: (...args: unknown[]) => mockGetPieceDescription(...args), })); @@ -73,6 +73,7 @@ vi.mock('../features/interactive/index.js', () => ({ runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '', })), runRetryMode: (...args: unknown[]) => mockRunRetryMode(...args), + findPreviousOrderContent: vi.fn(() => null), })); vi.mock('../infra/task/index.js', () => ({ @@ -126,9 +127,9 @@ beforeEach(() => { mockExistsSync.mockReturnValue(true); mockSelectPiece.mockResolvedValue('default'); - mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); + mockResolvePieceConfigValue.mockReturnValue(3); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); - mockSelectOption.mockResolvedValue('plan'); + mockSelectOptionWithDefault.mockResolvedValue('plan'); mockRunRetryMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); mockStartReExecution.mockReturnValue({ name: 'my-task', @@ -151,14 +152,31 @@ describe('retryFailedTask', () => { expect.objectContaining({ failure: expect.objectContaining({ taskName: 'my-task', taskContent: 'Do something' }), }), + null, ); expect(mockStartReExecution).toHaveBeenCalledWith('my-task', ['failed'], undefined, '追加指示A'); expect(mockExecuteAndCompleteTask).toHaveBeenCalled(); }); + it('should pass failed movement as default to selectOptionWithDefault', async () => { + const task = makeFailedTask(); // failure.movement = 'review' + + await retryFailedTask(task, '/project'); + + expect(mockSelectOptionWithDefault).toHaveBeenCalledWith( + 'Start from movement:', + expect.arrayContaining([ + expect.objectContaining({ value: 'plan' }), + expect.objectContaining({ value: 'implement' }), + expect.objectContaining({ value: 'review' }), + ]), + 'review', + ); + }); + it('should pass non-initial movement as startMovement', async () => { const task = makeFailedTask(); - mockSelectOption.mockResolvedValue('implement'); + mockSelectOptionWithDefault.mockResolvedValue('implement'); await retryFailedTask(task, '/project'); diff --git a/src/__tests__/taskStatusLabel.test.ts b/src/__tests__/taskStatusLabel.test.ts index 7efb53f..8e06987 100644 --- a/src/__tests__/taskStatusLabel.test.ts +++ b/src/__tests__/taskStatusLabel.test.ts @@ -1,39 +1,50 @@ import { describe, expect, it } from 'vitest'; -import { formatTaskStatusLabel } from '../features/tasks/list/taskStatusLabel.js'; +import { formatTaskStatusLabel, formatShortDate } from '../features/tasks/list/taskStatusLabel.js'; import type { TaskListItem } from '../infra/task/types.js'; +function makeTask(overrides: Partial): TaskListItem { + return { + kind: 'pending', + name: 'test-task', + createdAt: '2026-02-11T00:00:00.000Z', + filePath: '/tmp/task.md', + content: 'content', + ...overrides, + }; +} + describe('formatTaskStatusLabel', () => { it("should format pending task as '[pending] name'", () => { - // Given: pending タスク - const task: TaskListItem = { - kind: 'pending', - name: 'implement test', - createdAt: '2026-02-11T00:00:00.000Z', - filePath: '/tmp/task.md', - content: 'content', - }; - - // When: ステータスラベルを生成する - const result = formatTaskStatusLabel(task); - - // Then: pending は pending 表示になる - expect(result).toBe('[pending] implement test'); + const task = makeTask({ kind: 'pending', name: 'implement-test' }); + expect(formatTaskStatusLabel(task)).toBe('[pending] implement-test'); }); it("should format failed task as '[failed] name'", () => { - // Given: failed タスク - const task: TaskListItem = { - kind: 'failed', - name: 'retry payment', - createdAt: '2026-02-11T00:00:00.000Z', - filePath: '/tmp/task.md', - content: 'content', - }; + const task = makeTask({ kind: 'failed', name: 'retry-payment' }); + expect(formatTaskStatusLabel(task)).toBe('[failed] retry-payment'); + }); - // When: ステータスラベルを生成する - const result = formatTaskStatusLabel(task); + it('should include branch when present', () => { + const task = makeTask({ + kind: 'completed', + name: 'fix-login-bug', + branch: 'takt/284/fix-login-bug', + }); + expect(formatTaskStatusLabel(task)).toBe('[completed] fix-login-bug (takt/284/fix-login-bug)'); + }); - // Then: failed は failed 表示になる - expect(result).toBe('[failed] retry payment'); + it('should not include branch when absent', () => { + const task = makeTask({ kind: 'running', name: 'my-task' }); + expect(formatTaskStatusLabel(task)).toBe('[running] my-task'); + }); +}); + +describe('formatShortDate', () => { + it('should format ISO string to MM/DD HH:mm', () => { + expect(formatShortDate('2025-02-18T14:30:00.000Z')).toBe('02/18 14:30'); + }); + + it('should zero-pad single digit values', () => { + expect(formatShortDate('2025-01-05T03:07:00.000Z')).toBe('01/05 03:07'); }); }); diff --git a/src/__tests__/watchTasks.test.ts b/src/__tests__/watchTasks.test.ts index 81991ee..48a74d3 100644 --- a/src/__tests__/watchTasks.test.ts +++ b/src/__tests__/watchTasks.test.ts @@ -14,7 +14,7 @@ const { mockSuccess, mockWarn, mockError, - mockGetCurrentPiece, + mockResolveConfigValue, } = vi.hoisted(() => ({ mockRecoverInterruptedRunningTasks: vi.fn(), mockGetTasksDir: vi.fn(), @@ -28,7 +28,7 @@ const { mockSuccess: vi.fn(), mockWarn: vi.fn(), mockError: vi.fn(), - mockGetCurrentPiece: vi.fn(), + mockResolveConfigValue: vi.fn(), })); vi.mock('../infra/task/index.js', () => ({ @@ -61,7 +61,7 @@ vi.mock('../shared/i18n/index.js', () => ({ })); vi.mock('../infra/config/index.js', () => ({ - getCurrentPiece: mockGetCurrentPiece, + resolveConfigValue: mockResolveConfigValue, })); import { watchTasks } from '../features/tasks/watch/index.js'; @@ -69,7 +69,7 @@ import { watchTasks } from '../features/tasks/watch/index.js'; describe('watchTasks', () => { beforeEach(() => { vi.clearAllMocks(); - mockGetCurrentPiece.mockReturnValue('default'); + mockResolveConfigValue.mockReturnValue('default'); mockRecoverInterruptedRunningTasks.mockReturnValue(0); mockGetTasksDir.mockReturnValue('/project/.takt/tasks.yaml'); mockExecuteAndCompleteTask.mockResolvedValue(true); diff --git a/src/__tests__/workerPool.test.ts b/src/__tests__/workerPool.test.ts index 7254ab8..c35dbb1 100644 --- a/src/__tests__/workerPool.test.ts +++ b/src/__tests__/workerPool.test.ts @@ -45,11 +45,17 @@ const mockInfo = vi.mocked(info); const TEST_POLL_INTERVAL_MS = 50; -function createTask(name: string): TaskInfo { +function createTask(name: string, options?: { issue?: number }): TaskInfo { return { name, content: `Task: ${name}`, filePath: `/tasks/${name}.yaml`, + createdAt: '2026-01-01T00:00:00.000Z', + status: 'pending', + data: { + task: `Task: ${name}`, + ...(options?.issue !== undefined ? { issue: options.issue } : {}), + }, }; } @@ -135,10 +141,41 @@ describe('runWithWorkerPool', () => { // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; - expect(parallelOpts).toEqual({ + expect(parallelOpts).toMatchObject({ abortSignal: expect.any(AbortSignal), taskPrefix: 'my-task', taskColorIndex: 0, + taskDisplayLabel: undefined, + }); + }); + + it('should use full issue number as taskPrefix label when task has issue in parallel execution', async () => { + // Given: task with 5-digit issue number should not be truncated + const issueNumber = 12345; + const tasks = [createTask('issue-task', { issue: issueNumber })]; + 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', undefined, TEST_POLL_INTERVAL_MS); + + // Then: Issue label is used instead of truncated task name + writeSpy.mockRestore(); + const allOutput = stdoutChunks.join(''); + expect(allOutput).toContain('[#12345]'); + expect(allOutput).not.toContain('[#123]'); + + expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); + const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; + expect(parallelOpts).toEqual({ + abortSignal: expect.any(AbortSignal), + taskPrefix: `#${issueNumber}`, + taskDisplayLabel: `#${issueNumber}`, + taskColorIndex: 0, }); }); @@ -153,10 +190,11 @@ describe('runWithWorkerPool', () => { // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; - expect(parallelOpts).toEqual({ + expect(parallelOpts).toMatchObject({ abortSignal: expect.any(AbortSignal), taskPrefix: undefined, taskColorIndex: undefined, + taskDisplayLabel: undefined, }); }); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 560742e..e71ac9a 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -4,7 +4,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { basename, dirname } from 'node:path'; -import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig } from '../infra/config/index.js'; +import { loadCustomAgents, loadAgentPrompt, resolveConfigValues } from '../infra/config/index.js'; import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js'; import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js'; import { createLogger } from '../shared/utils/index.js'; @@ -29,16 +29,10 @@ export class AgentRunner { agentConfig?: CustomAgentConfig, ): ProviderType { if (options?.provider) return options.provider; - const projectConfig = loadProjectConfig(cwd); - if (projectConfig.provider) return projectConfig.provider; + const config = resolveConfigValues(cwd, ['provider']); + if (config.provider) return config.provider; if (options?.stepProvider) return options.stepProvider; if (agentConfig?.provider) return agentConfig.provider; - try { - const globalConfig = loadGlobalConfig(); - if (globalConfig.provider) return globalConfig.provider; - } catch (error) { - log.debug('Global config not available for provider resolution', { error }); - } return 'claude'; } @@ -55,14 +49,11 @@ export class AgentRunner { if (options?.model) return options.model; if (options?.stepModel) return options.stepModel; if (agentConfig?.model) return agentConfig.model; - try { - const globalConfig = loadGlobalConfig(); - if (globalConfig.model) { - const globalProvider = globalConfig.provider ?? 'claude'; - if (globalProvider === resolvedProvider) return globalConfig.model; - } - } catch (error) { - log.debug('Global config not available for model resolution', { error }); + if (!options?.cwd) return undefined; + const config = resolveConfigValues(options.cwd, ['provider', 'model']); + if (config.model) { + const defaultProvider = config.provider ?? 'claude'; + if (defaultProvider === resolvedProvider) return config.model; } return undefined; } @@ -131,7 +122,7 @@ export class AgentRunner { name: agentConfig.name, systemPrompt: agentConfig.claudeAgent || agentConfig.claudeSkill ? undefined - : loadAgentPrompt(agentConfig), + : loadAgentPrompt(agentConfig, options.cwd), claudeAgent: agentConfig.claudeAgent, claudeSkill: agentConfig.claudeSkill, }); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 4c12eb1..50db1e7 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -1,15 +1,18 @@ /** * CLI subcommand definitions * - * Registers all named subcommands (run, watch, add, list, switch, clear, eject, config, prompt, catalog). + * Registers all named subcommands (run, watch, add, list, switch, clear, eject, prompt, catalog). */ -import { clearPersonaSessions, getCurrentPiece } from '../../infra/config/index.js'; -import { success } from '../../shared/ui/index.js'; +import { join } from 'node:path'; +import { clearPersonaSessions, resolveConfigValue } from '../../infra/config/index.js'; +import { getGlobalConfigDir } from '../../infra/config/paths.js'; +import { success, info } from '../../shared/ui/index.js'; import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js'; -import { switchPiece, switchConfig, ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, deploySkill } from '../../features/config/index.js'; +import { switchPiece, ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, resetConfigToDefault, deploySkill } from '../../features/config/index.js'; import { previewPrompts } from '../../features/prompt/index.js'; import { showCatalog } from '../../features/catalog/index.js'; +import { computeReviewMetrics, formatReviewMetrics, parseSinceDuration, purgeOldEvents } from '../../features/analytics/index.js'; import { program, resolvedCwd } from './program.js'; import { resolveAgentOverrides } from './helpers.js'; @@ -17,7 +20,7 @@ program .command('run') .description('Run all pending tasks from .takt/tasks.yaml') .action(async () => { - const piece = getCurrentPiece(resolvedCwd); + const piece = resolveConfigValue(resolvedCwd, 'piece'); await runAllTasks(resolvedCwd, piece, resolveAgentOverrides(program)); }); @@ -96,23 +99,22 @@ program } }); -program - .command('config') - .description('Configure settings (permission mode)') - .argument('[key]', 'Configuration key') - .action(async (key?: string) => { - await switchConfig(resolvedCwd, key); - }); - const reset = program .command('reset') .description('Reset settings to defaults'); +reset + .command('config') + .description('Reset global config to builtin template (with backup)') + .action(async () => { + await resetConfigToDefault(); + }); + reset .command('categories') .description('Reset piece categories to builtin defaults') .action(async () => { - await resetCategoriesToDefault(); + await resetCategoriesToDefault(resolvedCwd); }); program @@ -137,3 +139,37 @@ program .action((type?: string) => { showCatalog(resolvedCwd, type); }); + +const metrics = program + .command('metrics') + .description('Show analytics metrics'); + +metrics + .command('review') + .description('Show review quality metrics') + .option('--since ', 'Time window (e.g. "7d", "30d")', '30d') + .action((opts: { since: string }) => { + const analytics = resolveConfigValue(resolvedCwd, 'analytics'); + const eventsDir = analytics?.eventsPath ?? join(getGlobalConfigDir(), 'analytics', 'events'); + const durationMs = parseSinceDuration(opts.since); + const sinceMs = Date.now() - durationMs; + const result = computeReviewMetrics(eventsDir, sinceMs); + info(formatReviewMetrics(result)); + }); + +program + .command('purge') + .description('Purge old analytics event files') + .option('--retention-days ', 'Retention period in days', '30') + .action((opts: { retentionDays: string }) => { + const analytics = resolveConfigValue(resolvedCwd, 'analytics'); + const eventsDir = analytics?.eventsPath ?? join(getGlobalConfigDir(), 'analytics', 'events'); + const retentionDays = analytics?.retentionDays + ?? parseInt(opts.retentionDays, 10); + const deleted = purgeOldEvents(eventsDir, retentionDays, new Date()); + if (deleted.length === 0) { + info('No files to purge.'); + } else { + success(`Purged ${deleted.length} file(s): ${deleted.join(', ')}`); + } + }); diff --git a/src/app/cli/program.ts b/src/app/cli/program.ts index 728f8ca..47b9614 100644 --- a/src/app/cli/program.ts +++ b/src/app/cli/program.ts @@ -11,7 +11,7 @@ import { resolve } from 'node:path'; import { initGlobalDirs, initProjectDirs, - loadGlobalConfig, + resolveConfigValues, isVerboseMode, } from '../../infra/config/index.js'; import { setQuietMode } from '../../shared/context.js'; @@ -51,7 +51,8 @@ program .option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation') .option('--skip-git', 'Skip branch creation, commit, and push (pipeline mode)') .option('--create-worktree ', 'Skip the worktree prompt by explicitly specifying yes or no') - .option('-q, --quiet', 'Minimal output mode: suppress AI output (for CI)'); + .option('-q, --quiet', 'Minimal output mode: suppress AI output (for CI)') + .option('-c, --continue', 'Continue from the last assistant session'); /** * Run pre-action hook: common initialization for all commands. @@ -69,7 +70,7 @@ export async function runPreActionHook(): Promise { const verbose = isVerboseMode(resolvedCwd); initDebugLogger(verbose ? { enabled: true } : undefined, resolvedCwd); - const config = loadGlobalConfig(); + const config = resolveConfigValues(resolvedCwd, ['logLevel', 'minimalOutput']); if (verbose) { setVerboseConsole(true); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 02870e3..f97b023 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -6,7 +6,6 @@ */ import { info, error as logError, withProgress } from '../../shared/ui/index.js'; -import { confirm } from '../../shared/prompt/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'; @@ -15,7 +14,6 @@ import { executePipeline } from '../../features/pipeline/index.js'; import { interactiveMode, selectInteractiveMode, - selectRecentSession, passthroughMode, quietMode, personaMode, @@ -23,8 +21,7 @@ import { dispatchConversationAction, type InteractiveModeResult, } from '../../features/interactive/index.js'; -import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js'; -import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; +import { getPieceDescription, resolveConfigValue, resolveConfigValues, loadPersonaSessions } from '../../infra/config/index.js'; import { program, resolvedCwd, pipelineMode } from './program.js'; import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js'; import { loadTaskHistory } from './taskHistory.js'; @@ -85,8 +82,12 @@ export async function executeDefaultAction(task?: string): Promise { const opts = program.opts(); const agentOverrides = resolveAgentOverrides(program); const createWorktreeOverride = parseCreateWorktreeOption(opts.createWorktree as string | undefined); + const resolvedPipelinePiece = (opts.piece as string | undefined) ?? resolveConfigValue(resolvedCwd, 'piece'); + const resolvedPipelineAutoPr = opts.autoPr === true + ? true + : (resolveConfigValue(resolvedCwd, 'autoPr') ?? false); const selectOptions: SelectAndExecuteOptions = { - autoPr: opts.autoPr === true, + autoPr: opts.autoPr === true ? true : undefined, repo: opts.repo as string | undefined, piece: opts.piece as string | undefined, createWorktree: createWorktreeOverride, @@ -97,9 +98,9 @@ export async function executeDefaultAction(task?: string): Promise { const exitCode = await executePipeline({ issueNumber: opts.issue as number | undefined, task: opts.task as string | undefined, - piece: (opts.piece as string | undefined) ?? DEFAULT_PIECE_NAME, + piece: resolvedPipelinePiece, branch: opts.branch as string | undefined, - autoPr: opts.autoPr === true, + autoPr: resolvedPipelineAutoPr, repo: opts.repo as string | undefined, skipGit: opts.skipGit === true, cwd: resolvedCwd, @@ -137,7 +138,7 @@ export async function executeDefaultAction(task?: string): Promise { } // All paths below go through interactive mode - const globalConfig = loadGlobalConfig(); + const globalConfig = resolveConfigValues(resolvedCwd, ['language', 'interactivePreviewMovements', 'provider']); const lang = resolveLanguage(globalConfig.language); const pieceId = await determinePiece(resolvedCwd, selectOptions.piece); @@ -169,17 +170,14 @@ export async function executeDefaultAction(task?: string): Promise { switch (selectedMode) { case 'assistant': { let selectedSessionId: string | undefined; - const provider = globalConfig.provider; - if (provider === 'claude') { - const shouldSelectSession = await confirm( - getLabel('interactive.sessionSelector.confirm', lang), - false, - ); - if (shouldSelectSession) { - const sessionId = await selectRecentSession(resolvedCwd, lang); - if (sessionId) { - selectedSessionId = sessionId; - } + if (opts.continue === true) { + const providerType = globalConfig.provider; + const savedSessions = loadPersonaSessions(resolvedCwd, providerType); + const savedSessionId = savedSessions['interactive']; + if (savedSessionId) { + selectedSessionId = savedSessionId; + } else { + info(getLabel('interactive.continueNoSession', lang)); } } result = await interactiveMode(resolvedCwd, initialInput, pieceContext, selectedSessionId); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 9214dfe..802bdd9 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -23,6 +23,16 @@ export interface ObservabilityConfig { providerEvents?: boolean; } +/** Analytics configuration for local metrics collection */ +export interface AnalyticsConfig { + /** Whether analytics collection is enabled */ + enabled?: boolean; + /** Custom path for analytics events directory (default: ~/.takt/analytics/events) */ + eventsPath?: string; + /** Retention period in days for analytics event files (default: 30) */ + retentionDays?: number; +} + /** Language setting for takt */ export type Language = 'en' | 'ja'; @@ -53,11 +63,11 @@ export interface NotificationSoundEventsConfig { /** Global configuration for takt */ export interface GlobalConfig { language: Language; - defaultPiece: string; logLevel: 'debug' | 'info' | 'warn' | 'error'; provider?: 'claude' | 'codex' | 'opencode' | 'mock'; model?: string; observability?: ObservabilityConfig; + analytics?: AnalyticsConfig; /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ worktreeDir?: string; /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ @@ -100,6 +110,8 @@ export interface GlobalConfig { notificationSoundEvents?: NotificationSoundEventsConfig; /** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ interactivePreviewMovements?: number; + /** Verbose output mode */ + verbose?: boolean; /** 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) */ @@ -109,7 +121,6 @@ export interface GlobalConfig { /** Project-level configuration */ export interface ProjectConfig { piece?: string; - agents?: CustomAgentConfig[]; provider?: 'claude' | 'codex' | 'opencode' | 'mock'; providerOptions?: MovementProviderOptions; /** Provider-specific permission profiles */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index c36ace7..0076130 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -378,6 +378,13 @@ export const ObservabilityConfigSchema = z.object({ provider_events: z.boolean().optional(), }); +/** Analytics config schema */ +export const AnalyticsConfigSchema = z.object({ + enabled: z.boolean().optional(), + events_path: z.string().optional(), + retention_days: z.number().int().positive().optional(), +}); + /** Language setting schema */ export const LanguageSchema = z.enum(['en', 'ja']); @@ -405,11 +412,11 @@ export const PieceCategoryConfigSchema = z.record(z.string(), PieceCategoryConfi /** Global config schema */ export const GlobalConfigSchema = z.object({ language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), - default_piece: z.string().optional().default('default'), log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'), provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'), model: z.string().optional(), observability: ObservabilityConfigSchema.optional(), + analytics: AnalyticsConfigSchema.optional(), /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ worktree_dir: z.string().optional(), /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ @@ -458,6 +465,8 @@ export const GlobalConfigSchema = z.object({ }).optional(), /** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3), + /** Verbose output mode */ + verbose: z.boolean().optional(), /** 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) */ @@ -467,7 +476,6 @@ export const GlobalConfigSchema = z.object({ /** Project config schema */ export const ProjectConfigSchema = z.object({ piece: z.string().optional(), - agents: z.array(CustomAgentConfigSchema).optional(), provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), provider_options: MovementProviderOptionsSchema, provider_profiles: ProviderPermissionProfilesSchema, diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index 259a572..8f99282 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -1,5 +1,6 @@ import { join } from 'node:path'; import type { PieceMovement, PieceState, Language } from '../../models/types.js'; +import type { MovementProviderOptions } from '../../models/piece-types.js'; import type { RunAgentOptions } from '../../../agents/runner.js'; import type { PhaseRunnerContext } from '../phase-runner.js'; import type { PieceEngineOptions, PhaseName } from '../types.js'; @@ -7,6 +8,27 @@ import { buildSessionKey } from '../session-key.js'; import { resolveMovementProviderModel } from '../provider-resolution.js'; import { DEFAULT_PROVIDER_PERMISSION_PROFILES, resolveMovementPermissionMode } from '../permission-profile-resolution.js'; +function mergeProviderOptions( + ...layers: (MovementProviderOptions | undefined)[] +): MovementProviderOptions | undefined { + const result: MovementProviderOptions = {}; + for (const layer of layers) { + if (!layer) continue; + if (layer.codex) { + result.codex = { ...result.codex, ...layer.codex }; + } + if (layer.opencode) { + result.opencode = { ...result.opencode, ...layer.opencode }; + } + if (layer.claude?.sandbox) { + result.claude = { + sandbox: { ...result.claude?.sandbox, ...layer.claude.sandbox }, + }; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + export class OptionsBuilder { constructor( private readonly engineOptions: PieceEngineOptions, @@ -34,9 +56,7 @@ export class OptionsBuilder { const resolvedProviderForPermissions = this.engineOptions.provider - ?? this.engineOptions.projectProvider ?? resolved.provider - ?? this.engineOptions.globalProvider ?? 'claude'; return { @@ -51,10 +71,13 @@ export class OptionsBuilder { movementName: step.name, requiredPermissionMode: step.requiredPermissionMode, provider: resolvedProviderForPermissions, - projectProviderProfiles: this.engineOptions.projectProviderProfiles, - globalProviderProfiles: this.engineOptions.globalProviderProfiles ?? DEFAULT_PROVIDER_PERMISSION_PROFILES, + projectProviderProfiles: this.engineOptions.providerProfiles, + globalProviderProfiles: DEFAULT_PROVIDER_PERMISSION_PROFILES, }), - providerOptions: step.providerOptions, + providerOptions: mergeProviderOptions( + this.engineOptions.providerOptions, + step.providerOptions, + ), language: this.getLanguage(), onStream: this.engineOptions.onStream, onPermissionRequest: this.engineOptions.onPermissionRequest, diff --git a/src/core/piece/instruction/InstructionBuilder.ts b/src/core/piece/instruction/InstructionBuilder.ts index 1c39df9..4a9bd06 100644 --- a/src/core/piece/instruction/InstructionBuilder.ts +++ b/src/core/piece/instruction/InstructionBuilder.ts @@ -3,6 +3,9 @@ * * Builds the instruction string for main agent execution. * Assembles template variables and renders a single complete template. + * + * Truncation and context preparation are delegated to faceted-prompting. + * preparePreviousResponseContent is TAKT-specific and stays here. */ import type { PieceMovement, Language, OutputContractItem, OutputContractEntry } from '../../models/types.js'; @@ -10,62 +13,25 @@ import type { InstructionContext } from './instruction-context.js'; import { buildEditRule } from './instruction-context.js'; import { escapeTemplateChars, replaceTemplatePlaceholders } from './escape.js'; import { loadTemplate } from '../../../shared/prompts/index.js'; +import { + trimContextContent, + renderConflictNotice, + prepareKnowledgeContent as prepareKnowledgeContentGeneric, + preparePolicyContent as preparePolicyContentGeneric, +} from '../../../faceted-prompting/index.js'; const CONTEXT_MAX_CHARS = 2000; -interface PreparedContextBlock { - readonly content: string; - readonly truncated: boolean; -} - -function trimContextContent(content: string): PreparedContextBlock { - if (content.length <= CONTEXT_MAX_CHARS) { - return { content, truncated: false }; - } - return { - content: `${content.slice(0, CONTEXT_MAX_CHARS)}\n...TRUNCATED...`, - truncated: true, - }; -} - -function renderConflictNotice(): string { - return 'If prompt content conflicts with source files, source files take precedence.'; -} - function prepareKnowledgeContent(content: string, sourcePath?: string): string { - const prepared = trimContextContent(content); - const lines: string[] = [prepared.content]; - if (prepared.truncated && sourcePath) { - lines.push( - '', - `Knowledge is truncated. You MUST consult the source files before making decisions. Source: ${sourcePath}`, - ); - } - if (sourcePath) { - lines.push('', `Knowledge Source: ${sourcePath}`); - } - lines.push('', renderConflictNotice()); - return lines.join('\n'); + return prepareKnowledgeContentGeneric(content, CONTEXT_MAX_CHARS, sourcePath); } function preparePolicyContent(content: string, sourcePath?: string): string { - const prepared = trimContextContent(content); - const lines: string[] = [prepared.content]; - if (prepared.truncated && sourcePath) { - lines.push( - '', - `Policy is authoritative. If truncated, you MUST read the full policy file and follow it strictly. Source: ${sourcePath}`, - ); - } - if (sourcePath) { - lines.push('', `Policy Source: ${sourcePath}`); - } - lines.push('', renderConflictNotice()); - return lines.join('\n'); + return preparePolicyContentGeneric(content, CONTEXT_MAX_CHARS, sourcePath); } function preparePreviousResponseContent(content: string, sourcePath?: string): string { - const prepared = trimContextContent(content); + const prepared = trimContextContent(content, CONTEXT_MAX_CHARS); const lines: string[] = [prepared.content]; if (prepared.truncated && sourcePath) { lines.push('', `Previous Response is truncated. Source: ${sourcePath}`); diff --git a/src/core/piece/instruction/escape.ts b/src/core/piece/instruction/escape.ts index 9b4fcdd..a796a90 100644 --- a/src/core/piece/instruction/escape.ts +++ b/src/core/piece/instruction/escape.ts @@ -2,17 +2,16 @@ * Template escaping and placeholder replacement utilities * * Used by instruction builders to process instruction_template content. + * + * escapeTemplateChars is re-exported from faceted-prompting. + * replaceTemplatePlaceholders is TAKT-specific and stays here. */ import type { PieceMovement } from '../../models/types.js'; import type { InstructionContext } from './instruction-context.js'; +import { escapeTemplateChars } from '../../../faceted-prompting/index.js'; -/** - * Escape special characters in dynamic content to prevent template injection. - */ -export function escapeTemplateChars(str: string): string { - return str.replace(/\{/g, '{').replace(/\}/g, '}'); -} +export { escapeTemplateChars } from '../../../faceted-prompting/index.js'; /** * Replace template placeholders in the instruction_template body. diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index 8211673..f3ae155 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -8,6 +8,7 @@ import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js'; import type { ProviderPermissionProfiles } from '../models/provider-profiles.js'; +import type { MovementProviderOptions } from '../models/piece-types.js'; export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; @@ -171,24 +172,20 @@ export interface PieceEngineOptions { onAskUserQuestion?: AskUserQuestionHandler; /** Callback when iteration limit is reached - returns additional iterations or null to stop */ onIterationLimit?: IterationLimitCallback; - /** Bypass all permission checks (sacrifice-my-pc mode) */ + /** Bypass all permission checks */ bypassPermissions?: boolean; /** Project root directory (where .takt/ lives). */ projectCwd: string; /** Language for instruction metadata. Defaults to 'en'. */ language?: Language; provider?: ProviderType; - /** Project config provider (used for provider/profile resolution parity with AgentRunner) */ - projectProvider?: ProviderType; - /** Global config provider (used for provider/profile resolution parity with AgentRunner) */ - globalProvider?: ProviderType; model?: string; + /** Resolved provider options */ + providerOptions?: MovementProviderOptions; /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ personaProviders?: Record; - /** Project-level provider permission profiles */ - projectProviderProfiles?: ProviderPermissionProfiles; - /** Global-level provider permission profiles */ - globalProviderProfiles?: ProviderPermissionProfiles; + /** Resolved provider permission profiles */ + providerProfiles?: ProviderPermissionProfiles; /** Enable interactive-only rules and user-input transitions */ interactive?: boolean; /** Rule tag index detector (required for rules evaluation) */ diff --git a/src/faceted-prompting/compose.ts b/src/faceted-prompting/compose.ts new file mode 100644 index 0000000..ae48359 --- /dev/null +++ b/src/faceted-prompting/compose.ts @@ -0,0 +1,56 @@ +/** + * Facet composition — the core placement rule. + * + * system prompt: persona only (WHO) + * user message: policy + knowledge + instruction (HOW / WHAT TO KNOW / WHAT TO DO) + * + * This module has ZERO dependencies on TAKT internals. + */ + +import type { FacetSet, ComposedPrompt, ComposeOptions } from './types.js'; +import { prepareKnowledgeContent, preparePolicyContent } from './truncation.js'; + +/** + * Compose facets into an LLM-ready prompt according to Faceted Prompting + * placement rules. + * + * - persona → systemPrompt + * - policy / knowledge / instruction → userMessage (in that order) + */ +export function compose(facets: FacetSet, options: ComposeOptions): ComposedPrompt { + const systemPrompt = facets.persona?.body ?? ''; + + const userParts: string[] = []; + + // Policy (HOW) + if (facets.policies && facets.policies.length > 0) { + const joined = facets.policies.map(p => p.body).join('\n\n---\n\n'); + const sourcePath = facets.policies.length === 1 + ? facets.policies[0]!.sourcePath + : undefined; + userParts.push( + preparePolicyContent(joined, options.contextMaxChars, sourcePath), + ); + } + + // Knowledge (WHAT TO KNOW) + if (facets.knowledge && facets.knowledge.length > 0) { + const joined = facets.knowledge.map(k => k.body).join('\n\n---\n\n'); + const sourcePath = facets.knowledge.length === 1 + ? facets.knowledge[0]!.sourcePath + : undefined; + userParts.push( + prepareKnowledgeContent(joined, options.contextMaxChars, sourcePath), + ); + } + + // Instruction (WHAT TO DO) + if (facets.instruction) { + userParts.push(facets.instruction.body); + } + + return { + systemPrompt, + userMessage: userParts.join('\n\n'), + }; +} diff --git a/src/faceted-prompting/data-engine.ts b/src/faceted-prompting/data-engine.ts new file mode 100644 index 0000000..40d07a5 --- /dev/null +++ b/src/faceted-prompting/data-engine.ts @@ -0,0 +1,103 @@ +/** + * DataEngine — abstract interface for facet data retrieval. + * + * Compose logic depends only on this interface; callers wire + * concrete implementations (FileDataEngine, SqliteDataEngine, etc.). + * + * This module depends only on node:fs, node:path. + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { FacetKind, FacetContent } from './types.js'; + +/** Plural-kind to directory name mapping (identity for all current kinds). */ +const KIND_DIR: Record = { + personas: 'personas', + policies: 'policies', + knowledge: 'knowledge', + instructions: 'instructions', + 'output-contracts': 'output-contracts', +}; + +/** + * Abstract interface for facet data retrieval. + * + * Methods return Promises so that implementations backed by + * async I/O (database, network) can be used without changes. + */ +export interface DataEngine { + /** + * Resolve a single facet by kind and key (name without extension). + * Returns undefined if the facet does not exist. + */ + resolve(kind: FacetKind, key: string): Promise; + + /** List available facet keys for a given kind. */ + list(kind: FacetKind): Promise; +} + +/** + * File-system backed DataEngine. + * + * Resolves facets from a single root directory using the convention: + * {root}/{kind}/{key}.md + */ +export class FileDataEngine implements DataEngine { + constructor(private readonly root: string) {} + + async resolve(kind: FacetKind, key: string): Promise { + const dir = KIND_DIR[kind]; + const filePath = join(this.root, dir, `${key}.md`); + if (!existsSync(filePath)) return undefined; + const body = readFileSync(filePath, 'utf-8'); + return { body, sourcePath: filePath }; + } + + async list(kind: FacetKind): Promise { + const dir = KIND_DIR[kind]; + const dirPath = join(this.root, dir); + if (!existsSync(dirPath)) return []; + return readdirSync(dirPath) + .filter(f => f.endsWith('.md')) + .map(f => f.slice(0, -3)); + } +} + +/** + * Chains multiple DataEngines with first-match-wins resolution. + * + * resolve() returns the first non-undefined result. + * list() returns deduplicated keys from all engines. + */ +export class CompositeDataEngine implements DataEngine { + constructor(private readonly engines: readonly DataEngine[]) { + if (engines.length === 0) { + throw new Error('CompositeDataEngine requires at least one engine'); + } + } + + async resolve(kind: FacetKind, key: string): Promise { + for (const engine of this.engines) { + const result = await engine.resolve(kind, key); + if (result !== undefined) return result; + } + return undefined; + } + + async list(kind: FacetKind): Promise { + const seen = new Set(); + const result: string[] = []; + for (const engine of this.engines) { + const keys = await engine.list(kind); + for (const key of keys) { + if (!seen.has(key)) { + seen.add(key); + result.push(key); + } + } + } + return result; + } +} diff --git a/src/faceted-prompting/escape.ts b/src/faceted-prompting/escape.ts new file mode 100644 index 0000000..61fec3b --- /dev/null +++ b/src/faceted-prompting/escape.ts @@ -0,0 +1,16 @@ +/** + * Template injection prevention. + * + * Escapes curly braces in dynamic content so they are not + * interpreted as template variables by the template engine. + * + * This module has ZERO dependencies on TAKT internals. + */ + +/** + * Replace ASCII curly braces with full-width equivalents + * to prevent template variable injection in user-supplied content. + */ +export function escapeTemplateChars(str: string): string { + return str.replace(/\{/g, '\uff5b').replace(/\}/g, '\uff5d'); +} diff --git a/src/faceted-prompting/index.ts b/src/faceted-prompting/index.ts new file mode 100644 index 0000000..c50353a --- /dev/null +++ b/src/faceted-prompting/index.ts @@ -0,0 +1,51 @@ +/** + * faceted-prompting — Public API + * + * Re-exports all public types, interfaces, and functions. + * Consumers should import from this module only. + */ + +// Types +export type { + FacetKind, + FacetContent, + FacetSet, + ComposedPrompt, + ComposeOptions, +} from './types.js'; + +// Compose +export { compose } from './compose.js'; + +// DataEngine +export type { DataEngine } from './data-engine.js'; +export { FileDataEngine, CompositeDataEngine } from './data-engine.js'; + +// Truncation +export { + trimContextContent, + renderConflictNotice, + prepareKnowledgeContent, + preparePolicyContent, +} from './truncation.js'; + +// Template engine +export { renderTemplate } from './template.js'; + +// Escape +export { escapeTemplateChars } from './escape.js'; + +// Resolve +export type { PieceSections } from './resolve.js'; +export { + isResourcePath, + resolveFacetPath, + resolveFacetByName, + resolveResourcePath, + resolveResourceContent, + resolveRefToContent, + resolveRefList, + resolveSectionMap, + extractPersonaDisplayName, + resolvePersona, +} from './resolve.js'; diff --git a/src/faceted-prompting/resolve.ts b/src/faceted-prompting/resolve.ts new file mode 100644 index 0000000..8ebeeb7 --- /dev/null +++ b/src/faceted-prompting/resolve.ts @@ -0,0 +1,208 @@ +/** + * Facet reference resolution utilities. + * + * Resolves facet names / paths / content from section maps + * and candidate directories. Directory construction is delegated + * to the caller (TAKT provides project/global/builtin dirs). + * + * This module depends only on node:fs, node:os, node:path. + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, basename } from 'node:path'; + +/** Pre-resolved section maps passed to movement normalization. */ +export interface PieceSections { + /** Persona name -> file path (raw, not content-resolved) */ + personas?: Record; + /** Policy name -> resolved content */ + resolvedPolicies?: Record; + /** Knowledge name -> resolved content */ + resolvedKnowledge?: Record; + /** Instruction name -> resolved content */ + resolvedInstructions?: Record; + /** Report format name -> resolved content */ + resolvedReportFormats?: Record; +} + +/** + * Check if a spec looks like a resource path (vs. a facet name). + * Paths start with './', '../', '/', '~' or end with '.md'. + */ +export function isResourcePath(spec: string): boolean { + return ( + spec.startsWith('./') || + spec.startsWith('../') || + spec.startsWith('/') || + spec.startsWith('~') || + spec.endsWith('.md') + ); +} + +/** + * Resolve a facet name to its file path by scanning candidate directories. + * + * The caller builds the candidate list (e.g. project/.takt/{kind}, + * ~/.takt/{kind}, builtins/{lang}/{kind}) and passes it in. + * + * @returns Absolute file path if found, undefined otherwise. + */ +export function resolveFacetPath( + name: string, + candidateDirs: readonly string[], +): string | undefined { + for (const dir of candidateDirs) { + const filePath = join(dir, `${name}.md`); + if (existsSync(filePath)) { + return filePath; + } + } + return undefined; +} + +/** + * Resolve a facet name to its file content via candidate directories. + * + * @returns File content if found, undefined otherwise. + */ +export function resolveFacetByName( + name: string, + candidateDirs: readonly string[], +): string | undefined { + const filePath = resolveFacetPath(name, candidateDirs); + if (filePath) { + return readFileSync(filePath, 'utf-8'); + } + return undefined; +} + +/** Resolve a resource spec to an absolute file path. */ +export function resolveResourcePath(spec: string, pieceDir: string): string { + if (spec.startsWith('./')) return join(pieceDir, spec.slice(2)); + if (spec.startsWith('~')) return join(homedir(), spec.slice(1)); + if (spec.startsWith('/')) return spec; + return join(pieceDir, spec); +} + +/** + * Resolve a resource spec to its file content. + * If the spec ends with .md and the file exists, returns file content. + * Otherwise returns the spec as-is (treated as inline content). + */ +export function resolveResourceContent( + spec: string | undefined, + pieceDir: string, +): string | undefined { + if (spec == null) return undefined; + if (spec.endsWith('.md')) { + const resolved = resolveResourcePath(spec, pieceDir); + if (existsSync(resolved)) return readFileSync(resolved, 'utf-8'); + } + return spec; +} + +/** + * Resolve a section reference to content. + * Looks up ref in resolvedMap first, then falls back to path resolution. + * If candidateDirs are provided and ref is a name (not a path), + * falls back to facet resolution via candidate directories. + */ +export function resolveRefToContent( + ref: string, + resolvedMap: Record | undefined, + pieceDir: string, + candidateDirs?: readonly string[], +): string | undefined { + const mapped = resolvedMap?.[ref]; + if (mapped) return mapped; + + if (isResourcePath(ref)) { + return resolveResourceContent(ref, pieceDir); + } + + if (candidateDirs) { + const facetContent = resolveFacetByName(ref, candidateDirs); + if (facetContent !== undefined) return facetContent; + } + + return resolveResourceContent(ref, pieceDir); +} + +/** Resolve multiple references to content strings (for fields that accept string | string[]). */ +export function resolveRefList( + refs: string | string[] | undefined, + resolvedMap: Record | undefined, + pieceDir: string, + candidateDirs?: readonly string[], +): string[] | undefined { + if (refs == null) return undefined; + const list = Array.isArray(refs) ? refs : [refs]; + const contents: string[] = []; + for (const ref of list) { + const content = resolveRefToContent(ref, resolvedMap, pieceDir, candidateDirs); + if (content) contents.push(content); + } + return contents.length > 0 ? contents : undefined; +} + +/** Resolve a piece-level section map (each value resolved to file content or inline). */ +export function resolveSectionMap( + raw: Record | undefined, + pieceDir: string, +): Record | undefined { + if (!raw) return undefined; + const resolved: Record = {}; + for (const [name, value] of Object.entries(raw)) { + const content = resolveResourceContent(value, pieceDir); + if (content) resolved[name] = content; + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +/** Extract display name from persona path (e.g., "coder.md" -> "coder"). */ +export function extractPersonaDisplayName(personaPath: string): string { + return basename(personaPath, '.md'); +} + +/** + * Resolve persona from YAML field to spec + absolute path. + * + * Candidate directories for name-based lookup are provided by the caller. + */ +export function resolvePersona( + rawPersona: string | undefined, + sections: PieceSections, + pieceDir: string, + candidateDirs?: readonly string[], +): { personaSpec?: string; personaPath?: string } { + if (!rawPersona) return {}; + + // If section map has explicit mapping, use it (path-based) + const sectionMapping = sections.personas?.[rawPersona]; + if (sectionMapping) { + const resolved = resolveResourcePath(sectionMapping, pieceDir); + const personaPath = existsSync(resolved) ? resolved : undefined; + return { personaSpec: sectionMapping, personaPath }; + } + + // If rawPersona is a path, resolve it directly + if (isResourcePath(rawPersona)) { + const resolved = resolveResourcePath(rawPersona, pieceDir); + const personaPath = existsSync(resolved) ? resolved : undefined; + return { personaSpec: rawPersona, personaPath }; + } + + // Name-based: try candidate directories + if (candidateDirs) { + const filePath = resolveFacetPath(rawPersona, candidateDirs); + if (filePath) { + return { personaSpec: rawPersona, personaPath: filePath }; + } + } + + // Fallback: try as relative path from pieceDir + const resolved = resolveResourcePath(rawPersona, pieceDir); + const personaPath = existsSync(resolved) ? resolved : undefined; + return { personaSpec: rawPersona, personaPath }; +} diff --git a/src/faceted-prompting/template.ts b/src/faceted-prompting/template.ts new file mode 100644 index 0000000..78d21f5 --- /dev/null +++ b/src/faceted-prompting/template.ts @@ -0,0 +1,65 @@ +/** + * Minimal template engine for Markdown prompt templates. + * + * Supports: + * - {{#if variable}}...{{else}}...{{/if}} conditional blocks (no nesting) + * - {{variableName}} substitution + * + * This module has ZERO dependencies on TAKT internals. + */ + +/** + * Process {{#if variable}}...{{else}}...{{/if}} conditional blocks. + * + * A variable is truthy when it is a non-empty string or boolean true. + * Nesting is NOT supported. + */ +export function processConditionals( + template: string, + vars: Record, +): string { + return template.replace( + /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, + (_match, varName: string, body: string): string => { + const value = vars[varName]; + const isTruthy = value !== undefined && value !== false && value !== ''; + + const elseIndex = body.indexOf('{{else}}'); + if (isTruthy) { + return elseIndex >= 0 ? body.slice(0, elseIndex) : body; + } + return elseIndex >= 0 ? body.slice(elseIndex + '{{else}}'.length) : ''; + }, + ); +} + +/** + * Replace {{variableName}} placeholders with values from vars. + * Undefined or false variables are replaced with empty string. + * True is replaced with the string "true". + */ +export function substituteVariables( + template: string, + vars: Record, +): string { + return template.replace( + /\{\{(\w+)\}\}/g, + (_match, varName: string) => { + const value = vars[varName]; + if (value === undefined || value === false) return ''; + if (value === true) return 'true'; + return value; + }, + ); +} + +/** + * Render a template string by processing conditionals then substituting variables. + */ +export function renderTemplate( + template: string, + vars: Record, +): string { + const afterConditionals = processConditionals(template, vars); + return substituteVariables(afterConditionals, vars); +} diff --git a/src/faceted-prompting/truncation.ts b/src/faceted-prompting/truncation.ts new file mode 100644 index 0000000..ff2c655 --- /dev/null +++ b/src/faceted-prompting/truncation.ts @@ -0,0 +1,88 @@ +/** + * Context truncation for knowledge and policy facets. + * + * When facet content exceeds a character limit, it is trimmed and + * annotated with source-path metadata so the LLM can consult the + * original file. + * + * This module has ZERO dependencies on TAKT internals. + */ + +interface PreparedContextBlock { + readonly content: string; + readonly truncated: boolean; +} + +/** + * Trim content to a maximum character length, appending a + * "...TRUNCATED..." marker when truncation occurs. + */ +export function trimContextContent( + content: string, + maxChars: number, +): PreparedContextBlock { + if (content.length <= maxChars) { + return { content, truncated: false }; + } + return { + content: `${content.slice(0, maxChars)}\n...TRUNCATED...`, + truncated: true, + }; +} + +/** + * Standard notice appended to knowledge and policy blocks. + */ +export function renderConflictNotice(): string { + return 'If prompt content conflicts with source files, source files take precedence.'; +} + +/** + * Prepare a knowledge facet for inclusion in a prompt. + * + * Trims to maxChars, appends truncation notice and source path if available. + */ +export function prepareKnowledgeContent( + content: string, + maxChars: number, + sourcePath?: string, +): string { + const prepared = trimContextContent(content, maxChars); + const lines: string[] = [prepared.content]; + if (prepared.truncated && sourcePath) { + lines.push( + '', + `Knowledge is truncated. You MUST consult the source files before making decisions. Source: ${sourcePath}`, + ); + } + if (sourcePath) { + lines.push('', `Knowledge Source: ${sourcePath}`); + } + lines.push('', renderConflictNotice()); + return lines.join('\n'); +} + +/** + * Prepare a policy facet for inclusion in a prompt. + * + * Trims to maxChars, appends authoritative-source notice and source path if available. + */ +export function preparePolicyContent( + content: string, + maxChars: number, + sourcePath?: string, +): string { + const prepared = trimContextContent(content, maxChars); + const lines: string[] = [prepared.content]; + if (prepared.truncated && sourcePath) { + lines.push( + '', + `Policy is authoritative. If truncated, you MUST read the full policy file and follow it strictly. Source: ${sourcePath}`, + ); + } + if (sourcePath) { + lines.push('', `Policy Source: ${sourcePath}`); + } + lines.push('', renderConflictNotice()); + return lines.join('\n'); +} diff --git a/src/faceted-prompting/types.ts b/src/faceted-prompting/types.ts new file mode 100644 index 0000000..ff9cb17 --- /dev/null +++ b/src/faceted-prompting/types.ts @@ -0,0 +1,53 @@ +/** + * Core type definitions for Faceted Prompting. + * + * Defines the vocabulary of facets (persona, policy, knowledge, instruction, + * output-contract) and the structures used by compose() and DataEngine. + * + * This module has ZERO dependencies on TAKT internals. + */ + +/** Plural directory names used in facet resolution. */ +export type FacetKind = + | 'personas' + | 'policies' + | 'knowledge' + | 'instructions' + | 'output-contracts'; + +/** A single piece of facet content with optional metadata. */ +export interface FacetContent { + /** Raw text body of the facet. */ + readonly body: string; + /** Filesystem path the content was loaded from, if applicable. */ + readonly sourcePath?: string; +} + +/** + * A complete set of resolved facet contents to be composed. + * + * All fields are optional — a FacetSet may contain only a subset of facets. + */ +export interface FacetSet { + readonly persona?: FacetContent; + readonly policies?: readonly FacetContent[]; + readonly knowledge?: readonly FacetContent[]; + readonly instruction?: FacetContent; +} + +/** + * The output of compose(): facet content assigned to LLM message slots. + * + * persona → systemPrompt + * policy + knowledge + instruction → userMessage + */ +export interface ComposedPrompt { + readonly systemPrompt: string; + readonly userMessage: string; +} + +/** Options controlling compose() behaviour. */ +export interface ComposeOptions { + /** Maximum character length for knowledge/policy content before truncation. */ + readonly contextMaxChars: number; +} diff --git a/src/features/analytics/events.ts b/src/features/analytics/events.ts new file mode 100644 index 0000000..f829a46 --- /dev/null +++ b/src/features/analytics/events.ts @@ -0,0 +1,64 @@ +/** + * Analytics event type definitions for metrics collection. + * + * Three event types capture review findings, fix actions, and movement results + * for local-only analysis when analytics.enabled = true. + */ + +/** Status of a review finding across iterations */ +export type FindingStatus = 'new' | 'persists' | 'resolved'; + +/** Severity level of a review finding */ +export type FindingSeverity = 'error' | 'warning'; + +/** Decision taken on a finding */ +export type FindingDecision = 'reject' | 'approve'; + +/** Action taken to address a finding */ +export type FixActionType = 'fixed' | 'rebutted' | 'not_applicable'; + +/** Review finding event — emitted per finding during review movements */ +export interface ReviewFindingEvent { + type: 'review_finding'; + findingId: string; + status: FindingStatus; + ruleId: string; + severity: FindingSeverity; + decision: FindingDecision; + file: string; + line: number; + iteration: number; + runId: string; + timestamp: string; +} + +/** Fix action event — emitted per finding addressed during fix movements */ +export interface FixActionEvent { + type: 'fix_action'; + findingId: string; + action: FixActionType; + changedFiles?: string[]; + testCommand?: string; + testResult?: string; + iteration: number; + runId: string; + timestamp: string; +} + +/** Movement result event — emitted after each movement completes */ +export interface MovementResultEvent { + type: 'movement_result'; + movement: string; + provider: string; + model: string; + decisionTag: string; + iteration: number; + runId: string; + timestamp: string; +} + +/** Union of all analytics event types */ +export type AnalyticsEvent = + | ReviewFindingEvent + | FixActionEvent + | MovementResultEvent; diff --git a/src/features/analytics/index.ts b/src/features/analytics/index.ts new file mode 100644 index 0000000..7f3614d --- /dev/null +++ b/src/features/analytics/index.ts @@ -0,0 +1,33 @@ +/** + * Analytics module — event collection and metrics. + */ + +export type { + AnalyticsEvent, + ReviewFindingEvent, + FixActionEvent, + MovementResultEvent, +} from './events.js'; + +export { + initAnalyticsWriter, + isAnalyticsEnabled, + writeAnalyticsEvent, +} from './writer.js'; + +export { + parseFindingsFromReport, + extractDecisionFromReport, + inferSeverity, + emitFixActionEvents, + emitRebuttalEvents, +} from './report-parser.js'; + +export { + computeReviewMetrics, + formatReviewMetrics, + parseSinceDuration, + type ReviewMetrics, +} from './metrics.js'; + +export { purgeOldEvents } from './purge.js'; diff --git a/src/features/analytics/metrics.ts b/src/features/analytics/metrics.ts new file mode 100644 index 0000000..f7ce7bc --- /dev/null +++ b/src/features/analytics/metrics.ts @@ -0,0 +1,225 @@ +/** + * Analytics metrics computation from JSONL event files. + * + * Reads events from ~/.takt/analytics/events/*.jsonl and computes + * five key indicators for review quality assessment. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { AnalyticsEvent, ReviewFindingEvent, FixActionEvent } from './events.js'; + +/** Aggregated metrics output */ +export interface ReviewMetrics { + /** Re-report count per finding_id (same finding raised more than once) */ + reReportCounts: Map; + /** Ratio of findings that required 2+ round-trips before resolution */ + roundTripRatio: number; + /** Average number of iterations to resolve a finding */ + averageResolutionIterations: number; + /** Number of REJECT decisions per rule_id */ + rejectCountsByRule: Map; + /** Ratio of rebutted findings that were subsequently resolved */ + rebuttalResolvedRatio: number; +} + +/** + * Compute review metrics from events within a time window. + * + * @param eventsDir Absolute path to the analytics events directory + * @param sinceMs Epoch ms — only events after this time are included + */ +export function computeReviewMetrics(eventsDir: string, sinceMs: number): ReviewMetrics { + const events = loadEventsAfter(eventsDir, sinceMs); + const reviewFindings = events.filter( + (e): e is ReviewFindingEvent => e.type === 'review_finding', + ); + const fixActions = events.filter( + (e): e is FixActionEvent => e.type === 'fix_action', + ); + + return { + reReportCounts: computeReReportCounts(reviewFindings), + roundTripRatio: computeRoundTripRatio(reviewFindings), + averageResolutionIterations: computeAverageResolutionIterations(reviewFindings), + rejectCountsByRule: computeRejectCountsByRule(reviewFindings), + rebuttalResolvedRatio: computeRebuttalResolvedRatio(fixActions, reviewFindings), + }; +} + +/** + * Format review metrics for CLI display. + */ +export function formatReviewMetrics(metrics: ReviewMetrics): string { + const lines: string[] = []; + lines.push('=== Review Metrics ==='); + lines.push(''); + + lines.push('Re-report counts (finding_id → count):'); + if (metrics.reReportCounts.size === 0) { + lines.push(' (none)'); + } else { + for (const [findingId, count] of metrics.reReportCounts) { + lines.push(` ${findingId}: ${count}`); + } + } + lines.push(''); + + lines.push(`Round-trip ratio (2+ iterations): ${(metrics.roundTripRatio * 100).toFixed(1)}%`); + lines.push(`Average resolution iterations: ${metrics.averageResolutionIterations.toFixed(2)}`); + lines.push(''); + + lines.push('REJECT counts by rule:'); + if (metrics.rejectCountsByRule.size === 0) { + lines.push(' (none)'); + } else { + for (const [ruleId, count] of metrics.rejectCountsByRule) { + lines.push(` ${ruleId}: ${count}`); + } + } + lines.push(''); + + lines.push(`Rebuttal → resolved ratio: ${(metrics.rebuttalResolvedRatio * 100).toFixed(1)}%`); + + return lines.join('\n'); +} + +// ---- Internal helpers ---- + +/** Load all events from JSONL files whose date >= since */ +function loadEventsAfter(eventsDir: string, sinceMs: number): AnalyticsEvent[] { + const sinceDate = new Date(sinceMs).toISOString().slice(0, 10); + + let files: string[]; + try { + files = readdirSync(eventsDir).filter((f) => f.endsWith('.jsonl')); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw e; + } + + const relevantFiles = files.filter((f) => { + const dateStr = f.replace('.jsonl', ''); + return dateStr >= sinceDate; + }); + + const events: AnalyticsEvent[] = []; + for (const file of relevantFiles) { + const content = readFileSync(join(eventsDir, file), 'utf-8'); + for (const line of content.split('\n')) { + if (!line.trim()) continue; + const event = JSON.parse(line) as AnalyticsEvent; + if (new Date(event.timestamp).getTime() >= sinceMs) { + events.push(event); + } + } + } + + return events; +} + +/** Count how many times each finding_id appears (only those appearing 2+) */ +function computeReReportCounts(findings: ReviewFindingEvent[]): Map { + const counts = new Map(); + for (const f of findings) { + counts.set(f.findingId, (counts.get(f.findingId) ?? 0) + 1); + } + + const result = new Map(); + for (const [id, count] of counts) { + if (count >= 2) { + result.set(id, count); + } + } + return result; +} + +/** Ratio of findings that appear in 2+ iterations before resolution */ +function computeRoundTripRatio(findings: ReviewFindingEvent[]): number { + const findingIds = new Set(findings.map((f) => f.findingId)); + if (findingIds.size === 0) return 0; + + let multiIterationCount = 0; + for (const id of findingIds) { + const iterations = new Set( + findings.filter((f) => f.findingId === id).map((f) => f.iteration), + ); + if (iterations.size >= 2) { + multiIterationCount++; + } + } + + return multiIterationCount / findingIds.size; +} + +/** Average number of iterations from first appearance to resolution */ +function computeAverageResolutionIterations(findings: ReviewFindingEvent[]): number { + const findingIds = new Set(findings.map((f) => f.findingId)); + if (findingIds.size === 0) return 0; + + let totalIterations = 0; + let resolvedCount = 0; + + for (const id of findingIds) { + const related = findings.filter((f) => f.findingId === id); + const minIteration = Math.min(...related.map((f) => f.iteration)); + const resolved = related.find((f) => f.status === 'resolved'); + if (resolved) { + totalIterations += resolved.iteration - minIteration + 1; + resolvedCount++; + } + } + + if (resolvedCount === 0) return 0; + return totalIterations / resolvedCount; +} + +/** Ratio of rebutted findings that were subsequently resolved in a review */ +function computeRebuttalResolvedRatio( + fixActions: FixActionEvent[], + findings: ReviewFindingEvent[], +): number { + const rebuttedIds = new Set( + fixActions.filter((a) => a.action === 'rebutted').map((a) => a.findingId), + ); + if (rebuttedIds.size === 0) return 0; + + let resolvedCount = 0; + for (const id of rebuttedIds) { + const wasResolved = findings.some( + (f) => f.findingId === id && f.status === 'resolved', + ); + if (wasResolved) { + resolvedCount++; + } + } + + return resolvedCount / rebuttedIds.size; +} + +/** Count of REJECT decisions per rule_id */ +function computeRejectCountsByRule(findings: ReviewFindingEvent[]): Map { + const counts = new Map(); + for (const f of findings) { + if (f.decision === 'reject') { + counts.set(f.ruleId, (counts.get(f.ruleId) ?? 0) + 1); + } + } + return counts; +} + +/** + * Parse a duration string like "7d", "30d", "14d" into milliseconds. + */ +export function parseSinceDuration(since: string): number { + const match = since.match(/^(\d+)d$/); + if (!match) { + throw new Error(`Invalid duration format: "${since}". Use format like "7d", "30d".`); + } + const daysStr = match[1]; + if (!daysStr) { + throw new Error(`Invalid duration format: "${since}". Use format like "7d", "30d".`); + } + const days = parseInt(daysStr, 10); + return days * 24 * 60 * 60 * 1000; +} diff --git a/src/features/analytics/purge.ts b/src/features/analytics/purge.ts new file mode 100644 index 0000000..c1c59b7 --- /dev/null +++ b/src/features/analytics/purge.ts @@ -0,0 +1,40 @@ +/** + * Retention-based purge for analytics event files. + * + * Deletes JSONL files older than the configured retention period. + */ + +import { readdirSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Purge JSONL event files older than the retention period. + * + * @param eventsDir Absolute path to the analytics events directory + * @param retentionDays Number of days to retain (files older than this are deleted) + * @param now Reference time for age calculation + * @returns List of deleted file names + */ +export function purgeOldEvents(eventsDir: string, retentionDays: number, now: Date): string[] { + const cutoffDate = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000); + const cutoffStr = cutoffDate.toISOString().slice(0, 10); + + let files: string[]; + try { + files = readdirSync(eventsDir).filter((f) => f.endsWith('.jsonl')); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw e; + } + + const deleted: string[] = []; + for (const file of files) { + const dateStr = file.replace('.jsonl', ''); + if (dateStr < cutoffStr) { + unlinkSync(join(eventsDir, file)); + deleted.push(file); + } + } + + return deleted; +} diff --git a/src/features/analytics/report-parser.ts b/src/features/analytics/report-parser.ts new file mode 100644 index 0000000..12192a5 --- /dev/null +++ b/src/features/analytics/report-parser.ts @@ -0,0 +1,191 @@ +/** + * Extracts analytics event data from review report markdown. + * + * Review reports follow a consistent structure with finding tables + * under "new", "persists", and "resolved" sections. Each table row + * contains a finding_id column. + */ + +import type { FindingStatus, FindingSeverity, FindingDecision, FixActionEvent, FixActionType } from './events.js'; +import { writeAnalyticsEvent } from './writer.js'; + +export interface ParsedFinding { + findingId: string; + status: FindingStatus; + ruleId: string; + file: string; + line: number; +} + +const SECTION_PATTERNS: Array<{ pattern: RegExp; status: FindingStatus }> = [ + { pattern: /^##\s+.*\bnew\b/i, status: 'new' }, + { pattern: /^##\s+.*\bpersists\b/i, status: 'persists' }, + { pattern: /^##\s+.*\bresolved\b/i, status: 'resolved' }, +]; + +export function parseFindingsFromReport(reportContent: string): ParsedFinding[] { + const lines = reportContent.split('\n'); + const findings: ParsedFinding[] = []; + let currentStatus: FindingStatus | null = null; + let columnIndices: TableColumnIndices | null = null; + let headerParsed = false; + + for (const line of lines) { + const sectionMatch = matchSection(line); + if (sectionMatch) { + currentStatus = sectionMatch; + columnIndices = null; + headerParsed = false; + continue; + } + + if (line.startsWith('## ')) { + currentStatus = null; + columnIndices = null; + headerParsed = false; + continue; + } + + if (!currentStatus) continue; + + const trimmed = line.trim(); + if (!trimmed.startsWith('|')) continue; + if (isSeparatorRow(trimmed)) continue; + + if (!headerParsed) { + columnIndices = detectColumnIndices(trimmed); + headerParsed = true; + continue; + } + + if (!columnIndices || columnIndices.findingId < 0) continue; + + const finding = parseTableRow(line, currentStatus, columnIndices); + if (finding) { + findings.push(finding); + } + } + + return findings; +} + +export function extractDecisionFromReport(reportContent: string): FindingDecision | null { + const resultMatch = reportContent.match(/^##\s+(?:結果|Result)\s*:\s*(\w+)/m); + const decision = resultMatch?.[1]; + if (!decision) return null; + return decision.toUpperCase() === 'REJECT' ? 'reject' : 'approve'; +} + +function matchSection(line: string): FindingStatus | null { + for (const { pattern, status } of SECTION_PATTERNS) { + if (pattern.test(line)) return status; + } + return null; +} + +function isSeparatorRow(trimmed: string): boolean { + return /^\|[\s-]+\|/.test(trimmed); +} + +interface TableColumnIndices { + findingId: number; + category: number; +} + +function detectColumnIndices(headerRow: string): TableColumnIndices { + const cells = headerRow.split('|').map((c) => c.trim()).filter(Boolean); + const findingId = cells.findIndex((c) => c.toLowerCase() === 'finding_id'); + const category = cells.findIndex((c) => { + const lower = c.toLowerCase(); + return lower === 'category' || lower === 'カテゴリ'; + }); + return { findingId, category }; +} + +function parseTableRow( + line: string, + status: FindingStatus, + indices: TableColumnIndices, +): ParsedFinding | null { + const cells = line.split('|').map((c) => c.trim()).filter(Boolean); + if (cells.length <= indices.findingId) return null; + + const findingId = cells[indices.findingId]; + if (!findingId) return null; + + const categoryValue = indices.category >= 0 ? cells[indices.category] : undefined; + const ruleId = categoryValue ?? findingId; + + const locationCell = findLocation(cells); + const { file, line: lineNum } = parseLocation(locationCell); + + return { findingId, status, ruleId, file, line: lineNum }; +} + +function findLocation(cells: string[]): string { + for (const cell of cells) { + if (cell.includes('/') || cell.includes('.ts') || cell.includes('.js') || cell.includes('.py')) { + return cell; + } + } + return ''; +} + +function parseLocation(location: string): { file: string; line: number } { + const cleaned = location.replace(/`/g, ''); + const lineMatch = cleaned.match(/:(\d+)/); + const lineStr = lineMatch?.[1]; + const lineNum = lineStr ? parseInt(lineStr, 10) : 0; + const file = cleaned.replace(/:\d+.*$/, '').trim(); + return { file, line: lineNum }; +} + +export function inferSeverity(findingId: string): FindingSeverity { + const id = findingId.toUpperCase(); + if (id.includes('SEC')) return 'error'; + return 'warning'; +} + +const FINDING_ID_PATTERN = /\b[A-Z]{2,}-(?:NEW-)?[\w-]+\b/g; + +export function emitFixActionEvents( + responseContent: string, + iteration: number, + runId: string, + timestamp: Date, +): void { + emitActionEvents(responseContent, 'fixed', iteration, runId, timestamp); +} + +export function emitRebuttalEvents( + responseContent: string, + iteration: number, + runId: string, + timestamp: Date, +): void { + emitActionEvents(responseContent, 'rebutted', iteration, runId, timestamp); +} + +function emitActionEvents( + responseContent: string, + action: FixActionType, + iteration: number, + runId: string, + timestamp: Date, +): void { + const matches = responseContent.match(FINDING_ID_PATTERN); + if (!matches) return; + + const uniqueIds = [...new Set(matches)]; + for (const findingId of uniqueIds) { + const event: FixActionEvent = { + type: 'fix_action', + findingId, + action, + iteration, + runId, + timestamp: timestamp.toISOString(), + }; + writeAnalyticsEvent(event); + } +} diff --git a/src/features/analytics/writer.ts b/src/features/analytics/writer.ts new file mode 100644 index 0000000..0234bc5 --- /dev/null +++ b/src/features/analytics/writer.ts @@ -0,0 +1,82 @@ +/** + * Analytics event writer — JSONL append-only with date-based rotation. + * + * Writes to ~/.takt/analytics/events/YYYY-MM-DD.jsonl when analytics.enabled = true. + * Does nothing when disabled. + */ + +import { appendFileSync, mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { AnalyticsEvent } from './events.js'; + +export class AnalyticsWriter { + private static instance: AnalyticsWriter | null = null; + + private enabled = false; + private eventsDir: string | null = null; + + private constructor() {} + + static getInstance(): AnalyticsWriter { + if (!AnalyticsWriter.instance) { + AnalyticsWriter.instance = new AnalyticsWriter(); + } + return AnalyticsWriter.instance; + } + + static resetInstance(): void { + AnalyticsWriter.instance = null; + } + + /** + * Initialize writer. + * @param enabled Whether analytics collection is active + * @param eventsDir Absolute path to the events directory (e.g. ~/.takt/analytics/events) + */ + init(enabled: boolean, eventsDir: string): void { + this.enabled = enabled; + this.eventsDir = eventsDir; + + if (this.enabled) { + if (!existsSync(this.eventsDir)) { + mkdirSync(this.eventsDir, { recursive: true }); + } + } + } + + isEnabled(): boolean { + return this.enabled; + } + + /** Append an analytics event to the current day's JSONL file */ + write(event: AnalyticsEvent): void { + if (!this.enabled || !this.eventsDir) { + return; + } + + const filePath = join(this.eventsDir, `${formatDate(event.timestamp)}.jsonl`); + appendFileSync(filePath, JSON.stringify(event) + '\n', 'utf-8'); + } +} + +function formatDate(isoTimestamp: string): string { + return isoTimestamp.slice(0, 10); +} + +// ---- Module-level convenience functions ---- + +export function initAnalyticsWriter(enabled: boolean, eventsDir: string): void { + AnalyticsWriter.getInstance().init(enabled, eventsDir); +} + +export function resetAnalyticsWriter(): void { + AnalyticsWriter.resetInstance(); +} + +export function isAnalyticsEnabled(): boolean { + return AnalyticsWriter.getInstance().isEnabled(); +} + +export function writeAnalyticsEvent(event: AnalyticsEvent): void { + AnalyticsWriter.getInstance().write(event); +} diff --git a/src/features/catalog/catalogFacets.ts b/src/features/catalog/catalogFacets.ts index 5a37a43..88160c3 100644 --- a/src/features/catalog/catalogFacets.ts +++ b/src/features/catalog/catalogFacets.ts @@ -11,7 +11,7 @@ import chalk from 'chalk'; import type { PieceSource } from '../../infra/config/loaders/pieceResolver.js'; import { getLanguageResourcesDir } from '../../infra/resources/index.js'; import { getGlobalConfigDir, getProjectConfigDir } from '../../infra/config/paths.js'; -import { getLanguage, getBuiltinPiecesEnabled } from '../../infra/config/global/globalConfig.js'; +import { resolvePieceConfigValues } from '../../infra/config/index.js'; import { section, error as logError, info } from '../../shared/ui/index.js'; const FACET_TYPES = [ @@ -62,10 +62,11 @@ function getFacetDirs( facetType: FacetType, cwd: string, ): { dir: string; source: PieceSource }[] { + const config = resolvePieceConfigValues(cwd, ['enableBuiltinPieces', 'language']); const dirs: { dir: string; source: PieceSource }[] = []; - if (getBuiltinPiecesEnabled()) { - const lang = getLanguage(); + if (config.enableBuiltinPieces !== false) { + const lang = config.language; dirs.push({ dir: join(getLanguageResourcesDir(lang), facetType), source: 'builtin' }); } diff --git a/src/features/config/index.ts b/src/features/config/index.ts index 2847c03..db73c75 100644 --- a/src/features/config/index.ts +++ b/src/features/config/index.ts @@ -3,7 +3,7 @@ */ export { switchPiece } from './switchPiece.js'; -export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './switchConfig.js'; export { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES } from './ejectBuiltin.js'; export { resetCategoriesToDefault } from './resetCategories.js'; +export { resetConfigToDefault } from './resetConfig.js'; export { deploySkill } from './deploySkill.js'; diff --git a/src/features/config/resetCategories.ts b/src/features/config/resetCategories.ts index 369d9cf..ff3d494 100644 --- a/src/features/config/resetCategories.ts +++ b/src/features/config/resetCategories.ts @@ -5,12 +5,12 @@ import { resetPieceCategories, getPieceCategoriesPath } from '../../infra/config/global/pieceCategories.js'; import { header, success, info } from '../../shared/ui/index.js'; -export async function resetCategoriesToDefault(): Promise { +export async function resetCategoriesToDefault(cwd: string): Promise { header('Reset Categories'); - resetPieceCategories(); + resetPieceCategories(cwd); - const userPath = getPieceCategoriesPath(); + const userPath = getPieceCategoriesPath(cwd); success('User category overlay reset.'); info(` ${userPath}`); } diff --git a/src/features/config/resetConfig.ts b/src/features/config/resetConfig.ts new file mode 100644 index 0000000..e63b93c --- /dev/null +++ b/src/features/config/resetConfig.ts @@ -0,0 +1,13 @@ +import { resetGlobalConfigToTemplate } from '../../infra/config/global/index.js'; +import { header, info, success } from '../../shared/ui/index.js'; + +export async function resetConfigToDefault(): Promise { + header('Reset Config'); + + const result = resetGlobalConfigToTemplate(); + success('Global config reset from builtin template.'); + info(` config: ${result.configPath}`); + if (result.backupPath) { + info(` backup: ${result.backupPath}`); + } +} diff --git a/src/features/config/switchConfig.ts b/src/features/config/switchConfig.ts deleted file mode 100644 index 048e8a1..0000000 --- a/src/features/config/switchConfig.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Config switching command (like piece switching) - * - * Permission mode selection that works from CLI. - * Uses selectOption for prompt selection, same pattern as switchPiece. - */ - -import chalk from 'chalk'; -import { info, success } from '../../shared/ui/index.js'; -import { selectOption } from '../../shared/prompt/index.js'; -import { - loadProjectConfig, - updateProjectConfig, -} from '../../infra/config/index.js'; -import type { PermissionMode } from '../../infra/config/index.js'; - -// Re-export for convenience -export type { PermissionMode } from '../../infra/config/index.js'; - -/** - * Get permission mode options for selection - */ -/** Common permission mode option definitions */ -export const PERMISSION_MODE_OPTIONS: { - key: PermissionMode; - label: string; - description: string; - details: string[]; - icon: string; -}[] = [ - { - key: 'default', - label: 'デフォルト (default)', - description: 'Agent SDK標準モード(ファイル編集自動承認、最小限の確認)', - details: [ - 'Claude Agent SDKの標準設定(acceptEdits)を使用', - 'ファイル編集は自動承認され、確認プロンプトなしで実行', - 'Bash等の危険な操作は権限確認が表示される', - '通常の開発作業に推奨', - ], - icon: '📋', - }, - { - key: 'sacrifice-my-pc', - label: 'SACRIFICE-MY-PC', - description: '全ての権限リクエストが自動承認されます', - details: [ - '⚠️ 警告: 全ての操作が確認なしで実行されます', - 'Bash, ファイル削除, システム操作も自動承認', - 'ブロック状態(判断待ち)も自動スキップ', - '完全自動化が必要な場合のみ使用してください', - ], - icon: '💀', - }, -]; - -function getPermissionModeOptions(currentMode: PermissionMode): { - label: string; - value: PermissionMode; - description: string; - details: string[]; -}[] { - return PERMISSION_MODE_OPTIONS.map((opt) => ({ - label: currentMode === opt.key - ? (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`) + ' (current)' - : (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`), - value: opt.key, - description: opt.description, - details: opt.details, - })); -} - -/** - * Get current permission mode from project config - */ -export function getCurrentPermissionMode(cwd: string): PermissionMode { - const config = loadProjectConfig(cwd); - if (config.permissionMode) { - return config.permissionMode as PermissionMode; - } - return 'default'; -} - -/** - * Set permission mode in project config - */ -export function setPermissionMode(cwd: string, mode: PermissionMode): void { - updateProjectConfig(cwd, 'permissionMode', mode); -} - -/** - * Switch permission mode (like switchPiece) - * @returns true if switch was successful - */ -export async function switchConfig(cwd: string, modeName?: string): Promise { - const currentMode = getCurrentPermissionMode(cwd); - - // No mode specified - show selection prompt - if (!modeName) { - info(`Current mode: ${currentMode}`); - - const options = getPermissionModeOptions(currentMode); - const selected = await selectOption('Select permission mode:', options); - - if (!selected) { - info('Cancelled'); - return false; - } - - modeName = selected; - } - - // Validate mode name - if (modeName !== 'default' && modeName !== 'sacrifice-my-pc') { - info(`Invalid mode: ${modeName}`); - info('Available modes: default, sacrifice-my-pc'); - return false; - } - - const finalMode: PermissionMode = modeName as PermissionMode; - - // Save to project config - setPermissionMode(cwd, finalMode); - - if (finalMode === 'sacrifice-my-pc') { - success('Switched to: sacrifice-my-pc 💀'); - info('All permission requests will be auto-approved.'); - } else { - success('Switched to: default 📋'); - info('Using Agent SDK default mode (acceptEdits - minimal permission prompts).'); - } - - return true; -} diff --git a/src/features/config/switchPiece.ts b/src/features/config/switchPiece.ts index d2b26c1..59d0fe5 100644 --- a/src/features/config/switchPiece.ts +++ b/src/features/config/switchPiece.ts @@ -4,7 +4,7 @@ import { loadPiece, - getCurrentPiece, + resolveConfigValue, setCurrentPiece, } from '../../infra/config/index.js'; import { info, success, error } from '../../shared/ui/index.js'; @@ -16,7 +16,7 @@ import { selectPiece } from '../pieceSelection/index.js'; */ export async function switchPiece(cwd: string, pieceName?: string): Promise { if (!pieceName) { - const current = getCurrentPiece(cwd); + const current = resolveConfigValue(cwd, 'piece'); info(`Current piece: ${current}`); const selected = await selectPiece(cwd, { fallbackToDefault: false }); diff --git a/src/features/interactive/aiCaller.ts b/src/features/interactive/aiCaller.ts new file mode 100644 index 0000000..a2efebd --- /dev/null +++ b/src/features/interactive/aiCaller.ts @@ -0,0 +1,123 @@ +/** + * AI call with automatic retry on stale/invalid session. + * + * Extracted from conversationLoop.ts for single-responsibility: + * this module handles only the AI call + retry logic. + */ + +import { + updatePersonaSession, +} from '../../infra/config/index.js'; +import { isQuietMode } from '../../shared/context.js'; +import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; +import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; +import { getLabel } from '../../shared/i18n/index.js'; +import { EXIT_SIGINT } from '../../shared/exitCodes.js'; +import type { ProviderType } from '../../infra/providers/index.js'; +import { getProvider } from '../../infra/providers/index.js'; + +const log = createLogger('ai-caller'); + +/** 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; +} + +/** + * 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()); + const abortController = new AbortController(); + let sigintCount = 0; + const onSigInt = (): void => { + sigintCount += 1; + if (sigintCount === 1) { + blankLine(); + info(getLabel('piece.sigintGraceful', ctx.lang)); + abortController.abort(); + return; + } + blankLine(); + error(getLabel('piece.sigintForce', ctx.lang)); + process.exit(EXIT_SIGINT); + }; + process.on('SIGINT', onSigInt); + 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, + abortSignal: abortController.signal, + onStream: display.createHandler(), + }); + display.flush(); + const success = response.status !== 'blocked' && response.status !== 'error'; + + 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, + abortSignal: abortController.signal, + 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' && retry.status !== 'error' }, + 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 }; + } finally { + process.removeListener('SIGINT', onSigInt); + } +} diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index fb988ef..b3adbd2 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -3,26 +3,22 @@ * * 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, + resolveConfigValues, 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 { 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 { EXIT_SIGINT } from '../../shared/exitCodes.js'; +import { selectRecentSession } from './sessionSelector.js'; import { type PieceContext, type InteractiveModeResult, @@ -34,31 +30,21 @@ import { selectPostSummaryAction, formatSessionStatus, } from './interactive.js'; +import { callAIWithRetry, type CallAIResult, type SessionContext } from './aiCaller.js'; + +export { type CallAIResult, type SessionContext, callAIWithRetry } from './aiCaller.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. + * Initialize provider and language for interactive conversation. + * + * Session ID is always undefined (new session). + * Callers that need session continuity pass sessionId explicitly + * (e.g., --continue flag or /resume command). */ export function initializeSession(cwd: string, personaName: string): SessionContext { - const globalConfig = loadGlobalConfig(); + const globalConfig = resolveConfigValues(cwd, ['language', 'provider', 'model']); const lang = resolveLanguage(globalConfig.language); if (!globalConfig.provider) { throw new Error('Provider is not configured.'); @@ -66,10 +52,8 @@ export function initializeSession(cwd: string, personaName: string): SessionCont 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 }; + return { provider, providerType, model, lang, personaName, sessionId: undefined }; } /** @@ -85,93 +69,6 @@ export function displayAndClearSessionState(cwd: string, lang: 'en' | 'ja'): voi } } -/** - * 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()); - const abortController = new AbortController(); - let sigintCount = 0; - const onSigInt = (): void => { - sigintCount += 1; - if (sigintCount === 1) { - blankLine(); - info(getLabel('piece.sigintGraceful', ctx.lang)); - abortController.abort(); - return; - } - blankLine(); - error(getLabel('piece.sigintForce', ctx.lang)); - process.exit(EXIT_SIGINT); - }; - process.on('SIGINT', onSigInt); - 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, - abortSignal: abortController.signal, - onStream: display.createHandler(), - }); - display.flush(); - const success = response.status !== 'blocked' && response.status !== 'error'; - - 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, - abortSignal: abortController.signal, - 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' && retry.status !== 'error' }, - 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 }; - } finally { - process.removeListener('SIGINT', onSigInt); - } -} - export type { PostSummaryAction } from './interactive.js'; /** Strategy for customizing conversation loop behavior */ @@ -186,12 +83,14 @@ export interface ConversationStrategy { introMessage: string; /** Custom action selector (optional). If not provided, uses default selectPostSummaryAction. */ selectAction?: (task: string, lang: 'en' | 'ja') => Promise; + /** Previous order.md content for /replay command (retry/instruct only) */ + previousOrderContent?: string; } /** * Run the shared conversation loop. * - * Handles: EOF, /play, /go (summary), /cancel, regular AI messaging. + * Handles: EOF, /play, /retry, /go (summary), /cancel, regular AI messaging. * The Strategy object controls system prompt, tool access, and prompt transformation. */ export async function runConversationLoop( @@ -266,6 +165,15 @@ export async function runConversationLoop( return { action: 'execute', task }; } + if (trimmed === '/retry') { + if (!strategy.previousOrderContent) { + info(ui.retryNoOrder); + continue; + } + log.info('Retry command — resubmitting previous order.md'); + return { action: 'execute', task: strategy.previousOrderContent }; + } + if (trimmed.startsWith('/go')) { const userNote = trimmed.slice(3).trim(); let summaryPrompt = buildSummaryPrompt( @@ -300,11 +208,30 @@ export async function runConversationLoop( return { action: selectedAction, task }; } + if (trimmed === '/replay') { + if (!strategy.previousOrderContent) { + const replayNoOrder = getLabel('instruct.ui.replayNoOrder', ctx.lang); + info(replayNoOrder); + continue; + } + log.info('Replay command'); + return { action: 'execute', task: strategy.previousOrderContent }; + } + if (trimmed === '/cancel') { info(ui.cancelled); return { action: 'cancel', task: '' }; } + if (trimmed === '/resume') { + const selectedId = await selectRecentSession(cwd, ctx.lang); + if (selectedId) { + sessionId = selectedId; + info(getLabel('interactive.resumeSessionLoaded', ctx.lang)); + } + continue; + } + history.push({ role: 'user', content: trimmed }); log.debug('Sending to AI', { messageCount: history.length, sessionId }); process.stdin.pause(); diff --git a/src/features/interactive/index.ts b/src/features/interactive/index.ts index 36fb96b..bceb902 100644 --- a/src/features/interactive/index.ts +++ b/src/features/interactive/index.ts @@ -22,6 +22,7 @@ export { passthroughMode } from './passthroughMode.js'; export { quietMode } from './quietMode.js'; export { personaMode } from './personaMode.js'; export { selectRun } from './runSelector.js'; -export { listRecentRuns, findRunForTask, loadRunSessionContext, formatRunSessionForPrompt, getRunPaths, type RunSessionContext, type RunPaths } from './runSessionReader.js'; +export { listRecentRuns, findRunForTask, loadRunSessionContext, formatRunSessionForPrompt, getRunPaths, loadPreviousOrderContent, type RunSessionContext, type RunPaths } from './runSessionReader.js'; export { runRetryMode, buildRetryTemplateVars, type RetryContext, type RetryFailureInfo, type RetryRunInfo } from './retryMode.js'; export { dispatchConversationAction, type ConversationActionResult } from './actionDispatcher.js'; +export { findPreviousOrderContent } from './orderReader.js'; diff --git a/src/features/interactive/interactive-summary.ts b/src/features/interactive/interactive-summary.ts index 13c64ae..68bf5e1 100644 --- a/src/features/interactive/interactive-summary.ts +++ b/src/features/interactive/interactive-summary.ts @@ -197,13 +197,15 @@ export interface InteractiveSummaryUIText { export function buildSummaryActionOptions( labels: SummaryActionLabels, append: readonly SummaryActionValue[] = [], + exclude: readonly SummaryActionValue[] = [], ): SummaryActionOption[] { const order = [...BASE_SUMMARY_ACTIONS, ...append]; + const excluded = new Set(exclude); const seen = new Set(); const options: SummaryActionOption[] = []; for (const action of order) { - if (seen.has(action)) { + if (seen.has(action) || excluded.has(action)) { continue; } seen.add(action); @@ -261,3 +263,52 @@ export function selectPostSummaryAction( ), ); } + +/** + * Build the /replay command hint for intro messages. + * + * Returns a hint string when previous order content is available, empty string otherwise. + */ +export function buildReplayHint(lang: 'en' | 'ja', hasPreviousOrder: boolean): string { + if (!hasPreviousOrder) return ''; + return lang === 'ja' + ? ', /replay(前回の指示書を再投入)' + : ', /replay (resubmit previous order)'; +} + +/** UI labels required by createSelectActionWithoutExecute */ +export interface ActionWithoutExecuteUIText { + proposed: string; + actionPrompt: string; + actions: { + execute: string; + saveTask: string; + continue: string; + }; +} + +/** + * Create an action selector that excludes the 'execute' option. + * + * Used by retry and instruct modes where worktree execution is assumed. + */ +export function createSelectActionWithoutExecute( + ui: ActionWithoutExecuteUIText, +): (task: string, lang: 'en' | 'ja') => Promise { + return async (task: string, _lang: 'en' | 'ja'): Promise => { + return selectSummaryAction( + task, + ui.proposed, + ui.actionPrompt, + buildSummaryActionOptions( + { + execute: ui.actions.execute, + saveTask: ui.actions.saveTask, + continue: ui.actions.continue, + }, + [], + ['execute'], + ), + ); + }; +} diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 63ef1b1..398ead3 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -45,6 +45,7 @@ export interface InteractiveUIText { }; cancelled: string; playNoTask: string; + retryNoOrder: string; } /** diff --git a/src/features/interactive/orderReader.ts b/src/features/interactive/orderReader.ts new file mode 100644 index 0000000..784389c --- /dev/null +++ b/src/features/interactive/orderReader.ts @@ -0,0 +1,57 @@ +/** + * Order reader for retry/instruct modes. + * + * Reads the previous order.md from a run's context directory + * to inject into conversation system prompts. + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Find and read the previous order.md content from a run directory. + * + * When runSlug is provided, reads directly from that run's context. + * When runSlug is null, scans .takt/runs/ directories in reverse order + * and returns the first order.md found. + * + * @returns The order.md content, or null if not found. + */ +export function findPreviousOrderContent(worktreeCwd: string, runSlug: string | null): string | null { + if (runSlug) { + return readOrderFromRun(worktreeCwd, runSlug); + } + + return findOrderFromLatestRun(worktreeCwd); +} + +function readOrderFromRun(worktreeCwd: string, slug: string): string | null { + const orderPath = join(worktreeCwd, '.takt', 'runs', slug, 'context', 'task', 'order.md'); + if (!existsSync(orderPath)) { + return null; + } + const content = readFileSync(orderPath, 'utf-8').trim(); + return content || null; +} + +function findOrderFromLatestRun(worktreeCwd: string): string | null { + const runsDir = join(worktreeCwd, '.takt', 'runs'); + if (!existsSync(runsDir)) { + return null; + } + + const entries = readdirSync(runsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort() + .reverse(); + + for (const slug of entries) { + const content = readOrderFromRun(worktreeCwd, slug); + if (content) { + return content; + } + } + + return null; +} diff --git a/src/features/interactive/retryMode.ts b/src/features/interactive/retryMode.ts index 4b2bbbe..317cc85 100644 --- a/src/features/interactive/retryMode.ts +++ b/src/features/interactive/retryMode.ts @@ -11,18 +11,17 @@ import { runConversationLoop, type SessionContext, type ConversationStrategy, - type PostSummaryAction, } from './conversationLoop.js'; import { - buildSummaryActionOptions, - selectSummaryAction, + createSelectActionWithoutExecute, + buildReplayHint, formatMovementPreviews, type PieceContext, } from './interactive-summary.js'; import { resolveLanguage } from './interactive.js'; import { loadTemplate } from '../../shared/prompts/index.js'; import { getLabelObject } from '../../shared/i18n/index.js'; -import { loadGlobalConfig } from '../../infra/config/index.js'; +import { resolveConfigValues } from '../../infra/config/index.js'; import type { InstructModeResult, InstructUIText } from '../tasks/list/instructMode.js'; /** Failure information for a retry task */ @@ -53,6 +52,7 @@ export interface RetryContext { readonly branchName: string; readonly pieceContext: PieceContext; readonly run: RetryRunInfo | null; + readonly previousOrderContent: string | null; } const RETRY_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; @@ -60,14 +60,13 @@ const RETRY_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; /** * Convert RetryContext into template variable map. */ -export function buildRetryTemplateVars(ctx: RetryContext, lang: 'en' | 'ja'): Record { +export function buildRetryTemplateVars(ctx: RetryContext, lang: 'en' | 'ja', previousOrderContent: string | null = null): Record { const hasPiecePreview = !!ctx.pieceContext.movementPreviews?.length; const movementDetails = hasPiecePreview ? formatMovementPreviews(ctx.pieceContext.movementPreviews!, lang) : ''; const hasRun = ctx.run !== null; - return { taskName: ctx.failure.taskName, taskContent: ctx.failure.taskContent, @@ -88,21 +87,8 @@ export function buildRetryTemplateVars(ctx: RetryContext, lang: 'en' | 'ja'): Re runStatus: hasRun ? ctx.run!.status : '', runMovementLogs: hasRun ? ctx.run!.movementLogs : '', runReports: hasRun ? ctx.run!.reports : '', - }; -} - -function createSelectRetryAction(ui: InstructUIText): (task: string, lang: 'en' | 'ja') => Promise { - return async (task: string, _lang: 'en' | 'ja'): Promise => { - return selectSummaryAction( - task, - ui.proposed, - ui.actionPrompt, - buildSummaryActionOptions({ - execute: ui.actions.execute, - saveTask: ui.actions.saveTask, - continue: ui.actions.continue, - }), - ); + hasOrderContent: previousOrderContent !== null, + orderContent: previousOrderContent ?? '', }; } @@ -115,8 +101,9 @@ function createSelectRetryAction(ui: InstructUIText): (task: string, lang: 'en' export async function runRetryMode( cwd: string, retryContext: RetryContext, + previousOrderContent: string | null, ): Promise { - const globalConfig = loadGlobalConfig(); + const globalConfig = resolveConfigValues(cwd, ['language', 'provider']); const lang = resolveLanguage(globalConfig.language); if (!globalConfig.provider) { @@ -130,12 +117,13 @@ export async function runRetryMode( const ui = getLabelObject('instruct.ui', ctx.lang); - const templateVars = buildRetryTemplateVars(retryContext, lang); + const templateVars = buildRetryTemplateVars(retryContext, lang, previousOrderContent); const systemPrompt = loadTemplate('score_retry_system_prompt', ctx.lang, templateVars); + const replayHint = buildReplayHint(ctx.lang, previousOrderContent !== null); const introLabel = ctx.lang === 'ja' - ? `## リトライ: ${retryContext.failure.taskName}\n\nブランチ: ${retryContext.branchName}\n\n${ui.intro}` - : `## Retry: ${retryContext.failure.taskName}\n\nBranch: ${retryContext.branchName}\n\n${ui.intro}`; + ? `## リトライ: ${retryContext.failure.taskName}\n\nブランチ: ${retryContext.branchName}\n\n${ui.intro}${replayHint}` + : `## Retry: ${retryContext.failure.taskName}\n\nBranch: ${retryContext.branchName}\n\n${ui.intro}${replayHint}`; const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {}); @@ -154,7 +142,8 @@ export async function runRetryMode( allowedTools: RETRY_TOOLS, transformPrompt: injectPolicy, introMessage: introLabel, - selectAction: createSelectRetryAction(ui), + selectAction: createSelectActionWithoutExecute(ui), + previousOrderContent: previousOrderContent ?? undefined, }; const result = await runConversationLoop(cwd, ctx, strategy, retryContext.pieceContext, undefined); diff --git a/src/features/interactive/runSessionReader.ts b/src/features/interactive/runSessionReader.ts index 28e672d..da4b4d1 100644 --- a/src/features/interactive/runSessionReader.ts +++ b/src/features/interactive/runSessionReader.ts @@ -216,6 +216,28 @@ export function loadRunSessionContext(cwd: string, slug: string): RunSessionCont }; } +/** + * Load the previous order.md content from the run directory. + * + * Uses findRunForTask to locate the matching run by task content, + * then reads order.md from its context/task directory. + * + * @returns The order.md content if found, null otherwise. + */ +export function loadPreviousOrderContent(cwd: string, taskContent: string): string | null { + const slug = findRunForTask(cwd, taskContent); + if (!slug) { + return null; + } + + const orderPath = join(cwd, '.takt', 'runs', slug, 'context', 'task', 'order.md'); + if (!existsSync(orderPath)) { + return null; + } + + return readFileSync(orderPath, 'utf-8'); +} + /** * Format run session context into a text block for the system prompt. */ diff --git a/src/features/pieceSelection/index.ts b/src/features/pieceSelection/index.ts index 67cfa98..2f6fdfd 100644 --- a/src/features/pieceSelection/index.ts +++ b/src/features/pieceSelection/index.ts @@ -11,13 +11,12 @@ import { removeBookmark, } from '../../infra/config/global/index.js'; import { - findPieceCategories, listPieces, listPieceEntries, loadAllPiecesWithSources, getPieceCategories, buildCategorizedPieces, - getCurrentPiece, + resolveConfigValue, type PieceDirEntry, type PieceCategoryNode, type CategorizedPieces, @@ -160,8 +159,6 @@ function buildCategoryLevelOptions( categories: PieceCategoryNode[], pieces: string[], currentPiece: string, - rootCategories: PieceCategoryNode[], - currentPathLabel: string, ): { options: SelectionOption[]; categoryMap: Map; @@ -181,19 +178,7 @@ function buildCategoryLevelOptions( for (const pieceName of pieces) { const isCurrent = pieceName === currentPiece; - const alsoIn = findPieceCategories(pieceName, rootCategories) - .filter((path) => path !== currentPathLabel); - const alsoInLabel = alsoIn.length > 0 ? `also in ${alsoIn.join(', ')}` : ''; - - let label = `🎼 ${pieceName}`; - if (isCurrent && alsoInLabel) { - label = `🎼 ${pieceName} (current, ${alsoInLabel})`; - } else if (isCurrent) { - label = `🎼 ${pieceName} (current)`; - } else if (alsoInLabel) { - label = `🎼 ${pieceName} (${alsoInLabel})`; - } - + const label = isCurrent ? `🎼 ${pieceName} (current)` : `🎼 ${pieceName}`; options.push({ label, value: pieceName }); } @@ -223,8 +208,6 @@ async function selectPieceFromCategoryTree( currentCategories, currentPieces, currentPiece, - categories, - currentPathLabel, ); if (options.length === 0) { @@ -521,8 +504,8 @@ export async function selectPiece( options?: SelectPieceOptions, ): Promise { const fallbackToDefault = options?.fallbackToDefault !== false; - const categoryConfig = getPieceCategories(); - const currentPiece = getCurrentPiece(cwd); + const categoryConfig = getPieceCategories(cwd); + const currentPiece = resolveConfigValue(cwd, 'piece'); if (categoryConfig) { const allPieces = loadAllPiecesWithSources(cwd); @@ -534,7 +517,7 @@ export async function selectPiece( info('No pieces found.'); return null; } - const categorized = buildCategorizedPieces(allPieces, categoryConfig); + const categorized = buildCategorizedPieces(allPieces, categoryConfig, cwd); warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user')); return selectPieceFromCategorizedPieces(categorized, currentPiece); } diff --git a/src/features/pipeline/execute.ts b/src/features/pipeline/execute.ts index f885872..385e0f3 100644 --- a/src/features/pipeline/execute.ts +++ b/src/features/pipeline/execute.ts @@ -21,7 +21,7 @@ import { } from '../../infra/github/index.js'; import { stageAndCommit, getCurrentBranch } from '../../infra/task/index.js'; import { executeTask, type TaskExecutionOptions, type PipelineExecutionOptions } from '../tasks/index.js'; -import { loadGlobalConfig } from '../../infra/config/index.js'; +import { resolveConfigValues } from '../../infra/config/index.js'; import { info, error, success, status, blankLine } from '../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import type { PipelineConfig } from '../../core/models/index.js'; @@ -106,7 +106,7 @@ function buildPipelinePrBody( */ export async function executePipeline(options: PipelineExecutionOptions): Promise { const { cwd, piece, autoPr, skipGit } = options; - const globalConfig = loadGlobalConfig(); + const globalConfig = resolveConfigValues(cwd, ['pipeline']); const pipelineConfig = globalConfig.pipeline; let issue: GitHubIssue | undefined; let task: string; diff --git a/src/features/prompt/preview.ts b/src/features/prompt/preview.ts index 27d5bc8..a4faa7b 100644 --- a/src/features/prompt/preview.ts +++ b/src/features/prompt/preview.ts @@ -5,7 +5,7 @@ * Useful for debugging and understanding what prompts agents will receive. */ -import { loadPieceByIdentifier, getCurrentPiece, loadGlobalConfig } from '../../infra/config/index.js'; +import { loadPieceByIdentifier, resolvePieceConfigValue } from '../../infra/config/index.js'; import { InstructionBuilder } from '../../core/piece/instruction/InstructionBuilder.js'; import { ReportInstructionBuilder } from '../../core/piece/instruction/ReportInstructionBuilder.js'; import { StatusJudgmentBuilder } from '../../core/piece/instruction/StatusJudgmentBuilder.js'; @@ -21,7 +21,7 @@ import { header, info, error, blankLine } from '../../shared/ui/index.js'; * the Phase 1, Phase 2, and Phase 3 prompts with sample variable values. */ export async function previewPrompts(cwd: string, pieceIdentifier?: string): Promise { - const identifier = pieceIdentifier ?? getCurrentPiece(cwd); + const identifier = pieceIdentifier ?? resolvePieceConfigValue(cwd, 'piece'); const config = loadPieceByIdentifier(identifier, cwd); if (!config) { @@ -29,8 +29,7 @@ export async function previewPrompts(cwd: string, pieceIdentifier?: string): Pro return; } - const globalConfig = loadGlobalConfig(); - const language: Language = globalConfig.language ?? 'en'; + const language = resolvePieceConfigValue(cwd, 'language') as Language; header(`Prompt Preview: ${config.name}`); info(`Movements: ${config.movements.length}`); diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 79599f7..107e2de 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -8,10 +8,11 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import { promptInput, confirm } from '../../../shared/prompt/index.js'; import { success, info, error, withProgress } from '../../../shared/ui/index.js'; -import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js'; +import { TaskRunner, type TaskFileData, summarizeTaskName } from '../../../infra/task/index.js'; import { determinePiece } from '../execute/selectAndExecute.js'; import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js'; import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js'; +import { firstLine } from '../../../infra/task/naming.js'; const log = createLogger('add-task'); @@ -39,9 +40,11 @@ export async function saveTaskFile( options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean }, ): Promise<{ taskName: string; tasksFile: string }> { const runner = new TaskRunner(cwd); - const taskSlug = resolveUniqueTaskSlug(cwd, generateReportDir(taskContent)); - const taskDir = path.join(cwd, '.takt', 'tasks', taskSlug); - const taskDirRelative = `.takt/tasks/${taskSlug}`; + const slug = await summarizeTaskName(taskContent, { cwd }); + const summary = firstLine(taskContent); + const taskDirSlug = resolveUniqueTaskSlug(cwd, generateReportDir(taskContent)); + const taskDir = path.join(cwd, '.takt', 'tasks', taskDirSlug); + const taskDirRelative = `.takt/tasks/${taskDirSlug}`; const orderPath = path.join(taskDir, 'order.md'); fs.mkdirSync(taskDir, { recursive: true }); fs.writeFileSync(orderPath, taskContent, 'utf-8'); @@ -55,6 +58,8 @@ export async function saveTaskFile( const created = runner.addTask(taskContent, { ...config, task_dir: taskDirRelative, + slug, + summary, }); const tasksFile = path.join(cwd, '.takt', 'tasks.yaml'); log.info('Task created', { taskName: created.name, tasksFile, config }); @@ -69,8 +74,8 @@ export async function saveTaskFile( */ export function createIssueFromTask(task: string): number | undefined { info('Creating GitHub Issue...'); - const firstLine = task.split('\n')[0] || task; - const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine; + const titleLine = task.split('\n')[0] || task; + const title = titleLine.length > 100 ? `${titleLine.slice(0, 97)}...` : titleLine; const issueResult = createIssue({ title, body: task }); if (issueResult.success) { success(`Issue created: ${issueResult.url}`); diff --git a/src/features/tasks/execute/parallelExecution.ts b/src/features/tasks/execute/parallelExecution.ts index 39a67fd..93b9dc6 100644 --- a/src/features/tasks/execute/parallelExecution.ts +++ b/src/features/tasks/execute/parallelExecution.ts @@ -205,9 +205,17 @@ function fillSlots( const task = queue.shift()!; const isParallel = concurrency > 1; const colorIndex = colorCounter.value++; + const issueNumber = task.data?.issue; + const taskPrefix = issueNumber === undefined ? task.name : `#${issueNumber}`; + const taskDisplayLabel = issueNumber === undefined ? undefined : taskPrefix; if (isParallel) { - const writer = new TaskPrefixWriter({ taskName: task.name, colorIndex }); + const writer = new TaskPrefixWriter({ + taskName: task.name, + colorIndex, + issue: issueNumber, + displayLabel: taskDisplayLabel, + }); writer.writeLine(`=== Task: ${task.name} ===`); } else { blankLine(); @@ -216,8 +224,9 @@ function fillSlots( const promise = executeAndCompleteTask(task, taskRunner, cwd, pieceName, options, { abortSignal: abortController.signal, - taskPrefix: isParallel ? task.name : undefined, + taskPrefix: isParallel ? taskPrefix : undefined, taskColorIndex: isParallel ? colorIndex : undefined, + taskDisplayLabel: isParallel ? taskDisplayLabel : undefined, }); active.set(promise, task); } diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index c958cc3..5f54919 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -3,6 +3,7 @@ */ import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { PieceEngine, type IterationLimitRequest, type UserInputRequest } from '../../../core/piece/index.js'; import type { PieceConfig } from '../../../core/models/index.js'; import type { PieceExecutionResult, PieceExecutionOptions } from './types.js'; @@ -17,7 +18,7 @@ import { updatePersonaSession, loadWorktreeSessions, updateWorktreeSession, - loadGlobalConfig, + resolvePieceConfigValues, saveSessionState, type SessionState, } from '../../../infra/config/index.js'; @@ -72,6 +73,17 @@ import { buildRunPaths } from '../../../core/piece/run/run-paths.js'; import { resolveMovementProviderModel } from '../../../core/piece/provider-resolution.js'; import { resolveRuntimeConfig } from '../../../core/runtime/runtime-environment.js'; import { writeFileAtomic, ensureDir } from '../../../infra/config/index.js'; +import { getGlobalConfigDir } from '../../../infra/config/paths.js'; +import { + initAnalyticsWriter, + writeAnalyticsEvent, + parseFindingsFromReport, + extractDecisionFromReport, + inferSeverity, + emitFixActionEvents, + emitRebuttalEvents, +} from '../../analytics/index.js'; +import type { MovementResultEvent, ReviewFindingEvent } from '../../analytics/index.js'; const log = createLogger('piece'); @@ -232,7 +244,11 @@ export async function executePiece( // When taskPrefix is set (parallel execution), route all output through TaskPrefixWriter const prefixWriter = options.taskPrefix != null - ? new TaskPrefixWriter({ taskName: options.taskPrefix, colorIndex: options.taskColorIndex! }) + ? new TaskPrefixWriter({ + taskName: options.taskPrefix, + colorIndex: options.taskColorIndex!, + displayLabel: options.taskDisplayLabel, + }) : undefined; const out = createOutputFns(prefixWriter); @@ -313,13 +329,16 @@ export async function executePiece( // Load saved agent sessions only on retry; normal runs start with empty sessions const isWorktree = cwd !== projectCwd; - const globalConfig = loadGlobalConfig(); + const globalConfig = resolvePieceConfigValues( + projectCwd, + ['notificationSound', 'notificationSoundEvents', 'provider', 'runtime', 'preventSleep', 'model', 'observability', 'analytics'], + ); const shouldNotify = globalConfig.notificationSound !== false; const notificationSoundEvents = globalConfig.notificationSoundEvents; const shouldNotifyIterationLimit = shouldNotify && notificationSoundEvents?.iterationLimit !== false; const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false; const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false; - const currentProvider = globalConfig.provider ?? 'claude'; + const currentProvider = globalConfig.provider; const effectivePieceConfig: PieceConfig = { ...pieceConfig, runtime: resolveRuntimeConfig(globalConfig.runtime, pieceConfig.runtime), @@ -333,6 +352,11 @@ export async function executePiece( enabled: isProviderEventsEnabled(globalConfig), }); + const analyticsEnabled = globalConfig.analytics?.enabled === true; + const eventsDir = globalConfig.analytics?.eventsPath + ?? join(getGlobalConfigDir(), 'analytics', 'events'); + initAnalyticsWriter(analyticsEnabled, eventsDir); + // Prevent macOS idle sleep if configured if (globalConfig.preventSleep) { preventSleep(); @@ -420,6 +444,8 @@ export async function executePiece( let lastMovementContent: string | undefined; let lastMovementName: string | undefined; let currentIteration = 0; + let currentMovementProvider = currentProvider; + let currentMovementModel = globalConfig.model ?? '(default)'; const phasePrompts = new Map(); const movementIterations = new Map(); let engine: PieceEngine | null = null; @@ -439,12 +465,10 @@ export async function executePiece( projectCwd, language: options.language, provider: options.provider, - projectProvider: options.projectProvider, - globalProvider: options.globalProvider, model: options.model, + providerOptions: options.providerOptions, personaProviders: options.personaProviders, - projectProviderProfiles: options.projectProviderProfiles, - globalProviderProfiles: options.globalProviderProfiles, + providerProfiles: options.providerProfiles, interactive: interactiveUserInput, detectRuleIndex, callAiJudge, @@ -525,6 +549,8 @@ export async function executePiece( }); const movementProvider = resolved.provider ?? currentProvider; const movementModel = resolved.model ?? globalConfig.model ?? '(default)'; + currentMovementProvider = movementProvider; + currentMovementModel = movementModel; providerEventLogger.setMovement(step.name); providerEventLogger.setProvider(movementProvider); out.info(`Provider: ${movementProvider}`); @@ -623,15 +649,60 @@ export async function executePiece( }; appendNdjsonLine(ndjsonLogPath, record); + const decisionTag = (response.matchedRuleIndex != null && step.rules) + ? (step.rules[response.matchedRuleIndex]?.condition ?? response.status) + : response.status; + const movementResultEvent: MovementResultEvent = { + type: 'movement_result', + movement: step.name, + provider: currentMovementProvider, + model: currentMovementModel, + decisionTag, + iteration: currentIteration, + runId: runSlug, + timestamp: response.timestamp.toISOString(), + }; + writeAnalyticsEvent(movementResultEvent); + + if (step.edit === true && step.name.includes('fix')) { + emitFixActionEvents(response.content, currentIteration, runSlug, response.timestamp); + } + + if (step.name.includes('no_fix')) { + emitRebuttalEvents(response.content, currentIteration, runSlug, response.timestamp); + } // Update in-memory log for pointer metadata (immutable) sessionLog = { ...sessionLog, iterations: sessionLog.iterations + 1 }; }); - engine.on('movement:report', (_step, filePath, fileName) => { + engine.on('movement:report', (step, filePath, fileName) => { const content = readFileSync(filePath, 'utf-8'); out.logLine(`\n📄 Report: ${fileName}\n`); out.logLine(content); + + if (step.edit === false) { + const decision = extractDecisionFromReport(content); + if (decision) { + const findings = parseFindingsFromReport(content); + for (const finding of findings) { + const event: ReviewFindingEvent = { + type: 'review_finding', + findingId: finding.findingId, + status: finding.status, + ruleId: finding.ruleId, + severity: inferSeverity(finding.findingId), + decision, + file: finding.file, + line: finding.line, + iteration: currentIteration, + runId: runSlug, + timestamp: new Date().toISOString(), + }; + writeAnalyticsEvent(event); + } + } + } }); engine.on('piece:complete', (state) => { diff --git a/src/features/tasks/execute/postExecution.ts b/src/features/tasks/execute/postExecution.ts index 2bb5bf9..6df53cb 100644 --- a/src/features/tasks/execute/postExecution.ts +++ b/src/features/tasks/execute/postExecution.ts @@ -5,12 +5,12 @@ * instructBranch (instruct mode from takt list). */ -import { loadGlobalConfig } from '../../../infra/config/index.js'; +import { resolvePieceConfigValue } from '../../../infra/config/index.js'; import { confirm } from '../../../shared/prompt/index.js'; import { autoCommitAndPush } from '../../../infra/task/index.js'; import { info, error, success } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.js'; -import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js'; +import { createPullRequest, buildPrBody, pushBranch, findExistingPr, commentOnPr } from '../../../infra/github/index.js'; import type { GitHubIssue } from '../../../infra/github/index.js'; const log = createLogger('postExecution'); @@ -18,16 +18,15 @@ const log = createLogger('postExecution'); /** * Resolve auto-PR setting with priority: CLI option > config > prompt. */ -export async function resolveAutoPr(optionAutoPr: boolean | undefined): Promise { +export async function resolveAutoPr(optionAutoPr: boolean | undefined, cwd: string): Promise { if (typeof optionAutoPr === 'boolean') { return optionAutoPr; } - const globalConfig = loadGlobalConfig(); - if (typeof globalConfig.autoPr === 'boolean') { - return globalConfig.autoPr; + const autoPr = resolvePieceConfigValue(cwd, 'autoPr'); + if (typeof autoPr === 'boolean') { + return autoPr; } - return confirm('Create pull request?', true); } @@ -57,25 +56,37 @@ export async function postExecutionFlow(options: PostExecutionOptions): Promise< } if (commitResult.success && commitResult.commitHash && branch && shouldCreatePr) { - info('Creating pull request...'); try { pushBranch(projectCwd, branch); } catch (pushError) { log.info('Branch push from project cwd failed (may already exist)', { error: pushError }); } const report = pieceIdentifier ? `Piece \`${pieceIdentifier}\` completed successfully.` : 'Task completed successfully.'; - const prBody = buildPrBody(issues, report); - const prResult = createPullRequest(projectCwd, { - branch, - title: task.length > 100 ? `${task.slice(0, 97)}...` : task, - body: prBody, - base: baseBranch, - repo, - }); - if (prResult.success) { - success(`PR created: ${prResult.url}`); + const existingPr = findExistingPr(projectCwd, branch); + if (existingPr) { + // PRが既に存在する場合はコメントを追加(push済みなので新コミットはPRに自動反映) + const commentBody = buildPrBody(issues, report); + const commentResult = commentOnPr(projectCwd, existingPr.number, commentBody); + if (commentResult.success) { + success(`PR updated with comment: ${existingPr.url}`); + } else { + error(`PR comment failed: ${commentResult.error}`); + } } else { - error(`PR creation failed: ${prResult.error}`); + info('Creating pull request...'); + const prBody = buildPrBody(issues, report); + const prResult = createPullRequest(projectCwd, { + branch, + title: task.length > 100 ? `${task.slice(0, 97)}...` : task, + body: prBody, + base: baseBranch, + repo, + }); + if (prResult.success) { + success(`PR created: ${prResult.url}`); + } else { + error(`PR creation failed: ${prResult.error}`); + } } } } diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index f4e5c3b..60adb6d 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -4,7 +4,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { loadGlobalConfig } from '../../../infra/config/index.js'; +import { resolvePieceConfigValue } from '../../../infra/config/index.js'; import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; import { withProgress } from '../../../shared/ui/index.js'; import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js'; @@ -104,7 +104,7 @@ export async function resolveTaskExecution( worktreePath = task.worktreePath; isWorktree = true; } else { - const taskSlug = await withProgress( + const taskSlug = task.slug ?? await withProgress( 'Generating branch name...', (slug) => `Branch name generated: ${slug}`, () => summarizeTaskName(task.content, { cwd: defaultCwd }), @@ -141,8 +141,7 @@ export async function resolveTaskExecution( if (data.auto_pr !== undefined) { autoPr = data.auto_pr; } else { - const globalConfig = loadGlobalConfig(); - autoPr = globalConfig.autoPr ?? false; + autoPr = resolvePieceConfigValue(defaultCwd, 'autoPr') ?? false; } return { diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 78e5fe7..dccfb31 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -72,7 +72,7 @@ export async function confirmAndCreateWorktree( }), ); - return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch }; + return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch, taskSlug }; } /** @@ -92,7 +92,7 @@ export async function selectAndExecuteTask( return; } - const { execCwd, isWorktree, branch, baseBranch } = await confirmAndCreateWorktree( + const { execCwd, isWorktree, branch, baseBranch, taskSlug } = await confirmAndCreateWorktree( cwd, task, options?.createWorktree, @@ -101,7 +101,7 @@ export async function selectAndExecuteTask( // Ask for PR creation BEFORE execution (only if worktree is enabled) let shouldCreatePr = false; if (isWorktree) { - shouldCreatePr = await resolveAutoPr(options?.autoPr); + shouldCreatePr = await resolveAutoPr(options?.autoPr, cwd); } log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr }); @@ -112,6 +112,7 @@ export async function selectAndExecuteTask( ...(branch ? { branch } : {}), ...(isWorktree ? { worktree_path: execCwd } : {}), auto_pr: shouldCreatePr, + ...(taskSlug ? { slug: taskSlug } : {}), }); const startedAt = new Date().toISOString(); diff --git a/src/features/tasks/execute/session.ts b/src/features/tasks/execute/session.ts index 843fee8..62547ff 100644 --- a/src/features/tasks/execute/session.ts +++ b/src/features/tasks/execute/session.ts @@ -2,7 +2,7 @@ * Session management helpers for agent execution */ -import { loadPersonaSessions, updatePersonaSession, loadGlobalConfig } from '../../../infra/config/index.js'; +import { loadPersonaSessions, updatePersonaSession, resolvePieceConfigValue } from '../../../infra/config/index.js'; import type { AgentResponse } from '../../../core/models/index.js'; /** @@ -15,7 +15,7 @@ export async function withPersonaSession( fn: (sessionId?: string) => Promise, provider?: string ): Promise { - const resolvedProvider = provider ?? loadGlobalConfig().provider ?? 'claude'; + const resolvedProvider = provider ?? resolvePieceConfigValue(cwd, 'provider'); const sessions = loadPersonaSessions(cwd, resolvedProvider); const sessionId = sessions[personaName]; diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 2f443cb..98c7af3 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -2,7 +2,7 @@ * Task execution logic */ -import { loadPieceByIdentifier, isPiecePath, loadGlobalConfig, loadProjectConfig } from '../../../infra/config/index.js'; +import { loadPieceByIdentifier, isPiecePath, resolvePieceConfigValues } from '../../../infra/config/index.js'; import { TaskRunner, type TaskInfo } from '../../../infra/task/index.js'; import { header, @@ -51,7 +51,22 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType { - const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, reportDirName, abortSignal, taskPrefix, taskColorIndex } = options; + const { + task, + cwd, + pieceIdentifier, + projectCwd, + agentOverrides, + interactiveUserInput, + interactiveMetadata, + startMovement, + retryNote, + reportDirName, + abortSignal, + taskPrefix, + taskColorIndex, + taskDisplayLabel, + } = options; const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd); if (!pieceConfig) { @@ -71,18 +86,22 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise s.name), }); - const globalConfig = loadGlobalConfig(); - const projectConfig = loadProjectConfig(projectCwd); + const config = resolvePieceConfigValues(projectCwd, [ + 'language', + 'provider', + 'model', + 'providerOptions', + 'personaProviders', + 'providerProfiles', + ]); return await executePiece(pieceConfig, task, cwd, { projectCwd, - language: globalConfig.language, - provider: agentOverrides?.provider, - projectProvider: projectConfig.provider, - globalProvider: globalConfig.provider, - model: agentOverrides?.model, - personaProviders: globalConfig.personaProviders, - projectProviderProfiles: projectConfig.providerProfiles, - globalProviderProfiles: globalConfig.providerProfiles, + language: config.language, + provider: agentOverrides?.provider ?? config.provider, + model: agentOverrides?.model ?? config.model, + providerOptions: config.providerOptions, + personaProviders: config.personaProviders, + providerProfiles: config.providerProfiles, interactiveUserInput, interactiveMetadata, startMovement, @@ -91,8 +110,9 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise { const startedAt = new Date().toISOString(); const taskAbortController = new AbortController(); @@ -164,6 +184,7 @@ export async function executeAndCompleteTask( abortSignal: taskAbortSignal, taskPrefix: parallelOptions?.taskPrefix, taskColorIndex: parallelOptions?.taskColorIndex, + taskDisplayLabel: parallelOptions?.taskDisplayLabel, }); const taskSuccess = taskRunResult.success; @@ -217,7 +238,10 @@ export async function runAllTasks( options?: TaskExecutionOptions, ): Promise { const taskRunner = new TaskRunner(cwd); - const globalConfig = loadGlobalConfig(); + const globalConfig = resolvePieceConfigValues( + cwd, + ['notificationSound', 'notificationSoundEvents', 'concurrency', 'taskPollIntervalMs'], + ); const shouldNotifyRunComplete = globalConfig.notificationSound !== false && globalConfig.notificationSoundEvents?.runComplete !== false; const shouldNotifyRunAbort = globalConfig.notificationSound !== false diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index d09c9f1..2636115 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -4,6 +4,7 @@ import type { Language } from '../../../core/models/index.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; +import type { MovementProviderOptions } from '../../../core/models/piece-types.js'; import type { ProviderType } from '../../../infra/providers/index.js'; import type { GitHubIssue } from '../../../infra/github/index.js'; @@ -32,17 +33,13 @@ export interface PieceExecutionOptions { /** Language for instruction metadata */ language?: Language; provider?: ProviderType; - /** Project config provider */ - projectProvider?: ProviderType; - /** Global config provider */ - globalProvider?: ProviderType; model?: string; + /** Resolved provider options */ + providerOptions?: MovementProviderOptions; /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ personaProviders?: Record; - /** Project-level provider permission profiles */ - projectProviderProfiles?: ProviderPermissionProfiles; - /** Global-level provider permission profiles */ - globalProviderProfiles?: ProviderPermissionProfiles; + /** Resolved provider permission profiles */ + providerProfiles?: ProviderPermissionProfiles; /** Enable interactive user input during step transitions */ interactiveUserInput?: boolean; /** Interactive mode result metadata for NDJSON logging */ @@ -57,6 +54,8 @@ export interface PieceExecutionOptions { abortSignal?: AbortSignal; /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ taskPrefix?: string; + /** Optional full task label used instead of taskName truncation when prefixed output is rendered */ + taskDisplayLabel?: string; /** Color index for task prefix (cycled mod 4 across concurrent tasks) */ taskColorIndex?: number; } @@ -91,6 +90,8 @@ export interface ExecuteTaskOptions { abortSignal?: AbortSignal; /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ taskPrefix?: string; + /** Optional full task label used instead of taskName truncation when prefixed output is rendered */ + taskDisplayLabel?: string; /** Color index for task prefix (cycled mod 4 across concurrent tasks) */ taskColorIndex?: number; } @@ -121,6 +122,7 @@ export interface WorktreeConfirmationResult { isWorktree: boolean; branch?: string; baseBranch?: string; + taskSlug?: string; } export interface SelectAndExecuteOptions { diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 351cbe3..7367161 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -26,7 +26,7 @@ import { import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from './taskDeleteActions.js'; import { retryFailedTask } from './taskRetryActions.js'; import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js'; -import { formatTaskStatusLabel } from './taskStatusLabel.js'; +import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js'; export type { ListNonInteractiveOptions } from './listNonInteractive.js'; @@ -130,7 +130,7 @@ export async function listTasks( const menuOptions = tasks.map((task, idx) => ({ label: formatTaskStatusLabel(task), value: `${task.kind}:${idx}`, - description: `${task.content} | ${task.createdAt}`, + description: `${task.summary ?? task.content} | ${formatShortDate(task.createdAt)}`, })); const selected = await selectOption( diff --git a/src/features/tasks/list/instructMode.ts b/src/features/tasks/list/instructMode.ts index 6cc56b8..c6838b0 100644 --- a/src/features/tasks/list/instructMode.ts +++ b/src/features/tasks/list/instructMode.ts @@ -11,19 +11,17 @@ import { runConversationLoop, type SessionContext, type ConversationStrategy, - type PostSummaryAction, } from '../../interactive/conversationLoop.js'; import { resolveLanguage, - buildSummaryActionOptions, - selectSummaryAction, formatMovementPreviews, type PieceContext, } from '../../interactive/interactive.js'; +import { createSelectActionWithoutExecute, buildReplayHint } from '../../interactive/interactive-summary.js'; import { type RunSessionContext, formatRunSessionForPrompt } from '../../interactive/runSessionReader.js'; import { loadTemplate } from '../../../shared/prompts/index.js'; import { getLabelObject } from '../../../shared/i18n/index.js'; -import { loadGlobalConfig } from '../../../infra/config/index.js'; +import { resolvePieceConfigValues } from '../../../infra/config/index.js'; export type InstructModeAction = 'execute' | 'save_task' | 'cancel'; @@ -50,21 +48,6 @@ export interface InstructUIText { const INSTRUCT_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; -function createSelectInstructAction(ui: InstructUIText): (task: string, lang: 'en' | 'ja') => Promise { - return async (task: string, _lang: 'en' | 'ja'): Promise => { - return selectSummaryAction( - task, - ui.proposed, - ui.actionPrompt, - buildSummaryActionOptions({ - execute: ui.actions.execute, - saveTask: ui.actions.saveTask, - continue: ui.actions.continue, - }), - ); - }; -} - function buildInstructTemplateVars( branchContext: string, branchName: string, @@ -74,6 +57,7 @@ function buildInstructTemplateVars( lang: 'en' | 'ja', pieceContext?: PieceContext, runSessionContext?: RunSessionContext, + previousOrderContent?: string | null, ): Record { const hasPiecePreview = !!pieceContext?.movementPreviews?.length; const movementDetails = hasPiecePreview @@ -96,6 +80,8 @@ function buildInstructTemplateVars( movementDetails, hasRunSession, ...runPromptVars, + hasOrderContent: !!previousOrderContent, + orderContent: previousOrderContent ?? '', }; } @@ -108,8 +94,9 @@ export async function runInstructMode( retryNote: string, pieceContext?: PieceContext, runSessionContext?: RunSessionContext, + previousOrderContent?: string | null, ): Promise { - const globalConfig = loadGlobalConfig(); + const globalConfig = resolvePieceConfigValues(cwd, ['language', 'provider']); const lang = resolveLanguage(globalConfig.language); if (!globalConfig.provider) { @@ -125,10 +112,12 @@ export async function runInstructMode( const templateVars = buildInstructTemplateVars( branchContext, branchName, taskName, taskContent, retryNote, lang, - pieceContext, runSessionContext, + pieceContext, runSessionContext, previousOrderContent, ); const systemPrompt = loadTemplate('score_instruct_system_prompt', ctx.lang, templateVars); + const replayHint = buildReplayHint(ctx.lang, !!previousOrderContent); + const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {}); function injectPolicy(userMessage: string): string { @@ -145,8 +134,9 @@ export async function runInstructMode( systemPrompt, allowedTools: INSTRUCT_TOOLS, transformPrompt: injectPolicy, - introMessage: ui.intro, - selectAction: createSelectInstructAction(ui), + introMessage: `${ui.intro}${replayHint}`, + selectAction: createSelectActionWithoutExecute(ui), + previousOrderContent: previousOrderContent ?? undefined, }; const result = await runConversationLoop(cwd, ctx, strategy, pieceContext, undefined); diff --git a/src/features/tasks/list/listNonInteractive.ts b/src/features/tasks/list/listNonInteractive.ts index 3c11cad..f8c271b 100644 --- a/src/features/tasks/list/listNonInteractive.ts +++ b/src/features/tasks/list/listNonInteractive.ts @@ -18,7 +18,7 @@ import { mergeBranch, deleteBranch, } from './taskActions.js'; -import { formatTaskStatusLabel } from './taskStatusLabel.js'; +import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js'; export interface ListNonInteractiveOptions { enabled: boolean; @@ -43,7 +43,7 @@ function printNonInteractiveList(tasks: TaskListItem[], format?: string): void { } for (const task of tasks) { - info(`${formatTaskStatusLabel(task)} - ${task.content} (${task.createdAt})`); + info(`${formatTaskStatusLabel(task)} - ${task.summary ?? task.content} (${formatShortDate(task.createdAt)})`); } } diff --git a/src/features/tasks/list/taskInstructionActions.ts b/src/features/tasks/list/taskInstructionActions.ts index 578962a..4f16405 100644 --- a/src/features/tasks/list/taskInstructionActions.ts +++ b/src/features/tasks/list/taskInstructionActions.ts @@ -11,14 +11,14 @@ import { TaskRunner, detectDefaultBranch, } from '../../../infra/task/index.js'; -import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js'; +import { resolvePieceConfigValues, getPieceDescription } from '../../../infra/config/index.js'; import { info, error as logError } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { runInstructMode } from './instructMode.js'; import { selectPiece } from '../../pieceSelection/index.js'; import { dispatchConversationAction } from '../../interactive/actionDispatcher.js'; import type { PieceContext } from '../../interactive/interactive.js'; -import { resolveLanguage } from '../../interactive/index.js'; +import { resolveLanguage, findRunForTask, findPreviousOrderContent } from '../../interactive/index.js'; import { type BranchActionTarget, resolveTargetBranch } from './taskActionTarget.js'; import { appendRetryNote, selectRunSessionContext } from './requeueHelpers.js'; import { executeAndCompleteTask } from '../execute/taskExecution.js'; @@ -93,7 +93,7 @@ export async function instructBranch( return false; } - const globalConfig = loadGlobalConfig(); + const globalConfig = resolvePieceConfigValues(projectDir, ['interactivePreviewMovements', 'language']); const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); const pieceContext: PieceContext = { name: pieceDesc.name, @@ -105,13 +105,15 @@ export async function instructBranch( const lang = resolveLanguage(globalConfig.language); // Runs data lives in the worktree (written during previous execution) const runSessionContext = await selectRunSessionContext(worktreePath, lang); + const matchedSlug = findRunForTask(worktreePath, target.content); + const previousOrderContent = findPreviousOrderContent(worktreePath, matchedSlug); const branchContext = getBranchContext(projectDir, branch); const result = await runInstructMode( worktreePath, branchContext, branch, target.name, target.content, target.data?.retry_note ?? '', - pieceContext, runSessionContext, + pieceContext, runSessionContext, previousOrderContent, ); const executeWithInstruction = async (instruction: string): Promise => { diff --git a/src/features/tasks/list/taskRetryActions.ts b/src/features/tasks/list/taskRetryActions.ts index 4a4bfce..88aa354 100644 --- a/src/features/tasks/list/taskRetryActions.ts +++ b/src/features/tasks/list/taskRetryActions.ts @@ -8,9 +8,9 @@ import * as fs from 'node:fs'; import type { TaskListItem } from '../../../infra/task/index.js'; import { TaskRunner } from '../../../infra/task/index.js'; -import { loadPieceByIdentifier, loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js'; +import { loadPieceByIdentifier, resolvePieceConfigValue, getPieceDescription } from '../../../infra/config/index.js'; import { selectPiece } from '../../pieceSelection/index.js'; -import { selectOption } from '../../../shared/prompt/index.js'; +import { selectOptionWithDefault } from '../../../shared/prompt/index.js'; import { info, header, blankLine, status } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.js'; import type { PieceConfig } from '../../../core/models/index.js'; @@ -20,6 +20,7 @@ import { getRunPaths, formatRunSessionForPrompt, runRetryMode, + findPreviousOrderContent, type RetryContext, type RetryFailureInfo, type RetryRunInfo, @@ -59,12 +60,12 @@ async function selectStartMovement( const effectiveDefault = defaultIdx >= 0 ? movements[defaultIdx] : movements[0]; const options = movements.map((name) => ({ - label: name === effectiveDefault ? `${name} (default)` : name, + label: name, value: name, description: name === pieceConfig.initialMovement ? 'Initial movement' : undefined, })); - return await selectOption('Start from movement:', options); + return await selectOptionWithDefault('Start from movement:', options, effectiveDefault ?? movements[0]!); } function buildRetryFailureInfo(task: TaskListItem): RetryFailureInfo { @@ -133,7 +134,7 @@ export async function retryFailedTask( return false; } - const globalConfig = loadGlobalConfig(); + const previewCount = resolvePieceConfigValue(projectDir, 'interactivePreviewMovements'); const pieceConfig = loadPieceByIdentifier(selectedPiece, projectDir); if (!pieceConfig) { @@ -145,7 +146,7 @@ export async function retryFailedTask( return false; } - const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); + const pieceDesc = getPieceDescription(selectedPiece, projectDir, previewCount); const pieceContext = { name: pieceDesc.name, description: pieceDesc.description, @@ -156,6 +157,7 @@ export async function retryFailedTask( // Runs data lives in the worktree (written during previous execution) const matchedSlug = findRunForTask(worktreePath, task.content); const runInfo = matchedSlug ? buildRetryRunInfo(worktreePath, matchedSlug) : null; + const previousOrderContent = findPreviousOrderContent(worktreePath, matchedSlug); blankLine(); const branchName = task.branch ?? task.name; @@ -164,9 +166,10 @@ export async function retryFailedTask( branchName, pieceContext, run: runInfo, + previousOrderContent, }; - const retryResult = await runRetryMode(worktreePath, retryContext); + const retryResult = await runRetryMode(worktreePath, retryContext, previousOrderContent); if (retryResult.action === 'cancel') { return false; } diff --git a/src/features/tasks/list/taskStatusLabel.ts b/src/features/tasks/list/taskStatusLabel.ts index 4a891b1..2212784 100644 --- a/src/features/tasks/list/taskStatusLabel.ts +++ b/src/features/tasks/list/taskStatusLabel.ts @@ -8,5 +8,18 @@ const TASK_STATUS_BY_KIND: Record = { }; export function formatTaskStatusLabel(task: TaskListItem): string { - return `[${TASK_STATUS_BY_KIND[task.kind]}] ${task.name}`; + const status = `[${TASK_STATUS_BY_KIND[task.kind]}] ${task.name}`; + if (task.branch) { + return `${status} (${task.branch})`; + } + return status; +} + +export function formatShortDate(isoString: string): string { + const date = new Date(isoString); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + return `${month}/${day} ${hours}:${minutes}`; } diff --git a/src/features/tasks/watch/index.ts b/src/features/tasks/watch/index.ts index d2131fa..91feaaa 100644 --- a/src/features/tasks/watch/index.ts +++ b/src/features/tasks/watch/index.ts @@ -6,7 +6,7 @@ */ import { TaskRunner, type TaskInfo, TaskWatcher } from '../../../infra/task/index.js'; -import { getCurrentPiece } from '../../../infra/config/index.js'; +import { resolveConfigValue } from '../../../infra/config/index.js'; import { header, info, @@ -15,7 +15,6 @@ import { blankLine, } from '../../../shared/ui/index.js'; import { executeAndCompleteTask } from '../execute/taskExecution.js'; -import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; import { ShutdownManager } from '../execute/shutdownManager.js'; import type { TaskExecutionOptions } from '../execute/types.js'; @@ -25,7 +24,7 @@ import type { TaskExecutionOptions } from '../execute/types.js'; * Runs until Ctrl+C. */ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise { - const pieceName = getCurrentPiece(cwd) || DEFAULT_PIECE_NAME; + const pieceName = resolveConfigValue(cwd, 'piece'); const taskRunner = new TaskRunner(cwd); const watcher = new TaskWatcher(cwd); const recovered = taskRunner.recoverInterruptedRunningTasks(); diff --git a/src/index.ts b/src/index.ts index bd9541d..e4d138b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,10 +30,11 @@ export { } from './infra/config/loaders/index.js'; export type { PieceSource, PieceWithSource, PieceDirEntry } from './infra/config/loaders/index.js'; export { - loadProjectConfig, + loadConfig, +} from './infra/config/loadConfig.js'; +export { saveProjectConfig, updateProjectConfig, - getCurrentPiece, setCurrentPiece, isVerboseMode, type ProjectLocalConfig, diff --git a/src/infra/claude/types.ts b/src/infra/claude/types.ts index 67114c6..1964aaa 100644 --- a/src/infra/claude/types.ts +++ b/src/infra/claude/types.ts @@ -141,7 +141,7 @@ export interface ClaudeCallOptions { onPermissionRequest?: PermissionHandler; /** Custom handler for AskUserQuestion tool */ onAskUserQuestion?: AskUserQuestionHandler; - /** Bypass all permission checks (sacrifice-my-pc mode) */ + /** Bypass all permission checks */ bypassPermissions?: boolean; /** Anthropic API key to inject via env (bypasses CLI auth) */ anthropicApiKey?: string; @@ -172,7 +172,7 @@ export interface ClaudeSpawnOptions { onPermissionRequest?: PermissionHandler; /** Custom handler for AskUserQuestion tool */ onAskUserQuestion?: AskUserQuestionHandler; - /** Bypass all permission checks (sacrifice-my-pc mode) */ + /** Bypass all permission checks */ bypassPermissions?: boolean; /** Anthropic API key to inject via env (bypasses CLI auth) */ anthropicApiKey?: string; diff --git a/src/infra/config/env/config-env-overrides.ts b/src/infra/config/env/config-env-overrides.ts new file mode 100644 index 0000000..db9df70 --- /dev/null +++ b/src/infra/config/env/config-env-overrides.ts @@ -0,0 +1,142 @@ +type EnvValueType = 'string' | 'boolean' | 'number' | 'json'; + +interface EnvSpec { + path: string; + type: EnvValueType; +} + +function normalizeEnvSegment(segment: string): string { + return segment + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[^a-zA-Z0-9]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') + .toUpperCase(); +} + +export function envVarNameFromPath(path: string): string { + const key = path + .split('.') + .map(normalizeEnvSegment) + .filter((segment) => segment.length > 0) + .join('_'); + return `TAKT_${key}`; +} + +function parseEnvValue(envKey: string, raw: string, type: EnvValueType): unknown { + if (type === 'string') { + return raw; + } + if (type === 'boolean') { + const normalized = raw.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + throw new Error(`${envKey} must be one of: true, false`); + } + if (type === 'number') { + const trimmed = raw.trim(); + const value = Number(trimmed); + if (!Number.isFinite(value)) { + throw new Error(`${envKey} must be a number`); + } + return value; + } + try { + return JSON.parse(raw); + } catch { + throw new Error(`${envKey} must be valid JSON`); + } +} + +function setNested(target: Record, path: string, value: unknown): void { + const parts = path.split('.'); + let current: Record = target; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!part) continue; + const next = current[part]; + if (typeof next !== 'object' || next === null || Array.isArray(next)) { + current[part] = {}; + } + current = current[part] as Record; + } + const leaf = parts[parts.length - 1]; + if (!leaf) return; + current[leaf] = value; +} + +function applyEnvOverrides(target: Record, specs: readonly EnvSpec[]): void { + for (const spec of specs) { + const envKey = envVarNameFromPath(spec.path); + const raw = process.env[envKey]; + if (raw === undefined) continue; + const parsedValue = parseEnvValue(envKey, raw, spec.type); + setNested(target, spec.path, parsedValue); + } +} + +const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [ + { path: 'language', type: 'string' }, + { path: 'log_level', type: 'string' }, + { path: 'provider', type: 'string' }, + { path: 'model', type: 'string' }, + { path: 'observability', type: 'json' }, + { path: 'observability.provider_events', type: 'boolean' }, + { path: 'worktree_dir', type: 'string' }, + { path: 'auto_pr', type: 'boolean' }, + { path: 'disabled_builtins', type: 'json' }, + { path: 'enable_builtin_pieces', type: 'boolean' }, + { path: 'anthropic_api_key', type: 'string' }, + { path: 'openai_api_key', type: 'string' }, + { path: 'codex_cli_path', type: 'string' }, + { path: 'opencode_api_key', type: 'string' }, + { path: 'pipeline', type: 'json' }, + { path: 'pipeline.default_branch_prefix', type: 'string' }, + { path: 'pipeline.commit_message_template', type: 'string' }, + { path: 'pipeline.pr_body_template', type: 'string' }, + { path: 'minimal_output', type: 'boolean' }, + { path: 'bookmarks_file', type: 'string' }, + { path: 'piece_categories_file', type: 'string' }, + { path: 'persona_providers', type: 'json' }, + { path: 'provider_options', type: 'json' }, + { path: 'provider_options.codex.network_access', type: 'boolean' }, + { path: 'provider_options.opencode.network_access', type: 'boolean' }, + { path: 'provider_options.claude.sandbox.allow_unsandboxed_commands', type: 'boolean' }, + { path: 'provider_options.claude.sandbox.excluded_commands', type: 'json' }, + { path: 'provider_profiles', type: 'json' }, + { path: 'runtime', type: 'json' }, + { path: 'runtime.prepare', type: 'json' }, + { path: 'branch_name_strategy', type: 'string' }, + { path: 'prevent_sleep', type: 'boolean' }, + { path: 'notification_sound', type: 'boolean' }, + { path: 'notification_sound_events', type: 'json' }, + { path: 'notification_sound_events.iteration_limit', type: 'boolean' }, + { path: 'notification_sound_events.piece_complete', type: 'boolean' }, + { path: 'notification_sound_events.piece_abort', type: 'boolean' }, + { path: 'notification_sound_events.run_complete', type: 'boolean' }, + { path: 'notification_sound_events.run_abort', type: 'boolean' }, + { path: 'interactive_preview_movements', type: 'number' }, + { path: 'verbose', type: 'boolean' }, + { path: 'concurrency', type: 'number' }, + { path: 'task_poll_interval_ms', type: 'number' }, +]; + +const PROJECT_ENV_SPECS: readonly EnvSpec[] = [ + { path: 'piece', type: 'string' }, + { path: 'provider', type: 'string' }, + { path: 'verbose', type: 'boolean' }, + { path: 'provider_options', type: 'json' }, + { path: 'provider_options.codex.network_access', type: 'boolean' }, + { path: 'provider_options.opencode.network_access', type: 'boolean' }, + { path: 'provider_options.claude.sandbox.allow_unsandboxed_commands', type: 'boolean' }, + { path: 'provider_options.claude.sandbox.excluded_commands', type: 'json' }, + { path: 'provider_profiles', type: 'json' }, +]; + +export function applyGlobalConfigEnvOverrides(target: Record): void { + applyEnvOverrides(target, GLOBAL_ENV_SPECS); +} + +export function applyProjectConfigEnvOverrides(target: Record): void { + applyEnvOverrides(target, PROJECT_ENV_SPECS); +} diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index f233245..b8d98fc 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -15,6 +15,7 @@ import { normalizeProviderOptions } from '../loaders/pieceParser.js'; import { getGlobalConfigPath } from '../paths.js'; import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; import { parseProviderModel } from '../../../shared/utils/providerModel.js'; +import { applyGlobalConfigEnvOverrides, envVarNameFromPath } from '../env/config-env-overrides.js'; /** Claude-specific model aliases that are not valid for other providers */ const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']); @@ -107,20 +108,6 @@ function denormalizeProviderProfiles( }])) as Record }>; } -/** Create default global configuration (fresh instance each call) */ -function createDefaultGlobalConfig(): GlobalConfig { - return { - language: DEFAULT_LANGUAGE, - defaultPiece: 'default', - logLevel: 'info', - provider: 'claude', - enableBuiltinPieces: true, - interactivePreviewMovements: 3, - concurrency: 1, - taskPollIntervalMs: 500, - }; -} - /** * Manages global configuration loading and caching. * Singleton — use GlobalConfigManager.getInstance(). @@ -154,23 +141,34 @@ export class GlobalConfigManager { return this.cachedConfig; } const configPath = getGlobalConfigPath(); - if (!existsSync(configPath)) { - const defaultConfig = createDefaultGlobalConfig(); - this.cachedConfig = defaultConfig; - return defaultConfig; + + const rawConfig: Record = {}; + if (existsSync(configPath)) { + const content = readFileSync(configPath, 'utf-8'); + const parsedRaw = parseYaml(content); + if (parsedRaw && typeof parsedRaw === 'object' && !Array.isArray(parsedRaw)) { + Object.assign(rawConfig, parsedRaw as Record); + } else if (parsedRaw != null) { + throw new Error('Configuration error: ~/.takt/config.yaml must be a YAML object.'); + } } - const content = readFileSync(configPath, 'utf-8'); - const raw = parseYaml(content); - const parsed = GlobalConfigSchema.parse(raw); + + applyGlobalConfigEnvOverrides(rawConfig); + + const parsed = GlobalConfigSchema.parse(rawConfig); const config: GlobalConfig = { language: parsed.language, - defaultPiece: parsed.default_piece, logLevel: parsed.log_level, provider: parsed.provider, model: parsed.model, observability: parsed.observability ? { providerEvents: parsed.observability.provider_events, } : undefined, + analytics: parsed.analytics ? { + enabled: parsed.analytics.enabled, + eventsPath: parsed.analytics.events_path, + retentionDays: parsed.analytics.retention_days, + } : undefined, worktreeDir: parsed.worktree_dir, autoPr: parsed.auto_pr, disabledBuiltins: parsed.disabled_builtins, @@ -204,6 +202,7 @@ export class GlobalConfigManager { runAbort: parsed.notification_sound_events.run_abort, } : undefined, interactivePreviewMovements: parsed.interactive_preview_movements, + verbose: parsed.verbose, concurrency: parsed.concurrency, taskPollIntervalMs: parsed.task_poll_interval_ms, }; @@ -217,7 +216,6 @@ export class GlobalConfigManager { const configPath = getGlobalConfigPath(); const raw: Record = { language: config.language, - default_piece: config.defaultPiece, log_level: config.logLevel, provider: config.provider, }; @@ -229,6 +227,15 @@ export class GlobalConfigManager { provider_events: config.observability.providerEvents, }; } + if (config.analytics) { + const analyticsRaw: Record = {}; + if (config.analytics.enabled !== undefined) analyticsRaw.enabled = config.analytics.enabled; + if (config.analytics.eventsPath) analyticsRaw.events_path = config.analytics.eventsPath; + if (config.analytics.retentionDays !== undefined) analyticsRaw.retention_days = config.analytics.retentionDays; + if (Object.keys(analyticsRaw).length > 0) { + raw.analytics = analyticsRaw; + } + } if (config.worktreeDir) { raw.worktree_dir = config.worktreeDir; } @@ -316,6 +323,9 @@ export class GlobalConfigManager { if (config.interactivePreviewMovements !== undefined) { raw.interactive_preview_movements = config.interactivePreviewMovements; } + if (config.verbose !== undefined) { + raw.verbose = config.verbose; + } if (config.concurrency !== undefined && config.concurrency > 1) { raw.concurrency = config.concurrency; } @@ -383,7 +393,7 @@ export function setProvider(provider: 'claude' | 'codex' | 'opencode'): void { * Priority: TAKT_ANTHROPIC_API_KEY env var > config.yaml > undefined (CLI auth fallback) */ export function resolveAnthropicApiKey(): string | undefined { - const envKey = process.env['TAKT_ANTHROPIC_API_KEY']; + const envKey = process.env[envVarNameFromPath('anthropic_api_key')]; if (envKey) return envKey; try { @@ -399,7 +409,7 @@ export function resolveAnthropicApiKey(): string | undefined { * Priority: TAKT_OPENAI_API_KEY env var > config.yaml > undefined (CLI auth fallback) */ export function resolveOpenaiApiKey(): string | undefined { - const envKey = process.env['TAKT_OPENAI_API_KEY']; + const envKey = process.env[envVarNameFromPath('openai_api_key')]; if (envKey) return envKey; try { @@ -415,7 +425,7 @@ export function resolveOpenaiApiKey(): string | undefined { * Priority: TAKT_CODEX_CLI_PATH env var > config.yaml > undefined (SDK vendored binary fallback) */ export function resolveCodexCliPath(): string | undefined { - const envPath = process.env['TAKT_CODEX_CLI_PATH']; + const envPath = process.env[envVarNameFromPath('codex_cli_path')]; if (envPath !== undefined) { return validateCodexCliPath(envPath, 'TAKT_CODEX_CLI_PATH'); } @@ -437,7 +447,7 @@ export function resolveCodexCliPath(): string | undefined { * Priority: TAKT_OPENCODE_API_KEY env var > config.yaml > undefined */ export function resolveOpencodeApiKey(): string | undefined { - const envKey = process.env['TAKT_OPENCODE_API_KEY']; + const envKey = process.env[envVarNameFromPath('opencode_api_key')]; if (envKey) return envKey; try { @@ -447,4 +457,3 @@ export function resolveOpencodeApiKey(): string | undefined { return undefined; } } - diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index b51034d..b232603 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -30,6 +30,11 @@ export { resetPieceCategories, } from './pieceCategories.js'; +export { + resetGlobalConfigToTemplate, + type ResetGlobalConfigResult, +} from './resetConfig.js'; + export { needsLanguageSetup, promptLanguageSelection, diff --git a/src/infra/config/global/pieceCategories.ts b/src/infra/config/global/pieceCategories.ts index b189ab1..b927ae1 100644 --- a/src/infra/config/global/pieceCategories.ts +++ b/src/infra/config/global/pieceCategories.ts @@ -7,7 +7,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { getGlobalConfigDir } from '../paths.js'; -import { loadGlobalConfig } from './globalConfig.js'; +import { resolvePieceConfigValue } from '../resolvePieceConfigValue.js'; const INITIAL_USER_CATEGORIES_CONTENT = 'piece_categories: {}\n'; @@ -16,10 +16,10 @@ function getDefaultPieceCategoriesPath(): string { } /** Get the path to the user's piece categories file. */ -export function getPieceCategoriesPath(): string { - const config = loadGlobalConfig(); - if (config.pieceCategoriesFile) { - return config.pieceCategoriesFile; +export function getPieceCategoriesPath(cwd: string): string { + const pieceCategoriesFile = resolvePieceConfigValue(cwd, 'pieceCategoriesFile'); + if (pieceCategoriesFile) { + return pieceCategoriesFile; } return getDefaultPieceCategoriesPath(); } @@ -27,8 +27,8 @@ export function getPieceCategoriesPath(): string { /** * Reset user categories overlay file to initial content. */ -export function resetPieceCategories(): void { - const userPath = getPieceCategoriesPath(); +export function resetPieceCategories(cwd: string): void { + const userPath = getPieceCategoriesPath(cwd); const dir = dirname(userPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); diff --git a/src/infra/config/global/resetConfig.ts b/src/infra/config/global/resetConfig.ts new file mode 100644 index 0000000..7687884 --- /dev/null +++ b/src/infra/config/global/resetConfig.ts @@ -0,0 +1,71 @@ +import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import type { Language } from '../../../core/models/index.js'; +import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; +import { getLanguageResourcesDir } from '../../resources/index.js'; +import { getGlobalConfigPath } from '../paths.js'; +import { invalidateGlobalConfigCache } from './globalConfig.js'; + +export interface ResetGlobalConfigResult { + configPath: string; + backupPath?: string; + language: Language; +} + +function detectConfigLanguage(configPath: string): Language { + if (!existsSync(configPath)) return DEFAULT_LANGUAGE; + const raw = readFileSync(configPath, 'utf-8'); + const parsed = parseYaml(raw) as { language?: unknown } | null; + if (parsed && typeof parsed !== 'object') { + throw new Error(`Invalid config format: ${configPath} must be a YAML object.`); + } + const language = parsed?.language; + if (language === undefined) return DEFAULT_LANGUAGE; + if (language === 'ja' || language === 'en') return language; + throw new Error(`Invalid language in ${configPath}: ${String(language)} (expected: ja | en)`); +} + +function formatTimestamp(date: Date): string { + const y = String(date.getFullYear()); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const mm = String(date.getMinutes()).padStart(2, '0'); + const ss = String(date.getSeconds()).padStart(2, '0'); + return `${y}${m}${d}-${hh}${mm}${ss}`; +} + +function resolveBackupPath(configPath: string, timestamp: string): string { + const base = `${configPath}.${timestamp}.old`; + if (!existsSync(base)) return base; + let index = 1; + while (true) { + const candidate = `${base}.${index}`; + if (!existsSync(candidate)) return candidate; + index += 1; + } +} + +export function resetGlobalConfigToTemplate(now = new Date()): ResetGlobalConfigResult { + const configPath = getGlobalConfigPath(); + const configDir = dirname(configPath); + mkdirSync(configDir, { recursive: true }); + + const language = detectConfigLanguage(configPath); + const templatePath = join(getLanguageResourcesDir(language), 'config.yaml'); + if (!existsSync(templatePath)) { + throw new Error(`Builtin config template not found: ${templatePath}`); + } + + let backupPath: string | undefined; + if (existsSync(configPath)) { + backupPath = resolveBackupPath(configPath, formatTimestamp(now)); + renameSync(configPath, backupPath); + } + + copyFileSync(templatePath, configPath); + invalidateGlobalConfigCache(); + + return { configPath, backupPath, language }; +} diff --git a/src/infra/config/index.ts b/src/infra/config/index.ts index 03378b8..87bde56 100644 --- a/src/infra/config/index.ts +++ b/src/infra/config/index.ts @@ -6,3 +6,5 @@ export * from './paths.js'; export * from './loaders/index.js'; export * from './global/index.js'; export * from './project/index.js'; +export * from './resolveConfigValue.js'; +export * from './resolvePieceConfigValue.js'; diff --git a/src/infra/config/loadConfig.ts b/src/infra/config/loadConfig.ts new file mode 100644 index 0000000..c907a6f --- /dev/null +++ b/src/infra/config/loadConfig.ts @@ -0,0 +1,110 @@ +import type { GlobalConfig } from '../../core/models/index.js'; +import type { MovementProviderOptions } from '../../core/models/piece-types.js'; +import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; +import { loadGlobalConfig } from './global/globalConfig.js'; +import { loadProjectConfig } from './project/projectConfig.js'; +import { envVarNameFromPath } from './env/config-env-overrides.js'; + +export interface LoadedConfig extends GlobalConfig { + piece: string; + provider: NonNullable; + verbose: boolean; + providerOptions?: MovementProviderOptions; + providerProfiles?: ProviderPermissionProfiles; +} + +export function loadConfig(projectDir: string): LoadedConfig { + const global = loadGlobalConfig(); + const project = loadProjectConfig(projectDir); + const provider = (project.provider ?? global.provider ?? 'claude') as NonNullable; + + return { + ...global, + piece: project.piece ?? 'default', + provider, + autoPr: project.auto_pr ?? global.autoPr, + model: resolveModel(global, provider), + verbose: resolveVerbose(project.verbose, global.verbose), + providerOptions: mergeProviderOptions(global.providerOptions, project.providerOptions), + providerProfiles: mergeProviderProfiles(global.providerProfiles, project.providerProfiles), + }; +} + +function resolveModel(global: GlobalConfig, provider: GlobalConfig['provider']): string | undefined { + if (!global.model) return undefined; + const globalProvider = global.provider ?? 'claude'; + const resolvedProvider = provider ?? 'claude'; + if (globalProvider !== resolvedProvider) return undefined; + return global.model; +} + +function resolveVerbose(projectVerbose: boolean | undefined, globalVerbose: boolean | undefined): boolean { + const envVerbose = loadEnvBooleanSetting('verbose'); + if (envVerbose !== undefined) return envVerbose; + if (projectVerbose !== undefined) return projectVerbose; + if (globalVerbose !== undefined) return globalVerbose; + return false; +} + +function loadEnvBooleanSetting(configKey: string): boolean | undefined { + const envKey = envVarNameFromPath(configKey); + const raw = process.env[envKey]; + if (raw === undefined) return undefined; + + const normalized = raw.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + + throw new Error(`${envKey} must be one of: true, false`); +} + +function mergeProviderOptions( + globalOptions: MovementProviderOptions | undefined, + projectOptions: MovementProviderOptions | undefined, +): MovementProviderOptions | undefined { + if (!globalOptions && !projectOptions) return undefined; + + const result: MovementProviderOptions = {}; + if (globalOptions?.codex || projectOptions?.codex) { + result.codex = { ...globalOptions?.codex, ...projectOptions?.codex }; + } + if (globalOptions?.opencode || projectOptions?.opencode) { + result.opencode = { ...globalOptions?.opencode, ...projectOptions?.opencode }; + } + if (globalOptions?.claude?.sandbox || projectOptions?.claude?.sandbox) { + result.claude = { + sandbox: { + ...globalOptions?.claude?.sandbox, + ...projectOptions?.claude?.sandbox, + }, + }; + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function mergeProviderProfiles( + globalProfiles: ProviderPermissionProfiles | undefined, + projectProfiles: ProviderPermissionProfiles | undefined, +): ProviderPermissionProfiles | undefined { + if (!globalProfiles && !projectProfiles) return undefined; + + const merged: ProviderPermissionProfiles = { ...(globalProfiles ?? {}) }; + for (const [provider, profile] of Object.entries(projectProfiles ?? {})) { + const key = provider as keyof ProviderPermissionProfiles; + const existing = merged[key]; + if (!existing) { + merged[key] = profile; + continue; + } + merged[key] = { + defaultPermissionMode: profile.defaultPermissionMode, + movementPermissionOverrides: { + ...(existing.movementPermissionOverrides ?? {}), + ...(profile.movementPermissionOverrides ?? {}), + }, + }; + } + + return Object.keys(merged).length > 0 ? merged : undefined; +} diff --git a/src/infra/config/loaders/agentLoader.ts b/src/infra/config/loaders/agentLoader.ts index 483d690..97012cb 100644 --- a/src/infra/config/loaders/agentLoader.ts +++ b/src/infra/config/loaders/agentLoader.ts @@ -16,11 +16,11 @@ import { getBuiltinPiecesDir, isPathSafe, } from '../paths.js'; -import { getLanguage } from '../global/globalConfig.js'; +import { resolveConfigValue } from '../resolveConfigValue.js'; /** Get all allowed base directories for persona prompt files */ -function getAllowedPromptBases(): string[] { - const lang = getLanguage(); +function getAllowedPromptBases(cwd: string): string[] { + const lang = resolveConfigValue(cwd, 'language'); return [ getGlobalPersonasDir(), getGlobalPiecesDir(), @@ -63,14 +63,14 @@ export function listCustomAgents(): string[] { } /** Load agent prompt content. */ -export function loadAgentPrompt(agent: CustomAgentConfig): string { +export function loadAgentPrompt(agent: CustomAgentConfig, cwd: string): string { if (agent.prompt) { return agent.prompt; } if (agent.promptFile) { const promptFile = agent.promptFile; - const isValid = getAllowedPromptBases().some((base) => isPathSafe(base, promptFile)); + const isValid = getAllowedPromptBases(cwd).some((base) => isPathSafe(base, promptFile)); if (!isValid) { throw new Error(`Agent prompt file path is not allowed: ${agent.promptFile}`); } @@ -86,8 +86,8 @@ export function loadAgentPrompt(agent: CustomAgentConfig): string { } /** Load persona prompt from a resolved path. */ -export function loadPersonaPromptFromPath(personaPath: string): string { - const isValid = getAllowedPromptBases().some((base) => isPathSafe(base, personaPath)); +export function loadPersonaPromptFromPath(personaPath: string, cwd: string): string { + const isValid = getAllowedPromptBases(cwd).some((base) => isPathSafe(base, personaPath)); if (!isValid) { throw new Error(`Persona prompt file path is not allowed: ${personaPath}`); } diff --git a/src/infra/config/loaders/index.ts b/src/infra/config/loaders/index.ts index dca855a..81a8af2 100644 --- a/src/infra/config/loaders/index.ts +++ b/src/infra/config/loaders/index.ts @@ -20,6 +20,7 @@ export { } from './pieceLoader.js'; export { + BUILTIN_CATEGORY_NAME, loadDefaultCategories, getDefaultCategoriesPath, getPieceCategories, diff --git a/src/infra/config/loaders/pieceCategories.ts b/src/infra/config/loaders/pieceCategories.ts index 6bbd64b..70410cd 100644 --- a/src/infra/config/loaders/pieceCategories.ts +++ b/src/infra/config/loaders/pieceCategories.ts @@ -10,10 +10,10 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod/v4'; -import { getLanguage, getBuiltinPiecesEnabled, getDisabledBuiltins } from '../global/globalConfig.js'; import { getPieceCategoriesPath } from '../global/pieceCategories.js'; import { getLanguageResourcesDir } from '../../resources/index.js'; import { listBuiltinPieceNames } from './pieceResolver.js'; +import { resolvePieceConfigValues } from '../resolvePieceConfigValue.js'; import type { PieceWithSource } from './pieceResolver.js'; const CategoryConfigSchema = z.object({ @@ -22,6 +22,8 @@ const CategoryConfigSchema = z.object({ others_category_name: z.string().min(1).optional(), }).passthrough(); +export const BUILTIN_CATEGORY_NAME = 'builtin'; + export interface PieceCategoryNode { name: string; pieces: string[]; @@ -32,6 +34,7 @@ export interface CategoryConfig { pieceCategories: PieceCategoryNode[]; builtinPieceCategories: PieceCategoryNode[]; userPieceCategories: PieceCategoryNode[]; + hasUserCategories: boolean; showOthersCategory: boolean; othersCategoryName: string; } @@ -57,7 +60,6 @@ interface RawCategoryConfig { interface ParsedCategoryNode { name: string; pieces: string[]; - hasPieces: boolean; children: ParsedCategoryNode[]; } @@ -97,7 +99,6 @@ function parseCategoryNode( throw new Error(`category "${name}" must be an object in ${sourceLabel} at ${path.join(' > ')}`); } - const hasPieces = Object.prototype.hasOwnProperty.call(raw, 'pieces'); const pieces = parsePieces(raw.pieces, sourceLabel, path); const children: ParsedCategoryNode[] = []; @@ -109,7 +110,7 @@ function parseCategoryNode( children.push(parseCategoryNode(key, value, sourceLabel, [...path, key])); } - return { name, pieces, hasPieces, children }; + return { name, pieces, children }; } function parseCategoryTree(raw: unknown, sourceLabel: string): ParsedCategoryNode[] { @@ -176,38 +177,6 @@ function convertParsedNodes(nodes: ParsedCategoryNode[]): PieceCategoryNode[] { })); } -function mergeCategoryNodes(baseNodes: ParsedCategoryNode[], overlayNodes: ParsedCategoryNode[]): ParsedCategoryNode[] { - const overlayByName = new Map(); - for (const overlayNode of overlayNodes) { - overlayByName.set(overlayNode.name, overlayNode); - } - - const merged: ParsedCategoryNode[] = []; - for (const baseNode of baseNodes) { - const overlayNode = overlayByName.get(baseNode.name); - if (!overlayNode) { - merged.push(baseNode); - continue; - } - - overlayByName.delete(baseNode.name); - - const mergedNode: ParsedCategoryNode = { - name: baseNode.name, - pieces: overlayNode.hasPieces ? overlayNode.pieces : baseNode.pieces, - hasPieces: baseNode.hasPieces || overlayNode.hasPieces, - children: mergeCategoryNodes(baseNode.children, overlayNode.children), - }; - merged.push(mergedNode); - } - - for (const overlayNode of overlayByName.values()) { - merged.push(overlayNode); - } - - return merged; -} - function resolveShowOthersCategory(defaultConfig: ParsedCategoryConfig, userConfig: ParsedCategoryConfig | null): boolean { if (userConfig?.showOthersCategory !== undefined) { return userConfig.showOthersCategory; @@ -232,8 +201,8 @@ function resolveOthersCategoryName(defaultConfig: ParsedCategoryConfig, userConf * Load default categories from builtin resource file. * Returns null if file doesn't exist or has no piece_categories. */ -export function loadDefaultCategories(): CategoryConfig | null { - const lang = getLanguage(); +export function loadDefaultCategories(cwd: string): CategoryConfig | null { + const { language: lang } = resolvePieceConfigValues(cwd, ['language']); const filePath = join(getLanguageResourcesDir(lang), 'piece-categories.yaml'); const parsed = loadCategoryConfigFromPath(filePath, filePath); @@ -249,42 +218,57 @@ export function loadDefaultCategories(): CategoryConfig | null { pieceCategories: builtinPieceCategories, builtinPieceCategories, userPieceCategories: [], + hasUserCategories: false, showOthersCategory, othersCategoryName, }; } /** Get the path to the builtin default categories file. */ -export function getDefaultCategoriesPath(): string { - const lang = getLanguage(); +export function getDefaultCategoriesPath(cwd: string): string { + const { language: lang } = resolvePieceConfigValues(cwd, ['language']); return join(getLanguageResourcesDir(lang), 'piece-categories.yaml'); } +function buildSeparatedCategories( + userCategories: PieceCategoryNode[], + builtinCategories: PieceCategoryNode[], +): PieceCategoryNode[] { + const builtinWrapper: PieceCategoryNode = { + name: BUILTIN_CATEGORY_NAME, + pieces: [], + children: builtinCategories, + }; + return [...userCategories, builtinWrapper]; +} + /** * Get effective piece categories configuration. * Built from builtin categories and optional user overlay. */ -export function getPieceCategories(): CategoryConfig | null { - const defaultPath = getDefaultCategoriesPath(); +export function getPieceCategories(cwd: string): CategoryConfig | null { + const defaultPath = getDefaultCategoriesPath(cwd); const defaultConfig = loadCategoryConfigFromPath(defaultPath, defaultPath); if (!defaultConfig?.pieceCategories) { return null; } - const userPath = getPieceCategoriesPath(); + const userPath = getPieceCategoriesPath(cwd); const userConfig = loadCategoryConfigFromPath(userPath, userPath); - const merged = userConfig?.pieceCategories - ? mergeCategoryNodes(defaultConfig.pieceCategories, userConfig.pieceCategories) - : defaultConfig.pieceCategories; - const builtinPieceCategories = convertParsedNodes(defaultConfig.pieceCategories); const userPieceCategories = convertParsedNodes(userConfig?.pieceCategories ?? []); + const hasUserCategories = userPieceCategories.length > 0; + + const pieceCategories = hasUserCategories + ? buildSeparatedCategories(userPieceCategories, builtinPieceCategories) + : builtinPieceCategories; return { - pieceCategories: convertParsedNodes(merged), + pieceCategories, builtinPieceCategories, userPieceCategories, + hasUserCategories, showOthersCategory: resolveShowOthersCategory(defaultConfig, userConfig), othersCategoryName: resolveOthersCategoryName(defaultConfig, userConfig), }; @@ -376,14 +360,16 @@ function appendOthersCategory( export function buildCategorizedPieces( allPieces: Map, config: CategoryConfig, + cwd: string, ): CategorizedPieces { + const globalConfig = resolvePieceConfigValues(cwd, ['enableBuiltinPieces', 'disabledBuiltins']); const ignoreMissing = new Set(); - if (!getBuiltinPiecesEnabled()) { - for (const name of listBuiltinPieceNames({ includeDisabled: true })) { + if (globalConfig.enableBuiltinPieces === false) { + for (const name of listBuiltinPieceNames(cwd, { includeDisabled: true })) { ignoreMissing.add(name); } } else { - for (const name of getDisabledBuiltins()) { + for (const name of (globalConfig.disabledBuiltins ?? [])) { ignoreMissing.add(name); } } diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index cf39b40..fbedd07 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -11,7 +11,7 @@ import { parse as parseYaml } from 'yaml'; import type { z } from 'zod'; import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js'; import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge, ArpeggioMovementConfig, ArpeggioMergeMovementConfig, TeamLeaderConfig } from '../../../core/models/index.js'; -import { getLanguage } from '../global/globalConfig.js'; +import { resolvePieceConfigValue } from '../resolvePieceConfigValue.js'; import { type PieceSections, type FacetResolutionContext, @@ -428,9 +428,9 @@ export function normalizePieceConfig( /** * Load a piece from a YAML file. * @param filePath Path to the piece YAML file - * @param projectDir Optional project directory for 3-layer facet resolution + * @param projectDir Project directory for 3-layer facet resolution */ -export function loadPieceFromFile(filePath: string, projectDir?: string): PieceConfig { +export function loadPieceFromFile(filePath: string, projectDir: string): PieceConfig { if (!existsSync(filePath)) { throw new Error(`Piece file not found: ${filePath}`); } @@ -439,7 +439,7 @@ export function loadPieceFromFile(filePath: string, projectDir?: string): PieceC const pieceDir = dirname(filePath); const context: FacetResolutionContext = { - lang: getLanguage(), + lang: resolvePieceConfigValue(projectDir, 'language'), projectDir, }; diff --git a/src/infra/config/loaders/pieceResolver.ts b/src/infra/config/loaders/pieceResolver.ts index 7a60f9d..5b62385 100644 --- a/src/infra/config/loaders/pieceResolver.ts +++ b/src/infra/config/loaders/pieceResolver.ts @@ -10,7 +10,7 @@ import { join, resolve, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; 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 { resolvePieceConfigValues } from '../resolvePieceConfigValue.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { loadPieceFromFile } from './pieceParser.js'; @@ -23,10 +23,11 @@ export interface PieceWithSource { source: PieceSource; } -export function listBuiltinPieceNames(options?: { includeDisabled?: boolean }): string[] { - const lang = getLanguage(); +export function listBuiltinPieceNames(cwd: string, options?: { includeDisabled?: boolean }): string[] { + const config = resolvePieceConfigValues(cwd, ['language', 'disabledBuiltins']); + const lang = config.language; const dir = getBuiltinPiecesDir(lang); - const disabled = options?.includeDisabled ? undefined : getDisabledBuiltins(); + const disabled = options?.includeDisabled ? undefined : (config.disabledBuiltins ?? []); const names = new Set(); for (const entry of iteratePieceDir(dir, 'builtin', disabled)) { names.add(entry.name); @@ -35,10 +36,11 @@ export function listBuiltinPieceNames(options?: { includeDisabled?: boolean }): } /** Get builtin piece by name */ -export function getBuiltinPiece(name: string, projectCwd?: string): PieceConfig | null { - if (!getBuiltinPiecesEnabled()) return null; - const lang = getLanguage(); - const disabled = getDisabledBuiltins(); +export function getBuiltinPiece(name: string, projectCwd: string): PieceConfig | null { + const config = resolvePieceConfigValues(projectCwd, ['enableBuiltinPieces', 'language', 'disabledBuiltins']); + if (config.enableBuiltinPieces === false) return null; + const lang = config.language; + const disabled = config.disabledBuiltins ?? []; if (disabled.includes(name)) return null; const builtinDir = getBuiltinPiecesDir(lang); @@ -69,7 +71,7 @@ function resolvePath(pathInput: string, basePath: string): string { function loadPieceFromPath( filePath: string, basePath: string, - projectCwd?: string, + projectCwd: string, ): PieceConfig | null { const resolvedPath = resolvePath(filePath, basePath); if (!existsSync(resolvedPath)) { @@ -371,10 +373,11 @@ function* iteratePieceDir( /** Get the 3-layer directory list (builtin → user → project-local) */ function getPieceDirs(cwd: string): { dir: string; source: PieceSource; disabled?: string[] }[] { - const disabled = getDisabledBuiltins(); - const lang = getLanguage(); + const config = resolvePieceConfigValues(cwd, ['enableBuiltinPieces', 'language', 'disabledBuiltins']); + const disabled = config.disabledBuiltins ?? []; + const lang = config.language; const dirs: { dir: string; source: PieceSource; disabled?: string[] }[] = []; - if (getBuiltinPiecesEnabled()) { + if (config.enableBuiltinPieces !== false) { dirs.push({ dir: getBuiltinPiecesDir(lang), disabled, source: 'builtin' }); } dirs.push({ dir: getGlobalPiecesDir(), source: 'user' }); diff --git a/src/infra/config/loaders/resource-resolver.ts b/src/infra/config/loaders/resource-resolver.ts index 05a2765..f819944 100644 --- a/src/infra/config/loaders/resource-resolver.ts +++ b/src/infra/config/loaders/resource-resolver.ts @@ -1,50 +1,54 @@ /** * Resource resolution helpers for piece YAML parsing. * - * Resolves file paths, content references, and persona specs - * from piece-level section maps. Supports 3-layer facet resolution - * (project → user → builtin). + * Facade: delegates to faceted-prompting/resolve.ts and re-exports + * its types/functions. resolveFacetPath and resolveFacetByName build + * TAKT-specific candidate directories then delegate to the generic + * implementation. */ -import { readFileSync, existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join, basename } from 'node:path'; import type { Language } from '../../../core/models/index.js'; import type { FacetType } from '../paths.js'; import { getProjectFacetDir, getGlobalFacetDir, getBuiltinFacetDir } from '../paths.js'; -/** Context for 3-layer facet resolution. */ +import { + resolveFacetPath as resolveFacetPathGeneric, + resolveFacetByName as resolveFacetByNameGeneric, + resolveRefToContent as resolveRefToContentGeneric, + resolveRefList as resolveRefListGeneric, + resolvePersona as resolvePersonaGeneric, +} from '../../../faceted-prompting/index.js'; + +// Re-export types and pure functions that need no TAKT wrapping +export type { PieceSections } from '../../../faceted-prompting/index.js'; +export { + isResourcePath, + resolveResourcePath, + resolveResourceContent, + resolveSectionMap, + extractPersonaDisplayName, +} from '../../../faceted-prompting/index.js'; + +/** Context for 3-layer facet resolution (TAKT-specific). */ export interface FacetResolutionContext { projectDir?: string; lang: Language; } -/** Pre-resolved section maps passed to movement normalization. */ -export interface PieceSections { - /** Persona name → file path (raw, not content-resolved) */ - personas?: Record; - /** Policy name → resolved content */ - resolvedPolicies?: Record; - /** Knowledge name → resolved content */ - resolvedKnowledge?: Record; - /** Instruction name → resolved content */ - resolvedInstructions?: Record; - /** Report format name → resolved content */ - resolvedReportFormats?: Record; -} - /** - * Check if a spec looks like a resource path (vs. a facet name). - * Paths start with './', '../', '/', '~' or end with '.md'. + * Build TAKT-specific candidate directories for a facet type. */ -export function isResourcePath(spec: string): boolean { - return ( - spec.startsWith('./') || - spec.startsWith('../') || - spec.startsWith('/') || - spec.startsWith('~') || - spec.endsWith('.md') - ); +function buildCandidateDirs( + facetType: FacetType, + context: FacetResolutionContext, +): string[] { + const dirs: string[] = []; + if (context.projectDir) { + dirs.push(getProjectFacetDir(context.projectDir, facetType)); + } + dirs.push(getGlobalFacetDir(facetType)); + dirs.push(getBuiltinFacetDir(context.lang, facetType)); + return dirs; } /** @@ -62,20 +66,7 @@ export function resolveFacetPath( facetType: FacetType, context: FacetResolutionContext, ): string | undefined { - const candidateDirs = [ - ...(context.projectDir ? [getProjectFacetDir(context.projectDir, facetType)] : []), - getGlobalFacetDir(facetType), - getBuiltinFacetDir(context.lang, facetType), - ]; - - for (const dir of candidateDirs) { - const filePath = join(dir, `${name}.md`); - if (existsSync(filePath)) { - return filePath; - } - } - - return undefined; + return resolveFacetPathGeneric(name, buildCandidateDirs(facetType, context)); } /** @@ -88,33 +79,7 @@ export function resolveFacetByName( facetType: FacetType, context: FacetResolutionContext, ): string | undefined { - const filePath = resolveFacetPath(name, facetType, context); - if (filePath) { - return readFileSync(filePath, 'utf-8'); - } - return undefined; -} - -/** Resolve a resource spec to an absolute file path. */ -export function resolveResourcePath(spec: string, pieceDir: string): string { - if (spec.startsWith('./')) return join(pieceDir, spec.slice(2)); - if (spec.startsWith('~')) return join(homedir(), spec.slice(1)); - if (spec.startsWith('/')) return spec; - return join(pieceDir, spec); -} - -/** - * Resolve a resource spec to its file content. - * If the spec ends with .md and the file exists, returns file content. - * Otherwise returns the spec as-is (treated as inline content). - */ -export function resolveResourceContent(spec: string | undefined, pieceDir: string): string | undefined { - if (spec == null) return undefined; - if (spec.endsWith('.md')) { - const resolved = resolveResourcePath(spec, pieceDir); - if (existsSync(resolved)) return readFileSync(resolved, 'utf-8'); - } - return spec; + return resolveFacetByNameGeneric(name, buildCandidateDirs(facetType, context)); } /** @@ -130,19 +95,10 @@ export function resolveRefToContent( facetType?: FacetType, context?: FacetResolutionContext, ): string | undefined { - const mapped = resolvedMap?.[ref]; - if (mapped) return mapped; - - if (isResourcePath(ref)) { - return resolveResourceContent(ref, pieceDir); - } - - if (facetType && context) { - const facetContent = resolveFacetByName(ref, facetType, context); - if (facetContent !== undefined) return facetContent; - } - - return resolveResourceContent(ref, pieceDir); + const candidateDirs = facetType && context + ? buildCandidateDirs(facetType, context) + : undefined; + return resolveRefToContentGeneric(ref, resolvedMap, pieceDir, candidateDirs); } /** Resolve multiple references to content strings (for fields that accept string | string[]). */ @@ -153,69 +109,21 @@ export function resolveRefList( facetType?: FacetType, context?: FacetResolutionContext, ): string[] | undefined { - if (refs == null) return undefined; - const list = Array.isArray(refs) ? refs : [refs]; - const contents: string[] = []; - for (const ref of list) { - const content = resolveRefToContent(ref, resolvedMap, pieceDir, facetType, context); - if (content) contents.push(content); - } - return contents.length > 0 ? contents : undefined; -} - -/** Resolve a piece-level section map (each value resolved to file content or inline). */ -export function resolveSectionMap( - raw: Record | undefined, - pieceDir: string, -): Record | undefined { - if (!raw) return undefined; - const resolved: Record = {}; - for (const [name, value] of Object.entries(raw)) { - const content = resolveResourceContent(value, pieceDir); - if (content) resolved[name] = content; - } - return Object.keys(resolved).length > 0 ? resolved : undefined; -} - -/** Extract display name from persona path (e.g., "coder.md" → "coder"). */ -export function extractPersonaDisplayName(personaPath: string): string { - return basename(personaPath, '.md'); + const candidateDirs = facetType && context + ? buildCandidateDirs(facetType, context) + : undefined; + return resolveRefListGeneric(refs, resolvedMap, pieceDir, candidateDirs); } /** Resolve persona from YAML field to spec + absolute path. */ export function resolvePersona( rawPersona: string | undefined, - sections: PieceSections, + sections: import('../../../faceted-prompting/index.js').PieceSections, pieceDir: string, context?: FacetResolutionContext, ): { personaSpec?: string; personaPath?: string } { - if (!rawPersona) return {}; - - // If section map has explicit mapping, use it (path-based) - const sectionMapping = sections.personas?.[rawPersona]; - if (sectionMapping) { - const resolved = resolveResourcePath(sectionMapping, pieceDir); - const personaPath = existsSync(resolved) ? resolved : undefined; - return { personaSpec: sectionMapping, personaPath }; - } - - // If rawPersona is a path, resolve it directly - if (isResourcePath(rawPersona)) { - const resolved = resolveResourcePath(rawPersona, pieceDir); - const personaPath = existsSync(resolved) ? resolved : undefined; - return { personaSpec: rawPersona, personaPath }; - } - - // Name-based: try 3-layer resolution to find the persona file - if (context) { - const filePath = resolveFacetPath(rawPersona, 'personas', context); - if (filePath) { - return { personaSpec: rawPersona, personaPath: filePath }; - } - } - - // Fallback: try as relative path from pieceDir (backward compat) - const resolved = resolveResourcePath(rawPersona, pieceDir); - const personaPath = existsSync(resolved) ? resolved : undefined; - return { personaSpec: rawPersona, personaPath }; + const candidateDirs = context + ? buildCandidateDirs('personas', context) + : undefined; + return resolvePersonaGeneric(rawPersona, sections, pieceDir, candidateDirs); } diff --git a/src/infra/config/paths.ts b/src/infra/config/paths.ts index c806d25..214950b 100644 --- a/src/infra/config/paths.ts +++ b/src/infra/config/paths.ts @@ -11,8 +11,12 @@ import { existsSync, mkdirSync } from 'node:fs'; import type { Language } from '../../core/models/index.js'; import { getLanguageResourcesDir } from '../resources/index.js'; +import type { FacetKind } from '../../faceted-prompting/index.js'; + /** Facet types used in layer resolution */ -export type FacetType = 'personas' | 'policies' | 'knowledge' | 'instructions' | 'output-contracts'; +export type { FacetKind as FacetType } from '../../faceted-prompting/index.js'; + +type FacetType = FacetKind; /** Get takt global config directory (~/.takt or TAKT_CONFIG_DIR) */ export function getGlobalConfigDir(): string { @@ -113,11 +117,12 @@ export { loadProjectConfig, saveProjectConfig, updateProjectConfig, - getCurrentPiece, setCurrentPiece, - isVerboseMode, type ProjectLocalConfig, } from './project/projectConfig.js'; +export { + isVerboseMode, +} from './project/resolvedSettings.js'; // Re-export session storage functions export { diff --git a/src/infra/config/project/index.ts b/src/infra/config/project/index.ts index cb88e8d..db97287 100644 --- a/src/infra/config/project/index.ts +++ b/src/infra/config/project/index.ts @@ -6,12 +6,12 @@ export { loadProjectConfig, saveProjectConfig, updateProjectConfig, - getCurrentPiece, setCurrentPiece, - isVerboseMode, - type PermissionMode, type ProjectLocalConfig, } from './projectConfig.js'; +export { + isVerboseMode, +} from './resolvedSettings.js'; export { writeFileAtomic, diff --git a/src/infra/config/project/projectConfig.ts b/src/infra/config/project/projectConfig.ts index 83e7b8a..2671ba9 100644 --- a/src/infra/config/project/projectConfig.ts +++ b/src/infra/config/project/projectConfig.ts @@ -8,15 +8,16 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { parse, stringify } from 'yaml'; import { copyProjectResourcesToDir } from '../../resources/index.js'; -import type { PermissionMode, ProjectLocalConfig } from '../types.js'; +import type { ProjectLocalConfig } from '../types.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; +import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js'; +import { normalizeProviderOptions } from '../loaders/pieceParser.js'; -export type { PermissionMode, ProjectLocalConfig }; +export type { ProjectLocalConfig } from '../types.js'; /** Default project configuration */ const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = { piece: 'default', - permissionMode: 'default', }; /** @@ -63,21 +64,34 @@ function denormalizeProviderProfiles(profiles: ProviderPermissionProfiles | unde export function loadProjectConfig(projectDir: string): ProjectLocalConfig { const configPath = getConfigPath(projectDir); - if (!existsSync(configPath)) { - return { ...DEFAULT_PROJECT_CONFIG }; + const parsedConfig: Record = {}; + if (existsSync(configPath)) { + try { + const content = readFileSync(configPath, 'utf-8'); + const parsed = (parse(content) as Record | null) ?? {}; + Object.assign(parsedConfig, parsed); + } catch { + return { ...DEFAULT_PROJECT_CONFIG }; + } } - try { - const content = readFileSync(configPath, 'utf-8'); - const parsed = (parse(content) as ProjectLocalConfig | null) ?? {}; - return { - ...DEFAULT_PROJECT_CONFIG, - ...parsed, - providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record }> | undefined), - }; - } catch { - return { ...DEFAULT_PROJECT_CONFIG }; - } + applyProjectConfigEnvOverrides(parsedConfig); + + return { + ...DEFAULT_PROJECT_CONFIG, + ...(parsedConfig as ProjectLocalConfig), + providerOptions: normalizeProviderOptions(parsedConfig.provider_options as { + codex?: { network_access?: boolean }; + opencode?: { network_access?: boolean }; + claude?: { + sandbox?: { + allow_unsandboxed_commands?: boolean; + excluded_commands?: string[]; + }; + }; + } | undefined), + providerProfiles: normalizeProviderProfiles(parsedConfig.provider_profiles as Record }> | undefined), + }; } /** @@ -103,6 +117,7 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig delete savePayload.provider_profiles; } delete savePayload.providerProfiles; + delete savePayload.providerOptions; const content = stringify(savePayload, { indent: 2 }); writeFileSync(configPath, content, 'utf-8'); @@ -121,25 +136,9 @@ export function updateProjectConfig( saveProjectConfig(projectDir, config); } -/** - * Get current piece from project config - */ -export function getCurrentPiece(projectDir: string): string { - const config = loadProjectConfig(projectDir); - return config.piece || 'default'; -} - /** * Set current piece in project config */ export function setCurrentPiece(projectDir: string, piece: string): void { updateProjectConfig(projectDir, 'piece', piece); } - -/** - * Get verbose mode from project config - */ -export function isVerboseMode(projectDir: string): boolean { - const config = loadProjectConfig(projectDir); - return config.verbose === true; -} diff --git a/src/infra/config/project/resolvedSettings.ts b/src/infra/config/project/resolvedSettings.ts new file mode 100644 index 0000000..e514c5d --- /dev/null +++ b/src/infra/config/project/resolvedSettings.ts @@ -0,0 +1,32 @@ +import { envVarNameFromPath } from '../env/config-env-overrides.js'; +import { loadConfig } from '../loadConfig.js'; + +function resolveValue( + envValue: T | undefined, + localValue: T | undefined, + globalValue: T | undefined, + defaultValue: T, +): T { + if (envValue !== undefined) return envValue; + if (localValue !== undefined) return localValue; + if (globalValue !== undefined) return globalValue; + return defaultValue; +} + +function loadEnvBooleanSetting(configKey: string): boolean | undefined { + const envKey = envVarNameFromPath(configKey); + const raw = process.env[envKey]; + if (raw === undefined) return undefined; + + const normalized = raw.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + + throw new Error(`${envKey} must be one of: true, false`); +} + +export function isVerboseMode(projectDir: string): boolean { + const envValue = loadEnvBooleanSetting('verbose'); + const config = loadConfig(projectDir); + return resolveValue(envValue, undefined, config.verbose, false); +} diff --git a/src/infra/config/resolveConfigValue.ts b/src/infra/config/resolveConfigValue.ts new file mode 100644 index 0000000..8f7d4f9 --- /dev/null +++ b/src/infra/config/resolveConfigValue.ts @@ -0,0 +1,22 @@ +import { loadConfig, type LoadedConfig } from './loadConfig.js'; + +export type ConfigParameterKey = keyof LoadedConfig; + +export function resolveConfigValue( + projectDir: string, + key: K, +): LoadedConfig[K] { + return loadConfig(projectDir)[key]; +} + +export function resolveConfigValues( + projectDir: string, + keys: readonly K[], +): Pick { + const config = loadConfig(projectDir); + const result = {} as Pick; + for (const key of keys) { + result[key] = config[key]; + } + return result; +} diff --git a/src/infra/config/resolvePieceConfigValue.ts b/src/infra/config/resolvePieceConfigValue.ts new file mode 100644 index 0000000..98b0375 --- /dev/null +++ b/src/infra/config/resolvePieceConfigValue.ts @@ -0,0 +1,17 @@ +import type { ConfigParameterKey } from './resolveConfigValue.js'; +import { resolveConfigValue, resolveConfigValues } from './resolveConfigValue.js'; +import type { LoadedConfig } from './loadConfig.js'; + +export function resolvePieceConfigValue( + projectDir: string, + key: K, +): LoadedConfig[K] { + return resolveConfigValue(projectDir, key); +} + +export function resolvePieceConfigValues( + projectDir: string, + keys: readonly K[], +): Pick { + return resolveConfigValues(projectDir, keys); +} diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index f7a31d7..e5d659a 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -2,40 +2,27 @@ * Config module type definitions */ -import type { PieceCategoryConfigNode } from '../../core/models/schemas.js'; import type { MovementProviderOptions } from '../../core/models/piece-types.js'; import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; -/** Permission mode for the project - * - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts) - * - sacrifice-my-pc: Auto-approves all permission requests (bypassPermissions) - * - * Note: 'confirm' mode is planned but not yet implemented - */ -export type PermissionMode = 'default' | 'sacrifice-my-pc'; - /** Project configuration stored in .takt/config.yaml */ export interface ProjectLocalConfig { /** Current piece name */ piece?: string; /** Provider selection for agent runtime */ - provider?: 'claude' | 'codex' | 'opencode'; - /** Permission mode setting */ - permissionMode?: PermissionMode; + provider?: 'claude' | 'codex' | 'opencode' | 'mock'; + /** Auto-create PR after worktree execution */ + auto_pr?: boolean; /** Verbose output mode */ verbose?: boolean; /** Provider-specific options (overrides global, overridden by piece/movement) */ provider_options?: MovementProviderOptions; + /** Provider-specific options (camelCase alias) */ + providerOptions?: MovementProviderOptions; /** Provider-specific permission profiles (project-level override) */ provider_profiles?: ProviderPermissionProfiles; /** Provider-specific permission profiles (camelCase alias) */ providerProfiles?: ProviderPermissionProfiles; - /** Piece categories (name -> piece list) */ - piece_categories?: Record; - /** Show uncategorized pieces under Others category */ - show_others_category?: boolean; - /** Display name for Others category */ - others_category_name?: string; /** Custom settings */ [key: string]: unknown; } diff --git a/src/infra/github/index.ts b/src/infra/github/index.ts index b085622..21e59e2 100644 --- a/src/infra/github/index.ts +++ b/src/infra/github/index.ts @@ -14,4 +14,5 @@ export { createIssue, } from './issue.js'; -export { pushBranch, createPullRequest, buildPrBody } from './pr.js'; +export type { ExistingPr } from './pr.js'; +export { pushBranch, createPullRequest, buildPrBody, findExistingPr, commentOnPr } from './pr.js'; diff --git a/src/infra/github/pr.ts b/src/infra/github/pr.ts index 3b366bd..08b1668 100644 --- a/src/infra/github/pr.ts +++ b/src/infra/github/pr.ts @@ -13,6 +13,54 @@ export type { CreatePrOptions, CreatePrResult }; const log = createLogger('github-pr'); +export interface ExistingPr { + number: number; + url: string; +} + +/** + * Find an open PR for the given branch. + * Returns undefined if no PR exists. + */ +export function findExistingPr(cwd: string, branch: string): ExistingPr | undefined { + const ghStatus = checkGhCli(); + if (!ghStatus.available) return undefined; + + try { + const output = execFileSync( + 'gh', ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number,url', '--limit', '1'], + { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, + ); + const prs = JSON.parse(output) as ExistingPr[]; + return prs[0]; + } catch { + return undefined; + } +} + +/** + * Add a comment to an existing PR. + */ +export function commentOnPr(cwd: string, prNumber: number, body: string): CreatePrResult { + const ghStatus = checkGhCli(); + if (!ghStatus.available) { + return { success: false, error: ghStatus.error ?? 'gh CLI is not available' }; + } + + try { + execFileSync('gh', ['pr', 'comment', String(prNumber), '--body', body], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { success: true }; + } catch (err) { + const errorMessage = getErrorMessage(err); + log.error('PR comment failed', { error: errorMessage }); + return { success: false, error: errorMessage }; + } +} + /** * Push a branch to origin. * Throws on failure. diff --git a/src/infra/task/clone.ts b/src/infra/task/clone.ts index 9a2d393..ad4bc22 100644 --- a/src/infra/task/clone.ts +++ b/src/infra/task/clone.ts @@ -10,8 +10,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { execFileSync } from 'node:child_process'; -import { createLogger, slugify } from '../../shared/utils/index.js'; -import { loadGlobalConfig } from '../config/global/globalConfig.js'; +import { createLogger } from '../../shared/utils/index.js'; +import { resolveConfigValue } from '../config/index.js'; import type { WorktreeOptions, WorktreeResult } from './types.js'; export type { WorktreeOptions, WorktreeResult }; @@ -36,11 +36,11 @@ export class CloneManager { * Returns the configured worktree_dir (resolved to absolute), or ../ */ private static resolveCloneBaseDir(projectDir: string): string { - const globalConfig = loadGlobalConfig(); - if (globalConfig.worktreeDir) { - return path.isAbsolute(globalConfig.worktreeDir) - ? globalConfig.worktreeDir - : path.resolve(projectDir, globalConfig.worktreeDir); + const worktreeDir = resolveConfigValue(projectDir, 'worktreeDir'); + if (worktreeDir) { + return path.isAbsolute(worktreeDir) + ? worktreeDir + : path.resolve(projectDir, worktreeDir); } return path.join(projectDir, '..', 'takt-worktree'); } @@ -48,7 +48,7 @@ export class CloneManager { /** Resolve the clone path based on options and global config */ private static resolveClonePath(projectDir: string, options: WorktreeOptions): string { const timestamp = CloneManager.generateTimestamp(); - const slug = slugify(options.taskSlug); + const slug = options.taskSlug; let dirName: string; if (options.issueNumber !== undefined && slug) { @@ -74,7 +74,7 @@ export class CloneManager { return options.branch; } - const slug = slugify(options.taskSlug); + const slug = options.taskSlug; if (options.issueNumber !== undefined && slug) { return `takt/${options.issueNumber}/${slug}`; diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts index c424b75..ae561a4 100644 --- a/src/infra/task/mapper.ts +++ b/src/infra/task/mapper.ts @@ -1,12 +1,9 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { TaskFileSchema, type TaskFileData, type TaskRecord } from './schema.js'; +import { firstLine } from './naming.js'; import type { TaskInfo, TaskListItem } from './types.js'; -function firstLine(content: string): string { - return content.trim().split('\n')[0]?.slice(0, 80) ?? ''; -} - function toDisplayPath(projectDir: string, targetPath: string): string { const relativePath = path.relative(projectDir, targetPath); if (!relativePath || relativePath.startsWith('..')) { @@ -66,6 +63,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco return { filePath: tasksFile, name: task.name, + slug: task.slug, content, taskDir: task.task_dir, createdAt: task.created_at, @@ -119,6 +117,7 @@ function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRec createdAt: task.created_at, filePath: tasksFile, content: firstLine(resolveTaskContent(projectDir, task)), + summary: task.summary, branch: task.branch, worktreePath: task.worktree_path, startedAt: task.started_at ?? undefined, diff --git a/src/infra/task/naming.ts b/src/infra/task/naming.ts index 649fe8e..f208b48 100644 --- a/src/infra/task/naming.ts +++ b/src/infra/task/naming.ts @@ -5,18 +5,3 @@ export function nowIso(): string { export function firstLine(content: string): string { return content.trim().split('\n')[0]?.slice(0, 80) ?? ''; } - -export function sanitizeTaskName(base: string): string { - const normalized = base - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, ' ') - .trim() - .replace(/\s+/g, '-') - .replace(/-+/g, '-'); - - if (!normalized) { - return `task-${Date.now()}`; - } - - return normalized; -} diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index 20c658c..b8266d1 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -33,7 +33,13 @@ export class TaskRunner { addTask( content: string, - options?: Omit & { content_file?: string; task_dir?: string; worktree_path?: string }, + options?: Omit & { + content_file?: string; + task_dir?: string; + worktree_path?: string; + slug?: string; + summary?: string; + }, ): TaskInfo { return this.lifecycle.addTask(content, options); } diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index 3f5cc52..7f9c607 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -41,6 +41,8 @@ export type TaskFailure = z.infer; export const TaskRecordSchema = TaskExecutionConfigSchema.extend({ name: z.string().min(1), status: TaskStatusSchema, + slug: z.string().optional(), + summary: z.string().optional(), worktree_path: z.string().optional(), content: z.string().min(1).optional(), content_file: z.string().min(1).optional(), diff --git a/src/infra/task/summarize.ts b/src/infra/task/summarize.ts index a8c8041..662b2fb 100644 --- a/src/infra/task/summarize.ts +++ b/src/infra/task/summarize.ts @@ -5,9 +5,9 @@ */ import * as wanakana from 'wanakana'; -import { loadGlobalConfig } from '../config/global/globalConfig.js'; +import { resolveConfigValues } from '../config/index.js'; import { getProvider, type ProviderType } from '../providers/index.js'; -import { createLogger } from '../../shared/utils/index.js'; +import { createLogger, slugify } from '../../shared/utils/index.js'; import { loadTemplate } from '../../shared/prompts/index.js'; import type { SummarizeOptions } from './types.js'; @@ -15,27 +15,12 @@ export type { SummarizeOptions }; const log = createLogger('summarize'); -/** - * Sanitize a string for use as git branch name and directory name. - * Allows only: a-z, 0-9, hyphen. - */ -function sanitizeSlug(input: string, maxLength = 30): string { - return input - .trim() - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-+/, '') - .slice(0, maxLength) - .replace(/-+$/, ''); -} - /** * Convert Japanese text to romaji slug. */ function toRomajiSlug(text: string): string { const romaji = wanakana.toRomaji(text, { customRomajiMapping: {} }); - return sanitizeSlug(romaji); + return slugify(romaji); } /** @@ -53,7 +38,7 @@ export class TaskSummarizer { taskName: string, options: SummarizeOptions, ): Promise { - const globalConfig = loadGlobalConfig(); + const globalConfig = resolveConfigValues(options.cwd, ['branchNameStrategy', 'provider', 'model']); const useLLM = options.useLLM ?? (globalConfig.branchNameStrategy === 'ai'); log.info('Summarizing task name', { taskName, useLLM }); @@ -77,7 +62,7 @@ export class TaskSummarizer { permissionMode: 'readonly', }); - const slug = sanitizeSlug(response.content); + const slug = slugify(response.content); log.info('Task name summarized', { original: taskName, slug }); return slug || 'task'; diff --git a/src/infra/task/taskLifecycleService.ts b/src/infra/task/taskLifecycleService.ts index 6cf6a93..c236103 100644 --- a/src/infra/task/taskLifecycleService.ts +++ b/src/infra/task/taskLifecycleService.ts @@ -3,7 +3,8 @@ import { TaskRecordSchema, type TaskFileData, type TaskRecord, type TaskFailure import type { TaskInfo, TaskResult } from './types.js'; import { toTaskInfo } from './mapper.js'; import { TaskStore } from './store.js'; -import { firstLine, nowIso, sanitizeTaskName } from './naming.js'; +import { firstLine, nowIso } from './naming.js'; +import { slugify } from '../../shared/utils/slug.js'; import { isStaleRunningTask } from './process.js'; import type { TaskStatus } from './schema.js'; @@ -16,13 +17,22 @@ export class TaskLifecycleService { addTask( content: string, - options?: Omit & { content_file?: string; task_dir?: string; worktree_path?: string }, + options?: Omit & { + content_file?: string; + task_dir?: string; + worktree_path?: string; + slug?: string; + summary?: string; + }, ): TaskInfo { const state = this.store.update((current) => { - const name = this.generateTaskName(content, current.tasks.map((task) => task.name)); + const slug = options?.slug ?? slugify(firstLine(content)); + const name = this.generateTaskName(slug, current.tasks.map((task) => task.name)); const contentValue = options?.task_dir ? undefined : content; const record: TaskRecord = TaskRecordSchema.parse({ name, + slug, + summary: options?.summary, status: 'pending', content: contentValue, created_at: nowIso(), @@ -258,8 +268,8 @@ export class TaskLifecycleService { return isStaleRunningTask(task.owner_pid ?? undefined); } - private generateTaskName(content: string, existingNames: string[]): string { - const base = sanitizeTaskName(firstLine(content)); + private generateTaskName(slug: string, existingNames: string[]): string { + const base = slug || `task-${Date.now()}`; let candidate = base; let counter = 1; while (existingNames.includes(candidate)) { diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index aee43fa..42d0f57 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -9,6 +9,7 @@ import type { TaskFailure, TaskStatus } from './schema.js'; export interface TaskInfo { filePath: string; name: string; + slug?: string; content: string; taskDir?: string; createdAt: string; @@ -81,6 +82,7 @@ export interface TaskListItem { createdAt: string; filePath: string; content: string; + summary?: string; branch?: string; worktreePath?: string; data?: TaskFileData; diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 8e36598..31167cc 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -10,7 +10,7 @@ interactive: conversationLabel: "Conversation:" noTranscript: "(No local transcript. Summarize the current session context.)" ui: - intro: "Interactive mode - describe your task. Commands: /go (execute), /play (run now), /cancel (exit)" + intro: "Interactive mode - describe your task. Commands: /go (execute), /play (run now), /resume (load session), /retry (rerun previous order), /cancel (exit)" resume: "Resuming previous session" noConversation: "No conversation yet. Please describe your task first." summarizeFailed: "Failed to summarize conversation. Please try again." @@ -24,6 +24,7 @@ interactive: continue: "Continue editing" cancelled: "Cancelled" playNoTask: "Please specify task content: /play " + retryNoOrder: "No previous order (order.md) found. /retry is only available during retry." personaFallback: "No persona available for the first movement. Falling back to assistant mode." modeSelection: prompt: "Select interactive mode:" @@ -39,8 +40,9 @@ interactive: confirm: "Reference a previous run's results?" prompt: "Select a run to reference:" noRuns: "No previous runs found." + continueNoSession: "No previous assistant session found. Starting a new session." + resumeSessionLoaded: "Session loaded. Subsequent AI calls will use this session." sessionSelector: - confirm: "Choose a previous session?" prompt: "Resume from a recent session?" newSession: "New session" newSessionDescription: "Start a fresh conversation" @@ -75,7 +77,7 @@ piece: # ===== Instruct Mode UI (takt list -> instruct) ===== instruct: ui: - intro: "Instruct mode - describe additional instructions. Commands: /go (summarize), /cancel (exit)" + intro: "Instruct mode - describe additional instructions. Commands: /go (summarize), /retry (rerun previous order), /cancel (exit)" resume: "Resuming previous session" noConversation: "No conversation yet. Please describe your instructions first." summarizeFailed: "Failed to summarize conversation. Please try again." @@ -87,6 +89,7 @@ instruct: saveTask: "Save as Task" continue: "Continue editing" cancelled: "Cancelled" + replayNoOrder: "Previous order (order.md) not found" run: notifyComplete: "Run complete ({total} tasks)" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 6f1d93b..bf0353e 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -10,7 +10,7 @@ interactive: conversationLabel: "会話:" noTranscript: "(ローカル履歴なし。現在のセッション文脈を要約してください。)" ui: - intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /play(即実行), /cancel(終了)" + intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /play(即実行), /resume(セッション読込), /retry(前回の指示書で再実行), /cancel(終了)" resume: "前回のセッションを再開します" noConversation: "まだ会話がありません。まずタスク内容を入力してください。" summarizeFailed: "会話の要約に失敗しました。再度お試しください。" @@ -24,6 +24,7 @@ interactive: continue: "会話を続ける" cancelled: "キャンセルしました" playNoTask: "タスク内容を指定してください: /play <タスク内容>" + retryNoOrder: "前回の指示書(order.md)が見つかりません。/retry はリトライ時のみ使用できます。" personaFallback: "先頭ムーブメントにペルソナがありません。アシスタントモードにフォールバックします。" modeSelection: prompt: "対話モードを選択してください:" @@ -39,8 +40,9 @@ interactive: confirm: "前回の実行結果を参照しますか?" prompt: "参照するrunを選択してください:" noRuns: "前回のrunが見つかりませんでした。" + continueNoSession: "前回のアシスタントセッションが見つかりません。新しいセッションで開始します。" + resumeSessionLoaded: "セッションを読み込みました。以降のAI呼び出しでこのセッションを使用します。" sessionSelector: - confirm: "前回セッションを選択しますか?" prompt: "直近のセッションを引き継ぎますか?" newSession: "新しいセッション" newSessionDescription: "新しい会話を始める" @@ -75,7 +77,7 @@ piece: # ===== Instruct Mode UI (takt list -> instruct) ===== instruct: ui: - intro: "指示モード - 追加指示を入力してください。コマンド: /go(要約), /cancel(終了)" + intro: "指示モード - 追加指示を入力してください。コマンド: /go(要約), /retry(前回の指示書で再実行), /cancel(終了)" resume: "前回のセッションを再開します" noConversation: "まだ会話がありません。まず追加指示を入力してください。" summarizeFailed: "会話の要約に失敗しました。再度お試しください。" @@ -87,6 +89,7 @@ instruct: saveTask: "タスクにつむ" continue: "会話を続ける" cancelled: "キャンセルしました" + replayNoOrder: "前回の指示書(order.md)が見つかりません" run: notifyComplete: "run完了 ({total} tasks)" diff --git a/src/shared/prompts/en/score_instruct_system_prompt.md b/src/shared/prompts/en/score_instruct_system_prompt.md index 881c1d2..a690ee5 100644 --- a/src/shared/prompts/en/score_instruct_system_prompt.md +++ b/src/shared/prompts/en/score_instruct_system_prompt.md @@ -1,7 +1,7 @@ # Additional Instruction Assistant @@ -85,3 +85,11 @@ The user has selected a previous run for reference. Use this information to help - Help the user identify what went wrong or what needs additional work - Suggest concrete follow-up instructions based on the run results {{/if}} +{{#if hasOrderContent}} + +## Previous Order (order.md) + +The instruction document used in the previous execution. Use it as a reference for re-execution. + +{{orderContent}} +{{/if}} diff --git a/src/shared/prompts/en/score_retry_system_prompt.md b/src/shared/prompts/en/score_retry_system_prompt.md index ca89064..1943f4c 100644 --- a/src/shared/prompts/en/score_retry_system_prompt.md +++ b/src/shared/prompts/en/score_retry_system_prompt.md @@ -1,7 +1,7 @@ # Retry Assistant @@ -95,3 +95,11 @@ Logs and reports from the previous execution are available for reference. Use th - Cross-reference the plans and implementation recorded in reports with the actual failure point - If the user wants more details, files in the directories above can be read using the Read tool {{/if}} +{{#if hasOrderContent}} + +## Previous Order (order.md) + +The instruction document used in the previous execution. Use it as a reference for re-execution. + +{{orderContent}} +{{/if}} diff --git a/src/shared/prompts/index.ts b/src/shared/prompts/index.ts index 696fe67..7d1e857 100644 --- a/src/shared/prompts/index.ts +++ b/src/shared/prompts/index.ts @@ -7,12 +7,18 @@ * * Templates are organized in language subdirectories: * {lang}/{name}.md — localized templates + * + * Template engine functions (processConditionals, substituteVariables, + * renderTemplate) are delegated to faceted-prompting. */ import { existsSync, readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { Language } from '../../core/models/types.js'; +import { renderTemplate } from '../../faceted-prompting/index.js'; + +export { renderTemplate } from '../../faceted-prompting/index.js'; /** Cached raw template text (before variable substitution) */ const templateCache = new Map(); @@ -56,66 +62,6 @@ function readTemplate(filePath: string): string { return content; } -/** - * Process {{#if variable}}...{{else}}...{{/if}} conditional blocks. - * - * A variable is truthy when: - * - It is a non-empty string - * - It is boolean true - * - * Nesting is NOT supported (per architecture decision). - */ -function processConditionals( - template: string, - vars: Record, -): string { - // Pattern: {{#if varName}}...content...{{else}}...altContent...{{/if}} - // or: {{#if varName}}...content...{{/if}} - return template.replace( - /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, - (_match, varName: string, body: string): string => { - const value = vars[varName]; - const isTruthy = value !== undefined && value !== false && value !== ''; - - const elseIndex = body.indexOf('{{else}}'); - if (isTruthy) { - return elseIndex >= 0 ? body.slice(0, elseIndex) : body; - } - return elseIndex >= 0 ? body.slice(elseIndex + '{{else}}'.length) : ''; - }, - ); -} - -/** - * Replace {{variableName}} placeholders with values from vars. - * Undefined variables are replaced with empty string. - */ -function substituteVariables( - template: string, - vars: Record, -): string { - return template.replace( - /\{\{(\w+)\}\}/g, - (_match, varName: string) => { - const value = vars[varName]; - if (value === undefined || value === false) return ''; - if (value === true) return 'true'; - return value; - }, - ); -} - -/** - * Render a template string by processing conditionals then substituting variables. - */ -export function renderTemplate( - template: string, - vars: Record, -): string { - const afterConditionals = processConditionals(template, vars); - return substituteVariables(afterConditionals, vars); -} - /** * Load a Markdown template, apply variable substitution and conditional blocks. * diff --git a/src/shared/prompts/ja/score_instruct_system_prompt.md b/src/shared/prompts/ja/score_instruct_system_prompt.md index 74f12f8..e5b0367 100644 --- a/src/shared/prompts/ja/score_instruct_system_prompt.md +++ b/src/shared/prompts/ja/score_instruct_system_prompt.md @@ -1,7 +1,7 @@ # 追加指示アシスタント @@ -85,3 +85,11 @@ - 何がうまくいかなかったか、追加作業が必要な箇所をユーザーが特定できるよう支援してください - 実行結果に基づいて、具体的なフォローアップ指示を提案してください {{/if}} +{{#if hasOrderContent}} + +## 前回の指示書(order.md) + +前回の実行時に使用された指示書です。再実行の参考にしてください。 + +{{orderContent}} +{{/if}} diff --git a/src/shared/prompts/ja/score_retry_system_prompt.md b/src/shared/prompts/ja/score_retry_system_prompt.md index 85d3fce..a303ac1 100644 --- a/src/shared/prompts/ja/score_retry_system_prompt.md +++ b/src/shared/prompts/ja/score_retry_system_prompt.md @@ -1,7 +1,7 @@ # リトライアシスタント @@ -95,3 +95,11 @@ - レポートに記録された計画や実装内容と、実際の失敗箇所を照合してください - ユーザーが詳細を知りたい場合は、上記ディレクトリのファイルを Read ツールで参照できます {{/if}} +{{#if hasOrderContent}} + +## 前回の指示書(order.md) + +前回の実行時に使用された指示書です。再実行の参考にしてください。 + +{{orderContent}} +{{/if}} diff --git a/src/shared/ui/TaskPrefixWriter.ts b/src/shared/ui/TaskPrefixWriter.ts index 7cf508b..99cc05c 100644 --- a/src/shared/ui/TaskPrefixWriter.ts +++ b/src/shared/ui/TaskPrefixWriter.ts @@ -21,6 +21,10 @@ const RESET = '\x1b[0m'; export interface TaskPrefixWriterOptions { /** Task name used in the prefix */ taskName: string; + /** Optional pre-computed label used in the prefix (overrides taskName truncation). */ + displayLabel?: string; + /** Optional issue number used in the prefix (overrides taskName and display label). */ + issue?: number; /** Color index for the prefix (cycled mod 4) */ colorIndex: number; /** Override process.stdout.write for testing */ @@ -49,7 +53,8 @@ export class TaskPrefixWriter { constructor(options: TaskPrefixWriterOptions) { const color = TASK_COLORS[options.colorIndex % TASK_COLORS.length]; - const taskLabel = options.taskName.slice(0, 4); + const issueLabel = options.issue == null ? undefined : `#${options.issue}`; + const taskLabel = issueLabel ?? options.displayLabel ?? options.taskName.slice(0, 4); this.taskPrefix = `${color}[${taskLabel}]${RESET}`; this.writeFn = options.writeFn ?? ((text: string) => process.stdout.write(text)); } diff --git a/src/shared/utils/reportDir.ts b/src/shared/utils/reportDir.ts index 84480ac..244133f 100644 --- a/src/shared/utils/reportDir.ts +++ b/src/shared/utils/reportDir.ts @@ -2,6 +2,8 @@ * Report directory name generation. */ +import { slugify } from './slug.js'; + export function generateReportDir(task: string): string { const now = new Date(); const timestamp = now.toISOString() @@ -9,12 +11,7 @@ export function generateReportDir(task: string): string { .slice(0, 14) .replace(/(\d{8})(\d{6})/, '$1-$2'); - const summary = task - .slice(0, 30) - .toLowerCase() - .replace(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]+/g, '-') - .replace(/^-+|-+$/g, '') - || 'task'; + const summary = slugify(task.slice(0, 80)) || 'task'; return `${timestamp}-${summary}`; } diff --git a/src/shared/utils/slug.ts b/src/shared/utils/slug.ts index 6bf6440..8dc0e7f 100644 --- a/src/shared/utils/slug.ts +++ b/src/shared/utils/slug.ts @@ -2,17 +2,18 @@ * Text slugification utility * * Converts text into URL/filename-safe slugs. - * Supports ASCII alphanumerics and CJK characters. + * Allowed characters: a-z, 0-9, hyphen. Max 30 characters. */ /** * Convert text into a slug for use in filenames, paths, and branch names. - * Preserves CJK characters (U+3000-9FFF, FF00-FFEF). + * Allowed: a-z 0-9 hyphen. Max 30 characters. */ export function slugify(text: string): string { return text .toLowerCase() - .replace(/[^a-z0-9\u3000-\u9fff\uff00-\uffef]+/g, '-') + .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') - .slice(0, 50); + .slice(0, 30) + .replace(/-+$/, ''); } diff --git a/src/shared/utils/taskPaths.ts b/src/shared/utils/taskPaths.ts index b12d582..905eb4c 100644 --- a/src/shared/utils/taskPaths.ts +++ b/src/shared/utils/taskPaths.ts @@ -1,5 +1,5 @@ const TASK_SLUG_PATTERN = - '[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf](?:[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf-]*[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf])?'; + '[a-z0-9](?:[a-z0-9-]*[a-z0-9])?'; const TASK_DIR_PREFIX = '.takt/tasks/'; const TASK_DIR_PATTERN = new RegExp(`^\\.takt/tasks/${TASK_SLUG_PATTERN}$`); const REPORT_DIR_NAME_PATTERN = new RegExp(`^${TASK_SLUG_PATTERN}$`); diff --git a/vitest.config.e2e.mock.ts b/vitest.config.e2e.mock.ts index b9826e5..96f8b00 100644 --- a/vitest.config.e2e.mock.ts +++ b/vitest.config.e2e.mock.ts @@ -35,6 +35,7 @@ export default defineConfig({ 'e2e/specs/eject.e2e.ts', 'e2e/specs/quiet-mode.e2e.ts', 'e2e/specs/task-content-file.e2e.ts', + 'e2e/specs/config-priority.e2e.ts', ], }, });