Merge pull request #260 from nrslib/release/v0.13.0-alpha.1

Release v0.13.0-alpha.1
This commit is contained in:
nrs 2026-02-13 07:33:21 +09:00 committed by GitHub
commit 76fed1f902
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
197 changed files with 6679 additions and 1775 deletions

View File

@ -86,13 +86,21 @@ jobs:
- name: Verify dist-tags
run: |
PACKAGE_NAME=$(node -p "require('./package.json').name")
for attempt in 1 2 3 4 5; do
LATEST=$(npm view "${PACKAGE_NAME}" dist-tags.latest)
NEXT=$(npm view "${PACKAGE_NAME}" dist-tags.next || true)
echo "latest=${LATEST}"
echo "next=${NEXT}"
echo "Attempt ${attempt}: latest=${LATEST}, next=${NEXT}"
if [ "${{ steps.npm-tag.outputs.tag }}" = "latest" ] && [ "${LATEST}" != "${NEXT}" ]; then
echo "Expected next to match latest on stable release, but they differ."
exit 1
if [ "${{ steps.npm-tag.outputs.tag }}" != "latest" ] || [ "${LATEST}" = "${NEXT}" ]; then
echo "Dist-tags verified."
exit 0
fi
if [ "$attempt" -eq 5 ]; then
echo "::warning::dist-tags not synced after 5 attempts (latest=${LATEST}, next=${NEXT}). Registry propagation may be delayed."
exit 0
fi
sleep $((attempt * 10))
done

View File

