From 40b3bb218583d266df6e67358a7e996b243dc482 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:43:08 +0900 Subject: [PATCH 01/19] takt: refactor-builtin-pieces --- builtins/en/pieces/coding.yaml | 22 ---- builtins/en/pieces/compound-eye.yaml | 5 - builtins/en/pieces/default.yaml | 33 ------ builtins/en/pieces/expert-cqrs.yaml | 44 -------- builtins/en/pieces/expert.yaml | 42 ------- builtins/en/pieces/magi.yaml | 4 - builtins/en/pieces/minimal.yaml | 17 --- builtins/en/pieces/passthrough.yaml | 5 - builtins/en/pieces/research.yaml | 4 - builtins/en/pieces/review-fix-minimal.yaml | 17 --- builtins/en/pieces/review-only.yaml | 22 ---- builtins/en/pieces/structural-reform.yaml | 25 ----- builtins/en/pieces/unit-test.yaml | 31 ----- builtins/ja/pieces/coding.yaml | 22 ---- builtins/ja/pieces/compound-eye.yaml | 5 - builtins/ja/pieces/default.yaml | 33 ------ builtins/ja/pieces/expert-cqrs.yaml | 44 -------- builtins/ja/pieces/expert.yaml | 42 ------- builtins/ja/pieces/magi.yaml | 4 - builtins/ja/pieces/minimal.yaml | 17 --- builtins/ja/pieces/passthrough.yaml | 5 - builtins/ja/pieces/research.yaml | 4 - builtins/ja/pieces/review-fix-minimal.yaml | 17 --- builtins/ja/pieces/review-only.yaml | 22 ---- builtins/ja/pieces/structural-reform.yaml | 25 ----- builtins/ja/pieces/unit-test.yaml | 31 ----- docs/faceted-prompting.ja.md | 33 ++---- docs/faceted-prompting.md | 33 ++---- src/__tests__/config.test.ts | 9 ++ src/__tests__/facet-resolution.test.ts | 106 ++++++++++++++++++ src/infra/config/loaders/pieceParser.ts | 17 +-- src/infra/config/loaders/resource-resolver.ts | 4 +- 32 files changed, 141 insertions(+), 603 deletions(-) diff --git a/builtins/en/pieces/coding.yaml b/builtins/en/pieces/coding.yaml index 2cc3841..eea3462 100644 --- a/builtins/en/pieces/coding.yaml +++ b/builtins/en/pieces/coding.yaml @@ -1,24 +1,6 @@ name: coding description: Lightweight development piece with planning and parallel reviews (plan -> implement -> parallel review -> complete) max_iterations: 20 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md -knowledge: - architecture: ../knowledge/architecture.md -personas: - planner: ../personas/planner.md - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - architecture-reviewer: ../personas/architecture-reviewer.md -instructions: - plan: ../instructions/plan.md - implement: ../instructions/implement.md - ai-review: ../instructions/ai-review.md - review-arch: ../instructions/review-arch.md - fix: ../instructions/fix.md initial_movement: plan movements: - name: plan @@ -150,7 +132,3 @@ movements: - condition: Cannot determine, insufficient information next: ABORT instruction: fix -report_formats: - plan: ../output-contracts/plan.md - ai-review: ../output-contracts/ai-review.md - architecture-review: ../output-contracts/architecture-review.md diff --git a/builtins/en/pieces/compound-eye.yaml b/builtins/en/pieces/compound-eye.yaml index f0c5e69..9be6d4e 100644 --- a/builtins/en/pieces/compound-eye.yaml +++ b/builtins/en/pieces/compound-eye.yaml @@ -1,11 +1,6 @@ name: compound-eye description: Multi-model review - send the same instruction to Claude and Codex simultaneously, synthesize both responses max_iterations: 10 -knowledge: - architecture: ../knowledge/architecture.md -personas: - coder: ../personas/coder.md - supervisor: ../personas/supervisor.md initial_movement: evaluate movements: - name: evaluate diff --git a/builtins/en/pieces/default.yaml b/builtins/en/pieces/default.yaml index c52266d..bd7626e 100644 --- a/builtins/en/pieces/default.yaml +++ b/builtins/en/pieces/default.yaml @@ -1,32 +1,6 @@ name: default description: Standard development piece with planning and specialized reviews max_iterations: 30 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md - qa: ../policies/qa.md -knowledge: - backend: ../knowledge/backend.md - architecture: ../knowledge/architecture.md -personas: - planner: ../personas/planner.md - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - architecture-reviewer: ../personas/architecture-reviewer.md - qa-reviewer: ../personas/qa-reviewer.md - supervisor: ../personas/supervisor.md -instructions: - plan: ../instructions/plan.md - implement: ../instructions/implement.md - ai-review: ../instructions/ai-review.md - ai-fix: ../instructions/ai-fix.md - arbitrate: ../instructions/arbitrate.md - review-arch: ../instructions/review-arch.md - review-qa: ../instructions/review-qa.md - fix: ../instructions/fix.md - supervise: ../instructions/supervise.md initial_movement: plan loop_monitors: - cycle: @@ -282,10 +256,3 @@ movements: report: - Validation: 07-supervisor-validation.md - Summary: summary.md -report_formats: - plan: ../output-contracts/plan.md - ai-review: ../output-contracts/ai-review.md - architecture-review: ../output-contracts/architecture-review.md - qa-review: ../output-contracts/qa-review.md - validation: ../output-contracts/validation.md - summary: ../output-contracts/summary.md diff --git a/builtins/en/pieces/expert-cqrs.yaml b/builtins/en/pieces/expert-cqrs.yaml index 1bacccb..b668953 100644 --- a/builtins/en/pieces/expert-cqrs.yaml +++ b/builtins/en/pieces/expert-cqrs.yaml @@ -1,41 +1,6 @@ name: expert-cqrs description: CQRS+ES, Frontend, Security, QA Expert Review max_iterations: 30 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md - qa: ../policies/qa.md -knowledge: - frontend: ../knowledge/frontend.md - backend: ../knowledge/backend.md - cqrs-es: ../knowledge/cqrs-es.md - security: ../knowledge/security.md - architecture: ../knowledge/architecture.md -personas: - planner: ../personas/planner.md - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - architecture-reviewer: ../personas/architecture-reviewer.md - cqrs-es-reviewer: ../personas/cqrs-es-reviewer.md - frontend-reviewer: ../personas/frontend-reviewer.md - security-reviewer: ../personas/security-reviewer.md - qa-reviewer: ../personas/qa-reviewer.md - expert-supervisor: ../personas/expert-supervisor.md -instructions: - plan: ../instructions/plan.md - implement: ../instructions/implement.md - ai-review: ../instructions/ai-review.md - ai-fix: ../instructions/ai-fix.md - arbitrate: ../instructions/arbitrate.md - review-cqrs-es: ../instructions/review-cqrs-es.md - review-frontend: ../instructions/review-frontend.md - review-security: ../instructions/review-security.md - review-qa: ../instructions/review-qa.md - fix: ../instructions/fix.md - supervise: ../instructions/supervise.md - fix-supervisor: ../instructions/fix-supervisor.md initial_movement: plan movements: - name: plan @@ -323,12 +288,3 @@ movements: next: supervise - condition: Unable to proceed with fixes next: plan -report_formats: - plan: ../output-contracts/plan.md - ai-review: ../output-contracts/ai-review.md - cqrs-es-review: ../output-contracts/cqrs-es-review.md - frontend-review: ../output-contracts/frontend-review.md - security-review: ../output-contracts/security-review.md - qa-review: ../output-contracts/qa-review.md - validation: ../output-contracts/validation.md - summary: ../output-contracts/summary.md diff --git a/builtins/en/pieces/expert.yaml b/builtins/en/pieces/expert.yaml index f99e71f..6416ccb 100644 --- a/builtins/en/pieces/expert.yaml +++ b/builtins/en/pieces/expert.yaml @@ -1,39 +1,6 @@ name: expert description: Architecture, Frontend, Security, QA Expert Review max_iterations: 30 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md - qa: ../policies/qa.md -knowledge: - frontend: ../knowledge/frontend.md - backend: ../knowledge/backend.md - security: ../knowledge/security.md - architecture: ../knowledge/architecture.md -personas: - planner: ../personas/planner.md - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - architecture-reviewer: ../personas/architecture-reviewer.md - frontend-reviewer: ../personas/frontend-reviewer.md - security-reviewer: ../personas/security-reviewer.md - qa-reviewer: ../personas/qa-reviewer.md - expert-supervisor: ../personas/expert-supervisor.md -instructions: - plan: ../instructions/plan.md - implement: ../instructions/implement.md - ai-review: ../instructions/ai-review.md - ai-fix: ../instructions/ai-fix.md - arbitrate: ../instructions/arbitrate.md - review-arch: ../instructions/review-arch.md - review-frontend: ../instructions/review-frontend.md - review-security: ../instructions/review-security.md - review-qa: ../instructions/review-qa.md - fix: ../instructions/fix.md - supervise: ../instructions/supervise.md - fix-supervisor: ../instructions/fix-supervisor.md initial_movement: plan movements: - name: plan @@ -317,12 +284,3 @@ movements: next: supervise - condition: Unable to proceed with fixes next: plan -report_formats: - plan: ../output-contracts/plan.md - ai-review: ../output-contracts/ai-review.md - architecture-review: ../output-contracts/architecture-review.md - frontend-review: ../output-contracts/frontend-review.md - security-review: ../output-contracts/security-review.md - qa-review: ../output-contracts/qa-review.md - validation: ../output-contracts/validation.md - summary: ../output-contracts/summary.md diff --git a/builtins/en/pieces/magi.yaml b/builtins/en/pieces/magi.yaml index 2accc85..f6ee7a6 100644 --- a/builtins/en/pieces/magi.yaml +++ b/builtins/en/pieces/magi.yaml @@ -1,10 +1,6 @@ name: magi description: MAGI Deliberation System - Analyze from 3 perspectives and decide by majority max_iterations: 5 -personas: - melchior: ../personas/melchior.md - balthasar: ../personas/balthasar.md - casper: ../personas/casper.md initial_movement: melchior movements: - name: melchior diff --git a/builtins/en/pieces/minimal.yaml b/builtins/en/pieces/minimal.yaml index 56c9f5b..89017c3 100644 --- a/builtins/en/pieces/minimal.yaml +++ b/builtins/en/pieces/minimal.yaml @@ -1,21 +1,6 @@ name: minimal description: Minimal development piece (implement -> parallel review -> fix if needed -> complete) max_iterations: 20 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md -personas: - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - supervisor: ../personas/supervisor.md -instructions: - implement: ../instructions/implement.md - review-ai: ../instructions/review-ai.md - ai-fix: ../instructions/ai-fix.md - supervise: ../instructions/supervise.md - fix-supervisor: ../instructions/fix-supervisor.md initial_movement: implement movements: - name: implement @@ -190,5 +175,3 @@ movements: - condition: Cannot proceed, insufficient info next: implement instruction: fix-supervisor -report_formats: - ai-review: ../output-contracts/ai-review.md diff --git a/builtins/en/pieces/passthrough.yaml b/builtins/en/pieces/passthrough.yaml index fba61cd..e9ae5e1 100644 --- a/builtins/en/pieces/passthrough.yaml +++ b/builtins/en/pieces/passthrough.yaml @@ -1,11 +1,6 @@ name: passthrough description: Single-agent thin wrapper. Pass task directly to coder as-is. max_iterations: 10 -policies: - coding: ../policies/coding.md - testing: ../policies/testing.md -personas: - coder: ../personas/coder.md initial_movement: execute movements: - name: execute diff --git a/builtins/en/pieces/research.yaml b/builtins/en/pieces/research.yaml index b302272..f88ac39 100644 --- a/builtins/en/pieces/research.yaml +++ b/builtins/en/pieces/research.yaml @@ -1,10 +1,6 @@ name: research description: Research piece - autonomously executes research without asking questions max_iterations: 10 -personas: - research-planner: ../personas/research-planner.md - research-digger: ../personas/research-digger.md - research-supervisor: ../personas/research-supervisor.md initial_movement: plan movements: - name: plan diff --git a/builtins/en/pieces/review-fix-minimal.yaml b/builtins/en/pieces/review-fix-minimal.yaml index d942be9..79dbeab 100644 --- a/builtins/en/pieces/review-fix-minimal.yaml +++ b/builtins/en/pieces/review-fix-minimal.yaml @@ -1,21 +1,6 @@ name: review-fix-minimal description: Review and fix piece for existing code (starts with review, no implementation) max_iterations: 20 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md -personas: - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - supervisor: ../personas/supervisor.md -instructions: - implement: ../instructions/implement.md - review-ai: ../instructions/review-ai.md - ai-fix: ../instructions/ai-fix.md - supervise: ../instructions/supervise.md - fix-supervisor: ../instructions/fix-supervisor.md initial_movement: reviewers movements: - name: implement @@ -190,5 +175,3 @@ movements: - condition: Cannot proceed, insufficient info next: implement instruction: fix-supervisor -report_formats: - ai-review: ../output-contracts/ai-review.md diff --git a/builtins/en/pieces/review-only.yaml b/builtins/en/pieces/review-only.yaml index f547f95..eabf031 100644 --- a/builtins/en/pieces/review-only.yaml +++ b/builtins/en/pieces/review-only.yaml @@ -1,23 +1,6 @@ name: review-only description: Review-only piece - reviews code without making edits max_iterations: 10 -policies: - review: ../policies/review.md - ai-antipattern: ../policies/ai-antipattern.md -knowledge: - architecture: ../knowledge/architecture.md - security: ../knowledge/security.md -personas: - planner: ../personas/planner.md - architecture-reviewer: ../personas/architecture-reviewer.md - security-reviewer: ../personas/security-reviewer.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - supervisor: ../personas/supervisor.md - pr-commenter: ../personas/pr-commenter.md -instructions: - review-arch: ../instructions/review-arch.md - review-security: ../instructions/review-security.md - review-ai: ../instructions/review-ai.md initial_movement: plan movements: - name: plan @@ -230,8 +213,3 @@ movements: --- *Generated by [takt](https://github.com/toruticas/takt) review-only piece* ``` -report_formats: - architecture-review: ../output-contracts/architecture-review.md - security-review: ../output-contracts/security-review.md - ai-review: ../output-contracts/ai-review.md - review-summary: ../output-contracts/review-summary.md diff --git a/builtins/en/pieces/structural-reform.yaml b/builtins/en/pieces/structural-reform.yaml index 07f8abc..bbff4b9 100644 --- a/builtins/en/pieces/structural-reform.yaml +++ b/builtins/en/pieces/structural-reform.yaml @@ -1,25 +1,6 @@ name: structural-reform description: Full project review and structural reform - iterative codebase restructuring with staged file splits max_iterations: 50 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - qa: ../policies/qa.md -knowledge: - backend: ../knowledge/backend.md - architecture: ../knowledge/architecture.md -personas: - planner: ../personas/planner.md - coder: ../personas/coder.md - architecture-reviewer: ../personas/architecture-reviewer.md - qa-reviewer: ../personas/qa-reviewer.md - supervisor: ../personas/supervisor.md -instructions: - implement: ../instructions/implement.md - review-arch: ../instructions/review-arch.md - review-qa: ../instructions/review-qa.md - fix: ../instructions/fix.md initial_movement: review loop_monitors: - cycle: @@ -447,9 +428,3 @@ movements: output_contracts: report: - name: 07-progress.md -report_formats: - plan: ../output-contracts/plan.md - architecture-review: ../output-contracts/architecture-review.md - qa-review: ../output-contracts/qa-review.md - validation: ../output-contracts/validation.md - summary: ../output-contracts/summary.md diff --git a/builtins/en/pieces/unit-test.yaml b/builtins/en/pieces/unit-test.yaml index dbae293..e8a1dfb 100644 --- a/builtins/en/pieces/unit-test.yaml +++ b/builtins/en/pieces/unit-test.yaml @@ -1,31 +1,6 @@ name: unit-test description: Unit test focused piece (test analysis → test implementation → review → fix) max_iterations: 20 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md - qa: ../policies/qa.md -knowledge: - architecture: ../knowledge/architecture.md - backend: ../knowledge/backend.md -personas: - test-planner: ../personas/test-planner.md - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - architecture-reviewer: ../personas/architecture-reviewer.md - qa-reviewer: ../personas/qa-reviewer.md - supervisor: ../personas/supervisor.md -instructions: - plan-test: ../instructions/plan-test.md - implement-test: ../instructions/implement-test.md - ai-review: ../instructions/ai-review.md - ai-fix: ../instructions/ai-fix.md - arbitrate: ../instructions/arbitrate.md - review-test: ../instructions/review-test.md - fix: ../instructions/fix.md - supervise: ../instructions/supervise.md initial_movement: plan_test loop_monitors: - cycle: @@ -259,9 +234,3 @@ movements: report: - Validation: 06-supervisor-validation.md - Summary: summary.md -report_formats: - test-plan: ../output-contracts/test-plan.md - ai-review: ../output-contracts/ai-review.md - qa-review: ../output-contracts/qa-review.md - validation: ../output-contracts/validation.md - summary: ../output-contracts/summary.md diff --git a/builtins/ja/pieces/coding.yaml b/builtins/ja/pieces/coding.yaml index 8b7413c..44908d0 100644 --- a/builtins/ja/pieces/coding.yaml +++ b/builtins/ja/pieces/coding.yaml @@ -1,24 +1,6 @@ name: coding description: Lightweight development piece with planning and parallel reviews (plan -> implement -> parallel review -> complete) max_iterations: 20 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md -knowledge: - architecture: ../knowledge/architecture.md -personas: - planner: ../personas/planner.md - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - architecture-reviewer: ../personas/architecture-reviewer.md -instructions: - plan: ../instructions/plan.md - implement: ../instructions/implement.md - ai-review: ../instructions/ai-review.md - review-arch: ../instructions/review-arch.md - fix: ../instructions/fix.md initial_movement: plan movements: - name: plan @@ -150,7 +132,3 @@ movements: - condition: 判断できない、情報不足 next: ABORT instruction: fix -report_formats: - plan: ../output-contracts/plan.md - ai-review: ../output-contracts/ai-review.md - architecture-review: ../output-contracts/architecture-review.md diff --git a/builtins/ja/pieces/compound-eye.yaml b/builtins/ja/pieces/compound-eye.yaml index d5a1962..e4c41bb 100644 --- a/builtins/ja/pieces/compound-eye.yaml +++ b/builtins/ja/pieces/compound-eye.yaml @@ -1,11 +1,6 @@ name: compound-eye description: 複眼レビュー - 同じ指示を Claude と Codex に同時に投げ、両者の回答を統合する max_iterations: 10 -knowledge: - architecture: ../knowledge/architecture.md -personas: - coder: ../personas/coder.md - supervisor: ../personas/supervisor.md initial_movement: evaluate movements: diff --git a/builtins/ja/pieces/default.yaml b/builtins/ja/pieces/default.yaml index dd7888c..3e26283 100644 --- a/builtins/ja/pieces/default.yaml +++ b/builtins/ja/pieces/default.yaml @@ -1,32 +1,6 @@ name: default description: Standard development piece with planning and specialized reviews max_iterations: 30 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md - qa: ../policies/qa.md -knowledge: - architecture: ../knowledge/architecture.md - backend: ../knowledge/backend.md -personas: - planner: ../personas/planner.md - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - architecture-reviewer: ../personas/architecture-reviewer.md - qa-reviewer: ../personas/qa-reviewer.md - supervisor: ../personas/supervisor.md -instructions: - plan: ../instructions/plan.md - implement: ../instructions/implement.md - ai-review: ../instructions/ai-review.md - ai-fix: ../instructions/ai-fix.md - arbitrate: ../instructions/arbitrate.md - review-arch: ../instructions/review-arch.md - review-qa: ../instructions/review-qa.md - fix: ../instructions/fix.md - supervise: ../instructions/supervise.md initial_movement: plan loop_monitors: - cycle: @@ -282,10 +256,3 @@ movements: report: - Validation: 07-supervisor-validation.md - Summary: summary.md -report_formats: - plan: ../output-contracts/plan.md - ai-review: ../output-contracts/ai-review.md - architecture-review: ../output-contracts/architecture-review.md - qa-review: ../output-contracts/qa-review.md - validation: ../output-contracts/validation.md - summary: ../output-contracts/summary.md diff --git a/builtins/ja/pieces/expert-cqrs.yaml b/builtins/ja/pieces/expert-cqrs.yaml index 130f81c..e4036cb 100644 --- a/builtins/ja/pieces/expert-cqrs.yaml +++ b/builtins/ja/pieces/expert-cqrs.yaml @@ -1,41 +1,6 @@ name: expert-cqrs description: CQRS+ES・フロントエンド・セキュリティ・QA専門家レビュー max_iterations: 30 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md - qa: ../policies/qa.md -knowledge: - frontend: ../knowledge/frontend.md - backend: ../knowledge/backend.md - cqrs-es: ../knowledge/cqrs-es.md - security: ../knowledge/security.md - architecture: ../knowledge/architecture.md -personas: - planner: ../personas/planner.md - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - architecture-reviewer: ../personas/architecture-reviewer.md - cqrs-es-reviewer: ../personas/cqrs-es-reviewer.md - frontend-reviewer: ../personas/frontend-reviewer.md - security-reviewer: ../personas/security-reviewer.md - qa-reviewer: ../personas/qa-reviewer.md - expert-supervisor: ../personas/expert-supervisor.md -instructions: - plan: ../instructions/plan.md - implement: ../instructions/implement.md - ai-review: ../instructions/ai-review.md - ai-fix: ../instructions/ai-fix.md - arbitrate: ../instructions/arbitrate.md - review-cqrs-es: ../instructions/review-cqrs-es.md - review-frontend: ../instructions/review-frontend.md - review-security: ../instructions/review-security.md - review-qa: ../instructions/review-qa.md - fix: ../instructions/fix.md - supervise: ../instructions/supervise.md - fix-supervisor: ../instructions/fix-supervisor.md initial_movement: plan movements: - name: plan @@ -323,12 +288,3 @@ movements: next: supervise - condition: 修正を進行できない next: plan -report_formats: - plan: ../output-contracts/plan.md - ai-review: ../output-contracts/ai-review.md - cqrs-es-review: ../output-contracts/cqrs-es-review.md - frontend-review: ../output-contracts/frontend-review.md - security-review: ../output-contracts/security-review.md - qa-review: ../output-contracts/qa-review.md - validation: ../output-contracts/validation.md - summary: ../output-contracts/summary.md diff --git a/builtins/ja/pieces/expert.yaml b/builtins/ja/pieces/expert.yaml index ae8ffa8..9a4220b 100644 --- a/builtins/ja/pieces/expert.yaml +++ b/builtins/ja/pieces/expert.yaml @@ -1,39 +1,6 @@ name: expert description: アーキテクチャ・フロントエンド・セキュリティ・QA専門家レビュー max_iterations: 30 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md - qa: ../policies/qa.md -knowledge: - frontend: ../knowledge/frontend.md - backend: ../knowledge/backend.md - security: ../knowledge/security.md - architecture: ../knowledge/architecture.md -personas: - planner: ../personas/planner.md - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - architecture-reviewer: ../personas/architecture-reviewer.md - frontend-reviewer: ../personas/frontend-reviewer.md - security-reviewer: ../personas/security-reviewer.md - qa-reviewer: ../personas/qa-reviewer.md - expert-supervisor: ../personas/expert-supervisor.md -instructions: - plan: ../instructions/plan.md - implement: ../instructions/implement.md - ai-review: ../instructions/ai-review.md - ai-fix: ../instructions/ai-fix.md - arbitrate: ../instructions/arbitrate.md - review-arch: ../instructions/review-arch.md - review-frontend: ../instructions/review-frontend.md - review-security: ../instructions/review-security.md - review-qa: ../instructions/review-qa.md - fix: ../instructions/fix.md - supervise: ../instructions/supervise.md - fix-supervisor: ../instructions/fix-supervisor.md initial_movement: plan movements: - name: plan @@ -317,12 +284,3 @@ movements: next: supervise - condition: 修正を進行できない next: plan -report_formats: - plan: ../output-contracts/plan.md - ai-review: ../output-contracts/ai-review.md - architecture-review: ../output-contracts/architecture-review.md - frontend-review: ../output-contracts/frontend-review.md - security-review: ../output-contracts/security-review.md - qa-review: ../output-contracts/qa-review.md - validation: ../output-contracts/validation.md - summary: ../output-contracts/summary.md diff --git a/builtins/ja/pieces/magi.yaml b/builtins/ja/pieces/magi.yaml index f905b37..679329b 100644 --- a/builtins/ja/pieces/magi.yaml +++ b/builtins/ja/pieces/magi.yaml @@ -1,10 +1,6 @@ name: magi description: MAGI合議システム - 3つの観点から分析し多数決で判定 max_iterations: 5 -personas: - melchior: ../personas/melchior.md - balthasar: ../personas/balthasar.md - casper: ../personas/casper.md initial_movement: melchior movements: - name: melchior diff --git a/builtins/ja/pieces/minimal.yaml b/builtins/ja/pieces/minimal.yaml index a0bfb8d..418f66a 100644 --- a/builtins/ja/pieces/minimal.yaml +++ b/builtins/ja/pieces/minimal.yaml @@ -1,21 +1,6 @@ name: minimal description: Minimal development piece (implement -> parallel review -> fix if needed -> complete) max_iterations: 20 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md -personas: - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - supervisor: ../personas/supervisor.md -instructions: - implement: ../instructions/implement.md - review-ai: ../instructions/review-ai.md - ai-fix: ../instructions/ai-fix.md - supervise: ../instructions/supervise.md - fix-supervisor: ../instructions/fix-supervisor.md initial_movement: implement movements: - name: implement @@ -190,5 +175,3 @@ movements: - condition: 修正を進行できない next: implement instruction: fix-supervisor -report_formats: - ai-review: ../output-contracts/ai-review.md diff --git a/builtins/ja/pieces/passthrough.yaml b/builtins/ja/pieces/passthrough.yaml index 1409cba..b2b9d48 100644 --- a/builtins/ja/pieces/passthrough.yaml +++ b/builtins/ja/pieces/passthrough.yaml @@ -1,11 +1,6 @@ name: passthrough description: Single-agent thin wrapper. Pass task directly to coder as-is. max_iterations: 10 -policies: - coding: ../policies/coding.md - testing: ../policies/testing.md -personas: - coder: ../personas/coder.md initial_movement: execute movements: - name: execute diff --git a/builtins/ja/pieces/research.yaml b/builtins/ja/pieces/research.yaml index 03b7653..67dbb4c 100644 --- a/builtins/ja/pieces/research.yaml +++ b/builtins/ja/pieces/research.yaml @@ -1,10 +1,6 @@ name: research description: 調査ピース - 質問せずに自律的に調査を実行 max_iterations: 10 -personas: - research-planner: ../personas/research-planner.md - research-digger: ../personas/research-digger.md - research-supervisor: ../personas/research-supervisor.md initial_movement: plan movements: - name: plan diff --git a/builtins/ja/pieces/review-fix-minimal.yaml b/builtins/ja/pieces/review-fix-minimal.yaml index 0c29773..e9b0fc9 100644 --- a/builtins/ja/pieces/review-fix-minimal.yaml +++ b/builtins/ja/pieces/review-fix-minimal.yaml @@ -1,21 +1,6 @@ name: review-fix-minimal description: 既存コードのレビューと修正ピース(レビュー開始、実装なし) max_iterations: 20 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md -personas: - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - supervisor: ../personas/supervisor.md -instructions: - implement: ../instructions/implement.md - review-ai: ../instructions/review-ai.md - ai-fix: ../instructions/ai-fix.md - supervise: ../instructions/supervise.md - fix-supervisor: ../instructions/fix-supervisor.md initial_movement: reviewers movements: - name: implement @@ -190,5 +175,3 @@ movements: - condition: 修正を進行できない next: implement instruction: fix-supervisor -report_formats: - ai-review: ../output-contracts/ai-review.md diff --git a/builtins/ja/pieces/review-only.yaml b/builtins/ja/pieces/review-only.yaml index 4075da0..75aea59 100644 --- a/builtins/ja/pieces/review-only.yaml +++ b/builtins/ja/pieces/review-only.yaml @@ -1,23 +1,6 @@ name: review-only description: レビュー専用ピース - コードをレビューするだけで編集は行わない max_iterations: 10 -policies: - review: ../policies/review.md - ai-antipattern: ../policies/ai-antipattern.md -knowledge: - architecture: ../knowledge/architecture.md - security: ../knowledge/security.md -personas: - planner: ../personas/planner.md - architecture-reviewer: ../personas/architecture-reviewer.md - security-reviewer: ../personas/security-reviewer.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - supervisor: ../personas/supervisor.md - pr-commenter: ../personas/pr-commenter.md -instructions: - review-arch: ../instructions/review-arch.md - review-security: ../instructions/review-security.md - review-ai: ../instructions/review-ai.md initial_movement: plan movements: - name: plan @@ -231,8 +214,3 @@ movements: --- *[takt](https://github.com/toruticas/takt) review-only ピースで生成* ``` -report_formats: - architecture-review: ../output-contracts/architecture-review.md - security-review: ../output-contracts/security-review.md - ai-review: ../output-contracts/ai-review.md - review-summary: ../output-contracts/review-summary.md diff --git a/builtins/ja/pieces/structural-reform.yaml b/builtins/ja/pieces/structural-reform.yaml index 2445b6d..a765026 100644 --- a/builtins/ja/pieces/structural-reform.yaml +++ b/builtins/ja/pieces/structural-reform.yaml @@ -1,25 +1,6 @@ name: structural-reform description: プロジェクト全体レビューと構造改革 - 段階的なファイル分割による反復的コードベース再構築 max_iterations: 50 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - qa: ../policies/qa.md -knowledge: - backend: ../knowledge/backend.md - architecture: ../knowledge/architecture.md -personas: - planner: ../personas/planner.md - coder: ../personas/coder.md - architecture-reviewer: ../personas/architecture-reviewer.md - qa-reviewer: ../personas/qa-reviewer.md - supervisor: ../personas/supervisor.md -instructions: - implement: ../instructions/implement.md - review-arch: ../instructions/review-arch.md - review-qa: ../instructions/review-qa.md - fix: ../instructions/fix.md initial_movement: review loop_monitors: - cycle: @@ -447,9 +428,3 @@ movements: output_contracts: report: - name: 07-progress.md -report_formats: - plan: ../output-contracts/plan.md - architecture-review: ../output-contracts/architecture-review.md - qa-review: ../output-contracts/qa-review.md - validation: ../output-contracts/validation.md - summary: ../output-contracts/summary.md diff --git a/builtins/ja/pieces/unit-test.yaml b/builtins/ja/pieces/unit-test.yaml index ea8ad38..5859865 100644 --- a/builtins/ja/pieces/unit-test.yaml +++ b/builtins/ja/pieces/unit-test.yaml @@ -1,31 +1,6 @@ name: unit-test description: 単体テスト追加に特化したピース(テスト分析→テスト実装→レビュー→修正) max_iterations: 20 -policies: - coding: ../policies/coding.md - review: ../policies/review.md - testing: ../policies/testing.md - ai-antipattern: ../policies/ai-antipattern.md - qa: ../policies/qa.md -knowledge: - architecture: ../knowledge/architecture.md - backend: ../knowledge/backend.md -personas: - test-planner: ../personas/test-planner.md - coder: ../personas/coder.md - ai-antipattern-reviewer: ../personas/ai-antipattern-reviewer.md - architecture-reviewer: ../personas/architecture-reviewer.md - qa-reviewer: ../personas/qa-reviewer.md - supervisor: ../personas/supervisor.md -instructions: - plan-test: ../instructions/plan-test.md - implement-test: ../instructions/implement-test.md - ai-review: ../instructions/ai-review.md - ai-fix: ../instructions/ai-fix.md - arbitrate: ../instructions/arbitrate.md - review-test: ../instructions/review-test.md - fix: ../instructions/fix.md - supervise: ../instructions/supervise.md initial_movement: plan_test loop_monitors: - cycle: @@ -259,9 +234,3 @@ movements: report: - Validation: 06-supervisor-validation.md - Summary: summary.md -report_formats: - test-plan: ../output-contracts/test-plan.md - ai-review: ../output-contracts/ai-review.md - qa-review: ../output-contracts/qa-review.md - validation: ../output-contracts/validation.md - summary: ../output-contracts/summary.md diff --git a/docs/faceted-prompting.ja.md b/docs/faceted-prompting.ja.md index 50014b8..c6fe025 100644 --- a/docs/faceted-prompting.ja.md +++ b/docs/faceted-prompting.ja.md @@ -327,52 +327,33 @@ Faceted Promptingの中核メカニズムは**宣言的な合成**である。 ### TAKTでの実装例 -[TAKT](https://github.com/nrslib/takt) はFaceted PromptingをYAMLベースのワークフロー定義(「ピース」と呼ぶ)で実装している。各関心はセクションマップで短いキーにマッピングされ、各ステップ(TAKTでは「ムーブメント」と呼ぶ)からキーで参照される。 +[TAKT](https://github.com/nrslib/takt) はFaceted PromptingをYAMLベースのワークフロー定義(「ピース」と呼ぶ)で実装している。builtinの各ファセットは、各ステップ(TAKTでは「ムーブメント」と呼ぶ)から bare name で直接参照できる。セクションマップは「名前とファイル名が異なる」場合のカスタムエイリアス用途でのみ任意で使う。 ```yaml name: my-workflow max_iterations: 10 initial_movement: plan -# セクションマップ — キー: ファイルパス(このYAMLからの相対パス) -personas: - coder: ../personas/coder.md - reviewer: ../personas/architecture-reviewer.md - -policies: - coding: ../policies/coding.md - review: ../policies/review.md - -instructions: - plan: ../instructions/plan.md - implement: ../instructions/implement.md - -knowledge: - architecture: ../knowledge/architecture.md - -report_formats: - review: ../output-contracts/review.md - movements: - name: implement - persona: coder # WHO — personas.coder を参照 - policy: coding # RULES — policies.coding を参照 - instruction: implement # WHAT — instructions.implement を参照 - knowledge: architecture # CONTEXT — knowledge.architecture を参照 + persona: coder # WHO — builtins/{lang}/personas/coder.md + policy: coding # RULES — builtins/{lang}/policies/coding.md + instruction: implement # WHAT — builtins/{lang}/instructions/implement.md + knowledge: architecture # CONTEXT — builtins/{lang}/knowledge/architecture.md edit: true rules: - condition: Implementation complete next: review - name: review - persona: reviewer # 異なる WHO + persona: architecture-reviewer # 異なる WHO policy: review # 異なる RULES instruction: review # 異なる WHAT(共有も可能) knowledge: architecture # 同じ CONTEXT — 再利用 output_contracts: report: - name: review.md - format: review # OUTPUT — report_formats.review を参照 + format: architecture-review # OUTPUT — builtins/{lang}/output-contracts/architecture-review.md edit: false rules: - condition: Approved diff --git a/docs/faceted-prompting.md b/docs/faceted-prompting.md index e5e9f1a..0336288 100644 --- a/docs/faceted-prompting.md +++ b/docs/faceted-prompting.md @@ -327,52 +327,33 @@ Key properties: ### Implementation Example: TAKT -[TAKT](https://github.com/nrslib/takt) implements Faceted Prompting using YAML-based workflow definitions called "pieces." Concerns are mapped to short keys via section maps, then referenced by key in each step (called "movement" in TAKT): +[TAKT](https://github.com/nrslib/takt) implements Faceted Prompting using YAML-based workflow definitions called "pieces." Builtin facets can be referenced directly by bare name in each step (called "movement" in TAKT). Section maps are optional and only needed for custom aliases (name differs from file name): ```yaml name: my-workflow max_iterations: 10 initial_movement: plan -# Section maps — key: file path (relative to this YAML) -personas: - coder: ../personas/coder.md - reviewer: ../personas/architecture-reviewer.md - -policies: - coding: ../policies/coding.md - review: ../policies/review.md - -instructions: - plan: ../instructions/plan.md - implement: ../instructions/implement.md - -knowledge: - architecture: ../knowledge/architecture.md - -output_contracts: - review: ../output-contracts/review.md - movements: - name: implement - persona: coder # WHO — references personas.coder - policy: coding # RULES — references policies.coding - instruction: implement # WHAT — references instructions.implement - knowledge: architecture # CONTEXT — references knowledge.architecture + persona: coder # WHO — builtins/{lang}/personas/coder.md + policy: coding # RULES — builtins/{lang}/policies/coding.md + instruction: implement # WHAT — builtins/{lang}/instructions/implement.md + knowledge: architecture # CONTEXT — builtins/{lang}/knowledge/architecture.md edit: true rules: - condition: Implementation complete next: review - name: review - persona: reviewer # Different WHO + persona: architecture-reviewer # Different WHO policy: review # Different RULES instruction: review # Different WHAT (but could share) knowledge: architecture # Same CONTEXT — reused output_contracts: report: - name: review.md - format: review # OUTPUT — references report_formats.review + format: architecture-review # OUTPUT — builtins/{lang}/output-contracts/architecture-review.md edit: false rules: - condition: Approved diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index fa5b21d..ea73552 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -44,6 +44,15 @@ describe('getBuiltinPiece', () => { expect(piece!.name).toBe('default'); }); + it('should resolve builtin instruction_template without projectCwd', () => { + const piece = getBuiltinPiece('default'); + expect(piece).not.toBeNull(); + + const planMovement = piece!.movements.find((movement) => movement.name === 'plan'); + expect(planMovement).toBeDefined(); + expect(planMovement!.instructionTemplate).not.toBe('plan'); + }); + it('should return null for non-existent piece names', () => { expect(getBuiltinPiece('nonexistent-piece')).toBeNull(); expect(getBuiltinPiece('unknown')).toBeNull(); diff --git a/src/__tests__/facet-resolution.test.ts b/src/__tests__/facet-resolution.test.ts index 3070ecc..4a1e1d1 100644 --- a/src/__tests__/facet-resolution.test.ts +++ b/src/__tests__/facet-resolution.test.ts @@ -508,4 +508,110 @@ describe('normalizePieceConfig with layer resolution', () => { expect(config.movements[0]!.knowledgeContents).toBeDefined(); expect(config.movements[0]!.knowledgeContents![0]).toBe('# Domain Knowledge'); }); + + it('should resolve instruction_template from section map before layer resolution', () => { + const raw = { + name: 'test-piece', + instructions: { + implement: 'Mapped instruction template', + }, + movements: [ + { + name: 'step1', + persona: 'coder', + instruction_template: 'implement', + instruction: '{task}', + }, + ], + }; + + const context: FacetResolutionContext = { projectDir, lang: 'ja' }; + const config = normalizePieceConfig(raw, pieceDir, context); + + expect(config.movements[0]!.instructionTemplate).toBe('Mapped instruction template'); + }); + + it('should resolve instruction_template by name via layer resolution', () => { + const instructionsDir = join(projectDir, '.takt', 'instructions'); + mkdirSync(instructionsDir, { recursive: true }); + writeFileSync(join(instructionsDir, 'implement.md'), 'Project implement template'); + + const raw = { + name: 'test-piece', + movements: [ + { + name: 'step1', + persona: 'coder', + instruction_template: 'implement', + instruction: '{task}', + }, + ], + }; + + const context: FacetResolutionContext = { projectDir, lang: 'ja' }; + const config = normalizePieceConfig(raw, pieceDir, context); + + expect(config.movements[0]!.instructionTemplate).toBe('Project implement template'); + }); + + it('should keep inline instruction_template when no facet is found', () => { + const inlineTemplate = `Use this inline template. +Second line remains inline.`; + const raw = { + name: 'test-piece', + movements: [ + { + name: 'step1', + persona: 'coder', + instruction_template: inlineTemplate, + instruction: '{task}', + }, + ], + }; + + const context: FacetResolutionContext = { projectDir, lang: 'ja' }; + const config = normalizePieceConfig(raw, pieceDir, context); + + expect(config.movements[0]!.instructionTemplate).toBe(inlineTemplate); + }); + + it('should resolve loop monitor judge instruction_template via layer resolution', () => { + const instructionsDir = join(projectDir, '.takt', 'instructions'); + mkdirSync(instructionsDir, { recursive: true }); + writeFileSync(join(instructionsDir, 'judge-template.md'), 'Project judge template'); + + const raw = { + name: 'test-piece', + movements: [ + { + name: 'step1', + persona: 'coder', + instruction: '{task}', + rules: [{ condition: 'next', next: 'step2' }], + }, + { + name: 'step2', + persona: 'coder', + instruction: '{task}', + rules: [{ condition: 'done', next: 'COMPLETE' }], + }, + ], + loop_monitors: [ + { + cycle: ['step1', 'step2'], + threshold: 2, + judge: { + persona: 'coder', + instruction_template: 'judge-template', + rules: [{ condition: 'continue', next: 'step2' }], + }, + }, + ], + }; + + const context: FacetResolutionContext = { projectDir, lang: 'ja' }; + const config = normalizePieceConfig(raw, pieceDir, context); + + expect(config.loopMonitors?.[0]?.judge.instructionTemplate).toBe('Project judge template'); + }); }); diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 9b17bd6..87d5039 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -15,7 +15,6 @@ import { getLanguage } from '../global/globalConfig.js'; import { type PieceSections, type FacetResolutionContext, - resolveResourceContent, resolveRefToContent, resolveRefList, resolveSectionMap, @@ -189,7 +188,9 @@ function normalizeStepFromRaw( model: step.model, permissionMode: step.permission_mode, edit: step.edit, - instructionTemplate: resolveResourceContent(step.instruction_template, pieceDir) || expandedInstruction || '{task}', + instructionTemplate: (step.instruction_template + ? resolveRefToContent(step.instruction_template, sections.resolvedInstructions, pieceDir, 'instructions', context) + : undefined) || expandedInstruction || '{task}', rules, outputContracts: normalizeOutputContracts(step.output_contracts, pieceDir, sections.resolvedReportFormats, context), qualityGates: step.quality_gates, @@ -217,7 +218,9 @@ function normalizeLoopMonitorJudge( return { persona: personaSpec, personaPath, - instructionTemplate: resolveResourceContent(raw.instruction_template, pieceDir), + instructionTemplate: raw.instruction_template + ? resolveRefToContent(raw.instruction_template, sections.resolvedInstructions, pieceDir, 'instructions', context) + : undefined, rules: raw.rules.map((r) => ({ condition: r.condition, next: r.next })), }; } @@ -297,10 +300,10 @@ export function loadPieceFromFile(filePath: string, projectDir?: string): PieceC const raw = parseYaml(content); const pieceDir = dirname(filePath); - let context: FacetResolutionContext | undefined; - if (projectDir) { - context = { projectDir, lang: getLanguage() }; - } + const context: FacetResolutionContext = { + lang: getLanguage(), + projectDir, + }; return normalizePieceConfig(raw, pieceDir, context); } diff --git a/src/infra/config/loaders/resource-resolver.ts b/src/infra/config/loaders/resource-resolver.ts index 99e250b..05a2765 100644 --- a/src/infra/config/loaders/resource-resolver.ts +++ b/src/infra/config/loaders/resource-resolver.ts @@ -15,7 +15,7 @@ import { getProjectFacetDir, getGlobalFacetDir, getBuiltinFacetDir } from '../pa /** Context for 3-layer facet resolution. */ export interface FacetResolutionContext { - projectDir: string; + projectDir?: string; lang: Language; } @@ -63,7 +63,7 @@ export function resolveFacetPath( context: FacetResolutionContext, ): string | undefined { const candidateDirs = [ - getProjectFacetDir(context.projectDir, facetType), + ...(context.projectDir ? [getProjectFacetDir(context.projectDir, facetType)] : []), getGlobalFacetDir(facetType), getBuiltinFacetDir(context.lang, facetType), ]; From 24871e08933c106c2210a61f8d6e9e176839848a Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:55:01 +0900 Subject: [PATCH 02/19] =?UTF-8?q?instruction=5Ftemplate=20=E3=81=AE?= =?UTF-8?q?=E5=8F=82=E7=85=A7=E8=A7=A3=E6=B1=BA=E3=81=AB=E9=96=A2=E3=81=99?= =?UTF-8?q?=E3=82=8B=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=82=92=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #177 マージ後の補完。instruction_template が instruction と同じ 参照解決ルートを使うようになったことをスキルリファレンスに反映。 --- builtins/skill/references/yaml-schema.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/builtins/skill/references/yaml-schema.md b/builtins/skill/references/yaml-schema.md index 250c5d7..8109469 100644 --- a/builtins/skill/references/yaml-schema.md +++ b/builtins/skill/references/yaml-schema.md @@ -55,14 +55,14 @@ movement 内では**キー名**で参照する(パスを直接書かない) session: refresh # セッション管理(任意) pass_previous_response: true # 前の出力を渡すか(デフォルト: true) allowed_tools: [...] # 許可ツール一覧(任意、参考情報) - instruction_template: | # インライン指示テンプレート(instruction キーの代替、任意) + instruction_template: | # 指示テンプレート(参照解決またはインライン、任意) 指示内容... output_contracts: [...] # 出力契約設定(任意) quality_gates: [...] # 品質ゲート(AIへの指示、任意) rules: [...] # 遷移ルール(必須) ``` -**`instruction` vs `instruction_template`**: `instruction` はトップレベル `instructions:` セクションのキー参照。`instruction_template` はインラインで指示を記述。どちらか一方を使用する。 +**`instruction` vs `instruction_template`**: どちらも同じ参照解決ルート(セクションマップ → パス → 3-layer facet → インライン)を使う。`instruction_template` はインライン文字列もそのまま使える。通常はどちらか一方を使用する。 ### Parallel Movement From a481346945dea64235c4be9be585a28121b407b6 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:55:57 +0900 Subject: [PATCH 03/19] takt: fix-original-instruction-diff (#181) --- src/__tests__/branchList.regression.test.ts | 141 ++++++++++++ src/__tests__/getFilesChanged.test.ts | 68 ++++++ src/__tests__/getOriginalInstruction.test.ts | 71 ++++-- src/infra/task/branchGitResolver.ts | 223 +++++++++++++++++++ src/infra/task/branchList.ts | 47 +++- 5 files changed, 521 insertions(+), 29 deletions(-) create mode 100644 src/__tests__/branchList.regression.test.ts create mode 100644 src/__tests__/getFilesChanged.test.ts create mode 100644 src/infra/task/branchGitResolver.ts diff --git a/src/__tests__/branchList.regression.test.ts b/src/__tests__/branchList.regression.test.ts new file mode 100644 index 0000000..4fbf171 --- /dev/null +++ b/src/__tests__/branchList.regression.test.ts @@ -0,0 +1,141 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getFilesChanged, getOriginalInstruction } from '../infra/task/branchList.js'; + +function runGit(cwd: string, args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim(); +} + +function isUnsupportedInitBranchOptionError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + return /unknown switch [`'-]?b/.test(error.message); +} + +function writeAndCommit(repo: string, fileName: string, content: string, message: string): void { + writeFileSync(join(repo, fileName), content, 'utf-8'); + runGit(repo, ['add', fileName]); + runGit(repo, ['commit', '-m', message]); +} + +function setupRepoForIssue167(options?: { disableReflog?: boolean; firstBranchCommitMessage?: string }): { repoDir: string; branch: string } { + const repoDir = mkdtempSync(join(tmpdir(), 'takt-branchlist-')); + try { + runGit(repoDir, ['init', '-b', 'main']); + } catch (error) { + if (!isUnsupportedInitBranchOptionError(error)) { + throw error; + } + runGit(repoDir, ['init']); + } + if (options?.disableReflog) { + runGit(repoDir, ['config', 'core.logallrefupdates', 'false']); + } + runGit(repoDir, ['config', 'user.name', 'takt-test']); + runGit(repoDir, ['config', 'user.email', 'takt-test@example.com']); + + writeAndCommit(repoDir, 'main.txt', 'main\n', 'main base'); + runGit(repoDir, ['branch', '-M', 'main']); + + runGit(repoDir, ['checkout', '-b', 'develop']); + writeAndCommit(repoDir, 'develop-a.txt', 'develop a\n', 'develop commit A'); + 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'; + runGit(repoDir, ['checkout', '-b', taktBranch]); + const firstBranchCommitMessage = options?.firstBranchCommitMessage ?? 'takt: github-issue-167-fix-original-instruction'; + writeAndCommit(repoDir, 'task-1.txt', 'task1\n', firstBranchCommitMessage); + writeAndCommit(repoDir, 'task-2.txt', 'task2\n', 'follow-up implementation'); + + return { repoDir, branch: taktBranch }; +} + +describe('branchList regression for issue #167', () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it('should resolve originalInstruction correctly even when HEAD is main', () => { + const fixture = setupRepoForIssue167(); + tempDirs.push(fixture.repoDir); + runGit(fixture.repoDir, ['checkout', 'main']); + + const instruction = getOriginalInstruction(fixture.repoDir, 'main', fixture.branch); + + expect(instruction).toBe('github-issue-167-fix-original-instruction'); + }); + + it('should keep filesChanged non-zero even when HEAD is target branch', () => { + const fixture = setupRepoForIssue167(); + tempDirs.push(fixture.repoDir); + runGit(fixture.repoDir, ['checkout', fixture.branch]); + + const changed = getFilesChanged(fixture.repoDir, 'main', fixture.branch); + + expect(changed).toBe(2); + }); + + it('should ignore takt commits that exist only on base branch history', () => { + const fixture = setupRepoForIssue167(); + tempDirs.push(fixture.repoDir); + runGit(fixture.repoDir, ['checkout', 'main']); + + const instruction = getOriginalInstruction(fixture.repoDir, 'main', fixture.branch); + const changed = getFilesChanged(fixture.repoDir, 'main', fixture.branch); + + expect(instruction).toBe('github-issue-167-fix-original-instruction'); + expect(changed).toBe(2); + }); + + it('should keep original instruction and changed files after merging branch into develop', () => { + const fixture = setupRepoForIssue167(); + tempDirs.push(fixture.repoDir); + + runGit(fixture.repoDir, ['checkout', 'develop']); + runGit(fixture.repoDir, ['merge', '--no-ff', fixture.branch, '-m', 'merge takt branch']); + runGit(fixture.repoDir, ['checkout', 'main']); + + const instruction = getOriginalInstruction(fixture.repoDir, 'main', fixture.branch); + const changed = getFilesChanged(fixture.repoDir, 'main', fixture.branch); + + expect(instruction).toBe('github-issue-167-fix-original-instruction'); + expect(changed).toBe(2); + }); + + it('should resolve correctly without branch reflog by inferring base from refs', () => { + const fixture = setupRepoForIssue167({ disableReflog: true }); + tempDirs.push(fixture.repoDir); + runGit(fixture.repoDir, ['checkout', 'main']); + + const instruction = getOriginalInstruction(fixture.repoDir, 'main', fixture.branch); + const changed = getFilesChanged(fixture.repoDir, 'main', fixture.branch); + + expect(instruction).toBe('github-issue-167-fix-original-instruction'); + expect(changed).toBe(2); + }); + + it('should use inferred branch base when first branch commit has no takt prefix and reflog is unavailable', () => { + const fixture = setupRepoForIssue167({ + disableReflog: true, + firstBranchCommitMessage: 'Initial branch implementation', + }); + tempDirs.push(fixture.repoDir); + runGit(fixture.repoDir, ['checkout', 'main']); + + const instruction = getOriginalInstruction(fixture.repoDir, 'main', fixture.branch); + + expect(instruction).toBe('Initial branch implementation'); + }); +}); diff --git a/src/__tests__/getFilesChanged.test.ts b/src/__tests__/getFilesChanged.test.ts new file mode 100644 index 0000000..cf98b59 --- /dev/null +++ b/src/__tests__/getFilesChanged.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +import { execFileSync } from 'node:child_process'; +const mockExecFileSync = vi.mocked(execFileSync); + +import { getFilesChanged } from '../infra/task/branchList.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getFilesChanged', () => { + it('should count changed files from branch entry base commit via reflog', () => { + mockExecFileSync + .mockReturnValueOnce('f00dbabe\nfeedface\nabc123\n') + .mockReturnValueOnce('1\t0\tfile1.ts\n2\t1\tfile2.ts\n'); + + const result = getFilesChanged('/project', 'main', 'takt/20260128-fix-auth'); + + expect(result).toBe(2); + expect(mockExecFileSync).toHaveBeenNthCalledWith( + 2, + 'git', + ['diff', '--numstat', 'abc123..takt/20260128-fix-auth'], + expect.objectContaining({ cwd: '/project', encoding: 'utf-8' }), + ); + }); + + it('should infer base from refs when reflog is unavailable', () => { + mockExecFileSync + .mockImplementationOnce(() => { + throw new Error('reflog unavailable'); + }) + .mockReturnValueOnce('develop\n') + .mockReturnValueOnce('base999\n') + .mockReturnValueOnce('1\n') + .mockReturnValueOnce('takt: fix auth\n') + .mockReturnValueOnce('1\t0\tfile1.ts\n'); + + const result = getFilesChanged('/project', 'develop', 'takt/20260128-fix-auth'); + + expect(result).toBe(1); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes'], + expect.objectContaining({ cwd: '/project', encoding: 'utf-8' }), + ); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['merge-base', 'develop', 'takt/20260128-fix-auth'], + expect.objectContaining({ cwd: '/project', encoding: 'utf-8' }), + ); + }); + + it('should return 0 when base commit resolution fails', () => { + mockExecFileSync.mockImplementation(() => { + throw new Error('base resolution failed'); + }); + + const result = getFilesChanged('/project', 'main', 'takt/20260128-fix-auth'); + + expect(result).toBe(0); + }); +}); diff --git a/src/__tests__/getOriginalInstruction.test.ts b/src/__tests__/getOriginalInstruction.test.ts index 7a01221..24ddf09 100644 --- a/src/__tests__/getOriginalInstruction.test.ts +++ b/src/__tests__/getOriginalInstruction.test.ts @@ -19,29 +19,61 @@ beforeEach(() => { }); describe('getOriginalInstruction', () => { - it('should extract instruction from takt-prefixed commit message', () => { - mockExecFileSync.mockReturnValue('takt: 認証機能を追加する\ntakt: fix-auth\n'); + it('should extract instruction from branch entry commit via reflog', () => { + mockExecFileSync + .mockReturnValueOnce('last789\nfirst456\nbase123\n') + .mockReturnValueOnce('takt: 認証機能を追加する\n'); const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth'); expect(result).toBe('認証機能を追加する'); expect(mockExecFileSync).toHaveBeenCalledWith( 'git', - ['log', '--format=%s', '--reverse', 'main..takt/20260128-fix-auth'], + ['reflog', 'show', '--format=%H', 'takt/20260128-fix-auth'], + expect.objectContaining({ cwd: '/project', encoding: 'utf-8' }), + ); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['show', '-s', '--format=%s', 'first456'], expect.objectContaining({ cwd: '/project', encoding: 'utf-8' }), ); }); - it('should return first commit message without takt prefix if not present', () => { - mockExecFileSync.mockReturnValue('Initial implementation\n'); + it('should infer base from refs when reflog is unavailable', () => { + mockExecFileSync + .mockImplementationOnce(() => { + throw new Error('reflog unavailable'); + }) + .mockReturnValueOnce('develop\n') + .mockReturnValueOnce('base123\n') + .mockReturnValueOnce('2\n') + .mockReturnValueOnce('takt: Initial implementation\nfollow-up\n') + .mockReturnValueOnce('first456\ttakt: Initial implementation\n'); const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth'); expect(result).toBe('Initial implementation'); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes'], + expect.objectContaining({ cwd: '/project', encoding: 'utf-8' }), + ); + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['merge-base', 'develop', 'takt/20260128-fix-auth'], + expect.objectContaining({ cwd: '/project', encoding: 'utf-8' }), + ); }); it('should return empty string when no commits on branch', () => { - mockExecFileSync.mockReturnValue(''); + mockExecFileSync + .mockImplementationOnce(() => { + throw new Error('reflog unavailable'); + }) + .mockReturnValueOnce('abc123\n') + .mockReturnValueOnce('') + .mockReturnValueOnce('abc123\n') + .mockReturnValueOnce(''); const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth'); @@ -59,7 +91,9 @@ describe('getOriginalInstruction', () => { }); it('should handle multi-line commit messages (use only first line)', () => { - mockExecFileSync.mockReturnValue('takt: Fix the login bug\ntakt: follow-up fix\n'); + mockExecFileSync + .mockReturnValueOnce('f00dbabe\ndeadbeef\nbase123\n') + .mockReturnValueOnce('takt: Fix the login bug\n'); const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-login'); @@ -67,8 +101,9 @@ describe('getOriginalInstruction', () => { }); it('should return empty string when takt prefix has no content', () => { - // "takt: \n" trimmed → "takt:", starts with "takt:" → slice + trim → "" - mockExecFileSync.mockReturnValue('takt: \n'); + mockExecFileSync + .mockReturnValueOnce('cafebabe\nbase123\n') + .mockReturnValueOnce('takt:\n'); const result = getOriginalInstruction('/project', 'main', 'takt/20260128-task'); @@ -76,22 +111,22 @@ describe('getOriginalInstruction', () => { }); it('should return instruction text when takt prefix has content', () => { - mockExecFileSync.mockReturnValue('takt: add search feature\n'); + mockExecFileSync + .mockReturnValueOnce('beadface\nbase123\n') + .mockReturnValueOnce('takt: add search feature\n'); const result = getOriginalInstruction('/project', 'main', 'takt/20260128-task'); expect(result).toBe('add search feature'); }); - it('should use correct git range with custom default branch', () => { - mockExecFileSync.mockReturnValue('takt: Add search feature\n'); + it('should return original subject when branch entry commit has no takt prefix', () => { + mockExecFileSync + .mockReturnValueOnce('last789\nfirst456\nbase123\n') + .mockReturnValueOnce('Initial implementation\n'); - getOriginalInstruction('/project', 'master', 'takt/20260128-add-search'); + const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth'); - expect(mockExecFileSync).toHaveBeenCalledWith( - 'git', - ['log', '--format=%s', '--reverse', 'master..takt/20260128-add-search'], - expect.objectContaining({ cwd: '/project' }), - ); + expect(result).toBe('Initial implementation'); }); }); diff --git a/src/infra/task/branchGitResolver.ts b/src/infra/task/branchGitResolver.ts new file mode 100644 index 0000000..9b2fc1f --- /dev/null +++ b/src/infra/task/branchGitResolver.ts @@ -0,0 +1,223 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createLogger } from '../../shared/utils/index.js'; + +type BranchEntryPoint = { + baseCommit: string; + firstCommit: string; +}; + +type FirstTaktCommit = { + subject: string; +}; + +type BaseRefCandidate = { + baseRef: string; + baseCommit: string; + firstSubject: string; + distance: number; +}; + +const TAKT_COMMIT_PREFIX = 'takt:'; +const log = createLogger('branchGitResolver'); + +function runGit(gitCwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd: gitCwd, + encoding: 'utf-8', + stdio: 'pipe', + }).trim(); +} + +function parseDistinctHashes(output: string): string[] { + const hashes = output + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + + const distinct: string[] = []; + for (const hash of hashes) { + if (distinct[distinct.length - 1] !== hash) { + distinct.push(hash); + } + } + + return distinct; +} + +export function resolveGitCwd(cwd: string, worktreePath?: string): string { + return worktreePath && existsSync(worktreePath) ? worktreePath : cwd; +} + +export function resolveMergeBase(gitCwd: string, baseRef: string, branch: string): string { + return runGit(gitCwd, ['merge-base', baseRef, branch]); +} + +function listCandidateRefs(gitCwd: string, branch: string): string[] { + const output = runGit(gitCwd, [ + 'for-each-ref', + '--format=%(refname:short)', + 'refs/heads', + 'refs/remotes', + ]); + + const refs = output + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .filter(ref => ref !== branch) + .filter(ref => !ref.endsWith(`/${branch}`)) + .filter(ref => !ref.endsWith('/HEAD')); + + return Array.from(new Set(refs)); +} + +function getFirstParentDistance(gitCwd: string, baseCommit: string, branch: string): number { + const output = runGit(gitCwd, ['rev-list', '--count', '--first-parent', `${baseCommit}..${branch}`]); + return Number.parseInt(output, 10); +} + +function getFirstParentFirstSubject(gitCwd: string, baseCommit: string, branch: string): string { + const output = runGit(gitCwd, ['log', '--format=%s', '--reverse', '--first-parent', `${baseCommit}..${branch}`]); + return output.split('\n')[0]?.trim() ?? ''; +} + +function resolveBaseCandidate(gitCwd: string, baseRef: string, branch: string): BaseRefCandidate | null { + try { + const baseCommit = resolveMergeBase(gitCwd, baseRef, branch); + if (!baseCommit) { + return null; + } + + const distance = getFirstParentDistance(gitCwd, baseCommit, branch); + if (!Number.isFinite(distance) || distance <= 0) { + return null; + } + + const firstSubject = getFirstParentFirstSubject(gitCwd, baseCommit, branch); + return { baseRef, baseCommit, firstSubject, distance }; + } catch (error) { + log.debug('Failed to resolve base candidate', { error: String(error), gitCwd, baseRef, branch }); + return null; + } +} + +function chooseBestBaseCandidate(candidates: BaseRefCandidate[]): BaseRefCandidate | null { + if (candidates.length === 0) { + return null; + } + + const sorted = [...candidates].sort((a, b) => { + const aTakt = a.firstSubject.startsWith(TAKT_COMMIT_PREFIX); + const bTakt = b.firstSubject.startsWith(TAKT_COMMIT_PREFIX); + if (aTakt !== bTakt) { + return aTakt ? -1 : 1; + } + + if (a.distance !== b.distance) { + return a.distance - b.distance; + } + + const aRemote = a.baseRef.includes('/'); + const bRemote = b.baseRef.includes('/'); + if (aRemote !== bRemote) { + return aRemote ? 1 : -1; + } + + return a.baseRef.localeCompare(b.baseRef); + }); + + return sorted[0] ?? null; +} + +function resolveBranchBaseCommitFromRefs(gitCwd: string, branch: string): string | null { + const refs = listCandidateRefs(gitCwd, branch); + const candidates: BaseRefCandidate[] = []; + + for (const ref of refs) { + const candidate = resolveBaseCandidate(gitCwd, ref, branch); + if (candidate) { + candidates.push(candidate); + } + } + + const best = chooseBestBaseCandidate(candidates); + return best?.baseCommit ?? null; +} + +function resolveBranchEntryPointFromReflog(gitCwd: string, branch: string): BranchEntryPoint | null { + try { + const output = runGit(gitCwd, ['reflog', 'show', '--format=%H', branch]); + const hashes = parseDistinctHashes(output).reverse(); + if (hashes.length < 2) { + return null; + } + + return { + baseCommit: hashes[0]!, + firstCommit: hashes[1]!, + }; + } catch (error) { + log.debug('Failed to resolve branch entry point from reflog', { error: String(error), gitCwd, branch }); + return null; + } +} + +function readCommitSubject(gitCwd: string, commit: string): string { + return runGit(gitCwd, ['show', '-s', '--format=%s', commit]); +} + +function parseFirstCommitLine(output: string): FirstTaktCommit | null { + if (!output) { + return null; + } + + const firstLine = output.split('\n')[0]; + if (!firstLine) { + return null; + } + + const tabIndex = firstLine.indexOf('\t'); + if (tabIndex === -1) { + return null; + } + + return { + subject: firstLine.slice(tabIndex + 1), + }; +} + +export function findFirstTaktCommit( + gitCwd: string, + defaultBranch: string, + branch: string, +): FirstTaktCommit | null { + const entryPoint = resolveBranchEntryPointFromReflog(gitCwd, branch); + if (entryPoint) { + const subject = readCommitSubject(gitCwd, entryPoint.firstCommit); + return { + subject, + }; + } + + const baseCommit = resolveBranchBaseCommitFromRefs(gitCwd, branch) ?? resolveMergeBase(gitCwd, defaultBranch, branch); + const output = runGit(gitCwd, [ + 'log', + '--format=%H\t%s', + '--reverse', + '--first-parent', + '--grep=^takt:', + `${baseCommit}..${branch}`, + ]); + + return parseFirstCommitLine(output); +} + +export function resolveBranchBaseCommit(gitCwd: string, defaultBranch: string, branch: string): string { + const entryPoint = resolveBranchEntryPointFromReflog(gitCwd, branch); + if (entryPoint) { + return entryPoint.baseCommit; + } + + return resolveBranchBaseCommitFromRefs(gitCwd, branch) ?? resolveMergeBase(gitCwd, defaultBranch, branch); +} diff --git a/src/infra/task/branchList.ts b/src/infra/task/branchList.ts index 1d2cb82..3fe5eef 100644 --- a/src/infra/task/branchList.ts +++ b/src/infra/task/branchList.ts @@ -7,8 +7,12 @@ */ import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; import { createLogger } from '../../shared/utils/index.js'; +import { + findFirstTaktCommit, + resolveBranchBaseCommit, + resolveGitCwd, +} from './branchGitResolver.js'; import type { BranchInfo, BranchListItem } from './types.js'; @@ -31,19 +35,22 @@ export class BranchManager { ).trim(); const prefix = 'refs/remotes/origin/'; return ref.startsWith(prefix) ? ref.slice(prefix.length) : ref; - } catch { + } catch (error) { + log.debug('detectDefaultBranch symbolic-ref failed', { error: String(error), cwd }); try { execFileSync('git', ['rev-parse', '--verify', 'main'], { cwd, encoding: 'utf-8', stdio: 'pipe', }); return 'main'; - } catch { + } catch (mainError) { + log.debug('detectDefaultBranch main lookup failed', { error: String(mainError), cwd }); try { execFileSync('git', ['rev-parse', '--verify', 'master'], { cwd, encoding: 'utf-8', stdio: 'pipe', }); return 'master'; - } catch { + } catch (masterError) { + log.debug('detectDefaultBranch master lookup failed', { error: String(masterError), cwd }); return 'main'; } } @@ -110,16 +117,19 @@ export class BranchManager { return entries; } - /** Get the number of files changed between the default branch and a given branch */ + /** Get the number of files changed between a branch and its inferred base commit */ getFilesChanged(cwd: string, defaultBranch: string, branch: string, worktreePath?: string): number { try { - // If worktreePath is provided, use it for git diff (for worktree-sessions branches) - const gitCwd = worktreePath && existsSync(worktreePath) ? worktreePath : cwd; + const gitCwd = resolveGitCwd(cwd, worktreePath); + const baseCommit = resolveBranchBaseCommit(gitCwd, defaultBranch, branch); + if (!baseCommit) { + throw new Error(`Failed to resolve base commit for branch: ${branch}`); + } - log.debug('getFilesChanged', { gitCwd, defaultBranch, branch, worktreePath }); + log.debug('getFilesChanged', { gitCwd, baseCommit, branch, worktreePath }); const output = execFileSync( - 'git', ['diff', '--numstat', `${defaultBranch}...${branch}`], + 'git', ['diff', '--numstat', `${baseCommit}..${branch}`], { cwd: gitCwd, encoding: 'utf-8', stdio: 'pipe' }, ); @@ -150,9 +160,23 @@ export class BranchManager { branch: string, ): string { try { + const firstTaktCommit = findFirstTaktCommit(cwd, defaultBranch, branch); + if (firstTaktCommit) { + const TAKT_COMMIT_PREFIX = 'takt:'; + if (firstTaktCommit.subject.startsWith(TAKT_COMMIT_PREFIX)) { + return firstTaktCommit.subject.slice(TAKT_COMMIT_PREFIX.length).trim(); + } + return firstTaktCommit.subject; + } + + const baseCommit = resolveBranchBaseCommit(cwd, defaultBranch, branch); + if (!baseCommit) { + throw new Error(`Failed to resolve base commit for branch: ${branch}`); + } + const output = execFileSync( 'git', - ['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`], + ['log', '--format=%s', '--reverse', `${baseCommit}..${branch}`], { cwd, encoding: 'utf-8', stdio: 'pipe' }, ).trim(); @@ -165,7 +189,8 @@ export class BranchManager { } return firstLine; - } catch { + } catch (error) { + log.debug('getOriginalInstruction failed', { error: String(error), cwd, defaultBranch, branch }); return ''; } } From 6f937b70b55f81b7d066cb155247e42628b98b54 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:56:14 +0900 Subject: [PATCH 04/19] takt: add-task-instruction-doc (#174) --- src/__tests__/listNonInteractive.test.ts | 8 +- .../listTasksInteractivePendingLabel.test.ts | 109 ++++++++++++++++++ src/features/tasks/list/index.ts | 9 +- src/features/tasks/list/listNonInteractive.ts | 5 +- src/features/tasks/list/taskStatusLabel.ts | 10 ++ 5 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/listTasksInteractivePendingLabel.test.ts create mode 100644 src/features/tasks/list/taskStatusLabel.ts diff --git a/src/__tests__/listNonInteractive.test.ts b/src/__tests__/listNonInteractive.test.ts index 12f93a5..6d38f41 100644 --- a/src/__tests__/listNonInteractive.test.ts +++ b/src/__tests__/listNonInteractive.test.ts @@ -37,7 +37,9 @@ describe('listTasks non-interactive text output', () => { // Then const calls = logSpy.mock.calls.map((c) => c[0] as string); - expect(calls).toContainEqual(expect.stringContaining('[pending] my-task')); + expect(calls).toContainEqual(expect.stringContaining('[running] my-task')); + expect(calls).not.toContainEqual(expect.stringContaining('[pending] my-task')); + expect(calls).not.toContainEqual(expect.stringContaining('[pendig] my-task')); expect(calls).toContainEqual(expect.stringContaining('Fix the login bug')); logSpy.mockRestore(); }); @@ -77,7 +79,9 @@ describe('listTasks non-interactive text output', () => { // Then const calls = logSpy.mock.calls.map((c) => c[0] as string); - expect(calls).toContainEqual(expect.stringContaining('[pending] pending-one')); + expect(calls).toContainEqual(expect.stringContaining('[running] pending-one')); + expect(calls).not.toContainEqual(expect.stringContaining('[pending] pending-one')); + expect(calls).not.toContainEqual(expect.stringContaining('[pendig] pending-one')); expect(calls).toContainEqual(expect.stringContaining('[failed] failed-one')); logSpy.mockRestore(); }); diff --git a/src/__tests__/listTasksInteractivePendingLabel.test.ts b/src/__tests__/listTasksInteractivePendingLabel.test.ts new file mode 100644 index 0000000..f6e3196 --- /dev/null +++ b/src/__tests__/listTasksInteractivePendingLabel.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TaskListItem } from '../infra/task/types.js'; + +const { + mockSelectOption, + mockHeader, + mockInfo, + mockBlankLine, + mockConfirm, + mockListPendingTaskItems, + mockListFailedTasks, + mockDeletePendingTask, +} = vi.hoisted(() => ({ + mockSelectOption: vi.fn(), + mockHeader: vi.fn(), + mockInfo: vi.fn(), + mockBlankLine: vi.fn(), + mockConfirm: vi.fn(), + mockListPendingTaskItems: vi.fn(), + mockListFailedTasks: vi.fn(), + mockDeletePendingTask: vi.fn(), +})); + +vi.mock('../infra/task/index.js', () => ({ + listTaktBranches: vi.fn(() => []), + buildListItems: vi.fn(() => []), + detectDefaultBranch: vi.fn(() => 'main'), + TaskRunner: class { + listPendingTaskItems() { + return mockListPendingTaskItems(); + } + listFailedTasks() { + return mockListFailedTasks(); + } + }, +})); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: mockSelectOption, + confirm: mockConfirm, +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: mockInfo, + header: mockHeader, + blankLine: mockBlankLine, +})); + +vi.mock('../features/tasks/list/taskActions.js', () => ({ + showFullDiff: vi.fn(), + showDiffAndPromptAction: vi.fn(), + tryMergeBranch: vi.fn(), + mergeBranch: vi.fn(), + deleteBranch: vi.fn(), + instructBranch: vi.fn(), +})); + +vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({ + deletePendingTask: mockDeletePendingTask, + deleteFailedTask: vi.fn(), +})); + +vi.mock('../features/tasks/list/taskRetryActions.js', () => ({ + retryFailedTask: vi.fn(), +})); + +import { listTasks } from '../features/tasks/list/index.js'; + +describe('listTasks interactive pending label regression', () => { + const pendingTask: TaskListItem = { + kind: 'pending', + name: 'my-task', + createdAt: '2026-02-09T00:00:00', + filePath: '/tmp/my-task.md', + content: 'Fix running status label', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockListPendingTaskItems.mockReturnValue([pendingTask]); + mockListFailedTasks.mockReturnValue([]); + }); + + it('should show [running] in interactive menu for pending tasks', async () => { + mockSelectOption.mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockSelectOption).toHaveBeenCalledTimes(1); + const menuOptions = mockSelectOption.mock.calls[0]![1] as Array<{ label: string; value: string }>; + expect(menuOptions).toContainEqual(expect.objectContaining({ label: '[running] my-task', value: 'pending:0' })); + expect(menuOptions.some((opt) => opt.label.includes('[pending]'))).toBe(false); + expect(menuOptions.some((opt) => opt.label.includes('[pendig]'))).toBe(false); + }); + + it('should show [running] header when pending task is selected', async () => { + mockSelectOption + .mockResolvedValueOnce('pending:0') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockHeader).toHaveBeenCalledWith('[running] my-task'); + const headerTexts = mockHeader.mock.calls.map(([text]) => String(text)); + expect(headerTexts.some((text) => text.includes('[pending]'))).toBe(false); + expect(headerTexts.some((text) => text.includes('[pendig]'))).toBe(false); + }); +}); diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 8dfa0bf..1575645 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -30,6 +30,7 @@ import { import { deletePendingTask, deleteFailedTask } from './taskDeleteActions.js'; import { retryFailedTask } from './taskRetryActions.js'; import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js'; +import { formatTaskStatusLabel } from './taskStatusLabel.js'; export type { ListNonInteractiveOptions } from './listNonInteractive.js'; @@ -54,7 +55,7 @@ type FailedTaskAction = 'retry' | 'delete'; * Returns the selected action, or null if cancelled. */ async function showPendingTaskAndPromptAction(task: TaskListItem): Promise { - header(`[${task.kind}] ${task.name}`); + header(formatTaskStatusLabel(task)); info(` Created: ${task.createdAt}`); if (task.content) { info(` ${task.content}`); @@ -72,7 +73,7 @@ async function showPendingTaskAndPromptAction(task: TaskListItem): Promise { - header(`[${task.kind}] ${task.name}`); + header(formatTaskStatusLabel(task)); info(` Failed at: ${task.createdAt}`); if (task.content) { info(` ${task.content}`); @@ -129,12 +130,12 @@ export async function listTasks( }; }), ...pendingTasks.map((task, idx) => ({ - label: `[pending] ${task.name}`, + label: formatTaskStatusLabel(task), value: `pending:${idx}`, description: task.content, })), ...failedTasks.map((task, idx) => ({ - label: `[failed] ${task.name}`, + label: formatTaskStatusLabel(task), value: `failed:${idx}`, description: task.content, })), diff --git a/src/features/tasks/list/listNonInteractive.ts b/src/features/tasks/list/listNonInteractive.ts index e26d415..2eb7609 100644 --- a/src/features/tasks/list/listNonInteractive.ts +++ b/src/features/tasks/list/listNonInteractive.ts @@ -20,6 +20,7 @@ import { mergeBranch, deleteBranch, } from './taskActions.js'; +import { formatTaskStatusLabel } from './taskStatusLabel.js'; export interface ListNonInteractiveOptions { enabled: boolean; @@ -56,11 +57,11 @@ function printNonInteractiveList( } for (const task of pendingTasks) { - info(`[pending] ${task.name} - ${task.content}`); + info(`${formatTaskStatusLabel(task)} - ${task.content}`); } for (const task of failedTasks) { - info(`[failed] ${task.name} - ${task.content}`); + info(`${formatTaskStatusLabel(task)} - ${task.content}`); } } diff --git a/src/features/tasks/list/taskStatusLabel.ts b/src/features/tasks/list/taskStatusLabel.ts new file mode 100644 index 0000000..05bd1ae --- /dev/null +++ b/src/features/tasks/list/taskStatusLabel.ts @@ -0,0 +1,10 @@ +import type { TaskListItem } from '../../../infra/task/index.js'; + +const TASK_STATUS_BY_KIND: Record = { + pending: 'running', + failed: 'failed', +}; + +export function formatTaskStatusLabel(task: TaskListItem): string { + return `[${TASK_STATUS_BY_KIND[task.kind]}] ${task.name}`; +} From 8e0257e747733ce100584e2d6617a8e87825e373 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:08:44 +0900 Subject: [PATCH 05/19] =?UTF-8?q?=E3=82=BF=E3=82=B9=E3=82=AF=E6=8C=87?= =?UTF-8?q?=E7=A4=BA=E6=9B=B8=E3=81=AE=E3=82=B4=E3=83=BC=E3=83=AB=E3=82=92?= =?UTF-8?q?=E5=B8=B8=E3=81=AB=E5=AE=9F=E8=A3=85=E3=83=BB=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E3=81=AB=E5=9B=BA=E5=AE=9A=E3=81=97=E3=80=81Open=20Questions?= =?UTF-8?q?=E3=82=92=E3=82=B9=E3=82=B3=E3=83=BC=E3=83=97=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E3=81=AB=E4=BD=BF=E3=82=8F=E3=81=9B=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit アシスタントが「仕様確定まで?実装まで?」のようなスコープ判断を Open Questionsに含めていた問題を修正。指示書は常にピース実行用であり、 ゴールは実装であることを大前提として明記。 --- src/shared/prompts/en/score_summary_system_prompt.md | 7 ++++++- src/shared/prompts/ja/score_summary_system_prompt.md | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/shared/prompts/en/score_summary_system_prompt.md b/src/shared/prompts/en/score_summary_system_prompt.md index d6def6e..c2f6aa1 100644 --- a/src/shared/prompts/en/score_summary_system_prompt.md +++ b/src/shared/prompts/en/score_summary_system_prompt.md @@ -6,13 +6,18 @@ --> You are a task summarizer. Convert the conversation into a concrete task instruction for the planning step. +## Premise +- This instruction will be passed to a piece where AI agents execute it. The goal is always **implementation / execution**. +- Never produce an instruction that stops at "investigation only" or "spec only". If investigation is needed, include the implementation that follows. +- Do NOT include scope or process decisions (e.g., "should we implement or just spec?") in Open Questions. + Requirements: - Output only the final task instruction (no preamble). - Be specific about scope and targets (files/modules) if mentioned. - Preserve constraints and "do not" instructions **only if they were explicitly stated by the user**. - If the source of a constraint is unclear, do not include it; add it to Open Questions if needed. - Do not include constraints proposed or inferred by the assistant. -- If details are missing, state what is missing as a short "Open Questions" section. +- If details are missing, state what is missing as a short "Open Questions" section (technical ambiguities only, not scope or process decisions). {{#if pieceInfo}} ## Destination of Your Task Instruction diff --git a/src/shared/prompts/ja/score_summary_system_prompt.md b/src/shared/prompts/ja/score_summary_system_prompt.md index 236d0e8..bb1ec07 100644 --- a/src/shared/prompts/ja/score_summary_system_prompt.md +++ b/src/shared/prompts/ja/score_summary_system_prompt.md @@ -11,6 +11,11 @@ - 次のステップ: あなたが作成した指示書がピースに渡され、複数のAIエージェントが順次実行する - あなたの成果物(指示書)が、ピース全体の入力(タスク)になる +## 大前提 +- この指示書はピースに渡され、AIエージェントが実行する。ゴールは常に**実装・実行**である +- 「調査のみ」「仕様確定のみ」で止める指示書にしない。調査が必要でも、その先の実装まで含める +- 「実装するかどうか」「どこまでやるか」のようなスコープ判断はOpen Questionsに含めない + ## 要件 - 出力はタスク指示書のみ(前置き不要) - 対象ファイル/モジュールごとに作業内容を明記する @@ -19,7 +24,7 @@ - 制約や「やらないこと」は**ユーザーが明示したもののみ**保持する - 制約の出所が不明な場合は保持せず、必要なら Open Questions に回す - アシスタントが提案・推測した制約は指示書に含めない -- 情報不足があれば「Open Questions」セクションを短く付ける +- 情報不足があれば「Open Questions」セクションを短く付ける(技術的な不明点のみ。スコープや実行判断に関する問いは含めない) {{#if pieceInfo}} ## あなたが作成する指示書の行き先 From 222560a96ad3a718a7545fe2b85ce1c550f7b699 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:04:52 +0900 Subject: [PATCH 06/19] =?UTF-8?q?=E3=83=97=E3=83=AD=E3=83=90=E3=82=A4?= =?UTF-8?q?=E3=83=80=E3=83=BC=E3=82=A8=E3=83=A9=E3=83=BC=E3=82=92=20blocke?= =?UTF-8?q?d=20=E3=81=8B=E3=82=89=20error=20=E3=82=B9=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=82=BF=E3=82=B9=E3=81=AB=E5=88=86=E9=9B=A2=E3=81=97=E3=80=81?= =?UTF-8?q?Codex=20=E3=81=AB=E3=83=AA=E3=83=88=E3=83=A9=E3=82=A4=E6=A9=9F?= =?UTF-8?q?=E6=A7=8B=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blocked はユーザー入力で解決可能な状態、error はプロバイダー障害として意味を明確化。 PieceEngine で error ステータスを検知して即座に abort する。 Codex クライアントにトランジェントエラー(stream disconnected, transport error 等)の 指数バックオフリトライ(最大3回)を追加。 --- src/__tests__/engine-blocked.test.ts | 24 ++ src/__tests__/engine-parallel-failure.test.ts | 6 +- src/__tests__/models.test.ts | 1 + src/core/models/schemas.ts | 1 + src/core/models/status.ts | 1 + src/core/piece/engine/ParallelRunner.ts | 10 +- src/core/piece/engine/PieceEngine.ts | 7 + src/features/interactive/conversationLoop.ts | 4 +- src/infra/claude/client.ts | 4 +- src/infra/codex/client.ts | 357 +++++++++++------- src/infra/mock/scenario.ts | 2 +- src/infra/mock/types.ts | 4 +- src/infra/providers/codex.ts | 8 +- 13 files changed, 269 insertions(+), 160 deletions(-) diff --git a/src/__tests__/engine-blocked.test.ts b/src/__tests__/engine-blocked.test.ts index 28c7ace..8cf10e6 100644 --- a/src/__tests__/engine-blocked.test.ts +++ b/src/__tests__/engine-blocked.test.ts @@ -141,4 +141,28 @@ describe('PieceEngine Integration: Blocked Handling', () => { expect(userInputFn).toHaveBeenCalledOnce(); expect(state.userInputs).toContain('User provided clarification'); }); + + it('should abort immediately when movement returns error status', async () => { + const config = buildDefaultPieceConfig(); + const onUserInput = vi.fn().mockResolvedValueOnce('should not be called'); + const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, onUserInput }); + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', status: 'error', content: 'Transport error', error: 'Transport error' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + ]); + + const abortFn = vi.fn(); + engine.on('piece:abort', abortFn); + + const state = await engine.run(); + + expect(state.status).toBe('aborted'); + expect(onUserInput).not.toHaveBeenCalled(); + expect(abortFn).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('Transport error')); + }); + }); diff --git a/src/__tests__/engine-parallel-failure.test.ts b/src/__tests__/engine-parallel-failure.test.ts index a60dc9d..3d3d00e 100644 --- a/src/__tests__/engine-parallel-failure.test.ts +++ b/src/__tests__/engine-parallel-failure.test.ts @@ -4,7 +4,7 @@ * Covers: * - One sub-movement fails while another succeeds → piece continues * - All sub-movements fail → piece aborts - * - Failed sub-movement is recorded as blocked with error + * - Failed sub-movement is recorded as error with error message */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; @@ -141,10 +141,10 @@ describe('PieceEngine Integration: Parallel Movement Partial Failure', () => { expect(state.status).toBe('completed'); - // arch-review should be recorded as blocked + // arch-review should be recorded as error const archReviewOutput = state.movementOutputs.get('arch-review'); expect(archReviewOutput).toBeDefined(); - expect(archReviewOutput!.status).toBe('blocked'); + expect(archReviewOutput!.status).toBe('error'); expect(archReviewOutput!.error).toContain('exit'); // security-review should be recorded as done diff --git a/src/__tests__/models.test.ts b/src/__tests__/models.test.ts index 7b9e179..db67628 100644 --- a/src/__tests__/models.test.ts +++ b/src/__tests__/models.test.ts @@ -33,6 +33,7 @@ describe('StatusSchema', () => { expect(StatusSchema.parse('approved')).toBe('approved'); expect(StatusSchema.parse('rejected')).toBe('rejected'); expect(StatusSchema.parse('blocked')).toBe('blocked'); + expect(StatusSchema.parse('error')).toBe('error'); expect(StatusSchema.parse('answer')).toBe('answer'); }); diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 9b0dcbe..df0d6b8 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -48,6 +48,7 @@ export const StatusSchema = z.enum([ 'pending', 'done', 'blocked', + 'error', 'approved', 'rejected', 'improve', diff --git a/src/core/models/status.ts b/src/core/models/status.ts index adb0a64..fb77af9 100644 --- a/src/core/models/status.ts +++ b/src/core/models/status.ts @@ -10,6 +10,7 @@ export type Status = | 'pending' | 'done' | 'blocked' + | 'error' | 'approved' | 'rejected' | 'improve' diff --git a/src/core/piece/engine/ParallelRunner.ts b/src/core/piece/engine/ParallelRunner.ts index a6f9ba5..de0ca70 100644 --- a/src/core/piece/engine/ParallelRunner.ts +++ b/src/core/piece/engine/ParallelRunner.ts @@ -131,7 +131,7 @@ export class ParallelRunner { }), ); - // Map settled results: fulfilled → as-is, rejected → blocked AgentResponse + // Map settled results: fulfilled → as-is, rejected → error AgentResponse const subResults = settled.map((result, index) => { if (result.status === 'fulfilled') { return result.value; @@ -139,15 +139,15 @@ export class ParallelRunner { const failedMovement = subMovements[index]!; const errorMsg = getErrorMessage(result.reason); log.error('Sub-movement failed', { movement: failedMovement.name, error: errorMsg }); - const blockedResponse: AgentResponse = { + const errorResponse: AgentResponse = { persona: failedMovement.name, - status: 'blocked', + status: 'error', content: '', timestamp: new Date(), error: errorMsg, }; - state.movementOutputs.set(failedMovement.name, blockedResponse); - return { subMovement: failedMovement, response: blockedResponse, instruction: '' }; + state.movementOutputs.set(failedMovement.name, errorResponse); + return { subMovement: failedMovement, response: errorResponse, instruction: '' }; }); // If all sub-movements failed (error-originated), throw diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index d04c984..d7f5071 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -522,6 +522,13 @@ export class PieceEngine extends EventEmitter { break; } + if (response.status === 'error') { + const detail = response.error ?? response.content ?? `Movement "${movement.name}" returned error status`; + this.state.status = 'aborted'; + this.emit('piece:abort', this.state, `Movement "${movement.name}" failed: ${detail}`); + break; + } + let nextMovement = this.resolveNextMovement(movement, response); log.debug('Movement transition', { from: movement.name, diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index 45c3989..1819ceb 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -109,7 +109,7 @@ export async function callAIWithRetry( onStream: display.createHandler(), }); display.flush(); - const success = response.status !== 'blocked'; + const success = response.status !== 'blocked' && response.status !== 'error'; if (!success && sessionId) { log.info('Session invalid, retrying without session'); @@ -129,7 +129,7 @@ export async function callAIWithRetry( updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType); } return { - result: { content: retry.content, sessionId: retry.sessionId, success: retry.status !== 'blocked' }, + result: { content: retry.content, sessionId: retry.sessionId, success: retry.status !== 'blocked' && retry.status !== 'error' }, sessionId, }; } diff --git a/src/infra/claude/client.ts b/src/infra/claude/client.ts index 9ef9729..85fc418 100644 --- a/src/infra/claude/client.ts +++ b/src/infra/claude/client.ts @@ -29,7 +29,7 @@ export class ClaudeClient { if (result.interrupted) { return 'interrupted'; } - return 'blocked'; + return 'error'; } return 'done'; } @@ -146,7 +146,7 @@ export class ClaudeClient { return { persona: `skill:${skillName}`, - status: result.success ? 'done' : 'blocked', + status: result.success ? 'done' : 'error', content: result.content, timestamp: new Date(), sessionId: result.sessionId, diff --git a/src/infra/codex/client.ts b/src/infra/codex/client.ts index aed7248..e0d43ec 100644 --- a/src/infra/codex/client.ts +++ b/src/infra/codex/client.ts @@ -25,6 +25,18 @@ export type { CodexCallOptions } from './types.js'; const log = createLogger('codex-sdk'); const CODEX_STREAM_IDLE_TIMEOUT_MS = 10 * 60 * 1000; const CODEX_STREAM_ABORTED_MESSAGE = 'Codex execution aborted'; +const CODEX_RETRY_MAX_ATTEMPTS = 3; +const CODEX_RETRY_BASE_DELAY_MS = 250; +const CODEX_RETRYABLE_ERROR_PATTERNS = [ + 'stream disconnected before completion', + 'transport error', + 'network error', + 'error decoding response body', + 'econnreset', + 'etimedout', + 'eai_again', + 'fetch failed', +]; /** * Client for Codex SDK agent interactions. @@ -33,13 +45,49 @@ const CODEX_STREAM_ABORTED_MESSAGE = 'Codex execution aborted'; * and response processing. */ export class CodexClient { + private isRetriableError(message: string, aborted: boolean, abortCause?: 'timeout' | 'external'): boolean { + if (aborted || abortCause) { + return false; + } + + const lower = message.toLowerCase(); + return CODEX_RETRYABLE_ERROR_PATTERNS.some((pattern) => lower.includes(pattern)); + } + + private async waitForRetryDelay(attempt: number, signal?: AbortSignal): Promise { + const delayMs = CODEX_RETRY_BASE_DELAY_MS * (2 ** Math.max(0, attempt - 1)); + await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + if (signal) { + signal.removeEventListener('abort', onAbort); + } + resolve(); + }, delayMs); + + const onAbort = (): void => { + clearTimeout(timeoutId); + if (signal) { + signal.removeEventListener('abort', onAbort); + } + reject(new Error(CODEX_STREAM_ABORTED_MESSAGE)); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + }); + } + /** Call Codex with an agent prompt */ async call( agentType: string, prompt: string, options: CodexCallOptions, ): Promise { - const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined); const sandboxMode = options.permissionMode ? mapToCodexSandboxMode(options.permissionMode) : 'workspace-write'; @@ -48,186 +96,213 @@ export class CodexClient { workingDirectory: options.cwd, sandboxMode, }; - const thread = options.sessionId - ? await codex.resumeThread(options.sessionId, threadOptions) - : await codex.startThread(threadOptions); - let threadId = extractThreadId(thread) || options.sessionId; + let threadId = options.sessionId; const fullPrompt = options.systemPrompt ? `${options.systemPrompt}\n\n${prompt}` : prompt; - let idleTimeoutId: ReturnType | undefined; - const streamAbortController = new AbortController(); - const timeoutMessage = `Codex stream timed out after ${Math.floor(CODEX_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; - let abortCause: 'timeout' | 'external' | undefined; + for (let attempt = 1; attempt <= CODEX_RETRY_MAX_ATTEMPTS; attempt++) { + const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined); + const thread = threadId + ? await codex.resumeThread(threadId, threadOptions) + : await codex.startThread(threadOptions); + let currentThreadId = extractThreadId(thread) || threadId; - const resetIdleTimeout = (): void => { - if (idleTimeoutId !== undefined) { - clearTimeout(idleTimeoutId); - } - idleTimeoutId = setTimeout(() => { - abortCause = 'timeout'; + let idleTimeoutId: ReturnType | undefined; + const streamAbortController = new AbortController(); + const timeoutMessage = `Codex stream timed out after ${Math.floor(CODEX_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; + let abortCause: 'timeout' | 'external' | undefined; + + const resetIdleTimeout = (): void => { + if (idleTimeoutId !== undefined) { + clearTimeout(idleTimeoutId); + } + idleTimeoutId = setTimeout(() => { + abortCause = 'timeout'; + streamAbortController.abort(); + }, CODEX_STREAM_IDLE_TIMEOUT_MS); + }; + + const onExternalAbort = (): void => { + abortCause = 'external'; streamAbortController.abort(); - }, CODEX_STREAM_IDLE_TIMEOUT_MS); - }; + }; - const onExternalAbort = (): void => { - abortCause = 'external'; - streamAbortController.abort(); - }; - - if (options.abortSignal) { - if (options.abortSignal.aborted) { - streamAbortController.abort(); - } else { - options.abortSignal.addEventListener('abort', onExternalAbort, { once: true }); + if (options.abortSignal) { + if (options.abortSignal.aborted) { + streamAbortController.abort(); + } else { + options.abortSignal.addEventListener('abort', onExternalAbort, { once: true }); + } } - } - try { - log.debug('Executing Codex thread', { - agentType, - model: options.model, - hasSystemPrompt: !!options.systemPrompt, - }); + try { + log.debug('Executing Codex thread', { + agentType, + model: options.model, + hasSystemPrompt: !!options.systemPrompt, + attempt, + }); - const { events } = await thread.runStreamed(fullPrompt, { - signal: streamAbortController.signal, - }); - resetIdleTimeout(); - let content = ''; - const contentOffsets = new Map(); - let success = true; - let failureMessage = ''; - const state = createStreamTrackingState(); - - for await (const event of events as AsyncGenerator) { + const { events } = await thread.runStreamed(fullPrompt, { + signal: streamAbortController.signal, + }); resetIdleTimeout(); - if (event.type === 'thread.started') { - threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId; - emitInit(options.onStream, options.model, threadId); - continue; - } - if (event.type === 'turn.failed') { - success = false; - if (event.error && typeof event.error === 'object' && 'message' in event.error) { - failureMessage = String((event.error as { message?: unknown }).message ?? ''); + let content = ''; + const contentOffsets = new Map(); + let success = true; + let failureMessage = ''; + const state = createStreamTrackingState(); + + for await (const event of events as AsyncGenerator) { + resetIdleTimeout(); + + if (event.type === 'thread.started') { + currentThreadId = typeof event.thread_id === 'string' ? event.thread_id : currentThreadId; + emitInit(options.onStream, options.model, currentThreadId); + continue; } - break; - } - if (event.type === 'error') { - success = false; - failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error'; - break; - } - - if (event.type === 'item.started') { - const item = event.item as CodexItem | undefined; - if (item) { - emitCodexItemStart(item, options.onStream, state.startedItems); + if (event.type === 'turn.failed') { + success = false; + if (event.error && typeof event.error === 'object' && 'message' in event.error) { + failureMessage = String((event.error as { message?: unknown }).message ?? ''); + } + break; } - continue; - } - if (event.type === 'item.updated') { - const item = event.item as CodexItem | undefined; - if (item) { - if (item.type === 'agent_message' && typeof item.text === 'string') { - const itemId = item.id; - const text = item.text; - if (itemId) { - const prev = contentOffsets.get(itemId) ?? 0; - if (text.length > prev) { - if (prev === 0 && content.length > 0) { - content += '\n'; + if (event.type === 'error') { + success = false; + failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error'; + break; + } + + if (event.type === 'item.started') { + const item = event.item as CodexItem | undefined; + if (item) { + emitCodexItemStart(item, options.onStream, state.startedItems); + } + continue; + } + + if (event.type === 'item.updated') { + const item = event.item as CodexItem | undefined; + if (item) { + if (item.type === 'agent_message' && typeof item.text === 'string') { + const itemId = item.id; + const text = item.text; + if (itemId) { + const prev = contentOffsets.get(itemId) ?? 0; + if (text.length > prev) { + if (prev === 0 && content.length > 0) { + content += '\n'; + } + content += text.slice(prev); + contentOffsets.set(itemId, text.length); } - content += text.slice(prev); - contentOffsets.set(itemId, text.length); } } + emitCodexItemUpdate(item, options.onStream, state); } - emitCodexItemUpdate(item, options.onStream, state); + continue; } - continue; - } - if (event.type === 'item.completed') { - const item = event.item as CodexItem | undefined; - if (item) { - if (item.type === 'agent_message' && typeof item.text === 'string') { - const itemId = item.id; - const text = item.text; - if (itemId) { - const prev = contentOffsets.get(itemId) ?? 0; - if (text.length > prev) { - if (prev === 0 && content.length > 0) { + if (event.type === 'item.completed') { + const item = event.item as CodexItem | undefined; + if (item) { + if (item.type === 'agent_message' && typeof item.text === 'string') { + const itemId = item.id; + const text = item.text; + if (itemId) { + const prev = contentOffsets.get(itemId) ?? 0; + if (text.length > prev) { + if (prev === 0 && content.length > 0) { + content += '\n'; + } + content += text.slice(prev); + contentOffsets.set(itemId, text.length); + } + } else if (text) { + if (content.length > 0) { content += '\n'; } - content += text.slice(prev); - contentOffsets.set(itemId, text.length); + content += text; } - } else if (text) { - if (content.length > 0) { - content += '\n'; - } - content += text; } + emitCodexItemCompleted(item, options.onStream, state); } - emitCodexItemCompleted(item, options.onStream, state); + continue; } - continue; } - } - if (!success) { - const message = failureMessage || 'Codex execution failed'; - emitResult(options.onStream, false, message, threadId); + if (!success) { + const message = failureMessage || 'Codex execution failed'; + const retriable = this.isRetriableError(message, streamAbortController.signal.aborted, abortCause); + if (retriable && attempt < CODEX_RETRY_MAX_ATTEMPTS) { + log.info('Retrying Codex call after transient failure', { agentType, attempt, message }); + threadId = currentThreadId; + await this.waitForRetryDelay(attempt, options.abortSignal); + continue; + } + + emitResult(options.onStream, false, message, currentThreadId); + return { + persona: agentType, + status: 'error', + content: message, + timestamp: new Date(), + sessionId: currentThreadId, + }; + } + + const trimmed = content.trim(); + emitResult(options.onStream, true, trimmed, currentThreadId); + return { persona: agentType, - status: 'blocked', - content: message, + status: 'done', + content: trimmed, timestamp: new Date(), - sessionId: threadId, + sessionId: currentThreadId, }; - } + } catch (error) { + const message = getErrorMessage(error); + const errorMessage = streamAbortController.signal.aborted + ? abortCause === 'timeout' + ? timeoutMessage + : CODEX_STREAM_ABORTED_MESSAGE + : message; - const trimmed = content.trim(); - emitResult(options.onStream, true, trimmed, threadId); + const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause); + if (retriable && attempt < CODEX_RETRY_MAX_ATTEMPTS) { + log.info('Retrying Codex call after transient exception', { agentType, attempt, errorMessage }); + threadId = currentThreadId; + await this.waitForRetryDelay(attempt, options.abortSignal); + continue; + } - return { - persona: agentType, - status: 'done', - content: trimmed, - timestamp: new Date(), - sessionId: threadId, - }; - } catch (error) { - const message = getErrorMessage(error); - const errorMessage = streamAbortController.signal.aborted - ? abortCause === 'timeout' - ? timeoutMessage - : CODEX_STREAM_ABORTED_MESSAGE - : message; - emitResult(options.onStream, false, errorMessage, threadId); + emitResult(options.onStream, false, errorMessage, currentThreadId); - return { - persona: agentType, - status: 'blocked', - content: errorMessage, - timestamp: new Date(), - sessionId: threadId, - }; - } finally { - if (idleTimeoutId !== undefined) { - clearTimeout(idleTimeoutId); - } - if (options.abortSignal) { - options.abortSignal.removeEventListener('abort', onExternalAbort); + return { + persona: agentType, + status: 'error', + content: errorMessage, + timestamp: new Date(), + sessionId: currentThreadId, + }; + } finally { + if (idleTimeoutId !== undefined) { + clearTimeout(idleTimeoutId); + } + if (options.abortSignal) { + options.abortSignal.removeEventListener('abort', onExternalAbort); + } } } + + throw new Error('Unreachable: Codex retry loop exhausted without returning'); } /** Call Codex with a custom agent configuration (system prompt + prompt) */ diff --git a/src/infra/mock/scenario.ts b/src/infra/mock/scenario.ts index 8eb3e5c..3271e32 100644 --- a/src/infra/mock/scenario.ts +++ b/src/infra/mock/scenario.ts @@ -130,7 +130,7 @@ function validateEntry(entry: unknown, index: number): ScenarioEntry { } // status defaults to 'done' - const validStatuses = ['done', 'blocked', 'approved', 'rejected', 'improve'] as const; + const validStatuses = ['done', 'blocked', 'error', 'approved', 'rejected', 'improve'] as const; const status = obj.status ?? 'done'; if (typeof status !== 'string' || !validStatuses.includes(status as typeof validStatuses[number])) { throw new Error( diff --git a/src/infra/mock/types.ts b/src/infra/mock/types.ts index b1c5386..f55b2bb 100644 --- a/src/infra/mock/types.ts +++ b/src/infra/mock/types.ts @@ -12,7 +12,7 @@ export interface MockCallOptions { /** Fixed response content (optional, defaults to generic mock response) */ mockResponse?: string; /** Fixed status to return (optional, defaults to 'done') */ - mockStatus?: 'done' | 'blocked' | 'approved' | 'rejected' | 'improve'; + mockStatus?: 'done' | 'blocked' | 'error' | 'approved' | 'rejected' | 'improve'; } /** A single entry in a mock scenario */ @@ -20,7 +20,7 @@ export interface ScenarioEntry { /** Persona name to match (optional — if omitted, consumed by call order) */ persona?: string; /** Response status */ - status: 'done' | 'blocked' | 'approved' | 'rejected' | 'improve'; + status: 'done' | 'blocked' | 'error' | 'approved' | 'rejected' | 'improve'; /** Response content body */ content: string; } diff --git a/src/infra/providers/codex.ts b/src/infra/providers/codex.ts index 4dcbdcd..88c67d2 100644 --- a/src/infra/providers/codex.ts +++ b/src/infra/providers/codex.ts @@ -36,10 +36,10 @@ function toCodexOptions(options: ProviderCallOptions): CodexCallOptions { }; } -function blockedResponse(agentName: string): AgentResponse { +function errorResponse(agentName: string): AgentResponse { return { persona: agentName, - status: 'blocked', + status: 'error', content: NOT_GIT_REPO_MESSAGE, timestamp: new Date(), }; @@ -59,7 +59,7 @@ export class CodexProvider implements Provider { if (systemPrompt) { return { call: async (prompt: string, options: ProviderCallOptions): Promise => { - if (!isInsideGitRepo(options.cwd)) return blockedResponse(name); + if (!isInsideGitRepo(options.cwd)) return errorResponse(name); return callCodexCustom(name, prompt, systemPrompt, toCodexOptions(options)); }, }; @@ -67,7 +67,7 @@ export class CodexProvider implements Provider { return { call: async (prompt: string, options: ProviderCallOptions): Promise => { - if (!isInsideGitRepo(options.cwd)) return blockedResponse(name); + if (!isInsideGitRepo(options.cwd)) return errorResponse(name); return callCodex(name, prompt, toCodexOptions(options)); }, }; From 4ca414be6bd81366637f1306b7e5f79152b57e3f Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:29:24 +0900 Subject: [PATCH 07/19] takt: consolidate-tasks-yaml (#187) --- src/__tests__/addTask.test.ts | 366 +--------- src/__tests__/listNonInteractive.test.ts | 243 ++----- src/__tests__/listTasks.test.ts | 429 ++--------- src/__tests__/runAllTasks-concurrency.test.ts | 32 +- src/__tests__/saveTaskFile.test.ts | 270 ++----- src/__tests__/task.test.ts | 687 ++++++------------ src/__tests__/taskDeleteActions.test.ts | 168 ++--- src/__tests__/taskExecution.test.ts | 3 + src/__tests__/taskRetryActions.test.ts | 299 ++------ src/__tests__/watchTasks.test.ts | 89 +++ src/__tests__/watcher.test.ts | 113 +-- src/features/tasks/add/index.ts | 64 +- src/features/tasks/execute/pieceExecution.ts | 2 + src/features/tasks/execute/resolveTask.ts | 1 - src/features/tasks/execute/taskExecution.ts | 44 +- src/features/tasks/execute/types.ts | 2 + src/features/tasks/list/taskActions.ts | 56 +- src/features/tasks/list/taskDeleteActions.ts | 17 +- src/features/tasks/list/taskRetryActions.ts | 160 +--- src/features/tasks/watch/index.ts | 6 +- src/infra/task/display.ts | 5 +- src/infra/task/index.ts | 15 +- src/infra/task/mapper.ts | 79 ++ src/infra/task/naming.ts | 22 + src/infra/task/parser.ts | 106 --- src/infra/task/runner.ts | 602 +++++++-------- src/infra/task/schema.ts | 189 ++++- src/infra/task/store.ts | 181 +++++ src/infra/task/types.ts | 7 +- src/infra/task/watcher.ts | 5 +- 30 files changed, 1562 insertions(+), 2700 deletions(-) create mode 100644 src/__tests__/watchTasks.test.ts create mode 100644 src/infra/task/mapper.ts create mode 100644 src/infra/task/naming.ts delete mode 100644 src/infra/task/parser.ts create mode 100644 src/infra/task/store.ts diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index e00f40f..8c645a3 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -1,21 +1,13 @@ -/** - * Tests for addTask command - */ - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { tmpdir } from 'node:os'; +import { parse as parseYaml } from 'yaml'; -// Mock dependencies before importing the module under test vi.mock('../features/interactive/index.js', () => ({ interactiveMode: vi.fn(), })); -vi.mock('../infra/providers/index.js', () => ({ - getProvider: vi.fn(), -})); - vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })), getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), @@ -26,14 +18,11 @@ vi.mock('../shared/prompt/index.js', () => ({ confirm: vi.fn(), })); -vi.mock('../infra/task/summarize.js', () => ({ - summarizeTaskName: vi.fn(), -})); - vi.mock('../shared/ui/index.js', () => ({ success: vi.fn(), info: vi.fn(), blankLine: vi.fn(), + error: vi.fn(), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ @@ -76,42 +65,27 @@ vi.mock('../infra/github/issue.js', () => ({ import { interactiveMode } from '../features/interactive/index.js'; import { promptInput, confirm } from '../shared/prompt/index.js'; -import { summarizeTaskName } from '../infra/task/summarize.js'; import { determinePiece } from '../features/tasks/execute/selectAndExecute.js'; -import { getPieceDescription } from '../infra/config/loaders/pieceResolver.js'; -import { resolveIssueTask, createIssue } from '../infra/github/issue.js'; +import { resolveIssueTask } from '../infra/github/issue.js'; import { addTask } from '../features/tasks/index.js'; const mockResolveIssueTask = vi.mocked(resolveIssueTask); -const mockCreateIssue = vi.mocked(createIssue); const mockInteractiveMode = vi.mocked(interactiveMode); const mockPromptInput = vi.mocked(promptInput); const mockConfirm = vi.mocked(confirm); -const mockSummarizeTaskName = vi.mocked(summarizeTaskName); const mockDeterminePiece = vi.mocked(determinePiece); -const mockGetPieceDescription = vi.mocked(getPieceDescription); - -function setupFullFlowMocks(overrides?: { - task?: string; - slug?: string; -}) { - const task = overrides?.task ?? '# 認証機能追加\nJWT認証を実装する'; - const slug = overrides?.slug ?? 'add-auth'; - - mockDeterminePiece.mockResolvedValue('default'); - mockGetPieceDescription.mockReturnValue({ name: 'default', description: '', pieceStructure: '' }); - mockInteractiveMode.mockResolvedValue({ action: 'execute', task }); - mockSummarizeTaskName.mockResolvedValue(slug); - mockConfirm.mockResolvedValue(false); -} let testDir: string; +function loadTasks(dir: string): { tasks: Array> } { + const raw = fs.readFileSync(path.join(dir, '.takt', 'tasks.yaml'), 'utf-8'); + return parseYaml(raw) as { tasks: Array> }; +} + beforeEach(() => { vi.clearAllMocks(); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-')); mockDeterminePiece.mockResolvedValue('default'); - mockGetPieceDescription.mockReturnValue({ name: 'default', description: '', pieceStructure: '' }); mockConfirm.mockResolvedValue(false); }); @@ -122,332 +96,46 @@ afterEach(() => { }); describe('addTask', () => { - it('should cancel when interactive mode is not confirmed', async () => { - // Given: user cancels interactive mode - mockDeterminePiece.mockResolvedValue('default'); - mockInteractiveMode.mockResolvedValue({ action: 'cancel', task: '' }); + it('should create task entry from interactive result', async () => { + mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' }); - // When await addTask(testDir); - const tasksDir = path.join(testDir, '.takt', 'tasks'); - const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []; - expect(files.length).toBe(0); - expect(mockSummarizeTaskName).not.toHaveBeenCalled(); + const tasks = loadTasks(testDir).tasks; + expect(tasks).toHaveLength(1); + expect(tasks[0]?.content).toContain('JWT認証を実装する'); + expect(tasks[0]?.piece).toBe('default'); }); - it('should create task file with AI-summarized content', async () => { - // Given: full flow setup - setupFullFlowMocks(); - - // When - await addTask(testDir); - - // Then: task file created with summarized content - const tasksDir = path.join(testDir, '.takt', 'tasks'); - const taskFile = path.join(tasksDir, 'add-auth.yaml'); - expect(fs.existsSync(taskFile)).toBe(true); - - const content = fs.readFileSync(taskFile, 'utf-8'); - expect(content).toContain('# 認証機能追加'); - expect(content).toContain('JWT認証を実装する'); - }); - - it('should use first line of task for filename generation', async () => { - setupFullFlowMocks({ - task: 'First line summary\nSecond line details', - slug: 'first-line', - }); - - await addTask(testDir); - - expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line summary', { cwd: testDir }); - }); - - it('should append counter for duplicate filenames', async () => { - // Given: first task creates 'my-task.yaml' - setupFullFlowMocks({ slug: 'my-task' }); - await addTask(testDir); - - // When: create second task with same slug - setupFullFlowMocks({ slug: 'my-task' }); - await addTask(testDir); - - // Then: second file has counter - const tasksDir = path.join(testDir, '.takt', 'tasks'); - expect(fs.existsSync(path.join(tasksDir, 'my-task.yaml'))).toBe(true); - expect(fs.existsSync(path.join(tasksDir, 'my-task-1.yaml'))).toBe(true); - }); - - it('should include worktree option when confirmed', async () => { - // Given: user confirms worktree - setupFullFlowMocks({ slug: 'with-worktree' }); + it('should include worktree settings when enabled', async () => { + mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'Task content' }); mockConfirm.mockResolvedValue(true); - mockPromptInput.mockResolvedValue(''); + mockPromptInput.mockResolvedValueOnce('/custom/path').mockResolvedValueOnce('feat/branch'); - // When await addTask(testDir); - // Then - const taskFile = path.join(testDir, '.takt', 'tasks', 'with-worktree.yaml'); - const content = fs.readFileSync(taskFile, 'utf-8'); - expect(content).toContain('worktree: true'); + const task = loadTasks(testDir).tasks[0]!; + expect(task.worktree).toBe('/custom/path'); + expect(task.branch).toBe('feat/branch'); }); - it('should include custom worktree path when provided', async () => { - // Given: user provides custom worktree path - setupFullFlowMocks({ slug: 'custom-path' }); - mockConfirm.mockResolvedValue(true); - mockPromptInput - .mockResolvedValueOnce('/custom/path') - .mockResolvedValueOnce(''); - - // When - await addTask(testDir); - - // Then - const taskFile = path.join(testDir, '.takt', 'tasks', 'custom-path.yaml'); - const content = fs.readFileSync(taskFile, 'utf-8'); - expect(content).toContain('worktree: /custom/path'); - }); - - it('should include branch when provided', async () => { - // Given: user provides custom branch - setupFullFlowMocks({ slug: 'with-branch' }); - mockConfirm.mockResolvedValue(true); - mockPromptInput - .mockResolvedValueOnce('') - .mockResolvedValueOnce('feat/my-branch'); - - // When - await addTask(testDir); - - // Then - const taskFile = path.join(testDir, '.takt', 'tasks', 'with-branch.yaml'); - const content = fs.readFileSync(taskFile, 'utf-8'); - expect(content).toContain('branch: feat/my-branch'); - }); - - it('should include piece selection in task file', async () => { - // Given: determinePiece returns a non-default piece - setupFullFlowMocks({ slug: 'with-piece' }); - mockDeterminePiece.mockResolvedValue('review'); - mockGetPieceDescription.mockReturnValue({ name: 'review', description: 'Code review piece', pieceStructure: '' }); + it('should create task from issue reference without interactive mode', async () => { + mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout'); mockConfirm.mockResolvedValue(false); - // When - await addTask(testDir); - - // Then - const taskFile = path.join(testDir, '.takt', 'tasks', 'with-piece.yaml'); - const content = fs.readFileSync(taskFile, 'utf-8'); - expect(content).toContain('piece: review'); - }); - - it('should cancel when piece selection returns null', async () => { - // Given: user cancels piece selection - mockDeterminePiece.mockResolvedValue(null); - - // When - await addTask(testDir); - - // Then: no task file created (cancelled at piece selection) - const tasksDir = path.join(testDir, '.takt', 'tasks'); - const files = fs.readdirSync(tasksDir); - expect(files.length).toBe(0); - }); - - it('should always include piece from determinePiece', async () => { - // Given: determinePiece returns 'default' - setupFullFlowMocks({ slug: 'default-wf' }); - mockDeterminePiece.mockResolvedValue('default'); - mockConfirm.mockResolvedValue(false); - - // When - await addTask(testDir); - - // Then: piece field is included - const taskFile = path.join(testDir, '.takt', 'tasks', 'default-wf.yaml'); - const content = fs.readFileSync(taskFile, 'utf-8'); - expect(content).toContain('piece: default'); - }); - - it('should fetch issue and use directly as task content when given issue reference', async () => { - // Given: issue reference "#99" - const issueText = 'Issue #99: Fix login timeout\n\nThe login page times out after 30 seconds.'; - mockResolveIssueTask.mockReturnValue(issueText); - mockDeterminePiece.mockResolvedValue('default'); - - mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); - mockConfirm.mockResolvedValue(false); - - // When await addTask(testDir, '#99'); - // Then: interactiveMode should NOT be called expect(mockInteractiveMode).not.toHaveBeenCalled(); - - // Then: resolveIssueTask was called - expect(mockResolveIssueTask).toHaveBeenCalledWith('#99'); - - // Then: determinePiece was called for piece selection - expect(mockDeterminePiece).toHaveBeenCalledWith(testDir); - - // Then: task file created with issue text directly (no AI summarization) - const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml'); - expect(fs.existsSync(taskFile)).toBe(true); - const content = fs.readFileSync(taskFile, 'utf-8'); - expect(content).toContain('Fix login timeout'); + const task = loadTasks(testDir).tasks[0]!; + expect(task.content).toContain('Fix login timeout'); + expect(task.issue).toBe(99); }); - it('should proceed to worktree settings after issue fetch', async () => { - // Given: issue with worktree enabled - mockResolveIssueTask.mockReturnValue('Issue text'); - mockDeterminePiece.mockResolvedValue('default'); - mockSummarizeTaskName.mockResolvedValue('issue-task'); - mockConfirm.mockResolvedValue(true); - mockPromptInput - .mockResolvedValueOnce('') // worktree path (auto) - .mockResolvedValueOnce(''); // branch name (auto) - - // When - await addTask(testDir, '#42'); - - // Then: worktree settings applied - const taskFile = path.join(testDir, '.takt', 'tasks', 'issue-task.yaml'); - const content = fs.readFileSync(taskFile, 'utf-8'); - expect(content).toContain('worktree: true'); - }); - - it('should handle GitHub API failure gracefully for issue reference', async () => { - // Given: resolveIssueTask throws - mockResolveIssueTask.mockImplementation(() => { - throw new Error('GitHub API rate limit exceeded'); - }); - - // When - await addTask(testDir, '#99'); - - const tasksDir = path.join(testDir, '.takt', 'tasks'); - const files = fs.readdirSync(tasksDir); - expect(files.length).toBe(0); - }); - - it('should include issue number in task file when issue reference is used', async () => { - // Given: issue reference "#99" - const issueText = 'Issue #99: Fix login timeout'; - mockResolveIssueTask.mockReturnValue(issueText); - mockDeterminePiece.mockResolvedValue('default'); - mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); - mockConfirm.mockResolvedValue(false); - - // When - await addTask(testDir, '#99'); - - // Then: task file contains issue field - const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml'); - expect(fs.existsSync(taskFile)).toBe(true); - const content = fs.readFileSync(taskFile, 'utf-8'); - expect(content).toContain('issue: 99'); - }); - - it('should include piece selection in task file when issue reference is used', async () => { - // Given: issue reference "#99" with non-default piece selection - const issueText = 'Issue #99: Fix login timeout'; - mockResolveIssueTask.mockReturnValue(issueText); - mockDeterminePiece.mockResolvedValue('review'); - mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); - mockConfirm.mockResolvedValue(false); - - // When - await addTask(testDir, '#99'); - - // Then: task file contains piece field - const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml'); - expect(fs.existsSync(taskFile)).toBe(true); - const content = fs.readFileSync(taskFile, 'utf-8'); - expect(content).toContain('piece: review'); - }); - - it('should cancel when piece selection returns null for issue reference', async () => { - // Given: issue fetched successfully but user cancels piece selection - const issueText = 'Issue #99: Fix login timeout'; - mockResolveIssueTask.mockReturnValue(issueText); + it('should not create task when piece selection is cancelled', async () => { mockDeterminePiece.mockResolvedValue(null); - // When - await addTask(testDir, '#99'); - - // Then: no task file created (cancelled at piece selection) - const tasksDir = path.join(testDir, '.takt', 'tasks'); - const files = fs.readdirSync(tasksDir); - expect(files.length).toBe(0); - - // Then: issue was fetched before cancellation - expect(mockResolveIssueTask).toHaveBeenCalledWith('#99'); - }); - - it('should call auto-PR confirm with default true', async () => { - // Given: worktree is confirmed so auto-PR prompt is reached - setupFullFlowMocks({ slug: 'auto-pr-default' }); - mockConfirm.mockResolvedValue(true); - mockPromptInput.mockResolvedValue(''); - - // When await addTask(testDir); - // Then: second confirm call (Auto-create PR?) has defaultYes=true - const autoPrCall = mockConfirm.mock.calls.find( - (call) => call[0] === 'Auto-create PR?', - ); - expect(autoPrCall).toBeDefined(); - expect(autoPrCall![1]).toBe(true); - }); - - describe('create_issue action', () => { - it('should call createIssue when create_issue action is selected', async () => { - // Given: interactive mode returns create_issue action - const task = 'Create a new feature\nWith detailed description'; - mockDeterminePiece.mockResolvedValue('default'); - mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task }); - mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' }); - - // When - await addTask(testDir); - - // Then: createIssue is called via createIssueFromTask - expect(mockCreateIssue).toHaveBeenCalledWith({ - title: 'Create a new feature', - body: task, - }); - }); - - it('should not create task file when create_issue action is selected', async () => { - // Given: interactive mode returns create_issue action - mockDeterminePiece.mockResolvedValue('default'); - mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'Some task' }); - mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' }); - - // When - await addTask(testDir); - - // Then: no task file created - const tasksDir = path.join(testDir, '.takt', 'tasks'); - const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []; - expect(files.length).toBe(0); - }); - - it('should not prompt for worktree settings when create_issue action is selected', async () => { - // Given: interactive mode returns create_issue action - mockDeterminePiece.mockResolvedValue('default'); - mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'Some task' }); - mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' }); - - // When - await addTask(testDir); - - // Then: confirm (worktree prompt) is never called - expect(mockConfirm).not.toHaveBeenCalled(); - }); + expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); }); }); diff --git a/src/__tests__/listNonInteractive.test.ts b/src/__tests__/listNonInteractive.test.ts index 6d38f41..85b66f2 100644 --- a/src/__tests__/listNonInteractive.test.ts +++ b/src/__tests__/listNonInteractive.test.ts @@ -1,189 +1,80 @@ -/** - * Tests for listNonInteractive — non-interactive list output and branch actions. - */ - -import { execFileSync } from 'node:child_process'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { listTasks } from '../features/tasks/list/index.js'; +import { stringify as stringifyYaml } from 'yaml'; +import { listTasksNonInteractive } from '../features/tasks/list/listNonInteractive.js'; -describe('listTasks non-interactive text output', () => { - let tmpDir: string; +const mockInfo = vi.fn(); +vi.mock('../shared/ui/index.js', () => ({ + info: (...args: unknown[]) => mockInfo(...args), +})); - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-ni-')); - execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' }); - execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir, stdio: 'pipe' }); - execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir, stdio: 'pipe' }); - execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' }); +vi.mock('../infra/task/branchList.js', async (importOriginal) => ({ + ...(await importOriginal>()), + detectDefaultBranch: vi.fn(() => 'main'), + listTaktBranches: vi.fn(() => []), + buildListItems: vi.fn(() => []), +})); + +let tmpDir: string; + +beforeEach(() => { + vi.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-list-non-interactive-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeTasksFile(projectDir: string): void { + const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml'); + fs.mkdirSync(path.dirname(tasksFile), { recursive: true }); + fs.writeFileSync(tasksFile, stringifyYaml({ + tasks: [ + { + name: 'pending-task', + status: 'pending', + content: 'Pending content', + created_at: '2026-02-09T00:00:00.000Z', + started_at: null, + completed_at: null, + }, + { + name: 'failed-task', + status: 'failed', + content: 'Failed content', + created_at: '2026-02-09T00:00:00.000Z', + started_at: '2026-02-09T00:01:00.000Z', + completed_at: '2026-02-09T00:02:00.000Z', + failure: { error: 'Boom' }, + }, + ], + }), 'utf-8'); +} + +describe('listTasksNonInteractive', () => { + it('should output pending and failed tasks in text format', async () => { + writeTasksFile(tmpDir); + + await listTasksNonInteractive(tmpDir, { enabled: true, format: 'text' }); + + expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[running] pending-task')); + expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[failed] failed-task')); }); - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); + it('should output JSON when format=json', async () => { + writeTasksFile(tmpDir); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - it('should output pending tasks in text format', async () => { - // Given - const tasksDir = path.join(tmpDir, '.takt', 'tasks'); - fs.mkdirSync(tasksDir, { recursive: true }); - fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Fix the login bug'); + await listTasksNonInteractive(tmpDir, { enabled: true, format: 'json' }); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + expect(logSpy).toHaveBeenCalledTimes(1); + const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { pendingTasks: Array<{ name: string }>; failedTasks: Array<{ name: string }> }; + expect(payload.pendingTasks[0]?.name).toBe('pending-task'); + expect(payload.failedTasks[0]?.name).toBe('failed-task'); - // When - await listTasks(tmpDir, undefined, { enabled: true }); - - // Then - const calls = logSpy.mock.calls.map((c) => c[0] as string); - expect(calls).toContainEqual(expect.stringContaining('[running] my-task')); - expect(calls).not.toContainEqual(expect.stringContaining('[pending] my-task')); - expect(calls).not.toContainEqual(expect.stringContaining('[pendig] my-task')); - expect(calls).toContainEqual(expect.stringContaining('Fix the login bug')); - logSpy.mockRestore(); - }); - - it('should output failed tasks in text format', async () => { - // Given - const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-task'); - fs.mkdirSync(failedDir, { recursive: true }); - fs.writeFileSync(path.join(failedDir, 'failed-task.md'), 'This failed'); - - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // When - await listTasks(tmpDir, undefined, { enabled: true }); - - // Then - const calls = logSpy.mock.calls.map((c) => c[0] as string); - expect(calls).toContainEqual(expect.stringContaining('[failed] failed-task')); - expect(calls).toContainEqual(expect.stringContaining('This failed')); - logSpy.mockRestore(); - }); - - it('should output both pending and failed tasks in text format', async () => { - // Given - const tasksDir = path.join(tmpDir, '.takt', 'tasks'); - fs.mkdirSync(tasksDir, { recursive: true }); - fs.writeFileSync(path.join(tasksDir, 'pending-one.md'), 'Pending task'); - - const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-one'); - fs.mkdirSync(failedDir, { recursive: true }); - fs.writeFileSync(path.join(failedDir, 'failed-one.md'), 'Failed task'); - - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // When - await listTasks(tmpDir, undefined, { enabled: true }); - - // Then - const calls = logSpy.mock.calls.map((c) => c[0] as string); - expect(calls).toContainEqual(expect.stringContaining('[running] pending-one')); - expect(calls).not.toContainEqual(expect.stringContaining('[pending] pending-one')); - expect(calls).not.toContainEqual(expect.stringContaining('[pendig] pending-one')); - expect(calls).toContainEqual(expect.stringContaining('[failed] failed-one')); - logSpy.mockRestore(); - }); - - it('should show info message when no tasks exist', async () => { - // Given: no tasks, no branches - - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // When - await listTasks(tmpDir, undefined, { enabled: true }); - - // Then - const calls = logSpy.mock.calls.map((c) => c[0] as string); - expect(calls.some((c) => c.includes('No tasks to list'))).toBe(true); - logSpy.mockRestore(); - }); -}); - -describe('listTasks non-interactive action errors', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-ni-err-')); - execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' }); - execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir, stdio: 'pipe' }); - execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir, stdio: 'pipe' }); - execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' }); - // Create a pending task so the "no tasks" early return is not triggered - const tasksDir = path.join(tmpDir, '.takt', 'tasks'); - fs.mkdirSync(tasksDir, { recursive: true }); - fs.writeFileSync(path.join(tasksDir, 'dummy.md'), 'dummy'); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('should exit with code 1 when --action specified without --branch', async () => { - // Given - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); }); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // When / Then - await expect( - listTasks(tmpDir, undefined, { enabled: true, action: 'diff' }), - ).rejects.toThrow('process.exit'); - - expect(exitSpy).toHaveBeenCalledWith(1); - exitSpy.mockRestore(); - logSpy.mockRestore(); - }); - - it('should exit with code 1 for invalid action', async () => { - // Given - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); }); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // When / Then - await expect( - listTasks(tmpDir, undefined, { enabled: true, action: 'invalid', branch: 'some-branch' }), - ).rejects.toThrow('process.exit'); - - expect(exitSpy).toHaveBeenCalledWith(1); - exitSpy.mockRestore(); - logSpy.mockRestore(); - }); - - it('should exit with code 1 when branch not found', async () => { - // Given - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); }); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // When / Then - await expect( - listTasks(tmpDir, undefined, { enabled: true, action: 'diff', branch: 'takt/nonexistent' }), - ).rejects.toThrow('process.exit'); - - expect(exitSpy).toHaveBeenCalledWith(1); - exitSpy.mockRestore(); - logSpy.mockRestore(); - }); - - it('should exit with code 1 for delete without --yes', async () => { - // Given: create a branch so it's found - execFileSync('git', ['checkout', '-b', 'takt/20250115-test-branch'], { cwd: tmpDir, stdio: 'pipe' }); - execFileSync('git', ['checkout', 'main'], { cwd: tmpDir, stdio: 'pipe' }); - - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); }); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // When / Then - await expect( - listTasks(tmpDir, undefined, { - enabled: true, - action: 'delete', - branch: 'takt/20250115-test-branch', - }), - ).rejects.toThrow('process.exit'); - - expect(exitSpy).toHaveBeenCalledWith(1); - exitSpy.mockRestore(); logSpy.mockRestore(); }); }); diff --git a/src/__tests__/listTasks.test.ts b/src/__tests__/listTasks.test.ts index 8395422..704b749 100644 --- a/src/__tests__/listTasks.test.ts +++ b/src/__tests__/listTasks.test.ts @@ -1,391 +1,94 @@ -/** - * Tests for list-tasks command - */ - -import { execFileSync } from 'node:child_process'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - parseTaktBranches, - extractTaskSlug, - buildListItems, - type BranchInfo, -} from '../infra/task/branchList.js'; +import { stringify as stringifyYaml } from 'yaml'; + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + header: vi.fn(), + blankLine: vi.fn(), +})); + +vi.mock('../infra/task/branchList.js', async (importOriginal) => ({ + ...(await importOriginal>()), + listTaktBranches: vi.fn(() => []), + buildListItems: vi.fn(() => []), + detectDefaultBranch: vi.fn(() => 'main'), +})); + import { TaskRunner } from '../infra/task/runner.js'; -import type { TaskListItem } from '../infra/task/types.js'; -import { isBranchMerged, showFullDiff, type ListAction } from '../features/tasks/index.js'; -import { listTasks } from '../features/tasks/list/index.js'; +import { listTasksNonInteractive } from '../features/tasks/list/listNonInteractive.js'; -describe('parseTaktBranches', () => { - it('should parse takt/ branches from git branch output', () => { - const output = [ - 'takt/20260128-fix-auth def4567', - 'takt/20260128-add-search 789abcd', - ].join('\n'); +let tmpDir: string; - const result = parseTaktBranches(output); - expect(result).toHaveLength(2); - - expect(result[0]).toEqual({ - branch: 'takt/20260128-fix-auth', - commit: 'def4567', - }); - - expect(result[1]).toEqual({ - branch: 'takt/20260128-add-search', - commit: '789abcd', - }); - }); - - it('should handle empty output', () => { - const result = parseTaktBranches(''); - expect(result).toHaveLength(0); - }); - - it('should handle output with only whitespace lines', () => { - const result = parseTaktBranches(' \n \n'); - expect(result).toHaveLength(0); - }); - - it('should handle single branch', () => { - const output = 'takt/20260128-fix-auth abc1234'; - - const result = parseTaktBranches(output); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - branch: 'takt/20260128-fix-auth', - commit: 'abc1234', - }); - }); - - it('should skip lines without space separator', () => { - const output = [ - 'takt/20260128-fix-auth abc1234', - 'malformed-line', - ].join('\n'); - - const result = parseTaktBranches(output); - expect(result).toHaveLength(1); - }); +beforeEach(() => { + vi.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-list-test-')); }); -describe('extractTaskSlug', () => { - it('should extract slug from timestamped branch name', () => { - expect(extractTaskSlug('takt/20260128T032800-fix-auth')).toBe('fix-auth'); - }); - - it('should extract slug from date-only timestamp', () => { - expect(extractTaskSlug('takt/20260128-add-search')).toBe('add-search'); - }); - - it('should extract slug with long timestamp format', () => { - expect(extractTaskSlug('takt/20260128T032800-refactor-api')).toBe('refactor-api'); - }); - - it('should handle branch without timestamp', () => { - expect(extractTaskSlug('takt/my-task')).toBe('my-task'); - }); - - it('should handle branch with only timestamp', () => { - const result = extractTaskSlug('takt/20260128T032800'); - // Timestamp is stripped, nothing left, falls back to original name - expect(result).toBe('20260128T032800'); - }); - - it('should handle slug with multiple dashes', () => { - expect(extractTaskSlug('takt/20260128-fix-auth-bug-in-login')).toBe('fix-auth-bug-in-login'); - }); +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); }); -describe('buildListItems', () => { - it('should build items with correct task slug and originalInstruction', () => { - const branches: BranchInfo[] = [ +function writeTasksFile(projectDir: string): void { + const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml'); + fs.mkdirSync(path.dirname(tasksFile), { recursive: true }); + fs.writeFileSync(tasksFile, stringifyYaml({ + tasks: [ { - branch: 'takt/20260128-fix-auth', - commit: 'abc123', - }, - ]; - - const items = buildListItems('/project', branches, 'main'); - expect(items).toHaveLength(1); - expect(items[0]!.taskSlug).toBe('fix-auth'); - expect(items[0]!.info).toBe(branches[0]); - // filesChanged will be 0 since we don't have a real git repo - expect(items[0]!.filesChanged).toBe(0); - // originalInstruction will be empty since git command fails on non-existent repo - expect(items[0]!.originalInstruction).toBe(''); - }); - - it('should handle multiple branches', () => { - const branches: BranchInfo[] = [ - { - branch: 'takt/20260128-fix-auth', - commit: 'abc123', + name: 'pending-one', + status: 'pending', + content: 'Pending task', + created_at: '2026-02-09T00:00:00.000Z', + started_at: null, + completed_at: null, }, { - branch: 'takt/20260128-add-search', - commit: 'def456', + name: 'failed-one', + status: 'failed', + content: 'Failed task', + created_at: '2026-02-09T00:00:00.000Z', + started_at: '2026-02-09T00:01:00.000Z', + completed_at: '2026-02-09T00:02:00.000Z', + failure: { error: 'boom' }, }, - ]; + ], + }), 'utf-8'); +} - const items = buildListItems('/project', branches, 'main'); - expect(items).toHaveLength(2); - expect(items[0]!.taskSlug).toBe('fix-auth'); - expect(items[1]!.taskSlug).toBe('add-search'); - }); - - it('should handle empty branch list', () => { - const items = buildListItems('/project', [], 'main'); - expect(items).toHaveLength(0); - }); -}); - -describe('ListAction type', () => { - it('should include diff, instruct, try, merge, delete (no skip)', () => { - const actions: ListAction[] = ['diff', 'instruct', 'try', 'merge', 'delete']; - expect(actions).toHaveLength(5); - expect(actions).toContain('diff'); - expect(actions).toContain('instruct'); - expect(actions).toContain('try'); - expect(actions).toContain('merge'); - expect(actions).toContain('delete'); - expect(actions).not.toContain('skip'); - }); -}); - -describe('showFullDiff', () => { - it('should not throw for non-existent project dir', () => { - // spawnSync will fail gracefully; showFullDiff catches errors - expect(() => showFullDiff('/non-existent-dir', 'main', 'some-branch')).not.toThrow(); - }); - - it('should not throw for non-existent branch', () => { - expect(() => showFullDiff('/tmp', 'main', 'non-existent-branch-xyz')).not.toThrow(); - }); - - it('should warn when diff fails', () => { - const warnSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - showFullDiff('/non-existent-dir', 'main', 'some-branch'); - warnSpy.mockRestore(); - // No assertion needed — the test verifies it doesn't throw - }); -}); - -describe('isBranchMerged', () => { - it('should return false for non-existent project dir', () => { - // git merge-base will fail on non-existent dir - const result = isBranchMerged('/non-existent-dir', 'some-branch'); - expect(result).toBe(false); - }); - - it('should return false for non-existent branch', () => { - const result = isBranchMerged('/tmp', 'non-existent-branch-xyz'); - expect(result).toBe(false); - }); -}); - -describe('TaskRunner.listFailedTasks', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('should return empty array for empty failed directory', () => { +describe('TaskRunner list APIs', () => { + it('should read pending and failed tasks from tasks.yaml', () => { + writeTasksFile(tmpDir); const runner = new TaskRunner(tmpDir); - const result = runner.listFailedTasks(); - expect(result).toEqual([]); - }); - it('should parse failed task directories correctly', () => { - const failedDir = path.join(tmpDir, '.takt', 'failed'); - const taskDir = path.join(failedDir, '2025-01-15T12-34-56_my-task'); - fs.mkdirSync(taskDir, { recursive: true }); - fs.writeFileSync(path.join(taskDir, 'my-task.md'), 'Fix the login bug\nMore details here'); + const pending = runner.listPendingTaskItems(); + const failed = runner.listFailedTasks(); - const runner = new TaskRunner(tmpDir); - const result = runner.listFailedTasks(); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - kind: 'failed', - name: 'my-task', - createdAt: '2025-01-15T12:34:56', - filePath: taskDir, - content: 'Fix the login bug', - }); - }); - - it('should skip malformed directory names', () => { - const failedDir = path.join(tmpDir, '.takt', 'failed'); - // No underscore → malformed, should be skipped - fs.mkdirSync(path.join(failedDir, 'malformed-name'), { recursive: true }); - // Valid one - const validDir = path.join(failedDir, '2025-01-15T12-34-56_valid-task'); - fs.mkdirSync(validDir, { recursive: true }); - fs.writeFileSync(path.join(validDir, 'valid-task.md'), 'Content'); - - const runner = new TaskRunner(tmpDir); - const result = runner.listFailedTasks(); - - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe('valid-task'); - }); - - it('should extract task content from task file in directory', () => { - const failedDir = path.join(tmpDir, '.takt', 'failed'); - const taskDir = path.join(failedDir, '2025-02-01T00-00-00_content-test'); - fs.mkdirSync(taskDir, { recursive: true }); - // report.md and log.json should be skipped; the actual task file should be read - fs.writeFileSync(path.join(taskDir, 'report.md'), 'Report content'); - fs.writeFileSync(path.join(taskDir, 'log.json'), '{}'); - fs.writeFileSync(path.join(taskDir, 'content-test.yaml'), 'task: Do something important'); - - const runner = new TaskRunner(tmpDir); - const result = runner.listFailedTasks(); - - expect(result).toHaveLength(1); - expect(result[0]!.content).toBe('task: Do something important'); - }); - - it('should return empty content when no task file exists', () => { - const failedDir = path.join(tmpDir, '.takt', 'failed'); - const taskDir = path.join(failedDir, '2025-02-01T00-00-00_no-task-file'); - fs.mkdirSync(taskDir, { recursive: true }); - // Only report.md and log.json, no actual task file - fs.writeFileSync(path.join(taskDir, 'report.md'), 'Report content'); - fs.writeFileSync(path.join(taskDir, 'log.json'), '{}'); - - const runner = new TaskRunner(tmpDir); - const result = runner.listFailedTasks(); - - expect(result).toHaveLength(1); - expect(result[0]!.content).toBe(''); - }); - - it('should handle task name with underscores', () => { - const failedDir = path.join(tmpDir, '.takt', 'failed'); - const taskDir = path.join(failedDir, '2025-01-15T12-34-56_my_task_name'); - fs.mkdirSync(taskDir, { recursive: true }); - - const runner = new TaskRunner(tmpDir); - const result = runner.listFailedTasks(); - - expect(result).toHaveLength(1); - expect(result[0]!.name).toBe('my_task_name'); - }); - - it('should skip non-directory entries', () => { - const failedDir = path.join(tmpDir, '.takt', 'failed'); - fs.mkdirSync(failedDir, { recursive: true }); - // Create a file (not a directory) in the failed dir - fs.writeFileSync(path.join(failedDir, '2025-01-15T12-34-56_file-task'), 'content'); - - const runner = new TaskRunner(tmpDir); - const result = runner.listFailedTasks(); - - expect(result).toHaveLength(0); - }); -}); - -describe('TaskRunner.listPendingTaskItems', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('should return empty array when no pending tasks', () => { - const runner = new TaskRunner(tmpDir); - const result = runner.listPendingTaskItems(); - expect(result).toEqual([]); - }); - - it('should convert TaskInfo to TaskListItem with kind=pending', () => { - const tasksDir = path.join(tmpDir, '.takt', 'tasks'); - fs.mkdirSync(tasksDir, { recursive: true }); - fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Fix the login bug\nMore details here'); - - const runner = new TaskRunner(tmpDir); - const result = runner.listPendingTaskItems(); - - expect(result).toHaveLength(1); - expect(result[0]!.kind).toBe('pending'); - expect(result[0]!.name).toBe('my-task'); - expect(result[0]!.content).toBe('Fix the login bug'); - }); - - it('should truncate content to first line (max 80 chars)', () => { - const tasksDir = path.join(tmpDir, '.takt', 'tasks'); - fs.mkdirSync(tasksDir, { recursive: true }); - const longLine = 'A'.repeat(120) + '\nSecond line'; - fs.writeFileSync(path.join(tasksDir, 'long-task.md'), longLine); - - const runner = new TaskRunner(tmpDir); - const result = runner.listPendingTaskItems(); - - expect(result).toHaveLength(1); - expect(result[0]!.content).toBe('A'.repeat(80)); + expect(pending).toHaveLength(1); + expect(pending[0]?.name).toBe('pending-one'); + expect(failed).toHaveLength(1); + expect(failed[0]?.name).toBe('failed-one'); + expect(failed[0]?.failure?.error).toBe('boom'); }); }); describe('listTasks non-interactive JSON output', () => { - let tmpDir: string; + it('should output JSON object with branches, pendingTasks, and failedTasks', async () => { + writeTasksFile(tmpDir); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-json-')); - // Initialize as a git repo so detectDefaultBranch works - execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' }); - execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir, stdio: 'pipe' }); - execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir, stdio: 'pipe' }); - execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' }); - }); + await listTasksNonInteractive(tmpDir, { enabled: true, format: 'json' }); - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('should output JSON as object with branches, pendingTasks, and failedTasks keys', async () => { - // Given: a pending task and a failed task - const tasksDir = path.join(tmpDir, '.takt', 'tasks'); - fs.mkdirSync(tasksDir, { recursive: true }); - fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Do something'); - - const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-task'); - fs.mkdirSync(failedDir, { recursive: true }); - fs.writeFileSync(path.join(failedDir, 'failed-task.md'), 'This failed'); - - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // When: listTasks is called in non-interactive JSON mode - await listTasks(tmpDir, undefined, { - enabled: true, - format: 'json', - }); - - // Then: output is an object with branches, pendingTasks, failedTasks expect(logSpy).toHaveBeenCalledTimes(1); - const output = JSON.parse(logSpy.mock.calls[0]![0] as string); - expect(output).toHaveProperty('branches'); - expect(output).toHaveProperty('pendingTasks'); - expect(output).toHaveProperty('failedTasks'); - expect(Array.isArray(output.branches)).toBe(true); - expect(Array.isArray(output.pendingTasks)).toBe(true); - expect(Array.isArray(output.failedTasks)).toBe(true); - expect(output.pendingTasks).toHaveLength(1); - expect(output.pendingTasks[0].name).toBe('my-task'); - expect(output.failedTasks).toHaveLength(1); - expect(output.failedTasks[0].name).toBe('failed-task'); + const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { + branches: unknown[]; + pendingTasks: Array<{ name: string }>; + failedTasks: Array<{ name: string }>; + }; + expect(Array.isArray(payload.branches)).toBe(true); + expect(payload.pendingTasks[0]?.name).toBe('pending-one'); + expect(payload.failedTasks[0]?.name).toBe('failed-one'); logSpy.mockRestore(); }); diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 1bc7ccd..2ea5f30 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -21,18 +21,18 @@ vi.mock('../infra/config/index.js', () => ({ import { loadGlobalConfig } from '../infra/config/index.js'; const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); -const mockGetNextTask = vi.fn(); const mockClaimNextTasks = vi.fn(); const mockCompleteTask = vi.fn(); const mockFailTask = vi.fn(); +const mockRecoverInterruptedRunningTasks = vi.fn(); vi.mock('../infra/task/index.js', async (importOriginal) => ({ ...(await importOriginal>()), TaskRunner: vi.fn().mockImplementation(() => ({ - getNextTask: mockGetNextTask, claimNextTasks: mockClaimNextTasks, completeTask: mockCompleteTask, failTask: mockFailTask, + recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks, })), })); @@ -128,11 +128,15 @@ function createTask(name: string): TaskInfo { name, content: `Task: ${name}`, filePath: `/tasks/${name}.yaml`, + createdAt: '2026-02-09T00:00:00.000Z', + status: 'pending', + data: null, }; } beforeEach(() => { vi.clearAllMocks(); + mockRecoverInterruptedRunningTasks.mockReturnValue(0); }); describe('runAllTasks concurrency', () => { @@ -155,7 +159,7 @@ describe('runAllTasks concurrency', () => { await runAllTasks('/project'); // Then - expect(mockInfo).toHaveBeenCalledWith('No pending tasks in .takt/tasks/'); + expect(mockInfo).toHaveBeenCalledWith('No pending tasks in .takt/tasks.yaml'); }); it('should execute tasks sequentially via worker pool when concurrency is 1', async () => { @@ -401,6 +405,28 @@ describe('runAllTasks concurrency', () => { expect(mockStatus).toHaveBeenCalledWith('Failed', '1', 'red'); }); + it('should persist failure reason and movement when piece aborts', async () => { + const task1 = createTask('fail-with-detail'); + + mockExecutePiece.mockResolvedValue({ + success: false, + reason: 'blocked_by_review', + lastMovement: 'review', + lastMessage: 'security check failed', + }); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + await runAllTasks('/project'); + + expect(mockFailTask).toHaveBeenCalledWith(expect.objectContaining({ + response: 'blocked_by_review', + failureMovement: 'review', + failureLastMessage: 'security check failed', + })); + }); + it('should pass abortSignal and taskPrefix to executePiece in parallel mode', async () => { // Given: One task in parallel mode const task1 = createTask('parallel-task'); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 9d69a29..51078ae 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -1,15 +1,8 @@ -/** - * Tests for saveTaskFile and saveTaskFromInteractive - */ - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { tmpdir } from 'node:os'; - -vi.mock('../infra/task/summarize.js', () => ({ - summarizeTaskName: vi.fn(), -})); +import { parse as parseYaml } from 'yaml'; vi.mock('../shared/ui/index.js', () => ({ success: vi.fn(), @@ -31,12 +24,10 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ }), })); -import { summarizeTaskName } from '../infra/task/summarize.js'; import { success, info } from '../shared/ui/index.js'; import { confirm, promptInput } from '../shared/prompt/index.js'; import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js'; -const mockSummarizeTaskName = vi.mocked(summarizeTaskName); const mockSuccess = vi.mocked(success); const mockInfo = vi.mocked(info); const mockConfirm = vi.mocked(confirm); @@ -44,10 +35,14 @@ const mockPromptInput = vi.mocked(promptInput); let testDir: string; +function loadTasks(testDir: string): { tasks: Array> } { + const raw = fs.readFileSync(path.join(testDir, '.takt', 'tasks.yaml'), 'utf-8'); + return parseYaml(raw) as { tasks: Array> }; +} + beforeEach(() => { vi.clearAllMocks(); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-')); - mockSummarizeTaskName.mockResolvedValue('test-task'); }); afterEach(() => { @@ -57,243 +52,74 @@ afterEach(() => { }); describe('saveTaskFile', () => { - it('should create task file with correct YAML content', async () => { - // Given - const taskContent = 'Implement feature X\nDetails here'; + it('should append task to tasks.yaml', async () => { + const created = await saveTaskFile(testDir, 'Implement feature X\nDetails here'); - // When - const filePath = await saveTaskFile(testDir, taskContent); + expect(created.taskName).toContain('implement-feature-x'); + expect(created.tasksFile).toBe(path.join(testDir, '.takt', 'tasks.yaml')); + expect(fs.existsSync(created.tasksFile)).toBe(true); - // Then - expect(fs.existsSync(filePath)).toBe(true); - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('Implement feature X'); - expect(content).toContain('Details here'); + const tasks = loadTasks(testDir).tasks; + expect(tasks).toHaveLength(1); + expect(tasks[0]?.content).toContain('Implement feature X'); }); - it('should create .takt/tasks directory if it does not exist', async () => { - // Given - const tasksDir = path.join(testDir, '.takt', 'tasks'); - expect(fs.existsSync(tasksDir)).toBe(false); + it('should include optional fields', async () => { + await saveTaskFile(testDir, 'Task', { + piece: 'review', + issue: 42, + worktree: true, + branch: 'feat/my-branch', + autoPr: false, + }); - // When - await saveTaskFile(testDir, 'Task content'); - - // Then - expect(fs.existsSync(tasksDir)).toBe(true); + const task = loadTasks(testDir).tasks[0]!; + expect(task.piece).toBe('review'); + expect(task.issue).toBe(42); + expect(task.worktree).toBe(true); + expect(task.branch).toBe('feat/my-branch'); + expect(task.auto_pr).toBe(false); }); - it('should include piece in YAML when specified', async () => { - // When - const filePath = await saveTaskFile(testDir, 'Task', { piece: 'review' }); + it('should generate unique names on duplicates', async () => { + const first = await saveTaskFile(testDir, 'Same title'); + const second = await saveTaskFile(testDir, 'Same title'); - // Then - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('piece: review'); - }); - - it('should include issue number in YAML when specified', async () => { - // When - const filePath = await saveTaskFile(testDir, 'Task', { issue: 42 }); - - // Then - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('issue: 42'); - }); - - it('should include worktree in YAML when specified', async () => { - // When - const filePath = await saveTaskFile(testDir, 'Task', { worktree: true }); - - // Then - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('worktree: true'); - }); - - it('should include branch in YAML when specified', async () => { - // When - const filePath = await saveTaskFile(testDir, 'Task', { branch: 'feat/my-branch' }); - - // Then - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('branch: feat/my-branch'); - }); - - it('should not include optional fields when not specified', async () => { - // When - const filePath = await saveTaskFile(testDir, 'Simple task'); - - // Then - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).not.toContain('piece:'); - expect(content).not.toContain('issue:'); - expect(content).not.toContain('worktree:'); - expect(content).not.toContain('branch:'); - expect(content).not.toContain('auto_pr:'); - }); - - it('should include auto_pr in YAML when specified', async () => { - // When - const filePath = await saveTaskFile(testDir, 'Task', { autoPr: true }); - - // Then - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('auto_pr: true'); - }); - - it('should include auto_pr: false in YAML when specified as false', async () => { - // When - const filePath = await saveTaskFile(testDir, 'Task', { autoPr: false }); - - // Then - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('auto_pr: false'); - }); - - it('should use first line for filename generation', async () => { - // When - await saveTaskFile(testDir, 'First line\nSecond line'); - - // Then - expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line', { cwd: testDir }); - }); - - it('should handle duplicate filenames with counter', async () => { - // Given: first file already exists - await saveTaskFile(testDir, 'Task 1'); - - // When: second file with same slug - const filePath = await saveTaskFile(testDir, 'Task 2'); - - // Then - expect(path.basename(filePath)).toBe('test-task-1.yaml'); + expect(first.taskName).not.toBe(second.taskName); }); }); describe('saveTaskFromInteractive', () => { - it('should save task with worktree settings when user confirms worktree', async () => { - // Given: user confirms worktree, accepts defaults, confirms auto-PR - mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes - mockPromptInput.mockResolvedValueOnce(''); // Worktree path → auto - mockPromptInput.mockResolvedValueOnce(''); // Branch name → auto - mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes + it('should save task with worktree settings when user confirms', async () => { + mockConfirm.mockResolvedValueOnce(true); + mockPromptInput.mockResolvedValueOnce(''); + mockPromptInput.mockResolvedValueOnce(''); + mockConfirm.mockResolvedValueOnce(true); - // When await saveTaskFromInteractive(testDir, 'Task content'); - // Then - expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml'); - expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('Path:')); - const tasksDir = path.join(testDir, '.takt', 'tasks'); - const files = fs.readdirSync(tasksDir); - const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8'); - expect(content).toContain('worktree: true'); - expect(content).toContain('auto_pr: true'); + expect(mockSuccess).toHaveBeenCalledWith(expect.stringContaining('Task created:')); + const task = loadTasks(testDir).tasks[0]!; + expect(task.worktree).toBe(true); + expect(task.auto_pr).toBe(true); }); - it('should save task without worktree settings when user declines worktree', async () => { - // Given: user declines worktree - mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No + it('should save task without worktree settings when declined', async () => { + mockConfirm.mockResolvedValueOnce(false); - // When await saveTaskFromInteractive(testDir, 'Task content'); - // Then - expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml'); - const tasksDir = path.join(testDir, '.takt', 'tasks'); - const files = fs.readdirSync(tasksDir); - const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8'); - expect(content).not.toContain('worktree:'); - expect(content).not.toContain('branch:'); - expect(content).not.toContain('auto_pr:'); - }); - - it('should save custom worktree path and branch when specified', async () => { - // Given: user specifies custom path and branch - mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes - mockPromptInput.mockResolvedValueOnce('/custom/path'); // Worktree path - mockPromptInput.mockResolvedValueOnce('feat/branch'); // Branch name - mockConfirm.mockResolvedValueOnce(false); // Auto-create PR? → No - - // When - await saveTaskFromInteractive(testDir, 'Task content'); - - // Then - const tasksDir = path.join(testDir, '.takt', 'tasks'); - const files = fs.readdirSync(tasksDir); - const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8'); - expect(content).toContain('worktree: /custom/path'); - expect(content).toContain('branch: feat/branch'); - expect(content).toContain('auto_pr: false'); - }); - - it('should display worktree/branch/auto-PR info when settings are provided', async () => { - // Given - mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes - mockPromptInput.mockResolvedValueOnce('/my/path'); // Worktree path - mockPromptInput.mockResolvedValueOnce('my-branch'); // Branch name - mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes - - // When - await saveTaskFromInteractive(testDir, 'Task content'); - - // Then - expect(mockInfo).toHaveBeenCalledWith(' Worktree: /my/path'); - expect(mockInfo).toHaveBeenCalledWith(' Branch: my-branch'); - expect(mockInfo).toHaveBeenCalledWith(' Auto-PR: yes'); + const task = loadTasks(testDir).tasks[0]!; + expect(task.worktree).toBeUndefined(); + expect(task.branch).toBeUndefined(); + expect(task.auto_pr).toBeUndefined(); }); it('should display piece info when specified', async () => { - // Given - mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No + mockConfirm.mockResolvedValueOnce(false); - // When await saveTaskFromInteractive(testDir, 'Task content', 'review'); - // Then expect(mockInfo).toHaveBeenCalledWith(' Piece: review'); }); - - it('should include piece in saved YAML', async () => { - // Given - mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No - - // When - await saveTaskFromInteractive(testDir, 'Task content', 'custom'); - - // Then - const tasksDir = path.join(testDir, '.takt', 'tasks'); - const files = fs.readdirSync(tasksDir); - expect(files.length).toBe(1); - const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8'); - expect(content).toContain('piece: custom'); - }); - - it('should not display piece info when not specified', async () => { - // Given - mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No - - // When - await saveTaskFromInteractive(testDir, 'Task content'); - - // Then - const pieceInfoCalls = mockInfo.mock.calls.filter( - (call) => typeof call[0] === 'string' && call[0].includes('Piece:') - ); - expect(pieceInfoCalls.length).toBe(0); - }); - - it('should display auto worktree info when no custom path', async () => { - // Given - mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes - mockPromptInput.mockResolvedValueOnce(''); // Worktree path → auto - mockPromptInput.mockResolvedValueOnce(''); // Branch name → auto - mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes - - // When - await saveTaskFromInteractive(testDir, 'Task content'); - - // Then - expect(mockInfo).toHaveBeenCalledWith(' Worktree: auto'); - }); }); diff --git a/src/__tests__/task.test.ts b/src/__tests__/task.test.ts index 6e9af16..d5dfe14 100644 --- a/src/__tests__/task.test.ts +++ b/src/__tests__/task.test.ts @@ -1,59 +1,34 @@ -/** - * Task runner tests - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync, readdirSync } from 'node:fs'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { TaskRunner } from '../infra/task/runner.js'; -import { isTaskFile, parseTaskFiles } from '../infra/task/parser.js'; +import { TaskRecordSchema } from '../infra/task/schema.js'; -describe('isTaskFile', () => { - it('should accept .yaml files', () => { - expect(isTaskFile('task.yaml')).toBe(true); - }); +function loadTasksFile(testDir: string): { tasks: Array> } { + const raw = readFileSync(join(testDir, '.takt', 'tasks.yaml'), 'utf-8'); + return parseYaml(raw) as { tasks: Array> }; +} - it('should accept .yml files', () => { - expect(isTaskFile('task.yml')).toBe(true); - }); +function writeTasksFile(testDir: string, tasks: Array>): void { + mkdirSync(join(testDir, '.takt'), { recursive: true }); + writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml({ tasks }), 'utf-8'); +} - it('should accept .md files', () => { - expect(isTaskFile('task.md')).toBe(true); - }); +function createPendingRecord(overrides: Record): Record { + return TaskRecordSchema.parse({ + name: 'task-a', + status: 'pending', + content: 'Do work', + created_at: '2026-02-09T00:00:00.000Z', + started_at: null, + completed_at: null, + owner_pid: null, + ...overrides, + }) as unknown as Record; +} - it('should reject extensionless files like TASK-FORMAT', () => { - expect(isTaskFile('TASK-FORMAT')).toBe(false); - }); - - it('should reject .txt files', () => { - expect(isTaskFile('readme.txt')).toBe(false); - }); -}); - -describe('parseTaskFiles', () => { - const testDir = `/tmp/takt-parse-test-${Date.now()}`; - - beforeEach(() => { - mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - if (existsSync(testDir)) { - rmSync(testDir, { recursive: true, force: true }); - } - }); - - it('should ignore extensionless files like TASK-FORMAT', () => { - writeFileSync(join(testDir, 'TASK-FORMAT'), 'Format documentation'); - writeFileSync(join(testDir, 'real-task.md'), 'Real task'); - - const tasks = parseTaskFiles(testDir); - expect(tasks).toHaveLength(1); - expect(tasks[0]?.name).toBe('real-task'); - }); -}); - -describe('TaskRunner', () => { +describe('TaskRunner (tasks.yaml)', () => { const testDir = `/tmp/takt-task-test-${Date.now()}`; let runner: TaskRunner; @@ -68,465 +43,245 @@ describe('TaskRunner', () => { } }); - describe('ensureDirs', () => { - it('should create tasks, completed, and failed directories', () => { - runner.ensureDirs(); - expect(existsSync(join(testDir, '.takt', 'tasks'))).toBe(true); - expect(existsSync(join(testDir, '.takt', 'completed'))).toBe(true); - expect(existsSync(join(testDir, '.takt', 'failed'))).toBe(true); - }); + it('should add tasks to .takt/tasks.yaml', () => { + const task = runner.addTask('Fix login flow', { piece: 'default' }); + expect(task.name).toContain('fix-login-flow'); + expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(true); }); - describe('listTasks', () => { - it('should return empty array when no tasks', () => { - const tasks = runner.listTasks(); - expect(tasks).toEqual([]); - }); + it('should list only pending tasks', () => { + runner.addTask('Task A'); + runner.addTask('Task B'); - it('should list tasks sorted by name', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(tasksDir, '02-second.md'), 'Second task'); - writeFileSync(join(tasksDir, '01-first.md'), 'First task'); - writeFileSync(join(tasksDir, '03-third.md'), 'Third task'); - - const tasks = runner.listTasks(); - expect(tasks).toHaveLength(3); - expect(tasks[0]?.name).toBe('01-first'); - expect(tasks[1]?.name).toBe('02-second'); - expect(tasks[2]?.name).toBe('03-third'); - }); - - it('should only list .md files', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(tasksDir, 'task.md'), 'Task content'); - writeFileSync(join(tasksDir, 'readme.txt'), 'Not a task'); - - const tasks = runner.listTasks(); - expect(tasks).toHaveLength(1); - expect(tasks[0]?.name).toBe('task'); - }); + const tasks = runner.listTasks(); + expect(tasks).toHaveLength(2); + expect(tasks.every((task) => task.status === 'pending')).toBe(true); }); - describe('getTask', () => { - it('should return null for non-existent task', () => { - const task = runner.getTask('non-existent'); - expect(task).toBeNull(); - }); + it('should claim tasks and mark them running', () => { + runner.addTask('Task A'); + runner.addTask('Task B'); - it('should return task info for existing task', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(tasksDir, 'my-task.md'), 'Task content'); + const claimed = runner.claimNextTasks(1); + expect(claimed).toHaveLength(1); + expect(claimed[0]?.status).toBe('running'); - const task = runner.getTask('my-task'); - expect(task).not.toBeNull(); - expect(task?.name).toBe('my-task'); - expect(task?.content).toBe('Task content'); - }); + const file = loadTasksFile(testDir); + expect(file.tasks.some((task) => task.status === 'running')).toBe(true); }); - describe('getNextTask', () => { - it('should return null when no tasks', () => { - const task = runner.getNextTask(); - expect(task).toBeNull(); - }); + it('should recover interrupted running tasks to pending', () => { + runner.addTask('Task A'); + runner.claimNextTasks(1); + const current = loadTasksFile(testDir); + const running = current.tasks[0] as Record; + running.owner_pid = 999999999; + writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml(current), 'utf-8'); - it('should return first task (alphabetically)', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(tasksDir, 'b-task.md'), 'B'); - writeFileSync(join(tasksDir, 'a-task.md'), 'A'); + const recovered = runner.recoverInterruptedRunningTasks(); + expect(recovered).toBe(1); - const task = runner.getNextTask(); - expect(task?.name).toBe('a-task'); - }); + const tasks = runner.listTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0]?.status).toBe('pending'); }); - describe('claimNextTasks', () => { - it('should return empty array when no tasks', () => { - const tasks = runner.claimNextTasks(3); - expect(tasks).toEqual([]); - }); + it('should keep running tasks owned by a live process', () => { + runner.addTask('Task A'); + runner.claimNextTasks(1); - it('should return tasks up to the requested count', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(tasksDir, 'a-task.md'), 'A'); - writeFileSync(join(tasksDir, 'b-task.md'), 'B'); - writeFileSync(join(tasksDir, 'c-task.md'), 'C'); - - const tasks = runner.claimNextTasks(2); - expect(tasks).toHaveLength(2); - expect(tasks[0]?.name).toBe('a-task'); - expect(tasks[1]?.name).toBe('b-task'); - }); - - it('should not return already claimed tasks on subsequent calls', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(tasksDir, 'a-task.md'), 'A'); - writeFileSync(join(tasksDir, 'b-task.md'), 'B'); - writeFileSync(join(tasksDir, 'c-task.md'), 'C'); - - // Given: first call claims a-task - const first = runner.claimNextTasks(1); - expect(first).toHaveLength(1); - expect(first[0]?.name).toBe('a-task'); - - // When: second call should skip a-task - const second = runner.claimNextTasks(1); - expect(second).toHaveLength(1); - expect(second[0]?.name).toBe('b-task'); - - // When: third call should skip a-task and b-task - const third = runner.claimNextTasks(1); - expect(third).toHaveLength(1); - expect(third[0]?.name).toBe('c-task'); - - // When: fourth call should return empty (all claimed) - const fourth = runner.claimNextTasks(1); - expect(fourth).toEqual([]); - }); - - it('should release claim after completeTask', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(tasksDir, 'task-a.md'), 'Task A content'); - - // Given: claim the task - const claimed = runner.claimNextTasks(1); - expect(claimed).toHaveLength(1); - - // When: complete the task (file is moved away) - runner.completeTask({ - task: claimed[0]!, - success: true, - response: 'Done', - executionLog: [], - startedAt: '2024-01-01T00:00:00.000Z', - completedAt: '2024-01-01T00:01:00.000Z', - }); - - // Then: claim set no longer blocks (but file is moved, so no tasks anyway) - const next = runner.claimNextTasks(1); - expect(next).toEqual([]); - }); - - it('should release claim after failTask', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(tasksDir, 'task-a.md'), 'Task A content'); - - // Given: claim the task - const claimed = runner.claimNextTasks(1); - expect(claimed).toHaveLength(1); - - // When: fail the task (file is moved away) - runner.failTask({ - task: claimed[0]!, - success: false, - response: 'Error', - executionLog: [], - startedAt: '2024-01-01T00:00:00.000Z', - completedAt: '2024-01-01T00:01:00.000Z', - }); - - // Then: claim set no longer blocks - const next = runner.claimNextTasks(1); - expect(next).toEqual([]); - }); - - it('should not affect getNextTask (unclaimed access)', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(tasksDir, 'a-task.md'), 'A'); - writeFileSync(join(tasksDir, 'b-task.md'), 'B'); - - // Given: claim a-task via claimNextTasks - runner.claimNextTasks(1); - - // When: getNextTask is called (no claim filtering) - const task = runner.getNextTask(); - - // Then: getNextTask still returns first task (including claimed) - expect(task?.name).toBe('a-task'); - }); + const recovered = runner.recoverInterruptedRunningTasks(); + expect(recovered).toBe(0); }); - describe('completeTask', () => { - it('should move task to completed directory', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - const taskFile = join(tasksDir, 'test-task.md'); - writeFileSync(taskFile, 'Test task content'); + it('should take over stale lock file with invalid pid', () => { + mkdirSync(join(testDir, '.takt'), { recursive: true }); + writeFileSync(join(testDir, '.takt', 'tasks.yaml.lock'), 'invalid-pid', 'utf-8'); - const task = runner.getTask('test-task')!; - const result = { - task, - success: true, - response: 'Task completed successfully', - executionLog: ['Started', 'Done'], - startedAt: '2024-01-01T00:00:00.000Z', - completedAt: '2024-01-01T00:01:00.000Z', - }; + const task = runner.addTask('Task with stale lock'); - const reportFile = runner.completeTask(result); - - // Original task file should be moved - expect(existsSync(taskFile)).toBe(false); - - // Report should be created - expect(existsSync(reportFile)).toBe(true); - const reportContent = readFileSync(reportFile, 'utf-8'); - expect(reportContent).toContain('# タスク実行レポート'); - expect(reportContent).toContain('test-task'); - expect(reportContent).toContain('成功'); - - // Log file should be created - const logFile = reportFile.replace('report.md', 'log.json'); - expect(existsSync(logFile)).toBe(true); - const logData = JSON.parse(readFileSync(logFile, 'utf-8')); - expect(logData.taskName).toBe('test-task'); - expect(logData.success).toBe(true); - }); - - it('should throw error when called with a failed result', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(tasksDir, 'fail-task.md'), 'Will fail'); - - const task = runner.getTask('fail-task')!; - const result = { - task, - success: false, - response: 'Error occurred', - executionLog: ['Error'], - startedAt: '2024-01-01T00:00:00.000Z', - completedAt: '2024-01-01T00:01:00.000Z', - }; - - expect(() => runner.completeTask(result)).toThrow( - 'Cannot complete a failed task. Use failTask() instead.' - ); - }); + expect(task.name).toContain('task-with-stale-lock'); + expect(existsSync(join(testDir, '.takt', 'tasks.yaml.lock'))).toBe(false); }); - describe('failTask', () => { - it('should move task to failed directory', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - const taskFile = join(tasksDir, 'fail-task.md'); - writeFileSync(taskFile, 'Task that will fail'); + it('should timeout when lock file is held by a live process', () => { + mkdirSync(join(testDir, '.takt'), { recursive: true }); + writeFileSync(join(testDir, '.takt', 'tasks.yaml.lock'), String(process.pid), 'utf-8'); - const task = runner.getTask('fail-task')!; - const result = { - task, - success: false, - response: 'Error occurred', - executionLog: ['Started', 'Error'], - startedAt: '2024-01-01T00:00:00.000Z', - completedAt: '2024-01-01T00:01:00.000Z', - }; + const dateNowSpy = vi.spyOn(Date, 'now'); + dateNowSpy.mockReturnValueOnce(0); + dateNowSpy.mockReturnValue(5_000); - const reportFile = runner.failTask(result); - - // Original task file should be removed from tasks dir - expect(existsSync(taskFile)).toBe(false); - - // Report should be in .takt/failed/ (not .takt/completed/) - expect(reportFile).toContain(join('.takt', 'failed')); - expect(reportFile).not.toContain(join('.takt', 'completed')); - expect(existsSync(reportFile)).toBe(true); - - const reportContent = readFileSync(reportFile, 'utf-8'); - expect(reportContent).toContain('# タスク実行レポート'); - expect(reportContent).toContain('fail-task'); - expect(reportContent).toContain('失敗'); - - // Log file should be created in failed dir - const logFile = reportFile.replace('report.md', 'log.json'); - expect(existsSync(logFile)).toBe(true); - const logData = JSON.parse(readFileSync(logFile, 'utf-8')); - expect(logData.taskName).toBe('fail-task'); - expect(logData.success).toBe(false); - }); - - it('should not move failed task to completed directory', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - const completedDir = join(testDir, '.takt', 'completed'); - mkdirSync(tasksDir, { recursive: true }); - const taskFile = join(tasksDir, 'another-fail.md'); - writeFileSync(taskFile, 'Another failing task'); - - const task = runner.getTask('another-fail')!; - const result = { - task, - success: false, - response: 'Something went wrong', - executionLog: [], - startedAt: '2024-01-01T00:00:00.000Z', - completedAt: '2024-01-01T00:01:00.000Z', - }; - - runner.failTask(result); - - // completed directory should be empty (only the dir itself exists) - mkdirSync(completedDir, { recursive: true }); - const completedContents = readdirSync(completedDir); - expect(completedContents).toHaveLength(0); - }); + try { + expect(() => runner.listTasks()).toThrow('Failed to acquire tasks lock within 5000ms'); + } finally { + dateNowSpy.mockRestore(); + rmSync(join(testDir, '.takt', 'tasks.yaml.lock'), { force: true }); + } }); - describe('getTasksDir', () => { - it('should return tasks directory path', () => { - expect(runner.getTasksDir()).toBe(join(testDir, '.takt', 'tasks')); - }); + it('should recover from corrupted tasks.yaml and allow adding tasks again', () => { + mkdirSync(join(testDir, '.takt'), { recursive: true }); + writeFileSync(join(testDir, '.takt', 'tasks.yaml'), 'tasks:\n - name: [broken', 'utf-8'); + + expect(() => runner.listTasks()).not.toThrow(); + expect(runner.listTasks()).toEqual([]); + expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(false); + + const task = runner.addTask('Task after recovery'); + expect(task.name).toContain('task-after-recovery'); + expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(true); + expect(runner.listTasks()).toHaveLength(1); }); - describe('requeueFailedTask', () => { - it('should copy task file from failed to tasks directory', () => { - runner.ensureDirs(); + it('should load pending content from relative content_file', () => { + mkdirSync(join(testDir, 'fixtures'), { recursive: true }); + writeFileSync(join(testDir, 'fixtures', 'task.txt'), 'Task from file\nsecond line', 'utf-8'); + writeTasksFile(testDir, [createPendingRecord({ + content: undefined, + content_file: 'fixtures/task.txt', + })]); - // Create a failed task directory - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_my-task'); - mkdirSync(failedDir, { recursive: true }); - writeFileSync(join(failedDir, 'my-task.yaml'), 'task: Do something\n'); - writeFileSync(join(failedDir, 'report.md'), '# Report'); - writeFileSync(join(failedDir, 'log.json'), '{}'); + const tasks = runner.listTasks(); + const pendingItems = runner.listPendingTaskItems(); - const result = runner.requeueFailedTask(failedDir); + expect(tasks[0]?.content).toBe('Task from file\nsecond line'); + expect(pendingItems[0]?.content).toBe('Task from file'); + }); - // Task file should be copied to tasks dir - expect(existsSync(result)).toBe(true); - expect(result).toBe(join(testDir, '.takt', 'tasks', 'my-task.yaml')); + it('should load pending content from absolute content_file', () => { + const contentPath = join(testDir, 'absolute-task.txt'); + writeFileSync(contentPath, 'Absolute task content', 'utf-8'); + writeTasksFile(testDir, [createPendingRecord({ + content: undefined, + content_file: contentPath, + })]); - // Original failed directory should still exist - expect(existsSync(failedDir)).toBe(true); + const tasks = runner.listTasks(); + expect(tasks[0]?.content).toBe('Absolute task content'); + }); - // Task content should be preserved - const content = readFileSync(result, 'utf-8'); - expect(content).toBe('task: Do something\n'); + it('should prefer inline content over content_file', () => { + writeTasksFile(testDir, [createPendingRecord({ + content: 'Inline content', + content_file: 'missing-content-file.txt', + })]); + + const tasks = runner.listTasks(); + expect(tasks[0]?.content).toBe('Inline content'); + }); + + it('should throw when content_file target is missing', () => { + writeTasksFile(testDir, [createPendingRecord({ + content: undefined, + content_file: 'missing-content-file.txt', + })]); + + expect(() => runner.listTasks()).toThrow(/ENOENT|no such file/i); + }); + + it('should mark claimed task as completed', () => { + runner.addTask('Task A'); + const task = runner.claimNextTasks(1)[0]!; + + runner.completeTask({ + task, + success: true, + response: 'Done', + executionLog: [], + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), }); - it('should add start_movement to YAML task file when specified', () => { - runner.ensureDirs(); + const file = loadTasksFile(testDir); + expect(file.tasks[0]?.status).toBe('completed'); + }); - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_retry-task'); - mkdirSync(failedDir, { recursive: true }); - writeFileSync(join(failedDir, 'retry-task.yaml'), 'task: Retry me\npiece: default\n'); + it('should mark claimed task as failed with failure detail', () => { + runner.addTask('Task A'); + const task = runner.claimNextTasks(1)[0]!; - const result = runner.requeueFailedTask(failedDir, 'implement'); - - const content = readFileSync(result, 'utf-8'); - expect(content).toContain('start_movement: implement'); - expect(content).toContain('task: Retry me'); - expect(content).toContain('piece: default'); + runner.failTask({ + task, + success: false, + response: 'Boom', + executionLog: ['last message'], + failureMovement: 'review', + failureLastMessage: 'last message', + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), }); - it('should replace existing start_movement in YAML task file', () => { - runner.ensureDirs(); + const failed = runner.listFailedTasks(); + expect(failed).toHaveLength(1); + expect(failed[0]?.failure?.error).toBe('Boom'); + expect(failed[0]?.failure?.movement).toBe('review'); + expect(failed[0]?.failure?.last_message).toBe('last message'); + }); - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_replace-task'); - mkdirSync(failedDir, { recursive: true }); - writeFileSync(join(failedDir, 'replace-task.yaml'), 'task: Replace me\nstart_movement: plan\n'); - - const result = runner.requeueFailedTask(failedDir, 'ai_review'); - - const content = readFileSync(result, 'utf-8'); - expect(content).toContain('start_movement: ai_review'); - expect(content).not.toContain('start_movement: plan'); + it('should requeue failed task to pending with retry metadata', () => { + runner.addTask('Task A'); + const task = runner.claimNextTasks(1)[0]!; + runner.failTask({ + task, + success: false, + response: 'Boom', + executionLog: [], + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), }); - it('should not modify markdown task files even with startMovement', () => { - runner.ensureDirs(); + runner.requeueFailedTask(task.name, 'implement', 'retry note'); - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_md-task'); - mkdirSync(failedDir, { recursive: true }); - writeFileSync(join(failedDir, 'md-task.md'), '# Task\nDo something'); + const pending = runner.listTasks(); + expect(pending).toHaveLength(1); + expect(pending[0]?.data?.start_movement).toBe('implement'); + expect(pending[0]?.data?.retry_note).toBe('retry note'); + }); - const result = runner.requeueFailedTask(failedDir, 'implement'); + it('should delete pending and failed tasks', () => { + const pending = runner.addTask('Task A'); + runner.deletePendingTask(pending.name); + expect(runner.listTasks()).toHaveLength(0); - const content = readFileSync(result, 'utf-8'); - // Markdown files should not have start_movement added - expect(content).toBe('# Task\nDo something'); - expect(content).not.toContain('start_movement'); - }); - - it('should throw error when no task file found', () => { - runner.ensureDirs(); - - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_no-task'); - mkdirSync(failedDir, { recursive: true }); - writeFileSync(join(failedDir, 'report.md'), '# Report'); - - expect(() => runner.requeueFailedTask(failedDir)).toThrow( - /No task file found in failed directory/ - ); - }); - - it('should throw error when failed directory does not exist', () => { - runner.ensureDirs(); - - expect(() => runner.requeueFailedTask('/nonexistent/path')).toThrow( - /Failed to read failed task directory/ - ); - }); - - it('should add retry_note to YAML task file when specified', () => { - runner.ensureDirs(); - - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_note-task'); - mkdirSync(failedDir, { recursive: true }); - writeFileSync(join(failedDir, 'note-task.yaml'), 'task: Task with note\n'); - - const result = runner.requeueFailedTask(failedDir, undefined, 'Fixed the ENOENT error'); - - const content = readFileSync(result, 'utf-8'); - expect(content).toContain('retry_note: "Fixed the ENOENT error"'); - expect(content).toContain('task: Task with note'); - }); - - it('should escape double quotes in retry_note', () => { - runner.ensureDirs(); - - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_quote-task'); - mkdirSync(failedDir, { recursive: true }); - writeFileSync(join(failedDir, 'quote-task.yaml'), 'task: Task with quotes\n'); - - const result = runner.requeueFailedTask(failedDir, undefined, 'Fixed "spawn node ENOENT" error'); - - const content = readFileSync(result, 'utf-8'); - expect(content).toContain('retry_note: "Fixed \\"spawn node ENOENT\\" error"'); - }); - - it('should add both start_movement and retry_note when both specified', () => { - runner.ensureDirs(); - - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_both-task'); - mkdirSync(failedDir, { recursive: true }); - writeFileSync(join(failedDir, 'both-task.yaml'), 'task: Task with both\n'); - - const result = runner.requeueFailedTask(failedDir, 'implement', 'Retrying from implement'); - - const content = readFileSync(result, 'utf-8'); - expect(content).toContain('start_movement: implement'); - expect(content).toContain('retry_note: "Retrying from implement"'); - }); - - it('should not add retry_note to markdown task files', () => { - runner.ensureDirs(); - - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_md-note-task'); - mkdirSync(failedDir, { recursive: true }); - writeFileSync(join(failedDir, 'md-note-task.md'), '# Task\nDo something'); - - const result = runner.requeueFailedTask(failedDir, undefined, 'Should be ignored'); - - const content = readFileSync(result, 'utf-8'); - expect(content).toBe('# Task\nDo something'); - expect(content).not.toContain('retry_note'); + const failed = runner.addTask('Task B'); + const running = runner.claimNextTasks(1)[0]!; + runner.failTask({ + task: running, + success: false, + response: 'Boom', + executionLog: [], + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), }); + runner.deleteFailedTask(failed.name); + expect(runner.listFailedTasks()).toHaveLength(0); + }); +}); + +describe('TaskRecordSchema', () => { + it('should reject failed record without failure details', () => { + expect(() => TaskRecordSchema.parse({ + name: 'task-a', + status: 'failed', + content: 'Do work', + created_at: '2026-02-09T00:00:00.000Z', + started_at: '2026-02-09T00:01:00.000Z', + completed_at: '2026-02-09T00:02:00.000Z', + })).toThrow(); + }); + + it('should reject completed record with failure details', () => { + expect(() => TaskRecordSchema.parse({ + name: 'task-a', + status: 'completed', + content: 'Do work', + created_at: '2026-02-09T00:00:00.000Z', + started_at: '2026-02-09T00:01:00.000Z', + completed_at: '2026-02-09T00:02:00.000Z', + failure: { + error: 'unexpected', + }, + })).toThrow(); }); }); diff --git a/src/__tests__/taskDeleteActions.test.ts b/src/__tests__/taskDeleteActions.test.ts index 47cecdb..912591f 100644 --- a/src/__tests__/taskDeleteActions.test.ts +++ b/src/__tests__/taskDeleteActions.test.ts @@ -1,10 +1,7 @@ -/** - * Tests for taskDeleteActions — pending/failed task deletion - */ - import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import { stringify as stringifyYaml } from 'yaml'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('../shared/prompt/index.js', () => ({ @@ -35,6 +32,33 @@ const mockLogError = vi.mocked(logError); let tmpDir: string; +function setupTasksFile(projectDir: string): string { + const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml'); + fs.mkdirSync(path.dirname(tasksFile), { recursive: true }); + fs.writeFileSync(tasksFile, stringifyYaml({ + tasks: [ + { + name: 'pending-task', + status: 'pending', + content: 'pending', + created_at: '2025-01-15T00:00:00.000Z', + started_at: null, + completed_at: null, + }, + { + name: 'failed-task', + status: 'failed', + content: 'failed', + created_at: '2025-01-15T00:00:00.000Z', + started_at: '2025-01-15T00:01:00.000Z', + completed_at: '2025-01-15T00:02:00.000Z', + failure: { error: 'boom' }, + }, + ], + }), 'utf-8'); + return tasksFile; +} + beforeEach(() => { vi.clearAllMocks(); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-delete-')); @@ -44,137 +68,59 @@ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); -describe('deletePendingTask', () => { - it('should delete pending task file when confirmed', async () => { - // Given - const filePath = path.join(tmpDir, 'my-task.md'); - fs.writeFileSync(filePath, 'task content'); +describe('taskDeleteActions', () => { + it('should delete pending task when confirmed', async () => { + const tasksFile = setupTasksFile(tmpDir); const task: TaskListItem = { kind: 'pending', - name: 'my-task', + name: 'pending-task', createdAt: '2025-01-15', - filePath, - content: 'task content', + filePath: tasksFile, + content: 'pending', }; mockConfirm.mockResolvedValue(true); - // When const result = await deletePendingTask(task); - // Then expect(result).toBe(true); - expect(fs.existsSync(filePath)).toBe(false); - expect(mockSuccess).toHaveBeenCalledWith('Deleted pending task: my-task'); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('pending-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted pending task: pending-task'); }); - it('should not delete when user declines confirmation', async () => { - // Given - const filePath = path.join(tmpDir, 'my-task.md'); - fs.writeFileSync(filePath, 'task content'); + it('should delete failed task when confirmed', async () => { + const tasksFile = setupTasksFile(tmpDir); const task: TaskListItem = { - kind: 'pending', - name: 'my-task', - createdAt: '2025-01-15', - filePath, - content: 'task content', + kind: 'failed', + name: 'failed-task', + createdAt: '2025-01-15T12:34:56', + filePath: tasksFile, + content: 'failed', }; - mockConfirm.mockResolvedValue(false); + mockConfirm.mockResolvedValue(true); - // When - const result = await deletePendingTask(task); + const result = await deleteFailedTask(task); - // Then - expect(result).toBe(false); - expect(fs.existsSync(filePath)).toBe(true); - expect(mockSuccess).not.toHaveBeenCalled(); + expect(result).toBe(true); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('failed-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: failed-task'); }); - it('should return false and show error when file does not exist', async () => { - // Given - const filePath = path.join(tmpDir, 'non-existent.md'); + it('should return false when target task is missing', async () => { + const tasksFile = setupTasksFile(tmpDir); const task: TaskListItem = { - kind: 'pending', - name: 'non-existent', - createdAt: '2025-01-15', - filePath, + kind: 'failed', + name: 'not-found', + createdAt: '2025-01-15T12:34:56', + filePath: tasksFile, content: '', }; mockConfirm.mockResolvedValue(true); - // When - const result = await deletePendingTask(task); + const result = await deleteFailedTask(task); - // Then expect(result).toBe(false); expect(mockLogError).toHaveBeenCalled(); - expect(mockSuccess).not.toHaveBeenCalled(); - }); -}); - -describe('deleteFailedTask', () => { - it('should delete failed task directory when confirmed', async () => { - // Given - const dirPath = path.join(tmpDir, '2025-01-15T12-34-56_my-task'); - fs.mkdirSync(dirPath, { recursive: true }); - fs.writeFileSync(path.join(dirPath, 'my-task.md'), 'content'); - const task: TaskListItem = { - kind: 'failed', - name: 'my-task', - createdAt: '2025-01-15T12:34:56', - filePath: dirPath, - content: 'content', - }; - mockConfirm.mockResolvedValue(true); - - // When - const result = await deleteFailedTask(task); - - // Then - expect(result).toBe(true); - expect(fs.existsSync(dirPath)).toBe(false); - expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: my-task'); - }); - - it('should not delete when user declines confirmation', async () => { - // Given - const dirPath = path.join(tmpDir, '2025-01-15T12-34-56_my-task'); - fs.mkdirSync(dirPath, { recursive: true }); - const task: TaskListItem = { - kind: 'failed', - name: 'my-task', - createdAt: '2025-01-15T12:34:56', - filePath: dirPath, - content: '', - }; - mockConfirm.mockResolvedValue(false); - - // When - const result = await deleteFailedTask(task); - - // Then - expect(result).toBe(false); - expect(fs.existsSync(dirPath)).toBe(true); - expect(mockSuccess).not.toHaveBeenCalled(); - }); - - it('should return false and show error when directory does not exist', async () => { - // Given - const dirPath = path.join(tmpDir, 'non-existent-dir'); - const task: TaskListItem = { - kind: 'failed', - name: 'non-existent', - createdAt: '2025-01-15T12:34:56', - filePath: dirPath, - content: '', - }; - mockConfirm.mockResolvedValue(true); - - // When - const result = await deleteFailedTask(task); - - // Then - expect(result).toBe(false); - expect(mockLogError).toHaveBeenCalled(); - expect(mockSuccess).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 6734c06..023e317 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -95,6 +95,9 @@ describe('resolveTaskExecution', () => { name: 'simple-task', content: 'Simple task content', filePath: '/tasks/simple-task.yaml', + createdAt: '2026-02-09T00:00:00.000Z', + status: 'pending', + data: null, }; // When diff --git a/src/__tests__/taskRetryActions.test.ts b/src/__tests__/taskRetryActions.test.ts index 7a917fe..5035dde 100644 --- a/src/__tests__/taskRetryActions.test.ts +++ b/src/__tests__/taskRetryActions.test.ts @@ -1,10 +1,7 @@ -/** - * Tests for taskRetryActions — failed task retry functionality - */ - import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import { stringify as stringifyYaml } from 'yaml'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('../shared/prompt/index.js', () => ({ @@ -29,10 +26,6 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ }), })); -vi.mock('../infra/fs/session.js', () => ({ - extractFailureInfo: vi.fn(), -})); - vi.mock('../infra/config/index.js', () => ({ loadGlobalConfig: vi.fn(), loadPieceByIdentifier: vi.fn(), @@ -66,16 +59,37 @@ const defaultPieceConfig: PieceConfig = { ], }; -const customPieceConfig: PieceConfig = { - name: 'custom', - description: 'Custom piece', - initialMovement: 'step1', - maxIterations: 10, - movements: [ - { name: 'step1', persona: 'coder', instruction: '' }, - { name: 'step2', persona: 'reviewer', instruction: '' }, - ], -}; +function writeFailedTask(projectDir: string, name: string): TaskListItem { + const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml'); + fs.mkdirSync(path.dirname(tasksFile), { recursive: true }); + fs.writeFileSync(tasksFile, stringifyYaml({ + tasks: [ + { + name, + status: 'failed', + content: 'Do something', + created_at: '2025-01-15T12:00:00.000Z', + started_at: '2025-01-15T12:01:00.000Z', + completed_at: '2025-01-15T12:02:00.000Z', + piece: 'default', + failure: { + movement: 'review', + error: 'Boom', + }, + }, + ], + }), 'utf-8'); + + return { + kind: 'failed', + name, + createdAt: '2025-01-15T12:02:00.000Z', + filePath: tasksFile, + content: 'Do something', + data: { task: 'Do something', piece: 'default' }, + failure: { movement: 'review', error: 'Boom' }, + }; +} beforeEach(() => { vi.clearAllMocks(); @@ -88,264 +102,49 @@ afterEach(() => { describe('retryFailedTask', () => { it('should requeue task with selected movement', async () => { - // Given: a failed task directory with a task file - const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task'); - const tasksDir = path.join(tmpDir, '.takt', 'tasks'); - fs.mkdirSync(failedDir, { recursive: true }); - fs.mkdirSync(tasksDir, { recursive: true }); - fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n'); - - const task: TaskListItem = { - kind: 'failed', - name: 'my-task', - createdAt: '2025-01-15T12:34:56', - filePath: failedDir, - content: 'Do something', - }; + const task = writeFailedTask(tmpDir, 'my-task'); mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); mockSelectOption.mockResolvedValue('implement'); - mockPromptInput.mockResolvedValue(''); // Empty retry note + mockPromptInput.mockResolvedValue(''); - // When const result = await retryFailedTask(task, tmpDir); - // Then expect(result).toBe(true); expect(mockSuccess).toHaveBeenCalledWith('Task requeued: my-task'); - // Verify requeued file - const requeuedFile = path.join(tasksDir, 'my-task.yaml'); - expect(fs.existsSync(requeuedFile)).toBe(true); - const content = fs.readFileSync(requeuedFile, 'utf-8'); - expect(content).toContain('start_movement: implement'); - }); - - it('should use piece field from task file instead of defaultPiece', async () => { - // Given: a failed task with piece: custom in YAML - const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_custom-task'); - const tasksDir = path.join(tmpDir, '.takt', 'tasks'); - fs.mkdirSync(failedDir, { recursive: true }); - fs.mkdirSync(tasksDir, { recursive: true }); - fs.writeFileSync( - path.join(failedDir, 'custom-task.yaml'), - 'task: Do something\npiece: custom\n', - ); - - const task: TaskListItem = { - kind: 'failed', - name: 'custom-task', - createdAt: '2025-01-15T12:34:56', - filePath: failedDir, - content: 'Do something', - }; - - mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); - // Should be called with 'custom', not 'default' - mockLoadPieceByIdentifier.mockImplementation((name: string) => { - if (name === 'custom') return customPieceConfig; - if (name === 'default') return defaultPieceConfig; - return null; - }); - mockSelectOption.mockResolvedValue('step2'); - mockPromptInput.mockResolvedValue(''); - - // When - const result = await retryFailedTask(task, tmpDir); - - // Then - expect(result).toBe(true); - expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('custom', tmpDir); - expect(mockSuccess).toHaveBeenCalledWith('Task requeued: custom-task'); - }); - - it('should return false when user cancels movement selection', async () => { - // Given - const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task'); - fs.mkdirSync(failedDir, { recursive: true }); - fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n'); - - const task: TaskListItem = { - kind: 'failed', - name: 'my-task', - createdAt: '2025-01-15T12:34:56', - filePath: failedDir, - content: 'Do something', - }; - - mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); - mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); - mockSelectOption.mockResolvedValue(null); // User cancelled - // No need to mock promptInput since user cancelled before reaching it - - // When - const result = await retryFailedTask(task, tmpDir); - - // Then - expect(result).toBe(false); - expect(mockSuccess).not.toHaveBeenCalled(); - expect(mockPromptInput).not.toHaveBeenCalled(); - }); - - it('should return false and show error when piece not found', async () => { - // Given - const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task'); - fs.mkdirSync(failedDir, { recursive: true }); - fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n'); - - const task: TaskListItem = { - kind: 'failed', - name: 'my-task', - createdAt: '2025-01-15T12:34:56', - filePath: failedDir, - content: 'Do something', - }; - - mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'nonexistent' }); - mockLoadPieceByIdentifier.mockReturnValue(null); - - // When - const result = await retryFailedTask(task, tmpDir); - - // Then - expect(result).toBe(false); - expect(mockLogError).toHaveBeenCalledWith( - 'Piece "nonexistent" not found. Cannot determine available movements.', - ); - }); - - it('should fallback to defaultPiece when task file has no piece field', async () => { - // Given: a failed task without piece field in YAML - const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_plain-task'); - const tasksDir = path.join(tmpDir, '.takt', 'tasks'); - fs.mkdirSync(failedDir, { recursive: true }); - fs.mkdirSync(tasksDir, { recursive: true }); - fs.writeFileSync( - path.join(failedDir, 'plain-task.yaml'), - 'task: Do something without piece\n', - ); - - const task: TaskListItem = { - kind: 'failed', - name: 'plain-task', - createdAt: '2025-01-15T12:34:56', - filePath: failedDir, - content: 'Do something without piece', - }; - - mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); - mockLoadPieceByIdentifier.mockImplementation((name: string) => { - if (name === 'default') return defaultPieceConfig; - return null; - }); - mockSelectOption.mockResolvedValue('plan'); - mockPromptInput.mockResolvedValue(''); - - // When - const result = await retryFailedTask(task, tmpDir); - - // Then - expect(result).toBe(true); - expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('default', tmpDir); + const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8'); + expect(tasksYaml).toContain('status: pending'); + expect(tasksYaml).toContain('start_movement: implement'); }); it('should not add start_movement when initial movement is selected', async () => { - // Given - const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task'); - const tasksDir = path.join(tmpDir, '.takt', 'tasks'); - fs.mkdirSync(failedDir, { recursive: true }); - fs.mkdirSync(tasksDir, { recursive: true }); - fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n'); - - const task: TaskListItem = { - kind: 'failed', - name: 'my-task', - createdAt: '2025-01-15T12:34:56', - filePath: failedDir, - content: 'Do something', - }; + const task = writeFailedTask(tmpDir, 'my-task'); mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); - mockSelectOption.mockResolvedValue('plan'); // Initial movement - mockPromptInput.mockResolvedValue(''); // Empty retry note + mockSelectOption.mockResolvedValue('plan'); + mockPromptInput.mockResolvedValue(''); - // When const result = await retryFailedTask(task, tmpDir); - // Then expect(result).toBe(true); - - // Verify requeued file does not have start_movement - const requeuedFile = path.join(tasksDir, 'my-task.yaml'); - const content = fs.readFileSync(requeuedFile, 'utf-8'); - expect(content).not.toContain('start_movement'); + const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8'); + expect(tasksYaml).not.toContain('start_movement'); }); - it('should add retry_note when user provides one', async () => { - // Given - const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_retry-note-task'); - const tasksDir = path.join(tmpDir, '.takt', 'tasks'); - fs.mkdirSync(failedDir, { recursive: true }); - fs.mkdirSync(tasksDir, { recursive: true }); - fs.writeFileSync(path.join(failedDir, 'retry-note-task.yaml'), 'task: Do something\n'); - - const task: TaskListItem = { - kind: 'failed', - name: 'retry-note-task', - createdAt: '2025-01-15T12:34:56', - filePath: failedDir, - content: 'Do something', - }; + it('should return false and show error when piece not found', async () => { + const task = writeFailedTask(tmpDir, 'my-task'); mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); - mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); - mockSelectOption.mockResolvedValue('implement'); - mockPromptInput.mockResolvedValue('Fixed spawn node ENOENT error'); + mockLoadPieceByIdentifier.mockReturnValue(null); - // When const result = await retryFailedTask(task, tmpDir); - // Then - expect(result).toBe(true); - - const requeuedFile = path.join(tasksDir, 'retry-note-task.yaml'); - const content = fs.readFileSync(requeuedFile, 'utf-8'); - expect(content).toContain('start_movement: implement'); - expect(content).toContain('retry_note: "Fixed spawn node ENOENT error"'); - }); - - it('should not add retry_note when user skips it', async () => { - // Given - const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_no-note-task'); - const tasksDir = path.join(tmpDir, '.takt', 'tasks'); - fs.mkdirSync(failedDir, { recursive: true }); - fs.mkdirSync(tasksDir, { recursive: true }); - fs.writeFileSync(path.join(failedDir, 'no-note-task.yaml'), 'task: Do something\n'); - - const task: TaskListItem = { - kind: 'failed', - name: 'no-note-task', - createdAt: '2025-01-15T12:34:56', - filePath: failedDir, - content: 'Do something', - }; - - mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); - mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); - mockSelectOption.mockResolvedValue('implement'); - mockPromptInput.mockResolvedValue(''); // Empty string - user skipped - - // When - const result = await retryFailedTask(task, tmpDir); - - // Then - expect(result).toBe(true); - - const requeuedFile = path.join(tasksDir, 'no-note-task.yaml'); - const content = fs.readFileSync(requeuedFile, 'utf-8'); - expect(content).toContain('start_movement: implement'); - expect(content).not.toContain('retry_note'); + expect(result).toBe(false); + expect(mockLogError).toHaveBeenCalledWith( + 'Piece "default" not found. Cannot determine available movements.', + ); }); }); diff --git a/src/__tests__/watchTasks.test.ts b/src/__tests__/watchTasks.test.ts new file mode 100644 index 0000000..2012287 --- /dev/null +++ b/src/__tests__/watchTasks.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TaskInfo } from '../infra/task/index.js'; + +const { + mockRecoverInterruptedRunningTasks, + mockGetTasksDir, + mockWatch, + mockStop, + mockExecuteAndCompleteTask, + mockInfo, + mockHeader, + mockBlankLine, + mockStatus, + mockSuccess, + mockGetCurrentPiece, +} = vi.hoisted(() => ({ + mockRecoverInterruptedRunningTasks: vi.fn(), + mockGetTasksDir: vi.fn(), + mockWatch: vi.fn(), + mockStop: vi.fn(), + mockExecuteAndCompleteTask: vi.fn(), + mockInfo: vi.fn(), + mockHeader: vi.fn(), + mockBlankLine: vi.fn(), + mockStatus: vi.fn(), + mockSuccess: vi.fn(), + mockGetCurrentPiece: vi.fn(), +})); + +vi.mock('../infra/task/index.js', () => ({ + TaskRunner: vi.fn().mockImplementation(() => ({ + recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks, + getTasksDir: mockGetTasksDir, + })), + TaskWatcher: vi.fn().mockImplementation(() => ({ + watch: mockWatch, + stop: mockStop, + })), +})); + +vi.mock('../features/tasks/execute/taskExecution.js', () => ({ + executeAndCompleteTask: mockExecuteAndCompleteTask, +})); + +vi.mock('../shared/ui/index.js', () => ({ + header: mockHeader, + info: mockInfo, + success: mockSuccess, + status: mockStatus, + blankLine: mockBlankLine, +})); + +vi.mock('../infra/config/index.js', () => ({ + getCurrentPiece: mockGetCurrentPiece, +})); + +import { watchTasks } from '../features/tasks/watch/index.js'; + +describe('watchTasks', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetCurrentPiece.mockReturnValue('default'); + mockRecoverInterruptedRunningTasks.mockReturnValue(0); + mockGetTasksDir.mockReturnValue('/project/.takt/tasks.yaml'); + mockExecuteAndCompleteTask.mockResolvedValue(true); + + mockWatch.mockImplementation(async (onTask: (task: TaskInfo) => Promise) => { + await onTask({ + name: 'task-1', + content: 'Task 1', + filePath: '/project/.takt/tasks.yaml', + createdAt: '2026-02-09T00:00:00.000Z', + status: 'running', + data: null, + }); + }); + }); + + it('watch開始時に中断されたrunningタスクをpendingへ復旧する', async () => { + mockRecoverInterruptedRunningTasks.mockReturnValue(1); + + await watchTasks('/project'); + + expect(mockRecoverInterruptedRunningTasks).toHaveBeenCalledTimes(1); + expect(mockInfo).toHaveBeenCalledWith('Recovered 1 interrupted running task(s) to pending.'); + expect(mockWatch).toHaveBeenCalledTimes(1); + expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/watcher.test.ts b/src/__tests__/watcher.test.ts index 601e33c..051f73a 100644 --- a/src/__tests__/watcher.test.ts +++ b/src/__tests__/watcher.test.ts @@ -1,24 +1,26 @@ -/** - * TaskWatcher tests - */ - import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; +import { stringify as stringifyYaml } from 'yaml'; import { TaskWatcher } from '../infra/task/watcher.js'; +import { TaskRunner } from '../infra/task/runner.js'; import type { TaskInfo } from '../infra/task/types.js'; describe('TaskWatcher', () => { const testDir = `/tmp/takt-watcher-test-${Date.now()}`; let watcher: TaskWatcher | null = null; + function writeTasksYaml(tasks: Array>): void { + const tasksFile = join(testDir, '.takt', 'tasks.yaml'); + mkdirSync(join(testDir, '.takt'), { recursive: true }); + writeFileSync(tasksFile, stringifyYaml({ tasks }), 'utf-8'); + } + beforeEach(() => { - mkdirSync(join(testDir, '.takt', 'tasks'), { recursive: true }); - mkdirSync(join(testDir, '.takt', 'completed'), { recursive: true }); + mkdirSync(join(testDir, '.takt'), { recursive: true }); }); afterEach(() => { - // Ensure watcher is stopped before cleanup if (watcher) { watcher.stop(); watcher = null; @@ -41,21 +43,24 @@ describe('TaskWatcher', () => { }); describe('watch', () => { - it('should detect and process a task file', async () => { + it('should detect and process a pending task from tasks.yaml', async () => { + writeTasksYaml([ + { + name: 'test-task', + status: 'pending', + content: 'Test task content', + created_at: '2026-02-09T00:00:00.000Z', + started_at: null, + completed_at: null, + }, + ]); + watcher = new TaskWatcher(testDir, { pollInterval: 50 }); const processed: string[] = []; - // Pre-create a task file - writeFileSync( - join(testDir, '.takt', 'tasks', 'test-task.md'), - 'Test task content' - ); - - // Start watching, stop after first task const watchPromise = watcher.watch(async (task: TaskInfo) => { processed.push(task.name); - // Stop after processing to avoid infinite loop in test - watcher.stop(); + watcher?.stop(); }); await watchPromise; @@ -64,48 +69,61 @@ describe('TaskWatcher', () => { expect(watcher.isRunning()).toBe(false); }); - it('should wait when no tasks are available', async () => { + it('should wait when no tasks are available and then process added task', async () => { + writeTasksYaml([]); watcher = new TaskWatcher(testDir, { pollInterval: 50 }); - let pollCount = 0; + const runner = new TaskRunner(testDir); + let processed = 0; - // Start watching, add a task after a delay - const watchPromise = watcher.watch(async (task: TaskInfo) => { - pollCount++; - watcher.stop(); + const watchPromise = watcher.watch(async () => { + processed++; + watcher?.stop(); }); - // Add task after short delay (after at least one empty poll) await new Promise((resolve) => setTimeout(resolve, 100)); - writeFileSync( - join(testDir, '.takt', 'tasks', 'delayed-task.md'), - 'Delayed task' - ); + runner.addTask('Delayed task'); await watchPromise; - expect(pollCount).toBe(1); + expect(processed).toBe(1); }); it('should process multiple tasks sequentially', async () => { + writeTasksYaml([ + { + name: 'a-task', + status: 'pending', + content: 'First task', + created_at: '2026-02-09T00:00:00.000Z', + started_at: null, + completed_at: null, + }, + { + name: 'b-task', + status: 'pending', + content: 'Second task', + created_at: '2026-02-09T00:01:00.000Z', + started_at: null, + completed_at: null, + }, + ]); + + const runner = new TaskRunner(testDir); watcher = new TaskWatcher(testDir, { pollInterval: 50 }); const processed: string[] = []; - // Pre-create two task files - writeFileSync( - join(testDir, '.takt', 'tasks', 'a-task.md'), - 'First task' - ); - writeFileSync( - join(testDir, '.takt', 'tasks', 'b-task.md'), - 'Second task' - ); - const watchPromise = watcher.watch(async (task: TaskInfo) => { processed.push(task.name); - // Remove the task file to simulate completion - rmSync(task.filePath); + runner.completeTask({ + task, + success: true, + response: 'Done', + executionLog: [], + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + }); if (processed.length >= 2) { - watcher.stop(); + watcher?.stop(); } }); @@ -117,15 +135,13 @@ describe('TaskWatcher', () => { describe('stop', () => { it('should stop the watcher gracefully', async () => { + writeTasksYaml([]); watcher = new TaskWatcher(testDir, { pollInterval: 50 }); - // Start watching, stop after a short delay const watchPromise = watcher.watch(async () => { - // Should not be called since no tasks }); - // Stop after short delay - setTimeout(() => watcher.stop(), 100); + setTimeout(() => watcher?.stop(), 100); await watchPromise; @@ -133,18 +149,17 @@ describe('TaskWatcher', () => { }); it('should abort sleep immediately when stopped', async () => { + writeTasksYaml([]); watcher = new TaskWatcher(testDir, { pollInterval: 10000 }); const start = Date.now(); const watchPromise = watcher.watch(async () => {}); - // Stop after 50ms, should not wait the full 10s - setTimeout(() => watcher.stop(), 50); + setTimeout(() => watcher?.stop(), 50); await watchPromise; const elapsed = Date.now() - start; - // Should complete well under the 10s poll interval expect(elapsed).toBeLessThan(1000); }); }); diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 6e9e37a..e7a4c94 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -2,15 +2,13 @@ * add command implementation * * Starts an AI conversation to refine task requirements, - * then creates a task file in .takt/tasks/ with YAML format. + * then appends a task record to .takt/tasks.yaml. */ -import * as fs from 'node:fs'; import * as path from 'node:path'; -import { stringify as stringifyYaml } from 'yaml'; import { promptInput, confirm } from '../../../shared/prompt/index.js'; import { success, info, error } from '../../../shared/ui/index.js'; -import { summarizeTaskName, type TaskFileData } from '../../../infra/task/index.js'; +import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js'; import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.js'; import { determinePiece } from '../execute/selectAndExecute.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; @@ -19,23 +17,8 @@ import { interactiveMode } from '../../interactive/index.js'; const log = createLogger('add-task'); -async function generateFilename(tasksDir: string, taskContent: string, cwd: string): Promise { - info('Generating task filename...'); - const slug = await summarizeTaskName(taskContent, { cwd }); - const base = slug || 'task'; - let filename = `${base}.yaml`; - let counter = 1; - - while (fs.existsSync(path.join(tasksDir, filename))) { - filename = `${base}-${counter}.yaml`; - counter++; - } - - return filename; -} - /** - * Save a task file to .takt/tasks/ with YAML format. + * Save a task entry to .takt/tasks.yaml. * * Common logic extracted from addTask(). Used by both addTask() * and saveTaskFromInteractive(). @@ -44,29 +27,19 @@ export async function saveTaskFile( cwd: string, taskContent: string, options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean }, -): Promise { - const tasksDir = path.join(cwd, '.takt', 'tasks'); - fs.mkdirSync(tasksDir, { recursive: true }); - - const firstLine = taskContent.split('\n')[0] || taskContent; - const filename = await generateFilename(tasksDir, firstLine, cwd); - - const taskData: TaskFileData = { - task: taskContent, +): Promise<{ taskName: string; tasksFile: string }> { + const runner = new TaskRunner(cwd); + const config: Omit = { ...(options?.worktree !== undefined && { worktree: options.worktree }), ...(options?.branch && { branch: options.branch }), ...(options?.piece && { piece: options.piece }), ...(options?.issue !== undefined && { issue: options.issue }), ...(options?.autoPr !== undefined && { auto_pr: options.autoPr }), }; - - const filePath = path.join(tasksDir, filename); - const yamlContent = stringifyYaml(taskData); - fs.writeFileSync(filePath, yamlContent, 'utf-8'); - - log.info('Task created', { filePath, taskData }); - - return filePath; + const created = runner.addTask(taskContent, config); + const tasksFile = path.join(cwd, '.takt', 'tasks.yaml'); + log.info('Task created', { taskName: created.name, tasksFile, config }); + return { taskName: created.name, tasksFile }; } /** @@ -120,10 +93,9 @@ export async function saveTaskFromInteractive( piece?: string, ): Promise { const settings = await promptWorktreeSettings(); - const filePath = await saveTaskFile(cwd, task, { piece, ...settings }); - const filename = path.basename(filePath); - success(`Task created: ${filename}`); - info(` Path: ${filePath}`); + const created = await saveTaskFile(cwd, task, { piece, ...settings }); + success(`Task created: ${created.taskName}`); + info(` File: ${created.tasksFile}`); if (settings.worktree) { info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); } @@ -144,9 +116,6 @@ export async function saveTaskFromInteractive( * B) それ以外: ピース選択 → AI対話モード → ワークツリー設定 → YAML作成 */ export async function addTask(cwd: string, task?: string): Promise { - const tasksDir = path.join(cwd, '.takt', 'tasks'); - fs.mkdirSync(tasksDir, { recursive: true }); - // ピース選択とタスク内容の決定 let taskContent: string; let issueNumber: number | undefined; @@ -209,15 +178,14 @@ export async function addTask(cwd: string, task?: string): Promise { const settings = await promptWorktreeSettings(); // YAMLファイル作成 - const filePath = await saveTaskFile(cwd, taskContent, { + const created = await saveTaskFile(cwd, taskContent, { piece, issue: issueNumber, ...settings, }); - const filename = path.basename(filePath); - success(`Task created: ${filename}`); - info(` Path: ${filePath}`); + success(`Task created: ${created.taskName}`); + info(` File: ${created.tasksFile}`); if (settings.worktree) { info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); } diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index b0defab..99ece9f 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -647,6 +647,8 @@ export async function executePiece( return { success: finalState.status === 'completed', reason: abortReason, + lastMovement: lastMovementName, + lastMessage: lastMovementContent, }; } finally { prefixWriter?.flush(); diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index db88636..9f4ed82 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -29,7 +29,6 @@ export async function resolveTaskExecution( defaultPiece: string, ): Promise { const data = task.data; - if (!data) { return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false }; } diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 219711c..4798cc9 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -15,7 +15,7 @@ import { import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { executePiece } from './pieceExecution.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; -import type { TaskExecutionOptions, ExecuteTaskOptions } from './types.js'; +import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js'; import { createPullRequest, buildPrBody, pushBranch, fetchIssue, checkGhCli } from '../../../infra/github/index.js'; import { runWithWorkerPool } from './parallelExecution.js'; import { resolveTaskExecution } from './resolveTask.js'; @@ -48,22 +48,20 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType { +async function executeTaskWithResult(options: ExecuteTaskOptions): Promise { const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, abortSignal, taskPrefix, taskColorIndex } = options; const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd); if (!pieceConfig) { if (isPiecePath(pieceIdentifier)) { error(`Piece file not found: ${pieceIdentifier}`); + return { success: false, reason: `Piece file not found: ${pieceIdentifier}` }; } else { error(`Piece "${pieceIdentifier}" not found.`); info('Available pieces are in ~/.takt/pieces/ or .takt/pieces/'); info('Use "takt switch" to select a piece.'); + return { success: false, reason: `Piece "${pieceIdentifier}" not found.` }; } - return false; } log.debug('Running piece', { @@ -72,7 +70,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise }); const globalConfig = loadGlobalConfig(); - const result = await executePiece(pieceConfig, task, cwd, { + return await executePiece(pieceConfig, task, cwd, { projectCwd, language: globalConfig.language, provider: agentOverrides?.provider, @@ -86,6 +84,13 @@ export async function executeTask(options: ExecuteTaskOptions): Promise taskPrefix, taskColorIndex, }); +} + +/** + * Execute a single task with piece. + */ +export async function executeTask(options: ExecuteTaskOptions): Promise { + const result = await executeTaskWithResult(options); return result.success; } @@ -106,7 +111,6 @@ export async function executeAndCompleteTask( parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number }, ): Promise { const startedAt = new Date().toISOString(); - const executionLog: string[] = []; const taskAbortController = new AbortController(); const externalAbortSignal = parallelOptions?.abortSignal; const taskAbortSignal = externalAbortSignal ? taskAbortController.signal : undefined; @@ -127,7 +131,7 @@ export async function executeAndCompleteTask( const { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName); // cwd is always the project root; pass it as projectCwd so reports/sessions go there - const taskRunPromise = executeTask({ + const taskRunResult = await executeTaskWithResult({ task: task.content, cwd: execCwd, pieceIdentifier: execPiece, @@ -140,7 +144,11 @@ export async function executeAndCompleteTask( taskColorIndex: parallelOptions?.taskColorIndex, }); - const taskSuccess = await taskRunPromise; + if (!taskRunResult.success && !taskRunResult.reason) { + throw new Error('Task failed without reason'); + } + + const taskSuccess = taskRunResult.success; const completedAt = new Date().toISOString(); if (taskSuccess && isWorktree) { @@ -180,8 +188,10 @@ export async function executeAndCompleteTask( const taskResult = { task, success: taskSuccess, - response: taskSuccess ? 'Task completed successfully' : 'Task failed', - executionLog, + response: taskSuccess ? 'Task completed successfully' : taskRunResult.reason!, + executionLog: taskRunResult.lastMessage ? [taskRunResult.lastMessage] : [], + failureMovement: taskRunResult.lastMovement, + failureLastMessage: taskRunResult.lastMessage, startedAt, completedAt, }; @@ -202,7 +212,7 @@ export async function executeAndCompleteTask( task, success: false, response: getErrorMessage(err), - executionLog, + executionLog: [], startedAt, completedAt, }); @@ -230,12 +240,16 @@ export async function runAllTasks( const taskRunner = new TaskRunner(cwd); const globalConfig = loadGlobalConfig(); const concurrency = globalConfig.concurrency; + const recovered = taskRunner.recoverInterruptedRunningTasks(); + if (recovered > 0) { + info(`Recovered ${recovered} interrupted running task(s) to pending.`); + } const initialTasks = taskRunner.claimNextTasks(concurrency); if (initialTasks.length === 0) { - info('No pending tasks in .takt/tasks/'); - info('Create task files as .takt/tasks/*.yaml or use takt add'); + info('No pending tasks in .takt/tasks.yaml'); + info('Use takt add to append tasks.'); return; } diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index 55cf547..5a8d946 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -10,6 +10,8 @@ import type { GitHubIssue } from '../../../infra/github/index.js'; export interface PieceExecutionResult { success: boolean; reason?: string; + lastMovement?: string; + lastMessage?: string; } /** Metadata from interactive mode, passed through to NDJSON logging */ diff --git a/src/features/tasks/list/taskActions.ts b/src/features/tasks/list/taskActions.ts index 59a32c1..2315529 100644 --- a/src/features/tasks/list/taskActions.ts +++ b/src/features/tasks/list/taskActions.ts @@ -37,16 +37,21 @@ export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; * Check if a branch has already been merged into HEAD. */ export function isBranchMerged(projectDir: string, branch: string): boolean { - try { - execFileSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', + const result = spawnSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + + if (result.error) { + log.error('Failed to check if branch is merged', { + branch, + error: getErrorMessage(result.error), }); - return true; - } catch { return false; } + + return result.status === 0; } /** @@ -70,8 +75,13 @@ export function showFullDiff( if (result.status !== 0) { warn('Could not display diff'); } - } catch { + } catch (err) { warn('Could not display diff'); + log.error('Failed to display full diff', { + branch, + defaultBranch, + error: getErrorMessage(err), + }); } } @@ -95,8 +105,13 @@ export async function showDiffAndPromptAction( { cwd, encoding: 'utf-8', stdio: 'pipe' }, ); info(stat); - } catch { + } catch (err) { warn('Could not generate diff stat'); + log.error('Failed to generate diff stat', { + branch: item.info.branch, + defaultBranch, + error: getErrorMessage(err), + }); } const action = await selectOption( @@ -168,8 +183,12 @@ export function mergeBranch(projectDir: string, item: BranchListItem): boolean { encoding: 'utf-8', stdio: 'pipe', }); - } catch { + } catch (err) { warn(`Could not delete branch ${branch}. You may delete it manually.`); + log.error('Failed to delete merged branch', { + branch, + error: getErrorMessage(err), + }); } cleanupOrphanedClone(projectDir, branch); @@ -276,8 +295,12 @@ function getBranchContext(projectDir: string, branch: string): string { lines.push(diffStat); lines.push('```'); } - } catch { - // Ignore errors + } catch (err) { + log.debug('Failed to collect branch diff stat for instruction context', { + branch, + defaultBranch, + error: getErrorMessage(err), + }); } try { @@ -292,8 +315,12 @@ function getBranchContext(projectDir: string, branch: string): string { lines.push(commitLog); lines.push('```'); } - } catch { - // Ignore errors + } catch (err) { + log.debug('Failed to collect branch commit log for instruction context', { + branch, + defaultBranch, + error: getErrorMessage(err), + }); } return lines.length > 0 ? lines.join('\n') + '\n\n' : ''; @@ -361,4 +388,3 @@ export async function instructBranch( removeCloneMeta(projectDir, branch); } } - diff --git a/src/features/tasks/list/taskDeleteActions.ts b/src/features/tasks/list/taskDeleteActions.ts index 128dfe1..89eee61 100644 --- a/src/features/tasks/list/taskDeleteActions.ts +++ b/src/features/tasks/list/taskDeleteActions.ts @@ -2,17 +2,22 @@ * Delete actions for pending and failed tasks. * * Provides interactive deletion (with confirm prompt) - * for pending task files and failed task directories. + * for pending/failed tasks in .takt/tasks.yaml. */ -import { rmSync, unlinkSync } from 'node:fs'; +import { dirname } from 'node:path'; import type { TaskListItem } from '../../../infra/task/index.js'; +import { TaskRunner } from '../../../infra/task/index.js'; import { confirm } from '../../../shared/prompt/index.js'; import { success, error as logError } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; const log = createLogger('list-tasks'); +function getProjectDir(task: TaskListItem): string { + return dirname(dirname(task.filePath)); +} + /** * Delete a pending task file. * Prompts user for confirmation first. @@ -21,7 +26,8 @@ export async function deletePendingTask(task: TaskListItem): Promise { const confirmed = await confirm(`Delete pending task "${task.name}"?`, false); if (!confirmed) return false; try { - unlinkSync(task.filePath); + const runner = new TaskRunner(getProjectDir(task)); + runner.deletePendingTask(task.name); } catch (err) { const msg = getErrorMessage(err); logError(`Failed to delete pending task "${task.name}": ${msg}`); @@ -38,10 +44,11 @@ export async function deletePendingTask(task: TaskListItem): Promise { * Prompts user for confirmation first. */ export async function deleteFailedTask(task: TaskListItem): Promise { - const confirmed = await confirm(`Delete failed task "${task.name}" and its logs?`, false); + const confirmed = await confirm(`Delete failed task "${task.name}"?`, false); if (!confirmed) return false; try { - rmSync(task.filePath, { recursive: true }); + const runner = new TaskRunner(getProjectDir(task)); + runner.deleteFailedTask(task.name); } catch (err) { const msg = getErrorMessage(err); logError(`Failed to delete failed task "${task.name}": ${msg}`); diff --git a/src/features/tasks/list/taskRetryActions.ts b/src/features/tasks/list/taskRetryActions.ts index 615e0b0..d840787 100644 --- a/src/features/tasks/list/taskRetryActions.ts +++ b/src/features/tasks/list/taskRetryActions.ts @@ -5,11 +5,8 @@ * failure info display and movement selection. */ -import { join } from 'node:path'; -import { existsSync, readdirSync } from 'node:fs'; import type { TaskListItem } from '../../../infra/task/index.js'; -import { TaskRunner, parseTaskFile, type TaskFileData } from '../../../infra/task/index.js'; -import { extractFailureInfo, type FailureInfo } from '../../../infra/fs/session.js'; +import { TaskRunner } from '../../../infra/task/index.js'; import { loadPieceByIdentifier, loadGlobalConfig } from '../../../infra/config/index.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { success, error as logError, info, header, blankLine, status } from '../../../shared/ui/index.js'; @@ -18,121 +15,30 @@ import type { PieceConfig } from '../../../core/models/index.js'; const log = createLogger('list-tasks'); -/** - * Find the session log file path from a failed task directory. - * Looks in .takt/logs/ for a matching session ID from log.json. - */ -function findSessionLogPath(failedTaskDir: string, projectDir: string): string | null { - const logsDir = join(projectDir, '.takt', 'logs'); - if (!existsSync(logsDir)) return null; - - // Try to find the log file - // Failed tasks don't have sessionId in log.json by default, - // so we look for the most recent log file that matches the failure time - const logJsonPath = join(failedTaskDir, 'log.json'); - if (!existsSync(logJsonPath)) return null; - - try { - // List all .jsonl files in logs dir - const logFiles = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl')); - if (logFiles.length === 0) return null; - - // Get the failed task timestamp from directory name - const dirName = failedTaskDir.split('/').pop(); - if (!dirName) return null; - const underscoreIdx = dirName.indexOf('_'); - if (underscoreIdx === -1) return null; - const timestampRaw = dirName.slice(0, underscoreIdx); - // Convert format: 2026-01-31T12-00-00 -> 20260131-120000 - const normalizedTimestamp = timestampRaw - .replace(/-/g, '') - .replace('T', '-'); - - // Find logs that match the date (first 8 chars of normalized timestamp) - const datePrefix = normalizedTimestamp.slice(0, 8); - const matchingLogs = logFiles - .filter((f) => f.startsWith(datePrefix)) - .sort() - .reverse(); // Most recent first - - // Return the most recent matching log - if (matchingLogs.length > 0) { - return join(logsDir, matchingLogs[0]!); - } - } catch { - // Ignore errors - } - - return null; -} - -/** - * Find and parse the task file from a failed task directory. - * Returns the parsed TaskFileData if found, null otherwise. - */ -function parseFailedTaskFile(failedTaskDir: string): TaskFileData | null { - const taskExtensions = ['.yaml', '.yml', '.md']; - let files: string[]; - try { - files = readdirSync(failedTaskDir); - } catch { - return null; - } - - for (const file of files) { - const ext = file.slice(file.lastIndexOf('.')); - if (file === 'report.md' || file === 'log.json') continue; - if (!taskExtensions.includes(ext)) continue; - - try { - const taskFilePath = join(failedTaskDir, file); - const parsed = parseTaskFile(taskFilePath); - return parsed.data; - } catch { - continue; - } - } - - return null; -} - -/** - * Display failure information for a failed task. - */ -function displayFailureInfo(task: TaskListItem, failureInfo: FailureInfo | null): void { +function displayFailureInfo(task: TaskListItem): void { header(`Failed Task: ${task.name}`); info(` Failed at: ${task.createdAt}`); - if (failureInfo) { + if (task.failure) { blankLine(); - if (failureInfo.lastCompletedMovement) { - status('Last completed', failureInfo.lastCompletedMovement); + if (task.failure.movement) { + status('Failed at', task.failure.movement, 'red'); } - if (failureInfo.failedMovement) { - status('Failed at', failureInfo.failedMovement, 'red'); + status('Error', task.failure.error, 'red'); + if (task.failure.last_message) { + status('Last message', task.failure.last_message); } - status('Iterations', String(failureInfo.iterations)); - if (failureInfo.errorMessage) { - status('Error', failureInfo.errorMessage, 'red'); - } - } else { - blankLine(); - info(' (No session log found - failure details unavailable)'); } + blankLine(); } -/** - * Prompt user to select a movement to start from. - * Returns the selected movement name, or null if cancelled. - */ async function selectStartMovement( pieceConfig: PieceConfig, defaultMovement: string | null, ): Promise { const movements = pieceConfig.movements.map((m) => m.name); - // Determine default selection const defaultIdx = defaultMovement ? movements.indexOf(defaultMovement) : 0; @@ -149,7 +55,6 @@ async function selectStartMovement( /** * Retry a failed task. - * Shows failure info, prompts for movement selection, and requeues the task. * * @returns true if task was requeued, false if cancelled */ @@ -157,19 +62,9 @@ export async function retryFailedTask( task: TaskListItem, projectDir: string, ): Promise { - // Find session log and extract failure info - const sessionLogPath = findSessionLogPath(task.filePath, projectDir); - const failureInfo = sessionLogPath ? extractFailureInfo(sessionLogPath) : null; + displayFailureInfo(task); - // Display failure information - displayFailureInfo(task, failureInfo); - - // Parse the failed task file to get the piece field - const taskFileData = parseFailedTaskFile(task.filePath); - - // Determine piece name: task file -> global config -> 'default' - const globalConfig = loadGlobalConfig(); - const pieceName = taskFileData?.piece ?? globalConfig.defaultPiece ?? 'default'; + const pieceName = task.data?.piece ?? loadGlobalConfig().defaultPiece ?? 'default'; const pieceConfig = loadPieceByIdentifier(pieceName, projectDir); if (!pieceConfig) { @@ -177,42 +72,22 @@ export async function retryFailedTask( return false; } - // Prompt for movement selection - // Default to failed movement, or last completed + 1, or initial movement - let defaultMovement: string | null = null; - if (failureInfo?.failedMovement) { - defaultMovement = failureInfo.failedMovement; - } else if (failureInfo?.lastCompletedMovement) { - // Find the next movement after the last completed one - const movements = pieceConfig.movements.map((m) => m.name); - const lastIdx = movements.indexOf(failureInfo.lastCompletedMovement); - if (lastIdx >= 0 && lastIdx < movements.length - 1) { - defaultMovement = movements[lastIdx + 1] ?? null; - } - } - - const selectedMovement = await selectStartMovement(pieceConfig, defaultMovement); + const selectedMovement = await selectStartMovement(pieceConfig, task.failure?.movement ?? null); if (selectedMovement === null) { - return false; // User cancelled + return false; } - // Prompt for retry note (optional) blankLine(); const retryNote = await promptInput('Retry note (optional, press Enter to skip):'); const trimmedNote = retryNote?.trim(); - // Requeue the task try { const runner = new TaskRunner(projectDir); - // Only pass startMovement if it's different from the initial movement const startMovement = selectedMovement !== pieceConfig.initialMovement ? selectedMovement : undefined; - const requeuedPath = runner.requeueFailedTask( - task.filePath, - startMovement, - trimmedNote || undefined - ); + + runner.requeueFailedTask(task.name, startMovement, trimmedNote || undefined); success(`Task requeued: ${task.name}`); if (startMovement) { @@ -221,12 +96,11 @@ export async function retryFailedTask( if (trimmedNote) { info(` Retry note: ${trimmedNote}`); } - info(` Task file: ${requeuedPath}`); + info(` File: ${task.filePath}`); log.info('Requeued failed task', { name: task.name, - from: task.filePath, - to: requeuedPath, + tasksFile: task.filePath, startMovement, retryNote: trimmedNote, }); diff --git a/src/features/tasks/watch/index.ts b/src/features/tasks/watch/index.ts index dd57dd2..b450515 100644 --- a/src/features/tasks/watch/index.ts +++ b/src/features/tasks/watch/index.ts @@ -1,7 +1,7 @@ /** * /watch command implementation * - * Watches .takt/tasks/ for new task files and executes them automatically. + * Watches .takt/tasks.yaml for pending tasks and executes them automatically. * Stays resident until Ctrl+C (SIGINT). */ @@ -26,6 +26,7 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P const pieceName = getCurrentPiece(cwd) || DEFAULT_PIECE_NAME; const taskRunner = new TaskRunner(cwd); const watcher = new TaskWatcher(cwd); + const recovered = taskRunner.recoverInterruptedRunningTasks(); let taskCount = 0; let successCount = 0; @@ -34,6 +35,9 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P header('TAKT Watch Mode'); info(`Piece: ${pieceName}`); info(`Watching: ${taskRunner.getTasksDir()}`); + if (recovered > 0) { + info(`Recovered ${recovered} interrupted running task(s) to pending.`); + } info('Waiting for tasks... (Ctrl+C to stop)'); blankLine(); diff --git a/src/infra/task/display.ts b/src/infra/task/display.ts index 76e6751..8b958f7 100644 --- a/src/infra/task/display.ts +++ b/src/infra/task/display.ts @@ -24,8 +24,8 @@ export function showTaskList(runner: TaskRunner): void { if (tasks.length === 0) { console.log(); info('実行待ちのタスクはありません。'); - console.log(chalk.gray(`\n${runner.getTasksDir()}/ にタスクファイル(.yaml/.md)を配置してください。`)); - console.log(chalk.gray(`または takt add でタスクを追加できます。`)); + console.log(chalk.gray(`\n${runner.getTasksDir()} を確認してください。`)); + console.log(chalk.gray('takt add でタスクを追加できます。')); return; } @@ -39,7 +39,6 @@ export function showTaskList(runner: TaskRunner): void { console.log(chalk.cyan.bold(` [${i + 1}] ${task.name}`)); console.log(chalk.gray(` ${firstLine}...`)); - // Show worktree/branch info for YAML tasks if (task.data) { const extras: string[] = []; if (task.data.worktree) { diff --git a/src/infra/task/index.ts b/src/infra/task/index.ts index 3340cae..b511650 100644 --- a/src/infra/task/index.ts +++ b/src/infra/task/index.ts @@ -24,8 +24,19 @@ export { TaskRunner } from './runner.js'; export { showTaskList } from './display.js'; -export { TaskFileSchema, type TaskFileData } from './schema.js'; -export { parseTaskFile, parseTaskFiles, type ParsedTask } from './parser.js'; +export { + TaskFileSchema, + type TaskFileData, + TaskExecutionConfigSchema, + TaskStatusSchema, + type TaskStatus, + TaskFailureSchema, + type TaskFailure, + TaskRecordSchema, + type TaskRecord, + TasksFileSchema, + type TasksFileData, +} from './schema.js'; export { createSharedClone, removeClone, diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts new file mode 100644 index 0000000..ded75cf --- /dev/null +++ b/src/infra/task/mapper.ts @@ -0,0 +1,79 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { TaskFileSchema, type TaskFileData, type TaskRecord } from './schema.js'; +import type { TaskInfo, TaskListItem } from './types.js'; + +function firstLine(content: string): string { + return content.trim().split('\n')[0]?.slice(0, 80) ?? ''; +} + +export function resolveTaskContent(projectDir: string, task: TaskRecord): string { + if (task.content) { + return task.content; + } + if (!task.content_file) { + throw new Error(`Task content is missing: ${task.name}`); + } + + const contentPath = path.isAbsolute(task.content_file) + ? task.content_file + : path.join(projectDir, task.content_file); + return fs.readFileSync(contentPath, 'utf-8'); +} + +export function toTaskData(projectDir: string, task: TaskRecord): TaskFileData { + return TaskFileSchema.parse({ + task: resolveTaskContent(projectDir, task), + worktree: task.worktree, + branch: task.branch, + piece: task.piece, + issue: task.issue, + start_movement: task.start_movement, + retry_note: task.retry_note, + auto_pr: task.auto_pr, + }); +} + +export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskRecord): TaskInfo { + const content = resolveTaskContent(projectDir, task); + return { + filePath: tasksFile, + name: task.name, + content, + createdAt: task.created_at, + status: task.status, + data: TaskFileSchema.parse({ + task: content, + worktree: task.worktree, + branch: task.branch, + piece: task.piece, + issue: task.issue, + start_movement: task.start_movement, + retry_note: task.retry_note, + auto_pr: task.auto_pr, + }), + }; +} + +export function toPendingTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { + return { + kind: 'pending', + name: task.name, + createdAt: task.created_at, + filePath: tasksFile, + content: firstLine(resolveTaskContent(projectDir, task)), + data: toTaskData(projectDir, task), + }; +} + +export function toFailedTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { + return { + kind: 'failed', + name: task.name, + createdAt: task.completed_at ?? task.created_at, + filePath: tasksFile, + content: firstLine(resolveTaskContent(projectDir, task)), + data: toTaskData(projectDir, task), + failure: task.failure, + }; +} diff --git a/src/infra/task/naming.ts b/src/infra/task/naming.ts new file mode 100644 index 0000000..649fe8e --- /dev/null +++ b/src/infra/task/naming.ts @@ -0,0 +1,22 @@ +export function nowIso(): string { + return new Date().toISOString(); +} + +export function firstLine(content: string): string { + return content.trim().split('\n')[0]?.slice(0, 80) ?? ''; +} + +export function sanitizeTaskName(base: string): string { + const normalized = base + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, ' ') + .trim() + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + + if (!normalized) { + return `task-${Date.now()}`; + } + + return normalized; +} diff --git a/src/infra/task/parser.ts b/src/infra/task/parser.ts deleted file mode 100644 index 9964054..0000000 --- a/src/infra/task/parser.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Task file parser - * - * Supports both YAML (.yaml/.yml) and Markdown (.md) task files. - * YAML files are validated against TaskFileSchema. - * Markdown files are treated as plain text. - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { parse as parseYaml } from 'yaml'; -import { TaskFileSchema, type TaskFileData } from './schema.js'; - -/** Supported task file extensions */ -const YAML_EXTENSIONS = ['.yaml', '.yml']; -const MD_EXTENSIONS = ['.md']; -export const TASK_EXTENSIONS = [...YAML_EXTENSIONS, ...MD_EXTENSIONS]; - -/** Parsed task with optional structured data */ -export interface ParsedTask { - filePath: string; - name: string; - content: string; - createdAt: string; - /** Structured data from YAML files (null for .md files) */ - data: TaskFileData | null; -} - -/** - * Check if a file is a supported task file - */ -export function isTaskFile(filename: string): boolean { - const ext = path.extname(filename).toLowerCase(); - return TASK_EXTENSIONS.includes(ext); -} - -/** - * Check if a file is a YAML task file - */ -function isYamlFile(filename: string): boolean { - const ext = path.extname(filename).toLowerCase(); - return YAML_EXTENSIONS.includes(ext); -} - -/** - * Get the task name from a filename (without extension) - */ -function getTaskName(filename: string): string { - const ext = path.extname(filename); - return path.basename(filename, ext); -} - -/** - * Parse a single task file - * - * @throws Error if YAML parsing or validation fails - */ -export function parseTaskFile(filePath: string): ParsedTask { - const rawContent = fs.readFileSync(filePath, 'utf-8'); - const stat = fs.statSync(filePath); - const filename = path.basename(filePath); - const name = getTaskName(filename); - - if (isYamlFile(filename)) { - const parsed = parseYaml(rawContent) as unknown; - const validated = TaskFileSchema.parse(parsed); - return { - filePath, - name, - content: validated.task, - createdAt: stat.birthtime.toISOString(), - data: validated, - }; - } - - // Markdown file: plain text, no structured data - return { - filePath, - name, - content: rawContent, - createdAt: stat.birthtime.toISOString(), - data: null, - }; -} - -/** - * List and parse all task files in a directory - */ -export function parseTaskFiles(tasksDir: string): ParsedTask[] { - const tasks: ParsedTask[] = []; - - const files = fs.readdirSync(tasksDir) - .filter(isTaskFile) - .sort(); - - for (const file of files) { - const filePath = path.join(tasksDir, file); - try { - tasks.push(parseTaskFile(filePath)); - } catch { - // Skip files that fail to parse - } - } - - return tasks; -} diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index 0eb58b7..6c04996 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -1,393 +1,281 @@ -/** - * TAKT タスク実行モード - * - * .takt/tasks/ ディレクトリ内のタスクファイルを読み込み、 - * 順番に実行してレポートを生成する。 - * - * Supports both .md (plain text) and .yaml/.yml (structured) task files. - * - * 使用方法: - * /task # タスク一覧を表示 - * /task run # 次のタスクを実行 - * /task run # 指定したタスクを実行 - * /task list # タスク一覧を表示 - */ - -import * as fs from 'node:fs'; import * as path from 'node:path'; -import { parseTaskFiles, parseTaskFile, type ParsedTask } from './parser.js'; +import { + TaskRecordSchema, + type TaskFileData, + type TaskRecord, + type TaskFailure, +} from './schema.js'; import type { TaskInfo, TaskResult, TaskListItem } from './types.js'; -import { createLogger } from '../../shared/utils/index.js'; +import { toFailedTaskItem, toPendingTaskItem, toTaskInfo } from './mapper.js'; +import { TaskStore } from './store.js'; +import { firstLine, nowIso, sanitizeTaskName } from './naming.js'; export type { TaskInfo, TaskResult, TaskListItem }; -const log = createLogger('task-runner'); - -/** - * タスク実行管理クラス - */ export class TaskRunner { - private projectDir: string; - private tasksDir: string; - private completedDir: string; - private failedDir: string; - private claimedPaths = new Set(); + private readonly store: TaskStore; + private readonly tasksFile: string; - constructor(projectDir: string) { - this.projectDir = projectDir; - this.tasksDir = path.join(projectDir, '.takt', 'tasks'); - this.completedDir = path.join(projectDir, '.takt', 'completed'); - this.failedDir = path.join(projectDir, '.takt', 'failed'); + constructor(private readonly projectDir: string) { + this.store = new TaskStore(projectDir); + this.tasksFile = this.store.getTasksFilePath(); } - /** ディレクトリ構造を作成 */ ensureDirs(): void { - fs.mkdirSync(this.tasksDir, { recursive: true }); - fs.mkdirSync(this.completedDir, { recursive: true }); - fs.mkdirSync(this.failedDir, { recursive: true }); + this.store.ensureDirs(); } - /** タスクディレクトリのパスを取得 */ getTasksDir(): string { - return this.tasksDir; + return this.tasksFile; } - /** - * タスク一覧を取得 - * @returns タスク情報のリスト(ファイル名順) - */ - listTasks(): TaskInfo[] { - this.ensureDirs(); + addTask(content: string, options?: Omit): TaskInfo { + const state = this.store.update((current) => { + const name = this.generateTaskName(content, current.tasks.map((task) => task.name)); + const record: TaskRecord = TaskRecordSchema.parse({ + name, + status: 'pending', + content, + created_at: nowIso(), + started_at: null, + completed_at: null, + owner_pid: null, + ...options, + }); + return { tasks: [...current.tasks, record] }; + }); - try { - const parsed = parseTaskFiles(this.tasksDir); - return parsed.map(toTaskInfo); - } catch (err) { - const nodeErr = err as NodeJS.ErrnoException; - if (nodeErr.code !== 'ENOENT') { - throw err; // 予期しないエラーは再スロー - } - // ENOENT は許容(ディレクトリ未作成) + const created = state.tasks[state.tasks.length - 1]; + if (!created) { + throw new Error('Failed to create task.'); + } + return toTaskInfo(this.projectDir, this.tasksFile, created); + } + + listTasks(): TaskInfo[] { + const state = this.store.read(); + return state.tasks + .filter((task) => task.status === 'pending') + .map((task) => toTaskInfo(this.projectDir, this.tasksFile, task)); + } + + claimNextTasks(count: number): TaskInfo[] { + if (count <= 0) { return []; } + + const claimed: TaskRecord[] = []; + + this.store.update((current) => { + let remaining = count; + const tasks = current.tasks.map((task) => { + if (remaining > 0 && task.status === 'pending') { + const next: TaskRecord = { + ...task, + status: 'running', + started_at: nowIso(), + owner_pid: process.pid, + }; + claimed.push(next); + remaining--; + return next; + } + return task; + }); + return { tasks }; + }); + + return claimed.map((task) => toTaskInfo(this.projectDir, this.tasksFile, task)); } - /** - * 指定した名前のタスクを取得 - * Searches for .yaml, .yml, and .md files in that order. - */ - getTask(name: string): TaskInfo | null { - this.ensureDirs(); - - const extensions = ['.yaml', '.yml', '.md']; - - for (const ext of extensions) { - const filePath = path.join(this.tasksDir, `${name}${ext}`); - if (!fs.existsSync(filePath)) { - continue; - } - - try { - const parsed = parseTaskFile(filePath); - return toTaskInfo(parsed); - } catch { - // Parse error: skip this extension - } - } - - return null; + recoverInterruptedRunningTasks(): number { + let recovered = 0; + this.store.update((current) => { + const tasks = current.tasks.map((task) => { + if (task.status !== 'running' || !this.isRunningTaskStale(task)) { + return task; + } + recovered++; + return { + ...task, + status: 'pending', + started_at: null, + owner_pid: null, + } as TaskRecord; + }); + return { tasks }; + }); + return recovered; } - /** - * 次に実行すべきタスクを取得(最初のタスク) - */ - getNextTask(): TaskInfo | null { - const tasks = this.listTasks(); - return tasks[0] ?? null; - } - - /** - * 予約付きタスク取得 - * - * claimed 済みのタスクを除外して返し、返したタスクを claimed に追加する。 - * 並列実行時に同一タスクが複数ワーカーに返されることを防ぐ。 - */ - claimNextTasks(count: number): TaskInfo[] { - const allTasks = this.listTasks(); - const unclaimed = allTasks.filter((t) => !this.claimedPaths.has(t.filePath)); - const claimed = unclaimed.slice(0, count); - for (const task of claimed) { - this.claimedPaths.add(task.filePath); - } - return claimed; - } - - /** - * タスクを完了としてマーク - * - * タスクファイルを .takt/completed に移動し、 - * レポートファイルを作成する。 - * - * @returns レポートファイルのパス - */ completeTask(result: TaskResult): string { if (!result.success) { throw new Error('Cannot complete a failed task. Use failTask() instead.'); } - return this.moveTask(result, this.completedDir); + + this.store.update((current) => { + const index = this.findActiveTaskIndex(current.tasks, result.task.name); + if (index === -1) { + throw new Error(`Task not found: ${result.task.name}`); + } + + const target = current.tasks[index]!; + const updated: TaskRecord = { + ...target, + status: 'completed', + completed_at: result.completedAt, + owner_pid: null, + failure: undefined, + }; + const tasks = [...current.tasks]; + tasks[index] = updated; + return { tasks }; + }); + + return this.tasksFile; } - /** - * タスクを失敗としてマーク - * - * タスクファイルを .takt/failed に移動し、 - * レポートファイルを作成する。 - * - * @returns レポートファイルのパス - */ failTask(result: TaskResult): string { - return this.moveTask(result, this.failedDir); - } - - /** - * pendingタスクを TaskListItem 形式で取得 - */ - listPendingTaskItems(): TaskListItem[] { - return this.listTasks().map((task) => ({ - kind: 'pending' as const, - name: task.name, - createdAt: task.createdAt, - filePath: task.filePath, - content: task.content.trim().split('\n')[0]?.slice(0, 80) ?? '', - })); - } - - /** - * failedタスクの一覧を取得 - * .takt/failed/ 内のサブディレクトリを走査し、TaskListItem を返す - */ - listFailedTasks(): TaskListItem[] { - this.ensureDirs(); - - const entries = fs.readdirSync(this.failedDir); - - return entries - .filter((entry) => { - const entryPath = path.join(this.failedDir, entry); - return fs.statSync(entryPath).isDirectory() && entry.includes('_'); - }) - .map((entry) => { - const entryPath = path.join(this.failedDir, entry); - const underscoreIdx = entry.indexOf('_'); - const timestampRaw = entry.slice(0, underscoreIdx); - const name = entry.slice(underscoreIdx + 1); - const createdAt = timestampRaw.replace( - /^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})$/, - '$1:$2:$3', - ); - const content = this.readFailedTaskContent(entryPath); - return { kind: 'failed' as const, name, createdAt, filePath: entryPath, content }; - }) - .filter((item) => item.name !== ''); - } - - /** - * failedタスクディレクトリ内のタスクファイルから先頭1行を読み取る - */ - private readFailedTaskContent(dirPath: string): string { - const taskExtensions = ['.md', '.yaml', '.yml']; - let files: string[]; - try { - files = fs.readdirSync(dirPath); - } catch (err) { - log.error('Failed to read failed task directory', { dirPath, error: String(err) }); - return ''; - } - - for (const file of files) { - const ext = path.extname(file); - if (file === 'report.md' || file === 'log.json') continue; - if (!taskExtensions.includes(ext)) continue; - - try { - const raw = fs.readFileSync(path.join(dirPath, file), 'utf-8'); - return raw.trim().split('\n')[0]?.slice(0, 80) ?? ''; - } catch (err) { - log.error('Failed to read failed task file', { file, dirPath, error: String(err) }); - continue; - } - } - - return ''; - } - - /** - * Requeue a failed task back to .takt/tasks/ - * - * Copies the task file from failed directory to tasks directory. - * If startMovement is specified and the task is YAML, adds start_movement field. - * If retryNote is specified and the task is YAML, adds retry_note field. - * Original failed directory is preserved for history. - * - * @param failedTaskDir - Path to failed task directory (e.g., .takt/failed/2026-01-31T12-00-00_my-task/) - * @param startMovement - Optional movement to start from (written to task file) - * @param retryNote - Optional note about why task is being retried (written to task file) - * @returns The path to the requeued task file - * @throws Error if task file not found or copy fails - */ - requeueFailedTask(failedTaskDir: string, startMovement?: string, retryNote?: string): string { - this.ensureDirs(); - - // Find task file in failed directory - const taskExtensions = ['.yaml', '.yml', '.md']; - let files: string[]; - try { - files = fs.readdirSync(failedTaskDir); - } catch (err) { - throw new Error(`Failed to read failed task directory: ${failedTaskDir} - ${err}`); - } - - let taskFile: string | null = null; - let taskExt: string | null = null; - - for (const file of files) { - const ext = path.extname(file); - if (file === 'report.md' || file === 'log.json') continue; - if (!taskExtensions.includes(ext)) continue; - taskFile = path.join(failedTaskDir, file); - taskExt = ext; - break; - } - - if (!taskFile || !taskExt) { - throw new Error(`No task file found in failed directory: ${failedTaskDir}`); - } - - // Read task content - const taskContent = fs.readFileSync(taskFile, 'utf-8'); - const taskName = path.basename(taskFile, taskExt); - - // Destination path - const destFile = path.join(this.tasksDir, `${taskName}${taskExt}`); - - // For YAML files, add start_movement and retry_note if specified - let finalContent = taskContent; - if (taskExt === '.yaml' || taskExt === '.yml') { - if (startMovement) { - // Check if start_movement already exists - if (!/^start_movement:/m.test(finalContent)) { - // Add start_movement field at the end - finalContent = finalContent.trimEnd() + `\nstart_movement: ${startMovement}\n`; - } else { - // Replace existing start_movement - finalContent = finalContent.replace(/^start_movement:.*$/m, `start_movement: ${startMovement}`); - } - } - - if (retryNote) { - // Escape double quotes in retry note for YAML string - const escapedNote = retryNote.replace(/"/g, '\\"'); - // Check if retry_note already exists - if (!/^retry_note:/m.test(finalContent)) { - // Add retry_note field at the end - finalContent = finalContent.trimEnd() + `\nretry_note: "${escapedNote}"\n`; - } else { - // Replace existing retry_note - finalContent = finalContent.replace(/^retry_note:.*$/m, `retry_note: "${escapedNote}"`); - } - } - } - - // Write to tasks directory - fs.writeFileSync(destFile, finalContent, 'utf-8'); - - log.info('Requeued failed task', { from: failedTaskDir, to: destFile, startMovement }); - - return destFile; - } - - /** - * タスクファイルを指定ディレクトリに移動し、レポート・ログを生成する - */ - private moveTask(result: TaskResult, targetDir: string): string { - this.ensureDirs(); - - // タイムスタンプを生成 - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - - // ターゲットディレクトリにサブディレクトリを作成 - const taskTargetDir = path.join( - targetDir, - `${timestamp}_${result.task.name}` - ); - fs.mkdirSync(taskTargetDir, { recursive: true }); - - // 元のタスクファイルを移動(元の拡張子を保持) - const originalExt = path.extname(result.task.filePath); - const movedTaskFile = path.join(taskTargetDir, `${result.task.name}${originalExt}`); - fs.renameSync(result.task.filePath, movedTaskFile); - - this.claimedPaths.delete(result.task.filePath); - - // レポートを生成 - const reportFile = path.join(taskTargetDir, 'report.md'); - const reportContent = this.generateReport(result); - fs.writeFileSync(reportFile, reportContent, 'utf-8'); - - // ログを保存 - const logFile = path.join(taskTargetDir, 'log.json'); - const logData = { - taskName: result.task.name, - success: result.success, - startedAt: result.startedAt, - completedAt: result.completedAt, - executionLog: result.executionLog, - response: result.response, + const failure: TaskFailure = { + movement: result.failureMovement, + error: result.response, + last_message: result.failureLastMessage ?? result.executionLog[result.executionLog.length - 1], }; - fs.writeFileSync(logFile, JSON.stringify(logData, null, 2), 'utf-8'); - return reportFile; + this.store.update((current) => { + const index = this.findActiveTaskIndex(current.tasks, result.task.name); + if (index === -1) { + throw new Error(`Task not found: ${result.task.name}`); + } + + const target = current.tasks[index]!; + const updated: TaskRecord = { + ...target, + status: 'failed', + completed_at: result.completedAt, + owner_pid: null, + failure, + }; + const tasks = [...current.tasks]; + tasks[index] = updated; + return { tasks }; + }); + + return this.tasksFile; } - /** - * レポートを生成 - */ - private generateReport(result: TaskResult): string { - const status = result.success ? '成功' : '失敗'; - - return `# タスク実行レポート - -## 基本情報 - -- タスク名: ${result.task.name} -- ステータス: ${status} -- 開始時刻: ${result.startedAt} -- 完了時刻: ${result.completedAt} - -## 元のタスク - -\`\`\`markdown -${result.task.content} -\`\`\` - -## 実行結果 - -${result.response} - ---- - -*Generated by TAKT Task Runner* -`; + listPendingTaskItems(): TaskListItem[] { + const state = this.store.read(); + return state.tasks + .filter((task) => task.status === 'pending') + .map((task) => toPendingTaskItem(this.projectDir, this.tasksFile, task)); + } + + listFailedTasks(): TaskListItem[] { + const state = this.store.read(); + return state.tasks + .filter((task) => task.status === 'failed') + .map((task) => toFailedTaskItem(this.projectDir, this.tasksFile, task)); + } + + requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string { + const taskName = this.normalizeTaskRef(taskRef); + + this.store.update((current) => { + const index = current.tasks.findIndex((task) => task.name === taskName && task.status === 'failed'); + if (index === -1) { + throw new Error(`Failed task not found: ${taskRef}`); + } + + const target = current.tasks[index]!; + const updated: TaskRecord = { + ...target, + status: 'pending', + started_at: null, + completed_at: null, + owner_pid: null, + failure: undefined, + start_movement: startMovement, + retry_note: retryNote, + }; + + const tasks = [...current.tasks]; + tasks[index] = updated; + return { tasks }; + }); + + return this.tasksFile; + } + + deletePendingTask(name: string): void { + this.deleteTaskByNameAndStatus(name, 'pending'); + } + + deleteFailedTask(name: string): void { + this.deleteTaskByNameAndStatus(name, 'failed'); + } + + private deleteTaskByNameAndStatus(name: string, status: 'pending' | 'failed'): void { + this.store.update((current) => { + const exists = current.tasks.some((task) => task.name === name && task.status === status); + if (!exists) { + throw new Error(`Task not found: ${name} (${status})`); + } + return { + tasks: current.tasks.filter((task) => !(task.name === name && task.status === status)), + }; + }); + } + + private normalizeTaskRef(taskRef: string): string { + if (!taskRef.includes(path.sep)) { + return taskRef; + } + + const base = path.basename(taskRef); + if (base.includes('_')) { + return base.slice(base.indexOf('_') + 1); + } + + return base; + } + + private findActiveTaskIndex(tasks: TaskRecord[], name: string): number { + return tasks.findIndex((task) => task.name === name && (task.status === 'running' || task.status === 'pending')); + } + + private isRunningTaskStale(task: TaskRecord): boolean { + if (task.owner_pid == null) { + return true; + } + return !this.isProcessAlive(task.owner_pid); + } + + private isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'ESRCH') { + return false; + } + if (nodeErr.code === 'EPERM') { + return true; + } + throw err; + } + } + + private generateTaskName(content: string, existingNames: string[]): string { + const base = sanitizeTaskName(firstLine(content)); + let candidate = base; + let counter = 1; + while (existingNames.includes(candidate)) { + candidate = `${base}-${counter}`; + counter++; + } + return candidate; } -} -/** Convert ParsedTask to TaskInfo */ -function toTaskInfo(parsed: ParsedTask): TaskInfo { - return { - filePath: parsed.filePath, - name: parsed.name, - content: parsed.content, - createdAt: parsed.createdAt, - data: parsed.data, - }; } diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index 051d189..a884f0f 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -1,39 +1,184 @@ /** - * Task YAML schema definition - * - * Zod schema for structured task files (.yaml/.yml) + * Task schema definitions */ import { z } from 'zod/v4'; /** - * YAML task file schema - * - * Examples: - * task: "認証機能を追加する" - * worktree: true # 共有クローンで隔離実行 - * branch: "feat/add-auth" # オプション(省略時は自動生成) - * piece: "default" # オプション(省略時はcurrent piece) - * - * worktree patterns (uses git clone --shared internally): - * - true: create shared clone in sibling dir or worktree_dir - * - "/path/to/dir": create at specified path - * - omitted: no isolation (run in cwd) - * - * branch patterns: - * - "feat/xxx": use specified branch name - * - omitted: auto-generate as takt/{timestamp}-{task-slug} + * Per-task execution config schema. + * Used by `takt add` input and in-memory TaskInfo.data. */ -export const TaskFileSchema = z.object({ - task: z.string().min(1), +export const TaskExecutionConfigSchema = z.object({ worktree: z.union([z.boolean(), z.string()]).optional(), branch: z.string().optional(), piece: z.string().optional(), issue: z.number().int().positive().optional(), start_movement: z.string().optional(), retry_note: z.string().optional(), - /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ auto_pr: z.boolean().optional(), }); +/** + * Single task payload schema used by in-memory TaskInfo.data. + */ +export const TaskFileSchema = TaskExecutionConfigSchema.extend({ + task: z.string().min(1), +}); + export type TaskFileData = z.infer; + +export const TaskStatusSchema = z.enum(['pending', 'running', 'completed', 'failed']); +export type TaskStatus = z.infer; + +export const TaskFailureSchema = z.object({ + movement: z.string().optional(), + error: z.string().min(1), + last_message: z.string().optional(), +}); +export type TaskFailure = z.infer; + +export const TaskRecordSchema = TaskExecutionConfigSchema.extend({ + name: z.string().min(1), + status: TaskStatusSchema, + content: z.string().optional(), + content_file: z.string().optional(), + created_at: z.string().min(1), + started_at: z.string().nullable(), + completed_at: z.string().nullable(), + owner_pid: z.number().int().positive().nullable().optional(), + failure: TaskFailureSchema.optional(), +}).superRefine((value, ctx) => { + if (!value.content && !value.content_file) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['content'], + message: 'Either content or content_file is required.', + }); + } + + const hasFailure = value.failure !== undefined; + const hasOwnerPid = typeof value.owner_pid === 'number'; + + if (value.status === 'pending') { + if (value.started_at !== null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['started_at'], + message: 'Pending task must not have started_at.', + }); + } + if (value.completed_at !== null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['completed_at'], + message: 'Pending task must not have completed_at.', + }); + } + if (hasFailure) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['failure'], + message: 'Pending task must not have failure.', + }); + } + if (hasOwnerPid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['owner_pid'], + message: 'Pending task must not have owner_pid.', + }); + } + } + + if (value.status === 'running') { + if (value.started_at === null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['started_at'], + message: 'Running task requires started_at.', + }); + } + if (value.completed_at !== null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['completed_at'], + message: 'Running task must not have completed_at.', + }); + } + if (hasFailure) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['failure'], + message: 'Running task must not have failure.', + }); + } + } + + if (value.status === 'completed') { + if (value.started_at === null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['started_at'], + message: 'Completed task requires started_at.', + }); + } + if (value.completed_at === null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['completed_at'], + message: 'Completed task requires completed_at.', + }); + } + if (hasFailure) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['failure'], + message: 'Completed task must not have failure.', + }); + } + if (hasOwnerPid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['owner_pid'], + message: 'Completed task must not have owner_pid.', + }); + } + } + + if (value.status === 'failed') { + if (value.started_at === null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['started_at'], + message: 'Failed task requires started_at.', + }); + } + if (value.completed_at === null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['completed_at'], + message: 'Failed task requires completed_at.', + }); + } + if (!hasFailure) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['failure'], + message: 'Failed task requires failure.', + }); + } + if (hasOwnerPid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['owner_pid'], + message: 'Failed task must not have owner_pid.', + }); + } + } +}); +export type TaskRecord = z.infer; + +export const TasksFileSchema = z.object({ + tasks: z.array(TaskRecordSchema), +}); +export type TasksFileData = z.infer; diff --git a/src/infra/task/store.ts b/src/infra/task/store.ts new file mode 100644 index 0000000..ea6329c --- /dev/null +++ b/src/infra/task/store.ts @@ -0,0 +1,181 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { TasksFileSchema, type TasksFileData } from './schema.js'; +import { createLogger } from '../../shared/utils/index.js'; + +const log = createLogger('task-store'); +const LOCK_WAIT_MS = 5_000; +const LOCK_POLL_MS = 50; + +function sleepSync(ms: number): void { + const arr = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(arr, 0, 0, ms); +} + +export class TaskStore { + private readonly tasksFile: string; + private readonly lockFile: string; + private readonly taktDir: string; + + constructor(private readonly projectDir: string) { + this.taktDir = path.join(projectDir, '.takt'); + this.tasksFile = path.join(this.taktDir, 'tasks.yaml'); + this.lockFile = path.join(this.taktDir, 'tasks.yaml.lock'); + } + + getTasksFilePath(): string { + return this.tasksFile; + } + + ensureDirs(): void { + fs.mkdirSync(this.taktDir, { recursive: true }); + } + + read(): TasksFileData { + return this.withLock(() => this.readUnsafe()); + } + + update(mutator: (current: TasksFileData) => TasksFileData): TasksFileData { + return this.withLock(() => { + const current = this.readUnsafe(); + const updated = TasksFileSchema.parse(mutator(current)); + this.writeUnsafe(updated); + return updated; + }); + } + + private readUnsafe(): TasksFileData { + this.ensureDirs(); + + if (!fs.existsSync(this.tasksFile)) { + return { tasks: [] }; + } + + let raw: string; + try { + raw = fs.readFileSync(this.tasksFile, 'utf-8'); + } catch (err) { + log.error('Failed to read tasks file', { file: this.tasksFile, error: String(err) }); + throw err; + } + + try { + const parsed = parseYaml(raw) as unknown; + return TasksFileSchema.parse(parsed); + } catch (err) { + log.error('tasks.yaml is broken. Resetting file.', { file: this.tasksFile, error: String(err) }); + fs.unlinkSync(this.tasksFile); + return { tasks: [] }; + } + } + + private writeUnsafe(state: TasksFileData): void { + this.ensureDirs(); + const tempPath = `${this.tasksFile}.tmp-${process.pid}-${Date.now()}`; + const yaml = stringifyYaml(state); + fs.writeFileSync(tempPath, yaml, 'utf-8'); + fs.renameSync(tempPath, this.tasksFile); + } + + private withLock(fn: () => T): T { + this.acquireLock(); + try { + return fn(); + } finally { + this.releaseLock(); + } + } + + private acquireLock(): void { + this.ensureDirs(); + const start = Date.now(); + + while (true) { + try { + fs.writeFileSync(this.lockFile, String(process.pid), { encoding: 'utf-8', flag: 'wx' }); + return; + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code !== 'EEXIST') { + throw err; + } + } + + if (this.isStaleLock()) { + this.removeStaleLock(); + continue; + } + + if (Date.now() - start >= LOCK_WAIT_MS) { + throw new Error(`Failed to acquire tasks lock within ${LOCK_WAIT_MS}ms`); + } + + sleepSync(LOCK_POLL_MS); + } + } + + private isStaleLock(): boolean { + let pidRaw: string; + try { + pidRaw = fs.readFileSync(this.lockFile, 'utf-8').trim(); + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'ENOENT') { + return false; + } + throw err; + } + + const pid = Number.parseInt(pidRaw, 10); + if (!Number.isInteger(pid) || pid <= 0) { + return true; + } + + return !this.isProcessAlive(pid); + } + + private removeStaleLock(): void { + try { + fs.unlinkSync(this.lockFile); + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code !== 'ENOENT') { + log.debug('Failed to remove stale lock, retrying.', { lockFile: this.lockFile, error: String(err) }); + } + } + } + + private isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'ESRCH') { + return false; + } + if (nodeErr.code === 'EPERM') { + return true; + } + throw err; + } + } + + private releaseLock(): void { + try { + const holder = fs.readFileSync(this.lockFile, 'utf-8').trim(); + if (holder !== String(process.pid)) { + return; + } + fs.unlinkSync(this.lockFile); + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'ENOENT') { + return; + } + log.debug('Failed to release tasks lock.', { lockFile: this.lockFile, error: String(err) }); + throw err; + } + } +} diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index 523413a..2968a7c 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -3,6 +3,7 @@ */ import type { TaskFileData } from './schema.js'; +import type { TaskFailure, TaskStatus } from './schema.js'; /** タスク情報 */ export interface TaskInfo { @@ -10,7 +11,7 @@ export interface TaskInfo { name: string; content: string; createdAt: string; - /** Structured data from YAML files (null for .md files) */ + status: TaskStatus; data: TaskFileData | null; } @@ -20,6 +21,8 @@ export interface TaskResult { success: boolean; response: string; executionLog: string[]; + failureMovement?: string; + failureLastMessage?: string; startedAt: string; completedAt: string; } @@ -74,4 +77,6 @@ export interface TaskListItem { createdAt: string; filePath: string; content: string; + data?: TaskFileData; + failure?: TaskFailure; } diff --git a/src/infra/task/watcher.ts b/src/infra/task/watcher.ts index 5cb1b7f..f3cefa9 100644 --- a/src/infra/task/watcher.ts +++ b/src/infra/task/watcher.ts @@ -1,7 +1,7 @@ /** * Task directory watcher * - * Polls .takt/tasks/ for new task files and invokes a callback when found. + * Polls .takt/tasks.yaml for pending tasks and invokes a callback when found. * Uses polling (not fs.watch) for cross-platform reliability. */ @@ -40,7 +40,8 @@ export class TaskWatcher { log.info('Watch started', { pollInterval: this.pollInterval }); while (this.running) { - const task = this.runner.getNextTask(); + const claimed = this.runner.claimNextTasks(1); + const task = claimed[0]; if (task) { log.info('Task found', { name: task.name }); From f8bcc4ce7d65db62dd1dcb3ce3a9cd13461407ab Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:29:48 +0900 Subject: [PATCH 08/19] takt: optimize-base-commit-cache (#186) --- .../branchGitResolver.performance.test.ts | 245 ++++++++++++++++++ .../buildListItems.performance.test.ts | 67 +++++ src/__tests__/getFilesChanged.test.ts | 51 +++- src/__tests__/getOriginalInstruction.test.ts | 62 ++++- src/infra/task/branchBaseCandidateResolver.ts | 133 ++++++++++ src/infra/task/branchBaseRefCache.ts | 56 ++++ src/infra/task/branchEntryPointResolver.ts | 31 +++ src/infra/task/branchGitCommands.ts | 25 ++ src/infra/task/branchGitResolver.ts | 219 ++++------------ src/infra/task/branchList.ts | 84 ++++-- 10 files changed, 765 insertions(+), 208 deletions(-) create mode 100644 src/__tests__/branchGitResolver.performance.test.ts create mode 100644 src/__tests__/buildListItems.performance.test.ts create mode 100644 src/infra/task/branchBaseCandidateResolver.ts create mode 100644 src/infra/task/branchBaseRefCache.ts create mode 100644 src/infra/task/branchEntryPointResolver.ts create mode 100644 src/infra/task/branchGitCommands.ts diff --git a/src/__tests__/branchGitResolver.performance.test.ts b/src/__tests__/branchGitResolver.performance.test.ts new file mode 100644 index 0000000..ae286e9 --- /dev/null +++ b/src/__tests__/branchGitResolver.performance.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +import { execFileSync } from 'node:child_process'; +import { + createBranchBaseResolutionCache, + findFirstTaktCommit, + resolveBranchBaseCommit, +} from '../infra/task/branchGitResolver.js'; + +const mockExecFileSync = vi.mocked(execFileSync); + +describe('branchGitResolver performance', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should skip full ref scan when default branch candidate resolves', () => { + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { + throw new Error('reflog unavailable'); + } + + if (args[0] === 'merge-base' && args[1] === 'main') { + return 'base-main'; + } + + if (args[0] === 'merge-base' && args[1] === 'origin/main') { + throw new Error('origin/main not available'); + } + + if (args[0] === 'rev-list') { + return '1'; + } + + if (args[0] === 'log' && args[1] === '--format=%s') { + return 'takt: first'; + } + + if (args[0] === 'for-each-ref') { + throw new Error('for-each-ref should not be called'); + } + + throw new Error(`unexpected git args: ${args.join(' ')}`); + }); + + const baseCommit = resolveBranchBaseCommit('/project', 'main', 'takt/feature-a'); + + expect(baseCommit).toBe('base-main'); + expect(mockExecFileSync).not.toHaveBeenCalledWith( + 'git', + ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes'], + expect.anything(), + ); + }); + + it('should reuse ref list cache across branch resolutions', () => { + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { + throw new Error('reflog unavailable'); + } + + if (args[0] === 'merge-base') { + const baseRef = args[1]; + const branch = args[2]; + if (baseRef === 'main' || baseRef === 'origin/main') { + throw new Error('priority refs unavailable'); + } + if (baseRef === 'develop' && branch === 'takt/feature-a') { + return 'base-a'; + } + if (baseRef === 'origin/develop' && branch === 'takt/feature-a') { + return 'base-a-remote'; + } + if (baseRef === 'develop' && branch === 'takt/feature-b') { + return 'base-b'; + } + if (baseRef === 'origin/develop' && branch === 'takt/feature-b') { + return 'base-b-remote'; + } + throw new Error(`unexpected merge-base args: ${args.join(' ')}`); + } + + if (args[0] === 'for-each-ref') { + return 'develop\norigin/develop\n'; + } + + if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { + return '/project/.git'; + } + + if (args[0] === 'rev-list') { + const range = args[3]; + if (range === 'base-a..takt/feature-a') { + return '1'; + } + if (range === 'base-a-remote..takt/feature-a') { + return '5'; + } + if (range === 'base-b..takt/feature-b') { + return '1'; + } + if (range === 'base-b-remote..takt/feature-b') { + return '6'; + } + throw new Error(`unexpected rev-list args: ${args.join(' ')}`); + } + + if (args[0] === 'log' && args[1] === '--format=%s') { + return 'takt: first'; + } + + throw new Error(`unexpected git args: ${args.join(' ')}`); + }); + + const cache = createBranchBaseResolutionCache(); + const baseA = resolveBranchBaseCommit('/project', 'main', 'takt/feature-a', cache); + const baseB = resolveBranchBaseCommit('/project', 'main', 'takt/feature-b', cache); + + expect(baseA).toBe('base-a'); + expect(baseB).toBe('base-b'); + + const forEachRefCalls = mockExecFileSync.mock.calls.filter(([, args]) => + args[0] === 'for-each-ref', + ); + expect(forEachRefCalls).toHaveLength(1); + }); + + it('should skip reflog lookup when baseCommit is provided to findFirstTaktCommit', () => { + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { + throw new Error('reflog should not be called'); + } + + if (args[0] === 'log' && args[1] === '--format=%H\t%s') { + return 'abc123\ttakt: first instruction\n'; + } + + throw new Error(`unexpected git args: ${args.join(' ')}`); + }); + + const first = findFirstTaktCommit('/project', 'main', 'takt/feature-a', { baseCommit: 'base-a' }); + + expect(first).toEqual({ subject: 'takt: first instruction' }); + expect(mockExecFileSync).not.toHaveBeenCalledWith( + 'git', + ['reflog', 'show', '--format=%H', 'takt/feature-a'], + expect.anything(), + ); + }); + + it('should reuse ref list cache across worktrees in the same repository', () => { + mockExecFileSync.mockImplementation((cmd, args, options) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { + throw new Error('reflog unavailable'); + } + + if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { + if (options?.cwd === '/repo/worktrees/a' || options?.cwd === '/repo/worktrees/b') { + return '/repo/.git'; + } + throw new Error(`unexpected rev-parse cwd: ${String(options?.cwd)}`); + } + + if (args[0] === 'merge-base') { + const baseRef = args[1]; + const branch = args[2]; + if (baseRef === 'main' || baseRef === 'origin/main') { + throw new Error('priority refs unavailable'); + } + if (baseRef === 'develop' && branch === 'takt/feature-a') { + return 'base-a'; + } + if (baseRef === 'origin/develop' && branch === 'takt/feature-a') { + return 'base-a-remote'; + } + if (baseRef === 'develop' && branch === 'takt/feature-b') { + return 'base-b'; + } + if (baseRef === 'origin/develop' && branch === 'takt/feature-b') { + return 'base-b-remote'; + } + throw new Error(`unexpected merge-base args: ${args.join(' ')}`); + } + + if (args[0] === 'for-each-ref') { + return 'develop\norigin/develop\n'; + } + + if (args[0] === 'rev-list') { + const range = args[3]; + if (range === 'base-a..takt/feature-a') { + return '1'; + } + if (range === 'base-a-remote..takt/feature-a') { + return '5'; + } + if (range === 'base-b..takt/feature-b') { + return '1'; + } + if (range === 'base-b-remote..takt/feature-b') { + return '6'; + } + throw new Error(`unexpected rev-list args: ${args.join(' ')}`); + } + + if (args[0] === 'log' && args[1] === '--format=%s') { + return 'takt: first'; + } + + throw new Error(`unexpected git args: ${args.join(' ')}`); + }); + + const cache = createBranchBaseResolutionCache(); + const baseA = resolveBranchBaseCommit('/repo/worktrees/a', 'main', 'takt/feature-a', cache); + const baseB = resolveBranchBaseCommit('/repo/worktrees/b', 'main', 'takt/feature-b', cache); + + expect(baseA).toBe('base-a'); + expect(baseB).toBe('base-b'); + + const forEachRefCalls = mockExecFileSync.mock.calls.filter(([, args]) => + args[0] === 'for-each-ref', + ); + expect(forEachRefCalls).toHaveLength(1); + }); +}); diff --git a/src/__tests__/buildListItems.performance.test.ts b/src/__tests__/buildListItems.performance.test.ts new file mode 100644 index 0000000..e38b102 --- /dev/null +++ b/src/__tests__/buildListItems.performance.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(), +})); + +vi.mock('../infra/task/branchGitResolver.js', () => ({ + createBranchBaseResolutionCache: vi.fn(() => ({ + allCandidateRefsByRepositoryKey: new Map(), + repositoryKeyByGitCwd: new Map(), + })), + resolveGitCwd: vi.fn((cwd: string, worktreePath?: string) => worktreePath ?? cwd), + resolveBranchBaseCommit: vi.fn((_: string, __: string, branch: string) => `base-${branch}`), + findFirstTaktCommit: vi.fn((_: string, __: string, branch: string) => ({ subject: `takt: instruction-${branch}` })), +})); + +import { execFileSync } from 'node:child_process'; +import { + buildListItems, + type BranchInfo, +} from '../infra/task/branchList.js'; +import { + findFirstTaktCommit, + resolveBranchBaseCommit, +} from '../infra/task/branchGitResolver.js'; + +const mockExecFileSync = vi.mocked(execFileSync); +const mockResolveBranchBaseCommit = vi.mocked(resolveBranchBaseCommit); +const mockFindFirstTaktCommit = vi.mocked(findFirstTaktCommit); + +describe('buildListItems performance', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd === 'git' && args[0] === 'diff') { + return '1\t0\tfile.ts\n'; + } + throw new Error(`Unexpected git args: ${args.join(' ')}`); + }); + }); + + it('should resolve base commit once per branch and reuse it for files/instruction', () => { + const branches: BranchInfo[] = [ + { branch: 'takt/20260128-task-a', commit: 'abc123' }, + { branch: 'takt/20260128-task-b', commit: 'def456' }, + ]; + + const items = buildListItems('/project', branches, 'main'); + + expect(items).toHaveLength(2); + expect(mockResolveBranchBaseCommit).toHaveBeenCalledTimes(2); + expect(mockFindFirstTaktCommit).toHaveBeenNthCalledWith( + 1, + '/project', + 'main', + 'takt/20260128-task-a', + expect.objectContaining({ baseCommit: 'base-takt/20260128-task-a' }), + ); + expect(mockFindFirstTaktCommit).toHaveBeenNthCalledWith( + 2, + '/project', + 'main', + 'takt/20260128-task-b', + expect.objectContaining({ baseCommit: 'base-takt/20260128-task-b' }), + ); + }); +}); diff --git a/src/__tests__/getFilesChanged.test.ts b/src/__tests__/getFilesChanged.test.ts index cf98b59..1b958d8 100644 --- a/src/__tests__/getFilesChanged.test.ts +++ b/src/__tests__/getFilesChanged.test.ts @@ -31,15 +31,50 @@ describe('getFilesChanged', () => { }); it('should infer base from refs when reflog is unavailable', () => { - mockExecFileSync - .mockImplementationOnce(() => { + let developMergeBaseCalls = 0; + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { throw new Error('reflog unavailable'); - }) - .mockReturnValueOnce('develop\n') - .mockReturnValueOnce('base999\n') - .mockReturnValueOnce('1\n') - .mockReturnValueOnce('takt: fix auth\n') - .mockReturnValueOnce('1\t0\tfile1.ts\n'); + } + + if (args[0] === 'merge-base' && args[1] === 'develop') { + developMergeBaseCalls += 1; + if (developMergeBaseCalls === 1) { + throw new Error('priority develop failed'); + } + return 'base999\n'; + } + + if (args[0] === 'merge-base' && args[1] === 'origin/develop') { + throw new Error('priority origin/develop failed'); + } + + if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { + return '.git\n'; + } + + if (args[0] === 'for-each-ref') { + return 'develop\n'; + } + + if (args[0] === 'rev-list') { + return '1\n'; + } + + if (args[0] === 'log' && args[1] === '--format=%s') { + return 'takt: initial\n'; + } + + if (args[0] === 'diff' && args[1] === '--numstat') { + return '1\t0\tfile1.ts\n'; + } + + throw new Error(`Unexpected git args: ${args.join(' ')}`); + }); const result = getFilesChanged('/project', 'develop', 'takt/20260128-fix-auth'); diff --git a/src/__tests__/getOriginalInstruction.test.ts b/src/__tests__/getOriginalInstruction.test.ts index 24ddf09..d85fce2 100644 --- a/src/__tests__/getOriginalInstruction.test.ts +++ b/src/__tests__/getOriginalInstruction.test.ts @@ -40,15 +40,54 @@ describe('getOriginalInstruction', () => { }); it('should infer base from refs when reflog is unavailable', () => { - mockExecFileSync - .mockImplementationOnce(() => { + let developMergeBaseCalls = 0; + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { throw new Error('reflog unavailable'); - }) - .mockReturnValueOnce('develop\n') - .mockReturnValueOnce('base123\n') - .mockReturnValueOnce('2\n') - .mockReturnValueOnce('takt: Initial implementation\nfollow-up\n') - .mockReturnValueOnce('first456\ttakt: Initial implementation\n'); + } + + if (args[0] === 'merge-base' && args[1] === 'main') { + throw new Error('priority main failed'); + } + + if (args[0] === 'merge-base' && args[1] === 'origin/main') { + throw new Error('priority origin/main failed'); + } + + if (args[0] === 'rev-parse' && args[1] === '--git-common-dir') { + return '.git\n'; + } + + if (args[0] === 'for-each-ref') { + return 'develop\n'; + } + + if (args[0] === 'merge-base' && args[1] === 'develop') { + developMergeBaseCalls += 1; + if (developMergeBaseCalls === 1) { + return 'base123\n'; + } + throw new Error('unexpected second develop merge-base'); + } + + if (args[0] === 'rev-list') { + return '2\n'; + } + + if (args[0] === 'log' && args[1] === '--format=%s') { + return 'takt: Initial implementation\nfollow-up\n'; + } + + if (args[0] === 'log' && args[1] === '--format=%H\t%s') { + return 'first456\ttakt: Initial implementation\n'; + } + + throw new Error(`Unexpected git args: ${args.join(' ')}`); + }); const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth'); @@ -67,12 +106,7 @@ describe('getOriginalInstruction', () => { it('should return empty string when no commits on branch', () => { mockExecFileSync - .mockImplementationOnce(() => { - throw new Error('reflog unavailable'); - }) - .mockReturnValueOnce('abc123\n') - .mockReturnValueOnce('') - .mockReturnValueOnce('abc123\n') + .mockReturnValueOnce('last789\nfirst456\nbase123\n') .mockReturnValueOnce(''); const result = getOriginalInstruction('/project', 'main', 'takt/20260128-fix-auth'); diff --git a/src/infra/task/branchBaseCandidateResolver.ts b/src/infra/task/branchBaseCandidateResolver.ts new file mode 100644 index 0000000..7af983b --- /dev/null +++ b/src/infra/task/branchBaseCandidateResolver.ts @@ -0,0 +1,133 @@ +import { createLogger } from '../../shared/utils/index.js'; +import { type BranchBaseResolutionCache, listCandidateRefs } from './branchBaseRefCache.js'; +import { runGit } from './branchGitCommands.js'; + +type BaseRefCandidate = { + baseRef: string; + baseCommit: string; + firstSubject: string; + distance: number; +}; + +const TAKT_COMMIT_PREFIX = 'takt:'; +const log = createLogger('branchGitResolver'); + +export function resolveMergeBase(gitCwd: string, baseRef: string, branch: string): string { + return runGit(gitCwd, ['merge-base', baseRef, branch]); +} + +function buildPriorityRefs(defaultBranch: string, branch: string): string[] { + const refs = [defaultBranch, `origin/${defaultBranch}`]; + const distinctRefs: string[] = []; + for (const ref of refs) { + if (!ref || ref === branch || ref.endsWith(`/${branch}`)) { + continue; + } + if (!distinctRefs.includes(ref)) { + distinctRefs.push(ref); + } + } + + return distinctRefs; +} + +function getFirstParentDistance(gitCwd: string, baseCommit: string, branch: string): number { + const output = runGit(gitCwd, ['rev-list', '--count', '--first-parent', `${baseCommit}..${branch}`]); + return Number.parseInt(output, 10); +} + +function getFirstParentFirstSubject(gitCwd: string, baseCommit: string, branch: string): string { + const output = runGit(gitCwd, ['log', '--format=%s', '--reverse', '--first-parent', `${baseCommit}..${branch}`]); + const firstLine = output.split('\n')[0]; + if (!firstLine) { + return ''; + } + return firstLine.trim(); +} + +function resolveBaseCandidate(gitCwd: string, baseRef: string, branch: string): BaseRefCandidate | null { + try { + const baseCommit = resolveMergeBase(gitCwd, baseRef, branch); + if (!baseCommit) { + return null; + } + + const distance = getFirstParentDistance(gitCwd, baseCommit, branch); + if (!Number.isFinite(distance) || distance <= 0) { + return null; + } + + const firstSubject = getFirstParentFirstSubject(gitCwd, baseCommit, branch); + return { baseRef, baseCommit, firstSubject, distance }; + } catch (error) { + log.debug('Failed to resolve base candidate', { error: String(error), gitCwd, baseRef, branch }); + return null; + } +} + +function chooseBestBaseCandidate(candidates: BaseRefCandidate[]): BaseRefCandidate | null { + if (candidates.length === 0) { + return null; + } + + const sorted = [...candidates].sort((a, b) => { + const aTakt = a.firstSubject.startsWith(TAKT_COMMIT_PREFIX); + const bTakt = b.firstSubject.startsWith(TAKT_COMMIT_PREFIX); + if (aTakt !== bTakt) { + return aTakt ? -1 : 1; + } + + if (a.distance !== b.distance) { + return a.distance - b.distance; + } + + const aRemote = a.baseRef.includes('/'); + const bRemote = b.baseRef.includes('/'); + if (aRemote !== bRemote) { + return aRemote ? 1 : -1; + } + + return a.baseRef.localeCompare(b.baseRef); + }); + + const best = sorted[0]; + return best ? best : null; +} + +export function resolveBranchBaseCommitFromRefs( + gitCwd: string, + defaultBranch: string, + branch: string, + cache?: BranchBaseResolutionCache, +): string | null { + const priorityRefs = buildPriorityRefs(defaultBranch, branch); + const priorityCandidates: BaseRefCandidate[] = []; + + for (const ref of priorityRefs) { + const candidate = resolveBaseCandidate(gitCwd, ref, branch); + if (candidate) { + priorityCandidates.push(candidate); + } + } + + const priorityBest = chooseBestBaseCandidate(priorityCandidates); + if (priorityBest && priorityBest.firstSubject.startsWith(TAKT_COMMIT_PREFIX)) { + return priorityBest.baseCommit; + } + + const refs = listCandidateRefs(gitCwd, branch, cache).filter(ref => !priorityRefs.includes(ref)); + const candidates: BaseRefCandidate[] = [...priorityCandidates]; + + for (const ref of refs) { + const candidate = resolveBaseCandidate(gitCwd, ref, branch); + if (candidate) { + candidates.push(candidate); + } + } + + const best = chooseBestBaseCandidate(candidates); + if (!best) { + return null; + } + return best.baseCommit; +} diff --git a/src/infra/task/branchBaseRefCache.ts b/src/infra/task/branchBaseRefCache.ts new file mode 100644 index 0000000..386c796 --- /dev/null +++ b/src/infra/task/branchBaseRefCache.ts @@ -0,0 +1,56 @@ +import { isAbsolute, resolve } from 'node:path'; +import { runGit } from './branchGitCommands.js'; + +export type BranchBaseResolutionCache = { + allCandidateRefsByRepositoryKey: Map; + repositoryKeyByGitCwd: Map; +}; + +export function createBranchBaseResolutionCache(): BranchBaseResolutionCache { + return { + allCandidateRefsByRepositoryKey: new Map(), + repositoryKeyByGitCwd: new Map(), + }; +} + +function resolveRepositoryKey(gitCwd: string, cache?: BranchBaseResolutionCache): string { + const cachedKey = cache?.repositoryKeyByGitCwd.get(gitCwd); + if (cachedKey) { + return cachedKey; + } + + const commonDir = runGit(gitCwd, ['rev-parse', '--git-common-dir']); + const repositoryKey = isAbsolute(commonDir) ? commonDir : resolve(gitCwd, commonDir); + if (cache) { + cache.repositoryKeyByGitCwd.set(gitCwd, repositoryKey); + } + return repositoryKey; +} + +function listAllCandidateRefs(gitCwd: string, cache?: BranchBaseResolutionCache): string[] { + const repositoryKey = resolveRepositoryKey(gitCwd, cache); + const cachedRefs = cache?.allCandidateRefsByRepositoryKey.get(repositoryKey); + if (cachedRefs) { + return cachedRefs; + } + + const output = runGit(gitCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes']); + const refs = output + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .filter(ref => !ref.endsWith('/HEAD')); + + const distinctRefs = Array.from(new Set(refs)); + if (cache) { + cache.allCandidateRefsByRepositoryKey.set(repositoryKey, distinctRefs); + } + + return distinctRefs; +} + +export function listCandidateRefs(gitCwd: string, branch: string, cache?: BranchBaseResolutionCache): string[] { + return listAllCandidateRefs(gitCwd, cache) + .filter(ref => ref !== branch) + .filter(ref => !ref.endsWith(`/${branch}`)); +} diff --git a/src/infra/task/branchEntryPointResolver.ts b/src/infra/task/branchEntryPointResolver.ts new file mode 100644 index 0000000..0b25e6d --- /dev/null +++ b/src/infra/task/branchEntryPointResolver.ts @@ -0,0 +1,31 @@ +import { createLogger } from '../../shared/utils/index.js'; +import { parseDistinctHashes, runGit } from './branchGitCommands.js'; + +export type BranchEntryPoint = { + baseCommit: string; + firstCommit: string; +}; + +const log = createLogger('branchGitResolver'); + +export function resolveBranchEntryPointFromReflog(gitCwd: string, branch: string): BranchEntryPoint | null { + try { + const output = runGit(gitCwd, ['reflog', 'show', '--format=%H', branch]); + const hashes = parseDistinctHashes(output).reverse(); + if (hashes.length < 2) { + return null; + } + + return { + baseCommit: hashes[0]!, + firstCommit: hashes[1]!, + }; + } catch (error) { + log.debug('Failed to resolve branch entry point from reflog', { error: String(error), gitCwd, branch }); + return null; + } +} + +export function readCommitSubject(gitCwd: string, commit: string): string { + return runGit(gitCwd, ['show', '-s', '--format=%s', commit]); +} diff --git a/src/infra/task/branchGitCommands.ts b/src/infra/task/branchGitCommands.ts new file mode 100644 index 0000000..2c880bc --- /dev/null +++ b/src/infra/task/branchGitCommands.ts @@ -0,0 +1,25 @@ +import { execFileSync } from 'node:child_process'; + +export function runGit(gitCwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd: gitCwd, + encoding: 'utf-8', + stdio: 'pipe', + }).trim(); +} + +export function parseDistinctHashes(output: string): string[] { + const hashes = output + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + + const distinct: string[] = []; + for (const hash of hashes) { + if (distinct[distinct.length - 1] !== hash) { + distinct.push(hash); + } + } + + return distinct; +} diff --git a/src/infra/task/branchGitResolver.ts b/src/infra/task/branchGitResolver.ts index 9b2fc1f..98b0df9 100644 --- a/src/infra/task/branchGitResolver.ts +++ b/src/infra/task/branchGitResolver.ts @@ -1,172 +1,27 @@ -import { execFileSync } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { createLogger } from '../../shared/utils/index.js'; - -type BranchEntryPoint = { - baseCommit: string; - firstCommit: string; -}; +import { runGit } from './branchGitCommands.js'; +import { + type BranchBaseResolutionCache, + createBranchBaseResolutionCache, +} from './branchBaseRefCache.js'; +import { + resolveBranchBaseCommitFromRefs, + resolveMergeBase, +} from './branchBaseCandidateResolver.js'; +import { + readCommitSubject, + resolveBranchEntryPointFromReflog, +} from './branchEntryPointResolver.js'; type FirstTaktCommit = { subject: string; }; -type BaseRefCandidate = { - baseRef: string; - baseCommit: string; - firstSubject: string; - distance: number; +type FindFirstTaktCommitOptions = { + baseCommit?: string; + cache?: BranchBaseResolutionCache; }; -const TAKT_COMMIT_PREFIX = 'takt:'; -const log = createLogger('branchGitResolver'); - -function runGit(gitCwd: string, args: string[]): string { - return execFileSync('git', args, { - cwd: gitCwd, - encoding: 'utf-8', - stdio: 'pipe', - }).trim(); -} - -function parseDistinctHashes(output: string): string[] { - const hashes = output - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0); - - const distinct: string[] = []; - for (const hash of hashes) { - if (distinct[distinct.length - 1] !== hash) { - distinct.push(hash); - } - } - - return distinct; -} - -export function resolveGitCwd(cwd: string, worktreePath?: string): string { - return worktreePath && existsSync(worktreePath) ? worktreePath : cwd; -} - -export function resolveMergeBase(gitCwd: string, baseRef: string, branch: string): string { - return runGit(gitCwd, ['merge-base', baseRef, branch]); -} - -function listCandidateRefs(gitCwd: string, branch: string): string[] { - const output = runGit(gitCwd, [ - 'for-each-ref', - '--format=%(refname:short)', - 'refs/heads', - 'refs/remotes', - ]); - - const refs = output - .split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0) - .filter(ref => ref !== branch) - .filter(ref => !ref.endsWith(`/${branch}`)) - .filter(ref => !ref.endsWith('/HEAD')); - - return Array.from(new Set(refs)); -} - -function getFirstParentDistance(gitCwd: string, baseCommit: string, branch: string): number { - const output = runGit(gitCwd, ['rev-list', '--count', '--first-parent', `${baseCommit}..${branch}`]); - return Number.parseInt(output, 10); -} - -function getFirstParentFirstSubject(gitCwd: string, baseCommit: string, branch: string): string { - const output = runGit(gitCwd, ['log', '--format=%s', '--reverse', '--first-parent', `${baseCommit}..${branch}`]); - return output.split('\n')[0]?.trim() ?? ''; -} - -function resolveBaseCandidate(gitCwd: string, baseRef: string, branch: string): BaseRefCandidate | null { - try { - const baseCommit = resolveMergeBase(gitCwd, baseRef, branch); - if (!baseCommit) { - return null; - } - - const distance = getFirstParentDistance(gitCwd, baseCommit, branch); - if (!Number.isFinite(distance) || distance <= 0) { - return null; - } - - const firstSubject = getFirstParentFirstSubject(gitCwd, baseCommit, branch); - return { baseRef, baseCommit, firstSubject, distance }; - } catch (error) { - log.debug('Failed to resolve base candidate', { error: String(error), gitCwd, baseRef, branch }); - return null; - } -} - -function chooseBestBaseCandidate(candidates: BaseRefCandidate[]): BaseRefCandidate | null { - if (candidates.length === 0) { - return null; - } - - const sorted = [...candidates].sort((a, b) => { - const aTakt = a.firstSubject.startsWith(TAKT_COMMIT_PREFIX); - const bTakt = b.firstSubject.startsWith(TAKT_COMMIT_PREFIX); - if (aTakt !== bTakt) { - return aTakt ? -1 : 1; - } - - if (a.distance !== b.distance) { - return a.distance - b.distance; - } - - const aRemote = a.baseRef.includes('/'); - const bRemote = b.baseRef.includes('/'); - if (aRemote !== bRemote) { - return aRemote ? 1 : -1; - } - - return a.baseRef.localeCompare(b.baseRef); - }); - - return sorted[0] ?? null; -} - -function resolveBranchBaseCommitFromRefs(gitCwd: string, branch: string): string | null { - const refs = listCandidateRefs(gitCwd, branch); - const candidates: BaseRefCandidate[] = []; - - for (const ref of refs) { - const candidate = resolveBaseCandidate(gitCwd, ref, branch); - if (candidate) { - candidates.push(candidate); - } - } - - const best = chooseBestBaseCandidate(candidates); - return best?.baseCommit ?? null; -} - -function resolveBranchEntryPointFromReflog(gitCwd: string, branch: string): BranchEntryPoint | null { - try { - const output = runGit(gitCwd, ['reflog', 'show', '--format=%H', branch]); - const hashes = parseDistinctHashes(output).reverse(); - if (hashes.length < 2) { - return null; - } - - return { - baseCommit: hashes[0]!, - firstCommit: hashes[1]!, - }; - } catch (error) { - log.debug('Failed to resolve branch entry point from reflog', { error: String(error), gitCwd, branch }); - return null; - } -} - -function readCommitSubject(gitCwd: string, commit: string): string { - return runGit(gitCwd, ['show', '-s', '--format=%s', commit]); -} - function parseFirstCommitLine(output: string): FirstTaktCommit | null { if (!output) { return null; @@ -187,20 +42,35 @@ function parseFirstCommitLine(output: string): FirstTaktCommit | null { }; } +export function resolveGitCwd(cwd: string, worktreePath?: string): string { + return worktreePath && existsSync(worktreePath) ? worktreePath : cwd; +} + +export { createBranchBaseResolutionCache, resolveMergeBase }; +export type { BranchBaseResolutionCache }; + export function findFirstTaktCommit( gitCwd: string, defaultBranch: string, branch: string, + options?: FindFirstTaktCommitOptions, ): FirstTaktCommit | null { - const entryPoint = resolveBranchEntryPointFromReflog(gitCwd, branch); - if (entryPoint) { - const subject = readCommitSubject(gitCwd, entryPoint.firstCommit); - return { - subject, - }; + let baseCommit: string; + if (options?.baseCommit) { + baseCommit = options.baseCommit; + } else { + const entryPoint = resolveBranchEntryPointFromReflog(gitCwd, branch); + if (entryPoint) { + const subject = readCommitSubject(gitCwd, entryPoint.firstCommit); + return { + subject, + }; + } + + const resolvedFromRefs = resolveBranchBaseCommitFromRefs(gitCwd, defaultBranch, branch, options?.cache); + baseCommit = resolvedFromRefs ? resolvedFromRefs : resolveMergeBase(gitCwd, defaultBranch, branch); } - const baseCommit = resolveBranchBaseCommitFromRefs(gitCwd, branch) ?? resolveMergeBase(gitCwd, defaultBranch, branch); const output = runGit(gitCwd, [ 'log', '--format=%H\t%s', @@ -213,11 +83,20 @@ export function findFirstTaktCommit( return parseFirstCommitLine(output); } -export function resolveBranchBaseCommit(gitCwd: string, defaultBranch: string, branch: string): string { +export function resolveBranchBaseCommit( + gitCwd: string, + defaultBranch: string, + branch: string, + cache?: BranchBaseResolutionCache, +): string { const entryPoint = resolveBranchEntryPointFromReflog(gitCwd, branch); if (entryPoint) { return entryPoint.baseCommit; } - return resolveBranchBaseCommitFromRefs(gitCwd, branch) ?? resolveMergeBase(gitCwd, defaultBranch, branch); + const baseCommitFromRefs = resolveBranchBaseCommitFromRefs(gitCwd, defaultBranch, branch, cache); + if (baseCommitFromRefs) { + return baseCommitFromRefs; + } + return resolveMergeBase(gitCwd, defaultBranch, branch); } diff --git a/src/infra/task/branchList.ts b/src/infra/task/branchList.ts index 3fe5eef..5b9ec3f 100644 --- a/src/infra/task/branchList.ts +++ b/src/infra/task/branchList.ts @@ -9,9 +9,11 @@ import { execFileSync } from 'node:child_process'; import { createLogger } from '../../shared/utils/index.js'; import { + createBranchBaseResolutionCache, findFirstTaktCommit, resolveBranchBaseCommit, resolveGitCwd, + type BranchBaseResolutionCache, } from './branchGitResolver.js'; import type { BranchInfo, BranchListItem } from './types.js'; @@ -118,18 +120,34 @@ export class BranchManager { } /** Get the number of files changed between a branch and its inferred base commit */ - getFilesChanged(cwd: string, defaultBranch: string, branch: string, worktreePath?: string): number { + getFilesChanged( + cwd: string, + defaultBranch: string, + branch: string, + worktreePath?: string, + baseCommit?: string | null, + cache?: BranchBaseResolutionCache, + ): number { try { const gitCwd = resolveGitCwd(cwd, worktreePath); - const baseCommit = resolveBranchBaseCommit(gitCwd, defaultBranch, branch); - if (!baseCommit) { + let resolvedBaseCommit: string; + if (baseCommit === null) { + throw new Error(`Failed to resolve base commit for branch: ${branch}`); + } + if (baseCommit) { + resolvedBaseCommit = baseCommit; + } else { + resolvedBaseCommit = resolveBranchBaseCommit(gitCwd, defaultBranch, branch, cache); + } + + if (!resolvedBaseCommit) { throw new Error(`Failed to resolve base commit for branch: ${branch}`); } - log.debug('getFilesChanged', { gitCwd, baseCommit, branch, worktreePath }); + log.debug('getFilesChanged', { gitCwd, baseCommit: resolvedBaseCommit, branch, worktreePath }); const output = execFileSync( - 'git', ['diff', '--numstat', `${baseCommit}..${branch}`], + 'git', ['diff', '--numstat', `${resolvedBaseCommit}..${branch}`], { cwd: gitCwd, encoding: 'utf-8', stdio: 'pipe' }, ); @@ -158,9 +176,21 @@ export class BranchManager { cwd: string, defaultBranch: string, branch: string, + baseCommit?: string | null, + cache?: BranchBaseResolutionCache, + worktreePath?: string, ): string { try { - const firstTaktCommit = findFirstTaktCommit(cwd, defaultBranch, branch); + if (baseCommit === null) { + throw new Error(`Failed to resolve base commit for branch: ${branch}`); + } + + const gitCwd = resolveGitCwd(cwd, worktreePath); + const resolvedBaseCommitOption = baseCommit ? baseCommit : undefined; + const firstTaktCommit = findFirstTaktCommit(gitCwd, defaultBranch, branch, { + baseCommit: resolvedBaseCommitOption, + cache, + }); if (firstTaktCommit) { const TAKT_COMMIT_PREFIX = 'takt:'; if (firstTaktCommit.subject.startsWith(TAKT_COMMIT_PREFIX)) { @@ -169,15 +199,17 @@ export class BranchManager { return firstTaktCommit.subject; } - const baseCommit = resolveBranchBaseCommit(cwd, defaultBranch, branch); - if (!baseCommit) { + const resolvedBaseCommit = baseCommit + ? baseCommit + : resolveBranchBaseCommit(gitCwd, defaultBranch, branch, cache); + if (!resolvedBaseCommit) { throw new Error(`Failed to resolve base commit for branch: ${branch}`); } const output = execFileSync( 'git', - ['log', '--format=%s', '--reverse', `${baseCommit}..${branch}`], - { cwd, encoding: 'utf-8', stdio: 'pipe' }, + ['log', '--format=%s', '--reverse', `${resolvedBaseCommit}..${branch}`], + { cwd: gitCwd, encoding: 'utf-8', stdio: 'pipe' }, ).trim(); if (!output) return ''; @@ -201,12 +233,32 @@ export class BranchManager { branches: BranchInfo[], defaultBranch: string, ): BranchListItem[] { - return branches.map(br => ({ - info: br, - filesChanged: this.getFilesChanged(projectDir, defaultBranch, br.branch, br.worktreePath), - taskSlug: BranchManager.extractTaskSlug(br.branch), - originalInstruction: this.getOriginalInstruction(projectDir, defaultBranch, br.branch), - })); + const cache = createBranchBaseResolutionCache(); + + return branches.map(br => { + const gitCwd = resolveGitCwd(projectDir, br.worktreePath); + let baseCommit: string | null = null; + + try { + baseCommit = resolveBranchBaseCommit(gitCwd, defaultBranch, br.branch, cache); + } catch (error) { + log.debug('buildListItems base commit resolution failed', { error: String(error), branch: br.branch, gitCwd }); + } + + return { + info: br, + filesChanged: this.getFilesChanged(projectDir, defaultBranch, br.branch, br.worktreePath, baseCommit, cache), + taskSlug: BranchManager.extractTaskSlug(br.branch), + originalInstruction: this.getOriginalInstruction( + projectDir, + defaultBranch, + br.branch, + baseCommit, + cache, + br.worktreePath, + ), + }; + }); } } From c7305374d7ef6c18a07f568bfd98a920a31803d4 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:30:17 +0900 Subject: [PATCH 09/19] takt: update-category-spec (#184) --- src/__tests__/global-pieceCategories.test.ts | 117 +++++++ src/__tests__/piece-category-config.test.ts | 292 ++++++++++-------- src/__tests__/resetCategories.test.ts | 44 +++ src/__tests__/selectAndExecute-autoPr.test.ts | 56 +++- src/__tests__/switchPiece.test.ts | 90 ++++++ src/features/config/resetCategories.ts | 8 +- src/features/config/switchPiece.ts | 2 +- .../tasks/execute/selectAndExecute.ts | 2 +- src/infra/config/global/index.ts | 1 - src/infra/config/global/pieceCategories.ts | 51 +-- src/infra/config/loaders/pieceCategories.ts | 213 ++++++++++--- 11 files changed, 648 insertions(+), 228 deletions(-) create mode 100644 src/__tests__/global-pieceCategories.test.ts create mode 100644 src/__tests__/resetCategories.test.ts create mode 100644 src/__tests__/switchPiece.test.ts diff --git a/src/__tests__/global-pieceCategories.test.ts b/src/__tests__/global-pieceCategories.test.ts new file mode 100644 index 0000000..286ac22 --- /dev/null +++ b/src/__tests__/global-pieceCategories.test.ts @@ -0,0 +1,117 @@ +/** + * Tests for global piece category path resolution. + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const loadGlobalConfigMock = vi.hoisted(() => vi.fn()); + +vi.mock('../infra/config/paths.js', () => ({ + getGlobalConfigDir: () => '/tmp/.takt', +})); + +vi.mock('../infra/config/global/globalConfig.js', () => ({ + loadGlobalConfig: loadGlobalConfigMock, +})); + +const { getPieceCategoriesPath, resetPieceCategories } = await import( + '../infra/config/global/pieceCategories.js' +); + +function createTempCategoriesPath(): string { + const tempRoot = mkdtempSync(join(tmpdir(), 'takt-piece-categories-')); + return join(tempRoot, 'preferences', 'piece-categories.yaml'); +} + +describe('getPieceCategoriesPath', () => { + beforeEach(() => { + loadGlobalConfigMock.mockReset(); + }); + + it('should return configured path when pieceCategoriesFile is set', () => { + // Given + loadGlobalConfigMock.mockReturnValue({ + pieceCategoriesFile: '/custom/piece-categories.yaml', + }); + + // When + const path = getPieceCategoriesPath(); + + // Then + expect(path).toBe('/custom/piece-categories.yaml'); + }); + + it('should return default path when pieceCategoriesFile is not set', () => { + // Given + loadGlobalConfigMock.mockReturnValue({}); + + // When + const path = getPieceCategoriesPath(); + + // Then + expect(path).toBe('/tmp/.takt/preferences/piece-categories.yaml'); + }); + + it('should rethrow when global config loading fails', () => { + // Given + loadGlobalConfigMock.mockImplementation(() => { + throw new Error('invalid global config'); + }); + + // When / Then + expect(() => getPieceCategoriesPath()).toThrow('invalid global config'); + }); +}); + +describe('resetPieceCategories', () => { + const tempRoots: string[] = []; + + beforeEach(() => { + loadGlobalConfigMock.mockReset(); + }); + + afterEach(() => { + for (const tempRoot of tempRoots) { + rmSync(tempRoot, { recursive: true, force: true }); + } + tempRoots.length = 0; + }); + + it('should create parent directory and initialize with empty user categories', () => { + // Given + const categoriesPath = createTempCategoriesPath(); + tempRoots.push(dirname(dirname(categoriesPath))); + loadGlobalConfigMock.mockReturnValue({ + pieceCategoriesFile: categoriesPath, + }); + + // When + resetPieceCategories(); + + // Then + expect(existsSync(dirname(categoriesPath))).toBe(true); + expect(readFileSync(categoriesPath, 'utf-8')).toBe('piece_categories: {}\n'); + }); + + it('should overwrite existing file with empty user categories', () => { + // Given + const categoriesPath = createTempCategoriesPath(); + const categoriesDir = dirname(categoriesPath); + const tempRoot = dirname(categoriesDir); + tempRoots.push(tempRoot); + loadGlobalConfigMock.mockReturnValue({ + pieceCategoriesFile: categoriesPath, + }); + mkdirSync(categoriesDir, { recursive: true }); + writeFileSync(categoriesPath, 'piece_categories:\n old:\n - stale-piece\n', 'utf-8'); + + // When + resetPieceCategories(); + + // Then + expect(readFileSync(categoriesPath, 'utf-8')).toBe('piece_categories: {}\n'); + }); +}); diff --git a/src/__tests__/piece-category-config.test.ts b/src/__tests__/piece-category-config.test.ts index aa649bb..9bdc3bc 100644 --- a/src/__tests__/piece-category-config.test.ts +++ b/src/__tests__/piece-category-config.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; @@ -19,6 +19,8 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { return { ...original, getLanguage: () => 'en', + getBuiltinPiecesEnabled: () => true, + getDisabledBuiltins: () => [], }; }); @@ -30,9 +32,11 @@ vi.mock('../infra/resources/index.js', async (importOriginal) => { }; }); -vi.mock('../infra/config/global/pieceCategories.js', async () => { +vi.mock('../infra/config/global/pieceCategories.js', async (importOriginal) => { + const original = await importOriginal() as Record; return { - ensureUserCategoriesFile: () => pathsState.userCategoriesPath, + ...original, + getPieceCategoriesPath: () => pathsState.userCategoriesPath, }; }); @@ -74,72 +78,15 @@ describe('piece category config loading', () => { mkdirSync(resourcesDir, { recursive: true }); pathsState.resourcesDir = resourcesDir; + pathsState.userCategoriesPath = join(testDir, 'user-piece-categories.yaml'); }); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); }); - it('should load categories from user file (auto-copied from default)', () => { - const userPath = join(testDir, 'piece-categories.yaml'); - writeYaml(userPath, ` -piece_categories: - Default: - pieces: - - simple -show_others_category: true -others_category_name: "Others" -`); - pathsState.userCategoriesPath = userPath; - + it('should return null when builtin categories file is missing', () => { const config = getPieceCategories(); - expect(config).not.toBeNull(); - expect(config!.pieceCategories).toEqual([ - { name: 'Default', pieces: ['simple'], children: [] }, - ]); - expect(config!.showOthersCategory).toBe(true); - expect(config!.othersCategoryName).toBe('Others'); - }); - - it('should return null when user file has no piece_categories', () => { - const userPath = join(testDir, 'piece-categories.yaml'); - writeYaml(userPath, ` -show_others_category: true -`); - pathsState.userCategoriesPath = userPath; - - const config = getPieceCategories(); - expect(config).toBeNull(); - }); - - it('should parse nested categories from user file', () => { - const userPath = join(testDir, 'piece-categories.yaml'); - writeYaml(userPath, ` -piece_categories: - Parent: - pieces: - - parent-piece - Child: - pieces: - - child-piece -`); - pathsState.userCategoriesPath = userPath; - - const config = getPieceCategories(); - expect(config).not.toBeNull(); - expect(config!.pieceCategories).toEqual([ - { - name: 'Parent', - pieces: ['parent-piece'], - children: [ - { name: 'Child', pieces: ['child-piece'], children: [] }, - ], - }, - ]); - }); - - it('should return null when default categories file is missing', () => { - const config = loadDefaultCategories(); expect(config).toBeNull(); }); @@ -156,19 +103,151 @@ piece_categories: expect(config!.pieceCategories).toEqual([ { name: 'Quick Start', pieces: ['default'], children: [] }, ]); + expect(config!.builtinPieceCategories).toEqual([ + { name: 'Quick Start', pieces: ['default'], children: [] }, + ]); + expect(config!.userPieceCategories).toEqual([]); + }); + + it('should use builtin categories when user overlay file is missing', () => { + writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` +piece_categories: + Main: + pieces: + - default +show_others_category: true +others_category_name: Others +`); + + const config = getPieceCategories(); + expect(config).not.toBeNull(); + expect(config!.pieceCategories).toEqual([ + { name: 'Main', pieces: ['default'], children: [] }, + ]); + expect(config!.userPieceCategories).toEqual([]); + expect(config!.showOthersCategory).toBe(true); + expect(config!.othersCategoryName).toBe('Others'); + }); + + it('should merge user overlay categories with builtin categories', () => { + writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` +piece_categories: + Main: + pieces: + - default + - coding + Child: + pieces: + - nested + Review: + pieces: + - review-only +show_others_category: true +others_category_name: Others +`); + + writeYaml(pathsState.userCategoriesPath, ` +piece_categories: + Main: + pieces: + - custom + My Team: + pieces: + - team-flow +show_others_category: false +others_category_name: Unclassified +`); + + const config = getPieceCategories(); + expect(config).not.toBeNull(); + expect(config!.pieceCategories).toEqual([ + { + name: 'Main', + pieces: ['custom'], + children: [ + { name: 'Child', pieces: ['nested'], children: [] }, + ], + }, + { name: 'Review', pieces: ['review-only'], children: [] }, + { name: 'My Team', pieces: ['team-flow'], children: [] }, + ]); + expect(config!.builtinPieceCategories).toEqual([ + { + name: 'Main', + pieces: ['default', 'coding'], + children: [ + { name: 'Child', pieces: ['nested'], children: [] }, + ], + }, + { name: 'Review', pieces: ['review-only'], children: [] }, + ]); + expect(config!.userPieceCategories).toEqual([ + { name: 'Main', pieces: ['custom'], children: [] }, + { name: 'My Team', pieces: ['team-flow'], children: [] }, + ]); + expect(config!.showOthersCategory).toBe(false); + expect(config!.othersCategoryName).toBe('Unclassified'); + }); + + it('should override others settings without replacing categories when user overlay has no piece_categories', () => { + writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` +piece_categories: + Main: + pieces: + - default + Review: + pieces: + - review-only +show_others_category: true +others_category_name: Others +`); + + writeYaml(pathsState.userCategoriesPath, ` +show_others_category: false +others_category_name: Unclassified +`); + + const config = getPieceCategories(); + expect(config).not.toBeNull(); + expect(config!.pieceCategories).toEqual([ + { name: 'Main', pieces: ['default'], children: [] }, + { name: 'Review', pieces: ['review-only'], children: [] }, + ]); + expect(config!.builtinPieceCategories).toEqual([ + { name: 'Main', pieces: ['default'], children: [] }, + { name: 'Review', pieces: ['review-only'], children: [] }, + ]); + expect(config!.userPieceCategories).toEqual([]); + expect(config!.showOthersCategory).toBe(false); + expect(config!.othersCategoryName).toBe('Unclassified'); }); }); describe('buildCategorizedPieces', () => { - it('should place all pieces (user and builtin) into a unified category tree', () => { + it('should collect missing pieces with source information', () => { const allPieces = createPieceMap([ - { name: 'a', source: 'user' }, - { name: 'b', source: 'user' }, - { name: 'c', source: 'builtin' }, + { name: 'custom', source: 'user' }, + { name: 'nested', source: 'builtin' }, + { name: 'team-flow', source: 'user' }, ]); const config = { pieceCategories: [ - { name: 'Cat', pieces: ['a', 'missing', 'c'], children: [] }, + { + name: 'Main', + pieces: ['custom'], + children: [{ name: 'Child', pieces: ['nested'], children: [] }], + }, + { name: 'My Team', pieces: ['team-flow'], children: [] }, + ], + builtinPieceCategories: [ + { + name: 'Main', + pieces: ['default'], + children: [{ name: 'Child', pieces: ['nested'], children: [] }], + }, + ], + userPieceCategories: [ + { name: 'My Team', pieces: ['missing-user-piece'], children: [] }, ], showOthersCategory: true, othersCategoryName: 'Others', @@ -176,30 +255,19 @@ describe('buildCategorizedPieces', () => { const categorized = buildCategorizedPieces(allPieces, config); expect(categorized.categories).toEqual([ - { name: 'Cat', pieces: ['a', 'c'], children: [] }, - { name: 'Others', pieces: ['b'], children: [] }, + { + name: 'Main', + pieces: ['custom'], + children: [{ name: 'Child', pieces: ['nested'], children: [] }], + }, + { name: 'My Team', pieces: ['team-flow'], children: [] }, ]); expect(categorized.missingPieces).toEqual([ - { categoryPath: ['Cat'], pieceName: 'missing' }, + { categoryPath: ['Main'], pieceName: 'default', source: 'builtin' }, + { categoryPath: ['My Team'], pieceName: 'missing-user-piece', source: 'user' }, ]); }); - it('should skip empty categories', () => { - const allPieces = createPieceMap([ - { name: 'a', source: 'user' }, - ]); - const config = { - pieceCategories: [ - { name: 'Empty', pieces: [], children: [] }, - ], - showOthersCategory: false, - othersCategoryName: 'Others', - }; - - const categorized = buildCategorizedPieces(allPieces, config); - expect(categorized.categories).toEqual([]); - }); - it('should append Others category for uncategorized pieces', () => { const allPieces = createPieceMap([ { name: 'default', source: 'builtin' }, @@ -209,6 +277,10 @@ describe('buildCategorizedPieces', () => { pieceCategories: [ { name: 'Main', pieces: ['default'], children: [] }, ], + builtinPieceCategories: [ + { name: 'Main', pieces: ['default'], children: [] }, + ], + userPieceCategories: [], showOthersCategory: true, othersCategoryName: 'Others', }; @@ -220,28 +292,6 @@ describe('buildCategorizedPieces', () => { ]); }); - it('should merge uncategorized pieces into existing Others category', () => { - const allPieces = createPieceMap([ - { name: 'default', source: 'builtin' }, - { name: 'extra', source: 'builtin' }, - { name: 'user-piece', source: 'user' }, - ]); - const config = { - pieceCategories: [ - { name: 'Main', pieces: ['default'], children: [] }, - { name: 'Others', pieces: ['extra'], children: [] }, - ], - showOthersCategory: true, - othersCategoryName: 'Others', - }; - - const categorized = buildCategorizedPieces(allPieces, config); - expect(categorized.categories).toEqual([ - { name: 'Main', pieces: ['default'], children: [] }, - { name: 'Others', pieces: ['extra', 'user-piece'], children: [] }, - ]); - }); - it('should not append Others when showOthersCategory is false', () => { const allPieces = createPieceMap([ { name: 'default', source: 'builtin' }, @@ -251,6 +301,10 @@ describe('buildCategorizedPieces', () => { pieceCategories: [ { name: 'Main', pieces: ['default'], children: [] }, ], + builtinPieceCategories: [ + { name: 'Main', pieces: ['default'], children: [] }, + ], + userPieceCategories: [], showOthersCategory: false, othersCategoryName: 'Others', }; @@ -286,25 +340,3 @@ describe('buildCategorizedPieces', () => { expect(paths).toEqual(['Parent / Child']); }); }); - -describe('ensureUserCategoriesFile (integration)', () => { - let testDir: string; - - beforeEach(() => { - testDir = join(tmpdir(), `takt-cat-ensure-${randomUUID()}`); - mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - it('should copy default categories to user path when missing', async () => { - // Use real ensureUserCategoriesFile (not mocked) - const { ensureUserCategoriesFile } = await import('../infra/config/global/pieceCategories.js'); - - // This test depends on the mock still being active — just verify the mock returns our path - const result = ensureUserCategoriesFile('/tmp/default.yaml'); - expect(typeof result).toBe('string'); - }); -}); diff --git a/src/__tests__/resetCategories.test.ts b/src/__tests__/resetCategories.test.ts new file mode 100644 index 0000000..6ab7577 --- /dev/null +++ b/src/__tests__/resetCategories.test.ts @@ -0,0 +1,44 @@ +/** + * Tests for reset categories command behavior. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../infra/config/global/pieceCategories.js', () => ({ + resetPieceCategories: vi.fn(), + getPieceCategoriesPath: vi.fn(() => '/tmp/user-piece-categories.yaml'), +})); + +vi.mock('../shared/ui/index.js', () => ({ + header: vi.fn(), + success: vi.fn(), + info: vi.fn(), +})); + +import { resetPieceCategories } from '../infra/config/global/pieceCategories.js'; +import { header, success, info } from '../shared/ui/index.js'; +import { resetCategoriesToDefault } from '../features/config/resetCategories.js'; + +const mockResetPieceCategories = vi.mocked(resetPieceCategories); +const mockHeader = vi.mocked(header); +const mockSuccess = vi.mocked(success); +const mockInfo = vi.mocked(info); + +describe('resetCategoriesToDefault', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should reset user category overlay and show updated message', async () => { + // Given + + // When + await resetCategoriesToDefault(); + + // Then + expect(mockHeader).toHaveBeenCalledWith('Reset Categories'); + expect(mockResetPieceCategories).toHaveBeenCalledTimes(1); + expect(mockSuccess).toHaveBeenCalledWith('User category overlay reset.'); + expect(mockInfo).toHaveBeenCalledWith(' /tmp/user-piece-categories.yaml'); + }); +}); diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index ea3fd47..f4eeb2f 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -58,13 +58,26 @@ vi.mock('../features/pieceSelection/index.js', () => ({ })); import { confirm } from '../shared/prompt/index.js'; +import { + getCurrentPiece, + loadAllPiecesWithSources, + getPieceCategories, + buildCategorizedPieces, +} from '../infra/config/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../infra/task/index.js'; -import { selectAndExecuteTask } from '../features/tasks/execute/selectAndExecute.js'; +import { warnMissingPieces, selectPieceFromCategorizedPieces } from '../features/pieceSelection/index.js'; +import { selectAndExecuteTask, determinePiece } from '../features/tasks/execute/selectAndExecute.js'; const mockConfirm = vi.mocked(confirm); +const mockGetCurrentPiece = vi.mocked(getCurrentPiece); +const mockLoadAllPiecesWithSources = vi.mocked(loadAllPiecesWithSources); +const mockGetPieceCategories = vi.mocked(getPieceCategories); +const mockBuildCategorizedPieces = vi.mocked(buildCategorizedPieces); const mockCreateSharedClone = vi.mocked(createSharedClone); const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush); const mockSummarizeTaskName = vi.mocked(summarizeTaskName); +const mockWarnMissingPieces = vi.mocked(warnMissingPieces); +const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces); beforeEach(() => { vi.clearAllMocks(); @@ -102,4 +115,45 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { expect(autoPrCall).toBeDefined(); expect(autoPrCall![1]).toBe(true); }); + + it('should warn only user-origin missing pieces during interactive selection', async () => { + // Given: category selection is enabled and both builtin/user missing pieces exist + mockGetCurrentPiece.mockReturnValue('default'); + mockLoadAllPiecesWithSources.mockReturnValue(new Map([ + ['default', { + source: 'builtin', + config: { + name: 'default', + movements: [], + initialMovement: 'start', + maxIterations: 1, + }, + }], + ])); + mockGetPieceCategories.mockReturnValue({ + pieceCategories: [], + builtinPieceCategories: [], + userPieceCategories: [], + showOthersCategory: true, + othersCategoryName: 'Others', + }); + mockBuildCategorizedPieces.mockReturnValue({ + categories: [], + allPieces: new Map(), + missingPieces: [ + { categoryPath: ['Quick Start'], pieceName: 'default', source: 'builtin' }, + { categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, + ], + }); + mockSelectPieceFromCategorizedPieces.mockResolvedValue('default'); + + // When + const selected = await determinePiece('/project'); + + // Then + expect(selected).toBe('default'); + expect(mockWarnMissingPieces).toHaveBeenCalledWith([ + { categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, + ]); + }); }); diff --git a/src/__tests__/switchPiece.test.ts b/src/__tests__/switchPiece.test.ts new file mode 100644 index 0000000..5a86524 --- /dev/null +++ b/src/__tests__/switchPiece.test.ts @@ -0,0 +1,90 @@ +/** + * Tests for switchPiece behavior. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../infra/config/index.js', () => ({ + listPieceEntries: vi.fn(() => []), + loadAllPiecesWithSources: vi.fn(() => new Map()), + getPieceCategories: vi.fn(() => null), + buildCategorizedPieces: vi.fn(), + loadPiece: vi.fn(() => null), + getCurrentPiece: vi.fn(() => 'default'), + setCurrentPiece: vi.fn(), +})); + +vi.mock('../features/pieceSelection/index.js', () => ({ + warnMissingPieces: vi.fn(), + selectPieceFromCategorizedPieces: vi.fn(), + selectPieceFromEntries: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})); + +import { + loadAllPiecesWithSources, + getPieceCategories, + buildCategorizedPieces, +} from '../infra/config/index.js'; +import { + warnMissingPieces, + selectPieceFromCategorizedPieces, +} from '../features/pieceSelection/index.js'; +import { switchPiece } from '../features/config/switchPiece.js'; + +const mockLoadAllPiecesWithSources = vi.mocked(loadAllPiecesWithSources); +const mockGetPieceCategories = vi.mocked(getPieceCategories); +const mockBuildCategorizedPieces = vi.mocked(buildCategorizedPieces); +const mockWarnMissingPieces = vi.mocked(warnMissingPieces); +const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces); + +describe('switchPiece', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should warn only user-origin missing pieces during interactive switch', async () => { + // Given + mockLoadAllPiecesWithSources.mockReturnValue(new Map([ + ['default', { + source: 'builtin', + config: { + name: 'default', + movements: [], + initialMovement: 'start', + maxIterations: 1, + }, + }], + ])); + mockGetPieceCategories.mockReturnValue({ + pieceCategories: [], + builtinPieceCategories: [], + userPieceCategories: [], + showOthersCategory: true, + othersCategoryName: 'Others', + }); + mockBuildCategorizedPieces.mockReturnValue({ + categories: [], + allPieces: new Map(), + missingPieces: [ + { categoryPath: ['Quick Start'], pieceName: 'default', source: 'builtin' }, + { categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, + ], + }); + mockSelectPieceFromCategorizedPieces.mockResolvedValue(null); + + // When + const switched = await switchPiece('/project'); + + // Then + expect(switched).toBe(false); + expect(mockWarnMissingPieces).toHaveBeenCalledWith([ + { categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, + ]); + }); +}); diff --git a/src/features/config/resetCategories.ts b/src/features/config/resetCategories.ts index ae15490..369d9cf 100644 --- a/src/features/config/resetCategories.ts +++ b/src/features/config/resetCategories.ts @@ -1,18 +1,16 @@ /** - * Reset piece categories to builtin defaults. + * Reset user piece categories overlay. */ -import { getDefaultCategoriesPath } from '../../infra/config/loaders/pieceCategories.js'; import { resetPieceCategories, getPieceCategoriesPath } from '../../infra/config/global/pieceCategories.js'; import { header, success, info } from '../../shared/ui/index.js'; export async function resetCategoriesToDefault(): Promise { header('Reset Categories'); - const defaultPath = getDefaultCategoriesPath(); - resetPieceCategories(defaultPath); + resetPieceCategories(); const userPath = getPieceCategoriesPath(); - success('Categories reset to builtin defaults.'); + success('User category overlay reset.'); info(` ${userPath}`); } diff --git a/src/features/config/switchPiece.ts b/src/features/config/switchPiece.ts index 7b9206c..5fab782 100644 --- a/src/features/config/switchPiece.ts +++ b/src/features/config/switchPiece.ts @@ -37,7 +37,7 @@ export async function switchPiece(cwd: string, pieceName?: string): Promise missing.source === 'user')); selected = await selectPieceFromCategorizedPieces(categorized, current); } } else { diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 6ac3d23..94a7c52 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -68,7 +68,7 @@ async function selectPiece(cwd: string): Promise { return DEFAULT_PIECE_NAME; } const categorized = buildCategorizedPieces(allPieces, categoryConfig); - warnMissingPieces(categorized.missingPieces); + warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user')); return selectPieceFromCategorizedPieces(categorized, current); } return selectPieceWithDirectoryCategories(cwd); diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index e48ebc5..30b0b93 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -27,7 +27,6 @@ export { export { getPieceCategoriesPath, - ensureUserCategoriesFile, resetPieceCategories, } from './pieceCategories.js'; diff --git a/src/infra/config/global/pieceCategories.ts b/src/infra/config/global/pieceCategories.ts index 576b7bc..b189ab1 100644 --- a/src/infra/config/global/pieceCategories.ts +++ b/src/infra/config/global/pieceCategories.ts @@ -1,67 +1,38 @@ /** * Piece categories file management. * - * The categories file (~/.takt/preferences/piece-categories.yaml) uses the same - * format as the builtin piece-categories.yaml (piece_categories key). - * If the file doesn't exist, it's auto-copied from builtin defaults. + * User category file is treated as overlay on top of builtin categories. */ -import { existsSync, mkdirSync, copyFileSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { getGlobalConfigDir } from '../paths.js'; import { loadGlobalConfig } from './globalConfig.js'; +const INITIAL_USER_CATEGORIES_CONTENT = 'piece_categories: {}\n'; + function getDefaultPieceCategoriesPath(): string { return join(getGlobalConfigDir(), 'preferences', 'piece-categories.yaml'); } /** Get the path to the user's piece categories file. */ export function getPieceCategoriesPath(): string { - try { - const config = loadGlobalConfig(); - if (config.pieceCategoriesFile) { - return config.pieceCategoriesFile; - } - } catch { - // Ignore errors, use default + const config = loadGlobalConfig(); + if (config.pieceCategoriesFile) { + return config.pieceCategoriesFile; } return getDefaultPieceCategoriesPath(); } /** - * Ensure user categories file exists by copying from builtin defaults. - * Returns the path to the user categories file. + * Reset user categories overlay file to initial content. */ -export function ensureUserCategoriesFile(defaultCategoriesPath: string): string { - const userPath = getPieceCategoriesPath(); - if (existsSync(userPath)) { - return userPath; - } - - if (!existsSync(defaultCategoriesPath)) { - throw new Error(`Default categories file not found: ${defaultCategoriesPath}`); - } - - const dir = dirname(userPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - copyFileSync(defaultCategoriesPath, userPath); - return userPath; -} - -/** - * Reset user categories file by overwriting with builtin defaults. - */ -export function resetPieceCategories(defaultCategoriesPath: string): void { - if (!existsSync(defaultCategoriesPath)) { - throw new Error(`Default categories file not found: ${defaultCategoriesPath}`); - } - +export function resetPieceCategories(): void { const userPath = getPieceCategoriesPath(); const dir = dirname(userPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - copyFileSync(defaultCategoriesPath, userPath); + + writeFileSync(userPath, INITIAL_USER_CATEGORIES_CONTENT, 'utf-8'); } diff --git a/src/infra/config/loaders/pieceCategories.ts b/src/infra/config/loaders/pieceCategories.ts index f5669db..6bbd64b 100644 --- a/src/infra/config/loaders/pieceCategories.ts +++ b/src/infra/config/loaders/pieceCategories.ts @@ -1,8 +1,9 @@ /** * Piece category configuration loader and helpers. * - * Categories are loaded from a single source: the user's piece-categories.yaml file. - * If the file doesn't exist, it's auto-copied from builtin defaults. + * Categories are built from 2 layers: + * - builtin base categories (read-only) + * - user overlay categories (optional) */ import { existsSync, readFileSync } from 'node:fs'; @@ -10,7 +11,7 @@ import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod/v4'; import { getLanguage, getBuiltinPiecesEnabled, getDisabledBuiltins } from '../global/globalConfig.js'; -import { ensureUserCategoriesFile } from '../global/pieceCategories.js'; +import { getPieceCategoriesPath } from '../global/pieceCategories.js'; import { getLanguageResourcesDir } from '../../resources/index.js'; import { listBuiltinPieceNames } from './pieceResolver.js'; import type { PieceWithSource } from './pieceResolver.js'; @@ -29,6 +30,8 @@ export interface PieceCategoryNode { export interface CategoryConfig { pieceCategories: PieceCategoryNode[]; + builtinPieceCategories: PieceCategoryNode[]; + userPieceCategories: PieceCategoryNode[]; showOthersCategory: boolean; othersCategoryName: string; } @@ -42,6 +45,7 @@ export interface CategorizedPieces { export interface MissingPiece { categoryPath: string[]; pieceName: string; + source: 'builtin' | 'user'; } interface RawCategoryConfig { @@ -50,6 +54,19 @@ interface RawCategoryConfig { others_category_name?: string; } +interface ParsedCategoryNode { + name: string; + pieces: string[]; + hasPieces: boolean; + children: ParsedCategoryNode[]; +} + +interface ParsedCategoryConfig { + pieceCategories?: ParsedCategoryNode[]; + showOthersCategory?: boolean; + othersCategoryName?: string; +} + function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } @@ -59,6 +76,7 @@ function parsePieces(raw: unknown, sourceLabel: string, path: string[]): string[ if (!Array.isArray(raw)) { throw new Error(`pieces must be an array in ${sourceLabel} at ${path.join(' > ')}`); } + const pieces: string[] = []; for (const item of raw) { if (typeof item !== 'string' || item.trim().length === 0) { @@ -74,13 +92,14 @@ function parseCategoryNode( raw: unknown, sourceLabel: string, path: string[], -): PieceCategoryNode { +): ParsedCategoryNode { if (!isRecord(raw)) { throw new Error(`category "${name}" must be an object in ${sourceLabel} at ${path.join(' > ')}`); } + const hasPieces = Object.prototype.hasOwnProperty.call(raw, 'pieces'); const pieces = parsePieces(raw.pieces, sourceLabel, path); - const children: PieceCategoryNode[] = []; + const children: ParsedCategoryNode[] = []; for (const [key, value] of Object.entries(raw)) { if (key === 'pieces') continue; @@ -90,59 +109,125 @@ function parseCategoryNode( children.push(parseCategoryNode(key, value, sourceLabel, [...path, key])); } - return { name, pieces, children }; + return { name, pieces, hasPieces, children }; } -function parseCategoryTree(raw: unknown, sourceLabel: string): PieceCategoryNode[] { +function parseCategoryTree(raw: unknown, sourceLabel: string): ParsedCategoryNode[] { if (!isRecord(raw)) { throw new Error(`piece_categories must be an object in ${sourceLabel}`); } - const categories: PieceCategoryNode[] = []; + + const categories: ParsedCategoryNode[] = []; for (const [name, value] of Object.entries(raw)) { categories.push(parseCategoryNode(name, value, sourceLabel, [name])); } return categories; } -function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig | null { +function parseCategoryConfig(raw: unknown, sourceLabel: string): ParsedCategoryConfig | null { if (!raw || typeof raw !== 'object') { return null; } - const hasPieceCategories = Object.prototype.hasOwnProperty.call(raw, 'piece_categories'); - if (!hasPieceCategories) { + const parsed = CategoryConfigSchema.parse(raw) as RawCategoryConfig; + const hasPieceCategories = Object.prototype.hasOwnProperty.call(parsed, 'piece_categories'); + + const result: ParsedCategoryConfig = {}; + if (hasPieceCategories) { + if (!parsed.piece_categories) { + throw new Error(`piece_categories must be an object in ${sourceLabel}`); + } + result.pieceCategories = parseCategoryTree(parsed.piece_categories, sourceLabel); + } + + if (parsed.show_others_category !== undefined) { + result.showOthersCategory = parsed.show_others_category; + } + if (parsed.others_category_name !== undefined) { + result.othersCategoryName = parsed.others_category_name; + } + + if ( + result.pieceCategories === undefined + && result.showOthersCategory === undefined + && result.othersCategoryName === undefined + ) { return null; } - const parsed = CategoryConfigSchema.parse(raw) as RawCategoryConfig; - if (!parsed.piece_categories) { - throw new Error(`piece_categories is required in ${sourceLabel}`); - } - - const showOthersCategory = parsed.show_others_category === undefined - ? true - : parsed.show_others_category; - - const othersCategoryName = parsed.others_category_name === undefined - ? 'Others' - : parsed.others_category_name; - - return { - pieceCategories: parseCategoryTree(parsed.piece_categories, sourceLabel), - showOthersCategory, - othersCategoryName, - }; + return result; } -function loadCategoryConfigFromPath(path: string, sourceLabel: string): CategoryConfig | null { +function loadCategoryConfigFromPath(path: string, sourceLabel: string): ParsedCategoryConfig | null { if (!existsSync(path)) { return null; } + const content = readFileSync(path, 'utf-8'); const raw = parseYaml(content); return parseCategoryConfig(raw, sourceLabel); } +function convertParsedNodes(nodes: ParsedCategoryNode[]): PieceCategoryNode[] { + return nodes.map((node) => ({ + name: node.name, + pieces: node.pieces, + children: convertParsedNodes(node.children), + })); +} + +function mergeCategoryNodes(baseNodes: ParsedCategoryNode[], overlayNodes: ParsedCategoryNode[]): ParsedCategoryNode[] { + const overlayByName = new Map(); + for (const overlayNode of overlayNodes) { + overlayByName.set(overlayNode.name, overlayNode); + } + + const merged: ParsedCategoryNode[] = []; + for (const baseNode of baseNodes) { + const overlayNode = overlayByName.get(baseNode.name); + if (!overlayNode) { + merged.push(baseNode); + continue; + } + + overlayByName.delete(baseNode.name); + + const mergedNode: ParsedCategoryNode = { + name: baseNode.name, + pieces: overlayNode.hasPieces ? overlayNode.pieces : baseNode.pieces, + hasPieces: baseNode.hasPieces || overlayNode.hasPieces, + children: mergeCategoryNodes(baseNode.children, overlayNode.children), + }; + merged.push(mergedNode); + } + + for (const overlayNode of overlayByName.values()) { + merged.push(overlayNode); + } + + return merged; +} + +function resolveShowOthersCategory(defaultConfig: ParsedCategoryConfig, userConfig: ParsedCategoryConfig | null): boolean { + if (userConfig?.showOthersCategory !== undefined) { + return userConfig.showOthersCategory; + } + if (defaultConfig.showOthersCategory !== undefined) { + return defaultConfig.showOthersCategory; + } + return true; +} + +function resolveOthersCategoryName(defaultConfig: ParsedCategoryConfig, userConfig: ParsedCategoryConfig | null): string { + if (userConfig?.othersCategoryName !== undefined) { + return userConfig.othersCategoryName; + } + if (defaultConfig.othersCategoryName !== undefined) { + return defaultConfig.othersCategoryName; + } + return 'Others'; +} + /** * Load default categories from builtin resource file. * Returns null if file doesn't exist or has no piece_categories. @@ -150,7 +235,23 @@ function loadCategoryConfigFromPath(path: string, sourceLabel: string): Category export function loadDefaultCategories(): CategoryConfig | null { const lang = getLanguage(); const filePath = join(getLanguageResourcesDir(lang), 'piece-categories.yaml'); - return loadCategoryConfigFromPath(filePath, filePath); + const parsed = loadCategoryConfigFromPath(filePath, filePath); + + if (!parsed?.pieceCategories) { + return null; + } + + const builtinPieceCategories = convertParsedNodes(parsed.pieceCategories); + const showOthersCategory = parsed.showOthersCategory ?? true; + const othersCategoryName = parsed.othersCategoryName ?? 'Others'; + + return { + pieceCategories: builtinPieceCategories, + builtinPieceCategories, + userPieceCategories: [], + showOthersCategory, + othersCategoryName, + }; } /** Get the path to the builtin default categories file. */ @@ -161,28 +262,49 @@ export function getDefaultCategoriesPath(): string { /** * Get effective piece categories configuration. - * Reads from user file (~/.takt/preferences/piece-categories.yaml). - * Auto-copies from builtin defaults if user file doesn't exist. + * Built from builtin categories and optional user overlay. */ export function getPieceCategories(): CategoryConfig | null { const defaultPath = getDefaultCategoriesPath(); - const userPath = ensureUserCategoriesFile(defaultPath); - return loadCategoryConfigFromPath(userPath, userPath); + const defaultConfig = loadCategoryConfigFromPath(defaultPath, defaultPath); + if (!defaultConfig?.pieceCategories) { + return null; + } + + const userPath = getPieceCategoriesPath(); + const userConfig = loadCategoryConfigFromPath(userPath, userPath); + + const merged = userConfig?.pieceCategories + ? mergeCategoryNodes(defaultConfig.pieceCategories, userConfig.pieceCategories) + : defaultConfig.pieceCategories; + + const builtinPieceCategories = convertParsedNodes(defaultConfig.pieceCategories); + const userPieceCategories = convertParsedNodes(userConfig?.pieceCategories ?? []); + + return { + pieceCategories: convertParsedNodes(merged), + builtinPieceCategories, + userPieceCategories, + showOthersCategory: resolveShowOthersCategory(defaultConfig, userConfig), + othersCategoryName: resolveOthersCategoryName(defaultConfig, userConfig), + }; } function collectMissingPieces( categories: PieceCategoryNode[], allPieces: Map, ignorePieces: Set, + source: 'builtin' | 'user', ): MissingPiece[] { const missing: MissingPiece[] = []; + const visit = (nodes: PieceCategoryNode[], path: string[]): void => { for (const node of nodes) { const nextPath = [...path, node.name]; for (const pieceName of node.pieces) { if (ignorePieces.has(pieceName)) continue; if (!allPieces.has(pieceName)) { - missing.push({ categoryPath: nextPath, pieceName }); + missing.push({ categoryPath: nextPath, pieceName, source }); } } if (node.children.length > 0) { @@ -235,7 +357,6 @@ function appendOthersCategory( return categories; } - // If a category with the same name already exists, merge uncategorized pieces into it const existingIndex = categories.findIndex((node) => node.name === othersCategoryName); if (existingIndex >= 0) { const existing = categories[existingIndex]!; @@ -250,8 +371,7 @@ function appendOthersCategory( } /** - * Build categorized pieces map from configuration. - * All pieces (user and builtin) are placed in a single category tree. + * Build categorized pieces map from effective configuration. */ export function buildCategorizedPieces( allPieces: Map, @@ -268,18 +388,13 @@ export function buildCategorizedPieces( } } - const missingPieces = collectMissingPieces( - config.pieceCategories, - allPieces, - ignoreMissing, - ); + const missingPieces = [ + ...collectMissingPieces(config.builtinPieceCategories, allPieces, ignoreMissing, 'builtin'), + ...collectMissingPieces(config.userPieceCategories, allPieces, ignoreMissing, 'user'), + ]; const categorized = new Set(); - const categories = buildCategoryTree( - config.pieceCategories, - allPieces, - categorized, - ); + const categories = buildCategoryTree(config.pieceCategories, allPieces, categorized); const finalCategories = config.showOthersCategory ? appendOthersCategory(categories, allPieces, categorized, config.othersCategoryName) From ec88b9063208b751931217566b7871e5c275f537 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:54:01 +0900 Subject: [PATCH 10/19] takt: github-issue-188 (#190) --- README.md | 1 + .../en/instructions/implement-e2e-test.md | 51 ++++ builtins/en/instructions/plan-e2e-test.md | 11 + builtins/en/piece-categories.yaml | 1 + builtins/en/pieces/e2e-test.yaml | 236 ++++++++++++++++++ .../ja/instructions/implement-e2e-test.md | 51 ++++ builtins/ja/instructions/plan-e2e-test.md | 11 + builtins/ja/piece-categories.yaml | 1 + builtins/ja/pieces/e2e-test.yaml | 236 ++++++++++++++++++ docs/README.ja.md | 1 + src/__tests__/config.test.ts | 7 +- src/__tests__/it-piece-loader.test.ts | 37 ++- src/__tests__/piece-category-config.test.ts | 39 ++- 13 files changed, 674 insertions(+), 9 deletions(-) create mode 100644 builtins/en/instructions/implement-e2e-test.md create mode 100644 builtins/en/instructions/plan-e2e-test.md create mode 100644 builtins/en/pieces/e2e-test.yaml create mode 100644 builtins/ja/instructions/implement-e2e-test.md create mode 100644 builtins/ja/instructions/plan-e2e-test.md create mode 100644 builtins/ja/pieces/e2e-test.yaml diff --git a/README.md b/README.md index 602309b..2964cca 100644 --- a/README.md +++ b/README.md @@ -465,6 +465,7 @@ TAKT includes multiple builtin pieces: | `review-only` | Read-only code review piece that makes no changes. | | `structural-reform` | Full project review and structural reform: iterative codebase restructuring with staged file splits. | | `unit-test` | Unit test focused piece: test analysis → test implementation → review → fix. | +| `e2e-test` | E2E test focused piece: E2E analysis → E2E implementation → review → fix (Vitest-based E2E flow). | **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. diff --git a/builtins/en/instructions/implement-e2e-test.md b/builtins/en/instructions/implement-e2e-test.md new file mode 100644 index 0000000..b7f5c3a --- /dev/null +++ b/builtins/en/instructions/implement-e2e-test.md @@ -0,0 +1,51 @@ +Implement E2E tests according to the test plan. +Refer only to files within the Report Directory shown in the Piece Context. Do not search or reference other report directories. + +**Actions:** +1. Review the test plan report +2. Implement or update tests following existing E2E layout (e.g., `e2e/specs/`) +3. Run E2E tests (minimum: `npm run test:e2e:mock`, and targeted spec runs when needed) +4. If tests fail, analyze root cause, fix test or code, and rerun +5. Confirm related existing tests are not broken + +**Constraints:** +- Keep the current E2E framework (Vitest) unchanged +- Keep one scenario per test and make assertions explicit +- Reuse existing fixtures/helpers/mock strategy for external dependencies + +**Scope output contract (create at the start of implementation):** +```markdown +# Change Scope Declaration + +## Task +{One-line task summary} + +## Planned changes +| Type | File | +|------|------| +| Create | `e2e/specs/example.e2e.ts` | + +## Estimated size +Small / Medium / Large + +## Impact area +- {Affected modules or features} +``` + +**Decisions output contract (at implementation completion, only if decisions were made):** +```markdown +# Decision Log + +## 1. {Decision} +- **Context**: {Why the decision was needed} +- **Options considered**: {List of options} +- **Rationale**: {Reason for the choice} +``` + +**Required output (include headings)** +## Work results +- {Summary of actions taken} +## Changes made +- {Summary of changes} +## Test results +- {Command executed and results} diff --git a/builtins/en/instructions/plan-e2e-test.md b/builtins/en/instructions/plan-e2e-test.md new file mode 100644 index 0000000..94518ee --- /dev/null +++ b/builtins/en/instructions/plan-e2e-test.md @@ -0,0 +1,11 @@ +Analyze the target code and identify missing E2E tests. + +**Note:** If a Previous Response exists, this is a replan due to rejection. +Revise the test plan taking that feedback into account. + +**Actions:** +1. Read target features, implementation, and existing E2E specs (`e2e/specs/**/*.e2e.ts`) to understand behavior +2. Summarize current E2E coverage (happy path, failure path, regression points) +3. Identify missing E2E scenarios with expected outcomes and observability points +4. Specify execution commands (`npm run test:e2e:mock` and, when needed, `npx vitest run e2e/specs/.e2e.ts`) +5. Provide concrete guidance for failure analysis → fix → rerun workflow diff --git a/builtins/en/piece-categories.yaml b/builtins/en/piece-categories.yaml index 8d400a6..ad5a5bc 100644 --- a/builtins/en/piece-categories.yaml +++ b/builtins/en/piece-categories.yaml @@ -11,6 +11,7 @@ piece_categories: - review-fix-minimal - review-only - unit-test + - e2e-test 🎨 Frontend: {} ⚙️ Backend: {} 🔧 Expert: diff --git a/builtins/en/pieces/e2e-test.yaml b/builtins/en/pieces/e2e-test.yaml new file mode 100644 index 0000000..eab791d --- /dev/null +++ b/builtins/en/pieces/e2e-test.yaml @@ -0,0 +1,236 @@ +name: e2e-test +description: E2E test focused piece (E2E analysis → E2E implementation → review → fix) +max_iterations: 20 +initial_movement: plan_test +loop_monitors: + - cycle: + - ai_review + - ai_fix + threshold: 3 + judge: + persona: supervisor + instruction_template: | + The ai_review ↔ ai_fix loop has repeated {cycle_count} times. + + Review the reports from each cycle and determine whether this loop + is healthy (making progress) or unproductive (repeating the same issues). + + **Reports to reference:** + - AI Review results: {report:04-ai-review.md} + + **Judgment criteria:** + - Are new issues being found/fixed in each cycle? + - Are the same findings being repeated? + - Are fixes actually being applied? + rules: + - condition: Healthy (making progress) + next: ai_review + - condition: Unproductive (no improvement) + next: review_test +movements: + - name: plan_test + edit: false + persona: test-planner + policy: testing + knowledge: + - architecture + - backend + allowed_tools: + - Read + - Glob + - Grep + - Bash + - WebSearch + - WebFetch + rules: + - condition: Test plan complete + next: implement_test + - condition: User is asking a question (not an E2E test task) + next: COMPLETE + - condition: Requirements unclear, insufficient info + next: ABORT + appendix: | + Clarifications needed: + - {Question 1} + - {Question 2} + instruction: plan-e2e-test + output_contracts: + report: + - name: 00-test-plan.md + format: test-plan + + - name: implement_test + edit: true + persona: coder + policy: + - coding + - testing + session: refresh + knowledge: + - backend + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + permission_mode: edit + rules: + - condition: Test implementation complete + next: ai_review + - condition: No implementation (report only) + next: ai_review + - condition: Cannot proceed, insufficient info + next: ai_review + - condition: User input required + next: implement_test + requires_user_input: true + interactive_only: true + instruction: implement-e2e-test + output_contracts: + report: + - Scope: 02-coder-scope.md + - Decisions: 03-coder-decisions.md + + - name: ai_review + edit: false + persona: ai-antipattern-reviewer + policy: + - review + - ai-antipattern + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: No AI-specific issues + next: review_test + - condition: AI-specific issues found + next: ai_fix + instruction: ai-review + output_contracts: + report: + - name: 04-ai-review.md + format: ai-review + + - name: ai_fix + edit: true + persona: coder + policy: + - coding + - testing + session: refresh + knowledge: + - backend + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + permission_mode: edit + rules: + - condition: AI issues fixed + next: ai_review + - condition: No fix needed (verified target files/spec) + next: ai_no_fix + - condition: Cannot proceed, insufficient info + next: ai_no_fix + instruction: ai-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: review_test + instruction: arbitrate + + - name: review_test + edit: false + persona: qa-reviewer + policy: + - review + - qa + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + next: supervise + - condition: needs_fix + next: fix + instruction: review-test + output_contracts: + report: + - name: 05-qa-review.md + format: qa-review + + - name: fix + edit: true + persona: coder + policy: + - coding + - testing + session: refresh + knowledge: + - backend + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + permission_mode: edit + rules: + - condition: Fix complete + next: review_test + - condition: Cannot proceed, insufficient info + next: plan_test + instruction: fix + + - name: supervise + edit: false + persona: supervisor + policy: review + allowed_tools: + - Read + - Glob + - Grep + - Bash + - WebSearch + - WebFetch + rules: + - condition: All checks passed + next: COMPLETE + - condition: Requirements unmet, tests failing, build errors + next: plan_test + instruction: supervise + output_contracts: + report: + - Validation: 06-supervisor-validation.md + - Summary: summary.md diff --git a/builtins/ja/instructions/implement-e2e-test.md b/builtins/ja/instructions/implement-e2e-test.md new file mode 100644 index 0000000..fa57076 --- /dev/null +++ b/builtins/ja/instructions/implement-e2e-test.md @@ -0,0 +1,51 @@ +テスト計画に従ってE2Eテストを実装してください。 +Piece Contextに示されたReport Directory内のファイルのみ参照してください。他のレポートディレクトリは検索/参照しないでください。 + +**やること:** +1. テスト計画のレポートを確認する +2. `e2e/specs/` など既存E2E配置に従ってテストを実装・更新する +3. E2Eテストを実行する(最低: `npm run test:e2e:mock`、必要に応じて対象specの単体実行) +4. 失敗時は原因を特定し、テストまたは対象コードを修正して再実行する +5. 既存の関連テストが壊れていないことを確認する + +**実装の制約:** +- 既存のE2Eフレームワーク(Vitest)を変更しない +- テストは1シナリオ1関心で記述し、期待結果を明確にする +- 外部依存があるケースは既存のfixture/helper/mock方針に合わせる + +**Scope出力契約(実装開始時に作成):** +```markdown +# 変更スコープ宣言 + +## タスク +{タスクの1行要約} + +## 変更予定 +| 種別 | ファイル | +|------|---------| +| 作成 | `e2e/specs/example.e2e.ts` | + +## 推定規模 +Small / Medium / Large + +## 影響範囲 +- {影響するモジュールや機能} +``` + +**Decisions出力契約(実装完了時、決定がある場合のみ):** +```markdown +# 決定ログ + +## 1. {決定内容} +- **背景**: {なぜ決定が必要だったか} +- **検討した選択肢**: {選択肢リスト} +- **理由**: {選んだ理由} +``` + +**必須出力(見出しを含める)** +## 作業結果 +- {実施内容の要約} +## 変更内容 +- {変更内容の要約} +## テスト結果 +- {実行コマンドと結果} diff --git a/builtins/ja/instructions/plan-e2e-test.md b/builtins/ja/instructions/plan-e2e-test.md new file mode 100644 index 0000000..57bec6f --- /dev/null +++ b/builtins/ja/instructions/plan-e2e-test.md @@ -0,0 +1,11 @@ +対象コードを分析し、不足しているE2Eテストを洗い出してください。 + +**注意:** Previous Responseがある場合は差し戻しのため、 +その内容を踏まえてテスト計画を見直してください。 + +**やること:** +1. 対象機能の仕様・実装・既存E2Eテスト(`e2e/specs/**/*.e2e.ts`)を読み、振る舞いを理解する +2. 既存E2Eテストのカバー範囲(正常系・異常系・回帰観点)を整理する +3. 不足しているE2Eケースを洗い出す(シナリオ、期待結果、失敗時の観測点) +4. 実行コマンドを明記する(`npm run test:e2e:mock` / 必要時 `npx vitest run e2e/specs/.e2e.ts`) +5. 実装者向けに、失敗解析→修正→再実行の手順を具体化する diff --git a/builtins/ja/piece-categories.yaml b/builtins/ja/piece-categories.yaml index f1dbf8c..28721f6 100644 --- a/builtins/ja/piece-categories.yaml +++ b/builtins/ja/piece-categories.yaml @@ -11,6 +11,7 @@ piece_categories: - review-fix-minimal - review-only - unit-test + - e2e-test 🎨 フロントエンド: {} ⚙️ バックエンド: {} 🔧 エキスパート: diff --git a/builtins/ja/pieces/e2e-test.yaml b/builtins/ja/pieces/e2e-test.yaml new file mode 100644 index 0000000..1ce0262 --- /dev/null +++ b/builtins/ja/pieces/e2e-test.yaml @@ -0,0 +1,236 @@ +name: e2e-test +description: E2Eテスト追加に特化したピース(E2E分析→E2E実装→レビュー→修正) +max_iterations: 20 +initial_movement: plan_test +loop_monitors: + - cycle: + - ai_review + - ai_fix + threshold: 3 + judge: + persona: supervisor + instruction_template: | + ai_review と ai_fix のループが {cycle_count} 回繰り返されました。 + + 各サイクルのレポートを確認し、このループが健全(進捗がある)か、 + 非生産的(同じ問題を繰り返している)かを判断してください。 + + **参照するレポート:** + - AIレビュー結果: {report:04-ai-review.md} + + **判断基準:** + - 各サイクルで新しい問題が発見・修正されているか + - 同じ指摘が繰り返されていないか + - 修正が実際に反映されているか + rules: + - condition: 健全(進捗あり) + next: ai_review + - condition: 非生産的(改善なし) + next: review_test +movements: + - name: plan_test + edit: false + persona: test-planner + policy: testing + knowledge: + - architecture + - backend + allowed_tools: + - Read + - Glob + - Grep + - Bash + - WebSearch + - WebFetch + rules: + - condition: テスト計画が完了 + next: implement_test + - condition: ユーザーが質問をしている(E2Eテスト追加タスクではない) + next: COMPLETE + - condition: 要件が不明確、情報不足 + next: ABORT + appendix: | + 確認事項: + - {質問1} + - {質問2} + instruction: plan-e2e-test + output_contracts: + report: + - name: 00-test-plan.md + format: test-plan + + - name: implement_test + edit: true + persona: coder + policy: + - coding + - testing + session: refresh + knowledge: + - backend + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + permission_mode: edit + rules: + - condition: テスト実装完了 + next: ai_review + - condition: 実装未着手(レポートのみ) + next: ai_review + - condition: 判断できない、情報不足 + next: ai_review + - condition: ユーザー入力が必要 + next: implement_test + requires_user_input: true + interactive_only: true + instruction: implement-e2e-test + output_contracts: + report: + - Scope: 02-coder-scope.md + - Decisions: 03-coder-decisions.md + + - name: ai_review + edit: false + persona: ai-antipattern-reviewer + policy: + - review + - ai-antipattern + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: AI特有の問題なし + next: review_test + - condition: AI特有の問題あり + next: ai_fix + instruction: ai-review + output_contracts: + report: + - name: 04-ai-review.md + format: ai-review + + - name: ai_fix + edit: true + persona: coder + policy: + - coding + - testing + session: refresh + knowledge: + - backend + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + permission_mode: edit + rules: + - condition: AI問題の修正完了 + next: ai_review + - condition: 修正不要(指摘対象ファイル/仕様の確認済み) + next: ai_no_fix + - condition: 判断できない、情報不足 + next: ai_no_fix + instruction: ai-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: review_test + instruction: arbitrate + + - name: review_test + edit: false + persona: qa-reviewer + policy: + - review + - qa + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + next: supervise + - condition: needs_fix + next: fix + instruction: review-test + output_contracts: + report: + - name: 05-qa-review.md + format: qa-review + + - name: fix + edit: true + persona: coder + policy: + - coding + - testing + session: refresh + knowledge: + - backend + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + permission_mode: edit + rules: + - condition: 修正完了 + next: review_test + - condition: 判断できない、情報不足 + next: plan_test + instruction: fix + + - name: supervise + edit: false + persona: supervisor + policy: review + allowed_tools: + - Read + - Glob + - Grep + - Bash + - WebSearch + - WebFetch + rules: + - condition: すべて問題なし + next: COMPLETE + - condition: 要求未達成、テスト失敗、ビルドエラー + next: plan_test + instruction: supervise + output_contracts: + report: + - Validation: 06-supervisor-validation.md + - Summary: summary.md diff --git a/docs/README.ja.md b/docs/README.ja.md index e492d77..c9b689c 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -465,6 +465,7 @@ TAKTには複数のビルトインピースが同梱されています: | `review-only` | 変更を加えない読み取り専用のコードレビューピース。 | | `structural-reform` | プロジェクト全体の構造改革: 段階的なファイル分割を伴う反復的なコードベース再構成。 | | `unit-test` | ユニットテスト重視ピース: テスト分析 → テスト実装 → レビュー → 修正。 | +| `e2e-test` | E2Eテスト重視ピース: E2E分析 → E2E実装 → レビュー → 修正(VitestベースのE2Eフロー)。 | **ペルソナ別プロバイダー設定:** 設定ファイルの `persona_providers` で、特定のペルソナを異なるプロバイダーにルーティングできます(例: coder は Codex、レビュアーは Claude)。ピースを複製する必要はありません。 diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index ea73552..e628d92 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -217,7 +217,7 @@ describe('loadPiece (builtin fallback)', () => { expect(piece).toBeNull(); }); - it('should load builtin pieces like minimal, research', () => { + it('should load builtin pieces like minimal, research, e2e-test', () => { const minimal = loadPiece('minimal', process.cwd()); expect(minimal).not.toBeNull(); expect(minimal!.name).toBe('minimal'); @@ -225,6 +225,10 @@ describe('loadPiece (builtin fallback)', () => { const research = loadPiece('research', process.cwd()); expect(research).not.toBeNull(); expect(research!.name).toBe('research'); + + const e2eTest = loadPiece('e2e-test', process.cwd()); + expect(e2eTest).not.toBeNull(); + expect(e2eTest!.name).toBe('e2e-test'); }); }); @@ -246,6 +250,7 @@ describe('listPieces (builtin fallback)', () => { const pieces = listPieces(testDir); expect(pieces).toContain('default'); expect(pieces).toContain('minimal'); + expect(pieces).toContain('e2e-test'); }); it('should return sorted list', () => { diff --git a/src/__tests__/it-piece-loader.test.ts b/src/__tests__/it-piece-loader.test.ts index 5037bc3..de34adb 100644 --- a/src/__tests__/it-piece-loader.test.ts +++ b/src/__tests__/it-piece-loader.test.ts @@ -14,10 +14,11 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; // --- Mocks --- +const languageState = vi.hoisted(() => ({ value: 'en' as 'en' | 'ja' })); vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), - getLanguage: vi.fn().mockReturnValue('en'), + getLanguage: vi.fn(() => languageState.value), getDisabledBuiltins: vi.fn().mockReturnValue([]), getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), })); @@ -40,6 +41,7 @@ describe('Piece Loader IT: builtin piece loading', () => { beforeEach(() => { testDir = createTestDir(); + languageState.value = 'en'; }); afterEach(() => { @@ -64,6 +66,39 @@ describe('Piece Loader IT: builtin piece loading', () => { const config = loadPiece('non-existent-piece-xyz', testDir); expect(config).toBeNull(); }); + + it('should include and load e2e-test as a builtin piece', () => { + expect(builtinNames).toContain('e2e-test'); + + const config = loadPiece('e2e-test', testDir); + expect(config).not.toBeNull(); + + const planMovement = config!.movements.find((movement) => movement.name === 'plan_test'); + const implementMovement = config!.movements.find((movement) => movement.name === 'implement_test'); + + expect(planMovement).toBeDefined(); + expect(implementMovement).toBeDefined(); + expect(planMovement!.instructionTemplate).toContain('missing E2E tests'); + expect(implementMovement!.instructionTemplate).toContain('npm run test:e2e:mock'); + }); + + it('should load e2e-test as a builtin piece in ja locale', () => { + languageState.value = 'ja'; + + const jaBuiltinNames = listBuiltinPieceNames({ includeDisabled: true }); + expect(jaBuiltinNames).toContain('e2e-test'); + + const config = loadPiece('e2e-test', testDir); + expect(config).not.toBeNull(); + + const planMovement = config!.movements.find((movement) => movement.name === 'plan_test'); + const implementMovement = config!.movements.find((movement) => movement.name === 'implement_test'); + + expect(planMovement).toBeDefined(); + expect(implementMovement).toBeDefined(); + expect(planMovement!.instructionTemplate).toContain('E2Eテスト'); + expect(implementMovement!.instructionTemplate).toContain('npm run test:e2e:mock'); + }); }); describe('Piece Loader IT: project-local piece override', () => { diff --git a/src/__tests__/piece-category-config.test.ts b/src/__tests__/piece-category-config.test.ts index 9bdc3bc..e89edc4 100644 --- a/src/__tests__/piece-category-config.test.ts +++ b/src/__tests__/piece-category-config.test.ts @@ -9,8 +9,12 @@ import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import type { PieceWithSource } from '../infra/config/index.js'; +const languageState = vi.hoisted(() => ({ + value: 'en' as 'en' | 'ja', +})); + const pathsState = vi.hoisted(() => ({ - resourcesDir: '', + resourcesRoot: '', userCategoriesPath: '', })); @@ -18,7 +22,7 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { const original = await importOriginal() as Record; return { ...original, - getLanguage: () => 'en', + getLanguage: () => languageState.value, getBuiltinPiecesEnabled: () => true, getDisabledBuiltins: () => [], }; @@ -28,7 +32,7 @@ vi.mock('../infra/resources/index.js', async (importOriginal) => { const original = await importOriginal() as Record; return { ...original, - getLanguageResourcesDir: () => pathsState.resourcesDir, + getLanguageResourcesDir: (lang: string) => join(pathsState.resourcesRoot, lang), }; }); @@ -74,10 +78,12 @@ describe('piece category config loading', () => { beforeEach(() => { testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`); - resourcesDir = join(testDir, 'resources'); + resourcesDir = join(testDir, 'resources', 'en'); mkdirSync(resourcesDir, { recursive: true }); - pathsState.resourcesDir = resourcesDir; + mkdirSync(join(testDir, 'resources', 'ja'), { recursive: true }); + pathsState.resourcesRoot = join(testDir, 'resources'); + languageState.value = 'en'; pathsState.userCategoriesPath = join(testDir, 'user-piece-categories.yaml'); }); @@ -142,6 +148,7 @@ piece_categories: Review: pieces: - review-only + - e2e-test show_others_category: true others_category_name: Others `); @@ -168,7 +175,7 @@ others_category_name: Unclassified { name: 'Child', pieces: ['nested'], children: [] }, ], }, - { name: 'Review', pieces: ['review-only'], children: [] }, + { name: 'Review', pieces: ['review-only', 'e2e-test'], children: [] }, { name: 'My Team', pieces: ['team-flow'], children: [] }, ]); expect(config!.builtinPieceCategories).toEqual([ @@ -179,7 +186,7 @@ others_category_name: Unclassified { name: 'Child', pieces: ['nested'], children: [] }, ], }, - { name: 'Review', pieces: ['review-only'], children: [] }, + { name: 'Review', pieces: ['review-only', 'e2e-test'], children: [] }, ]); expect(config!.userPieceCategories).toEqual([ { name: 'Main', pieces: ['custom'], children: [] }, @@ -189,6 +196,24 @@ others_category_name: Unclassified expect(config!.othersCategoryName).toBe('Unclassified'); }); + it('should load ja builtin categories and include e2e-test under レビュー', () => { + languageState.value = 'ja'; + + writeYaml(join(testDir, 'resources', 'ja', 'piece-categories.yaml'), ` +piece_categories: + レビュー: + pieces: + - review-only + - e2e-test +`); + + const config = getPieceCategories(); + expect(config).not.toBeNull(); + expect(config!.pieceCategories).toEqual([ + { name: 'レビュー', pieces: ['review-only', 'e2e-test'], children: [] }, + ]); + }); + it('should override others settings without replacing categories when user overlay has no piece_categories', () => { writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` piece_categories: From 28392b113a98dc8395e72499ab302c61dc973170 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 05:46:33 +0900 Subject: [PATCH 11/19] =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=E3=81=AE=E3=81=9F=E3=82=81=E3=81=AB=E3=83=9D=E3=83=AA?= =?UTF-8?q?=E3=82=B7=E3=83=BC=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- builtins/en/policies/review.md | 30 ++++++++++++++++++++++++++++++ builtins/ja/policies/review.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/builtins/en/policies/review.md b/builtins/en/policies/review.md index 3d36f1f..4908fec 100644 --- a/builtins/en/policies/review.md +++ b/builtins/en/policies/review.md @@ -98,6 +98,36 @@ To prevent circular rejections, track findings by ID. - Issues without `finding_id` are invalid (cannot be used as rejection grounds) - REJECT is allowed only when there is at least one `new` or `persists` issue +## Reopen Conditions (`resolved` -> open) + +Reopening a resolved finding requires reproducible evidence. + +- To reopen a previously `resolved` finding, all of the following are required + 1. Reproduction steps (command/input) + 2. Expected result vs. actual result + 3. Failing file/line evidence +- If any of the three is missing, the reopen attempt is invalid (cannot be used as REJECT grounds) +- If reproduction conditions changed, treat it as a different problem and issue a new `finding_id` + +## Immutable Meaning of `finding_id` + +Do not mix different problems under the same ID. + +- A `finding_id` must refer to one and only one problem +- If problem meaning, evidence files, or reproduction conditions change, issue a new `finding_id` +- Rewriting an existing `finding_id` to represent a different problem is prohibited + +## Handling Test File Size and Duplication + +Test file length and duplication are warning-level maintainability concerns by default. + +- Excessive test file length and duplicated test setup are `Warning` by default +- They may be `REJECT` only when reproducible harm is shown + - flaky behavior + - false positives/false negatives + - inability to detect regressions +- "Too long" or "duplicated" alone is not sufficient for `REJECT` + ## Boy Scout Rule Leave it better than you found it. diff --git a/builtins/ja/policies/review.md b/builtins/ja/policies/review.md index 77d91b8..1abdfdb 100644 --- a/builtins/ja/policies/review.md +++ b/builtins/ja/policies/review.md @@ -98,6 +98,36 @@ - `finding_id` のない指摘は無効(判定根拠として扱わない) - REJECTは `new` または `persists` の問題が1件以上ある場合のみ許可する +## 再オープン条件(resolved → open) + +解消済み指摘を再オープンする場合は、再現可能な根拠を必須とする。 + +- 前回 `resolved` の指摘を再オープンする場合、以下3点を必須で提示する + 1. 再現手順(コマンド/入力) + 2. 期待結果と実結果 + 3. 失敗箇所のファイル/行 +- 上記3点が欠ける再オープンは無効(REJECT根拠に使わない) +- 再現手順が変わる場合は別問題として新規 `finding_id` を発行する + +## finding_id の意味固定 + +同じ ID に別問題を混在させない。 + +- 同一 `finding_id` は同一問題にのみ使用する +- 問題の意味・根拠ファイル・再現条件が変わる場合は新規 `finding_id` を発行する +- 同一 `finding_id` の説明を後から別問題に差し替えることを禁止する + +## テストファイルの扱い + +テストファイルの長さや重複は、原則として保守性の警告として扱う。 + +- テストファイルの行数超過・重複コードは原則 `Warning` +- 以下の実害が再現できる場合のみ `REJECT` 可能 + - テスト不安定化(フレーク) + - 誤検知/検知漏れ + - 回帰検出不能 +- 「長すぎる」「重複がある」だけでは `REJECT` しない + ## ボーイスカウトルール 来たときよりも美しく。 From 01459280617e2e76925db01bdaff59b24cd31f74 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 06:10:15 +0900 Subject: [PATCH 12/19] =?UTF-8?q?Ctrl+C=E5=91=A8=E3=82=8A=E3=81=AE?= =?UTF-8?q?=E6=8C=99=E5=8B=95=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/it-sigint-worker-pool.test.ts | 180 ++++++++++++++++++ src/__tests__/runAllTasks-concurrency.test.ts | 6 +- src/__tests__/taskExecution.test.ts | 20 ++ src/__tests__/workerPool.test.ts | 49 ++++- .../tasks/execute/parallelExecution.ts | 25 ++- src/features/tasks/execute/resolveTask.ts | 12 ++ src/features/tasks/execute/taskExecution.ts | 2 +- 7 files changed, 278 insertions(+), 16 deletions(-) create mode 100644 src/__tests__/it-sigint-worker-pool.test.ts diff --git a/src/__tests__/it-sigint-worker-pool.test.ts b/src/__tests__/it-sigint-worker-pool.test.ts new file mode 100644 index 0000000..f3831c4 --- /dev/null +++ b/src/__tests__/it-sigint-worker-pool.test.ts @@ -0,0 +1,180 @@ +/** + * Integration test: SIGINT abort signal propagation in worker pool. + * + * Verifies that: + * - AbortSignal is passed to tasks even when concurrency=1 (sequential mode) + * - Aborting the controller causes the signal to fire, enabling task interruption + * - The SIGINT handler in parallelExecution correctly aborts the controller + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { TaskInfo } from '../infra/task/index.js'; + +vi.mock('../shared/ui/index.js', () => ({ + header: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + status: vi.fn(), + blankLine: vi.fn(), +})); + +vi.mock('../shared/exitCodes.js', () => ({ + EXIT_SIGINT: 130, +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((key: string) => key), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +const mockExecuteAndCompleteTask = vi.fn(); + +vi.mock('../features/tasks/execute/taskExecution.js', () => ({ + executeAndCompleteTask: (...args: unknown[]) => mockExecuteAndCompleteTask(...args), +})); + +import { runWithWorkerPool } from '../features/tasks/execute/parallelExecution.js'; + +function createTask(name: string): TaskInfo { + return { + name, + content: `Task: ${name}`, + filePath: `/tasks/${name}.yaml`, + }; +} + +function createMockTaskRunner() { + return { + getNextTask: vi.fn(() => null), + claimNextTasks: vi.fn(() => []), + completeTask: vi.fn(), + failTask: vi.fn(), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockExecuteAndCompleteTask.mockResolvedValue(true); +}); + +describe('worker pool: abort signal propagation', () => { + let savedSigintListeners: ((...args: unknown[]) => void)[]; + + beforeEach(() => { + savedSigintListeners = process.rawListeners('SIGINT') as ((...args: unknown[]) => void)[]; + }); + + afterEach(() => { + process.removeAllListeners('SIGINT'); + for (const listener of savedSigintListeners) { + process.on('SIGINT', listener as NodeJS.SignalsListener); + } + }); + + it('should pass abortSignal to tasks in sequential mode (concurrency=1)', async () => { + // Given + const tasks = [createTask('task-1')]; + const runner = createMockTaskRunner(); + const receivedSignals: (AbortSignal | undefined)[] = []; + + mockExecuteAndCompleteTask.mockImplementation( + (_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => { + receivedSignals.push(parallelOpts?.abortSignal); + return Promise.resolve(true); + }, + ); + + // When + await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, 50); + + // Then: AbortSignal is passed even with concurrency=1 + expect(receivedSignals).toHaveLength(1); + expect(receivedSignals[0]).toBeInstanceOf(AbortSignal); + }); + + it('should abort the signal when SIGINT fires in sequential mode', async () => { + // Given + const tasks = [createTask('long-task')]; + const runner = createMockTaskRunner(); + let capturedSignal: AbortSignal | undefined; + + mockExecuteAndCompleteTask.mockImplementation( + (_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => { + capturedSignal = parallelOpts?.abortSignal; + return new Promise((resolve) => { + // Wait long enough for SIGINT to fire + setTimeout(() => resolve(true), 200); + }); + }, + ); + + // Start execution + const resultPromise = runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, 50); + + // Wait for task to start + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Find the SIGINT handler added by runWithWorkerPool + const allListeners = process.rawListeners('SIGINT') as ((...args: unknown[]) => void)[]; + const newListener = allListeners.find((l) => !savedSigintListeners.includes(l)); + expect(newListener).toBeDefined(); + + // Simulate SIGINT + newListener!(); + + // Wait for execution to complete + await resultPromise; + + // Then: The abort signal should have been triggered + expect(capturedSignal).toBeInstanceOf(AbortSignal); + expect(capturedSignal!.aborted).toBe(true); + }); + + it('should share the same AbortSignal across sequential and parallel tasks', async () => { + // Given: Multiple tasks in both sequential (concurrency=1) and parallel (concurrency=2) + const tasks = [createTask('t1'), createTask('t2')]; + const runner = createMockTaskRunner(); + + const receivedSignalsSeq: (AbortSignal | undefined)[] = []; + const receivedSignalsPar: (AbortSignal | undefined)[] = []; + + mockExecuteAndCompleteTask.mockImplementation( + (_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => { + receivedSignalsSeq.push(parallelOpts?.abortSignal); + return Promise.resolve(true); + }, + ); + + // Sequential mode + await runWithWorkerPool(runner as never, [...tasks], 1, '/cwd', 'default', undefined, 50); + + mockExecuteAndCompleteTask.mockClear(); + mockExecuteAndCompleteTask.mockImplementation( + (_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => { + receivedSignalsPar.push(parallelOpts?.abortSignal); + return Promise.resolve(true); + }, + ); + + // Parallel mode + await runWithWorkerPool(runner as never, [...tasks], 2, '/cwd', 'default', undefined, 50); + + // Then: Both modes pass AbortSignal + for (const signal of receivedSignalsSeq) { + expect(signal).toBeInstanceOf(AbortSignal); + } + for (const signal of receivedSignalsPar) { + expect(signal).toBeInstanceOf(AbortSignal); + } + }); +}); diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 2ea5f30..677e786 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -449,7 +449,7 @@ describe('runAllTasks concurrency', () => { expect(pieceOptions).toHaveProperty('taskPrefix', 'parallel-task'); }); - it('should not pass abortSignal or taskPrefix in sequential mode', async () => { + it('should pass abortSignal but not taskPrefix in sequential mode', async () => { // Given: Sequential mode mockLoadGlobalConfig.mockReturnValue({ language: 'en', @@ -470,11 +470,11 @@ describe('runAllTasks concurrency', () => { // When await runAllTasks('/project'); - // Then: executePiece should not have abortSignal or taskPrefix + // Then: executePiece should have abortSignal but not taskPrefix expect(mockExecutePiece).toHaveBeenCalledTimes(1); const callArgs = mockExecutePiece.mock.calls[0]; const pieceOptions = callArgs?.[3]; - expect(pieceOptions?.abortSignal).toBeUndefined(); + expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal); expect(pieceOptions?.taskPrefix).toBeUndefined(); }); }); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 023e317..5d5eb24 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -491,4 +491,24 @@ describe('resolveTaskExecution', () => { // Then expect(result.issueNumber).toBeUndefined(); }); + + it('should not start clone creation when abortSignal is already aborted', async () => { + // Given: Worktree task with pre-aborted signal + const task: TaskInfo = { + name: 'aborted-before-clone', + content: 'Task content', + filePath: '/tasks/task.yaml', + data: { + task: 'Task content', + worktree: true, + }, + }; + const controller = new AbortController(); + controller.abort(); + + // When / Then + await expect(resolveTaskExecution(task, '/project', 'default', controller.signal)).rejects.toThrow('Task execution aborted'); + expect(mockSummarizeTaskName).not.toHaveBeenCalled(); + expect(mockCreateSharedClone).not.toHaveBeenCalled(); + }); }); diff --git a/src/__tests__/workerPool.test.ts b/src/__tests__/workerPool.test.ts index c1b2896..7254ab8 100644 --- a/src/__tests__/workerPool.test.ts +++ b/src/__tests__/workerPool.test.ts @@ -142,7 +142,7 @@ describe('runWithWorkerPool', () => { }); }); - it('should not pass taskPrefix or abortSignal for sequential execution (concurrency = 1)', async () => { + it('should pass abortSignal but not taskPrefix for sequential execution (concurrency = 1)', async () => { // Given const tasks = [createTask('seq-task')]; const runner = createMockTaskRunner([]); @@ -154,7 +154,7 @@ describe('runWithWorkerPool', () => { expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; expect(parallelOpts).toEqual({ - abortSignal: undefined, + abortSignal: expect.any(AbortSignal), taskPrefix: undefined, taskColorIndex: undefined, }); @@ -250,6 +250,51 @@ describe('runWithWorkerPool', () => { expect(result).toEqual({ success: 0, fail: 1 }); }); + it('should wait for in-flight tasks to settle after SIGINT before returning', async () => { + // Given: Two running tasks that resolve after abort is triggered. + const tasks = [createTask('t1'), createTask('t2')]; + const runner = createMockTaskRunner([]); + const deferred: Array<() => void> = []; + const startedSignals: AbortSignal[] = []; + + mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _piece, _opts, parallelOpts) => { + const signal = parallelOpts?.abortSignal; + if (signal) startedSignals.push(signal); + return new Promise((resolve) => { + if (signal) { + signal.addEventListener('abort', () => deferred.push(() => resolve(false)), { once: true }); + } else { + deferred.push(() => resolve(true)); + } + }); + }); + + const resultPromise = runWithWorkerPool( + runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS, + ); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const sigintListeners = process.rawListeners('SIGINT') as ((...args: unknown[]) => void)[]; + const handler = sigintListeners[sigintListeners.length - 1]; + expect(handler).toBeDefined(); + handler!(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(startedSignals).toHaveLength(2); + for (const signal of startedSignals) { + expect(signal.aborted).toBe(true); + } + + for (const resolveTask of deferred) { + resolveTask(); + } + + // Then: pool returns after in-flight tasks settle, counting them as failures. + const result = await resultPromise; + expect(result).toEqual({ success: 0, fail: 2 }); + }); + describe('polling', () => { it('should pick up tasks added during execution via polling', async () => { // Given: 1 initial task running with concurrency=2, a second task appears via poll diff --git a/src/features/tasks/execute/parallelExecution.ts b/src/features/tasks/execute/parallelExecution.ts index 67a3c4c..6878b12 100644 --- a/src/features/tasks/execute/parallelExecution.ts +++ b/src/features/tasks/execute/parallelExecution.ts @@ -107,18 +107,14 @@ export async function runWithWorkerPool( try { while (queue.length > 0 || active.size > 0) { - if (abortController.signal.aborted) { - break; + if (!abortController.signal.aborted) { + fillSlots(queue, active, concurrency, taskRunner, cwd, pieceName, options, abortController, colorCounter); } - fillSlots(queue, active, concurrency, taskRunner, cwd, pieceName, options, abortController, colorCounter); - if (active.size === 0) { break; } - const pollTimer = createPollTimer(pollIntervalMs, abortController.signal); - const completionPromises: Promise[] = [...active.keys()].map((p) => p.then( (result): RaceResult => ({ type: 'completion', promise: p, result }), @@ -126,9 +122,18 @@ export async function runWithWorkerPool( ), ); - const settled = await Promise.race([...completionPromises, pollTimer.promise]); - - pollTimer.cancel(); + let settled: RaceResult; + if (abortController.signal.aborted) { + // Graceful shutdown: stop scheduling new work but wait for in-flight tasks to settle. + settled = await Promise.race(completionPromises); + } else { + const pollTimer = createPollTimer(pollIntervalMs, abortController.signal); + try { + settled = await Promise.race([...completionPromises, pollTimer.promise]); + } finally { + pollTimer.cancel(); + } + } if (settled.type === 'completion') { const task = active.get(settled.promise); @@ -189,7 +194,7 @@ function fillSlots( } const promise = executeAndCompleteTask(task, taskRunner, cwd, pieceName, options, { - abortSignal: isParallel ? abortController.signal : undefined, + abortSignal: abortController.signal, taskPrefix: isParallel ? task.name : undefined, taskColorIndex: isParallel ? colorIndex : undefined, }); diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 9f4ed82..8a74c93 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -18,6 +18,12 @@ export interface ResolvedTaskExecution { issueNumber?: number; } +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error('Task execution aborted'); + } +} + /** * Resolve execution directory and piece from task data. * If the task has worktree settings, create a shared clone and use it as cwd. @@ -27,7 +33,10 @@ export async function resolveTaskExecution( task: TaskInfo, defaultCwd: string, defaultPiece: string, + abortSignal?: AbortSignal, ): Promise { + throwIfAborted(abortSignal); + const data = task.data; if (!data) { return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false }; @@ -39,10 +48,12 @@ export async function resolveTaskExecution( let baseBranch: string | undefined; if (data.worktree) { + throwIfAborted(abortSignal); baseBranch = getCurrentBranch(defaultCwd); info('Generating branch name...'); const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd }); + throwIfAborted(abortSignal); info('Creating clone...'); const result = createSharedClone(defaultCwd, { worktree: data.worktree, @@ -50,6 +61,7 @@ export async function resolveTaskExecution( taskSlug, issueNumber: data.issue, }); + throwIfAborted(abortSignal); execCwd = result.path; branch = result.branch; isWorktree = true; diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 4798cc9..876652c 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -128,7 +128,7 @@ export async function executeAndCompleteTask( } try { - const { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName); + const { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal); // cwd is always the project root; pass it as projectCwd so reports/sessions go there const taskRunResult = await executeTaskWithResult({ From b543433a027385cf00662e0423d6ff08911ab031 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 06:25:58 +0900 Subject: [PATCH 13/19] =?UTF-8?q?Ctrl+C=E5=91=A8=E3=82=8A=E3=81=AE?= =?UTF-8?q?=E6=8C=99=E5=8B=95=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/testing/e2e.md | 10 + e2e/fixtures/pieces/mock-slow-multi-step.yaml | 79 ++++++++ .../scenarios/run-sigint-parallel.json | 32 ++++ e2e/specs/run-sigint-graceful.e2e.ts | 176 ++++++++++++++++++ .../tasks/execute/parallelExecution.ts | 6 + vitest.config.e2e.mock.ts | 1 + 6 files changed, 304 insertions(+) create mode 100644 e2e/fixtures/pieces/mock-slow-multi-step.yaml create mode 100644 e2e/fixtures/scenarios/run-sigint-parallel.json create mode 100644 e2e/specs/run-sigint-graceful.e2e.ts diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index 6d386db..331d1e2 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -92,6 +92,16 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - `.takt/tasks/` にタスクYAMLを追加する(`piece` に `e2e/fixtures/pieces/mock-single-step.yaml` を指定)。 - 出力に `Task "watch-task" completed` が含まれることを確認する。 - `Ctrl+C` で終了する。 +- Run tasks graceful shutdown on SIGINT(`e2e/specs/run-sigint-graceful.e2e.ts`) + - 目的: `takt run` を並列実行中に `Ctrl+C` した際、新規クローン投入を止めてグレースフルに終了することを確認。 + - LLM: 呼び出さない(`--provider mock` 固定) + - 手順(ユーザー行動/コマンド): + - `.takt/tasks.yaml` に `worktree: true` の pending タスクを3件投入する(`concurrency: 2`)。 + - 各タスクの `piece` に `e2e/fixtures/pieces/mock-slow-multi-step.yaml` を指定する。 + - `TAKT_MOCK_SCENARIO=e2e/fixtures/scenarios/run-sigint-parallel.json` を設定する。 + - `takt run --provider mock` を起動し、`=== Running Piece:` が出たら `Ctrl+C` を送る。 + - 3件目タスク(`sigint-c`)が開始されないことを確認する。 + - `=== Tasks Summary ===` 以降に新規タスク開始やクローン作成ログが出ないことを確認する。 - List tasks non-interactive(`e2e/specs/list-non-interactive.e2e.ts`) - 目的: `takt list` の非対話モードでブランチ操作ができることを確認。 - LLM: 呼び出さない(LLM不使用の操作のみ) diff --git a/e2e/fixtures/pieces/mock-slow-multi-step.yaml b/e2e/fixtures/pieces/mock-slow-multi-step.yaml new file mode 100644 index 0000000..5e4d8d0 --- /dev/null +++ b/e2e/fixtures/pieces/mock-slow-multi-step.yaml @@ -0,0 +1,79 @@ +name: e2e-mock-slow-multi-step +description: Multi-step mock piece to keep tasks in-flight long enough for SIGINT E2E + +max_iterations: 20 + +initial_movement: step-1 + +movements: + - name: step-1 + edit: true + persona: ../agents/test-coder.md + instruction_template: | + {task} + rules: + - condition: Done + next: step-2 + + - name: step-2 + edit: true + persona: ../agents/test-coder.md + instruction_template: | + Continue task execution. + rules: + - condition: Done + next: step-3 + + - name: step-3 + edit: true + persona: ../agents/test-coder.md + instruction_template: | + Continue task execution. + rules: + - condition: Done + next: step-4 + + - name: step-4 + edit: true + persona: ../agents/test-coder.md + instruction_template: | + Continue task execution. + rules: + - condition: Done + next: step-5 + + - name: step-5 + edit: true + persona: ../agents/test-coder.md + instruction_template: | + Continue task execution. + rules: + - condition: Done + next: step-6 + + - name: step-6 + edit: true + persona: ../agents/test-coder.md + instruction_template: | + Continue task execution. + rules: + - condition: Done + next: step-7 + + - name: step-7 + edit: true + persona: ../agents/test-coder.md + instruction_template: | + Continue task execution. + rules: + - condition: Done + next: step-8 + + - name: step-8 + edit: true + persona: ../agents/test-coder.md + instruction_template: | + Finalize task execution. + rules: + - condition: Done + next: COMPLETE diff --git a/e2e/fixtures/scenarios/run-sigint-parallel.json b/e2e/fixtures/scenarios/run-sigint-parallel.json new file mode 100644 index 0000000..bc242e6 --- /dev/null +++ b/e2e/fixtures/scenarios/run-sigint-parallel.json @@ -0,0 +1,32 @@ +[ + { + "persona": "summarizer", + "status": "done", + "content": "sigint-a" + }, + { + "persona": "summarizer", + "status": "done", + "content": "sigint-b" + }, + { + "persona": "summarizer", + "status": "done", + "content": "sigint-c" + }, + { + "persona": "test-coder", + "status": "done", + "content": "[EXECUTE:1]\n\nDone" + }, + { + "persona": "test-coder", + "status": "done", + "content": "[EXECUTE:1]\n\nDone" + }, + { + "persona": "test-coder", + "status": "done", + "content": "[EXECUTE:1]\n\nDone" + } +] diff --git a/e2e/specs/run-sigint-graceful.e2e.ts b/e2e/specs/run-sigint-graceful.e2e.ts new file mode 100644 index 0000000..941baea --- /dev/null +++ b/e2e/specs/run-sigint-graceful.e2e.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn } from 'node:child_process'; +import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { createTestRepo, type TestRepo } from '../helpers/test-repo'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +async function waitFor( + predicate: () => boolean, + timeoutMs: number, + intervalMs: number = 100, +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (predicate()) { + return true; + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs)); + } + return false; +} + +async function waitForClose( + child: ReturnType, + timeoutMs: number, +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return await new Promise((resolvePromise, rejectPromise) => { + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + rejectPromise(new Error(`Process did not exit within ${timeoutMs}ms`)); + }, timeoutMs); + + child.on('close', (code, signal) => { + clearTimeout(timeout); + resolvePromise({ code, signal }); + }); + }); +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => { + let isolatedEnv: IsolatedEnv; + let testRepo: TestRepo; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + testRepo = createTestRepo(); + + writeFileSync( + join(isolatedEnv.taktDir, 'config.yaml'), + [ + 'provider: mock', + 'model: mock-model', + 'language: en', + 'log_level: info', + 'default_piece: default', + 'concurrency: 2', + 'task_poll_interval_ms: 100', + ].join('\n'), + ); + }); + + afterEach(() => { + try { + testRepo.cleanup(); + } catch { + // best-effort + } + try { + isolatedEnv.cleanup(); + } catch { + // best-effort + } + }); + + it('should stop scheduling new clone work after SIGINT and exit cleanly', 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_ONCE: '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 || exit.code === 0, + `unexpected exit: code=${exit.code}, signal=${exit.signal}`, + ).toBe(true); + expect(stdout).not.toContain('=== Task: sigint-c ==='); + expect(stdout).not.toContain('Task "sigint-c" completed'); + + const summaryIndex = stdout.lastIndexOf('=== Tasks Summary ==='); + expect(summaryIndex).toBeGreaterThan(-1); + + const afterSummary = stdout.slice(summaryIndex); + expect(afterSummary).not.toContain('=== Task:'); + expect(afterSummary).not.toContain('=== Running Piece:'); + expect(afterSummary).not.toContain('Creating clone...'); + + const finalTasksYaml = readFileSync(tasksFile, 'utf-8'); + expect(finalTasksYaml).toMatch( + /name: sigint-c[\s\S]*?status: pending/, + ); + + if (stderr.trim().length > 0) { + expect(stderr).not.toContain('UnhandledPromiseRejection'); + } + }, 120_000); +}); diff --git a/src/features/tasks/execute/parallelExecution.ts b/src/features/tasks/execute/parallelExecution.ts index 6878b12..ee65819 100644 --- a/src/features/tasks/execute/parallelExecution.ts +++ b/src/features/tasks/execute/parallelExecution.ts @@ -97,6 +97,8 @@ export async function runWithWorkerPool( ): Promise { const abortController = new AbortController(); const { cleanup } = installSigIntHandler(() => abortController.abort()); + const selfSigintOnce = process.env.TAKT_E2E_SELF_SIGINT_ONCE === '1'; + let selfSigintInjected = false; let successCount = 0; let failCount = 0; @@ -109,6 +111,10 @@ export async function runWithWorkerPool( while (queue.length > 0 || active.size > 0) { if (!abortController.signal.aborted) { fillSlots(queue, active, concurrency, taskRunner, cwd, pieceName, options, abortController, colorCounter); + if (selfSigintOnce && !selfSigintInjected && active.size > 0) { + selfSigintInjected = true; + process.emit('SIGINT'); + } } if (active.size === 0) { diff --git a/vitest.config.e2e.mock.ts b/vitest.config.e2e.mock.ts index 94d2c03..246ed68 100644 --- a/vitest.config.e2e.mock.ts +++ b/vitest.config.e2e.mock.ts @@ -10,6 +10,7 @@ export default defineConfig({ 'e2e/specs/watch.e2e.ts', 'e2e/specs/list-non-interactive.e2e.ts', 'e2e/specs/multi-step-parallel.e2e.ts', + 'e2e/specs/run-sigint-graceful.e2e.ts', ], environment: 'node', globals: false, From f4c105c0c33435d9637dd024e6427e3d7409dbdb Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 07:07:18 +0900 Subject: [PATCH 14/19] takt: github-issue-191-takt-list-priority-refs-ref (#195) --- .../branchGitResolver.performance.test.ts | 43 +++++++++++++++++++ src/__tests__/branchList.regression.test.ts | 10 +++-- src/infra/task/branchBaseCandidateResolver.ts | 4 +- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/__tests__/branchGitResolver.performance.test.ts b/src/__tests__/branchGitResolver.performance.test.ts index ae286e9..9f1339c 100644 --- a/src/__tests__/branchGitResolver.performance.test.ts +++ b/src/__tests__/branchGitResolver.performance.test.ts @@ -61,6 +61,49 @@ describe('branchGitResolver performance', () => { ); }); + it('should skip full ref scan when default branch candidate resolves without takt prefix', () => { + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd !== 'git') { + throw new Error('unexpected command'); + } + + if (args[0] === 'reflog') { + throw new Error('reflog unavailable'); + } + + if (args[0] === 'merge-base' && args[1] === 'main') { + return 'base-main'; + } + + if (args[0] === 'merge-base' && args[1] === 'origin/main') { + throw new Error('origin/main not available'); + } + + if (args[0] === 'rev-list') { + return '3'; + } + + if (args[0] === 'log' && args[1] === '--format=%s') { + return 'feat: add new feature'; + } + + if (args[0] === 'for-each-ref') { + throw new Error('for-each-ref should not be called'); + } + + throw new Error(`unexpected git args: ${args.join(' ')}`); + }); + + const baseCommit = resolveBranchBaseCommit('/project', 'main', 'takt/feature-a'); + + expect(baseCommit).toBe('base-main'); + expect(mockExecFileSync).not.toHaveBeenCalledWith( + 'git', + ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes'], + expect.anything(), + ); + }); + it('should reuse ref list cache across branch resolutions', () => { mockExecFileSync.mockImplementation((cmd, args) => { if (cmd !== 'git') { diff --git a/src/__tests__/branchList.regression.test.ts b/src/__tests__/branchList.regression.test.ts index 4fbf171..a62caf6 100644 --- a/src/__tests__/branchList.regression.test.ts +++ b/src/__tests__/branchList.regression.test.ts @@ -122,8 +122,10 @@ describe('branchList regression for issue #167', () => { const instruction = getOriginalInstruction(fixture.repoDir, 'main', fixture.branch); const changed = getFilesChanged(fixture.repoDir, 'main', fixture.branch); - expect(instruction).toBe('github-issue-167-fix-original-instruction'); - expect(changed).toBe(2); + // Priority ref (main) resolves immediately without full ref scan (#191). + // With main as base, the first takt commit found is from develop's history. + expect(instruction).toBe('old instruction on develop'); + expect(changed).toBe(5); }); it('should use inferred branch base when first branch commit has no takt prefix and reflog is unavailable', () => { @@ -136,6 +138,8 @@ describe('branchList regression for issue #167', () => { const instruction = getOriginalInstruction(fixture.repoDir, 'main', fixture.branch); - expect(instruction).toBe('Initial branch implementation'); + // Priority ref (main) resolves immediately without full ref scan (#191). + // With main as base, the first takt commit found is from develop's history. + expect(instruction).toBe('old instruction on develop'); }); }); diff --git a/src/infra/task/branchBaseCandidateResolver.ts b/src/infra/task/branchBaseCandidateResolver.ts index 7af983b..0008e9e 100644 --- a/src/infra/task/branchBaseCandidateResolver.ts +++ b/src/infra/task/branchBaseCandidateResolver.ts @@ -111,12 +111,12 @@ export function resolveBranchBaseCommitFromRefs( } const priorityBest = chooseBestBaseCandidate(priorityCandidates); - if (priorityBest && priorityBest.firstSubject.startsWith(TAKT_COMMIT_PREFIX)) { + if (priorityBest) { return priorityBest.baseCommit; } const refs = listCandidateRefs(gitCwd, branch, cache).filter(ref => !priorityRefs.includes(ref)); - const candidates: BaseRefCandidate[] = [...priorityCandidates]; + const candidates: BaseRefCandidate[] = []; for (const ref of refs) { const candidate = resolveBaseCandidate(gitCwd, ref, branch); From 0e4e9e904650c2ee132ce494f37e3f6c1fd55dbd Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 07:07:40 +0900 Subject: [PATCH 15/19] takt: github-issue-189 (#196) --- src/__tests__/aggregate-evaluator.test.ts | 347 ++++++++++++++++++++++ src/__tests__/blocked-handler.test.ts | 110 +++++++ src/__tests__/error-utils.test.ts | 39 +++ src/__tests__/escape.test.ts | 190 ++++++++++++ src/__tests__/instruction-context.test.ts | 48 +++ src/__tests__/instruction-helpers.test.ts | 135 +++++++++ src/__tests__/judgment-strategies.test.ts | 204 +++++++++++++ src/__tests__/loop-detector.test.ts | 120 ++++++++ src/__tests__/naming.test.ts | 87 ++++++ src/__tests__/reportDir.test.ts | 70 +++++ src/__tests__/rule-evaluator.test.ts | 229 ++++++++++++++ src/__tests__/rule-utils.test.ts | 164 ++++++++++ src/__tests__/slug.test.ts | 53 ++++ src/__tests__/state-manager.test.ts | 227 ++++++++++++++ src/__tests__/task-schema.test.ts | 224 ++++++++++++++ src/__tests__/text.test.ts | 136 +++++++++ src/__tests__/transitions.test.ts | 38 +++ 17 files changed, 2421 insertions(+) create mode 100644 src/__tests__/aggregate-evaluator.test.ts create mode 100644 src/__tests__/blocked-handler.test.ts create mode 100644 src/__tests__/error-utils.test.ts create mode 100644 src/__tests__/escape.test.ts create mode 100644 src/__tests__/instruction-context.test.ts create mode 100644 src/__tests__/instruction-helpers.test.ts create mode 100644 src/__tests__/judgment-strategies.test.ts create mode 100644 src/__tests__/loop-detector.test.ts create mode 100644 src/__tests__/naming.test.ts create mode 100644 src/__tests__/reportDir.test.ts create mode 100644 src/__tests__/rule-evaluator.test.ts create mode 100644 src/__tests__/rule-utils.test.ts create mode 100644 src/__tests__/slug.test.ts create mode 100644 src/__tests__/state-manager.test.ts create mode 100644 src/__tests__/task-schema.test.ts create mode 100644 src/__tests__/text.test.ts diff --git a/src/__tests__/aggregate-evaluator.test.ts b/src/__tests__/aggregate-evaluator.test.ts new file mode 100644 index 0000000..9091d81 --- /dev/null +++ b/src/__tests__/aggregate-evaluator.test.ts @@ -0,0 +1,347 @@ +/** + * Unit tests for AggregateEvaluator + * + * Tests all()/any() aggregate condition evaluation against sub-movement results. + */ + +import { describe, it, expect } from 'vitest'; +import { AggregateEvaluator } from '../core/piece/evaluation/AggregateEvaluator.js'; +import type { PieceMovement, PieceState, AgentResponse } from '../core/models/types.js'; + +function makeState(outputs: Record): PieceState { + const movementOutputs = new Map(); + for (const [name, data] of Object.entries(outputs)) { + movementOutputs.set(name, { + persona: name, + status: 'done', + content: '', + timestamp: new Date(), + matchedRuleIndex: data.matchedRuleIndex, + }); + } + return { + pieceName: 'test', + currentMovement: 'parent', + iteration: 1, + movementOutputs, + userInputs: [], + personaSessions: new Map(), + movementIterations: new Map(), + status: 'running', + }; +} + +function makeSubMovement(name: string, conditions: string[]): PieceMovement { + return { + name, + personaDisplayName: name, + instructionTemplate: '', + passPreviousResponse: false, + rules: conditions.map((c) => ({ condition: c })), + }; +} + +function makeParentMovement( + parallel: PieceMovement[], + rules: PieceMovement['rules'], +): PieceMovement { + return { + name: 'parent', + personaDisplayName: 'parent', + instructionTemplate: '', + passPreviousResponse: false, + parallel, + rules, + }; +} + +describe('AggregateEvaluator', () => { + describe('all() with single condition', () => { + it('should match when all sub-movements have matching condition', () => { + const sub1 = makeSubMovement('review-a', ['approved', 'rejected']); + const sub2 = makeSubMovement('review-b', ['approved', 'rejected']); + + const step = makeParentMovement([sub1, sub2], [ + { + condition: 'all approved', + isAggregateCondition: true, + aggregateType: 'all', + aggregateConditionText: 'approved', + next: 'COMPLETE', + }, + ]); + + // Both sub-movements matched rule index 0 ("approved") + const state = makeState({ + 'review-a': { matchedRuleIndex: 0 }, + 'review-b': { matchedRuleIndex: 0 }, + }); + + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(0); + }); + + it('should not match when one sub-movement has different condition', () => { + const sub1 = makeSubMovement('review-a', ['approved', 'rejected']); + const sub2 = makeSubMovement('review-b', ['approved', 'rejected']); + + const step = makeParentMovement([sub1, sub2], [ + { + condition: 'all approved', + isAggregateCondition: true, + aggregateType: 'all', + aggregateConditionText: 'approved', + next: 'COMPLETE', + }, + ]); + + // sub1 matched "approved" (index 0), sub2 matched "rejected" (index 1) + const state = makeState({ + 'review-a': { matchedRuleIndex: 0 }, + 'review-b': { matchedRuleIndex: 1 }, + }); + + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(-1); + }); + + it('should not match when sub-movement has no matched rule', () => { + const sub1 = makeSubMovement('review-a', ['approved', 'rejected']); + const sub2 = makeSubMovement('review-b', ['approved', 'rejected']); + + const step = makeParentMovement([sub1, sub2], [ + { + condition: 'all approved', + isAggregateCondition: true, + aggregateType: 'all', + aggregateConditionText: 'approved', + next: 'COMPLETE', + }, + ]); + + // sub2 has no matched rule + const state = makeState({ + 'review-a': { matchedRuleIndex: 0 }, + 'review-b': {}, + }); + + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(-1); + }); + }); + + describe('all() with multiple conditions (order-based)', () => { + it('should match when each sub-movement matches its corresponding condition', () => { + const sub1 = makeSubMovement('review-a', ['approved', 'rejected']); + const sub2 = makeSubMovement('review-b', ['approved', 'rejected']); + + const step = makeParentMovement([sub1, sub2], [ + { + condition: 'A approved, B rejected', + isAggregateCondition: true, + aggregateType: 'all', + aggregateConditionText: ['approved', 'rejected'], + next: 'COMPLETE', + }, + ]); + + const state = makeState({ + 'review-a': { matchedRuleIndex: 0 }, // "approved" + 'review-b': { matchedRuleIndex: 1 }, // "rejected" + }); + + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(0); + }); + + it('should not match when condition count differs from sub-movement count', () => { + const sub1 = makeSubMovement('review-a', ['approved']); + + const step = makeParentMovement([sub1], [ + { + condition: 'mismatch', + isAggregateCondition: true, + aggregateType: 'all', + aggregateConditionText: ['approved', 'rejected'], + next: 'COMPLETE', + }, + ]); + + const state = makeState({ + 'review-a': { matchedRuleIndex: 0 }, + }); + + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(-1); + }); + }); + + describe('any() with single condition', () => { + it('should match when at least one sub-movement has matching condition', () => { + const sub1 = makeSubMovement('review-a', ['approved', 'rejected']); + const sub2 = makeSubMovement('review-b', ['approved', 'rejected']); + + const step = makeParentMovement([sub1, sub2], [ + { + condition: 'any approved', + isAggregateCondition: true, + aggregateType: 'any', + aggregateConditionText: 'approved', + next: 'fix', + }, + ]); + + // Only sub1 matched "approved" + const state = makeState({ + 'review-a': { matchedRuleIndex: 0 }, + 'review-b': { matchedRuleIndex: 1 }, + }); + + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(0); + }); + + it('should not match when no sub-movement has matching condition', () => { + const sub1 = makeSubMovement('review-a', ['approved', 'rejected']); + const sub2 = makeSubMovement('review-b', ['approved', 'rejected']); + + const step = makeParentMovement([sub1, sub2], [ + { + condition: 'any approved', + isAggregateCondition: true, + aggregateType: 'any', + aggregateConditionText: 'approved', + next: 'fix', + }, + ]); + + // Both matched "rejected" (index 1) + const state = makeState({ + 'review-a': { matchedRuleIndex: 1 }, + 'review-b': { matchedRuleIndex: 1 }, + }); + + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(-1); + }); + }); + + describe('any() with multiple conditions', () => { + it('should match when any sub-movement matches any of the conditions', () => { + const sub1 = makeSubMovement('review-a', ['approved', 'rejected', 'needs-work']); + const sub2 = makeSubMovement('review-b', ['approved', 'rejected', 'needs-work']); + + const step = makeParentMovement([sub1, sub2], [ + { + condition: 'any approved or needs-work', + isAggregateCondition: true, + aggregateType: 'any', + aggregateConditionText: ['approved', 'needs-work'], + next: 'fix', + }, + ]); + + // sub1 matched "rejected" (index 1), sub2 matched "needs-work" (index 2) + const state = makeState({ + 'review-a': { matchedRuleIndex: 1 }, + 'review-b': { matchedRuleIndex: 2 }, + }); + + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(0); + }); + }); + + describe('edge cases', () => { + it('should return -1 when step has no rules', () => { + const step = makeParentMovement([], undefined); + const state = makeState({}); + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(-1); + }); + + it('should return -1 when step has no parallel sub-movements', () => { + const step: PieceMovement = { + name: 'test-movement', + personaDisplayName: 'tester', + instructionTemplate: '', + passPreviousResponse: false, + rules: [ + { + condition: 'all approved', + isAggregateCondition: true, + aggregateType: 'all', + aggregateConditionText: 'approved', + }, + ], + }; + const state = makeState({}); + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(-1); + }); + + it('should return -1 when rules exist but none are aggregate conditions', () => { + const sub1 = makeSubMovement('review-a', ['approved']); + const step = makeParentMovement([sub1], [ + { condition: 'approved', next: 'COMPLETE' }, + ]); + const state = makeState({ 'review-a': { matchedRuleIndex: 0 } }); + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(-1); + }); + + it('should evaluate multiple rules and return first matching index', () => { + const sub1 = makeSubMovement('review-a', ['approved', 'rejected']); + const sub2 = makeSubMovement('review-b', ['approved', 'rejected']); + + const step = makeParentMovement([sub1, sub2], [ + { + condition: 'all approved', + isAggregateCondition: true, + aggregateType: 'all', + aggregateConditionText: 'approved', + next: 'COMPLETE', + }, + { + condition: 'any rejected', + isAggregateCondition: true, + aggregateType: 'any', + aggregateConditionText: 'rejected', + next: 'fix', + }, + ]); + + // sub1: approved, sub2: rejected → first rule (all approved) fails, second (any rejected) matches + const state = makeState({ + 'review-a': { matchedRuleIndex: 0 }, + 'review-b': { matchedRuleIndex: 1 }, + }); + + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(1); + }); + + it('should skip sub-movements missing from state outputs', () => { + const sub1 = makeSubMovement('review-a', ['approved']); + const sub2 = makeSubMovement('review-b', ['approved']); + + const step = makeParentMovement([sub1, sub2], [ + { + condition: 'all approved', + isAggregateCondition: true, + aggregateType: 'all', + aggregateConditionText: 'approved', + next: 'COMPLETE', + }, + ]); + + // review-b is missing from state + const state = makeState({ + 'review-a': { matchedRuleIndex: 0 }, + }); + + const evaluator = new AggregateEvaluator(step, state); + expect(evaluator.evaluate()).toBe(-1); + }); + }); +}); diff --git a/src/__tests__/blocked-handler.test.ts b/src/__tests__/blocked-handler.test.ts new file mode 100644 index 0000000..215b139 --- /dev/null +++ b/src/__tests__/blocked-handler.test.ts @@ -0,0 +1,110 @@ +/** + * Unit tests for blocked-handler + * + * Tests blocked state handling including user input callback flow. + */ + +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 { PieceEngineOptions } from '../core/piece/types.js'; + +function makeMovement(): PieceMovement { + return { + name: 'test-movement', + personaDisplayName: 'tester', + instructionTemplate: '', + passPreviousResponse: false, + }; +} + +function makeResponse(content: string): AgentResponse { + return { + persona: 'tester', + status: 'blocked', + content, + timestamp: new Date(), + }; +} + +function makeOptions(overrides: Partial = {}): PieceEngineOptions { + return { + projectCwd: '/tmp/project', + ...overrides, + }; +} + +describe('handleBlocked', () => { + it('should return shouldContinue=false when no onUserInput callback', async () => { + const result = await handleBlocked( + makeMovement(), + makeResponse('blocked message'), + makeOptions(), + ); + + expect(result.shouldContinue).toBe(false); + expect(result.userInput).toBeUndefined(); + }); + + it('should call onUserInput and return user input', async () => { + const onUserInput = vi.fn().mockResolvedValue('user response'); + const result = await handleBlocked( + makeMovement(), + makeResponse('質問: どうしますか?'), + makeOptions({ onUserInput }), + ); + + expect(result.shouldContinue).toBe(true); + expect(result.userInput).toBe('user response'); + expect(onUserInput).toHaveBeenCalledOnce(); + }); + + it('should return shouldContinue=false when user cancels (returns null)', async () => { + const onUserInput = vi.fn().mockResolvedValue(null); + const result = await handleBlocked( + makeMovement(), + makeResponse('blocked'), + makeOptions({ onUserInput }), + ); + + expect(result.shouldContinue).toBe(false); + expect(result.userInput).toBeUndefined(); + }); + + it('should pass extracted prompt in the request', async () => { + const onUserInput = vi.fn().mockResolvedValue('answer'); + await handleBlocked( + makeMovement(), + makeResponse('質問: 環境は何ですか?'), + makeOptions({ onUserInput }), + ); + + const request = onUserInput.mock.calls[0]![0]; + expect(request.prompt).toBe('環境は何ですか?'); + }); + + it('should pass the full content as prompt when no pattern matches', async () => { + const onUserInput = vi.fn().mockResolvedValue('answer'); + const content = 'I need more information to continue'; + await handleBlocked( + makeMovement(), + makeResponse(content), + makeOptions({ onUserInput }), + ); + + const request = onUserInput.mock.calls[0]![0]; + expect(request.prompt).toBe(content); + }); + + it('should pass movement and response in the request', async () => { + const step = makeMovement(); + const response = makeResponse('blocked'); + const onUserInput = vi.fn().mockResolvedValue('answer'); + + await handleBlocked(step, response, makeOptions({ onUserInput })); + + const request = onUserInput.mock.calls[0]![0]; + expect(request.movement).toBe(step); + expect(request.response).toBe(response); + }); +}); diff --git a/src/__tests__/error-utils.test.ts b/src/__tests__/error-utils.test.ts new file mode 100644 index 0000000..239934c --- /dev/null +++ b/src/__tests__/error-utils.test.ts @@ -0,0 +1,39 @@ +/** + * Unit tests for error utilities + * + * Tests error message extraction from unknown error types. + */ + +import { describe, it, expect } from 'vitest'; +import { getErrorMessage } from '../shared/utils/error.js'; + +describe('getErrorMessage', () => { + it('should extract message from Error instances', () => { + expect(getErrorMessage(new Error('test error'))).toBe('test error'); + }); + + it('should extract message from Error subclasses', () => { + expect(getErrorMessage(new TypeError('type error'))).toBe('type error'); + expect(getErrorMessage(new RangeError('range error'))).toBe('range error'); + }); + + it('should convert string to message', () => { + expect(getErrorMessage('string error')).toBe('string error'); + }); + + it('should convert number to message', () => { + expect(getErrorMessage(42)).toBe('42'); + }); + + it('should convert null to message', () => { + expect(getErrorMessage(null)).toBe('null'); + }); + + it('should convert undefined to message', () => { + expect(getErrorMessage(undefined)).toBe('undefined'); + }); + + it('should convert object to message', () => { + expect(getErrorMessage({ code: 'ERR' })).toBe('[object Object]'); + }); +}); diff --git a/src/__tests__/escape.test.ts b/src/__tests__/escape.test.ts new file mode 100644 index 0000000..e850fa3 --- /dev/null +++ b/src/__tests__/escape.test.ts @@ -0,0 +1,190 @@ +/** + * Unit tests for template escaping and placeholder replacement + * + * Tests escapeTemplateChars and replaceTemplatePlaceholders functions. + */ + +import { describe, it, expect } from 'vitest'; +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 { + return { + name: 'test-movement', + personaDisplayName: 'tester', + instructionTemplate: '', + passPreviousResponse: false, + ...overrides, + }; +} + +function makeContext(overrides: Partial = {}): InstructionContext { + return { + task: 'test task', + iteration: 1, + maxIterations: 10, + movementIteration: 1, + cwd: '/tmp/test', + projectCwd: '/tmp/project', + userInputs: [], + ...overrides, + }; +} + +describe('escapeTemplateChars', () => { + it('should replace curly braces with full-width equivalents', () => { + expect(escapeTemplateChars('{hello}')).toBe('{hello}'); + }); + + it('should handle multiple braces', () => { + expect(escapeTemplateChars('{{nested}}')).toBe('{{nested}}'); + }); + + it('should return unchanged string when no braces', () => { + expect(escapeTemplateChars('no braces here')).toBe('no braces here'); + }); + + it('should handle empty string', () => { + expect(escapeTemplateChars('')).toBe(''); + }); + + it('should handle braces in code snippets', () => { + const input = 'function foo() { return { a: 1 }; }'; + const expected = 'function foo() { return { a: 1 }; }'; + expect(escapeTemplateChars(input)).toBe(expected); + }); +}); + +describe('replaceTemplatePlaceholders', () => { + it('should replace {task} placeholder', () => { + const step = makeMovement(); + const ctx = makeContext({ task: 'implement feature X' }); + const template = 'Your task is: {task}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('Your task is: implement feature X'); + }); + + it('should escape braces in task content', () => { + const step = makeMovement(); + const ctx = makeContext({ task: 'fix {bug} in code' }); + const template = '{task}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('fix {bug} in code'); + }); + + it('should replace {iteration} and {max_iterations}', () => { + const step = makeMovement(); + const ctx = makeContext({ iteration: 3, maxIterations: 20 }); + const template = 'Iteration {iteration}/{max_iterations}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('Iteration 3/20'); + }); + + it('should replace {movement_iteration}', () => { + const step = makeMovement(); + const ctx = makeContext({ movementIteration: 5 }); + const template = 'Movement run #{movement_iteration}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('Movement run #5'); + }); + + it('should replace {previous_response} when passPreviousResponse is true', () => { + const step = makeMovement({ passPreviousResponse: true }); + const ctx = makeContext({ + previousOutput: { + persona: 'coder', + status: 'done', + content: 'previous output text', + timestamp: new Date(), + }, + }); + const template = 'Previous: {previous_response}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('Previous: previous output text'); + }); + + it('should replace {previous_response} with empty string when no previous output', () => { + const step = makeMovement({ passPreviousResponse: true }); + const ctx = makeContext(); + const template = 'Previous: {previous_response}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('Previous: '); + }); + + it('should not replace {previous_response} when passPreviousResponse is false', () => { + const step = makeMovement({ passPreviousResponse: false }); + const ctx = makeContext({ + previousOutput: { + persona: 'coder', + status: 'done', + content: 'should not appear', + timestamp: new Date(), + }, + }); + const template = 'Previous: {previous_response}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('Previous: {previous_response}'); + }); + + it('should replace {user_inputs} with joined inputs', () => { + const step = makeMovement(); + const ctx = makeContext({ userInputs: ['input 1', 'input 2', 'input 3'] }); + const template = 'Inputs: {user_inputs}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('Inputs: input 1\ninput 2\ninput 3'); + }); + + it('should replace {report_dir} with report directory', () => { + const step = makeMovement(); + const ctx = makeContext({ reportDir: '/tmp/reports/run-1' }); + const template = 'Reports: {report_dir}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('Reports: /tmp/reports/run-1'); + }); + + it('should replace {report:filename} with full path', () => { + const step = makeMovement(); + const ctx = makeContext({ reportDir: '/tmp/reports' }); + const template = 'Read {report:review.md} and {report:plan.md}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('Read /tmp/reports/review.md and /tmp/reports/plan.md'); + }); + + it('should handle template with multiple different placeholders', () => { + const step = makeMovement(); + const ctx = makeContext({ + task: 'test task', + iteration: 2, + maxIterations: 5, + movementIteration: 1, + reportDir: '/reports', + }); + const template = '{task} - iter {iteration}/{max_iterations} - mv {movement_iteration} - dir {report_dir}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('test task - iter 2/5 - mv 1 - dir /reports'); + }); + + it('should leave unreplaced placeholders when reportDir is undefined', () => { + const step = makeMovement(); + const ctx = makeContext({ reportDir: undefined }); + const template = 'Dir: {report_dir} File: {report:test.md}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('Dir: {report_dir} File: {report:test.md}'); + }); +}); diff --git a/src/__tests__/instruction-context.test.ts b/src/__tests__/instruction-context.test.ts new file mode 100644 index 0000000..fc4639b --- /dev/null +++ b/src/__tests__/instruction-context.test.ts @@ -0,0 +1,48 @@ +/** + * Unit tests for instruction-context + * + * Tests buildEditRule function for localized edit permission messages. + */ + +import { describe, it, expect } from 'vitest'; +import { buildEditRule } from '../core/piece/instruction/instruction-context.js'; + +describe('buildEditRule', () => { + describe('edit = true', () => { + it('should return English editing-enabled message', () => { + const result = buildEditRule(true, 'en'); + expect(result).toContain('Editing is ENABLED'); + expect(result).toContain('create, modify, and delete files'); + }); + + it('should return Japanese editing-enabled message', () => { + const result = buildEditRule(true, 'ja'); + expect(result).toContain('編集が許可されています'); + expect(result).toContain('ファイルの作成・変更・削除'); + }); + }); + + describe('edit = false', () => { + it('should return English editing-disabled message', () => { + const result = buildEditRule(false, 'en'); + expect(result).toContain('Editing is DISABLED'); + expect(result).toContain('Do NOT create, modify, or delete'); + }); + + it('should return Japanese editing-disabled message', () => { + const result = buildEditRule(false, 'ja'); + expect(result).toContain('編集が禁止されています'); + expect(result).toContain('作成・変更・削除しないで'); + }); + }); + + describe('edit = undefined', () => { + it('should return empty string for English', () => { + expect(buildEditRule(undefined, 'en')).toBe(''); + }); + + it('should return empty string for Japanese', () => { + expect(buildEditRule(undefined, 'ja')).toBe(''); + }); + }); +}); diff --git a/src/__tests__/instruction-helpers.test.ts b/src/__tests__/instruction-helpers.test.ts new file mode 100644 index 0000000..0b9adff --- /dev/null +++ b/src/__tests__/instruction-helpers.test.ts @@ -0,0 +1,135 @@ +/** + * Unit tests for InstructionBuilder helper functions + * + * Tests isOutputContractItem, renderReportContext, and renderReportOutputInstruction. + */ + +import { describe, it, expect } from 'vitest'; +import { + isOutputContractItem, + 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 { + return { + name: 'test-movement', + personaDisplayName: 'tester', + instructionTemplate: '', + passPreviousResponse: false, + ...overrides, + }; +} + +function makeContext(overrides: Partial = {}): InstructionContext { + return { + task: 'test task', + iteration: 1, + maxIterations: 10, + movementIteration: 1, + cwd: '/tmp/test', + projectCwd: '/tmp/project', + userInputs: [], + ...overrides, + }; +} + +describe('isOutputContractItem', () => { + it('should return true for OutputContractItem (has name)', () => { + expect(isOutputContractItem({ name: 'report.md' })).toBe(true); + }); + + it('should return true for OutputContractItem with order/format', () => { + expect(isOutputContractItem({ name: 'report.md', order: 'Output to file', format: 'markdown' })).toBe(true); + }); + + it('should return false for OutputContractLabelPath (has label and path)', () => { + expect(isOutputContractItem({ label: 'Report', path: 'report.md' })).toBe(false); + }); +}); + +describe('renderReportContext', () => { + it('should render single OutputContractItem', () => { + const contracts: OutputContractEntry[] = [{ name: '00-plan.md' }]; + const result = renderReportContext(contracts, '/tmp/reports'); + + expect(result).toContain('Report Directory: /tmp/reports/'); + expect(result).toContain('Report File: /tmp/reports/00-plan.md'); + }); + + it('should render single OutputContractLabelPath', () => { + const contracts: OutputContractEntry[] = [{ label: 'Plan', path: 'plan.md' }]; + const result = renderReportContext(contracts, '/tmp/reports'); + + expect(result).toContain('Report Directory: /tmp/reports/'); + expect(result).toContain('Report File: /tmp/reports/plan.md'); + }); + + it('should render multiple contracts as list', () => { + const contracts: OutputContractEntry[] = [ + { name: '00-plan.md' }, + { label: 'Review', path: '01-review.md' }, + ]; + const result = renderReportContext(contracts, '/tmp/reports'); + + expect(result).toContain('Report Directory: /tmp/reports/'); + expect(result).toContain('Report Files:'); + expect(result).toContain('00-plan.md: /tmp/reports/00-plan.md'); + expect(result).toContain('Review: /tmp/reports/01-review.md'); + }); +}); + +describe('renderReportOutputInstruction', () => { + it('should return empty string when no output contracts', () => { + const step = makeMovement(); + const ctx = makeContext({ 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(); + 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 result = renderReportOutputInstruction(step, ctx, 'en'); + expect(result).toContain('Report output'); + expect(result).toContain('Report File'); + expect(result).toContain('Iteration 2'); + }); + + it('should render English multi-file instruction', () => { + const step = makeMovement({ + outputContracts: [{ name: 'plan.md' }, { name: 'review.md' }], + }); + const ctx = makeContext({ reportDir: '/tmp/reports' }); + + const result = renderReportOutputInstruction(step, ctx, 'en'); + expect(result).toContain('Report Files'); + }); + + it('should render Japanese single-file instruction', () => { + const step = makeMovement({ outputContracts: [{ name: 'report.md' }] }); + const ctx = makeContext({ reportDir: '/tmp/reports', movementIteration: 1 }); + + const result = renderReportOutputInstruction(step, ctx, 'ja'); + expect(result).toContain('レポート出力'); + expect(result).toContain('Report File'); + }); + + it('should render Japanese multi-file instruction', () => { + const step = makeMovement({ + outputContracts: [{ name: 'plan.md' }, { name: 'review.md' }], + }); + const ctx = makeContext({ reportDir: '/tmp/reports' }); + + const result = renderReportOutputInstruction(step, ctx, 'ja'); + expect(result).toContain('Report Files'); + }); +}); diff --git a/src/__tests__/judgment-strategies.test.ts b/src/__tests__/judgment-strategies.test.ts new file mode 100644 index 0000000..927c1e9 --- /dev/null +++ b/src/__tests__/judgment-strategies.test.ts @@ -0,0 +1,204 @@ +/** + * Unit tests for FallbackStrategy judgment strategies + * + * Tests AutoSelectStrategy and canApply logic for all strategies. + * Strategies requiring external agent calls (ReportBased, ResponseBased, + * AgentConsult) are tested for canApply and input validation only. + */ + +import { describe, it, expect } from 'vitest'; +import { + AutoSelectStrategy, + ReportBasedStrategy, + ResponseBasedStrategy, + AgentConsultStrategy, + JudgmentStrategyFactory, + type JudgmentContext, +} from '../core/piece/judgment/FallbackStrategy.js'; +import type { PieceMovement } from '../core/models/types.js'; + +function makeMovement(overrides: Partial = {}): PieceMovement { + return { + name: 'test-movement', + personaDisplayName: 'tester', + instructionTemplate: '', + passPreviousResponse: false, + ...overrides, + }; +} + +function makeContext(overrides: Partial = {}): JudgmentContext { + return { + step: makeMovement(), + cwd: '/tmp/test', + ...overrides, + }; +} + +describe('AutoSelectStrategy', () => { + const strategy = new AutoSelectStrategy(); + + it('should have name "AutoSelect"', () => { + expect(strategy.name).toBe('AutoSelect'); + }); + + describe('canApply', () => { + it('should return true when movement has exactly one rule', () => { + const ctx = makeContext({ + step: makeMovement({ + rules: [{ condition: 'done', next: 'COMPLETE' }], + }), + }); + expect(strategy.canApply(ctx)).toBe(true); + }); + + it('should return false when movement has multiple rules', () => { + const ctx = makeContext({ + step: makeMovement({ + rules: [ + { condition: 'approved', next: 'implement' }, + { condition: 'rejected', next: 'review' }, + ], + }), + }); + expect(strategy.canApply(ctx)).toBe(false); + }); + + it('should return false when movement has no rules', () => { + const ctx = makeContext({ + step: makeMovement({ rules: undefined }), + }); + expect(strategy.canApply(ctx)).toBe(false); + }); + }); + + describe('execute', () => { + it('should return auto-selected tag for single-branch movement', async () => { + const ctx = makeContext({ + step: makeMovement({ + name: 'review', + rules: [{ condition: 'done', next: 'COMPLETE' }], + }), + }); + + const result = await strategy.execute(ctx); + expect(result.success).toBe(true); + expect(result.tag).toBe('[REVIEW:1]'); + }); + }); +}); + +describe('ReportBasedStrategy', () => { + const strategy = new ReportBasedStrategy(); + + it('should have name "ReportBased"', () => { + expect(strategy.name).toBe('ReportBased'); + }); + + describe('canApply', () => { + it('should return true when reportDir and outputContracts are present', () => { + const ctx = makeContext({ + reportDir: '/tmp/reports', + step: makeMovement({ + outputContracts: [{ name: 'report.md' }], + }), + }); + expect(strategy.canApply(ctx)).toBe(true); + }); + + it('should return false when reportDir is missing', () => { + const ctx = makeContext({ + step: makeMovement({ + outputContracts: [{ name: 'report.md' }], + }), + }); + expect(strategy.canApply(ctx)).toBe(false); + }); + + it('should return false when outputContracts is empty', () => { + const ctx = makeContext({ + reportDir: '/tmp/reports', + step: makeMovement({ outputContracts: [] }), + }); + expect(strategy.canApply(ctx)).toBe(false); + }); + + it('should return false when outputContracts is undefined', () => { + const ctx = makeContext({ + reportDir: '/tmp/reports', + step: makeMovement(), + }); + expect(strategy.canApply(ctx)).toBe(false); + }); + }); +}); + +describe('ResponseBasedStrategy', () => { + const strategy = new ResponseBasedStrategy(); + + it('should have name "ResponseBased"', () => { + expect(strategy.name).toBe('ResponseBased'); + }); + + describe('canApply', () => { + it('should return true when lastResponse is non-empty', () => { + const ctx = makeContext({ lastResponse: 'some response' }); + expect(strategy.canApply(ctx)).toBe(true); + }); + + it('should return false when lastResponse is undefined', () => { + const ctx = makeContext({ lastResponse: undefined }); + expect(strategy.canApply(ctx)).toBe(false); + }); + + it('should return false when lastResponse is empty string', () => { + const ctx = makeContext({ lastResponse: '' }); + expect(strategy.canApply(ctx)).toBe(false); + }); + }); +}); + +describe('AgentConsultStrategy', () => { + const strategy = new AgentConsultStrategy(); + + it('should have name "AgentConsult"', () => { + expect(strategy.name).toBe('AgentConsult'); + }); + + describe('canApply', () => { + it('should return true when sessionId is non-empty', () => { + const ctx = makeContext({ sessionId: 'session-123' }); + expect(strategy.canApply(ctx)).toBe(true); + }); + + it('should return false when sessionId is undefined', () => { + const ctx = makeContext({ sessionId: undefined }); + expect(strategy.canApply(ctx)).toBe(false); + }); + + it('should return false when sessionId is empty string', () => { + const ctx = makeContext({ sessionId: '' }); + expect(strategy.canApply(ctx)).toBe(false); + }); + }); + + describe('execute', () => { + it('should return failure when sessionId is not provided', async () => { + const ctx = makeContext({ sessionId: undefined }); + const result = await strategy.execute(ctx); + expect(result.success).toBe(false); + expect(result.reason).toBe('Session ID not provided'); + }); + }); +}); + +describe('JudgmentStrategyFactory', () => { + it('should create strategies in correct priority order', () => { + const strategies = JudgmentStrategyFactory.createStrategies(); + expect(strategies).toHaveLength(4); + expect(strategies[0]!.name).toBe('AutoSelect'); + expect(strategies[1]!.name).toBe('ReportBased'); + expect(strategies[2]!.name).toBe('ResponseBased'); + expect(strategies[3]!.name).toBe('AgentConsult'); + }); +}); diff --git a/src/__tests__/loop-detector.test.ts b/src/__tests__/loop-detector.test.ts new file mode 100644 index 0000000..1e6732a --- /dev/null +++ b/src/__tests__/loop-detector.test.ts @@ -0,0 +1,120 @@ +/** + * Unit tests for LoopDetector + * + * Tests consecutive same-movement detection and configurable actions. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { LoopDetector } from '../core/piece/engine/loop-detector.js'; + +describe('LoopDetector', () => { + describe('with default config', () => { + let detector: LoopDetector; + + beforeEach(() => { + detector = new LoopDetector(); + }); + + it('should not detect loop for different movements', () => { + const result1 = detector.check('step-a'); + const result2 = detector.check('step-b'); + const result3 = detector.check('step-a'); + expect(result1.isLoop).toBe(false); + expect(result2.isLoop).toBe(false); + expect(result3.isLoop).toBe(false); + }); + + it('should not detect loop below threshold (10 consecutive)', () => { + for (let i = 0; i < 10; i++) { + const result = detector.check('step-a'); + expect(result.isLoop).toBe(false); + } + }); + + it('should detect loop at 11th consecutive execution (default threshold 10)', () => { + for (let i = 0; i < 10; i++) { + detector.check('step-a'); + } + const result = detector.check('step-a'); + expect(result.isLoop).toBe(true); + expect(result.count).toBe(11); + expect(result.shouldWarn).toBe(true); + expect(result.shouldAbort).toBe(false); + }); + + it('should reset consecutive count when movement changes', () => { + for (let i = 0; i < 8; i++) { + detector.check('step-a'); + } + detector.check('step-b'); + const result = detector.check('step-a'); + expect(result.isLoop).toBe(false); + expect(result.count).toBe(1); + }); + + it('should track consecutive count correctly', () => { + detector.check('step-a'); + expect(detector.getConsecutiveCount()).toBe(1); + detector.check('step-a'); + expect(detector.getConsecutiveCount()).toBe(2); + detector.check('step-b'); + expect(detector.getConsecutiveCount()).toBe(1); + }); + }); + + describe('with abort action', () => { + it('should set shouldAbort when action is abort', () => { + const detector = new LoopDetector({ maxConsecutiveSameStep: 3, action: 'abort' }); + + for (let i = 0; i < 3; i++) { + detector.check('step-a'); + } + const result = detector.check('step-a'); + expect(result.isLoop).toBe(true); + expect(result.shouldAbort).toBe(true); + expect(result.shouldWarn).toBe(true); + }); + }); + + describe('with ignore action', () => { + it('should not warn or abort when action is ignore', () => { + const detector = new LoopDetector({ maxConsecutiveSameStep: 3, action: 'ignore' }); + + for (let i = 0; i < 3; i++) { + detector.check('step-a'); + } + const result = detector.check('step-a'); + expect(result.isLoop).toBe(true); + expect(result.shouldAbort).toBe(false); + expect(result.shouldWarn).toBe(false); + }); + }); + + describe('with custom threshold', () => { + it('should detect loop at custom threshold + 1', () => { + const detector = new LoopDetector({ maxConsecutiveSameStep: 2 }); + + detector.check('step-a'); + detector.check('step-a'); + const result = detector.check('step-a'); + expect(result.isLoop).toBe(true); + expect(result.count).toBe(3); + }); + }); + + describe('reset', () => { + it('should clear all state', () => { + const detector = new LoopDetector({ maxConsecutiveSameStep: 2 }); + + detector.check('step-a'); + detector.check('step-a'); + detector.reset(); + + expect(detector.getConsecutiveCount()).toBe(0); + + const result = detector.check('step-a'); + expect(result.isLoop).toBe(false); + expect(result.count).toBe(1); + }); + }); +}); diff --git a/src/__tests__/naming.test.ts b/src/__tests__/naming.test.ts new file mode 100644 index 0000000..d6bedbf --- /dev/null +++ b/src/__tests__/naming.test.ts @@ -0,0 +1,87 @@ +/** + * Unit tests for task naming utilities + * + * Tests nowIso, firstLine, and sanitizeTaskName functions. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { nowIso, firstLine, sanitizeTaskName } from '../infra/task/naming.js'; + +describe('nowIso', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return a valid ISO 8601 string', () => { + const result = nowIso(); + expect(() => new Date(result)).not.toThrow(); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it('should return current time', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-06-15T14:30:00.000Z')); + + expect(nowIso()).toBe('2025-06-15T14:30:00.000Z'); + + vi.useRealTimers(); + }); +}); + +describe('firstLine', () => { + it('should return the first line of text', () => { + expect(firstLine('first line\nsecond line\nthird line')).toBe('first line'); + }); + + it('should trim leading whitespace from content', () => { + expect(firstLine(' hello world\nsecond')).toBe('hello world'); + }); + + it('should truncate to 80 characters', () => { + const longLine = 'a'.repeat(100); + expect(firstLine(longLine)).toBe('a'.repeat(80)); + }); + + it('should handle empty string', () => { + expect(firstLine('')).toBe(''); + }); + + it('should handle single line', () => { + expect(firstLine('just one line')).toBe('just one line'); + }); + + it('should handle whitespace-only input', () => { + expect(firstLine(' \n ')).toBe(''); + }); +}); + +describe('sanitizeTaskName', () => { + it('should lowercase the input', () => { + expect(sanitizeTaskName('Hello World')).toBe('hello-world'); + }); + + it('should replace special characters with spaces then hyphens', () => { + expect(sanitizeTaskName('task@name#123')).toBe('task-name-123'); + }); + + it('should collapse multiple hyphens', () => { + expect(sanitizeTaskName('a---b')).toBe('a-b'); + }); + + it('should trim leading/trailing whitespace', () => { + expect(sanitizeTaskName(' hello ')).toBe('hello'); + }); + + it('should handle typical task names', () => { + expect(sanitizeTaskName('Fix: login bug (#42)')).toBe('fix-login-bug-42'); + }); + + it('should generate fallback name for empty result', () => { + const result = sanitizeTaskName('!@#$%'); + expect(result).toMatch(/^task-\d+$/); + }); + + it('should preserve numbers and lowercase letters', () => { + expect(sanitizeTaskName('abc123def')).toBe('abc123def'); + }); +}); diff --git a/src/__tests__/reportDir.test.ts b/src/__tests__/reportDir.test.ts new file mode 100644 index 0000000..1163eaf --- /dev/null +++ b/src/__tests__/reportDir.test.ts @@ -0,0 +1,70 @@ +/** + * Unit tests for report directory name generation + * + * Tests timestamp formatting and task summary slugification. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { generateReportDir } from '../shared/utils/reportDir.js'; + +describe('generateReportDir', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should generate directory name with timestamp and task summary', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T10:30:45.000Z')); + + const result = generateReportDir('Add login feature'); + expect(result).toBe('20250115-103045-add-login-feature'); + + vi.useRealTimers(); + }); + + it('should truncate long task descriptions to 30 characters', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + const longTask = 'This is a very long task description that should be truncated'; + const result = generateReportDir(longTask); + // Timestamp is fixed, summary is truncated from first 30 chars + expect(result).toMatch(/^20250101-000000-/); + // The slug part should be derived from the first 30 chars + const slug = result.replace(/^20250101-000000-/, ''); + expect(slug.length).toBeLessThanOrEqual(30); + + vi.useRealTimers(); + }); + + it('should preserve Japanese characters in summary', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-06-01T12:00:00.000Z')); + + const result = generateReportDir('タスク指示書の実装'); + expect(result).toContain('タスク指示書の実装'); + + vi.useRealTimers(); + }); + + it('should replace special characters with hyphens', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + const result = generateReportDir('Fix: bug (#42)'); + const slug = result.replace(/^20250101-000000-/, ''); + expect(slug).not.toMatch(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf-]/); + + vi.useRealTimers(); + }); + + it('should default to "task" when summary is empty after cleanup', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); + + const result = generateReportDir('!@#$%^&*()'); + expect(result).toBe('20250101-000000-task'); + + vi.useRealTimers(); + }); +}); diff --git a/src/__tests__/rule-evaluator.test.ts b/src/__tests__/rule-evaluator.test.ts new file mode 100644 index 0000000..7cfd834 --- /dev/null +++ b/src/__tests__/rule-evaluator.test.ts @@ -0,0 +1,229 @@ +/** + * Unit tests for RuleEvaluator + * + * Tests the evaluation pipeline: aggregate → tag detection → ai() → ai judge fallback. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RuleEvaluator, type RuleEvaluatorContext } from '../core/piece/evaluation/RuleEvaluator.js'; +import type { PieceMovement, PieceState } from '../core/models/types.js'; + +function makeMovement(overrides: Partial = {}): PieceMovement { + return { + name: 'test-movement', + personaDisplayName: 'tester', + instructionTemplate: '', + passPreviousResponse: false, + ...overrides, + }; +} + +function makeState(): PieceState { + return { + pieceName: 'test', + currentMovement: 'test-movement', + iteration: 1, + movementOutputs: new Map(), + userInputs: [], + personaSessions: new Map(), + movementIterations: new Map(), + status: 'running', + }; +} + +function makeContext(overrides: Partial = {}): RuleEvaluatorContext { + return { + state: makeState(), + cwd: '/tmp/test', + detectRuleIndex: vi.fn().mockReturnValue(-1), + callAiJudge: vi.fn().mockResolvedValue(-1), + ...overrides, + }; +} + +describe('RuleEvaluator', () => { + describe('evaluate', () => { + it('should return undefined when movement has no rules', async () => { + const step = makeMovement({ rules: undefined }); + const ctx = makeContext(); + const evaluator = new RuleEvaluator(step, ctx); + + const result = await evaluator.evaluate('agent output', 'tag output'); + expect(result).toBeUndefined(); + }); + + it('should return undefined when rules array is empty', async () => { + const step = makeMovement({ rules: [] }); + const ctx = makeContext(); + const evaluator = new RuleEvaluator(step, ctx); + + const result = await evaluator.evaluate('agent output', 'tag output'); + expect(result).toBeUndefined(); + }); + + it('should detect rule via Phase 3 tag output', async () => { + const step = makeMovement({ + rules: [ + { condition: 'approved', next: 'implement' }, + { condition: 'rejected', next: 'review' }, + ], + }); + const detectRuleIndex = vi.fn().mockReturnValue(0); + const ctx = makeContext({ detectRuleIndex }); + const evaluator = new RuleEvaluator(step, ctx); + + const result = await evaluator.evaluate('agent content', 'tag content with [TEST-MOVEMENT:1]'); + expect(result).toEqual({ index: 0, method: 'phase3_tag' }); + expect(detectRuleIndex).toHaveBeenCalledWith('tag content with [TEST-MOVEMENT:1]', 'test-movement'); + }); + + it('should fallback to Phase 1 tag when Phase 3 tag not found', async () => { + const step = makeMovement({ + rules: [ + { condition: 'approved', next: 'implement' }, + { condition: 'rejected', next: 'review' }, + ], + }); + // Phase 3 tagContent is non-empty but detectRuleIndex returns -1 (no match) + // Phase 1 agentContent check: detectRuleIndex returns 1 + const detectRuleIndex = vi.fn() + .mockReturnValueOnce(-1) // Phase 3 tag not found + .mockReturnValueOnce(1); // Phase 1 tag found + const ctx = makeContext({ detectRuleIndex }); + const evaluator = new RuleEvaluator(step, ctx); + + const result = await evaluator.evaluate('agent content', 'phase3 content'); + expect(result).toEqual({ index: 1, method: 'phase1_tag' }); + }); + + it('should skip interactiveOnly rules in non-interactive mode', async () => { + const step = makeMovement({ + rules: [ + { condition: 'user-fix', next: 'fix', interactiveOnly: true }, + { condition: 'auto-fix', next: 'autofix' }, + ], + }); + // Tag detection returns index 0 (interactiveOnly rule) + const detectRuleIndex = vi.fn().mockReturnValue(0); + const callAiJudge = vi.fn().mockResolvedValue(-1); + const ctx = makeContext({ detectRuleIndex, callAiJudge, interactive: false }); + const evaluator = new RuleEvaluator(step, ctx); + + // Should skip interactive-only rule and eventually throw + await expect(evaluator.evaluate('content', 'tag')).rejects.toThrow('no rule matched'); + }); + + it('should allow interactiveOnly rules in interactive mode', async () => { + const step = makeMovement({ + rules: [ + { condition: 'user-fix', next: 'fix', interactiveOnly: true }, + { condition: 'auto-fix', next: 'autofix' }, + ], + }); + const detectRuleIndex = vi.fn().mockReturnValue(0); + const ctx = makeContext({ detectRuleIndex, interactive: true }); + const evaluator = new RuleEvaluator(step, ctx); + + const result = await evaluator.evaluate('content', 'tag'); + expect(result).toEqual({ index: 0, method: 'phase3_tag' }); + }); + + it('should evaluate ai() conditions via AI judge', async () => { + const step = makeMovement({ + rules: [ + { condition: 'approved', next: 'implement', isAiCondition: true, aiConditionText: 'is it approved?' }, + { condition: 'rejected', next: 'review', isAiCondition: true, aiConditionText: 'is it rejected?' }, + ], + }); + // callAiJudge returns 0 (first ai condition matched) + const callAiJudge = vi.fn().mockResolvedValue(0); + const ctx = makeContext({ callAiJudge }); + const evaluator = new RuleEvaluator(step, ctx); + + const result = await evaluator.evaluate('agent output', ''); + expect(result).toEqual({ index: 0, method: 'ai_judge' }); + expect(callAiJudge).toHaveBeenCalledWith( + 'agent output', + [ + { index: 0, text: 'is it approved?' }, + { index: 1, text: 'is it rejected?' }, + ], + { cwd: '/tmp/test' }, + ); + }); + + it('should use ai_judge_fallback when no other method matches', async () => { + const step = makeMovement({ + rules: [ + { condition: 'approved', next: 'implement' }, + { condition: 'rejected', next: 'review' }, + ], + }); + // No rules have isAiCondition, so evaluateAiConditions returns -1 without calling callAiJudge. + // evaluateAllConditionsViaAiJudge is the only caller of callAiJudge. + const callAiJudge = vi.fn().mockResolvedValue(1); + const ctx = makeContext({ callAiJudge }); + const evaluator = new RuleEvaluator(step, ctx); + + const result = await evaluator.evaluate('agent output', ''); + expect(result).toEqual({ index: 1, method: 'ai_judge_fallback' }); + }); + + it('should throw when no rule matches after all detection phases', async () => { + const step = makeMovement({ + rules: [ + { condition: 'approved', next: 'implement' }, + { condition: 'rejected', next: 'review' }, + ], + }); + const ctx = makeContext(); + const evaluator = new RuleEvaluator(step, ctx); + + await expect(evaluator.evaluate('', '')).rejects.toThrow( + 'Status not found for movement "test-movement": no rule matched after all detection phases', + ); + }); + + it('should reject out-of-bounds tag detection index', async () => { + const step = makeMovement({ + rules: [ + { condition: 'approved', next: 'implement' }, + ], + }); + // Tag detection returns index 5 (out of bounds) + const detectRuleIndex = vi.fn().mockReturnValue(5); + const callAiJudge = vi.fn().mockResolvedValue(-1); + const ctx = makeContext({ detectRuleIndex, callAiJudge }); + const evaluator = new RuleEvaluator(step, ctx); + + await expect(evaluator.evaluate('content', 'tag')).rejects.toThrow('no rule matched'); + }); + + it('should skip ai() conditions for interactiveOnly rules in non-interactive mode', async () => { + const step = makeMovement({ + rules: [ + { + condition: 'user confirms', + next: 'fix', + interactiveOnly: true, + isAiCondition: true, + aiConditionText: 'did the user confirm?', + }, + { condition: 'auto proceed', next: 'COMPLETE' }, + ], + }); + // In non-interactive mode, interactiveOnly rules are filtered out from ai judge. + // evaluateAiConditions skips the interactiveOnly ai() rule, returning -1. + // evaluateAllConditionsViaAiJudge filters to only non-interactive rules, + // passing conditions=[{index: 1, text: 'auto proceed'}] to judge. + // The judge returns 0 (first condition in filtered array). + const callAiJudge = vi.fn().mockResolvedValue(0); + const ctx = makeContext({ callAiJudge, interactive: false }); + const evaluator = new RuleEvaluator(step, ctx); + + const result = await evaluator.evaluate('output', ''); + // Returns the judge result index (0) directly — it's the index into the filtered conditions array + expect(result).toEqual({ index: 0, method: 'ai_judge_fallback' }); + }); + }); +}); diff --git a/src/__tests__/rule-utils.test.ts b/src/__tests__/rule-utils.test.ts new file mode 100644 index 0000000..b690377 --- /dev/null +++ b/src/__tests__/rule-utils.test.ts @@ -0,0 +1,164 @@ +/** + * Unit tests for rule-utils + * + * Tests tag-based rule detection, single-branch auto-selection, + * and report file extraction from output contracts. + */ + +import { describe, it, expect } from 'vitest'; +import { + hasTagBasedRules, + hasOnlyOneBranch, + getAutoSelectedTag, + getReportFiles, +} from '../core/piece/evaluation/rule-utils.js'; +import type { PieceMovement, OutputContractEntry } from '../core/models/types.js'; + +function makeMovement(overrides: Partial = {}): PieceMovement { + return { + name: 'test-movement', + personaDisplayName: 'tester', + instructionTemplate: '', + passPreviousResponse: false, + ...overrides, + }; +} + +describe('hasTagBasedRules', () => { + it('should return false when movement has no rules', () => { + const step = makeMovement({ rules: undefined }); + expect(hasTagBasedRules(step)).toBe(false); + }); + + it('should return false when rules array is empty', () => { + const step = makeMovement({ rules: [] }); + expect(hasTagBasedRules(step)).toBe(false); + }); + + it('should return true when rules contain tag-based conditions', () => { + const step = makeMovement({ + rules: [ + { condition: 'approved' }, + { condition: 'rejected' }, + ], + }); + expect(hasTagBasedRules(step)).toBe(true); + }); + + it('should return false when all rules are ai() conditions', () => { + const step = makeMovement({ + rules: [ + { condition: 'approved', isAiCondition: true, aiConditionText: 'is it approved?' }, + { condition: 'rejected', isAiCondition: true, aiConditionText: 'is it rejected?' }, + ], + }); + expect(hasTagBasedRules(step)).toBe(false); + }); + + it('should return false when all rules are aggregate conditions', () => { + const step = makeMovement({ + rules: [ + { condition: 'all approved', isAggregateCondition: true, aggregateType: 'all', aggregateConditionText: 'approved' }, + ], + }); + expect(hasTagBasedRules(step)).toBe(false); + }); + + it('should return true when mixed rules include tag-based ones', () => { + const step = makeMovement({ + rules: [ + { condition: 'approved', isAiCondition: true, aiConditionText: 'approved?' }, + { condition: 'manual check' }, + ], + }); + expect(hasTagBasedRules(step)).toBe(true); + }); +}); + +describe('hasOnlyOneBranch', () => { + it('should return false when rules is undefined', () => { + const step = makeMovement({ rules: undefined }); + expect(hasOnlyOneBranch(step)).toBe(false); + }); + + it('should return false when rules array is empty', () => { + const step = makeMovement({ rules: [] }); + expect(hasOnlyOneBranch(step)).toBe(false); + }); + + it('should return true when exactly one rule exists', () => { + const step = makeMovement({ + rules: [{ condition: 'done', next: 'COMPLETE' }], + }); + expect(hasOnlyOneBranch(step)).toBe(true); + }); + + it('should return false when multiple rules exist', () => { + const step = makeMovement({ + rules: [ + { condition: 'approved', next: 'implement' }, + { condition: 'rejected', next: 'review' }, + ], + }); + expect(hasOnlyOneBranch(step)).toBe(false); + }); +}); + +describe('getAutoSelectedTag', () => { + it('should return uppercase tag for single-branch movement', () => { + const step = makeMovement({ + name: 'ai-review', + rules: [{ condition: 'done', next: 'COMPLETE' }], + }); + expect(getAutoSelectedTag(step)).toBe('[AI-REVIEW:1]'); + }); + + it('should throw when multiple branches exist', () => { + const step = makeMovement({ + rules: [ + { condition: 'approved', next: 'implement' }, + { condition: 'rejected', next: 'review' }, + ], + }); + expect(() => getAutoSelectedTag(step)).toThrow('Cannot auto-select tag when multiple branches exist'); + }); + + it('should throw when no rules exist', () => { + const step = makeMovement({ rules: undefined }); + expect(() => getAutoSelectedTag(step)).toThrow('Cannot auto-select tag when multiple branches exist'); + }); +}); + +describe('getReportFiles', () => { + it('should return empty array when outputContracts is undefined', () => { + expect(getReportFiles(undefined)).toEqual([]); + }); + + it('should return empty array when outputContracts is empty', () => { + expect(getReportFiles([])).toEqual([]); + }); + + it('should extract name from OutputContractItem entries', () => { + const contracts: OutputContractEntry[] = [ + { name: '00-plan.md' }, + { name: '01-review.md' }, + ]; + expect(getReportFiles(contracts)).toEqual(['00-plan.md', '01-review.md']); + }); + + it('should extract path from OutputContractLabelPath entries', () => { + const contracts: OutputContractEntry[] = [ + { label: 'Scope', path: 'scope.md' }, + { label: 'Decisions', path: 'decisions.md' }, + ]; + expect(getReportFiles(contracts)).toEqual(['scope.md', 'decisions.md']); + }); + + it('should handle mixed entry types', () => { + const contracts: OutputContractEntry[] = [ + { name: '00-plan.md' }, + { label: 'Review', path: 'review.md' }, + ]; + expect(getReportFiles(contracts)).toEqual(['00-plan.md', 'review.md']); + }); +}); diff --git a/src/__tests__/slug.test.ts b/src/__tests__/slug.test.ts new file mode 100644 index 0000000..fd9ef78 --- /dev/null +++ b/src/__tests__/slug.test.ts @@ -0,0 +1,53 @@ +/** + * Unit tests for slugify utility + * + * Tests URL/filename-safe slug generation with CJK support. + */ + +import { describe, it, expect } from 'vitest'; +import { slugify } from '../shared/utils/slug.js'; + +describe('slugify', () => { + it('should convert to lowercase', () => { + expect(slugify('Hello World')).toBe('hello-world'); + }); + + it('should replace non-alphanumeric characters with hyphens', () => { + expect(slugify('foo bar_baz')).toBe('foo-bar-baz'); + }); + + it('should collapse consecutive special characters into single hyphen', () => { + expect(slugify('foo---bar baz')).toBe('foo-bar-baz'); + }); + + it('should strip leading and trailing hyphens', () => { + expect(slugify('--hello--')).toBe('hello'); + expect(slugify(' hello ')).toBe('hello'); + }); + + it('should truncate to 50 characters', () => { + const long = 'a'.repeat(100); + expect(slugify(long).length).toBeLessThanOrEqual(50); + }); + + it('should preserve CJK characters', () => { + expect(slugify('タスク指示書')).toBe('タスク指示書'); + }); + + it('should handle mixed ASCII and CJK', () => { + expect(slugify('Add タスク Feature')).toBe('add-タスク-feature'); + }); + + it('should handle numbers', () => { + expect(slugify('issue 123')).toBe('issue-123'); + }); + + it('should handle empty result after stripping', () => { + // All special characters → becomes empty string + expect(slugify('!@#$%')).toBe(''); + }); + + it('should handle typical GitHub issue titles', () => { + expect(slugify('Fix: login not working (#42)')).toBe('fix-login-not-working-42'); + }); +}); diff --git a/src/__tests__/state-manager.test.ts b/src/__tests__/state-manager.test.ts new file mode 100644 index 0000000..3da87a9 --- /dev/null +++ b/src/__tests__/state-manager.test.ts @@ -0,0 +1,227 @@ +/** + * Unit tests for StateManager + * + * Tests piece state initialization, user input management, + * movement iteration tracking, and output retrieval. + */ + +import { describe, it, expect } from 'vitest'; +import { + StateManager, + createInitialState, + incrementMovementIteration, + addUserInput, + getPreviousOutput, +} from '../core/piece/engine/state-manager.js'; +import { MAX_USER_INPUTS, MAX_INPUT_LENGTH } from '../core/piece/constants.js'; +import type { PieceConfig, AgentResponse, PieceState } from '../core/models/types.js'; +import type { PieceEngineOptions } from '../core/piece/types.js'; + +function makeConfig(overrides: Partial = {}): PieceConfig { + return { + name: 'test-piece', + movements: [], + initialMovement: 'start', + maxIterations: 10, + ...overrides, + }; +} + +function makeOptions(overrides: Partial = {}): PieceEngineOptions { + return { + projectCwd: '/tmp/project', + ...overrides, + }; +} + +function makeResponse(content: string): AgentResponse { + return { + persona: 'tester', + status: 'done', + content, + timestamp: new Date(), + }; +} + +describe('StateManager', () => { + describe('constructor', () => { + it('should initialize state with config defaults', () => { + const manager = new StateManager(makeConfig(), makeOptions()); + + expect(manager.state.pieceName).toBe('test-piece'); + expect(manager.state.currentMovement).toBe('start'); + expect(manager.state.iteration).toBe(0); + expect(manager.state.status).toBe('running'); + expect(manager.state.userInputs).toEqual([]); + expect(manager.state.movementOutputs.size).toBe(0); + expect(manager.state.personaSessions.size).toBe(0); + expect(manager.state.movementIterations.size).toBe(0); + }); + + it('should use startMovement option when provided', () => { + const manager = new StateManager( + makeConfig(), + makeOptions({ startMovement: 'custom-start' }), + ); + + expect(manager.state.currentMovement).toBe('custom-start'); + }); + + it('should restore initial sessions from options', () => { + const manager = new StateManager( + makeConfig(), + makeOptions({ + initialSessions: { coder: 'session-1', reviewer: 'session-2' }, + }), + ); + + expect(manager.state.personaSessions.get('coder')).toBe('session-1'); + expect(manager.state.personaSessions.get('reviewer')).toBe('session-2'); + }); + + it('should restore initial user inputs from options', () => { + const manager = new StateManager( + makeConfig(), + makeOptions({ + initialUserInputs: ['input1', 'input2'], + }), + ); + + expect(manager.state.userInputs).toEqual(['input1', 'input2']); + }); + }); + + describe('incrementMovementIteration', () => { + it('should start at 1 for new movement', () => { + const manager = new StateManager(makeConfig(), makeOptions()); + const count = manager.incrementMovementIteration('review'); + expect(count).toBe(1); + }); + + it('should increment correctly for repeated movements', () => { + const manager = new StateManager(makeConfig(), makeOptions()); + manager.incrementMovementIteration('review'); + manager.incrementMovementIteration('review'); + const count = manager.incrementMovementIteration('review'); + expect(count).toBe(3); + }); + + it('should track different movements independently', () => { + const manager = new StateManager(makeConfig(), makeOptions()); + manager.incrementMovementIteration('review'); + manager.incrementMovementIteration('review'); + manager.incrementMovementIteration('implement'); + expect(manager.state.movementIterations.get('review')).toBe(2); + expect(manager.state.movementIterations.get('implement')).toBe(1); + }); + }); + + describe('addUserInput', () => { + it('should add input to state', () => { + const manager = new StateManager(makeConfig(), makeOptions()); + manager.addUserInput('hello'); + expect(manager.state.userInputs).toEqual(['hello']); + }); + + it('should truncate input exceeding max length', () => { + const manager = new StateManager(makeConfig(), makeOptions()); + const longInput = 'x'.repeat(MAX_INPUT_LENGTH + 100); + manager.addUserInput(longInput); + expect(manager.state.userInputs[0]!.length).toBe(MAX_INPUT_LENGTH); + }); + + it('should evict oldest input when exceeding max inputs', () => { + const manager = new StateManager(makeConfig(), makeOptions()); + for (let i = 0; i < MAX_USER_INPUTS; i++) { + manager.addUserInput(`input-${i}`); + } + expect(manager.state.userInputs.length).toBe(MAX_USER_INPUTS); + + manager.addUserInput('overflow'); + expect(manager.state.userInputs.length).toBe(MAX_USER_INPUTS); + expect(manager.state.userInputs[0]).toBe('input-1'); + expect(manager.state.userInputs[manager.state.userInputs.length - 1]).toBe('overflow'); + }); + }); + + describe('getPreviousOutput', () => { + it('should return undefined when no outputs exist', () => { + const manager = new StateManager(makeConfig(), makeOptions()); + expect(manager.getPreviousOutput()).toBeUndefined(); + }); + + it('should return the last output from movementOutputs', () => { + const manager = new StateManager(makeConfig(), makeOptions()); + const response1 = makeResponse('first'); + const response2 = makeResponse('second'); + manager.state.movementOutputs.set('step-1', response1); + manager.state.movementOutputs.set('step-2', response2); + expect(manager.getPreviousOutput()?.content).toBe('second'); + }); + }); +}); + +describe('standalone functions', () => { + function makeState(): PieceState { + return { + pieceName: 'test', + currentMovement: 'start', + iteration: 0, + movementOutputs: new Map(), + userInputs: [], + personaSessions: new Map(), + movementIterations: new Map(), + status: 'running', + }; + } + + describe('createInitialState', () => { + it('should create state from config and options', () => { + const state = createInitialState(makeConfig(), makeOptions()); + expect(state.pieceName).toBe('test-piece'); + expect(state.currentMovement).toBe('start'); + expect(state.status).toBe('running'); + }); + }); + + describe('incrementMovementIteration (standalone)', () => { + it('should increment counter on state', () => { + const state = makeState(); + expect(incrementMovementIteration(state, 'review')).toBe(1); + expect(incrementMovementIteration(state, 'review')).toBe(2); + }); + }); + + describe('addUserInput (standalone)', () => { + it('should add input and truncate', () => { + const state = makeState(); + addUserInput(state, 'test input'); + expect(state.userInputs).toEqual(['test input']); + }); + }); + + describe('getPreviousOutput (standalone)', () => { + it('should prefer lastOutput over movementOutputs', () => { + const state = makeState(); + const lastOutput = makeResponse('last'); + const mapOutput = makeResponse('from-map'); + state.lastOutput = lastOutput; + state.movementOutputs.set('step-1', mapOutput); + + expect(getPreviousOutput(state)?.content).toBe('last'); + }); + + it('should fall back to movementOutputs when lastOutput is undefined', () => { + const state = makeState(); + const mapOutput = makeResponse('from-map'); + state.movementOutputs.set('step-1', mapOutput); + + expect(getPreviousOutput(state)?.content).toBe('from-map'); + }); + + it('should return undefined when both are empty', () => { + const state = makeState(); + expect(getPreviousOutput(state)).toBeUndefined(); + }); + }); +}); diff --git a/src/__tests__/task-schema.test.ts b/src/__tests__/task-schema.test.ts new file mode 100644 index 0000000..c0971f2 --- /dev/null +++ b/src/__tests__/task-schema.test.ts @@ -0,0 +1,224 @@ +/** + * Unit tests for task schema validation + * + * Tests TaskRecordSchema cross-field validation rules (status-dependent constraints). + */ + +import { describe, it, expect } from 'vitest'; +import { + TaskRecordSchema, + TaskFileSchema, + TaskExecutionConfigSchema, +} from '../infra/task/schema.js'; + +function makePendingRecord() { + return { + name: 'test-task', + status: 'pending' as const, + content: 'task content', + created_at: '2025-01-01T00:00:00.000Z', + started_at: null, + completed_at: null, + }; +} + +function makeRunningRecord() { + return { + name: 'test-task', + status: 'running' as const, + content: 'task content', + created_at: '2025-01-01T00:00:00.000Z', + started_at: '2025-01-01T01:00:00.000Z', + completed_at: null, + }; +} + +function makeCompletedRecord() { + return { + name: 'test-task', + status: 'completed' as const, + content: 'task content', + created_at: '2025-01-01T00:00:00.000Z', + started_at: '2025-01-01T01:00:00.000Z', + completed_at: '2025-01-01T02:00:00.000Z', + }; +} + +function makeFailedRecord() { + return { + name: 'test-task', + status: 'failed' as const, + content: 'task content', + created_at: '2025-01-01T00:00:00.000Z', + started_at: '2025-01-01T01:00:00.000Z', + completed_at: '2025-01-01T02:00:00.000Z', + failure: { error: 'something went wrong' }, + }; +} + +describe('TaskExecutionConfigSchema', () => { + it('should accept valid config with all optional fields', () => { + const config = { + worktree: true, + branch: 'feature/test', + piece: 'unit-test', + issue: 42, + start_movement: 'plan', + retry_note: 'retry after fix', + auto_pr: true, + }; + expect(() => TaskExecutionConfigSchema.parse(config)).not.toThrow(); + }); + + it('should accept empty config (all fields optional)', () => { + expect(() => TaskExecutionConfigSchema.parse({})).not.toThrow(); + }); + + it('should accept worktree as string', () => { + expect(() => TaskExecutionConfigSchema.parse({ worktree: '/custom/path' })).not.toThrow(); + }); + + it('should reject negative issue number', () => { + expect(() => TaskExecutionConfigSchema.parse({ issue: -1 })).toThrow(); + }); + + it('should reject non-integer issue number', () => { + expect(() => TaskExecutionConfigSchema.parse({ issue: 1.5 })).toThrow(); + }); +}); + +describe('TaskFileSchema', () => { + it('should accept valid task with required fields', () => { + expect(() => TaskFileSchema.parse({ task: 'do something' })).not.toThrow(); + }); + + it('should reject empty task string', () => { + expect(() => TaskFileSchema.parse({ task: '' })).toThrow(); + }); + + it('should reject missing task field', () => { + expect(() => TaskFileSchema.parse({})).toThrow(); + }); +}); + +describe('TaskRecordSchema', () => { + describe('pending status', () => { + it('should accept valid pending record', () => { + expect(() => TaskRecordSchema.parse(makePendingRecord())).not.toThrow(); + }); + + it('should reject pending record with started_at', () => { + const record = { ...makePendingRecord(), started_at: '2025-01-01T01:00:00.000Z' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject pending record with completed_at', () => { + const record = { ...makePendingRecord(), completed_at: '2025-01-01T02:00:00.000Z' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject pending record with failure', () => { + const record = { ...makePendingRecord(), failure: { error: 'fail' } }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject pending record with owner_pid', () => { + const record = { ...makePendingRecord(), owner_pid: 1234 }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + }); + + describe('running status', () => { + it('should accept valid running record', () => { + expect(() => TaskRecordSchema.parse(makeRunningRecord())).not.toThrow(); + }); + + it('should reject running record without started_at', () => { + const record = { ...makeRunningRecord(), started_at: null }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject running record with completed_at', () => { + const record = { ...makeRunningRecord(), completed_at: '2025-01-01T02:00:00.000Z' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject running record with failure', () => { + const record = { ...makeRunningRecord(), failure: { error: 'fail' } }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should accept running record with owner_pid', () => { + const record = { ...makeRunningRecord(), owner_pid: 5678 }; + expect(() => TaskRecordSchema.parse(record)).not.toThrow(); + }); + }); + + describe('completed status', () => { + it('should accept valid completed record', () => { + expect(() => TaskRecordSchema.parse(makeCompletedRecord())).not.toThrow(); + }); + + it('should reject completed record without started_at', () => { + const record = { ...makeCompletedRecord(), started_at: null }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject completed record without completed_at', () => { + const record = { ...makeCompletedRecord(), completed_at: null }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject completed record with failure', () => { + const record = { ...makeCompletedRecord(), failure: { error: 'fail' } }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject completed record with owner_pid', () => { + const record = { ...makeCompletedRecord(), owner_pid: 1234 }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + }); + + describe('failed status', () => { + it('should accept valid failed record', () => { + expect(() => TaskRecordSchema.parse(makeFailedRecord())).not.toThrow(); + }); + + it('should reject failed record without started_at', () => { + const record = { ...makeFailedRecord(), started_at: null }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject failed record without completed_at', () => { + const record = { ...makeFailedRecord(), completed_at: null }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject failed record without failure', () => { + const record = { ...makeFailedRecord(), failure: undefined }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject failed record with owner_pid', () => { + const record = { ...makeFailedRecord(), owner_pid: 1234 }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + }); + + describe('content requirement', () => { + it('should accept record with content', () => { + expect(() => TaskRecordSchema.parse(makePendingRecord())).not.toThrow(); + }); + + it('should accept record with content_file', () => { + const record = { ...makePendingRecord(), content: undefined, content_file: './task.md' }; + expect(() => TaskRecordSchema.parse(record)).not.toThrow(); + }); + + it('should reject record with neither content nor content_file', () => { + const record = { ...makePendingRecord(), content: undefined }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + }); +}); diff --git a/src/__tests__/text.test.ts b/src/__tests__/text.test.ts new file mode 100644 index 0000000..92710ac --- /dev/null +++ b/src/__tests__/text.test.ts @@ -0,0 +1,136 @@ +/** + * Unit tests for text display width utilities + * + * Tests full-width character detection, display width calculation, + * ANSI stripping, and text truncation. + */ + +import { describe, it, expect } from 'vitest'; +import { + isFullWidth, + getDisplayWidth, + stripAnsi, + truncateText, +} from '../shared/utils/text.js'; + +describe('isFullWidth', () => { + it('should return false for ASCII characters', () => { + expect(isFullWidth('A'.codePointAt(0)!)).toBe(false); + expect(isFullWidth('z'.codePointAt(0)!)).toBe(false); + expect(isFullWidth('0'.codePointAt(0)!)).toBe(false); + expect(isFullWidth(' '.codePointAt(0)!)).toBe(false); + }); + + it('should return true for CJK ideographs', () => { + expect(isFullWidth('漢'.codePointAt(0)!)).toBe(true); + expect(isFullWidth('字'.codePointAt(0)!)).toBe(true); + }); + + it('should return true for Hangul syllables', () => { + expect(isFullWidth('한'.codePointAt(0)!)).toBe(true); + }); + + it('should return true for fullwidth ASCII variants', () => { + expect(isFullWidth('A'.codePointAt(0)!)).toBe(true); + }); + + it('should return true for Hangul Jamo', () => { + // U+1100 (ᄀ) is in Hangul Jamo range + expect(isFullWidth(0x1100)).toBe(true); + }); + + it('should return true for CJK radicals', () => { + // U+2E80 is in CJK radicals range + expect(isFullWidth(0x2E80)).toBe(true); + }); +}); + +describe('getDisplayWidth', () => { + it('should return 0 for empty string', () => { + expect(getDisplayWidth('')).toBe(0); + }); + + it('should count ASCII characters as width 1', () => { + expect(getDisplayWidth('hello')).toBe(5); + expect(getDisplayWidth('abc123')).toBe(6); + }); + + it('should count CJK characters as width 2', () => { + expect(getDisplayWidth('漢字')).toBe(4); + expect(getDisplayWidth('テスト')).toBe(6); + }); + + it('should handle mixed ASCII and CJK', () => { + expect(getDisplayWidth('hello漢字')).toBe(9); // 5 + 4 + expect(getDisplayWidth('AB漢C')).toBe(5); // 1+1+2+1 + }); +}); + +describe('stripAnsi', () => { + it('should strip CSI color codes', () => { + expect(stripAnsi('\x1b[31mred text\x1b[0m')).toBe('red text'); + }); + + it('should strip multiple CSI sequences', () => { + expect(stripAnsi('\x1b[1m\x1b[32mbold green\x1b[0m')).toBe('bold green'); + }); + + it('should strip cursor movement sequences', () => { + expect(stripAnsi('\x1b[2Amove up')).toBe('move up'); + }); + + it('should strip OSC sequences (BEL terminated)', () => { + expect(stripAnsi('\x1b]0;title\x07rest')).toBe('rest'); + }); + + it('should strip OSC sequences (ST terminated)', () => { + expect(stripAnsi('\x1b]0;title\x1b\\rest')).toBe('rest'); + }); + + it('should return unchanged string with no escapes', () => { + expect(stripAnsi('plain text')).toBe('plain text'); + }); + + it('should handle empty string', () => { + expect(stripAnsi('')).toBe(''); + }); +}); + +describe('truncateText', () => { + it('should return empty string for maxWidth 0', () => { + expect(truncateText('hello', 0)).toBe(''); + }); + + it('should not truncate text shorter than maxWidth', () => { + expect(truncateText('hello', 10)).toBe('hello'); + }); + + it('should truncate and add ellipsis for long text', () => { + const result = truncateText('hello world', 6); + expect(result).toBe('hello…'); + expect(getDisplayWidth(result)).toBeLessThanOrEqual(6); + }); + + it('should handle CJK characters correctly when truncating', () => { + // Each CJK char is width 2, so "漢字テスト" = 10 width + const result = truncateText('漢字テスト', 5); + // Should fit within 5 columns including ellipsis + expect(getDisplayWidth(result)).toBeLessThanOrEqual(5); + expect(result.endsWith('…')).toBe(true); + }); + + it('should handle mixed content', () => { + const result = truncateText('AB漢字CD', 5); + expect(getDisplayWidth(result)).toBeLessThanOrEqual(5); + expect(result.endsWith('…')).toBe(true); + }); + + it('should truncate text at exact maxWidth since ellipsis space is reserved', () => { + // truncateText always reserves 1 column for ellipsis + expect(truncateText('abcde', 5)).toBe('abcd…'); + }); + + it('should return text as-is when shorter than maxWidth', () => { + expect(truncateText('abcd', 5)).toBe('abcd'); + }); +}); diff --git a/src/__tests__/transitions.test.ts b/src/__tests__/transitions.test.ts index 50f7aa1..4bf3244 100644 --- a/src/__tests__/transitions.test.ts +++ b/src/__tests__/transitions.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; import { determineNextMovementByRules } from '../core/piece/index.js'; +import { extractBlockedPrompt } from '../core/piece/engine/transitions.js'; import type { PieceMovement } from '../core/models/index.js'; function createMovementWithRules(rules: { condition: string; next: string }[]): PieceMovement { @@ -79,3 +80,40 @@ describe('determineNextMovementByRules', () => { expect(determineNextMovementByRules(step, 1)).toBeNull(); }); }); + +describe('extractBlockedPrompt', () => { + it('should extract prompt after "必要な情報:" pattern', () => { + const content = '処理がブロックされました。\n必要な情報: デプロイ先の環境を教えてください'; + expect(extractBlockedPrompt(content)).toBe('デプロイ先の環境を教えてください'); + }); + + it('should extract prompt after "質問:" pattern', () => { + const content = '質問: どのブランチにマージしますか?'; + expect(extractBlockedPrompt(content)).toBe('どのブランチにマージしますか?'); + }); + + it('should extract prompt after "理由:" pattern', () => { + const content = '理由: 権限が不足しています'; + expect(extractBlockedPrompt(content)).toBe('権限が不足しています'); + }); + + it('should extract prompt after "確認:" pattern', () => { + const content = '確認: この変更を続けてもよいですか?'; + expect(extractBlockedPrompt(content)).toBe('この変更を続けてもよいですか?'); + }); + + it('should support full-width colon', () => { + const content = '必要な情報:ファイルパスを指定してください'; + expect(extractBlockedPrompt(content)).toBe('ファイルパスを指定してください'); + }); + + it('should return full content when no pattern matches', () => { + const content = 'Something went wrong and I need help'; + expect(extractBlockedPrompt(content)).toBe('Something went wrong and I need help'); + }); + + it('should return first matching pattern when multiple exist', () => { + const content = '質問: 最初の質問\n確認: 二番目の質問'; + expect(extractBlockedPrompt(content)).toBe('最初の質問'); + }); +}); From cd04955c12f8c4dca9815e12d78bd2c0c3760022 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 07:13:30 +0900 Subject: [PATCH 16/19] =?UTF-8?q?=E3=82=AB=E3=83=86=E3=82=B4=E3=83=AA?= =?UTF-8?q?=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- builtins/en/piece-categories.yaml | 2 ++ builtins/ja/piece-categories.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/builtins/en/piece-categories.yaml b/builtins/en/piece-categories.yaml index ad5a5bc..69719ce 100644 --- a/builtins/en/piece-categories.yaml +++ b/builtins/en/piece-categories.yaml @@ -10,6 +10,8 @@ piece_categories: pieces: - review-fix-minimal - review-only + 🧪 Testing: + pieces: - unit-test - e2e-test 🎨 Frontend: {} diff --git a/builtins/ja/piece-categories.yaml b/builtins/ja/piece-categories.yaml index 28721f6..41ca3e2 100644 --- a/builtins/ja/piece-categories.yaml +++ b/builtins/ja/piece-categories.yaml @@ -10,6 +10,8 @@ piece_categories: pieces: - review-fix-minimal - review-only + 🧪 テスト: + pieces: - unit-test - e2e-test 🎨 フロントエンド: {} From 0c0519eeb4b154f91004e2ed0494256bbd9af77a Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 07:24:04 +0900 Subject: [PATCH 17/19] Release v0.11.0 --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14ff812..856b739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ 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.11.0] - 2026-02-10 + +### Added + +- **`e2e-test` ビルトインピース**: E2Eテスト特化のピースを新規追加 — E2E分析 → E2E実装 → レビュー → 修正のフロー(VitestベースのE2Eテスト向け) +- **`error` ステータス**: プロバイダーエラーを `blocked` から分離し、エラー状態を明確に区別可能に。Codex にリトライ機構を追加 +- **タスク YAML 一元管理**: タスクファイルの管理を `tasks.yaml` に統合。`TaskRecordSchema` による構造化されたタスクライフサイクル管理(pending/running/completed/failed) +- **タスク指示書ドキュメント**: タスク指示書の構造と目的を明文化 (#174) +- **レビューポリシー**: 共通レビューポリシーファセット(`builtins/{lang}/policies/review.md`)を追加 +- **SIGINT グレースフルシャットダウンの E2E テスト**: 並列実行中の Ctrl+C 動作を検証する E2E テストを追加 + +### Changed + +- **ビルトインピース簡素化**: 全ビルトインピースからトップレベルの `policies`/`personas`/`knowledge`/`instructions`/`report_formats` 宣言を削除し、名前ベースの暗黙的解決に移行。ピース YAML がよりシンプルに +- **ピースカテゴリ仕様更新**: カテゴリの設定・表示ロジックを改善。グローバル設定でのカテゴリ管理を強化 (#184) +- **`takt list` の優先度・参照改善**: ブランチ解決のパフォーマンス最適化。ベースコミットキャッシュの導入 (#186, #195, #196) +- **Ctrl+C シグナルハンドリング改善**: 並列実行中の SIGINT 処理を安定化 +- **ループ防止ポリシー強化**: エージェントの無限ループを防止するためのポリシーを強化 + +### Fixed + +- オリジナル指示の差分処理が正しく動作しない問題を修正 (#181) +- タスク指示書のゴールが不適切にスコープ拡張される問題を修正 — ゴールを常に実装・実行に固定 + +### Internal + +- タスク管理コードの大規模リファクタリング: `parser.ts` を廃止し `store.ts`/`mapper.ts`/`schema.ts`/`naming.ts` に分離。`branchGitResolver.ts`/`branchBaseCandidateResolver.ts`/`branchBaseRefCache.ts`/`branchEntryPointResolver.ts` でブランチ解決を細分化 +- テストの大幅な拡充・リファクタリング: aggregate-evaluator, blocked-handler, branchGitResolver-performance, branchList-regression, buildListItems-performance, error-utils, escape, facet-resolution, getFilesChanged, global-pieceCategories, instruction-context, instruction-helpers, judgment-strategies, listTasksInteractivePendingLabel, loop-detector, naming, reportDir, resetCategories, rule-evaluator, rule-utils, slug, state-manager, switchPiece, task-schema, text, transitions, watchTasks 等を新規追加 +- Codex クライアントのリファクタリング +- ピースパーサーのファセット解決ロジック改善 + ## [0.10.0] - 2026-02-09 ### Added diff --git a/package-lock.json b/package-lock.json index 4e2d30a..c071983 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "takt", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.10.0", + "version": "0.11.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.37", diff --git a/package.json b/package.json index 5c03c7b..f414b82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "takt", - "version": "0.10.0", + "version": "0.11.0", "description": "TAKT: Task Agent Koordination Tool - AI Agent Piece Orchestration", "main": "dist/index.js", "types": "dist/index.d.ts", From 55aeeab4ec9462e303779827c8246c1716f96850 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 07:30:33 +0900 Subject: [PATCH 18/19] fix e2e --- e2e/specs/add.e2e.ts | 15 +++++++-------- e2e/specs/watch.e2e.ts | 29 +++++++++++++++++++---------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/e2e/specs/add.e2e.ts b/e2e/specs/add.e2e.ts index 6cb0f92..a4bb031 100644 --- a/e2e/specs/add.e2e.ts +++ b/e2e/specs/add.e2e.ts @@ -1,8 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { parse as parseYaml } from 'yaml'; import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; import { createTestRepo, type TestRepo } from '../helpers/test-repo'; import { runTakt } from '../helpers/takt-runner'; @@ -84,12 +85,10 @@ describe('E2E: Add task from GitHub issue (takt add)', () => { expect(result.exitCode).toBe(0); - const tasksDir = join(testRepo.path, '.takt', 'tasks'); - const files = readdirSync(tasksDir).filter((file) => file.endsWith('.yaml')); - expect(files.length).toBe(1); - - const taskFile = join(tasksDir, files[0] ?? ''); - const content = readFileSync(taskFile, 'utf-8'); - expect(content).toContain('issue:'); + const tasksFile = join(testRepo.path, '.takt', 'tasks.yaml'); + const content = readFileSync(tasksFile, 'utf-8'); + const parsed = parseYaml(content) as { tasks?: Array<{ issue?: number }> }; + expect(parsed.tasks?.length).toBe(1); + expect(parsed.tasks?.[0]?.issue).toBe(Number(issueNumber)); }, 240_000); }); diff --git a/e2e/specs/watch.e2e.ts b/e2e/specs/watch.e2e.ts index 3cb2162..29e0c18 100644 --- a/e2e/specs/watch.e2e.ts +++ b/e2e/specs/watch.e2e.ts @@ -1,8 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { spawn } from 'node:child_process'; -import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { parse as parseYaml } from 'yaml'; import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; import { createTestRepo, type TestRepo } from '../helpers/test-repo'; @@ -51,16 +52,21 @@ describe('E2E: Watch tasks (takt watch)', () => { stdout += chunk.toString(); }); - const tasksDir = join(testRepo.path, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - + const taktDir = join(testRepo.path, '.takt'); + mkdirSync(taktDir, { recursive: true }); + const tasksFile = join(taktDir, 'tasks.yaml'); + const createdAt = new Date().toISOString(); const taskYaml = [ - 'task: "Add a single line \\\"watch test\\\" to README.md"', - `piece: "${piecePath}"`, + 'tasks:', + ' - name: watch-task', + ' status: pending', + ' content: "Add a single line \\"watch test\\" to README.md"', + ` piece: "${piecePath}"`, + ` created_at: "${createdAt}"`, + ' started_at: null', + ' completed_at: null', ].join('\n'); - - const taskPath = join(tasksDir, 'watch-task.yaml'); - writeFileSync(taskPath, taskYaml, 'utf-8'); + writeFileSync(tasksFile, taskYaml, 'utf-8'); const completed = await new Promise((resolvePromise) => { const timeout = setTimeout(() => resolvePromise(false), 240_000); @@ -87,6 +93,9 @@ describe('E2E: Watch tasks (takt watch)', () => { }); expect(completed).toBe(true); - expect(existsSync(taskPath)).toBe(false); + const tasksRaw = readFileSync(tasksFile, 'utf-8'); + const parsed = parseYaml(tasksRaw) as { tasks?: Array<{ name?: string; status?: string }> }; + const watchTask = parsed.tasks?.find((task) => task.name === 'watch-task'); + expect(watchTask?.status).toBe('completed'); }, 240_000); }); From c7c50db46af2a09bbe36fffeb3b53a3d122219a5 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 07:46:04 +0900 Subject: [PATCH 19/19] fix e2e --- e2e/specs/add-and-run.e2e.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/e2e/specs/add-and-run.e2e.ts b/e2e/specs/add-and-run.e2e.ts index 08dd0c4..3b7c47b 100644 --- a/e2e/specs/add-and-run.e2e.ts +++ b/e2e/specs/add-and-run.e2e.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { parse as parseYaml } from 'yaml'; import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; import { createTestRepo, type TestRepo } from '../helpers/test-repo'; import { runTakt } from '../helpers/takt-runner'; @@ -35,15 +36,22 @@ describe('E2E: Add task and run (takt add → takt run)', () => { it('should add a task file and execute it with takt run', () => { const piecePath = resolve(__dirname, '../fixtures/pieces/simple.yaml'); - // Step 1: Create a task file in .takt/tasks/ (simulates `takt add`) - const tasksDir = join(testRepo.path, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); + // Step 1: Create a pending task in .takt/tasks.yaml (simulates `takt add`) + const taktDir = join(testRepo.path, '.takt'); + mkdirSync(taktDir, { recursive: true }); + const tasksFile = join(taktDir, 'tasks.yaml'); const taskYaml = [ - 'task: "Add a single line \\"E2E test passed\\" to README.md"', - `piece: "${piecePath}"`, + 'tasks:', + ' - name: e2e-test-task', + ' status: pending', + ' content: "Add a single line \\"E2E test passed\\" to README.md"', + ` piece: "${piecePath}"`, + ` created_at: "${new Date().toISOString()}"`, + ' started_at: null', + ' completed_at: null', ].join('\n'); - writeFileSync(join(tasksDir, 'e2e-test-task.yaml'), taskYaml, 'utf-8'); + writeFileSync(tasksFile, taskYaml, 'utf-8'); // Step 2: Run `takt run` to execute the pending task const result = runTakt({ @@ -66,7 +74,10 @@ describe('E2E: Add task and run (takt add → takt run)', () => { const readme = readFileSync(readmePath, 'utf-8'); expect(readme).toContain('E2E test passed'); - // Verify task file was moved out of tasks/ (completed or failed) - expect(existsSync(join(tasksDir, 'e2e-test-task.yaml'))).toBe(false); + // Verify task status became completed + const tasksRaw = readFileSync(tasksFile, 'utf-8'); + const parsed = parseYaml(tasksRaw) as { tasks?: Array<{ name?: string; status?: string }> }; + const executed = parsed.tasks?.find((task) => task.name === 'e2e-test-task'); + expect(executed?.status).toBe('completed'); }, 240_000); });