@ -4,6 +4,46 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.13.0-alpha.1] - 2026-02-13
### Added
- **Team Leader ムーブメント**: ムーブメント内でチームリーダーエージェントがタスクを動的にサブタスクPartへ分解し、複数のパートエージェントを並列実行する新しいムーブメントタイプ — `team_leader` 設定persona, maxParts, timeoutMs, partPersona, partEdit, partPermissionModeをサポート (#244)
- **構造化出力Structured Output**: エージェント呼び出しに JSON Schema ベースの構造化出力を導入 — タスク分解decomposition、ルール評価evaluation、ステータス判定judgmentの3つのスキーマを `builtins/schemas/` に追加。Claude / Codex 両プロバイダーで対応 (#257)
- **`backend` ビルトインピース**: バックエンド開発特化のピースを新規追加 — バックエンド、セキュリティ、QA の並列専門家レビュー対応
- **`backend-cqrs` ビルトインピース**: CQRS+ES 特化のバックエンド開発ピースを新規追加 — CQRS+ES、セキュリティ、QA の並列専門家レビュー対応
- **AbortSignal によるパートタイムアウト**: Team Leader のパート実行にタイムアウト制御と親シグナル連動の AbortSignal を追加
- **エージェントユースケース層**: `agent-usecases.ts` にエージェント呼び出しのユースケース(`decomposeTask`, `executeAgent`, `evaluateRules`)を集約し、構造化出力の注入を一元管理
### Changed
- **BREAKING: パブリック API の整理**: `src/index.ts` の公開 API を大幅に絞り込み — 内部実装の詳細セッション管理、Claude/Codex クライアント詳細、ユーティリティ関数等)を非公開化し、安定した最小限の API サーフェスに (#257)
- **Phase 3 判定ロジックの刷新**: `JudgmentDetector` / `FallbackStrategy` を廃止し、構造化出力ベースの `status-judgment-phase.ts` に統合。判定の安定性と保守性を向上 (#257)
- **Report フェーズのリトライ改善**: Report PhasePhase 2が失敗した場合、新規セッションで自動リトライするよう改善 (#245)
- **Ctrl+C シャットダウンの統一**: `sigintHandler.ts` を廃止し、`ShutdownManager` に統合 — グレースフルシャットダウン → タイムアウト → 強制終了の3段階制御を全プロバイダーで共通化 (#237)
- フロントエンドナレッジにデザイントークンとテーマスコープのガイダンスを追加
- アーキテクチャナレッジの改善en/ja 両対応)
### Fixed
- clone 時に既存ブランチの checkout が失敗する問題を修正 — `git clone --shared``--branch` を渡してからリモートを削除するよう変更
- Issue 参照付きブランチ名から `#` を除去(`takt/#N/slug``takt/N/slug`
- OpenCode の report フェーズで deprecated ツール依存を解消し、permission 中心の制御へ移行 (#246)
- 不要な export を排除し、パブリック API の整合性を確保
### Internal
- Team Leader 関連のテスト追加engine-team-leader, team-leader-schema-loader, task-decomposer
- 構造化出力関連のテスト追加parseStructuredOutput, claude-executor-structured-output, codex-structured-output, provider-structured-output, structured-output E2E
- ShutdownManager のユニットテスト追加
- AbortSignal のユニットテスト追加abort-signal, claude-executor-abort-signal, claude-provider-abort-signal
- Report Phase リトライのユニットテスト追加report-phase-retry
- パブリック API エクスポートのユニットテスト追加public-api-exports
- E2E テストの大幅拡充: cycle-detection, model-override, multi-step-sequential, pipeline-local-repo, report-file-output, run-sigint-graceful, session-log, structured-output, task-status-persistence
- E2E テストヘルパーのリファクタリング(共通 setup 関数の抽出)
- `judgment/` ディレクトリJudgmentDetector, FallbackStrategyを削除
- `ruleIndex.ts` ユーティリティを追加1-based → 0-based インデックス変換)
## [0.12.1] - 2026-02-11
### Fixed

View File

@ -371,19 +371,30 @@ Files: `.takt/logs/{sessionId}.jsonl`, with `latest.json` pointer. Legacy `.json
**Instruction auto-injection over explicit placeholders.** The instruction builder auto-injects `{task}`, `{previous_response}`, `{user_inputs}`, and status rules. Templates should contain only step-specific instructions, not boilerplate.
**Persona prompts contain only domain knowledge.** Persona prompt files (`builtins/{lang}/personas/*.md`) must contain only domain expertise and behavioral principles — never piece-specific procedures. Piece-specific details (which reports to read, step routing, specific templates with hardcoded step names) belong in the piece YAML's `instruction_template`. This keeps personas reusable across different pieces.
**Faceted prompting: each facet has a dedicated file type.** TAKT assembles agent prompts from 4 facets. Each facet has a distinct role. When adding new rules or knowledge, place content in the correct facet.
What belongs in persona prompts:
- Role definition ("You are a ... specialist")
- Domain expertise, review criteria, judgment standards
- Do / Don't behavioral rules
- Tool usage knowledge (general, not piece-specific)
```
builtins/{lang}/
personas/ — WHO: identity, expertise, behavioral habits
policies/ — HOW: judgment criteria, REJECT/APPROVE rules, prohibited patterns
knowledge/ — WHAT TO KNOW: domain patterns, anti-patterns, detailed reasoning with examples
instructions/ — WHAT TO DO NOW: step-specific procedures and checklists
```
What belongs in piece `instruction_template`:
- Step-specific procedures ("Read these specific reports")
- References to other steps or their outputs
- Specific report file names or formats
- Comment/output templates with hardcoded review type names
| Deciding where to place content | Facet | Example |
|--------------------------------|-------|---------|
| Role definition, AI habit prevention | Persona | "置き換えたコードを残す → 禁止" |
| Actionable REJECT/APPROVE criterion | Policy | "内部実装のパブリックAPIエクスポート → REJECT" |
| Detailed reasoning, REJECT/OK table with examples | Knowledge | "パブリックAPIの公開範囲" section |
| This-step-only procedure or checklist | Instruction | "レビュー観点: 構造・設計の妥当性..." |
| Workflow structure, facet assignment | Piece YAML | `persona: coder`, `policy: coding`, `knowledge: architecture` |
Key rules:
- Persona files are reusable across pieces. Never include piece-specific procedures (report names, step references)
- Policy REJECT lists are what reviewers enforce. If a criterion is not in the policy REJECT list, reviewers will not catch it — even if knowledge explains the reasoning
- Knowledge provides the WHY behind policy criteria. Knowledge alone does not trigger enforcement
- Instructions are bound to a single piece step. They reference procedures, not principles
- Piece YAML `instruction_template` is for step-specific details (which reports to read, step routing, output templates)
**Separation of concerns in piece engine:**
- `PieceEngine` - Orchestration, state management, event emission

View File

@ -474,6 +474,8 @@ TAKT includes multiple builtin pieces:
| `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.

View File

@ -18,6 +18,26 @@
- No circular dependencies
- Appropriate directory hierarchy
**Operation Discoverability:**
When calls to the same generic function are scattered across the codebase with different purposes, it becomes impossible to understand what the system does without grepping every call site. Group related operations into purpose-named functions within a single module. Reading that module should reveal the complete list of operations the system performs.
| Judgment | Criteria |
|----------|----------|
| REJECT | Same generic function called directly from 3+ places with different purposes |
| REJECT | Understanding all system operations requires grepping every call site |
| OK | Purpose-named functions defined and collected in a single module |
**Public API Surface:**
Public APIs should expose only domain-level functions and types. Do not export infrastructure internals (provider-specific functions, internal parsers, etc.).
| Judgment | Criteria |
|----------|----------|
| REJECT | Infrastructure-layer functions exported from public API |
| REJECT | Internal implementation functions callable from outside |
| OK | External consumers interact only through domain-level abstractions |
**Function Design:**
- One responsibility per function
@ -299,19 +319,18 @@ Correct handling:
## DRY Violation Detection
Detect duplicate code.
Eliminate duplication by default. When logic is essentially the same and should be unified, apply DRY. Do not judge mechanically by count.
| Pattern | Judgment |
|---------|----------|
| Same logic in 3+ places | Immediate REJECT - Extract to function/method |
| Same validation in 2+ places | Immediate REJECT - Extract to validator function |
| Similar components 3+ | Immediate REJECT - Create shared component |
| Copy-paste derived code | Immediate REJECT - Parameterize or abstract |
| Essentially identical logic duplicated | REJECT - Extract to function/method |
| Same validation duplicated | REJECT - Extract to validator function |
| Essentially identical component structure | REJECT - Create shared component |
| Copy-paste derived code | REJECT - Parameterize or abstract |
AHA principle (Avoid Hasty Abstractions) balance:
- 2 duplications → Wait and see
- 3 duplications → Extract immediately
- Different domain duplications → Don't abstract (e.g., customer validation vs admin validation are different)
When NOT to apply DRY:
- Different domains: Don't abstract (e.g., customer validation vs admin validation are different things)
- Superficially similar but different reasons to change: Treat as separate code
## Spec Compliance Verification

View File

@ -224,6 +224,54 @@ Signs to make separate components:
- Added variant is clearly different from original component's purpose
- Props specification becomes complex on the usage side
### Theme Differences and Design Tokens
When you need different visuals with the same functional components, manage it with design tokens + theme scope.
Principles:
- Define color, spacing, radius, shadow, and typography as tokens (CSS variables)
- Apply role/page-specific differences by overriding tokens in a theme scope (e.g. `.consumer-theme`, `.admin-theme`)
- Do not hardcode hex colors (`#xxxxxx`) in feature components
- Keep logic differences (API/state) separate from visual differences (tokens)
```css
/* tokens.css */
:root {
--color-bg-page: #f3f4f6;
--color-surface: #ffffff;
--color-text-primary: #1f2937;
--color-border: #d1d5db;
--color-accent: #2563eb;
}
.consumer-theme {
--color-bg-page: #f7f8fa;
--color-accent: #4daca1;
}
```
```tsx
// same component, different look by scope
<div className="consumer-theme">
<Button variant="primary">Submit</Button>
</div>
```
Operational rules:
- Implement shared UI primitives (Button/Card/Input/Tabs) using tokens only
- In feature views, use theme-common utility classes (e.g. `surface`, `title`, `chip`) to avoid duplicated styling logic
- For a new theme, follow: "add tokens -> override by scope -> reuse existing components"
Review checklist:
- No copy-pasted hardcoded colors/spacings
- No duplicated components per theme for the same UI behavior
- No API/state-management changes made solely for visual adjustments
Anti-patterns:
- Creating `ButtonConsumer`, `ButtonAdmin` for styling only
- Hardcoding colors in each feature component
- Changing response shaping logic when only the theme changed
## Abstraction Level Evaluation
**Conditional branch bloat detection:**

View File

@ -33,4 +33,5 @@ You are the implementer. Focus on implementation, not design decisions.
- Making design decisions arbitrarily → Report and ask for guidance
- Dismissing reviewer feedback → Prohibited
- Adding backward compatibility or legacy support without being asked → Absolutely prohibited
- Leaving replaced code/exports after refactoring → Prohibited (remove unless explicitly told to keep)
- Layering workarounds that bypass safety mechanisms on top of a root cause fix → Prohibited

View File

@ -9,7 +9,10 @@ piece_categories:
🎨 Frontend:
pieces:
- frontend
⚙️ Backend: {}
⚙️ Backend:
pieces:
- backend
- backend-cqrs
🔧 Expert:
Full Stack:
pieces:

View File

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

View File

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

View File

@ -7,7 +7,7 @@ Prioritize correctness over speed, and code accuracy over ease of implementation
| Principle | Criteria |
|-----------|----------|
| Simple > Easy | Prioritize readability over writability |
| DRY | Extract after 3 repetitions |
| DRY | Eliminate essential duplication |
| Comments | Why only. Never write What/How |
| Function size | One function, one responsibility. ~30 lines |
| File size | ~300 lines as a guideline. Be flexible depending on the task |
@ -245,23 +245,19 @@ Request → toInput() → UseCase/Service → Output → Response.from()
## Shared Code Decisions
### Rule of Three
- 1st occurrence: Write it inline
- 2nd occurrence: Do not extract yet (observe)
- 3rd occurrence: Consider extracting
Eliminate duplication by default. When logic is essentially the same and should be unified, apply DRY. Do not decide mechanically by count.
### Should Be Shared
- Same logic in 3+ places
- Essentially identical logic duplicated
- Same style/UI pattern
- Same validation logic
- Same formatting logic
### Should Not Be Shared
- Similar but subtly different (forced generalization adds complexity)
- Used in only 1-2 places
- Duplication across different domains (e.g., customer validation and admin validation are separate concerns)
- Superficially similar code with different reasons to change
- Based on "might need it in the future" predictions
```typescript
@ -289,4 +285,6 @@ function formatPercentage(value: number): string { ... }
- **Hardcoded secrets**
- **Scattered try-catch** - Centralize error handling at the upper layer
- **Unsolicited backward compatibility / legacy support** - Not needed unless explicitly instructed
- **Internal implementation exported from public API** - Only export domain-level functions and types. Do not export infrastructure functions or internal classes
- **Replaced code surviving after refactoring** - Remove replaced code and exports. Do not keep unless explicitly told to
- **Workarounds that bypass safety mechanisms** - If the root fix is correct, no additional bypass is needed

View File

@ -38,9 +38,11 @@ REJECT without exception if any of the following apply.
- Direct mutation of objects/arrays
- Swallowed errors (empty catch blocks)
- TODO comments (not tracked in an issue)
- Duplicated code in 3+ places (DRY violation)
- Essentially identical logic duplicated (DRY violation)
- Method proliferation doing the same thing (should be absorbed by configuration differences)
- Specific implementation leaking into generic layers (imports and branching for specific implementations in generic layers)
- Internal implementation exported from public API (infrastructure functions or internal classes exposed publicly)
- Replaced code/exports surviving after refactoring
- Missing cross-validation of related fields (invariants of semantically coupled config values left unverified)
### Warning

View File

@ -18,6 +18,26 @@
- 循環依存がないか
- 適切なディレクトリ階層か
**操作の一覧性**
同じ汎用関数への呼び出しがコードベースに散在すると、システムが何をしているか把握できなくなる。操作には目的に応じた名前を付けて関数化し、関連する操作を1つのモジュールにまとめる。そのモジュールを読めば「このシステムが行う操作の全体像」がわかる状態にする。
| 判定 | 基準 |
|------|------|
| REJECT | 同じ汎用関数が目的の異なる3箇所以上から直接呼ばれている |
| REJECT | 呼び出し元を全件 grep しないとシステムの操作一覧がわからない |
| OK | 目的ごとに名前付き関数が定義され、1モジュールに集約されている |
**パブリック API の公開範囲**
パブリック API が公開するのは、ドメインの操作に対応する関数・型のみ。インフラの実装詳細(特定プロバイダーの関数、内部パーサー等)を公開しない。
| 判定 | 基準 |
|------|------|
| REJECT | インフラ層の関数がパブリック API からエクスポートされている |
| REJECT | 内部実装の関数が外部から直接呼び出し可能になっている |
| OK | 外部消費者がドメインレベルの抽象のみを通じて対話する |
**関数設計**
- 1関数1責務になっているか
@ -299,19 +319,18 @@ TODOが許容される唯一のケース:
## DRY違反の検出
重複コードを検出する
基本的に重複は排除する。本質的に同じロジックであり、まとめるべきと判断したら DRY にする。回数で機械的に判断しない
| パターン | 判定 |
|---------|------|
| 同じロジックが3箇所以上 | 即REJECT - 関数/メソッドに抽出 |
| 同じバリデーションが2箇所以上 | 即REJECT - バリデーター関数に抽出 |
| 似たようなコンポーネントが3個以上 | 即REJECT - 共通コンポーネント化 |
| コピペで派生したコード | REJECT - パラメータ化または抽象化 |
| 本質的に同じロジックの重複 | REJECT - 関数/メソッドに抽出 |
| 同じバリデーションの重複 | REJECT - バリデーター関数に抽出 |
| 本質的に同じ構造のコンポーネント | REJECT - 共通コンポーネント化 |
| コピペで派生したコード | REJECT - パラメータ化または抽象化 |
AHA原則Avoid Hasty Abstractionsとのバランス:
- 2回の重複 → 様子見
- 3回の重複 → 即抽出
- ドメインが異なる重複 → 抽象化しない(例: 顧客用バリデーションと管理者用バリデーションは別物)
DRY にしないケース:
- ドメインが異なる重複は抽象化しない(例: 顧客用バリデーションと管理者用バリデーションは別物)
- 表面的に似ているが、変更理由が異なるコードは別物として扱う
## 仕様準拠の検証

View File

@ -369,6 +369,54 @@ export function StepperButton(props) {
- 追加したvariantが元のコンポーネントの用途と明らかに違う
- 使う側のprops指定が複雑になる
### テーマ差分とデザイントークン
同じ機能コンポーネントを再利用しつつ見た目だけ変える場合は、デザイントークン + テーマスコープで管理する。
原則:
- 色・余白・角丸・影・タイポをトークンCSS Variablesとして定義する
- 画面/ロール別の差分はテーマスコープ(例: `.consumer-theme`, `.admin-theme`)で上書きする
- コンポーネント内に16進カラー値`#xxxxxx`)を直書きしない
- ロジック差分API・状態管理と見た目差分トークンを混在させない
```css
/* tokens.css */
:root {
--color-bg-page: #f3f4f6;
--color-surface: #ffffff;
--color-text-primary: #1f2937;
--color-border: #d1d5db;
--color-accent: #2563eb;
}
.consumer-theme {
--color-bg-page: #f7f8fa;
--color-accent: #4daca1;
}
```
```tsx
// same component, different look by scope
<div className="consumer-theme">
<Button variant="primary">Submit</Button>
</div>
```
運用ルール:
- 共通UIButton/Card/Input/Tabsはトークン参照のみで実装する
- feature側はテーマ共通クラス例: `surface`, `title`, `chip`)を利用し、装飾ロジックを重複させない
- 追加テーマ実装時は「トークン追加 → スコープ上書き → 既存コンポーネント流用」の順で進める
レビュー観点:
- 直書き色・直書き余白のコピペがないか
- 同一UIパターンがテーマごとに別コンポーネント化されていないか
- 見た目変更のためにデータ取得/状態管理が改変されていないか
NG例:
- 見た目差分のために `ButtonConsumer`, `ButtonAdmin` を乱立
- featureコンポーネントごとに色を直書き
- テーマ切り替えのたびにAPIレスポンス整形ロジックを変更
## 抽象化レベルの評価
### 条件分岐の肥大化検出

View File

@ -33,4 +33,5 @@
- 設計判断を勝手にする → 報告して判断を仰ぐ
- レビュワーの指摘を軽視する → 禁止
- 後方互換・Legacy 対応を勝手に追加する → 絶対禁止
- リファクタリングで置き換えたコード・エクスポートを残す → 禁止(明示的に残すよう指示されない限り削除する)
- 根本原因を修正した上で安全機構を迂回するワークアラウンドを重ねる → 禁止

View File

@ -9,7 +9,10 @@ piece_categories:
🎨 フロントエンド:
pieces:
- frontend
⚙️ バックエンド: {}
⚙️ バックエンド:
pieces:
- backend
- backend-cqrs
🔧 エキスパート:
フルスタック:
pieces:

View File

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

View File

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

View File

@ -7,7 +7,7 @@
| 原則 | 基準 |
|------|------|
| Simple > Easy | 書きやすさより読みやすさを優先 |
| DRY | 3回重複したら抽出 |
| DRY | 本質的な重複は排除する |
| コメント | Why のみ。What/How は書かない |
| 関数サイズ | 1関数1責務。30行目安 |
| ファイルサイズ | 目安として300行。タスクに応じて柔軟に |
@ -245,23 +245,19 @@ Request → toInput() → UseCase/Service → Output → Response.from()
## 共通化の判断
### 3回ルール
- 1回目: そのまま書く
- 2回目: まだ共通化しない(様子見)
- 3回目: 共通化を検討
基本的に重複は排除する。本質的に同じロジックであり、まとめるべきと判断したら DRY にする。回数で機械的に判断しない。
### 共通化すべきもの
- 同じ処理が3箇所以上
- 本質的に同じロジックの重複
- 同じスタイル/UIパターン
- 同じバリデーションロジック
- 同じフォーマット処理
### 共通化すべきでないもの
- 似ているが微妙に違うもの(無理に汎用化すると複雑化
- 1-2箇所しか使わないもの
- ドメインが異なる重複(例: 顧客用バリデーションと管理者用バリデーションは別物
- 表面的に似ているが変更理由が異なるコード
- 「将来使うかも」という予測に基づくもの
```typescript
@ -289,4 +285,6 @@ function formatPercentage(value: number): string { ... }
- **機密情報のハードコーディング**
- **各所でのtry-catch** - エラーは上位層で一元処理
- **後方互換・Legacy対応の自発的追加** - 明示的な指示がない限り不要
- **内部実装のパブリック API エクスポート** - 公開するのはドメイン操作の関数・型のみ。インフラ層の関数や内部クラスをエクスポートしない
- **リファクタリング後の旧コード残存** - 置き換えたコード・エクスポートは削除する。明示的に残すよう指示されない限り残さない
- **安全機構を迂回するワークアラウンド** - 根本修正が正しいなら追加の迂回は不要

View File

@ -38,9 +38,11 @@
- オブジェクト/配列の直接変更
- エラーの握りつぶし(空の catch
- TODO コメントIssue化されていないもの
- 3箇所以上の重複コードDRY違反
- 本質的に同じロジックの重複DRY違反
- 同じことをするメソッドの増殖(構成の違いで吸収すべき)
- 特定実装の汎用層への漏洩(汎用層に特定実装のインポート・分岐がある)
- 内部実装のパブリック API エクスポート(インフラ層の関数・内部クラスが公開されている)
- リファクタリングで置き換えられた旧コード・旧エクスポートの残存
- 関連フィールドのクロスバリデーション欠如(意味的に結合した設定値の不変条件が未検証)
### Warning警告

View File

@ -0,0 +1,33 @@
{
"type": "object",
"properties": {
"parts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Unique part identifier"
},
"title": {
"type": "string",
"description": "Human-readable part title"
},
"instruction": {
"type": "string",
"description": "Instruction for the part agent"
},
"timeout_ms": {
"type": ["integer", "null"],
"description": "Optional timeout in ms"
}
},
"required": ["id", "title", "instruction", "timeout_ms"],
"additionalProperties": false
}
}
},
"required": ["parts"],
"additionalProperties": false
}

View File

@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"matched_index": {
"type": "integer",
"description": "Matched condition number (1-based)"
},
"reason": {
"type": "string",
"description": "Why this condition was matched"
}
},
"required": ["matched_index", "reason"],
"additionalProperties": false
}

View File

@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"step": {
"type": "integer",
"description": "Matched rule number (1-based)"
},
"reason": {
"type": "string",
"description": "Brief justification for the decision"
}
},
"required": ["step", "reason"],
"additionalProperties": false
}

View File

@ -474,6 +474,8 @@ TAKTには複数のビルトインピースが同梱されています:
| `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。ピースを複製する必要はありません。

View File

@ -0,0 +1,127 @@
# Structured Output — Phase 3 ステータス判定
## 概要
Phase 3ステータス判定において、エージェントの出力を structured outputJSON スキーマ)で取得し、ルールマッチングの精度と信頼性を向上させる。
## プロバイダ別の挙動
| プロバイダ | メソッド | 仕組み |
|-----------|---------|--------|
| Claude | `structured_output` | SDK が `StructuredOutput` ツールを自動追加。エージェントがツール経由で `{ step, reason }` を返す |
| Codex | `structured_output` | `TurnOptions.outputSchema` で API レベルの JSON 制約。テキストが JSON になる |
| OpenCode | `structured_output` | プロンプト末尾に JSON スキーマ付き出力指示を注入。テキストレスポンスから `parseStructuredOutput()` で JSON を抽出 |
## フォールバックチェーン
`judgeStatus()` は3段階の独立した LLM 呼び出しでルールをマッチする。
```
Stage 1: structured_output — outputSchema 付き LLM 呼び出し → structuredOutput.step1-based integer
Stage 2: phase3_tag — outputSchema なし LLM 呼び出し → content 内の [MOVEMENT:N] タグ検出
Stage 3: ai_judge — evaluateCondition() による AI 条件評価
```
各ステージは専用のインストラクションで LLM に問い合わせる。Stage 1 は「ルール番号を JSON で返せ」、Stage 2 は「タグを1行で出力せよ」と聞き方が異なる。
セッションログには `toJudgmentMatchMethod()` で変換された値が記録される。
| 内部メソッド | セッションログ |
|-------------|--------------|
| `structured_output` | `structured_output` |
| `phase3_tag` / `phase1_tag` | `tag_fallback` |
| `ai_judge` / `ai_judge_fallback` | `ai_judge` |
## インストラクション分岐
Phase 3 テンプレート(`perform_phase3_message`)は `structuredOutput` フラグで2つのモードを持つ。
### Structured Output モード(`structuredOutput: true`
主要指示: ルール番号1-basedと理由を返せ。
フォールバック指示: structured output が使えない場合はタグを出力せよ。
### タグモード(`structuredOutput: false`
従来の指示: 対応するタグを1行で出力せよ。
現在、Phase 3 は常に `structuredOutput: true` で実行される。
## アーキテクチャ
```
StatusJudgmentBuilder
└─ structuredOutput: true
├─ criteriaTable: ルール条件テーブル(常に含む)
├─ outputList: タグ一覧(フォールバック用に含む)
└─ テンプレート: "ルール番号と理由を返せ + タグはフォールバック"
runStatusJudgmentPhase()
└─ judgeStatus() → JudgeStatusResult { ruleIndex, method }
└─ StatusJudgmentPhaseResult { tag, ruleIndex, method }
MovementExecutor
├─ Phase 3 あり → judgeStatus の結果を直接使用method 伝搬)
└─ Phase 3 なし → detectMatchedRule() で Phase 1 コンテンツから検出
```
## JSON スキーマ
### judgment.jsonjudgeStatus 用)
```json
{
"type": "object",
"properties": {
"step": { "type": "integer", "description": "Matched rule number (1-based)" },
"reason": { "type": "string", "description": "Brief justification" }
},
"required": ["step", "reason"],
"additionalProperties": false
}
```
### evaluation.jsonevaluateCondition 用)
```json
{
"type": "object",
"properties": {
"matched_index": { "type": "integer" },
"reason": { "type": "string" }
},
"required": ["matched_index", "reason"],
"additionalProperties": false
}
```
## parseStructuredOutput() — JSON 抽出
Codex と OpenCode はテキストレスポンスから JSON を抽出する。3段階のフォールバック戦略を持つ。
```
1. Direct parse — テキスト全体が `{` で始まる JSON オブジェクト
2. Code block — ```json ... ``` または ``` ... ``` 内の JSON
3. Brace extraction — テキスト内の最初の `{` から最後の `}` までを切り出し
```
## OpenCode 固有の仕組み
OpenCode SDK は `outputFormat` を型定義でサポートしていない。代わりにプロンプト末尾に JSON 出力指示を注入する。
```
---
IMPORTANT: You MUST respond with ONLY a valid JSON object matching this schema. No other text, no markdown code blocks, no explanation.
```json
{ "type": "object", ... }
```
```
エージェントが返すテキストを `parseStructuredOutput()` でパースし、`AgentResponse.structuredOutput` に格納する。
## 注意事項
- OpenAI APICodex`required` に全プロパティを含めないとエラーになる(`additionalProperties: false` 時)
- Codex SDK の `TurnCompletedEvent` には `finalResponse` フィールドがない。structured output は `AgentMessageItem.text` の JSON テキストから `parseStructuredOutput()` でパースする
- Claude SDK は `StructuredOutput` ツール方式のため、インストラクションでタグ出力を強調しすぎるとエージェントがツールを呼ばずタグを出力してしまう
- OpenCode のプロンプト注入方式はモデルの指示従順性に依存する。JSON 以外のテキストが混在する場合は `parseStructuredOutput()` の code block / brace extraction で回収する

View File

@ -0,0 +1,34 @@
# Report Phase Permissions Design
## Summary
The report phase now uses permission mode as the primary control surface.
Call sites only provide resume metadata (for example, `maxTurns`), and tool compatibility details are isolated inside `OptionsBuilder`.
## Problem
Historically, report phase calls passed `allowedTools: []` directly from `phase-runner`.
This made phase control depend on a tool list setting that is treated as legacy in OpenCode.
## Design
1. `phase-runner` uses `buildResumeOptions(step, sessionId, { maxTurns })`.
2. `OptionsBuilder.buildResumeOptions` enforces:
- `permissionMode: 'readonly'`
- `allowedTools: []` (compatibility layer for SDK behavior differences)
3. OpenCode-specific execution is controlled by permission rules (`readonly` => deny).
## Rationale
- OpenCode permission rules are the stable and explicit control mechanism for report-phase safety.
- Centralizing compatibility behavior in `OptionsBuilder` prevents policy leakage into movement orchestration code.
- Resume-session behavior remains deterministic for both report and status phases.
## Test Coverage
- `src/__tests__/options-builder.test.ts`
- verifies report/status resume options force `readonly` and empty tools.
- `src/__tests__/phase-runner-report-history.test.ts`
- verifies report phase passes only `{ maxTurns: 3 }` override.
- `src/__tests__/opencode-types.test.ts`
- verifies readonly maps to deny in OpenCode permission config.

View File

@ -17,7 +17,8 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
## E2E用config.yaml
- E2Eのグローバル設定は `e2e/fixtures/config.e2e.yaml` を基準に生成する。
- `createIsolatedEnv()` は毎回一時ディレクトリ配下(`$TAKT_CONFIG_DIR/config.yaml`)にこの基準設定を書き出す。
- 通知音は `notification_sound_events` でタイミング別に制御し、E2E既定では道中`iteration_limit` / `piece_complete` / `piece_abort`をOFF、全体終了時`run_complete` / `run_abort`のみONにする。
- E2E実行中の `takt` 内通知音は `notification_sound: false` で無効化する。
- `npm run test:e2e` は成否にかかわらず最後に1回ベルを鳴らし、終了コードはテスト結果を維持する。
- 各スペックで `provider``concurrency` を変更する場合は、`updateIsolatedConfig()` を使って差分のみ上書きする。
- `~/.takt/config.yaml` はE2Eでは参照されないため、通常実行の設定には影響しない。

View File

@ -2,10 +2,10 @@ provider: claude
language: en
log_level: info
default_piece: default
notification_sound: true
notification_sound: false
notification_sound_events:
iteration_limit: false
piece_complete: false
piece_abort: false
run_complete: true
run_abort: true
run_abort: false

View File

@ -0,0 +1,37 @@
name: e2e-cycle-detect
description: Piece with loop_monitors for cycle detection E2E testing
max_movements: 20
initial_movement: review
loop_monitors:
- cycle: [review, fix]
threshold: 2
judge:
persona: ../agents/test-reviewer-b.md
rules:
- condition: continue
next: review
- condition: abort_loop
next: ABORT
movements:
- name: review
persona: ../agents/test-reviewer-a.md
instruction_template: |
Review the code.
rules:
- condition: approved
next: COMPLETE
- condition: needs_fix
next: fix
- name: fix
persona: ../agents/test-coder.md
edit: true
permission_mode: edit
instruction_template: |
Fix the issues found in review.
rules:
- condition: fixed
next: review

View File

@ -0,0 +1,18 @@
name: e2e-structured-output
description: E2E piece to verify structured output rule matching
max_movements: 5
movements:
- name: execute
edit: false
persona: ../agents/test-coder.md
permission_mode: readonly
instruction_template: |
Reply with exactly: "Task completed successfully."
Do not do anything else.
rules:
- condition: Task completed
next: COMPLETE
- condition: Task failed
next: ABORT

View File

@ -0,0 +1,13 @@
[
{"persona": "agents/test-reviewer-a", "status": "done", "content": "[REVIEW:2]\n\nNeeds fix."},
{"persona": "conductor", "status": "done", "content": "[REVIEW:2]"},
{"persona": "conductor", "status": "done", "content": "[REVIEW:2]"},
{"persona": "agents/test-coder", "status": "done", "content": "[FIX:1]\n\nFixed."},
{"persona": "agents/test-reviewer-a", "status": "done", "content": "[REVIEW:2]\n\nStill needs fix."},
{"persona": "conductor", "status": "done", "content": "[REVIEW:2]"},
{"persona": "conductor", "status": "done", "content": "[REVIEW:2]"},
{"persona": "agents/test-coder", "status": "done", "content": "[FIX:1]\n\nFixed again."},
{"persona": "agents/test-reviewer-b", "status": "done", "content": "[_LOOP_JUDGE_REVIEW_FIX:2]\n\nAbort this loop."},
{"persona": "conductor", "status": "done", "content": "[_LOOP_JUDGE_REVIEW_FIX:2]"},
{"persona": "conductor", "status": "done", "content": "[_LOOP_JUDGE_REVIEW_FIX:2]"}
]

View File

@ -0,0 +1,9 @@
[
{"persona": "agents/test-reviewer-a", "status": "done", "content": "[REVIEW:2]\n\nNeeds fix."},
{"persona": "conductor", "status": "done", "content": "[REVIEW:2]"},
{"persona": "conductor", "status": "done", "content": "[REVIEW:2]"},
{"persona": "agents/test-coder", "status": "done", "content": "[FIX:1]\n\nFixed."},
{"persona": "agents/test-reviewer-a", "status": "done", "content": "[REVIEW:1]\n\nApproved."},
{"persona": "conductor", "status": "done", "content": "[REVIEW:1]"},
{"persona": "conductor", "status": "done", "content": "[REVIEW:1]"}
]

View File

@ -1,7 +1,9 @@
[
{ "persona": "test-coder", "status": "done", "content": "Plan created." },
{ "persona": "test-reviewer-a", "status": "done", "content": "Architecture approved." },
{ "persona": "test-reviewer-b", "status": "done", "content": "Security approved." },
{ "persona": "agents/test-coder", "status": "done", "content": "Plan created." },
{ "persona": "agents/test-reviewer-a", "status": "done", "content": "Architecture approved." },
{ "persona": "agents/test-reviewer-b", "status": "done", "content": "Security approved." },
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" },
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" },
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" },
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" }
]

View File

@ -1,15 +1,19 @@
[
{ "persona": "test-coder", "status": "done", "content": "Plan created." },
{ "persona": "agents/test-coder", "status": "done", "content": "Plan created." },
{ "persona": "test-reviewer-a", "status": "done", "content": "Architecture looks good." },
{ "persona": "test-reviewer-b", "status": "done", "content": "Security issues found." },
{ "persona": "agents/test-reviewer-a", "status": "done", "content": "Architecture looks good." },
{ "persona": "agents/test-reviewer-b", "status": "done", "content": "Security issues found." },
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:2]" },
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:2]" },
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:2]" },
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:2]" },
{ "persona": "test-coder", "status": "done", "content": "Fix applied." },
{ "persona": "agents/test-coder", "status": "done", "content": "Fix applied." },
{ "persona": "test-reviewer-a", "status": "done", "content": "Architecture still approved." },
{ "persona": "test-reviewer-b", "status": "done", "content": "Security now approved." },
{ "persona": "agents/test-reviewer-a", "status": "done", "content": "Architecture still approved." },
{ "persona": "agents/test-reviewer-b", "status": "done", "content": "Security now approved." },
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" },
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" },
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" },
{ "persona": "conductor", "status": "done", "content": "[ARCH-REVIEW:1] [SECURITY-REVIEW:1]" }
]

View File

@ -1,14 +1,19 @@
[
{
"persona": "test-reporter",
"persona": "agents/test-reporter",
"status": "done",
"content": "Work completed."
},
{
"persona": "test-reporter",
"persona": "agents/test-reporter",
"status": "done",
"content": "Report summary: OK"
},
{
"persona": "conductor",
"status": "done",
"content": "[EXECUTE:1]"
},
{
"persona": "conductor",
"status": "done",

View File

@ -0,0 +1,26 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Read session NDJSON log records from a piece execution run.
* Finds the first .jsonl file whose first record is `piece_start`.
*/
export function readSessionRecords(repoPath: string): Array<Record<string, unknown>> {
const runsDir = join(repoPath, '.takt', 'runs');
const runDirs = readdirSync(runsDir).sort();
for (const runDir of runDirs) {
const logsDir = join(runsDir, runDir, 'logs');
const logFiles = readdirSync(logsDir).filter((file) => file.endsWith('.jsonl'));
for (const file of logFiles) {
const content = readFileSync(join(logsDir, file), 'utf-8').trim();
if (!content) continue;
const records = content.split('\n').map((line) => JSON.parse(line) as Record<string, unknown>);
if (records[0]?.type === 'piece_start') {
return records;
}
}
}
throw new Error('Session NDJSON log not found');
}

View File

@ -1,9 +1,13 @@
import { rmSync } from 'node:fs';
import { mkdtempSync } from 'node:fs';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
export interface LocalRepo {
path: string;
cleanup: () => void;
}
export interface TestRepo {
path: string;
repoName: string;
@ -11,6 +15,26 @@ export interface TestRepo {
cleanup: () => void;
}
/**
* Create a local git repository in a temporary directory.
* Use this for tests that don't need a remote (GitHub) repository.
*/
export function createLocalRepo(): LocalRepo {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-'));
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 });
},
};
}
export interface CreateTestRepoOptions {
/** Skip creating a test branch (stay on default branch). Use for pipeline tests. */
skipBranch?: boolean;
@ -30,6 +54,66 @@ function getGitHubUser(): string {
return user;
}
function canUseGitHubRepo(): boolean {
try {
const user = getGitHubUser();
const repoName = `${user}/takt-testing`;
execFileSync('gh', ['repo', 'view', repoName], {
encoding: 'utf-8',
stdio: 'pipe',
});
return true;
} catch {
return false;
}
}
export function isGitHubE2EAvailable(): boolean {
return canUseGitHubRepo();
}
function createOfflineTestRepo(options?: CreateTestRepoOptions): TestRepo {
const sandboxPath = mkdtempSync(join(tmpdir(), 'takt-e2e-repo-'));
const originPath = join(sandboxPath, 'origin.git');
const repoPath = join(sandboxPath, 'work');
execFileSync('git', ['init', '--bare', originPath], { stdio: 'pipe' });
execFileSync('git', ['clone', originPath, 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' });
execFileSync('git', ['push', '-u', 'origin', 'HEAD'], { cwd: repoPath, stdio: 'pipe' });
const testBranch = options?.skipBranch ? undefined : `e2e-test-${Date.now()}`;
if (testBranch) {
execFileSync('git', ['checkout', '-b', testBranch], {
cwd: repoPath,
stdio: 'pipe',
});
}
const currentBranch = testBranch
?? execFileSync('git', ['branch', '--show-current'], {
cwd: repoPath,
encoding: 'utf-8',
}).trim();
return {
path: repoPath,
repoName: 'local/takt-testing',
branch: currentBranch,
cleanup: () => {
try {
rmSync(sandboxPath, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
},
};
}
/**
* Clone the takt-testing repository and create a test branch.
*
@ -39,6 +123,10 @@ function getGitHubUser(): string {
* 3. Delete local directory
*/
export function createTestRepo(options?: CreateTestRepoOptions): TestRepo {
if (!canUseGitHubRepo()) {
return createOfflineTestRepo(options);
}
const user = getGitHubUser();
const repoName = `${user}/takt-testing`;

View File

@ -9,11 +9,12 @@ import {
updateIsolatedConfig,
type IsolatedEnv,
} from '../helpers/isolated-env';
import { createTestRepo, type TestRepo } from '../helpers/test-repo';
import { createTestRepo, isGitHubE2EAvailable, type TestRepo } from '../helpers/test-repo';
import { runTakt } from '../helpers/takt-runner';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const requiresGitHub = isGitHubE2EAvailable();
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Add task from GitHub issue (takt add)', () => {
@ -67,7 +68,7 @@ describe('E2E: Add task from GitHub issue (takt add)', () => {
}
});
it('should create a task file from issue reference', () => {
it.skipIf(!requiresGitHub)('should create a task file from issue reference', () => {
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/add-task.json');
const result = runTakt({

View File

@ -1,31 +1,12 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-catalog-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Catalog command (takt catalog)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();

View File

@ -1,31 +1,12 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-clear-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Clear sessions command (takt clear)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();

View File

@ -1,31 +1,14 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-config-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Config command (takt config)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();

View File

@ -1,31 +1,15 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
import { mkdtempSync, existsSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-export-cc-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Export-cc command (takt export-cc)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
let fakeHome: string;
beforeEach(() => {

View File

@ -1,31 +1,12 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-help-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Help command (takt --help)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();

View File

@ -1,36 +1,17 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-prompt-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Prompt preview command (takt prompt)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();

View File

@ -1,31 +1,14 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-reset-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Reset categories command (takt reset categories)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();

View File

@ -1,31 +1,12 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-switch-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Switch piece command (takt switch)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();

View File

@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
createIsolatedEnv,
updateIsolatedConfig,
type IsolatedEnv,
} from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
import { readSessionRecords } from '../helpers/session-log';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Cycle detection via loop_monitors (mock)', () => {
let isolatedEnv: IsolatedEnv;
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();
updateIsolatedConfig(isolatedEnv.taktDir, {
provider: 'mock',
});
repo = createLocalRepo();
});
afterEach(() => {
try { repo.cleanup(); } catch { /* best-effort */ }
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
});
it('should abort when cycle threshold is reached and judge selects ABORT', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-cycle-detect.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/cycle-detect-abort.json');
const result = runTakt({
args: [
'--task', 'Test cycle detection abort',
'--piece', piecePath,
'--create-worktree', 'no',
'--provider', 'mock',
],
cwd: repo.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
},
timeout: 240_000,
});
expect(result.exitCode).not.toBe(0);
const records = readSessionRecords(repo.path);
const judgeStep = records.find((r) => r.type === 'step_complete' && r.step === '_loop_judge_review_fix');
const abort = records.find((r) => r.type === 'piece_abort');
expect(judgeStep).toBeDefined();
expect(abort).toBeDefined();
}, 240_000);
it('should complete when cycle threshold is not reached', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-cycle-detect.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/cycle-detect-pass.json');
const result = runTakt({
args: [
'--task', 'Test cycle detection pass',
'--piece', piecePath,
'--create-worktree', 'no',
'--provider', 'mock',
],
cwd: repo.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
},
timeout: 240_000,
});
expect(result.exitCode).toBe(0);
const records = readSessionRecords(repo.path);
expect(records.some((r) => r.type === 'piece_complete')).toBe(true);
expect(records.some((r) => r.type === 'piece_abort')).toBe(false);
}, 240_000);
});

View File

@ -1,40 +1,14 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync, readFileSync, mkdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
/**
* Create a minimal local git repository for eject tests.
* No GitHub access needed just a local git init.
*/
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-eject-e2e-'));
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' });
// Create initial commit so branch exists
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: () => {
try {
rmSync(repoPath, { recursive: true, force: true });
} catch {
// best-effort
}
},
};
}
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Eject builtin pieces (takt eject)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();

View File

@ -1,36 +1,17 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-error-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Error handling edge cases (mock)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();

View File

@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: --model option override (mock)', () => {
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 complete direct task execution with --model', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
const result = runTakt({
args: [
'--task', 'Test model override direct',
'--piece', piecePath,
'--create-worktree', 'no',
'--provider', 'mock',
'--model', 'mock-model-override',
],
cwd: repo.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
},
timeout: 240_000,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Piece completed');
}, 240_000);
it('should complete pipeline --skip-git execution with --model', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
const result = runTakt({
args: [
'--pipeline',
'--task', 'Test model override pipeline',
'--piece', piecePath,
'--skip-git',
'--provider', 'mock',
'--model', 'mock-model-override',
],
cwd: repo.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
},
timeout: 240_000,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('completed');
}, 240_000);
});

View File

@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
import { readSessionRecords } from '../helpers/session-log';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Sequential multi-step session log transitions (mock)', () => {
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 record step_complete for both step-1 and step-2', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-two-step.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/two-step-done.json');
const result = runTakt({
args: [
'--task', 'Test sequential transitions',
'--piece', piecePath,
'--create-worktree', 'no',
'--provider', 'mock',
],
cwd: repo.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
},
timeout: 240_000,
});
expect(result.exitCode).toBe(0);
const records = readSessionRecords(repo.path);
const completedSteps = records
.filter((r) => r.type === 'step_complete')
.map((r) => String(r.step));
expect(completedSteps).toContain('step-1');
expect(completedSteps).toContain('step-2');
expect(records.some((r) => r.type === 'piece_complete')).toBe(true);
}, 240_000);
});

View File

@ -1,36 +1,17 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-piece-err-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Piece error handling (mock)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();

View File

@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function createNonGitDir(): { path: string; cleanup: () => void } {
const dirPath = mkdtempSync(join(tmpdir(), 'takt-e2e-pipeline-nongit-'));
writeFileSync(join(dirPath, 'README.md'), '# non-git\n');
return {
path: dirPath,
cleanup: () => {
try { rmSync(dirPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Pipeline --skip-git on local/non-git directories (mock)', () => {
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 execute pipeline with --skip-git in a local git repository', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
const result = runTakt({
args: [
'--pipeline',
'--task', 'Pipeline local repo test',
'--piece', piecePath,
'--skip-git',
'--provider', 'mock',
],
cwd: repo.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
},
timeout: 240_000,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('completed');
}, 240_000);
it('should execute pipeline with --skip-git in a non-git directory', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
const dir = createNonGitDir();
try {
const result = runTakt({
args: [
'--pipeline',
'--task', 'Pipeline non-git test',
'--piece', piecePath,
'--skip-git',
'--provider', 'mock',
],
cwd: dir.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
},
timeout: 240_000,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('completed');
} finally {
dir.cleanup();
}
}, 240_000);
});

View File

@ -1,40 +1,21 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import {
createIsolatedEnv,
updateIsolatedConfig,
type IsolatedEnv,
} from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-provider-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Provider error handling (mock)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();

View File

@ -1,36 +1,17 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-quiet-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Quiet mode (--quiet)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();

View File

@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import {
createIsolatedEnv,
updateIsolatedConfig,
type IsolatedEnv,
} from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Report file output (mock)', () => {
let isolatedEnv: IsolatedEnv;
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();
updateIsolatedConfig(isolatedEnv.taktDir, {
provider: 'mock',
});
repo = createLocalRepo();
});
afterEach(() => {
try { repo.cleanup(); } catch { /* best-effort */ }
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
});
it('should write report file to .takt/runs/*/reports with expected content', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/report-judge.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/report-judge.json');
const result = runTakt({
args: [
'--task', 'Test report output',
'--piece', piecePath,
'--create-worktree', 'no',
'--provider', 'mock',
],
cwd: repo.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
},
timeout: 240_000,
});
expect(result.exitCode).toBe(0);
const runsDir = join(repo.path, '.takt', 'runs');
expect(existsSync(runsDir)).toBe(true);
const runDirs = readdirSync(runsDir).sort();
expect(runDirs.length).toBeGreaterThan(0);
const latestRun = runDirs[runDirs.length - 1]!;
const reportPath = join(runsDir, latestRun, 'reports', 'report.md');
expect(existsSync(reportPath)).toBe(true);
const report = readFileSync(reportPath, 'utf-8');
expect(report).toContain('Report summary: OK');
}, 240_000);
});

View File

@ -1,40 +1,23 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import {
createIsolatedEnv,
updateIsolatedConfig,
type IsolatedEnv,
} from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-run-multi-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Run multiple tasks (takt run)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');

View File

@ -171,4 +171,85 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => {
expect(stderr).not.toContain('UnhandledPromiseRejection');
}
}, 120_000);
it('should force exit immediately on second SIGINT', async () => {
const binPath = resolve(__dirname, '../../bin/takt');
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-slow-multi-step.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/run-sigint-parallel.json');
const tasksFile = join(testRepo.path, '.takt', 'tasks.yaml');
mkdirSync(join(testRepo.path, '.takt'), { recursive: true });
const now = new Date().toISOString();
writeFileSync(
tasksFile,
[
'tasks:',
' - name: sigint-a',
' status: pending',
' content: "E2E SIGINT task A"',
` piece: "${piecePath}"`,
' worktree: true',
` created_at: "${now}"`,
' started_at: null',
' completed_at: null',
' owner_pid: null',
' - name: sigint-b',
' status: pending',
' content: "E2E SIGINT task B"',
` piece: "${piecePath}"`,
' worktree: true',
` created_at: "${now}"`,
' started_at: null',
' completed_at: null',
' owner_pid: null',
' - name: sigint-c',
' status: pending',
' content: "E2E SIGINT task C"',
` piece: "${piecePath}"`,
' worktree: true',
` created_at: "${now}"`,
' started_at: null',
' completed_at: null',
' owner_pid: null',
].join('\n'),
'utf-8',
);
const child = spawn('node', [binPath, 'run', '--provider', 'mock'], {
cwd: testRepo.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
TAKT_E2E_SELF_SIGINT_TWICE: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
const workersFilled = await waitFor(
() => stdout.includes('=== Task: sigint-b ==='),
30_000,
20,
);
expect(workersFilled, `stdout:\n${stdout}\n\nstderr:\n${stderr}`).toBe(true);
const exit = await waitForClose(child, 60_000);
expect(
exit.signal === 'SIGINT' || exit.code === 130,
`unexpected exit: code=${exit.code}, signal=${exit.signal}`,
).toBe(true);
if (stderr.trim().length > 0) {
expect(stderr).not.toContain('UnhandledPromiseRejection');
}
}, 120_000);
});

View File

@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
import { readSessionRecords } from '../helpers/session-log';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Session NDJSON log output (mock)', () => {
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 write piece_start, step_complete, and piece_complete on success', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
const result = runTakt({
args: [
'--task', 'Test session log success',
'--piece', piecePath,
'--create-worktree', 'no',
'--provider', 'mock',
],
cwd: repo.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
},
timeout: 240_000,
});
expect(result.exitCode).toBe(0);
const records = readSessionRecords(repo.path);
expect(records.some((r) => r.type === 'piece_start')).toBe(true);
expect(records.some((r) => r.type === 'step_complete')).toBe(true);
expect(records.some((r) => r.type === 'piece_complete')).toBe(true);
}, 240_000);
it('should write piece_abort with reason on failure', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-no-match.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/no-match.json');
const result = runTakt({
args: [
'--task', 'Test session log abort',
'--piece', piecePath,
'--create-worktree', 'no',
'--provider', 'mock',
],
cwd: repo.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
},
timeout: 240_000,
});
expect(result.exitCode).not.toBe(0);
const records = readSessionRecords(repo.path);
const abortRecord = records.find((r) => r.type === 'piece_abort');
expect(abortRecord).toBeDefined();
expect(typeof abortRecord?.reason).toBe('string');
expect((abortRecord?.reason as string).length).toBeGreaterThan(0);
}, 240_000);
});

View File

@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
import { runTakt } from '../helpers/takt-runner';
import { readSessionRecords } from '../helpers/session-log';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* E2E: Structured output for status judgment (Phase 3).
*
* Verifies that real providers (Claude, Codex, OpenCode) can execute a piece
* where the status judgment phase uses structured output (`outputSchema`)
* internally via `judgeStatus()`.
*
* The piece has 2 rules per step, so `judgeStatus` cannot auto-select
* and must actually call the provider with an outputSchema to determine
* which rule matched.
*
* If structured output works correctly, `judgeStatus` extracts the step
* number from `response.structuredOutput.step` (recorded as `structured_output`).
* If the agent happens to output `[STEP:N]` tags, the RuleEvaluator detects
* them as `phase3_tag`/`phase1_tag` (recorded as `tag_fallback` in session log).
* The session log matchMethod is transformed by `toJudgmentMatchMethod()`.
*
* Run with:
* TAKT_E2E_PROVIDER=claude vitest run --config vitest.config.e2e.structured-output.ts
* TAKT_E2E_PROVIDER=codex vitest run --config vitest.config.e2e.structured-output.ts
* TAKT_E2E_PROVIDER=opencode TAKT_E2E_MODEL=openai/gpt-4 vitest run --config vitest.config.e2e.structured-output.ts
*/
describe('E2E: Structured output rule matching', () => {
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 complete piece via Phase 3 status judgment with 2-rule step', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/structured-output.yaml');
const result = runTakt({
args: [
'--task', 'Say hello',
'--piece', piecePath,
'--create-worktree', 'no',
],
cwd: repo.path,
env: isolatedEnv.env,
timeout: 240_000,
});
if (result.exitCode !== 0) {
console.log('=== STDOUT ===\n', result.stdout);
console.log('=== STDERR ===\n', result.stderr);
}
// Always log the matchMethod for diagnostic purposes
const allRecords = readSessionRecords(repo.path);
const sc = allRecords.find((r) => r.type === 'step_complete');
console.log(`=== matchMethod: ${sc?.matchMethod ?? '(none)'} ===`);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Piece completed');
// Verify session log has proper step_complete with matchMethod
const records = readSessionRecords(repo.path);
const pieceComplete = records.find((r) => r.type === 'piece_complete');
expect(pieceComplete).toBeDefined();
const stepComplete = records.find((r) => r.type === 'step_complete');
expect(stepComplete).toBeDefined();
// matchMethod should be present — the 2-rule step required actual judgment
// (auto_select is only used for single-rule steps)
const matchMethod = stepComplete?.matchMethod as string | undefined;
expect(matchMethod).toBeDefined();
// Session log records transformed matchMethod via toJudgmentMatchMethod():
// structured_output → structured_output (judgeStatus extracted from structuredOutput.step)
// phase3_tag / phase1_tag → tag_fallback (agent output [STEP:N] tag, detected by RuleEvaluator)
// ai_judge / ai_judge_fallback → ai_judge (AI evaluated conditions as fallback)
const validMethods = ['structured_output', 'tag_fallback', 'ai_judge'];
expect(validMethods).toContain(matchMethod);
}, 240_000);
});

View File

@ -1,40 +1,23 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process';
import {
createIsolatedEnv,
updateIsolatedConfig,
type IsolatedEnv,
} from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function createLocalRepo(): { path: string; cleanup: () => void } {
const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-contentfile-'));
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: () => {
try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ }
},
};
}
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Task content_file reference (mock)', () => {
let isolatedEnv: IsolatedEnv;
let repo: { path: string; cleanup: () => void };
let repo: LocalRepo;
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');

View File

@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { parse as parseYaml } from 'yaml';
import { createIsolatedEnv, updateIsolatedConfig, type IsolatedEnv } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function writeSinglePendingTask(repoPath: string, piecePath: string): void {
const now = new Date().toISOString();
mkdirSync(join(repoPath, '.takt'), { recursive: true });
writeFileSync(
join(repoPath, '.takt', 'tasks.yaml'),
[
'tasks:',
' - name: task-1',
' status: pending',
' content: "Task 1"',
` piece: "${piecePath}"`,
` created_at: "${now}"`,
' started_at: null',
' completed_at: null',
].join('\n'),
'utf-8',
);
}
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: Task status persistence in tasks.yaml (mock)', () => {
let isolatedEnv: IsolatedEnv;
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();
repo = createLocalRepo();
updateIsolatedConfig(isolatedEnv.taktDir, {
provider: 'mock',
});
});
afterEach(() => {
try { repo.cleanup(); } catch { /* best-effort */ }
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
});
it('should remove task record after successful completion', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
writeSinglePendingTask(repo.path, piecePath);
const result = runTakt({
args: ['run', '--provider', 'mock'],
cwd: repo.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
},
timeout: 240_000,
});
expect(result.exitCode).toBe(0);
const tasksContent = readFileSync(join(repo.path, '.takt', 'tasks.yaml'), 'utf-8');
const tasks = parseYaml(tasksContent) as { tasks: Array<Record<string, unknown>> };
expect(Array.isArray(tasks.tasks)).toBe(true);
expect(tasks.tasks.length).toBe(0);
}, 240_000);
it('should persist failed status and failure details on failure', () => {
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-no-match.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/no-match.json');
writeSinglePendingTask(repo.path, piecePath);
const result = runTakt({
args: ['run', '--provider', 'mock'],
cwd: repo.path,
env: {
...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath,
},
timeout: 240_000,
});
expect(result.exitCode).toBe(0);
const tasksContent = readFileSync(join(repo.path, '.takt', 'tasks.yaml'), 'utf-8');
const tasks = parseYaml(tasksContent) as {
tasks: Array<{
status: string;
started_at: string | null;
completed_at: string | null;
failure?: { error?: string };
}>;
};
expect(tasks.tasks.length).toBe(1);
expect(tasks.tasks[0]?.status).toBe('failed');
expect(tasks.tasks[0]?.started_at).toBeTruthy();
expect(tasks.tasks[0]?.completed_at).toBeTruthy();
expect(tasks.tasks[0]?.failure?.error).toBeTruthy();
}, 240_000);
});

4
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{
"name": "takt",
"version": "0.12.1",
"version": "0.13.0-alpha.1",
"description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@ -14,8 +14,8 @@
"watch": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "npm run test:e2e:all",
"test:e2e:mock": "vitest run --config vitest.config.e2e.mock.ts --reporter=verbose",
"test:e2e": "npm run test:e2e:mock; code=$?; if [ \"$code\" -eq 0 ]; then msg='test:e2e passed'; else msg=\"test:e2e failed (exit=$code)\"; fi; if command -v osascript >/dev/null 2>&1; then osascript -e \"display notification \\\"$msg\\\" with title \\\"takt\\\" subtitle \\\"E2E\\\"\" >/dev/null 2>&1 || true; fi; echo \"[takt] $msg\"; exit $code",
"test:e2e:mock": "TAKT_E2E_PROVIDER=mock vitest run --config vitest.config.e2e.mock.ts --reporter=verbose",
"test:e2e:all": "npm run test:e2e:mock && npm run test:e2e:provider",
"test:e2e:provider": "npm run test:e2e:provider:claude && npm run test:e2e:provider:codex",
"test:e2e:provider:claude": "TAKT_E2E_PROVIDER=claude vitest run --config vitest.config.e2e.provider.ts --reporter=verbose",

View File

@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('chalk', () => {
const passthrough = (value: string) => value;
const bold = Object.assign((value: string) => value, {
cyan: (value: string) => value,
});
return {
default: {
gray: passthrough,
blue: passthrough,
yellow: passthrough,
red: passthrough,
green: passthrough,
white: passthrough,
bold,
},
};
});
import { LogManager } from '../shared/ui/LogManager.js';
describe('LogManager', () => {
beforeEach(() => {
// Given: テスト間でシングルトン状態が共有されないようにする
LogManager.resetInstance();
vi.clearAllMocks();
});
it('should filter by info level as debug=false, info=true, error=true', () => {
// Given: ログレベルが info
const manager = LogManager.getInstance();
manager.setLogLevel('info');
// When: 各レベルの出力可否を判定する
const debugResult = manager.shouldLog('debug');
const infoResult = manager.shouldLog('info');
const errorResult = manager.shouldLog('error');
// Then: info基準のフィルタリングが適用される
expect(debugResult).toBe(false);
expect(infoResult).toBe(true);
expect(errorResult).toBe(true);
});
it('should reflect level change after setLogLevel', () => {
// Given: 初期レベルinfo
const manager = LogManager.getInstance();
// When: warn レベルに変更する
manager.setLogLevel('warn');
// Then: info は抑制され warn は出力対象になる
expect(manager.shouldLog('info')).toBe(false);
expect(manager.shouldLog('warn')).toBe(true);
});
it('should clear singleton state when resetInstance is called', () => {
// Given: エラーレベルに変更済みのインスタンス
const first = LogManager.getInstance();
first.setLogLevel('error');
expect(first.shouldLog('info')).toBe(false);
// When: シングルトンをリセットして再取得する
LogManager.resetInstance();
const second = LogManager.getInstance();
// Then: 新しいインスタンスは初期レベルに戻る
expect(second.shouldLog('info')).toBe(true);
});
});

View File

@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAbortSignal } from '../core/piece/engine/abort-signal.js';
describe('buildAbortSignal', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('タイムアウトでabortされる', () => {
const { signal, dispose } = buildAbortSignal(100, undefined);
expect(signal.aborted).toBe(false);
vi.advanceTimersByTime(100);
expect(signal.aborted).toBe(true);
expect(signal.reason).toBeInstanceOf(Error);
expect((signal.reason as Error).message).toBe('Part timeout after 100ms');
dispose();
});
it('親シグナルがabortされると子シグナルへ伝搬する', () => {
const parent = new AbortController();
const { signal, dispose } = buildAbortSignal(1000, parent.signal);
const reason = new Error('parent aborted');
parent.abort(reason);
expect(signal.aborted).toBe(true);
expect(signal.reason).toBe(reason);
dispose();
});
it('disposeでタイマーと親リスナーを解放する', () => {
const parent = new AbortController();
const addSpy = vi.spyOn(parent.signal, 'addEventListener');
const removeSpy = vi.spyOn(parent.signal, 'removeEventListener');
const { signal, dispose } = buildAbortSignal(100, parent.signal);
expect(addSpy).toHaveBeenCalledTimes(1);
dispose();
vi.advanceTimersByTime(200);
expect(signal.aborted).toBe(false);
expect(removeSpy).toHaveBeenCalledTimes(1);
});
it('親シグナルが既にabort済みなら即時伝搬する', () => {
const parent = new AbortController();
const reason = new Error('already aborted');
const addSpy = vi.spyOn(parent.signal, 'addEventListener');
parent.abort(reason);
const { signal, dispose } = buildAbortSignal(1000, parent.signal);
expect(signal.aborted).toBe(true);
expect(signal.reason).toBe(reason);
expect(addSpy).not.toHaveBeenCalled();
dispose();
});
});

View File

@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { runAgent } from '../agents/runner.js';
import { parseParts } from '../core/piece/engine/task-decomposer.js';
import { detectJudgeIndex } from '../agents/judge-utils.js';
import {
executeAgent,
generateReport,
executePart,
evaluateCondition,
judgeStatus,
decomposeTask,
} from '../core/piece/agent-usecases.js';
vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
vi.mock('../core/piece/schema-loader.js', () => ({
loadJudgmentSchema: vi.fn(() => ({ type: 'judgment' })),
loadEvaluationSchema: vi.fn(() => ({ type: 'evaluation' })),
loadDecompositionSchema: vi.fn((maxParts: number) => ({ type: 'decomposition', maxParts })),
}));
vi.mock('../core/piece/engine/task-decomposer.js', () => ({
parseParts: vi.fn(),
}));
vi.mock('../agents/judge-utils.js', () => ({
buildJudgePrompt: vi.fn(() => 'judge prompt'),
detectJudgeIndex: vi.fn(() => -1),
}));
function doneResponse(content: string, structuredOutput?: Record<string, unknown>) {
return {
persona: 'tester',
status: 'done' as const,
content,
timestamp: new Date('2026-02-12T00:00:00Z'),
structuredOutput,
};
}
const judgeOptions = { cwd: '/repo', movementName: 'review' };
describe('agent-usecases', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('executeAgent/generateReport/executePart は runAgent に委譲する', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('ok'));
await executeAgent('coder', 'do work', { cwd: '/tmp' });
await generateReport('coder', 'write report', { cwd: '/tmp' });
await executePart('coder', 'part work', { cwd: '/tmp' });
expect(runAgent).toHaveBeenCalledTimes(3);
expect(runAgent).toHaveBeenNthCalledWith(1, 'coder', 'do work', { cwd: '/tmp' });
expect(runAgent).toHaveBeenNthCalledWith(2, 'coder', 'write report', { cwd: '/tmp' });
expect(runAgent).toHaveBeenNthCalledWith(3, 'coder', 'part work', { cwd: '/tmp' });
});
it('evaluateCondition は構造化出力の matched_index を優先する', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('ignored', { matched_index: 2 }));
const result = await evaluateCondition('agent output', [
{ index: 0, text: 'first' },
{ index: 1, text: 'second' },
], { cwd: '/repo' });
expect(result).toBe(1);
expect(runAgent).toHaveBeenCalledWith(undefined, 'judge prompt', expect.objectContaining({
cwd: '/repo',
outputSchema: { type: 'evaluation' },
}));
});
it('evaluateCondition は構造化出力が使えない場合にタグ検出へフォールバックする', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('[JUDGE:2]'));
vi.mocked(detectJudgeIndex).mockReturnValue(1);
const result = await evaluateCondition('agent output', [
{ index: 0, text: 'first' },
{ index: 1, text: 'second' },
], { cwd: '/repo' });
expect(result).toBe(1);
expect(detectJudgeIndex).toHaveBeenCalledWith('[JUDGE:2]');
});
it('evaluateCondition は runAgent が done 以外なら -1 を返す', async () => {
vi.mocked(runAgent).mockResolvedValue({
persona: 'tester',
status: 'error',
content: 'failed',
timestamp: new Date('2026-02-12T00:00:00Z'),
});
const result = await evaluateCondition('agent output', [
{ index: 0, text: 'first' },
], { cwd: '/repo' });
expect(result).toBe(-1);
expect(detectJudgeIndex).not.toHaveBeenCalled();
});
// --- judgeStatus: 3-stage fallback ---
it('judgeStatus は単一ルール時に auto_select を返す', async () => {
const result = await judgeStatus('structured', 'tag', [{ condition: 'always', next: 'done' }], judgeOptions);
expect(result).toEqual({ ruleIndex: 0, method: 'auto_select' });
expect(runAgent).not.toHaveBeenCalled();
});
it('judgeStatus はルールが空ならエラー', async () => {
await expect(judgeStatus('structured', 'tag', [], judgeOptions))
.rejects.toThrow('judgeStatus requires at least one rule');
});
it('judgeStatus は Stage 1 で構造化出力 step を採用する', async () => {
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('x', { step: 2 }));
const result = await judgeStatus('structured', 'tag', [
{ condition: 'a', next: 'one' },
{ condition: 'b', next: 'two' },
], judgeOptions);
expect(result).toEqual({ ruleIndex: 1, method: 'structured_output' });
expect(runAgent).toHaveBeenCalledTimes(1);
expect(runAgent).toHaveBeenCalledWith('conductor', 'structured', expect.objectContaining({
outputSchema: { type: 'judgment' },
}));
});
it('judgeStatus は Stage 2 でタグ検出を使う', async () => {
// Stage 1: structured output fails (no structuredOutput)
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no match'));
// Stage 2: tag detection succeeds
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('[REVIEW:2]'));
const result = await judgeStatus('structured', 'tag', [
{ condition: 'a', next: 'one' },
{ condition: 'b', next: 'two' },
], judgeOptions);
expect(result).toEqual({ ruleIndex: 1, method: 'phase3_tag' });
expect(runAgent).toHaveBeenCalledTimes(2);
expect(runAgent).toHaveBeenNthCalledWith(1, 'conductor', 'structured', expect.objectContaining({
outputSchema: { type: 'judgment' },
}));
expect(runAgent).toHaveBeenNthCalledWith(2, 'conductor', 'tag', expect.not.objectContaining({
outputSchema: expect.anything(),
}));
});
it('judgeStatus は Stage 3 で AI Judge を使う', async () => {
// Stage 1: structured output fails
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no match'));
// Stage 2: tag detection fails
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no tag'));
// Stage 3: evaluateCondition succeeds
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('ignored', { matched_index: 2 }));
const result = await judgeStatus('structured', 'tag', [
{ condition: 'a', next: 'one' },
{ condition: 'b', next: 'two' },
], judgeOptions);
expect(result).toEqual({ ruleIndex: 1, method: 'ai_judge' });
expect(runAgent).toHaveBeenCalledTimes(3);
});
it('judgeStatus は全ての判定に失敗したらエラー', async () => {
// Stage 1: structured output fails
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no match'));
// Stage 2: tag detection fails
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('no tag'));
// Stage 3: evaluateCondition fails
vi.mocked(runAgent).mockResolvedValueOnce(doneResponse('still no match'));
vi.mocked(detectJudgeIndex).mockReturnValue(-1);
await expect(judgeStatus('structured', 'tag', [
{ condition: 'a', next: 'one' },
{ condition: 'b', next: 'two' },
], judgeOptions)).rejects.toThrow('Status not found for movement "review"');
});
// --- decomposeTask ---
it('decomposeTask は構造化出力 parts を返す', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('x', {
parts: [
{ id: 'p1', title: 'Part 1', instruction: 'Do 1', timeout_ms: 1000 },
],
}));
const result = await decomposeTask('instruction', 3, { cwd: '/repo', persona: 'team-leader' });
expect(result).toEqual([
{ id: 'p1', title: 'Part 1', instruction: 'Do 1', timeoutMs: 1000 },
]);
expect(parseParts).not.toHaveBeenCalled();
});
it('decomposeTask は構造化出力がない場合 parseParts にフォールバックする', async () => {
vi.mocked(runAgent).mockResolvedValue(doneResponse('```json [] ```'));
vi.mocked(parseParts).mockReturnValue([
{ id: 'p1', title: 'Part 1', instruction: 'fallback', timeoutMs: undefined },
]);
const result = await decomposeTask('instruction', 2, { cwd: '/repo' });
expect(parseParts).toHaveBeenCalledWith('```json [] ```', 2);
expect(result).toEqual([
{ id: 'p1', title: 'Part 1', instruction: 'fallback', timeoutMs: undefined },
]);
});
it('decomposeTask は done 以外をエラーにする', async () => {
vi.mocked(runAgent).mockResolvedValue({
persona: 'team-leader',
status: 'error',
content: 'failure',
error: 'bad output',
timestamp: new Date('2026-02-12T00:00:00Z'),
});
await expect(decomposeTask('instruction', 2, { cwd: '/repo' }))
.rejects.toThrow('Team leader failed: bad output');
});
});

View File

@ -6,17 +6,9 @@
import { describe, it, expect, vi } from 'vitest';
import { handleBlocked } from '../core/piece/engine/blocked-handler.js';
import type { PieceMovement, AgentResponse } from '../core/models/types.js';
import type { AgentResponse } from '../core/models/types.js';
import type { PieceEngineOptions } from '../core/piece/types.js';
function makeMovement(): PieceMovement {
return {
name: 'test-movement',
personaDisplayName: 'tester',
instructionTemplate: '',
passPreviousResponse: false,
};
}
import { makeMovement } from './test-helpers.js';
function makeResponse(content: string): AgentResponse {
return {

View File

@ -0,0 +1,78 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
}));
import { execFileSync } from 'node:child_process';
import { parseDistinctHashes, runGit } from '../infra/task/branchGitCommands.js';
const mockExecFileSync = vi.mocked(execFileSync);
describe('parseDistinctHashes', () => {
it('should remove only consecutive duplicates', () => {
// Given: 連続重複と非連続重複を含む出力
const output = 'a\na\nb\nb\na\n';
// When: ハッシュを解析する
const result = parseDistinctHashes(output);
// Then: 連続重複のみ除去される
expect(result).toEqual(['a', 'b', 'a']);
});
it('should return empty array when output is empty', () => {
// Given: 空文字列
const output = '';
// When: ハッシュを解析する
const result = parseDistinctHashes(output);
// Then: 空配列を返す
expect(result).toEqual([]);
});
it('should trim each line and drop blank lines', () => {
// Given: 前後空白と空行を含む出力
const output = ' hash1 \n\n hash2\n \n';
// When: ハッシュを解析する
const result = parseDistinctHashes(output);
// Then: トリム済みの値のみ残る
expect(result).toEqual(['hash1', 'hash2']);
});
it('should return single hash as one-element array', () => {
// Given: 単一ハッシュ
const output = 'single-hash';
// When: ハッシュを解析する
const result = parseDistinctHashes(output);
// Then: 1件配列として返る
expect(result).toEqual(['single-hash']);
});
});
describe('runGit', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should execute git command with expected options and trim output', () => {
// Given: gitコマンドのモック応答
mockExecFileSync.mockReturnValue(' abc123 \n' as never);
// When: runGit を実行する
const result = runGit('/repo', ['rev-parse', 'HEAD']);
// Then: execFileSync が正しい引数で呼ばれ、trimされた値を返す
expect(mockExecFileSync).toHaveBeenCalledWith('git', ['rev-parse', 'HEAD'], {
cwd: '/repo',
encoding: 'utf-8',
stdio: 'pipe',
});
expect(result).toBe('abc123');
});
});

View File

@ -46,7 +46,7 @@ function setupRepoForIssue167(options?: { disableReflog?: boolean; firstBranchCo
writeAndCommit(repoDir, 'develop-takt.txt', 'develop takt\n', 'takt: old instruction on develop');
writeAndCommit(repoDir, 'develop-b.txt', 'develop b\n', 'develop commit B');
const taktBranch = 'takt/#167/fix-original-instruction';
const taktBranch = 'takt/167/fix-original-instruction';
runGit(repoDir, ['checkout', '-b', taktBranch]);
const firstBranchCommitMessage = options?.firstBranchCommitMessage ?? 'takt: github-issue-167-fix-original-instruction';
writeAndCommit(repoDir, 'task-1.txt', 'task1\n', firstBranchCommitMessage);

View File

@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
queryMock,
interruptMock,
AbortErrorMock,
} = vi.hoisted(() => {
const interruptMock = vi.fn(async () => {});
class AbortErrorMock extends Error {}
const queryMock = vi.fn(() => {
let interrupted = false;
interruptMock.mockImplementation(async () => {
interrupted = true;
});
return {
interrupt: interruptMock,
async *[Symbol.asyncIterator](): AsyncGenerator<never, void, unknown> {
while (!interrupted) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new AbortErrorMock('aborted');
},
};
});
return {
queryMock,
interruptMock,
AbortErrorMock,
};
});
vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
query: queryMock,
AbortError: AbortErrorMock,
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../shared/utils/index.js')>();
return {
...original,
createLogger: vi.fn().mockReturnValue({
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
}),
};
});
import { QueryExecutor } from '../infra/claude/executor.js';
describe('QueryExecutor abortSignal wiring', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('abortSignal 発火時に query.interrupt() を呼ぶ', async () => {
const controller = new AbortController();
const executor = new QueryExecutor();
const promise = executor.execute('test', {
cwd: '/tmp/project',
abortSignal: controller.signal,
});
await new Promise((resolve) => setTimeout(resolve, 20));
controller.abort();
const result = await promise;
expect(interruptMock).toHaveBeenCalledTimes(1);
expect(result.interrupted).toBe(true);
});
it('開始前に中断済みの signal でも query.interrupt() を呼ぶ', async () => {
const controller = new AbortController();
controller.abort();
const executor = new QueryExecutor();
const result = await executor.execute('test', {
cwd: '/tmp/project',
abortSignal: controller.signal,
});
expect(interruptMock).toHaveBeenCalledTimes(1);
expect(result.interrupted).toBe(true);
});
});

View File

@ -0,0 +1,164 @@
/**
* Claude SDK layer structured output tests.
*
* Tests two internal components:
* 1. SdkOptionsBuilder outputSchema outputFormat conversion
* 2. QueryExecutor structured_output extraction from SDK result messages
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
// ===== SdkOptionsBuilder tests (no mock needed) =====
import { buildSdkOptions } from '../infra/claude/options-builder.js';
describe('SdkOptionsBuilder — outputFormat 変換', () => {
it('outputSchema が outputFormat に変換される', () => {
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
const sdkOptions = buildSdkOptions({ cwd: '/tmp', outputSchema: schema });
expect((sdkOptions as Record<string, unknown>).outputFormat).toEqual({
type: 'json_schema',
schema,
});
});
it('outputSchema 未設定なら outputFormat は含まれない', () => {
const sdkOptions = buildSdkOptions({ cwd: '/tmp' });
expect(sdkOptions).not.toHaveProperty('outputFormat');
});
});
// ===== QueryExecutor tests (mock @anthropic-ai/claude-agent-sdk) =====
const { mockQuery } = vi.hoisted(() => ({
mockQuery: vi.fn(),
}));
vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
query: mockQuery,
AbortError: class AbortError extends Error {
constructor(message?: string) {
super(message);
this.name = 'AbortError';
}
},
}));
// QueryExecutor は executor.ts 内で query() を使うため、mock 後にインポート
const { QueryExecutor } = await import('../infra/claude/executor.js');
/**
* query() Query async iterable + interrupt
*/
function createMockQuery(messages: Array<Record<string, unknown>>) {
return {
[Symbol.asyncIterator]: async function* () {
for (const msg of messages) {
yield msg;
}
},
interrupt: vi.fn(),
};
}
describe('QueryExecutor — structuredOutput 抽出', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('result メッセージの structured_output (snake_case) を抽出する', async () => {
mockQuery.mockReturnValue(createMockQuery([
{ type: 'result', subtype: 'success', result: 'done', structured_output: { step: 2 } },
]));
const executor = new QueryExecutor();
const result = await executor.execute('test', { cwd: '/tmp' });
expect(result.success).toBe(true);
expect(result.structuredOutput).toEqual({ step: 2 });
});
it('result メッセージの structuredOutput (camelCase) を抽出する', async () => {
mockQuery.mockReturnValue(createMockQuery([
{ type: 'result', subtype: 'success', result: 'done', structuredOutput: { step: 3 } },
]));
const executor = new QueryExecutor();
const result = await executor.execute('test', { cwd: '/tmp' });
expect(result.structuredOutput).toEqual({ step: 3 });
});
it('structured_output が snake_case 優先 (snake_case と camelCase 両方ある場合)', async () => {
mockQuery.mockReturnValue(createMockQuery([
{
type: 'result',
subtype: 'success',
result: 'done',
structured_output: { step: 1 },
structuredOutput: { step: 9 },
},
]));
const executor = new QueryExecutor();
const result = await executor.execute('test', { cwd: '/tmp' });
expect(result.structuredOutput).toEqual({ step: 1 });
});
it('structuredOutput がない場合は undefined', async () => {
mockQuery.mockReturnValue(createMockQuery([
{ type: 'result', subtype: 'success', result: 'plain text' },
]));
const executor = new QueryExecutor();
const result = await executor.execute('test', { cwd: '/tmp' });
expect(result.structuredOutput).toBeUndefined();
});
it('structured_output が配列の場合は無視する', async () => {
mockQuery.mockReturnValue(createMockQuery([
{ type: 'result', subtype: 'success', result: 'done', structured_output: [1, 2, 3] },
]));
const executor = new QueryExecutor();
const result = await executor.execute('test', { cwd: '/tmp' });
expect(result.structuredOutput).toBeUndefined();
});
it('structured_output が null の場合は無視する', async () => {
mockQuery.mockReturnValue(createMockQuery([
{ type: 'result', subtype: 'success', result: 'done', structured_output: null },
]));
const executor = new QueryExecutor();
const result = await executor.execute('test', { cwd: '/tmp' });
expect(result.structuredOutput).toBeUndefined();
});
it('assistant テキストと structured_output を同時に取得する', async () => {
mockQuery.mockReturnValue(createMockQuery([
{
type: 'assistant',
message: { content: [{ type: 'text', text: 'thinking...' }] },
},
{
type: 'result',
subtype: 'success',
result: 'final text',
structured_output: { step: 1, reason: 'approved' },
},
]));
const executor = new QueryExecutor();
const result = await executor.execute('test', { cwd: '/tmp' });
expect(result.success).toBe(true);
expect(result.content).toBe('final text');
expect(result.structuredOutput).toEqual({ step: 1, reason: 'approved' });
});
});

View File

@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AgentSetup } from '../infra/providers/types.js';
const {
mockCallClaude,
mockResolveAnthropicApiKey,
} = vi.hoisted(() => ({
mockCallClaude: vi.fn(),
mockResolveAnthropicApiKey: vi.fn(),
}));
vi.mock('../infra/claude/client.js', () => ({
callClaude: mockCallClaude,
callClaudeCustom: vi.fn(),
callClaudeAgent: vi.fn(),
callClaudeSkill: vi.fn(),
}));
vi.mock('../infra/config/index.js', () => ({
resolveAnthropicApiKey: mockResolveAnthropicApiKey,
}));
import { ClaudeProvider } from '../infra/providers/claude.js';
describe('ClaudeProvider abortSignal wiring', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveAnthropicApiKey.mockReturnValue(undefined);
mockCallClaude.mockResolvedValue({
persona: 'coder',
status: 'done',
content: 'ok',
timestamp: new Date(),
});
});
it('ProviderCallOptions.abortSignal を Claude call options に渡す', async () => {
const provider = new ClaudeProvider();
const setup: AgentSetup = { name: 'coder' };
const agent = provider.setup(setup);
const controller = new AbortController();
await agent.call('test prompt', {
cwd: '/tmp/project',
abortSignal: controller.signal,
});
expect(mockCallClaude).toHaveBeenCalledTimes(1);
const callOptions = mockCallClaude.mock.calls[0]?.[2];
expect(callOptions).toHaveProperty('abortSignal', controller.signal);
});
});

View File

@ -3,10 +3,8 @@
*/
import { describe, it, expect } from 'vitest';
import {
detectRuleIndex,
isRegexSafe,
} from '../infra/claude/client.js';
import { isRegexSafe } from '../infra/claude/utils.js';
import { detectRuleIndex } from '../shared/utils/ruleIndex.js';
describe('isRegexSafe', () => {
it('should accept simple patterns', () => {

View File

@ -207,7 +207,7 @@ describe('branch and worktree path formatting with issue numbers', () => {
});
}
it('should format branch as takt/#{issue}/{slug} when issue number is provided', () => {
it('should format branch as takt/{issue}/{slug} when issue number is provided', () => {
// Given: issue number 99 with slug
setupMockForPathTest();
@ -219,7 +219,7 @@ describe('branch and worktree path formatting with issue numbers', () => {
});
// Then: branch should use issue format
expect(result.branch).toBe('takt/#99/fix-login-timeout');
expect(result.branch).toBe('takt/99/fix-login-timeout');
});
it('should format branch as takt/{timestamp}-{slug} when no issue number', () => {

View File

@ -0,0 +1,152 @@
/**
* Codex SDK layer structured output tests.
*
* Tests CodexClient's extraction of structuredOutput by parsing
* JSON text from agent_message items when outputSchema is provided.
*
* Codex SDK returns structured output as JSON text in agent_message
* items (not via turn.completed.finalResponse which doesn't exist
* on TurnCompletedEvent).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
// ===== Codex SDK mock =====
let mockEvents: Array<Record<string, unknown>> = [];
vi.mock('@openai/codex-sdk', () => {
return {
Codex: class MockCodex {
async startThread() {
return {
id: 'thread-mock',
runStreamed: async () => ({
events: (async function* () {
for (const event of mockEvents) {
yield event;
}
})(),
}),
};
}
async resumeThread() {
return this.startThread();
}
},
};
});
// CodexClient は @openai/codex-sdk をインポートするため、mock 後にインポート
const { CodexClient } = await import('../infra/codex/client.js');
describe('CodexClient — structuredOutput 抽出', () => {
beforeEach(() => {
vi.clearAllMocks();
mockEvents = [];
});
it('outputSchema 指定時に agent_message の JSON テキストを structuredOutput として返す', async () => {
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
mockEvents = [
{ type: 'thread.started', thread_id: 'thread-1' },
{
type: 'item.completed',
item: { id: 'msg-1', type: 'agent_message', text: '{"step": 2, "reason": "approved"}' },
},
{ type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } },
];
const client = new CodexClient();
const result = await client.call('coder', 'prompt', { cwd: '/tmp', outputSchema: schema });
expect(result.status).toBe('done');
expect(result.structuredOutput).toEqual({ step: 2, reason: 'approved' });
});
it('outputSchema なしの場合はテキストを JSON パースしない', async () => {
mockEvents = [
{ type: 'thread.started', thread_id: 'thread-1' },
{
type: 'item.completed',
item: { id: 'msg-1', type: 'agent_message', text: '{"step": 2}' },
},
{ type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } },
];
const client = new CodexClient();
const result = await client.call('coder', 'prompt', { cwd: '/tmp' });
expect(result.status).toBe('done');
expect(result.structuredOutput).toBeUndefined();
});
it('agent_message が JSON でない場合は undefined', async () => {
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
mockEvents = [
{ type: 'thread.started', thread_id: 'thread-1' },
{
type: 'item.completed',
item: { id: 'msg-1', type: 'agent_message', text: 'plain text response' },
},
{ type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } },
];
const client = new CodexClient();
const result = await client.call('coder', 'prompt', { cwd: '/tmp', outputSchema: schema });
expect(result.status).toBe('done');
expect(result.structuredOutput).toBeUndefined();
});
it('JSON が配列の場合は無視する', async () => {
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
mockEvents = [
{ type: 'thread.started', thread_id: 'thread-1' },
{
type: 'item.completed',
item: { id: 'msg-1', type: 'agent_message', text: '[1, 2, 3]' },
},
{ type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } },
];
const client = new CodexClient();
const result = await client.call('coder', 'prompt', { cwd: '/tmp', outputSchema: schema });
expect(result.structuredOutput).toBeUndefined();
});
it('agent_message がない場合は structuredOutput なし', async () => {
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
mockEvents = [
{ type: 'thread.started', thread_id: 'thread-1' },
{ type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } },
];
const client = new CodexClient();
const result = await client.call('coder', 'prompt', { cwd: '/tmp', outputSchema: schema });
expect(result.status).toBe('done');
expect(result.structuredOutput).toBeUndefined();
});
it('outputSchema 付きで呼び出して structuredOutput が返る', async () => {
const schema = { type: 'object', properties: { step: { type: 'integer' } } };
mockEvents = [
{ type: 'thread.started', thread_id: 'thread-1' },
{
type: 'item.completed',
item: { id: 'msg-1', type: 'agent_message', text: '{"step": 1}' },
},
{ type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } },
];
const client = new CodexClient();
const result = await client.call('coder', 'prompt', {
cwd: '/tmp',
outputSchema: schema,
});
expect(result.structuredOutput).toEqual({ step: 1 });
});
});

View File

@ -76,7 +76,7 @@ describe('createIsolatedEnv', () => {
expect(isolated.env.GIT_CONFIG_GLOBAL).toContain('takt-e2e-');
});
it('should create config.yaml from E2E fixture with notification_sound timing controls', () => {
it('should create config.yaml from E2E fixture with notification_sound disabled', () => {
const isolated = createIsolatedEnv();
cleanups.push(isolated.cleanup);
@ -86,13 +86,13 @@ describe('createIsolatedEnv', () => {
expect(config.language).toBe('en');
expect(config.log_level).toBe('info');
expect(config.default_piece).toBe('default');
expect(config.notification_sound).toBe(true);
expect(config.notification_sound).toBe(false);
expect(config.notification_sound_events).toEqual({
iteration_limit: false,
piece_complete: false,
piece_abort: false,
run_complete: true,
run_abort: true,
run_abort: false,
});
});
@ -120,13 +120,13 @@ describe('createIsolatedEnv', () => {
expect(config.provider).toBe('mock');
expect(config.concurrency).toBe(2);
expect(config.notification_sound).toBe(true);
expect(config.notification_sound).toBe(false);
expect(config.notification_sound_events).toEqual({
iteration_limit: false,
piece_complete: false,
piece_abort: false,
run_complete: true,
run_abort: true,
run_abort: false,
});
expect(config.language).toBe('en');
});
@ -149,7 +149,7 @@ describe('createIsolatedEnv', () => {
piece_complete: false,
piece_abort: false,
run_complete: false,
run_abort: true,
run_abort: false,
});
});

View File

@ -25,7 +25,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({

View File

@ -21,7 +21,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async () => {

View File

@ -23,7 +23,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({

View File

@ -24,7 +24,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
@ -36,7 +36,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
import { PieceEngine } from '../core/piece/index.js';
import { runAgent } from '../agents/runner.js';
import { detectMatchedRule } from '../core/piece/index.js';
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
import {
makeResponse,
makeMovement,

View File

@ -28,7 +28,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({

View File

@ -27,7 +27,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({

View File

@ -23,7 +23,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
@ -35,7 +35,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
import { PieceEngine } from '../core/piece/index.js';
import { runAgent } from '../agents/runner.js';
import { detectMatchedRule } from '../core/piece/index.js';
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
import {
makeResponse,
makeMovement,

View File

@ -24,7 +24,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({

View File

@ -0,0 +1,172 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, rmSync } from 'node:fs';
import { runAgent } from '../agents/runner.js';
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
import { PieceEngine } from '../core/piece/engine/PieceEngine.js';
import { makeMovement, makeRule, makeResponse, createTestTmpDir, applyDefaultMocks } from './engine-test-helpers.js';
import type { PieceConfig } from '../core/models/index.js';
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().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<Record<string, unknown>>()),
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
}));
function buildTeamLeaderConfig(): PieceConfig {
return {
name: 'team-leader-piece',
initialMovement: 'implement',
maxMovements: 5,
movements: [
makeMovement('implement', {
instructionTemplate: 'Task: {task}',
teamLeader: {
persona: '../personas/team-leader.md',
maxParts: 3,
timeoutMs: 10000,
partPersona: '../personas/coder.md',
partAllowedTools: ['Read', 'Edit', 'Write'],
partEdit: true,
partPermissionMode: 'edit',
},
rules: [makeRule('done', 'COMPLETE')],
}),
],
};
}
describe('PieceEngine Integration: TeamLeaderRunner', () => {
let tmpDir: string;
beforeEach(() => {
vi.resetAllMocks();
applyDefaultMocks();
tmpDir = createTestTmpDir();
});
afterEach(() => {
if (existsSync(tmpDir)) {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it('team leaderが分解したパートを並列実行し集約する', async () => {
const config = buildTeamLeaderConfig();
const engine = new PieceEngine(config, tmpDir, 'implement feature', { projectCwd: tmpDir });
vi.mocked(runAgent)
.mockResolvedValueOnce(makeResponse({
persona: 'team-leader',
content: [
'```json',
'[{"id":"part-1","title":"API","instruction":"Implement API"},{"id":"part-2","title":"Test","instruction":"Add tests"}]',
'```',
].join('\n'),
}))
.mockResolvedValueOnce(makeResponse({ persona: 'coder', content: 'API done' }))
.mockResolvedValueOnce(makeResponse({ persona: 'coder', content: 'Tests done' }));
vi.mocked(detectMatchedRule).mockResolvedValueOnce({ index: 0, method: 'phase1_tag' });
const state = await engine.run();
expect(state.status).toBe('completed');
expect(vi.mocked(runAgent)).toHaveBeenCalledTimes(3);
const output = state.movementOutputs.get('implement');
expect(output).toBeDefined();
expect(output!.content).toContain('## decomposition');
expect(output!.content).toContain('## part-1: API');
expect(output!.content).toContain('API done');
expect(output!.content).toContain('## part-2: Test');
expect(output!.content).toContain('Tests done');
});
it('全パートが失敗した場合はムーブメント失敗として中断する', async () => {
const config = buildTeamLeaderConfig();
const engine = new PieceEngine(config, tmpDir, 'implement feature', { projectCwd: tmpDir });
vi.mocked(runAgent)
.mockResolvedValueOnce(makeResponse({
persona: 'team-leader',
content: [
'```json',
'[{"id":"part-1","title":"API","instruction":"Implement API"},{"id":"part-2","title":"Test","instruction":"Add tests"}]',
'```',
].join('\n'),
}))
.mockResolvedValueOnce(makeResponse({ persona: 'coder', status: 'error', error: 'api failed' }))
.mockResolvedValueOnce(makeResponse({ persona: 'coder', status: 'error', error: 'test failed' }));
const state = await engine.run();
expect(state.status).toBe('aborted');
});
it('一部パートが失敗しても成功パートがあれば集約結果は完了する', async () => {
const config = buildTeamLeaderConfig();
const engine = new PieceEngine(config, tmpDir, 'implement feature', { projectCwd: tmpDir });
vi.mocked(runAgent)
.mockResolvedValueOnce(makeResponse({
persona: 'team-leader',
content: [
'```json',
'[{"id":"part-1","title":"API","instruction":"Implement API"},{"id":"part-2","title":"Test","instruction":"Add tests"}]',
'```',
].join('\n'),
}))
.mockResolvedValueOnce(makeResponse({ persona: 'coder', content: 'API done' }))
.mockResolvedValueOnce(makeResponse({ persona: 'coder', status: 'error', error: 'test failed' }));
vi.mocked(detectMatchedRule).mockResolvedValueOnce({ index: 0, method: 'phase1_tag' });
const state = await engine.run();
expect(state.status).toBe('completed');
const output = state.movementOutputs.get('implement');
expect(output).toBeDefined();
expect(output!.content).toContain('## part-1: API');
expect(output!.content).toContain('API done');
expect(output!.content).toContain('## part-2: Test');
expect(output!.content).toContain('[ERROR] test failed');
});
it('パート失敗時にerrorがなくてもcontentの詳細をエラー表示に使う', async () => {
const config = buildTeamLeaderConfig();
const engine = new PieceEngine(config, tmpDir, 'implement feature', { projectCwd: tmpDir });
vi.mocked(runAgent)
.mockResolvedValueOnce(makeResponse({
persona: 'team-leader',
content: [
'```json',
'[{"id":"part-1","title":"API","instruction":"Implement API"},{"id":"part-2","title":"Test","instruction":"Add tests"}]',
'```',
].join('\n'),
}))
.mockResolvedValueOnce(makeResponse({ persona: 'coder', status: 'error', content: 'api failed from content' }))
.mockResolvedValueOnce(makeResponse({ persona: 'coder', content: 'Tests done' }));
vi.mocked(detectMatchedRule).mockResolvedValueOnce({ index: 0, method: 'phase1_tag' });
const state = await engine.run();
expect(state.status).toBe('completed');
const output = state.movementOutputs.get('implement');
expect(output).toBeDefined();
expect(output!.content).toContain('[ERROR] api failed from content');
});
});

View File

@ -10,18 +10,21 @@ import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import type { PieceConfig, PieceMovement, AgentResponse, PieceRule } from '../core/models/index.js';
import type { PieceConfig, PieceMovement, AgentResponse } from '../core/models/index.js';
import { makeRule } from './test-helpers.js';
// --- Mock imports (consumers must call vi.mock before importing this) ---
import { runAgent } from '../agents/runner.js';
import { detectMatchedRule } from '../core/piece/index.js';
import type { RuleMatch } from '../core/piece/index.js';
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../core/piece/index.js';
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
import type { RuleMatch } from '../core/piece/evaluation/index.js';
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../core/piece/phase-runner.js';
import { generateReportDir } from '../shared/utils/index.js';
// --- Factory functions ---
export { makeRule };
export function makeResponse(overrides: Partial<AgentResponse> = {}): AgentResponse {
return {
persona: 'test-agent',
@ -33,10 +36,6 @@ export function makeResponse(overrides: Partial<AgentResponse> = {}): AgentRespo
};
}
export function makeRule(condition: string, next: string, extra: Partial<PieceRule> = {}): PieceRule {
return { condition, next, ...extra };
}
export function makeMovement(name: string, overrides: Partial<PieceMovement> = {}): PieceMovement {
return {
name,
@ -174,7 +173,7 @@ export function createTestTmpDir(): string {
export function applyDefaultMocks(): void {
vi.mocked(needsStatusJudgmentPhase).mockReturnValue(false);
vi.mocked(runReportPhase).mockResolvedValue(undefined);
vi.mocked(runStatusJudgmentPhase).mockResolvedValue('');
vi.mocked(runStatusJudgmentPhase).mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' });
vi.mocked(generateReportDir).mockReturnValue('test-report-dir');
}

View File

@ -24,7 +24,7 @@ vi.mock('../core/piece/evaluation/index.js', () => ({
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
@ -35,7 +35,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
// --- Imports (after mocks) ---
import { PieceEngine } from '../core/piece/index.js';
import { runReportPhase } from '../core/piece/index.js';
import { runReportPhase } from '../core/piece/phase-runner.js';
import {
makeResponse,
makeMovement,

View File

@ -9,31 +9,7 @@ import {
escapeTemplateChars,
replaceTemplatePlaceholders,
} from '../core/piece/instruction/escape.js';
import type { PieceMovement } from '../core/models/types.js';
import type { InstructionContext } from '../core/piece/instruction/instruction-context.js';
function makeMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
return {
name: 'test-movement',
personaDisplayName: 'tester',
instructionTemplate: '',
passPreviousResponse: false,
...overrides,
};
}
function makeContext(overrides: Partial<InstructionContext> = {}): InstructionContext {
return {
task: 'test task',
iteration: 1,
maxMovements: 10,
movementIteration: 1,
cwd: '/tmp/test',
projectCwd: '/tmp/project',
userInputs: [],
...overrides,
};
}
import { makeMovement, makeInstructionContext } from './test-helpers.js';
describe('escapeTemplateChars', () => {
it('should replace curly braces with full-width equivalents', () => {
@ -62,7 +38,7 @@ describe('escapeTemplateChars', () => {
describe('replaceTemplatePlaceholders', () => {
it('should replace {task} placeholder', () => {
const step = makeMovement();
const ctx = makeContext({ task: 'implement feature X' });
const ctx = makeInstructionContext({ task: 'implement feature X' });
const template = 'Your task is: {task}';
const result = replaceTemplatePlaceholders(template, step, ctx);
@ -71,7 +47,7 @@ describe('replaceTemplatePlaceholders', () => {
it('should escape braces in task content', () => {
const step = makeMovement();
const ctx = makeContext({ task: 'fix {bug} in code' });
const ctx = makeInstructionContext({ task: 'fix {bug} in code' });
const template = '{task}';
const result = replaceTemplatePlaceholders(template, step, ctx);
@ -80,7 +56,7 @@ describe('replaceTemplatePlaceholders', () => {
it('should replace {iteration} and {max_movements}', () => {
const step = makeMovement();
const ctx = makeContext({ iteration: 3, maxMovements: 20 });
const ctx = makeInstructionContext({ iteration: 3, maxMovements: 20 });
const template = 'Iteration {iteration}/{max_movements}';
const result = replaceTemplatePlaceholders(template, step, ctx);
@ -89,7 +65,7 @@ describe('replaceTemplatePlaceholders', () => {
it('should replace {movement_iteration}', () => {
const step = makeMovement();
const ctx = makeContext({ movementIteration: 5 });
const ctx = makeInstructionContext({ movementIteration: 5 });
const template = 'Movement run #{movement_iteration}';
const result = replaceTemplatePlaceholders(template, step, ctx);
@ -98,7 +74,7 @@ describe('replaceTemplatePlaceholders', () => {
it('should replace {previous_response} when passPreviousResponse is true', () => {
const step = makeMovement({ passPreviousResponse: true });
const ctx = makeContext({
const ctx = makeInstructionContext({
previousOutput: {
persona: 'coder',
status: 'done',
@ -114,7 +90,7 @@ describe('replaceTemplatePlaceholders', () => {
it('should prefer preprocessed previous response text when provided', () => {
const step = makeMovement({ passPreviousResponse: true });
const ctx = makeContext({
const ctx = makeInstructionContext({
previousOutput: {
persona: 'coder',
status: 'done',
@ -131,7 +107,7 @@ describe('replaceTemplatePlaceholders', () => {
it('should replace {previous_response} with empty string when no previous output', () => {
const step = makeMovement({ passPreviousResponse: true });
const ctx = makeContext();
const ctx = makeInstructionContext();
const template = 'Previous: {previous_response}';
const result = replaceTemplatePlaceholders(template, step, ctx);
@ -140,7 +116,7 @@ describe('replaceTemplatePlaceholders', () => {
it('should not replace {previous_response} when passPreviousResponse is false', () => {
const step = makeMovement({ passPreviousResponse: false });
const ctx = makeContext({
const ctx = makeInstructionContext({
previousOutput: {
persona: 'coder',
status: 'done',
@ -156,7 +132,7 @@ describe('replaceTemplatePlaceholders', () => {
it('should replace {user_inputs} with joined inputs', () => {
const step = makeMovement();
const ctx = makeContext({ userInputs: ['input 1', 'input 2', 'input 3'] });
const ctx = makeInstructionContext({ userInputs: ['input 1', 'input 2', 'input 3'] });
const template = 'Inputs: {user_inputs}';
const result = replaceTemplatePlaceholders(template, step, ctx);
@ -165,7 +141,7 @@ describe('replaceTemplatePlaceholders', () => {
it('should replace {report_dir} with report directory', () => {
const step = makeMovement();
const ctx = makeContext({ reportDir: '/tmp/reports/run-1' });
const ctx = makeInstructionContext({ reportDir: '/tmp/reports/run-1' });
const template = 'Reports: {report_dir}';
const result = replaceTemplatePlaceholders(template, step, ctx);
@ -174,7 +150,7 @@ describe('replaceTemplatePlaceholders', () => {
it('should replace {report:filename} with full path', () => {
const step = makeMovement();
const ctx = makeContext({ reportDir: '/tmp/reports' });
const ctx = makeInstructionContext({ reportDir: '/tmp/reports' });
const template = 'Read {report:review.md} and {report:plan.md}';
const result = replaceTemplatePlaceholders(template, step, ctx);
@ -183,7 +159,7 @@ describe('replaceTemplatePlaceholders', () => {
it('should handle template with multiple different placeholders', () => {
const step = makeMovement();
const ctx = makeContext({
const ctx = makeInstructionContext({
task: 'test task',
iteration: 2,
maxMovements: 5,
@ -198,7 +174,7 @@ describe('replaceTemplatePlaceholders', () => {
it('should leave unreplaced placeholders when reportDir is undefined', () => {
const step = makeMovement();
const ctx = makeContext({ reportDir: undefined });
const ctx = makeInstructionContext({ reportDir: undefined });
const template = 'Dir: {report_dir} File: {report:test.md}';
const result = replaceTemplatePlaceholders(template, step, ctx);

View File

@ -114,6 +114,7 @@ describe('label integrity', () => {
expect(() => getLabel('piece.notifyComplete')).not.toThrow();
expect(() => getLabel('piece.notifyAbort')).not.toThrow();
expect(() => getLabel('piece.sigintGraceful')).not.toThrow();
expect(() => getLabel('piece.sigintTimeout')).not.toThrow();
expect(() => getLabel('piece.sigintForce')).not.toThrow();
});

View File

@ -10,31 +10,8 @@ import {
renderReportContext,
renderReportOutputInstruction,
} from '../core/piece/instruction/InstructionBuilder.js';
import type { PieceMovement, OutputContractEntry } from '../core/models/types.js';
import type { InstructionContext } from '../core/piece/instruction/instruction-context.js';
function makeMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
return {
name: 'test-movement',
personaDisplayName: 'tester',
instructionTemplate: '',
passPreviousResponse: false,
...overrides,
};
}
function makeContext(overrides: Partial<InstructionContext> = {}): InstructionContext {
return {
task: 'test task',
iteration: 1,
maxMovements: 10,
movementIteration: 1,
cwd: '/tmp/test',
projectCwd: '/tmp/project',
userInputs: [],
...overrides,
};
}
import type { OutputContractEntry } from '../core/models/types.js';
import { makeMovement, makeInstructionContext } from './test-helpers.js';
describe('isOutputContractItem', () => {
it('should return true for OutputContractItem (has name)', () => {
@ -84,19 +61,19 @@ describe('renderReportContext', () => {
describe('renderReportOutputInstruction', () => {
it('should return empty string when no output contracts', () => {
const step = makeMovement();
const ctx = makeContext({ reportDir: '/tmp/reports' });
const ctx = makeInstructionContext({ reportDir: '/tmp/reports' });
expect(renderReportOutputInstruction(step, ctx, 'en')).toBe('');
});
it('should return empty string when no reportDir', () => {
const step = makeMovement({ outputContracts: [{ name: 'report.md' }] });
const ctx = makeContext();
const ctx = makeInstructionContext();
expect(renderReportOutputInstruction(step, ctx, 'en')).toBe('');
});
it('should render English single-file instruction', () => {
const step = makeMovement({ outputContracts: [{ name: 'report.md' }] });
const ctx = makeContext({ reportDir: '/tmp/reports', movementIteration: 2 });
const ctx = makeInstructionContext({ reportDir: '/tmp/reports', movementIteration: 2 });
const result = renderReportOutputInstruction(step, ctx, 'en');
expect(result).toContain('Report output');
@ -108,7 +85,7 @@ describe('renderReportOutputInstruction', () => {
const step = makeMovement({
outputContracts: [{ name: 'plan.md' }, { name: 'review.md' }],
});
const ctx = makeContext({ reportDir: '/tmp/reports' });
const ctx = makeInstructionContext({ reportDir: '/tmp/reports' });
const result = renderReportOutputInstruction(step, ctx, 'en');
expect(result).toContain('Report Files');
@ -116,7 +93,7 @@ describe('renderReportOutputInstruction', () => {
it('should render Japanese single-file instruction', () => {
const step = makeMovement({ outputContracts: [{ name: 'report.md' }] });
const ctx = makeContext({ reportDir: '/tmp/reports', movementIteration: 1 });
const ctx = makeInstructionContext({ reportDir: '/tmp/reports', movementIteration: 1 });
const result = renderReportOutputInstruction(step, ctx, 'ja');
expect(result).toContain('レポート出力');
@ -128,7 +105,7 @@ describe('renderReportOutputInstruction', () => {
const step = makeMovement({
outputContracts: [{ name: 'plan.md' }, { name: 'review.md' }],
});
const ctx = makeContext({ reportDir: '/tmp/reports' });
const ctx = makeInstructionContext({ reportDir: '/tmp/reports' });
const result = renderReportOutputInstruction(step, ctx, 'ja');
expect(result).toContain('Report Files');

View File

@ -14,7 +14,8 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { setMockScenario, resetScenario } from '../infra/mock/index.js';
import type { PieceConfig, PieceMovement, PieceRule } from '../core/models/index.js';
import { detectRuleIndex } from '../infra/claude/index.js';
import { detectRuleIndex } from '../shared/utils/ruleIndex.js';
import { makeRule } from './test-helpers.js';
import { callAiJudge } from '../agents/ai-judge.js';
// --- Mocks ---
@ -30,7 +31,7 @@ vi.mock('../agents/ai-judge.js', async (importOriginal) => {
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
@ -56,10 +57,6 @@ import { PieceEngine } from '../core/piece/index.js';
// --- Test helpers ---
function makeRule(condition: string, next: string): PieceRule {
return { condition, next };
}
function makeMovement(name: string, agentPath: string, rules: PieceRule[]): PieceMovement {
return {
name,

View File

@ -8,7 +8,8 @@
*/
import { describe, it, expect, vi } from 'vitest';
import type { PieceMovement, PieceRule, AgentResponse } from '../core/models/index.js';
import type { PieceMovement, AgentResponse } from '../core/models/index.js';
import { makeRule } from './test-helpers.js';
vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
@ -34,10 +35,6 @@ function buildStatusJudgmentInstruction(movement: PieceMovement, ctx: StatusJudg
// --- Test helpers ---
function makeRule(condition: string, next: string, extra?: Partial<PieceRule>): PieceRule {
return { condition, next, ...extra };
}
function makeMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
return {
name: 'test-step',

View File

@ -104,8 +104,7 @@ vi.mock('../core/piece/index.js', () => ({
PieceEngine: MockPieceEngine,
}));
vi.mock('../infra/claude/index.js', () => ({
detectRuleIndex: vi.fn(),
vi.mock('../infra/claude/query-manager.js', () => ({
interruptAllQueries: mockInterruptAllQueries,
}));

View File

@ -15,7 +15,8 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { setMockScenario, resetScenario } from '../infra/mock/index.js';
import type { PieceConfig, PieceMovement, PieceRule } from '../core/models/index.js';
import { detectRuleIndex } from '../infra/claude/index.js';
import { detectRuleIndex } from '../shared/utils/ruleIndex.js';
import { makeRule } from './test-helpers.js';
import { callAiJudge } from '../agents/ai-judge.js';
// --- Mocks (minimal — only infrastructure, not core logic) ---
@ -34,7 +35,7 @@ vi.mock('../agents/ai-judge.js', async (importOriginal) => {
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
@ -59,10 +60,6 @@ import { PieceEngine } from '../core/piece/index.js';
// --- Test helpers ---
function makeRule(condition: string, next: string): PieceRule {
return { condition, next };
}
function makeMovement(name: string, agentPath: string, rules: PieceRule[]): PieceMovement {
return {
name,

View File

@ -13,7 +13,7 @@ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { setMockScenario, resetScenario } from '../infra/mock/index.js';
import { detectRuleIndex } from '../infra/claude/index.js';
import { detectRuleIndex } from '../shared/utils/ruleIndex.js';
import { callAiJudge } from '../agents/ai-judge.js';
// --- Mocks ---
@ -37,7 +37,7 @@ vi.mock('../agents/ai-judge.js', async (importOriginal) => {
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({

View File

@ -144,7 +144,7 @@ vi.mock('../shared/prompt/index.js', () => ({
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
// --- Imports (after mocks) ---

View File

@ -125,7 +125,7 @@ vi.mock('../shared/prompt/index.js', () => ({
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
}));
// --- Imports (after mocks) ---

View File

@ -16,6 +16,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { PieceMovement, PieceState, PieceRule, AgentResponse } from '../core/models/index.js';
import { makeRule } from './test-helpers.js';
// --- Mocks ---
@ -33,16 +34,13 @@ vi.mock('../infra/config/project/projectConfig.js', () => ({
// --- Imports (after mocks) ---
import { detectMatchedRule, evaluateAggregateConditions } from '../core/piece/index.js';
import { detectRuleIndex } from '../infra/claude/index.js';
import { evaluateAggregateConditions } from '../core/piece/index.js';
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
import { detectRuleIndex } from '../shared/utils/ruleIndex.js';
import type { RuleMatch, RuleEvaluatorContext } from '../core/piece/index.js';
// --- Test helpers ---
function makeRule(condition: string, next: string, extra?: Partial<PieceRule>): PieceRule {
return { condition, next, ...extra };
}
function makeMovement(
name: string,
rules: PieceRule[],

View File

@ -74,8 +74,8 @@ vi.mock('../core/piece/index.js', () => ({
PieceEngine: MockPieceEngine,
}));
vi.mock('../infra/claude/index.js', () => ({
detectRuleIndex: vi.fn(),
vi.mock('../infra/claude/query-manager.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
interruptAllQueries: mockInterruptAllQueries,
}));

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