From 0b2fa0e0f7d41a97ffabcfff2921ef3c1d45fdaa Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:00:26 +0900 Subject: [PATCH 01/15] =?UTF-8?q?fix:=20OpenCode=20SDK=E3=82=B5=E3=83=BC?= =?UTF-8?q?=E3=83=90=E3=83=BC=E8=B5=B7=E5=8B=95=E3=82=BF=E3=82=A4=E3=83=A0?= =?UTF-8?q?=E3=82=A2=E3=82=A6=E3=83=88=E3=82=9230=E7=A7=92=E3=81=AB?= =?UTF-8?q?=E5=BB=B6=E9=95=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/infra/opencode/client.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index 0b04999..632ea9f 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -36,6 +36,7 @@ const OPENCODE_STREAM_ABORTED_MESSAGE = 'OpenCode execution aborted'; const OPENCODE_RETRY_MAX_ATTEMPTS = 3; const OPENCODE_RETRY_BASE_DELAY_MS = 250; const OPENCODE_INTERACTION_TIMEOUT_MS = 5000; +const OPENCODE_SERVER_START_TIMEOUT_MS = 30000; const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ 'stream disconnected before completion', 'transport error', @@ -324,6 +325,7 @@ export class OpenCodeClient { port, signal: streamAbortController.signal, config, + timeout: OPENCODE_SERVER_START_TIMEOUT_MS, }); opencodeApiClient = client; serverClose = server.close; From c85f23cb6eabf7a930acd246e70221c2f5a61cd3 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:46:11 +0900 Subject: [PATCH 02/15] =?UTF-8?q?claude=20code=20=E3=81=8Csandbox=E3=81=A7?= =?UTF-8?q?=E5=AE=9F=E8=A1=8C=E3=81=95=E3=82=8C=E3=82=8B=E3=81=9F=E3=82=81?= =?UTF-8?q?=E3=80=81=E3=83=86=E3=82=B9=E3=83=88=E3=81=8C=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=84=E5=95=8F=E9=A1=8C=E3=82=92?= =?UTF-8?q?=E5=AF=BE=E5=87=A6=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=AA=E3=83=97?= =?UTF-8?q?=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/provider-sandbox.md | 168 ++++++++++++++++++ .../provider-options-piece-parser.test.ts | 76 +++++++- src/agents/runner.ts | 23 ++- src/agents/types.ts | 7 +- src/core/models/global-config.ts | 5 + src/core/models/index.ts | 1 + src/core/models/piece-types.ts | 14 ++ src/core/models/schemas.ts | 12 ++ src/core/models/types.ts | 1 + src/infra/claude/client.ts | 1 + src/infra/claude/options-builder.ts | 4 + src/infra/claude/types.ts | 8 +- src/infra/config/global/globalConfig.ts | 2 + src/infra/config/loaders/pieceParser.ts | 71 +++++--- src/infra/config/types.ts | 3 + src/infra/providers/claude.ts | 5 + src/infra/providers/types.ts | 7 +- 17 files changed, 372 insertions(+), 36 deletions(-) create mode 100644 docs/provider-sandbox.md diff --git a/docs/provider-sandbox.md b/docs/provider-sandbox.md new file mode 100644 index 0000000..9553c00 --- /dev/null +++ b/docs/provider-sandbox.md @@ -0,0 +1,168 @@ +# Provider Sandbox Configuration + +TAKT supports configuring sandbox settings for AI agent providers. This document covers how sandbox isolation works across providers, how to configure it, and the security trade-offs. + +## Overview + +| Provider | Sandbox Mechanism | Build Tool Issues | TAKT Configuration | +|----------|------------------|-------------------|-------------------| +| **Claude Code** | macOS Seatbelt / Linux bubblewrap | Gradle/JVM blocked in `edit` mode | `provider_options.claude.sandbox` | +| **Codex CLI** | macOS Seatbelt / Linux Landlock+seccomp | npm/maven/pytest failures (widespread) | `provider_options.codex.network_access` | +| **OpenCode CLI** | None (no native sandbox) | No constraints (no security either) | N/A | + +## Claude Code Sandbox + +### The Problem + +When a movement uses `permission_mode: edit` (mapped to Claude SDK's `acceptEdits`), Bash commands run inside a macOS Seatbelt sandbox. This sandbox blocks: + +- Writes outside the working directory (e.g., `~/.gradle`) +- Certain system calls required by JVM initialization +- Network access (by default) + +As a result, build tools like Gradle, Maven, or any JVM-based tool fail with `Operation not permitted`. + +### Solution: `provider_options.claude.sandbox` + +TAKT exposes Claude SDK's `SandboxSettings` through `provider_options.claude.sandbox` at four configuration levels. + +#### Option A: `allow_unsandboxed_commands` (Recommended) + +Allow all Bash commands to run outside the sandbox while keeping file edit permissions controlled: + +```yaml +provider_options: + claude: + sandbox: + allow_unsandboxed_commands: true +``` + +#### Option B: `excluded_commands` + +Exclude only specific commands from the sandbox: + +```yaml +provider_options: + claude: + sandbox: + excluded_commands: + - ./gradlew + - npm + - npx +``` + +### Configuration Levels + +Settings are merged with the following priority (highest wins): + +``` +Movement > Piece > Project Local > Global +``` + +#### Global (`~/.takt/config.yaml`) + +Applies to all projects and all pieces: + +```yaml +# ~/.takt/config.yaml +provider_options: + claude: + sandbox: + allow_unsandboxed_commands: true +``` + +#### Project Local (`.takt/config.yaml`) + +Applies to this project only: + +```yaml +# .takt/config.yaml +provider_options: + claude: + sandbox: + excluded_commands: + - ./gradlew +``` + +#### Piece (`piece_config` section) + +Applies to all movements in this piece: + +```yaml +# pieces/my-piece.yaml +piece_config: + provider_options: + claude: + sandbox: + allow_unsandboxed_commands: true +``` + +#### Movement (per step) + +Applies to a specific movement only: + +```yaml +movements: + - name: implement + permission_mode: edit + provider_options: + claude: + sandbox: + allow_unsandboxed_commands: true + - name: review + permission_mode: readonly + # No sandbox config needed — readonly doesn't sandbox Bash +``` + +### Security Risk Comparison + +| Configuration | File Edits | Network | Bash Commands | CWD-external Writes | Risk Level | +|--------------|-----------|---------|---------------|---------------------|------------| +| `permission_mode: edit` (default) | Permitted | Blocked | Sandboxed | Blocked | Low | +| `excluded_commands: [./gradlew]` | Permitted | Blocked | Only `./gradlew` unsandboxed | Only via `./gradlew` | Low | +| `allow_unsandboxed_commands: true` | Permitted | Allowed | Unsandboxed | Allowed via Bash | **Medium** | +| `permission_mode: full` | All permitted | Allowed | Unsandboxed | All permitted | **High** | + +**Key difference between `allow_unsandboxed_commands` and `permission_mode: full`:** +- `allow_unsandboxed_commands`: File edits still require Claude Code's permission check (`acceptEdits` mode). Only Bash is unsandboxed. +- `permission_mode: full`: All permission checks are bypassed (`bypassPermissions` mode). No guardrails at all. + +### Practical Risk Assessment + +The "Medium" risk of `allow_unsandboxed_commands` is manageable in practice because: + +- TAKT runs locally on the developer's machine (not a public-facing service) +- Input comes from task instructions written by the developer +- Agent behavior is reviewed by the supervisor movement +- File edit operations still go through Claude Code's permission system + +## Codex CLI Sandbox + +Codex CLI uses macOS Seatbelt (same as Claude Code) but has **more severe compatibility issues** with build tools. Community reports show npm, Maven, pytest, and other tools frequently failing with `Operation not permitted` — even when the same commands work in Claude Code. + +Codex sandbox is configured via `~/.codex/config.toml` (not through TAKT): + +```toml +# ~/.codex/config.toml +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +network_access = true +writable_roots = ["/Users/YOU/.gradle"] +``` + +TAKT provides `provider_options.codex.network_access` to control network access via the Codex SDK: + +```yaml +provider_options: + codex: + network_access: true +``` + +For other sandbox settings (writable_roots, sandbox_mode), configure directly in `~/.codex/config.toml`. + +## OpenCode CLI Sandbox + +OpenCode CLI does not have a native sandbox mechanism. All commands run without filesystem or network restrictions. For isolation, the community recommends Docker containers (e.g., [opencode-sandbox](https://github.com/fabianlema/opencode-sandbox)). + +No TAKT-side sandbox configuration is needed or available for OpenCode. diff --git a/src/__tests__/provider-options-piece-parser.test.ts b/src/__tests__/provider-options-piece-parser.test.ts index 45d7adc..79e36b1 100644 --- a/src/__tests__/provider-options-piece-parser.test.ts +++ b/src/__tests__/provider-options-piece-parser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { normalizePieceConfig } from '../infra/config/loaders/pieceParser.js'; +import { normalizePieceConfig, mergeProviderOptions } from '../infra/config/loaders/pieceParser.js'; describe('normalizePieceConfig provider_options', () => { it('piece-level global を movement に継承し、movement 側で上書きできる', () => { @@ -43,4 +43,78 @@ describe('normalizePieceConfig provider_options', () => { opencode: { networkAccess: false }, }); }); + + it('claude sandbox を piece-level で設定し movement で上書きできる', () => { + const raw = { + name: 'claude-sandbox', + piece_config: { + provider_options: { + claude: { + sandbox: { allow_unsandboxed_commands: true }, + }, + }, + }, + movements: [ + { + name: 'inherit', + instruction: '{task}', + }, + { + name: 'override', + provider_options: { + claude: { + sandbox: { + allow_unsandboxed_commands: false, + excluded_commands: ['./gradlew'], + }, + }, + }, + instruction: '{task}', + }, + ], + }; + + const config = normalizePieceConfig(raw, process.cwd()); + + expect(config.providerOptions).toEqual({ + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }); + expect(config.movements[0]?.providerOptions).toEqual({ + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }); + expect(config.movements[1]?.providerOptions).toEqual({ + claude: { + sandbox: { + allowUnsandboxedCommands: false, + excludedCommands: ['./gradlew'], + }, + }, + }); + }); +}); + +describe('mergeProviderOptions', () => { + it('複数層を正しくマージする(後の層が優先)', () => { + const global = { + claude: { sandbox: { allowUnsandboxedCommands: false, excludedCommands: ['./gradlew'] } }, + codex: { networkAccess: true }, + }; + const local = { + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }; + const step = { + codex: { networkAccess: false }, + }; + + const result = mergeProviderOptions(global, local, step); + + expect(result).toEqual({ + claude: { sandbox: { allowUnsandboxedCommands: true, excludedCommands: ['./gradlew'] } }, + codex: { networkAccess: false }, + }); + }); + + it('すべて undefined なら undefined を返す', () => { + expect(mergeProviderOptions(undefined, undefined, undefined)).toBeUndefined(); + }); }); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 80952cd..75691a6 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -5,8 +5,9 @@ import { existsSync, readFileSync } from 'node:fs'; import { basename, dirname } from 'node:path'; import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig } from '../infra/config/index.js'; +import { mergeProviderOptions } from '../infra/config/loaders/pieceParser.js'; import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js'; -import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js'; +import type { AgentResponse, CustomAgentConfig, MovementProviderOptions } from '../core/models/index.js'; import { createLogger } from '../shared/utils/index.js'; import { loadTemplate } from '../shared/prompts/index.js'; import type { RunAgentOptions } from './types.js'; @@ -92,6 +93,24 @@ export class AgentRunner { return `${dir}/${name}`; } + /** + * Resolve provider options with 4-layer priority: Global < Local < Step (piece+movement merged). + * Step already contains the piece+movement merge result from pieceParser. + */ + private static resolveProviderOptions( + cwd: string, + stepOptions?: MovementProviderOptions, + ): MovementProviderOptions | undefined { + let globalOptions: MovementProviderOptions | undefined; + try { + globalOptions = loadGlobalConfig().providerOptions; + } catch { /* ignore */ } + + const localOptions = loadProjectConfig(cwd).provider_options; + + return mergeProviderOptions(globalOptions, localOptions, stepOptions); + } + /** Build ProviderCallOptions from RunAgentOptions */ private static buildCallOptions( resolvedProvider: ProviderType, @@ -107,7 +126,7 @@ export class AgentRunner { maxTurns: options.maxTurns, model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig), permissionMode: options.permissionMode, - providerOptions: options.providerOptions, + providerOptions: AgentRunner.resolveProviderOptions(options.cwd, options.providerOptions), onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, diff --git a/src/agents/types.ts b/src/agents/types.ts index da7cea0..a7cbd06 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -3,7 +3,7 @@ */ import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../infra/claude/types.js'; -import type { PermissionMode, Language, McpServerConfig } from '../core/models/index.js'; +import type { PermissionMode, Language, McpServerConfig, MovementProviderOptions } from '../core/models/index.js'; export type { StreamCallback }; @@ -25,10 +25,7 @@ export interface RunAgentOptions { /** Permission mode for tool execution (from piece step) */ permissionMode?: PermissionMode; /** Provider-specific movement options */ - providerOptions?: { - codex?: { networkAccess?: boolean }; - opencode?: { networkAccess?: boolean }; - }; + providerOptions?: MovementProviderOptions; onStream?: StreamCallback; onPermissionRequest?: PermissionHandler; onAskUserQuestion?: AskUserQuestionHandler; diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 7ab9db5..8e90589 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -2,6 +2,8 @@ * Configuration types (global and project) */ +import type { MovementProviderOptions } from './piece-types.js'; + /** Custom agent configuration */ export interface CustomAgentConfig { name: string; @@ -86,6 +88,8 @@ export interface GlobalConfig { pieceCategoriesFile?: string; /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ personaProviders?: Record; + /** Global provider-specific options (lowest priority) */ + providerOptions?: MovementProviderOptions; /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branchNameStrategy?: 'romaji' | 'ai'; /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ @@ -107,4 +111,5 @@ export interface ProjectConfig { piece?: string; agents?: CustomAgentConfig[]; provider?: 'claude' | 'codex' | 'opencode' | 'mock'; + providerOptions?: MovementProviderOptions; } diff --git a/src/core/models/index.ts b/src/core/models/index.ts index 9177e1b..9cd5116 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -14,6 +14,7 @@ export type { PartResult, TeamLeaderConfig, PieceRule, + MovementProviderOptions, PieceMovement, ArpeggioMovementConfig, ArpeggioMergeMovementConfig, diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index 5a02088..8449032 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -92,10 +92,24 @@ export interface OpenCodeProviderOptions { networkAccess?: boolean; } +/** Claude sandbox settings (maps to SDK SandboxSettings) */ +export interface ClaudeSandboxSettings { + /** Allow all Bash commands to run outside the sandbox */ + allowUnsandboxedCommands?: boolean; + /** Specific commands to exclude from sandbox (e.g., ["./gradlew", "npm test"]) */ + excludedCommands?: string[]; +} + +/** Claude provider-specific options */ +export interface ClaudeProviderOptions { + sandbox?: ClaudeSandboxSettings; +} + /** Provider-specific movement options */ export interface MovementProviderOptions { codex?: CodexProviderOptions; opencode?: OpenCodeProviderOptions; + claude?: ClaudeProviderOptions; } /** Single movement in a piece */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 82a3dde..b23ff3b 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -59,6 +59,12 @@ export const StatusSchema = z.enum([ /** Permission mode schema for tool execution */ export const PermissionModeSchema = z.enum(['readonly', 'edit', 'full']); +/** Claude sandbox settings schema */ +export const ClaudeSandboxSchema = z.object({ + allow_unsandboxed_commands: z.boolean().optional(), + excluded_commands: z.array(z.string()).optional(), +}).optional(); + /** Provider-specific movement options schema */ export const MovementProviderOptionsSchema = z.object({ codex: z.object({ @@ -67,6 +73,9 @@ export const MovementProviderOptionsSchema = z.object({ opencode: z.object({ network_access: z.boolean().optional(), }).optional(), + claude: z.object({ + sandbox: ClaudeSandboxSchema, + }).optional(), }).optional(); /** Piece-level provider options schema */ @@ -414,6 +423,8 @@ export const GlobalConfigSchema = z.object({ piece_categories_file: z.string().optional(), /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(), + /** Global provider-specific options (lowest priority) */ + provider_options: MovementProviderOptionsSchema, /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branch_name_strategy: z.enum(['romaji', 'ai']).optional(), /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ @@ -441,4 +452,5 @@ export const ProjectConfigSchema = z.object({ piece: z.string().optional(), agents: z.array(CustomAgentConfigSchema).optional(), provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), + provider_options: MovementProviderOptionsSchema, }); diff --git a/src/core/models/types.ts b/src/core/models/types.ts index 84b8340..ce59af8 100644 --- a/src/core/models/types.ts +++ b/src/core/models/types.ts @@ -37,6 +37,7 @@ export type { OutputContractItem, OutputContractEntry, McpServerConfig, + MovementProviderOptions, PieceMovement, ArpeggioMovementConfig, ArpeggioMergeMovementConfig, diff --git a/src/infra/claude/client.ts b/src/infra/claude/client.ts index cb568e5..03da8c5 100644 --- a/src/infra/claude/client.ts +++ b/src/infra/claude/client.ts @@ -52,6 +52,7 @@ export class ClaudeClient { bypassPermissions: options.bypassPermissions, anthropicApiKey: options.anthropicApiKey, outputSchema: options.outputSchema, + sandbox: options.sandbox, }; } diff --git a/src/infra/claude/options-builder.ts b/src/infra/claude/options-builder.ts index dac37ab..9cf2ce6 100644 --- a/src/infra/claude/options-builder.ts +++ b/src/infra/claude/options-builder.ts @@ -95,6 +95,10 @@ export class SdkOptionsBuilder { sdkOptions.stderr = this.options.onStderr; } + if (this.options.sandbox) { + sdkOptions.sandbox = this.options.sandbox; + } + return sdkOptions; } diff --git a/src/infra/claude/types.ts b/src/infra/claude/types.ts index 1c19741..67114c6 100644 --- a/src/infra/claude/types.ts +++ b/src/infra/claude/types.ts @@ -5,8 +5,10 @@ * used throughout the Claude integration layer. */ -import type { PermissionUpdate, AgentDefinition } from '@anthropic-ai/claude-agent-sdk'; +import type { PermissionUpdate, AgentDefinition, SandboxSettings } from '@anthropic-ai/claude-agent-sdk'; import type { PermissionMode, McpServerConfig } from '../../core/models/index.js'; + +export type { SandboxSettings }; import type { PermissionResult } from '../../core/piece/index.js'; // Re-export PermissionResult for convenience @@ -145,6 +147,8 @@ export interface ClaudeCallOptions { anthropicApiKey?: string; /** JSON Schema for structured output */ outputSchema?: Record; + /** Sandbox settings for Claude SDK */ + sandbox?: SandboxSettings; } /** Options for spawning a Claude SDK query (low-level, used by executor/process) */ @@ -176,4 +180,6 @@ export interface ClaudeSpawnOptions { outputSchema?: Record; /** Callback for stderr output from the Claude Code process */ onStderr?: (data: string) => void; + /** Sandbox settings for Claude SDK */ + sandbox?: SandboxSettings; } diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 763f138..4d86a5c 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -9,6 +9,7 @@ import { readFileSync, existsSync, writeFileSync } from 'node:fs'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { GlobalConfigSchema } from '../../../core/models/index.js'; import type { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js'; +import { normalizeProviderOptions } from '../loaders/pieceParser.js'; import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js'; import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; import { parseProviderModel } from '../../../shared/utils/providerModel.js'; @@ -124,6 +125,7 @@ export class GlobalConfigManager { bookmarksFile: parsed.bookmarks_file, pieceCategoriesFile: parsed.piece_categories_file, personaProviders: parsed.persona_providers, + providerOptions: normalizeProviderOptions(parsed.provider_options), branchNameStrategy: parsed.branch_name_strategy, preventSleep: parsed.prevent_sleep, notificationSound: parsed.notification_sound, diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 83df5dc..525fc04 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -24,34 +24,61 @@ import { type RawStep = z.output; -function normalizeProviderOptions( +import type { MovementProviderOptions } from '../../../core/models/piece-types.js'; + +/** Convert raw YAML provider_options (snake_case) to internal format (camelCase). */ +export function normalizeProviderOptions( raw: RawStep['provider_options'], -): PieceMovement['providerOptions'] { +): MovementProviderOptions | undefined { if (!raw) return undefined; - const codex = raw.codex?.network_access === undefined - ? undefined - : { networkAccess: raw.codex.network_access }; - const opencode = raw.opencode?.network_access === undefined - ? undefined - : { networkAccess: raw.opencode.network_access }; - - if (!codex && !opencode) return undefined; - return { ...(codex ? { codex } : {}), ...(opencode ? { opencode } : {}) }; + const result: MovementProviderOptions = {}; + if (raw.codex?.network_access !== undefined) { + result.codex = { networkAccess: raw.codex.network_access }; + } + if (raw.opencode?.network_access !== undefined) { + result.opencode = { networkAccess: raw.opencode.network_access }; + } + if (raw.claude?.sandbox) { + result.claude = { + sandbox: { + ...(raw.claude.sandbox.allow_unsandboxed_commands !== undefined + ? { allowUnsandboxedCommands: raw.claude.sandbox.allow_unsandboxed_commands } + : {}), + ...(raw.claude.sandbox.excluded_commands !== undefined + ? { excludedCommands: raw.claude.sandbox.excluded_commands } + : {}), + }, + }; + } + return Object.keys(result).length > 0 ? result : undefined; } -function mergeProviderOptions( - base: PieceMovement['providerOptions'], - override: PieceMovement['providerOptions'], -): PieceMovement['providerOptions'] { - const codexNetworkAccess = override?.codex?.networkAccess ?? base?.codex?.networkAccess; - const opencodeNetworkAccess = override?.opencode?.networkAccess ?? base?.opencode?.networkAccess; - - const codex = codexNetworkAccess === undefined ? undefined : { networkAccess: codexNetworkAccess }; - const opencode = opencodeNetworkAccess === undefined ? undefined : { networkAccess: opencodeNetworkAccess }; +/** + * Deep merge provider options. Later sources override earlier ones. + * Exported for reuse in runner.ts (4-layer resolution). + */ +export function mergeProviderOptions( + ...layers: (MovementProviderOptions | undefined)[] +): MovementProviderOptions | undefined { + const result: MovementProviderOptions = {}; + + for (const layer of layers) { + if (!layer) continue; + if (layer.codex) { + result.codex = { ...result.codex, ...layer.codex }; + } + if (layer.opencode) { + result.opencode = { ...result.opencode, ...layer.opencode }; + } + if (layer.claude?.sandbox) { + result.claude = { + sandbox: { ...result.claude?.sandbox, ...layer.claude.sandbox }, + }; + } + } - if (!codex && !opencode) return undefined; - return { ...(codex ? { codex } : {}), ...(opencode ? { opencode } : {}) }; + return Object.keys(result).length > 0 ? result : undefined; } /** Check if a raw output contract item is the object form (has 'name' property). */ diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index f29d537..334d105 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -3,6 +3,7 @@ */ import type { PieceCategoryConfigNode } from '../../core/models/schemas.js'; +import type { MovementProviderOptions } from '../../core/models/piece-types.js'; /** Permission mode for the project * - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts) @@ -22,6 +23,8 @@ export interface ProjectLocalConfig { permissionMode?: PermissionMode; /** Verbose output mode */ verbose?: boolean; + /** Provider-specific options (overrides global, overridden by piece/movement) */ + provider_options?: MovementProviderOptions; /** Piece categories (name -> piece list) */ piece_categories?: Record; /** Show uncategorized pieces under Others category */ diff --git a/src/infra/providers/claude.ts b/src/infra/providers/claude.ts index a47702f..962afd5 100644 --- a/src/infra/providers/claude.ts +++ b/src/infra/providers/claude.ts @@ -9,6 +9,7 @@ import type { AgentResponse } from '../../core/models/index.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions { + const claudeSandbox = options.providerOptions?.claude?.sandbox; return { cwd: options.cwd, abortSignal: options.abortSignal, @@ -24,6 +25,10 @@ function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions { bypassPermissions: options.bypassPermissions, anthropicApiKey: options.anthropicApiKey ?? resolveAnthropicApiKey(), outputSchema: options.outputSchema, + sandbox: claudeSandbox ? { + allowUnsandboxedCommands: claudeSandbox.allowUnsandboxedCommands, + excludedCommands: claudeSandbox.excludedCommands, + } : undefined, }; } diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index b47c2e8..9560cf2 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -3,7 +3,7 @@ */ import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/index.js'; -import type { AgentResponse, PermissionMode, McpServerConfig } from '../../core/models/index.js'; +import type { AgentResponse, PermissionMode, McpServerConfig, MovementProviderOptions } from '../../core/models/index.js'; /** Agent setup configuration — determines HOW the provider invokes the agent */ export interface AgentSetup { @@ -31,10 +31,7 @@ export interface ProviderCallOptions { /** Permission mode for tool execution (from piece step) */ permissionMode?: PermissionMode; /** Provider-specific movement options */ - providerOptions?: { - codex?: { networkAccess?: boolean }; - opencode?: { networkAccess?: boolean }; - }; + providerOptions?: MovementProviderOptions; onStream?: StreamCallback; onPermissionRequest?: PermissionHandler; onAskUserQuestion?: AskUserQuestionHandler; From 3ff6f99152b787f4328af324b7d2d626effee8e0 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:52:17 +0900 Subject: [PATCH 03/15] task-1770947391780 (#270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: OpenCode SDKサーバー起動タイムアウトを30秒に延長 * takt: task-1770947391780 --- OPENCODE_CONFIG_CONTENT | 2 ++ src/__tests__/saveTaskFile.test.ts | 35 +++++++++++++++++++++++++++--- src/features/tasks/add/index.ts | 2 +- src/infra/opencode/client.ts | 2 ++ 4 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 OPENCODE_CONFIG_CONTENT diff --git a/OPENCODE_CONFIG_CONTENT b/OPENCODE_CONFIG_CONTENT new file mode 100644 index 0000000..6b5dea8 --- /dev/null +++ b/OPENCODE_CONFIG_CONTENT @@ -0,0 +1,2 @@ +{ + "$schema": "https://opencode.ai/config.json","model":"zai-coding-plan/glm-5","small_model":"zai-coding-plan/glm-5","permission":"deny"} \ No newline at end of file diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index f4edfb1..54d0e91 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -139,14 +139,43 @@ describe('saveTaskFromInteractive', () => { }); it('should record issue number in tasks.yaml when issue option is provided', async () => { - // Given: user declines worktree mockConfirm.mockResolvedValueOnce(false); - // When await saveTaskFromInteractive(testDir, 'Fix login bug', 'default', { issue: 42 }); - // Then const task = loadTasks(testDir).tasks[0]!; expect(task.issue).toBe(42); }); + + describe('with confirmAtEndMessage', () => { + it('should not save task when user declines confirmAtEndMessage', async () => { + mockConfirm.mockResolvedValueOnce(false); + + await saveTaskFromInteractive(testDir, 'Task content', 'default', { + issue: 42, + confirmAtEndMessage: 'Add this issue to tasks?', + }); + + expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); + }); + + it('should prompt worktree settings after confirming confirmAtEndMessage', async () => { + mockConfirm.mockResolvedValueOnce(true); + mockPromptInput.mockResolvedValueOnce(''); + mockPromptInput.mockResolvedValueOnce(''); + mockConfirm.mockResolvedValueOnce(true); + mockConfirm.mockResolvedValueOnce(false); + + await saveTaskFromInteractive(testDir, 'Task content', 'default', { + issue: 42, + confirmAtEndMessage: 'Add this issue to tasks?', + }); + + expect(mockConfirm).toHaveBeenNthCalledWith(1, 'Add this issue to tasks?', true); + expect(mockConfirm).toHaveBeenNthCalledWith(2, 'Create worktree?', true); + const task = loadTasks(testDir).tasks[0]!; + expect(task.issue).toBe(42); + expect(task.worktree).toBe(true); + }); + }); }); diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index cd48f40..3fe042f 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -151,13 +151,13 @@ export async function saveTaskFromInteractive( piece?: string, options?: { issue?: number; confirmAtEndMessage?: string }, ): Promise { - const settings = await promptWorktreeSettings(); if (options?.confirmAtEndMessage) { const approved = await confirm(options.confirmAtEndMessage, true); if (!approved) { return; } } + const settings = await promptWorktreeSettings(); const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings }); displayTaskCreationResult(created, settings, piece); } diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index 841221d..1f33038 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -36,6 +36,7 @@ const OPENCODE_STREAM_ABORTED_MESSAGE = 'OpenCode execution aborted'; const OPENCODE_RETRY_MAX_ATTEMPTS = 3; const OPENCODE_RETRY_BASE_DELAY_MS = 250; const OPENCODE_INTERACTION_TIMEOUT_MS = 5000; +const OPENCODE_SERVER_START_TIMEOUT_MS = 30000; const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ 'stream disconnected before completion', 'transport error', @@ -324,6 +325,7 @@ export class OpenCodeClient { port, signal: streamAbortController.signal, config, + timeout: OPENCODE_SERVER_START_TIMEOUT_MS, }); opencodeApiClient = client; serverClose = server.close; From 02272e595ccd98d4270b61f04669d4a5d58baf66 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:59:00 +0900 Subject: [PATCH 04/15] github-issue-255-ui (#266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update builtin * fix: OpenCode SDKサーバー起動タイムアウトを30秒に延長 * takt: github-issue-255-ui * 無駄な条件分岐を削除 --- src/__tests__/piece-selection.test.ts | 104 +++++++++++++++++- src/__tests__/selectAndExecute-autoPr.test.ts | 57 ++-------- src/__tests__/switchPiece.test.ts | 80 +++++--------- src/features/config/switchPiece.ts | 29 +---- src/features/pieceSelection/index.ts | 48 ++++++++ .../tasks/execute/selectAndExecute.ts | 58 +--------- src/features/tasks/list/taskActions.ts | 29 +---- 7 files changed, 190 insertions(+), 215 deletions(-) diff --git a/src/__tests__/piece-selection.test.ts b/src/__tests__/piece-selection.test.ts index a05088a..94ab056 100644 --- a/src/__tests__/piece-selection.test.ts +++ b/src/__tests__/piece-selection.test.ts @@ -33,7 +33,19 @@ vi.mock('../infra/config/index.js', async (importOriginal) => { return actual; }); -const { selectPieceFromEntries, selectPieceFromCategorizedPieces } = await import('../features/pieceSelection/index.js'); +const configMock = vi.hoisted(() => ({ + listPieces: vi.fn(), + listPieceEntries: vi.fn(), + loadAllPiecesWithSources: vi.fn(), + getPieceCategories: vi.fn(), + buildCategorizedPieces: vi.fn(), + getCurrentPiece: vi.fn(), + findPieceCategories: vi.fn(() => []), +})); + +vi.mock('../infra/config/index.js', () => configMock); + +const { selectPieceFromEntries, selectPieceFromCategorizedPieces, selectPiece } = await import('../features/pieceSelection/index.js'); describe('selectPieceFromEntries', () => { beforeEach(() => { @@ -231,3 +243,93 @@ describe('selectPieceFromCategorizedPieces', () => { expect(labels.some((l) => l.includes('Dev'))).toBe(false); }); }); + +describe('selectPiece', () => { + const entries: PieceDirEntry[] = [ + { name: 'custom-flow', path: '/tmp/custom.yaml', source: 'user' }, + { name: 'builtin-flow', path: '/tmp/builtin.yaml', source: 'builtin' }, + ]; + + beforeEach(() => { + selectOptionMock.mockReset(); + bookmarkState.bookmarks = []; + configMock.listPieces.mockReset(); + configMock.listPieceEntries.mockReset(); + configMock.loadAllPiecesWithSources.mockReset(); + configMock.getPieceCategories.mockReset(); + configMock.buildCategorizedPieces.mockReset(); + configMock.getCurrentPiece.mockReset(); + }); + + it('should return default piece when no pieces found and fallbackToDefault is true', async () => { + configMock.getPieceCategories.mockReturnValue(null); + configMock.listPieces.mockReturnValue([]); + configMock.getCurrentPiece.mockReturnValue('default'); + + const result = await selectPiece('/cwd'); + + expect(result).toBe('default'); + }); + + it('should return null when no pieces found and fallbackToDefault is false', async () => { + configMock.getPieceCategories.mockReturnValue(null); + configMock.listPieces.mockReturnValue([]); + configMock.getCurrentPiece.mockReturnValue('default'); + + const result = await selectPiece('/cwd', { fallbackToDefault: false }); + + expect(result).toBeNull(); + }); + + it('should prompt selection even when only one piece exists', async () => { + configMock.getPieceCategories.mockReturnValue(null); + configMock.listPieces.mockReturnValue(['only-piece']); + configMock.listPieceEntries.mockReturnValue([ + { name: 'only-piece', path: '/tmp/only-piece.yaml', source: 'user' }, + ]); + configMock.getCurrentPiece.mockReturnValue('only-piece'); + selectOptionMock.mockResolvedValueOnce('only-piece'); + + const result = await selectPiece('/cwd'); + + expect(result).toBe('only-piece'); + expect(selectOptionMock).toHaveBeenCalled(); + }); + + it('should use category-based selection when category config exists', async () => { + const pieceMap = createPieceMap([{ name: 'my-piece', source: 'user' }]); + const categorized: CategorizedPieces = { + categories: [{ name: 'Dev', pieces: ['my-piece'], children: [] }], + allPieces: pieceMap, + missingPieces: [], + }; + + configMock.getPieceCategories.mockReturnValue({ categories: ['Dev'] }); + configMock.loadAllPiecesWithSources.mockReturnValue(pieceMap); + configMock.buildCategorizedPieces.mockReturnValue(categorized); + configMock.getCurrentPiece.mockReturnValue('my-piece'); + + selectOptionMock.mockResolvedValueOnce('__current__'); + + const result = await selectPiece('/cwd'); + + expect(result).toBe('my-piece'); + expect(configMock.buildCategorizedPieces).toHaveBeenCalled(); + }); + + it('should use directory-based selection when no category config', async () => { + configMock.getPieceCategories.mockReturnValue(null); + configMock.listPieces.mockReturnValue(['piece-a', 'piece-b']); + configMock.listPieceEntries.mockReturnValue(entries); + configMock.getCurrentPiece.mockReturnValue('piece-a'); + + selectOptionMock + .mockResolvedValueOnce('custom') + .mockResolvedValueOnce('custom-flow'); + + const result = await selectPiece('/cwd'); + + expect(result).toBe('custom-flow'); + expect(configMock.listPieceEntries).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index 2aad356..b483e04 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -13,9 +13,6 @@ vi.mock('../infra/config/index.js', () => ({ listPieces: vi.fn(() => ['default']), listPieceEntries: vi.fn(() => []), isPiecePath: vi.fn(() => false), - loadAllPiecesWithSources: vi.fn(() => new Map()), - getPieceCategories: vi.fn(() => null), - buildCategorizedPieces: vi.fn(), loadGlobalConfig: vi.fn(() => ({})), })); @@ -60,29 +57,25 @@ vi.mock('../features/pieceSelection/index.js', () => ({ warnMissingPieces: vi.fn(), selectPieceFromCategorizedPieces: vi.fn(), selectPieceFromEntries: vi.fn(), + selectPiece: vi.fn(), })); import { confirm } from '../shared/prompt/index.js'; import { getCurrentPiece, - loadAllPiecesWithSources, - getPieceCategories, - buildCategorizedPieces, + listPieces, } from '../infra/config/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../infra/task/index.js'; -import { warnMissingPieces, selectPieceFromCategorizedPieces } from '../features/pieceSelection/index.js'; +import { selectPiece } 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 mockListPieces = vi.mocked(listPieces); 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); +const mockSelectPiece = vi.mocked(selectPiece); beforeEach(() => { vi.clearAllMocks(); @@ -121,44 +114,12 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { 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', - maxMovements: 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'); + it('should call selectPiece when no override is provided', async () => { + mockSelectPiece.mockResolvedValue('selected-piece'); - // When const selected = await determinePiece('/project'); - // Then - expect(selected).toBe('default'); - expect(mockWarnMissingPieces).toHaveBeenCalledWith([ - { categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, - ]); + expect(selected).toBe('selected-piece'); + expect(mockSelectPiece).toHaveBeenCalledWith('/project'); }); }); diff --git a/src/__tests__/switchPiece.test.ts b/src/__tests__/switchPiece.test.ts index 44ab51a..4f1857c 100644 --- a/src/__tests__/switchPiece.test.ts +++ b/src/__tests__/switchPiece.test.ts @@ -5,19 +5,13 @@ 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(), + selectPiece: vi.fn(), })); vi.mock('../shared/ui/index.js', () => ({ @@ -26,65 +20,41 @@ vi.mock('../shared/ui/index.js', () => ({ error: vi.fn(), })); -import { - loadAllPiecesWithSources, - getPieceCategories, - buildCategorizedPieces, -} from '../infra/config/index.js'; -import { - warnMissingPieces, - selectPieceFromCategorizedPieces, -} from '../features/pieceSelection/index.js'; +import { getCurrentPiece, loadPiece, setCurrentPiece } from '../infra/config/index.js'; +import { selectPiece } 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); +const mockGetCurrentPiece = vi.mocked(getCurrentPiece); +const mockLoadPiece = vi.mocked(loadPiece); +const mockSetCurrentPiece = vi.mocked(setCurrentPiece); +const mockSelectPiece = vi.mocked(selectPiece); 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', - maxMovements: 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); + it('should call selectPiece with fallbackToDefault: false', async () => { + mockSelectPiece.mockResolvedValue(null); - // When const switched = await switchPiece('/project'); - // Then expect(switched).toBe(false); - expect(mockWarnMissingPieces).toHaveBeenCalledWith([ - { categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, - ]); + expect(mockSelectPiece).toHaveBeenCalledWith('/project', { fallbackToDefault: false }); + }); + + it('should switch to selected piece', async () => { + mockSelectPiece.mockResolvedValue('new-piece'); + mockLoadPiece.mockReturnValue({ + name: 'new-piece', + movements: [], + initialMovement: 'start', + maxMovements: 1, + }); + + const switched = await switchPiece('/project'); + + expect(switched).toBe(true); + expect(mockSetCurrentPiece).toHaveBeenCalledWith('/project', 'new-piece'); }); }); diff --git a/src/features/config/switchPiece.ts b/src/features/config/switchPiece.ts index 5fab782..d2b26c1 100644 --- a/src/features/config/switchPiece.ts +++ b/src/features/config/switchPiece.ts @@ -3,48 +3,23 @@ */ import { - listPieceEntries, - loadAllPiecesWithSources, - getPieceCategories, - buildCategorizedPieces, loadPiece, getCurrentPiece, setCurrentPiece, } from '../../infra/config/index.js'; import { info, success, error } from '../../shared/ui/index.js'; -import { - warnMissingPieces, - selectPieceFromCategorizedPieces, - selectPieceFromEntries, -} from '../pieceSelection/index.js'; +import { selectPiece } from '../pieceSelection/index.js'; /** * Switch to a different piece * @returns true if switch was successful */ export async function switchPiece(cwd: string, pieceName?: string): Promise { - // No piece specified - show selection prompt if (!pieceName) { const current = getCurrentPiece(cwd); info(`Current piece: ${current}`); - const categoryConfig = getPieceCategories(); - let selected: string | null; - if (categoryConfig) { - const allPieces = loadAllPiecesWithSources(cwd); - if (allPieces.size === 0) { - info('No pieces found.'); - selected = null; - } else { - const categorized = buildCategorizedPieces(allPieces, categoryConfig); - warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user')); - selected = await selectPieceFromCategorizedPieces(categorized, current); - } - } else { - const entries = listPieceEntries(cwd); - selected = await selectPieceFromEntries(entries, current); - } - + const selected = await selectPiece(cwd, { fallbackToDefault: false }); if (!selected) { info('Cancelled'); return false; diff --git a/src/features/pieceSelection/index.ts b/src/features/pieceSelection/index.ts index 689c096..67cfa98 100644 --- a/src/features/pieceSelection/index.ts +++ b/src/features/pieceSelection/index.ts @@ -12,11 +12,18 @@ import { } from '../../infra/config/global/index.js'; import { findPieceCategories, + listPieces, + listPieceEntries, + loadAllPiecesWithSources, + getPieceCategories, + buildCategorizedPieces, + getCurrentPiece, type PieceDirEntry, type PieceCategoryNode, type CategorizedPieces, type MissingPiece, } from '../../infra/config/index.js'; +import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; /** Top-level selection item: either a piece or a category containing pieces */ export type PieceSelectionItem = @@ -504,3 +511,44 @@ export async function selectPieceFromEntries( const entriesToUse = customEntries.length > 0 ? customEntries : builtinEntries; return selectPieceFromEntriesWithCategories(entriesToUse, currentPiece); } + +export interface SelectPieceOptions { + fallbackToDefault?: boolean; +} + +export async function selectPiece( + cwd: string, + options?: SelectPieceOptions, +): Promise { + const fallbackToDefault = options?.fallbackToDefault !== false; + const categoryConfig = getPieceCategories(); + const currentPiece = getCurrentPiece(cwd); + + if (categoryConfig) { + const allPieces = loadAllPiecesWithSources(cwd); + if (allPieces.size === 0) { + if (fallbackToDefault) { + info(`No pieces found. Using default: ${DEFAULT_PIECE_NAME}`); + return DEFAULT_PIECE_NAME; + } + info('No pieces found.'); + return null; + } + const categorized = buildCategorizedPieces(allPieces, categoryConfig); + warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user')); + return selectPieceFromCategorizedPieces(categorized, currentPiece); + } + + const availablePieces = listPieces(cwd); + if (availablePieces.length === 0) { + if (fallbackToDefault) { + info(`No pieces found. Using default: ${DEFAULT_PIECE_NAME}`); + return DEFAULT_PIECE_NAME; + } + info('No pieces found.'); + return null; + } + + const entries = listPieceEntries(cwd); + return selectPieceFromEntries(entries, currentPiece); +} diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 5816921..3014634 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -7,13 +7,8 @@ */ import { - getCurrentPiece, listPieces, - listPieceEntries, isPiecePath, - loadAllPiecesWithSources, - getPieceCategories, - buildCategorizedPieces, loadGlobalConfig, } from '../../../infra/config/index.js'; import { confirm } from '../../../shared/prompt/index.js'; @@ -24,63 +19,12 @@ import { createLogger } from '../../../shared/utils/index.js'; import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js'; import { executeTask } from './taskExecution.js'; import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js'; -import { - warnMissingPieces, - selectPieceFromCategorizedPieces, - selectPieceFromEntries, -} from '../../pieceSelection/index.js'; +import { selectPiece } from '../../pieceSelection/index.js'; export type { WorktreeConfirmationResult, SelectAndExecuteOptions }; const log = createLogger('selectAndExecute'); -/** - * Select a piece interactively with directory categories and bookmarks. - */ -async function selectPieceWithDirectoryCategories(cwd: string): Promise { - const availablePieces = listPieces(cwd); - const currentPiece = getCurrentPiece(cwd); - - if (availablePieces.length === 0) { - info(`No pieces found. Using default: ${DEFAULT_PIECE_NAME}`); - return DEFAULT_PIECE_NAME; - } - - if (availablePieces.length === 1 && availablePieces[0]) { - return availablePieces[0]; - } - - const entries = listPieceEntries(cwd); - return selectPieceFromEntries(entries, currentPiece); -} - - -/** - * Select a piece interactively with 2-stage category support. - */ -async function selectPiece(cwd: string): Promise { - const categoryConfig = getPieceCategories(); - if (categoryConfig) { - const current = getCurrentPiece(cwd); - const allPieces = loadAllPiecesWithSources(cwd); - if (allPieces.size === 0) { - info(`No pieces found. Using default: ${DEFAULT_PIECE_NAME}`); - return DEFAULT_PIECE_NAME; - } - const categorized = buildCategorizedPieces(allPieces, categoryConfig); - warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user')); - return selectPieceFromCategorizedPieces(categorized, current); - } - return selectPieceWithDirectoryCategories(cwd); -} - -/** - * Determine piece to use. - * - * - If override looks like a path (isPiecePath), return it directly (validation is done at load time). - * - If override is a name, validate it exists in available pieces. - * - If no override, prompt user to select interactively. - */ export async function determinePiece(cwd: string, override?: string): Promise { if (override) { if (isPiecePath(override)) { diff --git a/src/features/tasks/list/taskActions.ts b/src/features/tasks/list/taskActions.ts index 2315529..8321c0b 100644 --- a/src/features/tasks/list/taskActions.ts +++ b/src/features/tasks/list/taskActions.ts @@ -24,13 +24,11 @@ import { info, success, error as logError, warn, header, blankLine } from '../.. import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { executeTask } from '../execute/taskExecution.js'; import type { TaskExecutionOptions } from '../execute/types.js'; -import { listPieces, getCurrentPiece } from '../../../infra/config/index.js'; -import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js'; +import { selectPiece } from '../../pieceSelection/index.js'; const log = createLogger('list-tasks'); -/** Actions available for a listed branch */ export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; /** @@ -254,29 +252,6 @@ export function deleteBranch(projectDir: string, item: BranchListItem): boolean } } -/** - * Get the piece to use for instruction. - */ -async function selectPieceForInstruction(projectDir: string): Promise { - const availablePieces = listPieces(projectDir); - const currentPiece = getCurrentPiece(projectDir); - - if (availablePieces.length === 0) { - return DEFAULT_PIECE_NAME; - } - - if (availablePieces.length === 1 && availablePieces[0]) { - return availablePieces[0]; - } - - const options = availablePieces.map((name) => ({ - label: name === currentPiece ? `${name} (current)` : name, - value: name, - })); - - return await selectOption('Select piece:', options); -} - /** * Get branch context: diff stat and commit log from main branch. */ @@ -343,7 +318,7 @@ export async function instructBranch( return false; } - const selectedPiece = await selectPieceForInstruction(projectDir); + const selectedPiece = await selectPiece(projectDir); if (!selectedPiece) { info('Cancelled'); return false; From 4e58c866437b28b8c1e4515da6972e36d042d3a6 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:08:28 +0900 Subject: [PATCH 05/15] github-issue-256-takt-list-instruct (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: OpenCode SDKサーバー起動タイムアウトを30秒に延長 * takt: github-issue-256-takt-list-instruct * refactor: 会話後アクションフローを共通化 --- src/__tests__/actionDispatcher.test.ts | 39 +++ src/__tests__/instructMode.test.ts | 282 +++++++++++++++++++ src/app/cli/routing.ts | 43 ++- src/features/interactive/actionDispatcher.ts | 20 ++ src/features/interactive/conversationLoop.ts | 9 +- src/features/interactive/interactive.ts | 85 +++++- src/features/tasks/list/index.ts | 6 + src/features/tasks/list/instructMode.ts | 123 ++++++++ src/features/tasks/list/taskActions.ts | 130 +++++---- src/shared/i18n/labels_en.yaml | 16 ++ src/shared/i18n/labels_ja.yaml | 16 ++ 11 files changed, 686 insertions(+), 83 deletions(-) create mode 100644 src/__tests__/actionDispatcher.test.ts create mode 100644 src/__tests__/instructMode.test.ts create mode 100644 src/features/interactive/actionDispatcher.ts create mode 100644 src/features/tasks/list/instructMode.ts diff --git a/src/__tests__/actionDispatcher.test.ts b/src/__tests__/actionDispatcher.test.ts new file mode 100644 index 0000000..59671e7 --- /dev/null +++ b/src/__tests__/actionDispatcher.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { dispatchConversationAction } from '../features/interactive/actionDispatcher.js'; + +describe('dispatchConversationAction', () => { + it('should dispatch to matching handler with full result payload', async () => { + const execute = vi.fn().mockResolvedValue('executed'); + const saveTask = vi.fn().mockResolvedValue('saved'); + const cancel = vi.fn().mockResolvedValue('cancelled'); + + const result = await dispatchConversationAction( + { action: 'save_task', task: 'refine branch docs' }, + { + execute, + save_task: saveTask, + cancel, + }, + ); + + expect(result).toBe('saved'); + expect(saveTask).toHaveBeenCalledWith({ action: 'save_task', task: 'refine branch docs' }); + expect(execute).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('should support synchronous handlers', async () => { + const result = await dispatchConversationAction( + { action: 'cancel', task: '' }, + { + execute: () => true, + save_task: () => true, + cancel: () => false, + }, + ); + + expect(result).toBe(false); + }); +}); + diff --git a/src/__tests__/instructMode.test.ts b/src/__tests__/instructMode.test.ts new file mode 100644 index 0000000..b01d1d1 --- /dev/null +++ b/src/__tests__/instructMode.test.ts @@ -0,0 +1,282 @@ +/** + * Tests for instruct mode + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../infra/config/global/globalConfig.js', () => ({ + loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })), + getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), +})); + +vi.mock('../infra/providers/index.js', () => ({ + getProvider: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('../shared/context.js', () => ({ + isQuietMode: vi.fn(() => false), +})); + +vi.mock('../infra/config/paths.js', async (importOriginal) => ({ + ...(await importOriginal>()), + loadPersonaSessions: vi.fn(() => ({})), + updatePersonaSession: vi.fn(), + getProjectConfigDir: vi.fn(() => '/tmp'), + loadSessionState: vi.fn(() => null), + clearSessionState: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + error: vi.fn(), + blankLine: vi.fn(), + StreamDisplay: vi.fn().mockImplementation(() => ({ + createHandler: vi.fn(() => vi.fn()), + flush: vi.fn(), + })), +})); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: vi.fn(), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'), + getLabelObject: vi.fn(() => ({ + intro: 'Instruct mode intro', + resume: 'Resuming', + noConversation: 'No conversation', + summarizeFailed: 'Summarize failed', + continuePrompt: 'Continue', + proposed: 'Proposed task:', + actionPrompt: 'What to do?', + actions: { + execute: 'Execute', + saveTask: 'Save task', + continue: 'Continue', + }, + cancelled: 'Cancelled', + })), +})); + +vi.mock('../shared/prompts/index.js', () => ({ + loadTemplate: vi.fn((_name: string, _lang: string) => 'Mock template content'), +})); + +import { getProvider } from '../infra/providers/index.js'; +import { runInstructMode } from '../features/tasks/list/instructMode.js'; +import { selectOption } from '../shared/prompt/index.js'; +import { info } from '../shared/ui/index.js'; + +const mockGetProvider = vi.mocked(getProvider); +const mockSelectOption = vi.mocked(selectOption); +const mockInfo = vi.mocked(info); + +let savedIsTTY: boolean | undefined; +let savedIsRaw: boolean | undefined; +let savedSetRawMode: typeof process.stdin.setRawMode | undefined; +let savedStdoutWrite: typeof process.stdout.write; +let savedStdinOn: typeof process.stdin.on; +let savedStdinRemoveListener: typeof process.stdin.removeListener; +let savedStdinResume: typeof process.stdin.resume; +let savedStdinPause: typeof process.stdin.pause; + +function setupRawStdin(rawInputs: string[]): void { + savedIsTTY = process.stdin.isTTY; + savedIsRaw = process.stdin.isRaw; + savedSetRawMode = process.stdin.setRawMode; + savedStdoutWrite = process.stdout.write; + savedStdinOn = process.stdin.on; + savedStdinRemoveListener = process.stdin.removeListener; + savedStdinResume = process.stdin.resume; + savedStdinPause = process.stdin.pause; + + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isRaw', { value: false, configurable: true, writable: true }); + process.stdin.setRawMode = vi.fn((mode: boolean) => { + (process.stdin as unknown as { isRaw: boolean }).isRaw = mode; + return process.stdin; + }) as unknown as typeof process.stdin.setRawMode; + process.stdout.write = vi.fn(() => true) as unknown as typeof process.stdout.write; + process.stdin.resume = vi.fn(() => process.stdin) as unknown as typeof process.stdin.resume; + process.stdin.pause = vi.fn(() => process.stdin) as unknown as typeof process.stdin.pause; + + let currentHandler: ((data: Buffer) => void) | null = null; + let inputIndex = 0; + + process.stdin.on = vi.fn(((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'data') { + currentHandler = handler as (data: Buffer) => void; + if (inputIndex < rawInputs.length) { + const data = rawInputs[inputIndex]!; + inputIndex++; + queueMicrotask(() => { + if (currentHandler) { + currentHandler(Buffer.from(data, 'utf-8')); + } + }); + } + } + return process.stdin; + }) as typeof process.stdin.on); + + process.stdin.removeListener = vi.fn(((event: string) => { + if (event === 'data') { + currentHandler = null; + } + return process.stdin; + }) as typeof process.stdin.removeListener); +} + +function restoreStdin(): void { + if (savedIsTTY !== undefined) { + Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true }); + } + if (savedIsRaw !== undefined) { + Object.defineProperty(process.stdin, 'isRaw', { value: savedIsRaw, configurable: true, writable: true }); + } + if (savedSetRawMode) { + process.stdin.setRawMode = savedSetRawMode; + } + if (savedStdoutWrite) { + process.stdout.write = savedStdoutWrite; + } + if (savedStdinOn) { + process.stdin.on = savedStdinOn; + } + if (savedStdinRemoveListener) { + process.stdin.removeListener = savedStdinRemoveListener; + } + if (savedStdinResume) { + process.stdin.resume = savedStdinResume; + } + if (savedStdinPause) { + process.stdin.pause = savedStdinPause; + } +} + +function toRawInputs(inputs: (string | null)[]): string[] { + return inputs.map((input) => { + if (input === null) return '\x04'; + return input + '\r'; + }); +} + +function setupMockProvider(responses: string[]): void { + let callIndex = 0; + const mockCall = vi.fn(async () => { + const content = callIndex < responses.length ? responses[callIndex] : 'AI response'; + callIndex++; + return { + persona: 'instruct', + status: 'done' as const, + content: content!, + timestamp: new Date(), + }; + }); + const mockProvider = { + setup: () => ({ call: mockCall }), + _call: mockCall, + }; + mockGetProvider.mockReturnValue(mockProvider); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockSelectOption.mockResolvedValue('execute'); +}); + +afterEach(() => { + restoreStdin(); +}); + +describe('runInstructMode', () => { + it('should return action=cancel when user types /cancel', async () => { + setupRawStdin(toRawInputs(['/cancel'])); + setupMockProvider([]); + + const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + + expect(result.action).toBe('cancel'); + expect(result.task).toBe(''); + }); + + it('should include branch name in intro message', async () => { + setupRawStdin(toRawInputs(['/cancel'])); + setupMockProvider([]); + + await runInstructMode('/project', 'diff stats', 'my-feature-branch'); + + const introCall = mockInfo.mock.calls.find((call) => + call[0]?.includes('my-feature-branch') + ); + expect(introCall).toBeDefined(); + }); + + it('should return action=execute with task on /go after conversation', async () => { + setupRawStdin(toRawInputs(['add more tests', '/go'])); + setupMockProvider(['What kind of tests?', 'Add unit tests for the feature.']); + + const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + + expect(result.action).toBe('execute'); + expect(result.task).toBe('Add unit tests for the feature.'); + }); + + it('should return action=save_task when user selects save task', async () => { + setupRawStdin(toRawInputs(['describe task', '/go'])); + setupMockProvider(['response', 'Summarized task.']); + mockSelectOption.mockResolvedValue('save_task'); + + const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + + expect(result.action).toBe('save_task'); + expect(result.task).toBe('Summarized task.'); + }); + + it('should continue editing when user selects continue', async () => { + setupRawStdin(toRawInputs(['describe task', '/go', '/cancel'])); + setupMockProvider(['response', 'Summarized task.']); + mockSelectOption.mockResolvedValueOnce('continue'); + + const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + + expect(result.action).toBe('cancel'); + }); + + it('should reject /go with no prior conversation', async () => { + setupRawStdin(toRawInputs(['/go', '/cancel'])); + setupMockProvider([]); + + const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + + expect(result.action).toBe('cancel'); + }); + + it('should use custom action selector without create_issue option', async () => { + setupRawStdin(toRawInputs(['task', '/go'])); + setupMockProvider(['response', 'Task summary.']); + + await runInstructMode('/project', 'branch context', 'feature-branch'); + + const selectCall = mockSelectOption.mock.calls.find((call) => + Array.isArray(call[1]) + ); + expect(selectCall).toBeDefined(); + const options = selectCall![1] as Array<{ value: string }>; + const values = options.map((o) => o.value); + expect(values).toContain('execute'); + expect(values).toContain('save_task'); + expect(values).toContain('continue'); + expect(values).not.toContain('create_issue'); + }); +}); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 140b16e..d4626a2 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -22,6 +22,7 @@ import { resolveLanguage, type InteractiveModeResult, } from '../../features/interactive/index.js'; +import { dispatchConversationAction } from '../../features/interactive/actionDispatcher.js'; import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js'; import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; import { program, resolvedCwd, pipelineMode } from './program.js'; @@ -202,33 +203,27 @@ export async function executeDefaultAction(task?: string): Promise { } } - switch (result.action) { - case 'execute': + await dispatchConversationAction(result, { + execute: async ({ task: confirmedTask }) => { selectOptions.interactiveUserInput = true; selectOptions.piece = pieceId; - selectOptions.interactiveMetadata = { confirmed: true, task: result.task }; - await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides); - break; - - case 'create_issue': - { - const issueNumber = createIssueFromTask(result.task); - if (issueNumber !== undefined) { - await saveTaskFromInteractive(resolvedCwd, result.task, pieceId, { - issue: issueNumber, - confirmAtEndMessage: 'Add this issue to tasks?', - }); - } + selectOptions.interactiveMetadata = { confirmed: true, task: confirmedTask }; + await selectAndExecuteTask(resolvedCwd, confirmedTask, selectOptions, agentOverrides); + }, + create_issue: async ({ task: confirmedTask }) => { + const issueNumber = createIssueFromTask(confirmedTask); + if (issueNumber !== undefined) { + await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId, { + issue: issueNumber, + confirmAtEndMessage: 'Add this issue to tasks?', + }); } - break; - - case 'save_task': - await saveTaskFromInteractive(resolvedCwd, result.task, pieceId); - break; - - case 'cancel': - break; - } + }, + save_task: async ({ task: confirmedTask }) => { + await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId); + }, + cancel: () => undefined, + }); } program diff --git a/src/features/interactive/actionDispatcher.ts b/src/features/interactive/actionDispatcher.ts new file mode 100644 index 0000000..4b5d23a --- /dev/null +++ b/src/features/interactive/actionDispatcher.ts @@ -0,0 +1,20 @@ +/** + * Shared dispatcher for post-conversation actions. + */ + +export interface ConversationActionResult { + action: A; + task: string; +} + +export type ConversationActionHandler = ( + result: ConversationActionResult, +) => Promise | R; + +export async function dispatchConversationAction( + result: ConversationActionResult, + handlers: Record>, +): Promise { + return handlers[result.action](result); +} + diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index 156862f..fb988ef 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -28,6 +28,7 @@ import { type InteractiveModeResult, type InteractiveUIText, type ConversationMessage, + type PostSummaryAction, resolveLanguage, buildSummaryPrompt, selectPostSummaryAction, @@ -171,6 +172,8 @@ export async function callAIWithRetry( } } +export type { PostSummaryAction } from './interactive.js'; + /** Strategy for customizing conversation loop behavior */ export interface ConversationStrategy { /** System prompt for AI calls */ @@ -181,6 +184,8 @@ export interface ConversationStrategy { transformPrompt: (userMessage: string) => string; /** Intro message displayed at start */ introMessage: string; + /** Custom action selector (optional). If not provided, uses default selectPostSummaryAction. */ + selectAction?: (task: string, lang: 'en' | 'ja') => Promise; } /** @@ -284,7 +289,9 @@ export async function runConversationLoop( return { action: 'cancel', task: '' }; } const task = summaryResult.content.trim(); - const selectedAction = await selectPostSummaryAction(task, ui.proposed, ui); + const selectedAction = strategy.selectAction + ? await strategy.selectAction(task, ctx.lang) + : await selectPostSummaryAction(task, ui.proposed, ui); if (selectedAction === 'continue' || selectedAction === null) { info(ui.continuePrompt); continue; diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 36b9104..bc31775 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -169,21 +169,90 @@ export function buildSummaryPrompt( export type PostSummaryAction = InteractiveModeAction | 'continue'; -export async function selectPostSummaryAction( +export type SummaryActionValue = 'execute' | 'create_issue' | 'save_task' | 'continue'; + +export interface SummaryActionOption { + label: string; + value: SummaryActionValue; +} + +export type SummaryActionLabels = { + execute: string; + createIssue?: string; + saveTask: string; + continue: string; +}; + +export const BASE_SUMMARY_ACTIONS: readonly SummaryActionValue[] = [ + 'execute', + 'save_task', + 'continue', +]; + +export function buildSummaryActionOptions( + labels: SummaryActionLabels, + append: readonly SummaryActionValue[] = [], +): SummaryActionOption[] { + const order = [...BASE_SUMMARY_ACTIONS, ...append]; + const seen = new Set(); + const options: SummaryActionOption[] = []; + + for (const action of order) { + if (seen.has(action)) continue; + seen.add(action); + + if (action === 'execute') { + options.push({ label: labels.execute, value: action }); + continue; + } + if (action === 'create_issue') { + if (labels.createIssue) { + options.push({ label: labels.createIssue, value: action }); + } + continue; + } + if (action === 'save_task') { + options.push({ label: labels.saveTask, value: action }); + continue; + } + options.push({ label: labels.continue, value: action }); + } + + return options; +} + +export async function selectSummaryAction( task: string, proposedLabel: string, - ui: InteractiveUIText, + actionPrompt: string, + options: SummaryActionOption[], ): Promise { blankLine(); info(proposedLabel); console.log(task); - return selectOption(ui.actionPrompt, [ - { label: ui.actions.execute, value: 'execute' }, - { label: ui.actions.createIssue, value: 'create_issue' }, - { label: ui.actions.saveTask, value: 'save_task' }, - { label: ui.actions.continue, value: 'continue' }, - ]); + return selectOption(actionPrompt, options); +} + +export async function selectPostSummaryAction( + task: string, + proposedLabel: string, + ui: InteractiveUIText, +): Promise { + return selectSummaryAction( + task, + proposedLabel, + ui.actionPrompt, + buildSummaryActionOptions( + { + execute: ui.actions.execute, + createIssue: ui.actions.createIssue, + saveTask: ui.actions.saveTask, + continue: ui.actions.continue, + }, + ['create_issue'], + ), + ); } export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel'; diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 4d26732..287962b 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -44,6 +44,12 @@ export { instructBranch, } from './taskActions.js'; +export { + type InstructModeAction, + type InstructModeResult, + runInstructMode, +} from './instructMode.js'; + /** Task action type for pending task action selection menu */ type PendingTaskAction = 'delete'; diff --git a/src/features/tasks/list/instructMode.ts b/src/features/tasks/list/instructMode.ts new file mode 100644 index 0000000..9d83096 --- /dev/null +++ b/src/features/tasks/list/instructMode.ts @@ -0,0 +1,123 @@ +/** + * Instruct mode for branch-based tasks. + * + * Provides conversation loop for additional instructions on existing branches, + * similar to interactive mode but with branch context and limited actions. + */ + +import { + initializeSession, + displayAndClearSessionState, + runConversationLoop, + type SessionContext, + type ConversationStrategy, + type PostSummaryAction, +} from '../../interactive/conversationLoop.js'; +import { + resolveLanguage, + buildSummaryActionOptions, + selectSummaryAction, +} from '../../interactive/interactive.js'; +import { loadTemplate } from '../../../shared/prompts/index.js'; +import { getLabelObject } from '../../../shared/i18n/index.js'; +import { loadGlobalConfig } from '../../../infra/config/index.js'; + +export type InstructModeAction = 'execute' | 'save_task' | 'cancel'; + +export interface InstructModeResult { + action: InstructModeAction; + task: string; +} + +export interface InstructUIText { + intro: string; + resume: string; + noConversation: string; + summarizeFailed: string; + continuePrompt: string; + proposed: string; + actionPrompt: string; + actions: { + execute: string; + saveTask: string; + continue: string; + }; + cancelled: string; +} + +const INSTRUCT_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; + +function createSelectInstructAction(ui: InstructUIText): (task: string, lang: 'en' | 'ja') => Promise { + return async (task: string, _lang: 'en' | 'ja'): Promise => { + return selectSummaryAction( + task, + ui.proposed, + ui.actionPrompt, + buildSummaryActionOptions({ + execute: ui.actions.execute, + saveTask: ui.actions.saveTask, + continue: ui.actions.continue, + }), + ); + }; +} + +export async function runInstructMode( + cwd: string, + branchContext: string, + branchName: string, +): Promise { + const globalConfig = loadGlobalConfig(); + const lang = resolveLanguage(globalConfig.language); + + if (!globalConfig.provider) { + throw new Error('Provider is not configured.'); + } + + const baseCtx = initializeSession(cwd, 'instruct'); + const ctx: SessionContext = { ...baseCtx, lang, personaName: 'instruct' }; + + displayAndClearSessionState(cwd, ctx.lang); + + const ui = getLabelObject('instruct.ui', ctx.lang); + + const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, { + hasPiecePreview: false, + pieceStructure: '', + movementDetails: '', + }); + + const branchIntro = ctx.lang === 'ja' + ? `## ブランチ: ${branchName}\n\n${branchContext}` + : `## Branch: ${branchName}\n\n${branchContext}`; + + const introMessage = `${branchIntro}\n\n${ui.intro}`; + + const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {}); + + function injectPolicy(userMessage: string): string { + const policyIntro = ctx.lang === 'ja' + ? '以下のポリシーは行動規範です。必ず遵守してください。' + : 'The following policy defines behavioral guidelines. Please follow them.'; + const reminderLabel = ctx.lang === 'ja' + ? '上記の Policy セクションで定義されたポリシー規範を遵守してください。' + : 'Please follow the policy guidelines defined in the Policy section above.'; + return `## Policy\n${policyIntro}\n\n${policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`; + } + + const strategy: ConversationStrategy = { + systemPrompt, + allowedTools: INSTRUCT_TOOLS, + transformPrompt: injectPolicy, + introMessage, + selectAction: createSelectInstructAction(ui), + }; + + const result = await runConversationLoop(cwd, ctx, strategy, undefined, undefined); + + if (result.action === 'cancel') { + return { action: 'cancel', task: '' }; + } + + return { action: result.action as InstructModeAction, task: result.task }; +} diff --git a/src/features/tasks/list/taskActions.ts b/src/features/tasks/list/taskActions.ts index 8321c0b..d062c88 100644 --- a/src/features/tasks/list/taskActions.ts +++ b/src/features/tasks/list/taskActions.ts @@ -19,13 +19,16 @@ import { autoCommitAndPush, type BranchListItem, } from '../../../infra/task/index.js'; -import { selectOption, promptInput } from '../../../shared/prompt/index.js'; +import { selectOption } from '../../../shared/prompt/index.js'; import { info, success, error as logError, warn, header, blankLine } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { executeTask } from '../execute/taskExecution.js'; import type { TaskExecutionOptions } from '../execute/types.js'; import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js'; +import { runInstructMode } from './instructMode.js'; +import { saveTaskFile } from '../add/index.js'; import { selectPiece } from '../../pieceSelection/index.js'; +import { dispatchConversationAction } from '../../interactive/actionDispatcher.js'; const log = createLogger('list-tasks'); @@ -302,8 +305,8 @@ function getBranchContext(projectDir: string, branch: string): string { } /** - * Instruct branch: create a temp clone, give additional instructions, - * auto-commit+push, then remove clone. + * Instruct branch: create a temp clone, give additional instructions via + * interactive conversation, then auto-commit+push or save as task file. */ export async function instructBranch( projectDir: string, @@ -312,54 +315,81 @@ export async function instructBranch( ): Promise { const { branch } = item.info; - const instruction = await promptInput('Enter instruction'); - if (!instruction) { - info('Cancelled'); - return false; - } - - const selectedPiece = await selectPiece(projectDir); - if (!selectedPiece) { - info('Cancelled'); - return false; - } - - log.info('Instructing branch via temp clone', { branch, piece: selectedPiece }); - info(`Running instruction on ${branch}...`); - - const clone = createTempCloneForBranch(projectDir, branch); - - try { - const branchContext = getBranchContext(projectDir, branch); - const fullInstruction = branchContext - ? `${branchContext}## 追加指示\n${instruction}` - : instruction; - - const taskSuccess = await executeTask({ - task: fullInstruction, - cwd: clone.path, - pieceIdentifier: selectedPiece, - projectCwd: projectDir, - agentOverrides: options, - }); + const branchContext = getBranchContext(projectDir, branch); + const result = await runInstructMode(projectDir, branchContext, branch); + let selectedPiece: string | null = null; - if (taskSuccess) { - const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir); - if (commitResult.success && commitResult.commitHash) { - info(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - warn(`Auto-commit skipped: ${commitResult.message}`); - } - success(`Instruction completed on ${branch}`); - log.info('Instruction completed', { branch }); - } else { - logError(`Instruction failed on ${branch}`); - log.error('Instruction failed', { branch }); + const ensurePieceSelected = async (): Promise => { + if (selectedPiece) { + return selectedPiece; + } + selectedPiece = await selectPiece(projectDir); + if (!selectedPiece) { + info('Cancelled'); + return null; } + return selectedPiece; + }; + + return dispatchConversationAction(result, { + cancel: () => { + info('Cancelled'); + return false; + }, + save_task: async ({ task }) => { + const piece = await ensurePieceSelected(); + if (!piece) { + return false; + } + const created = await saveTaskFile(projectDir, task, { piece }); + success(`Task saved: ${created.taskName}`); + info(` File: ${created.tasksFile}`); + log.info('Task saved from instruct mode', { branch, piece }); + return true; + }, + execute: async ({ task }) => { + const piece = await ensurePieceSelected(); + if (!piece) { + return false; + } - return taskSuccess; - } finally { - removeClone(clone.path); - removeCloneMeta(projectDir, branch); - } + log.info('Instructing branch via temp clone', { branch, piece }); + info(`Running instruction on ${branch}...`); + + const clone = createTempCloneForBranch(projectDir, branch); + + try { + const fullInstruction = branchContext + ? `${branchContext}## 追加指示\n${task}` + : task; + + const taskSuccess = await executeTask({ + task: fullInstruction, + cwd: clone.path, + pieceIdentifier: piece, + projectCwd: projectDir, + agentOverrides: options, + }); + + if (taskSuccess) { + const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir); + if (commitResult.success && commitResult.commitHash) { + info(`Auto-committed & pushed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + warn(`Auto-commit skipped: ${commitResult.message}`); + } + success(`Instruction completed on ${branch}`); + log.info('Instruction completed', { branch }); + } else { + logError(`Instruction failed on ${branch}`); + log.error('Instruction failed', { branch }); + } + + return taskSuccess; + } finally { + removeClone(clone.path); + removeCloneMeta(projectDir, branch); + } + }, + }); } diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 0d76f2f..607eafa 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -68,6 +68,22 @@ piece: sigintTimeout: "Graceful shutdown timed out after {timeoutMs}ms" sigintForce: "Ctrl+C: Force exit" +# ===== Instruct Mode UI (takt list -> instruct) ===== +instruct: + ui: + intro: "Instruct mode - describe additional instructions. Commands: /go (summarize), /cancel (exit)" + resume: "Resuming previous session" + noConversation: "No conversation yet. Please describe your instructions first." + summarizeFailed: "Failed to summarize conversation. Please try again." + continuePrompt: "Okay, continue describing your instructions." + proposed: "Proposed additional instructions:" + actionPrompt: "What would you like to do?" + actions: + execute: "Execute now" + saveTask: "Save as Task" + continue: "Continue editing" + cancelled: "Cancelled" + run: notifyComplete: "Run complete ({total} tasks)" notifyAbort: "Run finished with errors ({failed})" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 2bc9ed0..eac4cd8 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -68,6 +68,22 @@ piece: sigintTimeout: "graceful停止がタイムアウトしました ({timeoutMs}ms)" sigintForce: "Ctrl+C: 強制終了します" +# ===== Instruct Mode UI (takt list -> instruct) ===== +instruct: + ui: + intro: "指示モード - 追加指示を入力してください。コマンド: /go(要約), /cancel(終了)" + resume: "前回のセッションを再開します" + noConversation: "まだ会話がありません。まず追加指示を入力してください。" + summarizeFailed: "会話の要約に失敗しました。再度お試しください。" + continuePrompt: "続けて追加指示を入力してください。" + proposed: "提案された追加指示:" + actionPrompt: "どうしますか?" + actions: + execute: "実行する" + saveTask: "タスクにつむ" + continue: "会話を続ける" + cancelled: "キャンセルしました" + run: notifyComplete: "run完了 ({total} tasks)" notifyAbort: "runはエラー終了 ({failed})" From e7c5031a297935e8ffb8e8d31fb7a4166e613f9f Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:40:54 +0900 Subject: [PATCH 06/15] ignore OPENCODE_CONFIG_CONTENT --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7bc553b..ef14f23 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ coverage/ task_planning/ +OPENCODE_CONFIG_CONTENT From f5d1c6fae2c3766e981fb019f23005ae0cb4fab5 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:41:41 +0900 Subject: [PATCH 07/15] ignore OPENCODE_CONFIG_CONTENT --- OPENCODE_CONFIG_CONTENT | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 OPENCODE_CONFIG_CONTENT diff --git a/OPENCODE_CONFIG_CONTENT b/OPENCODE_CONFIG_CONTENT deleted file mode 100644 index 6b5dea8..0000000 --- a/OPENCODE_CONFIG_CONTENT +++ /dev/null @@ -1,2 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json","model":"zai-coding-plan/glm-5","small_model":"zai-coding-plan/glm-5","permission":"deny"} \ No newline at end of file From 54e9f80a5797f90f39a70701276dcea73b58fddc Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:11:32 +0900 Subject: [PATCH 08/15] =?UTF-8?q?opencode=E3=81=8C=E3=83=91=E3=83=A9?= =?UTF-8?q?=E3=83=AC=E3=83=AB=E5=AE=9F=E8=A1=8C=E6=99=82=E3=81=AB=E3=82=BB?= =?UTF-8?q?=E3=83=83=E3=82=B7=E3=83=A7=E3=83=B3ID=E3=82=92=E5=BC=95?= =?UTF-8?q?=E3=81=8D=E7=B6=99=E3=81=92=E3=81=AA=E3=81=84=E3=81=93=E3=81=A8?= =?UTF-8?q?=E3=81=8C=E3=81=82=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/types.ts | 10 ----- src/infra/opencode/client.ts | 80 +++++++++++++++++++----------------- src/infra/opencode/types.ts | 6 +-- src/infra/providers/types.ts | 8 ---- 4 files changed, 44 insertions(+), 60 deletions(-) diff --git a/src/agents/types.ts b/src/agents/types.ts index a7cbd06..d2b6e32 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -14,26 +14,17 @@ export interface RunAgentOptions { sessionId?: string; model?: string; provider?: 'claude' | 'codex' | 'opencode' | 'mock'; - /** Resolved path to persona prompt file */ personaPath?: string; - /** Allowed tools for this agent run */ allowedTools?: string[]; - /** MCP servers for this agent run */ mcpServers?: Record; - /** Maximum number of agentic turns */ maxTurns?: number; - /** Permission mode for tool execution (from piece step) */ permissionMode?: PermissionMode; - /** Provider-specific movement options */ providerOptions?: MovementProviderOptions; onStream?: StreamCallback; onPermissionRequest?: PermissionHandler; onAskUserQuestion?: AskUserQuestionHandler; - /** Bypass all permission checks (sacrifice-my-pc mode) */ bypassPermissions?: boolean; - /** Language for template resolution */ language?: Language; - /** Piece meta information for system prompt template */ pieceMeta?: { pieceName: string; pieceDescription?: string; @@ -41,6 +32,5 @@ export interface RunAgentOptions { movementsList: ReadonlyArray<{ name: string; description?: string }>; currentPosition: string; }; - /** JSON Schema for structured output */ outputSchema?: Record; } diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index 1f33038..07f3970 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -273,6 +273,8 @@ export class OpenCodeClient { let diagRef: StreamDiagnostics | undefined; let serverClose: (() => void) | undefined; let opencodeApiClient: Awaited>['client'] | undefined; + let sessionId: string | undefined = options.sessionId; + const interactionTimeoutMs = options.interactionTimeoutMs ?? OPENCODE_INTERACTION_TIMEOUT_MS; const resetIdleTimeout = (): void => { if (idleTimeoutId !== undefined) { @@ -330,14 +332,14 @@ export class OpenCodeClient { opencodeApiClient = client; serverClose = server.close; - const sessionResult = options.sessionId - ? { data: { id: options.sessionId } } + const sessionResult = sessionId + ? { data: { id: sessionId } } : await client.session.create({ directory: options.cwd, permission: buildOpenCodePermissionRuleset(options.permissionMode, options.networkAccess), }); - const sessionId = sessionResult.data?.id; + sessionId = sessionResult.data?.id; if (!sessionId) { throw new Error('Failed to create OpenCode session'); } @@ -420,18 +422,24 @@ export class OpenCodeClient { sessionID: string; }; if (permProps.sessionID === sessionId) { - const reply = options.permissionMode - ? mapToOpenCodePermissionReply(options.permissionMode) - : 'once'; - await withTimeout( - (signal) => client.permission.reply({ - requestID: permProps.id, - directory: options.cwd, - reply, - }, { signal }), - OPENCODE_INTERACTION_TIMEOUT_MS, - 'OpenCode permission reply timed out', - ); + try { + const reply = options.permissionMode + ? mapToOpenCodePermissionReply(options.permissionMode) + : 'once'; + await withTimeout( + (signal) => client.permission.reply({ + requestID: permProps.id, + directory: options.cwd, + reply, + }, { signal }), + interactionTimeoutMs, + 'OpenCode permission reply timed out', + ); + } catch (e) { + success = false; + failureMessage = getErrorMessage(e); + break; + } } continue; } @@ -440,14 +448,20 @@ export class OpenCodeClient { const questionProps = sseEvent.properties as OpenCodeQuestionAskedProperties; if (questionProps.sessionID === sessionId) { if (!options.onAskUserQuestion) { - await withTimeout( - (signal) => client.question.reject({ - requestID: questionProps.id, - directory: options.cwd, - }, { signal }), - OPENCODE_INTERACTION_TIMEOUT_MS, - 'OpenCode question reject timed out', - ); + try { + await withTimeout( + (signal) => client.question.reject({ + requestID: questionProps.id, + directory: options.cwd, + }, { signal }), + interactionTimeoutMs, + 'OpenCode question reject timed out', + ); + } catch (e) { + success = false; + failureMessage = getErrorMessage(e); + break; + } continue; } @@ -459,20 +473,12 @@ export class OpenCodeClient { directory: options.cwd, answers: toQuestionAnswers(questionProps, answers), }, { signal }), - OPENCODE_INTERACTION_TIMEOUT_MS, + interactionTimeoutMs, 'OpenCode question reply timed out', ); - } catch { - await withTimeout( - (signal) => client.question.reject({ - requestID: questionProps.id, - directory: options.cwd, - }, { signal }), - OPENCODE_INTERACTION_TIMEOUT_MS, - 'OpenCode question reject timed out', - ); + } catch (e) { success = false; - failureMessage = 'OpenCode question handling failed'; + failureMessage = getErrorMessage(e); break; } } @@ -631,8 +637,8 @@ export class OpenCodeClient { continue; } - if (options.sessionId) { - emitResult(options.onStream, false, errorMessage, options.sessionId); + if (sessionId) { + emitResult(options.onStream, false, errorMessage, sessionId); } return { @@ -640,7 +646,7 @@ export class OpenCodeClient { status: 'error', content: errorMessage, timestamp: new Date(), - sessionId: options.sessionId, + sessionId, }; } finally { if (idleTimeoutId !== undefined) { diff --git a/src/infra/opencode/types.ts b/src/infra/opencode/types.ts index e407d6b..a0c2c42 100644 --- a/src/infra/opencode/types.ts +++ b/src/infra/opencode/types.ts @@ -187,15 +187,11 @@ export interface OpenCodeCallOptions { model: string; systemPrompt?: string; allowedTools?: string[]; - /** Permission mode for automatic permission handling */ permissionMode?: PermissionMode; - /** Override network access (webfetch/websearch) */ networkAccess?: boolean; - /** Enable streaming mode with callback (best-effort) */ onStream?: StreamCallback; onAskUserQuestion?: AskUserQuestionHandler; - /** OpenCode API key */ opencodeApiKey?: string; - /** JSON Schema for structured output */ outputSchema?: Record; + interactionTimeoutMs?: number; } diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index 9560cf2..b5c9263 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -24,25 +24,17 @@ export interface ProviderCallOptions { sessionId?: string; model?: string; allowedTools?: string[]; - /** MCP servers configuration */ mcpServers?: Record; - /** Maximum number of agentic turns */ maxTurns?: number; - /** Permission mode for tool execution (from piece step) */ permissionMode?: PermissionMode; - /** Provider-specific movement options */ providerOptions?: MovementProviderOptions; onStream?: StreamCallback; onPermissionRequest?: PermissionHandler; onAskUserQuestion?: AskUserQuestionHandler; bypassPermissions?: boolean; - /** Anthropic API key for Claude provider */ anthropicApiKey?: string; - /** OpenAI API key for Codex provider */ openaiApiKey?: string; - /** OpenCode API key for OpenCode provider */ opencodeApiKey?: string; - /** JSON Schema for structured output */ outputSchema?: Record; } From 6fe8fece91f0b1d48084fbb78e59574dcacab255 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:28:20 +0900 Subject: [PATCH 09/15] =?UTF-8?q?interactive=20=E3=81=AE=E9=81=B8=E6=8A=9E?= =?UTF-8?q?=E8=82=A2=E3=81=8C=E9=9D=9E=E5=90=8C=E6=9C=9F=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E6=99=82=E3=81=AB=E5=87=BA=E3=81=A6=E3=81=97=E3=81=BE=E3=81=86?= =?UTF-8?q?=E3=83=90=E3=82=B0=E3=81=AEfix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/piece/agent-usecases.ts | 30 ++++++++++++++++++++----- src/core/piece/status-judgment-phase.ts | 3 +++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/core/piece/agent-usecases.ts b/src/core/piece/agent-usecases.ts index 2c20ee2..8dff719 100644 --- a/src/core/piece/agent-usecases.ts +++ b/src/core/piece/agent-usecases.ts @@ -10,6 +10,7 @@ export interface JudgeStatusOptions { cwd: string; movementName: string; language?: Language; + interactive?: boolean; } export interface JudgeStatusResult { @@ -98,6 +99,14 @@ export async function judgeStatus( return { ruleIndex: 0, method: 'auto_select' }; } + const interactiveEnabled = options.interactive === true; + + const isValidRuleIndex = (index: number): boolean => { + if (index < 0 || index >= rules.length) return false; + const rule = rules[index]; + return !(rule?.interactiveOnly && !interactiveEnabled); + }; + const agentOptions = { cwd: options.cwd, maxTurns: 3, @@ -115,7 +124,7 @@ export async function judgeStatus( const stepNumber = structuredResponse.structuredOutput?.step; if (typeof stepNumber === 'number' && Number.isInteger(stepNumber)) { const ruleIndex = stepNumber - 1; - if (ruleIndex >= 0 && ruleIndex < rules.length) { + if (isValidRuleIndex(ruleIndex)) { return { ruleIndex, method: 'structured_output' }; } } @@ -126,16 +135,25 @@ export async function judgeStatus( if (tagResponse.status === 'done') { const tagRuleIndex = detectRuleIndex(tagResponse.content, options.movementName); - if (tagRuleIndex >= 0 && tagRuleIndex < rules.length) { + if (isValidRuleIndex(tagRuleIndex)) { return { ruleIndex: tagRuleIndex, method: 'phase3_tag' }; } } // Stage 3: AI judge - const conditions = rules.map((rule, index) => ({ index, text: rule.condition })); - const fallbackIndex = await evaluateCondition(structuredInstruction, conditions, { cwd: options.cwd }); - if (fallbackIndex >= 0 && fallbackIndex < rules.length) { - return { ruleIndex: fallbackIndex, method: 'ai_judge' }; + const conditions = rules + .map((rule, index) => ({ rule, index })) + .filter(({ rule }) => interactiveEnabled || !rule.interactiveOnly) + .map(({ index, rule }) => ({ index, text: rule.condition })); + + if (conditions.length > 0) { + const fallbackIndex = await evaluateCondition(structuredInstruction, conditions, { cwd: options.cwd }); + if (fallbackIndex >= 0 && fallbackIndex < conditions.length) { + const originalIndex = conditions[fallbackIndex]?.index; + if (originalIndex !== undefined) { + return { ruleIndex: originalIndex, method: 'ai_judge' }; + } + } } throw new Error(`Status not found for movement "${options.movementName}"`); diff --git a/src/core/piece/status-judgment-phase.ts b/src/core/piece/status-judgment-phase.ts index 3c5d899..618de99 100644 --- a/src/core/piece/status-judgment-phase.ts +++ b/src/core/piece/status-judgment-phase.ts @@ -36,6 +36,7 @@ function buildBaseContext( if (reports.length > 0) { return { language: ctx.language, + interactive: ctx.interactive, reportContent: reports.join('\n\n---\n\n'), inputSource: 'report', }; @@ -46,6 +47,7 @@ function buildBaseContext( return { language: ctx.language, + interactive: ctx.interactive, lastResponse: ctx.lastResponse, inputSource: 'response', }; @@ -89,6 +91,7 @@ export async function runStatusJudgmentPhase( cwd: ctx.cwd, movementName: step.name, language: ctx.language, + interactive: ctx.interactive, }); const tag = `[${step.name.toUpperCase()}:${result.ruleIndex + 1}]`; ctx.onPhaseComplete?.(step, 3, 'judge', tag, 'done'); From 8af8ff0943a04f304249c670b4ae78f1aa4c0619 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:08:50 +0900 Subject: [PATCH 10/15] =?UTF-8?q?plan/ai-review/supervise=E3=81=AE?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=82=B9=E3=83=88=E3=83=A9=E3=82=AF=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=81=AB=E3=82=B9=E3=82=B3=E3=83=BC=E3=83=97?= =?UTF-8?q?=E7=B8=AE=E5=B0=8F=E9=98=B2=E6=AD=A2=E7=AD=96=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plan: 要件ごとに変更要/不要の根拠(ファイル:行)を必須化 - ai-review: スコープ縮小の検出をスコープクリープと並列で追加 - supervise: タスク指示書との独立照合を計画レポートに依存しない形で追加 --- builtins/en/instructions/ai-review.md | 1 + builtins/en/instructions/plan.md | 1 + builtins/en/instructions/supervise.md | 3 ++- builtins/ja/instructions/ai-review.md | 1 + builtins/ja/instructions/plan.md | 1 + builtins/ja/instructions/supervise.md | 3 ++- 6 files changed, 8 insertions(+), 2 deletions(-) diff --git a/builtins/en/instructions/ai-review.md b/builtins/en/instructions/ai-review.md index 00ee7d5..d7e600d 100644 --- a/builtins/en/instructions/ai-review.md +++ b/builtins/en/instructions/ai-review.md @@ -8,6 +8,7 @@ Review the code for AI-specific issues: - Plausible but incorrect patterns - Compatibility with the existing codebase - Scope creep detection +- Scope shrinkage detection (missing task requirements) ## Judgment Procedure diff --git a/builtins/en/instructions/plan.md b/builtins/en/instructions/plan.md index f77bf7a..264f3d1 100644 --- a/builtins/en/instructions/plan.md +++ b/builtins/en/instructions/plan.md @@ -12,6 +12,7 @@ For small tasks, skip the design sections in the report. **Actions:** 1. Understand the task requirements + - **For each requirement, determine "change needed / not needed". If "not needed", cite the relevant code (file:line) as evidence. Claiming "already correct" without evidence is prohibited** 2. Investigate code to resolve unknowns 3. Identify the impact area 4. Determine file structure and design patterns (if needed) diff --git a/builtins/en/instructions/supervise.md b/builtins/en/instructions/supervise.md index 78d7e86..f5c5dcd 100644 --- a/builtins/en/instructions/supervise.md +++ b/builtins/en/instructions/supervise.md @@ -3,7 +3,8 @@ Run tests, verify the build, and perform final approval. **Overall piece verification:** 1. Whether the plan and implementation results are consistent 2. Whether findings from each review movement have been addressed -3. Whether the original task objective has been achieved +3. Whether each task spec requirement has been achieved + - Do not rely on the plan report's judgment; independently verify each requirement against actual code (file:line) **Report verification:** Read all reports in the Report Directory and check for any unaddressed improvement suggestions. diff --git a/builtins/ja/instructions/ai-review.md b/builtins/ja/instructions/ai-review.md index dbca057..65c3bc3 100644 --- a/builtins/ja/instructions/ai-review.md +++ b/builtins/ja/instructions/ai-review.md @@ -8,6 +8,7 @@ AI特有の問題についてコードをレビューしてください: - もっともらしいが間違っているパターン - 既存コードベースとの適合性 - スコープクリープの検出 +- スコープ縮小の検出(タスク要件の取りこぼし) ## 判定手順 diff --git a/builtins/ja/instructions/plan.md b/builtins/ja/instructions/plan.md index 3827f76..b5dd901 100644 --- a/builtins/ja/instructions/plan.md +++ b/builtins/ja/instructions/plan.md @@ -18,6 +18,7 @@ - **指示書に明記されていない別ファイルを「参照資料の代わり」として使うことは禁止** 2. タスクの要件を理解する - 参照資料の内容と現在の実装を突き合わせて差分を特定する + - **要件ごとに「変更要/不要」を判定する。「不要」の場合は現行コードの該当箇所(ファイル:行)を根拠として示すこと。根拠なしの「既に正しい」は禁止** 3. コードを調査して不明点を解決する 4. 影響範囲を特定する 5. ファイル構成・設計パターンを決定する(必要な場合) diff --git a/builtins/ja/instructions/supervise.md b/builtins/ja/instructions/supervise.md index 45a9219..c2c44e2 100644 --- a/builtins/ja/instructions/supervise.md +++ b/builtins/ja/instructions/supervise.md @@ -3,7 +3,8 @@ **ピース全体の確認:** 1. 計画と実装結果が一致しているか 2. 各レビュームーブメントの指摘が対応されているか -3. 元のタスク目的が達成されているか +3. タスク指示書の各要件が達成されているか + - 計画レポートの判断を鵜呑みにせず、要件ごとに実コード(ファイル:行)で独立照合する **レポートの確認:** Report Directory内の全レポートを読み、 未対応の改善提案がないか確認してください。 From 9cc6ac2ca7d518b248d53bf6c3967520af046609 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sat, 14 Feb 2026 01:02:23 +0900 Subject: [PATCH 11/15] =?UTF-8?q?=E3=83=9D=E3=82=B9=E3=83=88=E3=82=A8?= =?UTF-8?q?=E3=82=AF=E3=82=B9=E3=82=AD=E3=83=A5=E3=83=BC=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E5=85=B1=E9=80=9A=E5=8C=96=E3=81=A8instruct?= =?UTF-8?q?=E3=83=A2=E3=83=BC=E3=83=89=E3=81=AE=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commit+push+PR作成ロジックをpostExecutionFlowに抽出し、interactive/run/watchの3ルートで共通化 - instructモードはexecuteでcommit+pushのみ(既存PRにpushで反映されるためPR作成不要) - instructのsave_taskで元ブランチ名・worktree・auto_pr:falseを固定保存(プロンプト不要) - instructの会話ループにpieceContextを渡し、/goのサマリー品質を改善 - resolveTaskExecutionのautoPrをboolean必須に変更(undefinedフォールバック廃止) - cloneデフォルトパスを../から../takt-worktree/に変更 --- src/__tests__/taskExecution.test.ts | 6 +- src/features/tasks/execute/postExecution.ts | 81 +++++++++++++++++++ src/features/tasks/execute/resolveTask.ts | 11 ++- .../tasks/execute/selectAndExecute.ts | 68 ++++------------ src/features/tasks/execute/taskExecution.ts | 47 ++++------- src/features/tasks/index.ts | 1 + src/features/tasks/list/instructMode.ts | 4 +- src/features/tasks/list/taskActions.ts | 62 +++++++------- src/infra/task/clone.ts | 2 +- 9 files changed, 154 insertions(+), 128 deletions(-) create mode 100644 src/features/tasks/execute/postExecution.ts diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 382e00c..0b05dea 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -120,6 +120,7 @@ describe('resolveTaskExecution', () => { execCwd: '/project', execPiece: 'default', isWorktree: false, + autoPr: false, }); expect(mockSummarizeTaskName).not.toHaveBeenCalled(); expect(mockCreateSharedClone).not.toHaveBeenCalled(); @@ -177,6 +178,7 @@ describe('resolveTaskExecution', () => { execCwd: '/project/../20260128T0504-add-auth', execPiece: 'default', isWorktree: true, + autoPr: false, branch: 'takt/20260128T0504-add-auth', baseBranch: 'main', }); @@ -372,7 +374,7 @@ describe('resolveTaskExecution', () => { expect(result.autoPr).toBe(true); }); - it('should return undefined autoPr when neither task nor config specifies', async () => { + it('should return false autoPr when neither task nor config specifies', async () => { // Given: Neither task nor config has autoPr mockLoadGlobalConfig.mockReturnValue({ language: 'en', @@ -393,7 +395,7 @@ describe('resolveTaskExecution', () => { const result = await resolveTaskExecution(task, '/project', 'default'); // Then - expect(result.autoPr).toBeUndefined(); + expect(result.autoPr).toBe(false); }); it('should prioritize task YAML auto_pr over global config', async () => { diff --git a/src/features/tasks/execute/postExecution.ts b/src/features/tasks/execute/postExecution.ts new file mode 100644 index 0000000..2bb5bf9 --- /dev/null +++ b/src/features/tasks/execute/postExecution.ts @@ -0,0 +1,81 @@ +/** + * Shared post-execution logic: auto-commit, push, and PR creation. + * + * Used by both selectAndExecuteTask (interactive mode) and + * instructBranch (instruct mode from takt list). + */ + +import { loadGlobalConfig } from '../../../infra/config/index.js'; +import { confirm } from '../../../shared/prompt/index.js'; +import { autoCommitAndPush } from '../../../infra/task/index.js'; +import { info, error, success } from '../../../shared/ui/index.js'; +import { createLogger } from '../../../shared/utils/index.js'; +import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js'; +import type { GitHubIssue } from '../../../infra/github/index.js'; + +const log = createLogger('postExecution'); + +/** + * Resolve auto-PR setting with priority: CLI option > config > prompt. + */ +export async function resolveAutoPr(optionAutoPr: boolean | undefined): Promise { + if (typeof optionAutoPr === 'boolean') { + return optionAutoPr; + } + + const globalConfig = loadGlobalConfig(); + if (typeof globalConfig.autoPr === 'boolean') { + return globalConfig.autoPr; + } + + return confirm('Create pull request?', true); +} + +export interface PostExecutionOptions { + execCwd: string; + projectCwd: string; + task: string; + branch?: string; + baseBranch?: string; + shouldCreatePr: boolean; + pieceIdentifier?: string; + issues?: GitHubIssue[]; + repo?: string; +} + +/** + * Auto-commit, push, and optionally create a PR after successful task execution. + */ +export async function postExecutionFlow(options: PostExecutionOptions): Promise { + const { execCwd, projectCwd, task, branch, baseBranch, shouldCreatePr, pieceIdentifier, issues, repo } = options; + + const commitResult = autoCommitAndPush(execCwd, task, projectCwd); + if (commitResult.success && commitResult.commitHash) { + success(`Auto-committed & pushed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + error(`Auto-commit failed: ${commitResult.message}`); + } + + if (commitResult.success && commitResult.commitHash && branch && shouldCreatePr) { + info('Creating pull request...'); + try { + pushBranch(projectCwd, branch); + } catch (pushError) { + log.info('Branch push from project cwd failed (may already exist)', { error: pushError }); + } + const report = pieceIdentifier ? `Piece \`${pieceIdentifier}\` completed successfully.` : 'Task completed successfully.'; + const prBody = buildPrBody(issues, report); + const prResult = createPullRequest(projectCwd, { + branch, + title: task.length > 100 ? `${task.slice(0, 97)}...` : task, + body: prBody, + base: baseBranch, + repo, + }); + if (prResult.success) { + success(`PR created: ${prResult.url}`); + } else { + error(`PR creation failed: ${prResult.error}`); + } + } +} diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 43d1e11..5e4d1ed 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -19,7 +19,7 @@ export interface ResolvedTaskExecution { baseBranch?: string; startMovement?: string; retryNote?: string; - autoPr?: boolean; + autoPr: boolean; issueNumber?: number; } @@ -74,7 +74,7 @@ export async function resolveTaskExecution( const data = task.data; if (!data) { - return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false }; + return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false, autoPr: false }; } let execCwd = defaultCwd; @@ -115,7 +115,6 @@ export async function resolveTaskExecution( execCwd = result.path; branch = result.branch; isWorktree = true; - } if (task.taskDir && reportDirName) { @@ -126,25 +125,25 @@ export async function resolveTaskExecution( const startMovement = data.start_movement; const retryNote = data.retry_note; - let autoPr: boolean | undefined; + let autoPr: boolean; if (data.auto_pr !== undefined) { autoPr = data.auto_pr; } else { const globalConfig = loadGlobalConfig(); - autoPr = globalConfig.autoPr; + autoPr = globalConfig.autoPr ?? false; } return { execCwd, execPiece, isWorktree, + autoPr, ...(taskPrompt ? { taskPrompt } : {}), ...(reportDirName ? { reportDirName } : {}), ...(branch ? { branch } : {}), ...(baseBranch ? { baseBranch } : {}), ...(startMovement ? { startMovement } : {}), ...(retryNote ? { retryNote } : {}), - ...(autoPr !== undefined ? { autoPr } : {}), ...(data.issue !== undefined ? { issueNumber: data.issue } : {}), }; } diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 3014634..32aedb0 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -9,15 +9,14 @@ import { listPieces, isPiecePath, - loadGlobalConfig, } from '../../../infra/config/index.js'; import { confirm } from '../../../shared/prompt/index.js'; -import { createSharedClone, autoCommitAndPush, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; +import { createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; -import { info, error, success, withProgress } from '../../../shared/ui/index.js'; +import { info, error, withProgress } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.js'; -import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js'; import { executeTask } from './taskExecution.js'; +import { resolveAutoPr, postExecutionFlow } from './postExecution.js'; import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js'; import { selectPiece } from '../../pieceSelection/index.js'; @@ -75,26 +74,6 @@ export async function confirmAndCreateWorktree( return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch }; } -/** - * Resolve auto-PR setting with priority: CLI option > config > prompt. - * Only applicable when worktree is enabled. - */ -async function resolveAutoPr(optionAutoPr: boolean | undefined): Promise { - // CLI option takes precedence - if (typeof optionAutoPr === 'boolean') { - return optionAutoPr; - } - - // Check global config - const globalConfig = loadGlobalConfig(); - if (typeof globalConfig.autoPr === 'boolean') { - return globalConfig.autoPr; - } - - // Fall back to interactive prompt - return confirm('Create pull request?', true); -} - /** * Execute a task with piece selection, optional worktree, and auto-commit. * Shared by direct task execution and interactive mode. @@ -136,36 +115,17 @@ export async function selectAndExecuteTask( }); if (taskSuccess && isWorktree) { - const commitResult = autoCommitAndPush(execCwd, task, cwd); - if (commitResult.success && commitResult.commitHash) { - success(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - error(`Auto-commit failed: ${commitResult.message}`); - } - - if (commitResult.success && commitResult.commitHash && branch && shouldCreatePr) { - info('Creating pull request...'); - // Push branch from project cwd to origin (clone's origin is removed after shared clone) - try { - pushBranch(cwd, branch); - } catch (pushError) { - // Branch may already be pushed by autoCommitAndPush, continue to PR creation - log.info('Branch push from project cwd failed (may already exist)', { error: pushError }); - } - const prBody = buildPrBody(options?.issues, `Piece \`${pieceIdentifier}\` completed successfully.`); - const prResult = createPullRequest(cwd, { - branch, - title: task.length > 100 ? `${task.slice(0, 97)}...` : task, - body: prBody, - base: baseBranch, - repo: options?.repo, - }); - if (prResult.success) { - success(`PR created: ${prResult.url}`); - } else { - error(`PR creation failed: ${prResult.error}`); - } - } + await postExecutionFlow({ + execCwd, + projectCwd: cwd, + task, + branch, + baseBranch, + shouldCreatePr, + pieceIdentifier, + issues: options?.issues, + repo: options?.repo, + }); } if (!taskSuccess) { diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 14c4a0d..f0b4751 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -3,7 +3,7 @@ */ import { loadPieceByIdentifier, isPiecePath, loadGlobalConfig } from '../../../infra/config/index.js'; -import { TaskRunner, type TaskInfo, autoCommitAndPush } from '../../../infra/task/index.js'; +import { TaskRunner, type TaskInfo } from '../../../infra/task/index.js'; import { header, info, @@ -17,9 +17,10 @@ import { getLabel } from '../../../shared/i18n/index.js'; import { executePiece } from './pieceExecution.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js'; -import { createPullRequest, buildPrBody, pushBranch, fetchIssue, checkGhCli } from '../../../infra/github/index.js'; +import { fetchIssue, checkGhCli } from '../../../infra/github/index.js'; import { runWithWorkerPool } from './parallelExecution.js'; import { resolveTaskExecution } from './resolveTask.js'; +import { postExecutionFlow } from './postExecution.js'; export type { TaskExecutionOptions, ExecuteTaskOptions }; @@ -167,37 +168,17 @@ export async function executeAndCompleteTask( const completedAt = new Date().toISOString(); if (taskSuccess && isWorktree) { - const commitResult = autoCommitAndPush(execCwd, task.name, cwd); - if (commitResult.success && commitResult.commitHash) { - info(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - error(`Auto-commit failed: ${commitResult.message}`); - } - - // Create PR if autoPr is enabled and commit succeeded - if (commitResult.success && commitResult.commitHash && branch && autoPr) { - info('Creating pull request...'); - // Push branch from project cwd to origin - try { - pushBranch(cwd, branch); - } catch (pushError) { - // Branch may already be pushed, continue to PR creation - log.info('Branch push from project cwd failed (may already exist)', { error: pushError }); - } - const issues = resolveTaskIssue(issueNumber); - const prBody = buildPrBody(issues, `Piece \`${execPiece}\` completed successfully.`); - const prResult = createPullRequest(cwd, { - branch, - title: task.name.length > 100 ? `${task.name.slice(0, 97)}...` : task.name, - body: prBody, - base: baseBranch, - }); - if (prResult.success) { - success(`PR created: ${prResult.url}`); - } else { - error(`PR creation failed: ${prResult.error}`); - } - } + const issues = resolveTaskIssue(issueNumber); + await postExecutionFlow({ + execCwd, + projectCwd: cwd, + task: task.name, + branch, + baseBranch, + shouldCreatePr: autoPr, + pieceIdentifier: execPiece, + issues, + }); } const taskResult = { diff --git a/src/features/tasks/index.ts b/src/features/tasks/index.ts index f413860..41e2b5a 100644 --- a/src/features/tasks/index.ts +++ b/src/features/tasks/index.ts @@ -14,6 +14,7 @@ export { type SelectAndExecuteOptions, type WorktreeConfirmationResult, } from './execute/selectAndExecute.js'; +export { resolveAutoPr, postExecutionFlow, type PostExecutionOptions } from './execute/postExecution.js'; export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask, createIssueAndSaveTask } from './add/index.js'; export { watchTasks } from './watch/index.js'; export { diff --git a/src/features/tasks/list/instructMode.ts b/src/features/tasks/list/instructMode.ts index 9d83096..8a8c8a3 100644 --- a/src/features/tasks/list/instructMode.ts +++ b/src/features/tasks/list/instructMode.ts @@ -17,6 +17,7 @@ import { resolveLanguage, buildSummaryActionOptions, selectSummaryAction, + type PieceContext, } from '../../interactive/interactive.js'; import { loadTemplate } from '../../../shared/prompts/index.js'; import { getLabelObject } from '../../../shared/i18n/index.js'; @@ -66,6 +67,7 @@ export async function runInstructMode( cwd: string, branchContext: string, branchName: string, + pieceContext?: PieceContext, ): Promise { const globalConfig = loadGlobalConfig(); const lang = resolveLanguage(globalConfig.language); @@ -113,7 +115,7 @@ export async function runInstructMode( selectAction: createSelectInstructAction(ui), }; - const result = await runConversationLoop(cwd, ctx, strategy, undefined, undefined); + const result = await runConversationLoop(cwd, ctx, strategy, pieceContext, undefined); if (result.action === 'cancel') { return { action: 'cancel', task: '' }; diff --git a/src/features/tasks/list/taskActions.ts b/src/features/tasks/list/taskActions.ts index d062c88..223d56d 100644 --- a/src/features/tasks/list/taskActions.ts +++ b/src/features/tasks/list/taskActions.ts @@ -19,6 +19,7 @@ import { autoCommitAndPush, type BranchListItem, } from '../../../infra/task/index.js'; +import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js'; import { selectOption } from '../../../shared/prompt/index.js'; import { info, success, error as logError, warn, header, blankLine } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; @@ -29,6 +30,7 @@ import { runInstructMode } from './instructMode.js'; import { saveTaskFile } from '../add/index.js'; import { selectPiece } from '../../pieceSelection/index.js'; import { dispatchConversationAction } from '../../interactive/actionDispatcher.js'; +import type { PieceContext } from '../../interactive/interactive.js'; const log = createLogger('list-tasks'); @@ -306,7 +308,7 @@ function getBranchContext(projectDir: string, branch: string): string { /** * Instruct branch: create a temp clone, give additional instructions via - * interactive conversation, then auto-commit+push or save as task file. + * interactive conversation, then auto-commit+push+PR or save as task file. */ export async function instructBranch( projectDir: string, @@ -315,45 +317,43 @@ export async function instructBranch( ): Promise { const { branch } = item.info; - const branchContext = getBranchContext(projectDir, branch); - const result = await runInstructMode(projectDir, branchContext, branch); - let selectedPiece: string | null = null; + const selectedPiece = await selectPiece(projectDir); + if (!selectedPiece) { + info('Cancelled'); + return false; + } - const ensurePieceSelected = async (): Promise => { - if (selectedPiece) { - return selectedPiece; - } - selectedPiece = await selectPiece(projectDir); - if (!selectedPiece) { - info('Cancelled'); - return null; - } - return selectedPiece; + const globalConfig = loadGlobalConfig(); + const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); + const pieceContext: PieceContext = { + name: pieceDesc.name, + description: pieceDesc.description, + pieceStructure: pieceDesc.pieceStructure, + movementPreviews: pieceDesc.movementPreviews, }; + const branchContext = getBranchContext(projectDir, branch); + const result = await runInstructMode(projectDir, branchContext, branch, pieceContext); + return dispatchConversationAction(result, { cancel: () => { info('Cancelled'); return false; }, save_task: async ({ task }) => { - const piece = await ensurePieceSelected(); - if (!piece) { - return false; - } - const created = await saveTaskFile(projectDir, task, { piece }); + const created = await saveTaskFile(projectDir, task, { + piece: selectedPiece, + worktree: true, + branch, + autoPr: false, + }); success(`Task saved: ${created.taskName}`); - info(` File: ${created.tasksFile}`); - log.info('Task saved from instruct mode', { branch, piece }); + info(` Branch: ${branch}`); + log.info('Task saved from instruct mode', { branch, piece: selectedPiece }); return true; }, execute: async ({ task }) => { - const piece = await ensurePieceSelected(); - if (!piece) { - return false; - } - - log.info('Instructing branch via temp clone', { branch, piece }); + log.info('Instructing branch via temp clone', { branch, piece: selectedPiece }); info(`Running instruction on ${branch}...`); const clone = createTempCloneForBranch(projectDir, branch); @@ -366,17 +366,17 @@ export async function instructBranch( const taskSuccess = await executeTask({ task: fullInstruction, cwd: clone.path, - pieceIdentifier: piece, + pieceIdentifier: selectedPiece, projectCwd: projectDir, agentOverrides: options, }); if (taskSuccess) { - const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir); + const commitResult = autoCommitAndPush(clone.path, task, projectDir); if (commitResult.success && commitResult.commitHash) { - info(`Auto-committed & pushed: ${commitResult.commitHash}`); + success(`Auto-committed & pushed: ${commitResult.commitHash}`); } else if (!commitResult.success) { - warn(`Auto-commit skipped: ${commitResult.message}`); + logError(`Auto-commit failed: ${commitResult.message}`); } success(`Instruction completed on ${branch}`); log.info('Instruction completed', { branch }); diff --git a/src/infra/task/clone.ts b/src/infra/task/clone.ts index f424707..69cc1fb 100644 --- a/src/infra/task/clone.ts +++ b/src/infra/task/clone.ts @@ -42,7 +42,7 @@ export class CloneManager { ? globalConfig.worktreeDir : path.resolve(projectDir, globalConfig.worktreeDir); } - return path.join(projectDir, '..'); + return path.join(projectDir, '..', 'takt-worktree'); } /** Resolve the clone path based on options and global config */ From eb593e3829ebe33061248ee89dbfa4897c91a545 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sat, 14 Feb 2026 09:04:06 +0900 Subject: [PATCH 12/15] =?UTF-8?q?OpenCode:=20=E3=82=B5=E3=83=BC=E3=83=90?= =?UTF-8?q?=E3=83=BC=E3=82=B7=E3=83=B3=E3=82=B0=E3=83=AB=E3=83=88=E3=83=B3?= =?UTF-8?q?=E5=8C=96=E3=81=A7=E4=B8=A6=E5=88=97=E5=AE=9F=E8=A1=8C=E6=99=82?= =?UTF-8?q?=E3=81=AE=E7=AB=B6=E5=90=88=E3=82=92=E8=A7=A3=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1つのサーバーを共有し、並列リクエストはキューで処理 - initPromiseで初期化中の競合を防止 - サーバー起動タイムアウトを30秒→60秒に延長 - 並列呼び出し/モデル変更時のテストを追加 --- src/__tests__/opencode-client-cleanup.test.ts | 131 +++++++++++------- src/infra/opencode/client.ts | 116 +++++++++++----- 2 files changed, 167 insertions(+), 80 deletions(-) diff --git a/src/__tests__/opencode-client-cleanup.test.ts b/src/__tests__/opencode-client-cleanup.test.ts index b4ebd59..ab40943 100644 --- a/src/__tests__/opencode-client-cleanup.test.ts +++ b/src/__tests__/opencode-client-cleanup.test.ts @@ -57,8 +57,10 @@ vi.mock('@opencode-ai/sdk/v2', () => ({ })); describe('OpenCodeClient stream cleanup', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + const { resetSharedServer } = await import('../infra/opencode/client.js'); + resetSharedServer(); }); it('should close SSE stream when session.idle is received', async () => { @@ -445,52 +447,6 @@ describe('OpenCodeClient stream cleanup', () => { ); }); - it('should configure allow permissions for edit mode', async () => { - const { OpenCodeClient } = await import('../infra/opencode/client.js'); - const stream = new MockEventStream([ - { - type: 'message.updated', - properties: { - info: { - sessionID: 'session-perm', - role: 'assistant', - time: { created: Date.now(), completed: Date.now() + 1 }, - }, - }, - }, - ]); - - const promptAsync = vi.fn().mockResolvedValue(undefined); - const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-perm' } }); - const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); - const subscribe = vi.fn().mockResolvedValue({ stream }); - - createOpencodeMock.mockResolvedValue({ - client: { - instance: { dispose: disposeInstance }, - session: { create: sessionCreate, promptAsync }, - event: { subscribe }, - permission: { reply: vi.fn() }, - }, - server: { close: vi.fn() }, - }); - - const client = new OpenCodeClient(); - await client.call('coder', 'hello', { - cwd: '/tmp', - model: 'opencode/big-pickle', - permissionMode: 'edit', - }); - - const createCallArgs = createOpencodeMock.mock.calls[0]?.[0] as { config?: Record }; - const permission = createCallArgs.config?.permission as Record; - expect(permission.read).toBe('allow'); - expect(permission.edit).toBe('allow'); - expect(permission.write).toBe('allow'); - expect(permission.bash).toBe('allow'); - expect(permission.question).toBe('deny'); - }); - it('should pass permission ruleset to session.create', async () => { const { OpenCodeClient } = await import('../infra/opencode/client.js'); const stream = new MockEventStream([ @@ -578,4 +534,85 @@ describe('OpenCodeClient stream cleanup', () => { expect(result.status).toBe('error'); expect(result.content).toContain('permission reply timed out'); }); + + it('should reuse shared server for parallel calls with same config', async () => { + const { OpenCodeClient, resetSharedServer } = await import('../infra/opencode/client.js'); + resetSharedServer(); + + let callCount = 0; + const sessionCreate = vi.fn().mockImplementation(() => { + callCount += 1; + return Promise.resolve({ data: { id: `session-${callCount}` } }); + }); + const promptAsync = vi.fn().mockResolvedValue(undefined); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const serverClose = vi.fn(); + + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe: vi.fn().mockImplementation(() => { + const events = [{ type: 'session.idle', properties: { sessionID: `session-${callCount}` } }]; + return Promise.resolve({ stream: new MockEventStream(events) }); + }) }, + permission: { reply: vi.fn() }, + }, + server: { close: serverClose }, + }); + + const client = new OpenCodeClient(); + + const [result1, result2, result3] = await Promise.all([ + client.call('coder', 'task1', { cwd: '/tmp', model: 'opencode/big-pickle' }), + client.call('coder', 'task2', { cwd: '/tmp', model: 'opencode/big-pickle' }), + client.call('coder', 'task3', { cwd: '/tmp', model: 'opencode/big-pickle' }), + ]); + + expect(createOpencodeMock).toHaveBeenCalledTimes(1); + expect(sessionCreate).toHaveBeenCalledTimes(3); + expect(result1.status).toBe('done'); + expect(result2.status).toBe('done'); + expect(result3.status).toBe('done'); + expect(serverClose).not.toHaveBeenCalled(); + }); + + it('should create new server when model changes', async () => { + const { OpenCodeClient, resetSharedServer } = await import('../infra/opencode/client.js'); + resetSharedServer(); + + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-1' } }); + const promptAsync = vi.fn().mockResolvedValue(undefined); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const serverClose1 = vi.fn(); + const serverClose2 = vi.fn(); + + createOpencodeMock.mockResolvedValueOnce({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe: vi.fn().mockResolvedValue({ stream: new MockEventStream([{ type: 'session.idle', properties: { sessionID: 'session-1' } }]) }) }, + permission: { reply: vi.fn() }, + }, + server: { close: serverClose1 }, + }).mockResolvedValueOnce({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe: vi.fn().mockResolvedValue({ stream: new MockEventStream([{ type: 'session.idle', properties: { sessionID: 'session-2' } }]) }) }, + permission: { reply: vi.fn() }, + }, + server: { close: serverClose2 }, + }); + + const client = new OpenCodeClient(); + + const result1 = await client.call('coder', 'task1', { cwd: '/tmp', model: 'opencode/model-a' }); + const result2 = await client.call('coder', 'task2', { cwd: '/tmp', model: 'opencode/model-b' }); + + expect(createOpencodeMock).toHaveBeenCalledTimes(2); + expect(serverClose1).toHaveBeenCalled(); + expect(result1.status).toBe('done'); + expect(result2.status).toBe('done'); + }); }); diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index 07f3970..a0cf429 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -11,7 +11,6 @@ import type { AgentResponse } from '../../core/models/index.js'; import { createLogger, getErrorMessage, createStreamDiagnostics, parseStructuredOutput, type StreamDiagnostics } from '../../shared/utils/index.js'; import { parseProviderModel } from '../../shared/utils/providerModel.js'; import { - buildOpenCodePermissionConfig, buildOpenCodePermissionRuleset, mapToOpenCodePermissionReply, mapToOpenCodeTools, @@ -36,7 +35,7 @@ const OPENCODE_STREAM_ABORTED_MESSAGE = 'OpenCode execution aborted'; const OPENCODE_RETRY_MAX_ATTEMPTS = 3; const OPENCODE_RETRY_BASE_DELAY_MS = 250; const OPENCODE_INTERACTION_TIMEOUT_MS = 5000; -const OPENCODE_SERVER_START_TIMEOUT_MS = 30000; +const OPENCODE_SERVER_START_TIMEOUT_MS = 60000; const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ 'stream disconnected before completion', 'transport error', @@ -47,8 +46,75 @@ const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ 'eai_again', 'fetch failed', 'failed to start server on port', + 'timeout waiting for server', ]; +type OpencodeClient = Awaited>['client']; + +interface SharedServer { + client: OpencodeClient; + close: () => void; + model: string; + apiKey?: string; + queue: Array<(client: OpencodeClient) => void>; +} + +let sharedServer: SharedServer | null = null; +let initPromise: Promise | null = null; + +async function acquireClient(model: string, apiKey?: string, signal?: AbortSignal): Promise<{ client: OpencodeClient; release: () => void }> { + if (initPromise) { + await initPromise; + } + + if (sharedServer?.model === model && sharedServer.apiKey === apiKey) { + if (sharedServer.queue.length === 0) { + return { client: sharedServer.client, release: () => releaseClient() }; + } + return new Promise((resolve) => { + sharedServer!.queue.push((client) => resolve({ client, release: () => releaseClient() })); + }); + } + + sharedServer?.close(); + + let resolveInit: () => void; + initPromise = new Promise((resolve) => { resolveInit = resolve; }); + + try { + const port = await getFreePort(); + const { client, server } = await createOpencode({ + port, + signal, + config: { + model, + small_model: model, + ...(apiKey ? { provider: { opencode: { options: { apiKey } } } } : {}), + }, + timeout: OPENCODE_SERVER_START_TIMEOUT_MS, + }); + + sharedServer = { client, close: server.close, model, apiKey, queue: [] }; + log.debug('OpenCode server started', { model, port }); + + return { client, release: () => releaseClient() }; + } finally { + initPromise = null; + resolveInit!(); + } +} + +function releaseClient(): void { + if (!sharedServer) return; + const next = sharedServer.queue.shift(); + next?.(sharedServer.client); +} + +export function resetSharedServer(): void { + sharedServer?.close(); + sharedServer = null; +} + async function withTimeout( operation: (signal: AbortSignal) => Promise, timeoutMs: number, @@ -271,8 +337,8 @@ export class OpenCodeClient { const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; let abortCause: 'timeout' | 'external' | undefined; let diagRef: StreamDiagnostics | undefined; - let serverClose: (() => void) | undefined; - let opencodeApiClient: Awaited>['client'] | undefined; + let release: (() => void) | undefined; + let opencodeApiClient: OpencodeClient | undefined; let sessionId: string | undefined = options.sessionId; const interactionTimeoutMs = options.interactionTimeoutMs ?? OPENCODE_INTERACTION_TIMEOUT_MS; @@ -313,37 +379,24 @@ export class OpenCodeClient { const parsedModel = parseProviderModel(options.model, 'OpenCode model'); const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`; - const port = await getFreePort(); - const permission = buildOpenCodePermissionConfig(options.permissionMode, options.networkAccess); - const config = { - model: fullModel, - small_model: fullModel, - permission, - ...(options.opencodeApiKey - ? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } - : {}), - }; - const { client, server } = await createOpencode({ - port, - signal: streamAbortController.signal, - config, - timeout: OPENCODE_SERVER_START_TIMEOUT_MS, - }); - opencodeApiClient = client; - serverClose = server.close; + + const acquired = await acquireClient(fullModel, options.opencodeApiKey, streamAbortController.signal); + opencodeApiClient = acquired.client; + release = acquired.release; const sessionResult = sessionId ? { data: { id: sessionId } } - : await client.session.create({ + : await opencodeApiClient.session.create({ directory: options.cwd, permission: buildOpenCodePermissionRuleset(options.permissionMode, options.networkAccess), }); sessionId = sessionResult.data?.id; if (!sessionId) { + release(); throw new Error('Failed to create OpenCode session'); } - const { stream } = await client.event.subscribe( + const { stream } = await opencodeApiClient.event.subscribe( { directory: options.cwd }, { signal: streamAbortController.signal }, ); @@ -365,9 +418,8 @@ export class OpenCodeClient { }; } - // OpenCode SDK types do not yet expose outputFormat even though runtime accepts it. - const promptPayloadForSdk = promptPayload as unknown as Parameters[0]; - await client.session.promptAsync(promptPayloadForSdk, { + const promptPayloadForSdk = promptPayload as unknown as Parameters[0]; + await opencodeApiClient.session.promptAsync(promptPayloadForSdk, { signal: streamAbortController.signal, }); @@ -427,7 +479,7 @@ export class OpenCodeClient { ? mapToOpenCodePermissionReply(options.permissionMode) : 'once'; await withTimeout( - (signal) => client.permission.reply({ + (signal) => opencodeApiClient!.permission.reply({ requestID: permProps.id, directory: options.cwd, reply, @@ -450,7 +502,7 @@ export class OpenCodeClient { if (!options.onAskUserQuestion) { try { await withTimeout( - (signal) => client.question.reject({ + (signal) => opencodeApiClient!.question.reject({ requestID: questionProps.id, directory: options.cwd, }, { signal }), @@ -468,7 +520,7 @@ export class OpenCodeClient { try { const answers = await options.onAskUserQuestion(toQuestionInput(questionProps)); await withTimeout( - (signal) => client.question.reply({ + (signal) => opencodeApiClient!.question.reply({ requestID: questionProps.id, directory: options.cwd, answers: toQuestionAnswers(questionProps, answers), @@ -671,9 +723,7 @@ export class OpenCodeClient { clearTimeout(disposeTimeoutId); } } - if (serverClose) { - serverClose(); - } + release?.(); if (!streamAbortController.signal.aborted) { streamAbortController.abort(); } From e52e1da6bf0bfdfb6a4e37add52a8935b6e76399 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Sat, 14 Feb 2026 11:44:01 +0900 Subject: [PATCH 13/15] takt-list (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: provider/modelの解決ロジックをAgentRunnerに集約 OptionsBuilderでCLIレベルとstepレベルを事前マージしていたのをやめ、 stepProvider/stepModelとして分離して渡す形に変更。 AgentRunnerが全レイヤーの優先度を一括で解決する。 * takt: takt-list --- src/__tests__/engine-agent-overrides.test.ts | 24 +- .../engine-persona-providers.test.ts | 20 +- ...istNonInteractive-completedActions.test.ts | 83 ++++ src/__tests__/listNonInteractive.test.ts | 12 +- src/__tests__/listTasks.test.ts | 16 +- .../listTasksInteractivePendingLabel.test.ts | 37 +- .../listTasksInteractiveStatusActions.test.ts | 147 +++++++ src/__tests__/option-resolution-order.test.ts | 210 +++++++++ src/__tests__/selectAndExecute-autoPr.test.ts | 111 ++++- src/__tests__/task.test.ts | 38 +- src/__tests__/taskDeleteActions.test.ts | 84 +++- src/__tests__/taskExecution.test.ts | 1 + src/__tests__/taskInstructionActions.test.ts | 157 +++++++ src/__tests__/taskStatusLabel.test.ts | 6 +- src/agents/runner.ts | 35 +- src/agents/types.ts | 2 + src/core/piece/engine/OptionsBuilder.ts | 6 +- src/features/tasks/execute/resolveTask.ts | 4 + .../tasks/execute/selectAndExecute.ts | 52 ++- src/features/tasks/execute/taskExecution.ts | 40 +- .../tasks/execute/taskResultHandler.ts | 125 ++++++ src/features/tasks/list/index.ts | 135 +++--- src/features/tasks/list/listNonInteractive.ts | 56 +-- src/features/tasks/list/taskActionTarget.ts | 29 ++ src/features/tasks/list/taskActions.ts | 402 +----------------- .../tasks/list/taskBranchLifecycleActions.ts | 141 ++++++ src/features/tasks/list/taskDeleteActions.ts | 40 +- src/features/tasks/list/taskDiffActions.ts | 74 ++++ .../tasks/list/taskInstructionActions.ts | 185 ++++++++ src/features/tasks/list/taskStatusLabel.ts | 4 +- src/infra/task/mapper.ts | 46 +- src/infra/task/runner.ts | 246 ++--------- src/infra/task/schema.ts | 1 + src/infra/task/taskDeletionService.ts | 29 ++ src/infra/task/taskLifecycleService.ts | 232 ++++++++++ src/infra/task/taskQueryService.ts | 37 ++ src/infra/task/types.ts | 7 +- 37 files changed, 2003 insertions(+), 871 deletions(-) create mode 100644 src/__tests__/listNonInteractive-completedActions.test.ts create mode 100644 src/__tests__/listTasksInteractiveStatusActions.test.ts create mode 100644 src/__tests__/option-resolution-order.test.ts create mode 100644 src/__tests__/taskInstructionActions.test.ts create mode 100644 src/features/tasks/execute/taskResultHandler.ts create mode 100644 src/features/tasks/list/taskActionTarget.ts create mode 100644 src/features/tasks/list/taskBranchLifecycleActions.ts create mode 100644 src/features/tasks/list/taskDiffActions.ts create mode 100644 src/features/tasks/list/taskInstructionActions.ts create mode 100644 src/infra/task/taskDeletionService.ts create mode 100644 src/infra/task/taskLifecycleService.ts create mode 100644 src/infra/task/taskQueryService.ts diff --git a/src/__tests__/engine-agent-overrides.test.ts b/src/__tests__/engine-agent-overrides.test.ts index 4ba823c..38b2804 100644 --- a/src/__tests__/engine-agent-overrides.test.ts +++ b/src/__tests__/engine-agent-overrides.test.ts @@ -1,8 +1,8 @@ /** * Tests for PieceEngine provider/model overrides. * - * Verifies that CLI-specified overrides take precedence over piece movement defaults, - * and that movement-specific values are used when no overrides are present. + * Verifies that PieceEngine passes CLI-level and movement-level provider/model + * as separate fields to AgentRunner. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -44,7 +44,7 @@ describe('PieceEngine agent overrides', () => { applyDefaultMocks(); }); - it('respects piece movement provider/model even when CLI overrides are provided', async () => { + it('passes CLI provider/model and movement provider/model separately', async () => { const movement = makeMovement('plan', { provider: 'claude', model: 'claude-movement', @@ -71,11 +71,13 @@ describe('PieceEngine agent overrides', () => { await engine.run(); const options = vi.mocked(runAgent).mock.calls[0][2]; - expect(options.provider).toBe('claude'); - expect(options.model).toBe('claude-movement'); + expect(options.provider).toBe('codex'); + expect(options.model).toBe('cli-model'); + expect(options.stepProvider).toBe('claude'); + expect(options.stepModel).toBe('claude-movement'); }); - it('allows CLI overrides when piece movement leaves provider/model undefined', async () => { + it('uses CLI provider/model when movement provider/model is undefined', async () => { const movement = makeMovement('plan', { rules: [makeRule('done', 'COMPLETE')], }); @@ -102,9 +104,11 @@ describe('PieceEngine agent overrides', () => { const options = vi.mocked(runAgent).mock.calls[0][2]; expect(options.provider).toBe('codex'); expect(options.model).toBe('cli-model'); + expect(options.stepProvider).toBe('codex'); + expect(options.stepModel).toBe('cli-model'); }); - it('falls back to piece movement provider/model when no overrides supplied', async () => { + it('sets movement provider/model to step fields when no CLI overrides are supplied', async () => { const movement = makeMovement('plan', { provider: 'claude', model: 'movement-model', @@ -126,7 +130,9 @@ describe('PieceEngine agent overrides', () => { await engine.run(); const options = vi.mocked(runAgent).mock.calls[0][2]; - expect(options.provider).toBe('claude'); - expect(options.model).toBe('movement-model'); + expect(options.provider).toBeUndefined(); + expect(options.model).toBeUndefined(); + expect(options.stepProvider).toBe('claude'); + expect(options.stepModel).toBe('movement-model'); }); }); diff --git a/src/__tests__/engine-persona-providers.test.ts b/src/__tests__/engine-persona-providers.test.ts index 4dc533c..2766186 100644 --- a/src/__tests__/engine-persona-providers.test.ts +++ b/src/__tests__/engine-persona-providers.test.ts @@ -1,10 +1,10 @@ /** * Tests for persona_providers config-level provider override. * - * Verifies the provider resolution priority: + * Verifies movement-level provider resolution for stepProvider: * 1. Movement YAML provider (highest) * 2. persona_providers[personaDisplayName] - * 3. CLI/global provider (lowest) + * 3. CLI provider (lowest) */ import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -72,7 +72,8 @@ describe('PieceEngine persona_providers override', () => { await engine.run(); const options = vi.mocked(runAgent).mock.calls[0][2]; - expect(options.provider).toBe('codex'); + expect(options.provider).toBe('claude'); + expect(options.stepProvider).toBe('codex'); }); it('should use global provider when persona is not in persona_providers', async () => { @@ -102,6 +103,7 @@ describe('PieceEngine persona_providers override', () => { const options = vi.mocked(runAgent).mock.calls[0][2]; expect(options.provider).toBe('claude'); + expect(options.stepProvider).toBe('claude'); }); it('should prioritize movement provider over persona_providers', async () => { @@ -131,7 +133,8 @@ describe('PieceEngine persona_providers override', () => { await engine.run(); const options = vi.mocked(runAgent).mock.calls[0][2]; - expect(options.provider).toBe('claude'); + expect(options.provider).toBe('mock'); + expect(options.stepProvider).toBe('claude'); }); it('should work without persona_providers (undefined)', async () => { @@ -160,6 +163,7 @@ describe('PieceEngine persona_providers override', () => { const options = vi.mocked(runAgent).mock.calls[0][2]; expect(options.provider).toBe('claude'); + expect(options.stepProvider).toBe('claude'); }); it('should apply different providers to different personas in a multi-movement piece', async () => { @@ -196,9 +200,11 @@ describe('PieceEngine persona_providers override', () => { await engine.run(); const calls = vi.mocked(runAgent).mock.calls; - // Plan movement: planner not in persona_providers → claude + // Plan movement: planner not in persona_providers → stepProvider は claude expect(calls[0][2].provider).toBe('claude'); - // Implement movement: coder in persona_providers → codex - expect(calls[1][2].provider).toBe('codex'); + expect(calls[0][2].stepProvider).toBe('claude'); + // Implement movement: coder in persona_providers → stepProvider は codex + expect(calls[1][2].provider).toBe('claude'); + expect(calls[1][2].stepProvider).toBe('codex'); }); }); diff --git a/src/__tests__/listNonInteractive-completedActions.test.ts b/src/__tests__/listNonInteractive-completedActions.test.ts new file mode 100644 index 0000000..7860340 --- /dev/null +++ b/src/__tests__/listNonInteractive-completedActions.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockDeleteCompletedTask, + mockListAllTaskItems, + mockMergeBranch, + mockDeleteBranch, + mockInfo, +} = vi.hoisted(() => ({ + mockDeleteCompletedTask: vi.fn(), + mockListAllTaskItems: vi.fn(), + mockMergeBranch: vi.fn(), + mockDeleteBranch: vi.fn(), + mockInfo: vi.fn(), +})); + +vi.mock('../infra/task/index.js', () => ({ + detectDefaultBranch: vi.fn(() => 'main'), + TaskRunner: class { + listAllTaskItems() { + return mockListAllTaskItems(); + } + deleteCompletedTask(name: string) { + mockDeleteCompletedTask(name); + } + }, +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: (...args: unknown[]) => mockInfo(...args), +})); + +vi.mock('../features/tasks/list/taskActions.js', () => ({ + tryMergeBranch: vi.fn(), + mergeBranch: (...args: unknown[]) => mockMergeBranch(...args), + deleteBranch: (...args: unknown[]) => mockDeleteBranch(...args), +})); + +import { listTasksNonInteractive } from '../features/tasks/list/listNonInteractive.js'; + +describe('listTasksNonInteractive completed actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockListAllTaskItems.mockReturnValue([ + { + kind: 'completed', + name: 'completed-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'done', + branch: 'takt/completed-task', + }, + ]); + }); + + it('should delete completed record after merge action', async () => { + mockMergeBranch.mockReturnValue(true); + + await listTasksNonInteractive('/project', { + enabled: true, + action: 'merge', + branch: 'takt/completed-task', + yes: true, + }); + + expect(mockMergeBranch).toHaveBeenCalled(); + expect(mockDeleteCompletedTask).toHaveBeenCalledWith('completed-task'); + }); + + it('should delete completed record after delete action', async () => { + mockDeleteBranch.mockReturnValue(true); + + await listTasksNonInteractive('/project', { + enabled: true, + action: 'delete', + branch: 'takt/completed-task', + yes: true, + }); + + expect(mockDeleteBranch).toHaveBeenCalled(); + expect(mockDeleteCompletedTask).toHaveBeenCalledWith('completed-task'); + }); +}); diff --git a/src/__tests__/listNonInteractive.test.ts b/src/__tests__/listNonInteractive.test.ts index 85b66f2..71f8ae8 100644 --- a/src/__tests__/listNonInteractive.test.ts +++ b/src/__tests__/listNonInteractive.test.ts @@ -13,8 +13,6 @@ vi.mock('../shared/ui/index.js', () => ({ vi.mock('../infra/task/branchList.js', async (importOriginal) => ({ ...(await importOriginal>()), detectDefaultBranch: vi.fn(() => 'main'), - listTaktBranches: vi.fn(() => []), - buildListItems: vi.fn(() => []), })); let tmpDir: string; @@ -60,7 +58,7 @@ describe('listTasksNonInteractive', () => { await listTasksNonInteractive(tmpDir, { enabled: true, format: 'text' }); - expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[running] pending-task')); + expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[pending] pending-task')); expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[failed] failed-task')); }); @@ -71,9 +69,11 @@ describe('listTasksNonInteractive', () => { await listTasksNonInteractive(tmpDir, { enabled: true, format: 'json' }); 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'); + const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { tasks: Array<{ name: string; kind: string }> }; + expect(payload.tasks[0]?.name).toBe('pending-task'); + expect(payload.tasks[0]?.kind).toBe('pending'); + expect(payload.tasks[1]?.name).toBe('failed-task'); + expect(payload.tasks[1]?.kind).toBe('failed'); logSpy.mockRestore(); }); diff --git a/src/__tests__/listTasks.test.ts b/src/__tests__/listTasks.test.ts index 704b749..ba74935 100644 --- a/src/__tests__/listTasks.test.ts +++ b/src/__tests__/listTasks.test.ts @@ -12,8 +12,6 @@ vi.mock('../shared/ui/index.js', () => ({ vi.mock('../infra/task/branchList.js', async (importOriginal) => ({ ...(await importOriginal>()), - listTaktBranches: vi.fn(() => []), - buildListItems: vi.fn(() => []), detectDefaultBranch: vi.fn(() => 'main'), })); @@ -74,7 +72,7 @@ describe('TaskRunner list APIs', () => { }); describe('listTasks non-interactive JSON output', () => { - it('should output JSON object with branches, pendingTasks, and failedTasks', async () => { + it('should output JSON object with tasks', async () => { writeTasksFile(tmpDir); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -82,13 +80,13 @@ describe('listTasks non-interactive JSON output', () => { expect(logSpy).toHaveBeenCalledTimes(1); const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { - branches: unknown[]; - pendingTasks: Array<{ name: string }>; - failedTasks: Array<{ name: string }>; + tasks: Array<{ name: string; kind: string }>; }; - expect(Array.isArray(payload.branches)).toBe(true); - expect(payload.pendingTasks[0]?.name).toBe('pending-one'); - expect(payload.failedTasks[0]?.name).toBe('failed-one'); + expect(Array.isArray(payload.tasks)).toBe(true); + expect(payload.tasks[0]?.name).toBe('pending-one'); + expect(payload.tasks[0]?.kind).toBe('pending'); + expect(payload.tasks[1]?.name).toBe('failed-one'); + expect(payload.tasks[1]?.kind).toBe('failed'); logSpy.mockRestore(); }); diff --git a/src/__tests__/listTasksInteractivePendingLabel.test.ts b/src/__tests__/listTasksInteractivePendingLabel.test.ts index f6e3196..5de745b 100644 --- a/src/__tests__/listTasksInteractivePendingLabel.test.ts +++ b/src/__tests__/listTasksInteractivePendingLabel.test.ts @@ -6,38 +6,27 @@ const { mockHeader, mockInfo, mockBlankLine, - mockConfirm, - mockListPendingTaskItems, - mockListFailedTasks, + mockListAllTaskItems, mockDeletePendingTask, } = vi.hoisted(() => ({ mockSelectOption: vi.fn(), mockHeader: vi.fn(), mockInfo: vi.fn(), mockBlankLine: vi.fn(), - mockConfirm: vi.fn(), - mockListPendingTaskItems: vi.fn(), - mockListFailedTasks: vi.fn(), + mockListAllTaskItems: 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(); + listAllTaskItems() { + return mockListAllTaskItems(); } }, })); vi.mock('../shared/prompt/index.js', () => ({ selectOption: mockSelectOption, - confirm: mockConfirm, })); vi.mock('../shared/ui/index.js', () => ({ @@ -48,7 +37,7 @@ vi.mock('../shared/ui/index.js', () => ({ vi.mock('../features/tasks/list/taskActions.js', () => ({ showFullDiff: vi.fn(), - showDiffAndPromptAction: vi.fn(), + showDiffAndPromptActionForTask: vi.fn(), tryMergeBranch: vi.fn(), mergeBranch: vi.fn(), deleteBranch: vi.fn(), @@ -58,6 +47,7 @@ vi.mock('../features/tasks/list/taskActions.js', () => ({ vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({ deletePendingTask: mockDeletePendingTask, deleteFailedTask: vi.fn(), + deleteCompletedTask: vi.fn(), })); vi.mock('../features/tasks/list/taskRetryActions.js', () => ({ @@ -77,23 +67,22 @@ describe('listTasks interactive pending label regression', () => { beforeEach(() => { vi.clearAllMocks(); - mockListPendingTaskItems.mockReturnValue([pendingTask]); - mockListFailedTasks.mockReturnValue([]); + mockListAllTaskItems.mockReturnValue([pendingTask]); }); - it('should show [running] in interactive menu for pending tasks', async () => { + it('should show [pending] 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).toContainEqual(expect.objectContaining({ label: '[pending] my-task', value: 'pending:0' })); + expect(menuOptions.some((opt) => opt.label.includes('[running]'))).toBe(false); expect(menuOptions.some((opt) => opt.label.includes('[pendig]'))).toBe(false); }); - it('should show [running] header when pending task is selected', async () => { + it('should show [pending] header when pending task is selected', async () => { mockSelectOption .mockResolvedValueOnce('pending:0') .mockResolvedValueOnce(null) @@ -101,9 +90,9 @@ describe('listTasks interactive pending label regression', () => { await listTasks('/project'); - expect(mockHeader).toHaveBeenCalledWith('[running] my-task'); + expect(mockHeader).toHaveBeenCalledWith('[pending] 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('[running]'))).toBe(false); expect(headerTexts.some((text) => text.includes('[pendig]'))).toBe(false); }); }); diff --git a/src/__tests__/listTasksInteractiveStatusActions.test.ts b/src/__tests__/listTasksInteractiveStatusActions.test.ts new file mode 100644 index 0000000..98b2730 --- /dev/null +++ b/src/__tests__/listTasksInteractiveStatusActions.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TaskListItem } from '../infra/task/types.js'; + +const { + mockSelectOption, + mockHeader, + mockInfo, + mockBlankLine, + mockListAllTaskItems, + mockDeleteCompletedRecord, + mockShowDiffAndPromptActionForTask, + mockMergeBranch, + mockDeleteCompletedTask, +} = vi.hoisted(() => ({ + mockSelectOption: vi.fn(), + mockHeader: vi.fn(), + mockInfo: vi.fn(), + mockBlankLine: vi.fn(), + mockListAllTaskItems: vi.fn(), + mockDeleteCompletedRecord: vi.fn(), + mockShowDiffAndPromptActionForTask: vi.fn(), + mockMergeBranch: vi.fn(), + mockDeleteCompletedTask: vi.fn(), +})); + +vi.mock('../infra/task/index.js', () => ({ + TaskRunner: class { + listAllTaskItems() { + return mockListAllTaskItems(); + } + deleteCompletedTask(name: string) { + mockDeleteCompletedRecord(name); + } + }, +})); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: mockSelectOption, +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: mockInfo, + header: mockHeader, + blankLine: mockBlankLine, +})); + +vi.mock('../features/tasks/list/taskActions.js', () => ({ + showFullDiff: vi.fn(), + showDiffAndPromptActionForTask: mockShowDiffAndPromptActionForTask, + tryMergeBranch: vi.fn(), + mergeBranch: mockMergeBranch, + deleteBranch: vi.fn(), + instructBranch: vi.fn(), +})); + +vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({ + deletePendingTask: vi.fn(), + deleteFailedTask: vi.fn(), + deleteCompletedTask: mockDeleteCompletedTask, +})); + +vi.mock('../features/tasks/list/taskRetryActions.js', () => ({ + retryFailedTask: vi.fn(), +})); + +import { listTasks } from '../features/tasks/list/index.js'; + +const runningTask: TaskListItem = { + kind: 'running', + name: 'running-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'in progress', +}; + +const completedTaskWithBranch: TaskListItem = { + kind: 'completed', + name: 'completed-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'done', + branch: 'takt/completed-task', +}; + +const completedTaskWithoutBranch: TaskListItem = { + ...completedTaskWithBranch, + branch: undefined, + name: 'completed-without-branch', +}; + +describe('listTasks interactive status actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('running タスク選択時は read-only メッセージを表示する', async () => { + mockListAllTaskItems.mockReturnValue([runningTask]); + mockSelectOption + .mockResolvedValueOnce('running:0') + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockHeader).toHaveBeenCalledWith('[running] running-task'); + expect(mockInfo).toHaveBeenCalledWith('Running task is read-only.'); + expect(mockShowDiffAndPromptActionForTask).not.toHaveBeenCalled(); + }); + + it('completed タスクで branch が無い場合はアクションに進まない', async () => { + mockListAllTaskItems.mockReturnValue([completedTaskWithoutBranch]); + mockSelectOption + .mockResolvedValueOnce('completed:0') + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockInfo).toHaveBeenCalledWith('Branch is missing for completed task: completed-without-branch'); + expect(mockShowDiffAndPromptActionForTask).not.toHaveBeenCalled(); + }); + + it('completed merge 成功時は tasks.yaml から completed レコードを削除する', async () => { + mockListAllTaskItems.mockReturnValue([completedTaskWithBranch]); + mockShowDiffAndPromptActionForTask.mockResolvedValueOnce('merge'); + mockMergeBranch.mockReturnValue(true); + mockSelectOption + .mockResolvedValueOnce('completed:0') + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockMergeBranch).toHaveBeenCalledWith('/project', completedTaskWithBranch); + expect(mockDeleteCompletedRecord).toHaveBeenCalledWith('completed-task'); + }); + + it('completed delete 選択時は deleteCompletedTask を呼ぶ', async () => { + mockListAllTaskItems.mockReturnValue([completedTaskWithBranch]); + mockShowDiffAndPromptActionForTask.mockResolvedValueOnce('delete'); + mockSelectOption + .mockResolvedValueOnce('completed:0') + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockDeleteCompletedTask).toHaveBeenCalledWith(completedTaskWithBranch); + expect(mockDeleteCompletedRecord).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/option-resolution-order.test.ts b/src/__tests__/option-resolution-order.test.ts new file mode 100644 index 0000000..fc71099 --- /dev/null +++ b/src/__tests__/option-resolution-order.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + getProviderMock, + loadProjectConfigMock, + loadGlobalConfigMock, + loadCustomAgentsMock, + loadAgentPromptMock, + loadTemplateMock, + providerSetupMock, + providerCallMock, +} = vi.hoisted(() => { + const providerCall = vi.fn(); + const providerSetup = vi.fn(() => ({ call: providerCall })); + + return { + getProviderMock: vi.fn(() => ({ setup: providerSetup })), + loadProjectConfigMock: vi.fn(), + loadGlobalConfigMock: vi.fn(), + loadCustomAgentsMock: vi.fn(), + loadAgentPromptMock: vi.fn(), + loadTemplateMock: vi.fn(), + providerSetupMock: providerSetup, + providerCallMock: providerCall, + }; +}); + +vi.mock('../infra/providers/index.js', () => ({ + getProvider: getProviderMock, +})); + +vi.mock('../infra/config/index.js', () => ({ + loadProjectConfig: loadProjectConfigMock, + loadGlobalConfig: loadGlobalConfigMock, + loadCustomAgents: loadCustomAgentsMock, + loadAgentPrompt: loadAgentPromptMock, +})); + +vi.mock('../shared/prompts/index.js', () => ({ + loadTemplate: loadTemplateMock, +})); + +import { runAgent } from '../agents/runner.js'; + +describe('option resolution order', () => { + beforeEach(() => { + vi.clearAllMocks(); + + providerCallMock.mockResolvedValue({ content: 'ok' }); + loadProjectConfigMock.mockReturnValue({}); + loadGlobalConfigMock.mockReturnValue({}); + loadCustomAgentsMock.mockReturnValue(new Map()); + loadAgentPromptMock.mockReturnValue('prompt'); + loadTemplateMock.mockReturnValue('template'); + }); + + it('should resolve provider in order: CLI > Local > Piece(step) > Global', async () => { + // Given + loadProjectConfigMock.mockReturnValue({ provider: 'opencode' }); + loadGlobalConfigMock.mockReturnValue({ provider: 'mock' }); + + // When: CLI provider が指定される + await runAgent(undefined, 'task', { + cwd: '/repo', + provider: 'codex', + stepProvider: 'claude', + }); + + // Then + expect(getProviderMock).toHaveBeenLastCalledWith('codex'); + + // When: CLI 指定なし(Local が有効) + await runAgent(undefined, 'task', { + cwd: '/repo', + stepProvider: 'claude', + }); + + // Then + expect(getProviderMock).toHaveBeenLastCalledWith('opencode'); + + // When: Local なし(Piece が有効) + loadProjectConfigMock.mockReturnValue({}); + await runAgent(undefined, 'task', { + cwd: '/repo', + stepProvider: 'claude', + }); + + // Then + expect(getProviderMock).toHaveBeenLastCalledWith('claude'); + + // When: Piece なし(Global が有効) + await runAgent(undefined, 'task', { cwd: '/repo' }); + + // Then + expect(getProviderMock).toHaveBeenLastCalledWith('mock'); + }); + + it('should resolve model in order: CLI > Piece(step) > Global(matching provider)', async () => { + // Given + loadProjectConfigMock.mockReturnValue({ provider: 'claude' }); + loadGlobalConfigMock.mockReturnValue({ provider: 'claude', model: 'global-model' }); + + // When: CLI model あり + await runAgent(undefined, 'task', { + cwd: '/repo', + model: 'cli-model', + stepModel: 'step-model', + }); + + // Then + expect(providerCallMock).toHaveBeenLastCalledWith( + 'task', + expect.objectContaining({ model: 'cli-model' }), + ); + + // When: CLI model なし + await runAgent(undefined, 'task', { + cwd: '/repo', + stepModel: 'step-model', + }); + + // Then + expect(providerCallMock).toHaveBeenLastCalledWith( + 'task', + expect.objectContaining({ model: 'step-model' }), + ); + + // When: stepModel なし + await runAgent(undefined, 'task', { cwd: '/repo' }); + + // Then + expect(providerCallMock).toHaveBeenLastCalledWith( + 'task', + expect.objectContaining({ model: 'global-model' }), + ); + }); + + it('should ignore global model when global provider does not match resolved provider', async () => { + // Given + loadProjectConfigMock.mockReturnValue({ provider: 'codex' }); + loadGlobalConfigMock.mockReturnValue({ provider: 'claude', model: 'global-model' }); + + // When + await runAgent(undefined, 'task', { cwd: '/repo' }); + + // Then + expect(providerCallMock).toHaveBeenLastCalledWith( + 'task', + expect.objectContaining({ model: undefined }), + ); + }); + + it('should use providerOptions from piece(step) only', async () => { + // Given + const stepProviderOptions = { + claude: { + sandbox: { + allowUnsandboxedCommands: false, + }, + }, + }; + + loadProjectConfigMock.mockReturnValue({ + provider: 'claude', + provider_options: { + claude: { sandbox: { allow_unsandboxed_commands: true } }, + }, + }); + loadGlobalConfigMock.mockReturnValue({ + provider: 'claude', + providerOptions: { + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }, + }); + + // When + await runAgent(undefined, 'task', { + cwd: '/repo', + provider: 'claude', + providerOptions: stepProviderOptions, + }); + + // Then + expect(providerCallMock).toHaveBeenLastCalledWith( + 'task', + expect.objectContaining({ providerOptions: stepProviderOptions }), + ); + }); + + it('should use custom agent provider/model when higher-priority values are absent', async () => { + // Given + const customAgents = new Map([ + ['custom', { name: 'custom', prompt: 'agent prompt', provider: 'opencode', model: 'agent-model' }], + ]); + loadCustomAgentsMock.mockReturnValue(customAgents); + + // When + await runAgent('custom', 'task', { cwd: '/repo' }); + + // Then + expect(getProviderMock).toHaveBeenLastCalledWith('opencode'); + expect(providerCallMock).toHaveBeenLastCalledWith( + 'task', + expect.objectContaining({ model: 'agent-model' }), + ); + expect(providerSetupMock).toHaveBeenLastCalledWith( + expect.objectContaining({ systemPrompt: 'prompt' }), + ); + }); +}); diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index b483e04..2224f78 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -4,6 +4,25 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +const { + mockAddTask, + mockCompleteTask, + mockFailTask, + mockExecuteTask, +} = vi.hoisted(() => ({ + mockAddTask: vi.fn(() => ({ + name: 'test-task', + content: 'test task', + filePath: '/project/.takt/tasks.yaml', + createdAt: '2026-02-14T00:00:00.000Z', + status: 'pending', + data: { task: 'test task' }, + })), + mockCompleteTask: vi.fn(), + mockFailTask: vi.fn(), + mockExecuteTask: vi.fn(), +})); + vi.mock('../shared/prompt/index.js', () => ({ confirm: vi.fn(), })); @@ -21,6 +40,11 @@ vi.mock('../infra/task/index.js', () => ({ autoCommitAndPush: vi.fn(), summarizeTaskName: vi.fn(), getCurrentBranch: vi.fn(() => 'main'), + TaskRunner: vi.fn(() => ({ + addTask: (...args: unknown[]) => mockAddTask(...args), + completeTask: (...args: unknown[]) => mockCompleteTask(...args), + failTask: (...args: unknown[]) => mockFailTask(...args), + })), })); vi.mock('../shared/ui/index.js', () => ({ @@ -50,7 +74,7 @@ vi.mock('../infra/github/index.js', () => ({ })); vi.mock('../features/tasks/execute/taskExecution.js', () => ({ - executeTask: vi.fn(), + executeTask: (...args: unknown[]) => mockExecuteTask(...args), })); vi.mock('../features/pieceSelection/index.js', () => ({ @@ -61,17 +85,11 @@ vi.mock('../features/pieceSelection/index.js', () => ({ })); import { confirm } from '../shared/prompt/index.js'; -import { - getCurrentPiece, - listPieces, -} from '../infra/config/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../infra/task/index.js'; import { selectPiece } 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 mockListPieces = vi.mocked(listPieces); const mockCreateSharedClone = vi.mocked(createSharedClone); const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush); const mockSummarizeTaskName = vi.mocked(summarizeTaskName); @@ -79,6 +97,7 @@ const mockSelectPiece = vi.mocked(selectPiece); beforeEach(() => { vi.clearAllMocks(); + mockExecuteTask.mockResolvedValue(true); }); describe('resolveAutoPr default in selectAndExecuteTask', () => { @@ -91,10 +110,6 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { branch: 'takt/test-task', }); - const { executeTask } = await import( - '../features/tasks/execute/taskExecution.js' - ); - vi.mocked(executeTask).mockResolvedValue(true); mockAutoCommitAndPush.mockReturnValue({ success: false, message: 'no changes', @@ -122,4 +137,78 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { expect(selected).toBe('selected-piece'); expect(mockSelectPiece).toHaveBeenCalledWith('/project'); }); + + it('should fail task record when executeTask throws', async () => { + mockConfirm.mockResolvedValue(true); + mockSummarizeTaskName.mockResolvedValue('test-task'); + mockCreateSharedClone.mockReturnValue({ + path: '/project/../clone', + branch: 'takt/test-task', + }); + mockExecuteTask.mockRejectedValue(new Error('boom')); + + await expect(selectAndExecuteTask('/project', 'test task', { + piece: 'default', + createWorktree: true, + })).rejects.toThrow('boom'); + + expect(mockAddTask).toHaveBeenCalledTimes(1); + expect(mockFailTask).toHaveBeenCalledTimes(1); + expect(mockCompleteTask).not.toHaveBeenCalled(); + }); + + it('should record task and complete when executeTask returns true', async () => { + mockConfirm.mockResolvedValue(true); + mockSummarizeTaskName.mockResolvedValue('test-task'); + mockCreateSharedClone.mockReturnValue({ + path: '/project/../clone', + branch: 'takt/test-task', + }); + mockExecuteTask.mockResolvedValue(true); + + await selectAndExecuteTask('/project', 'test task', { + piece: 'default', + createWorktree: true, + }); + + expect(mockAddTask).toHaveBeenCalledWith('test task', expect.objectContaining({ + piece: 'default', + worktree: true, + branch: 'takt/test-task', + worktree_path: '/project/../clone', + auto_pr: true, + })); + expect(mockCompleteTask).toHaveBeenCalledTimes(1); + expect(mockFailTask).not.toHaveBeenCalled(); + }); + + it('should record task and fail when executeTask returns false', async () => { + mockConfirm.mockResolvedValue(false); + mockSummarizeTaskName.mockResolvedValue('test-task'); + mockCreateSharedClone.mockReturnValue({ + path: '/project/../clone', + branch: 'takt/test-task', + }); + mockExecuteTask.mockResolvedValue(false); + + const processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process exit'); + }) as (code?: string | number | null | undefined) => never); + + await expect(selectAndExecuteTask('/project', 'test task', { + piece: 'default', + createWorktree: true, + })).rejects.toThrow('process exit'); + + expect(mockAddTask).toHaveBeenCalledWith('test task', expect.objectContaining({ + piece: 'default', + worktree: true, + branch: 'takt/test-task', + worktree_path: '/project/../clone', + auto_pr: false, + })); + expect(mockFailTask).toHaveBeenCalledTimes(1); + expect(mockCompleteTask).not.toHaveBeenCalled(); + processExitSpy.mockRestore(); + }); }); diff --git a/src/__tests__/task.test.ts b/src/__tests__/task.test.ts index d715390..1cf5e99 100644 --- a/src/__tests__/task.test.ts +++ b/src/__tests__/task.test.ts @@ -215,7 +215,7 @@ describe('TaskRunner (tasks.yaml)', () => { expect(() => runner.listTasks()).toThrow(/ENOENT|no such file/i); }); - it('should remove completed task record from tasks.yaml', () => { + it('should keep completed task record in tasks.yaml', () => { runner.addTask('Task A'); const task = runner.claimNextTasks(1)[0]!; @@ -229,10 +229,11 @@ describe('TaskRunner (tasks.yaml)', () => { }); const file = loadTasksFile(testDir); - expect(file.tasks).toHaveLength(0); + expect(file.tasks).toHaveLength(1); + expect(file.tasks[0]?.status).toBe('completed'); }); - it('should remove only the completed task when multiple tasks exist', () => { + it('should update only target task to completed when multiple tasks exist', () => { runner.addTask('Task A'); runner.addTask('Task B'); const task = runner.claimNextTasks(1)[0]!; @@ -247,9 +248,11 @@ describe('TaskRunner (tasks.yaml)', () => { }); const file = loadTasksFile(testDir); - expect(file.tasks).toHaveLength(1); - expect(file.tasks[0]?.name).toContain('task-b'); - expect(file.tasks[0]?.status).toBe('pending'); + expect(file.tasks).toHaveLength(2); + expect(file.tasks[0]?.name).toContain('task-a'); + expect(file.tasks[0]?.status).toBe('completed'); + expect(file.tasks[1]?.name).toContain('task-b'); + expect(file.tasks[1]?.status).toBe('pending'); }); it('should mark claimed task as failed with failure detail', () => { @@ -274,6 +277,29 @@ describe('TaskRunner (tasks.yaml)', () => { expect(failed[0]?.failure?.last_message).toBe('last message'); }); + it('should mark pending task as failed with started_at and branch', () => { + const task = runner.addTask('Task C', { branch: 'takt/task-c' }); + const startedAt = new Date().toISOString(); + const completedAt = new Date().toISOString(); + + runner.failTask({ + task, + success: false, + response: 'Boom', + executionLog: [], + startedAt, + completedAt, + branch: 'takt/task-c-updated', + }); + + const file = loadTasksFile(testDir); + const failed = file.tasks[0]; + expect(failed?.status).toBe('failed'); + expect(failed?.started_at).toBe(startedAt); + expect(failed?.completed_at).toBe(completedAt); + expect(failed?.branch).toBe('takt/task-c-updated'); + }); + it('should requeue failed task to pending with retry metadata', () => { runner.addTask('Task A'); const task = runner.claimNextTasks(1)[0]!; diff --git a/src/__tests__/taskDeleteActions.test.ts b/src/__tests__/taskDeleteActions.test.ts index 912591f..0a81403 100644 --- a/src/__tests__/taskDeleteActions.test.ts +++ b/src/__tests__/taskDeleteActions.test.ts @@ -21,9 +21,14 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ }), })); +const mockDeleteBranch = vi.fn(); +vi.mock('../features/tasks/list/taskActions.js', () => ({ + deleteBranch: (...args: unknown[]) => mockDeleteBranch(...args), +})); + import { confirm } from '../shared/prompt/index.js'; import { success, error as logError } from '../shared/ui/index.js'; -import { deletePendingTask, deleteFailedTask } from '../features/tasks/list/taskDeleteActions.js'; +import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from '../features/tasks/list/taskDeleteActions.js'; import type { TaskListItem } from '../infra/task/types.js'; const mockConfirm = vi.mocked(confirm); @@ -54,6 +59,16 @@ function setupTasksFile(projectDir: string): string { completed_at: '2025-01-15T00:02:00.000Z', failure: { error: 'boom' }, }, + { + name: 'completed-task', + status: 'completed', + content: 'completed', + branch: 'takt/completed-task', + worktree_path: '/tmp/takt/completed-task', + created_at: '2025-01-15T00:00:00.000Z', + started_at: '2025-01-15T00:01:00.000Z', + completed_at: '2025-01-15T00:02:00.000Z', + }, ], }), 'utf-8'); return tasksFile; @@ -61,6 +76,7 @@ function setupTasksFile(projectDir: string): string { beforeEach(() => { vi.clearAllMocks(); + mockDeleteBranch.mockReturnValue(true); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-delete-')); }); @@ -107,6 +123,50 @@ describe('taskDeleteActions', () => { expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: failed-task'); }); + it('should cleanup branch before deleting failed task when branch exists', async () => { + const tasksFile = setupTasksFile(tmpDir); + const task: TaskListItem = { + kind: 'failed', + name: 'failed-task', + createdAt: '2025-01-15T12:34:56', + filePath: tasksFile, + content: 'failed', + branch: 'takt/failed-task', + worktreePath: '/tmp/takt/failed-task', + }; + mockConfirm.mockResolvedValue(true); + + const result = await deleteFailedTask(task); + + expect(result).toBe(true); + expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('failed-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: failed-task'); + }); + + it('should keep failed task record when branch cleanup fails', async () => { + const tasksFile = setupTasksFile(tmpDir); + const task: TaskListItem = { + kind: 'failed', + name: 'failed-task', + createdAt: '2025-01-15T12:34:56', + filePath: tasksFile, + content: 'failed', + branch: 'takt/failed-task', + worktreePath: '/tmp/takt/failed-task', + }; + mockConfirm.mockResolvedValue(true); + mockDeleteBranch.mockReturnValue(false); + + const result = await deleteFailedTask(task); + + expect(result).toBe(false); + expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).toContain('failed-task'); + }); + it('should return false when target task is missing', async () => { const tasksFile = setupTasksFile(tmpDir); const task: TaskListItem = { @@ -123,4 +183,26 @@ describe('taskDeleteActions', () => { expect(result).toBe(false); expect(mockLogError).toHaveBeenCalled(); }); + + it('should delete completed task and cleanup worktree when confirmed', async () => { + const tasksFile = setupTasksFile(tmpDir); + const task: TaskListItem = { + kind: 'completed', + name: 'completed-task', + createdAt: '2025-01-15T12:34:56', + filePath: tasksFile, + content: 'completed', + branch: 'takt/completed-task', + worktreePath: '/tmp/takt/completed-task', + }; + mockConfirm.mockResolvedValue(true); + + const result = await deleteCompletedTask(task); + + expect(result).toBe(true); + expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('completed-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted completed task: completed-task'); + }); }); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 0b05dea..2ce647e 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -180,6 +180,7 @@ describe('resolveTaskExecution', () => { isWorktree: true, autoPr: false, branch: 'takt/20260128T0504-add-auth', + worktreePath: '/project/../20260128T0504-add-auth', baseBranch: 'main', }); }); diff --git a/src/__tests__/taskInstructionActions.test.ts b/src/__tests__/taskInstructionActions.test.ts new file mode 100644 index 0000000..d880ae2 --- /dev/null +++ b/src/__tests__/taskInstructionActions.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockAddTask, + mockCompleteTask, + mockFailTask, + mockExecuteTask, + mockRunInstructMode, + mockDispatchConversationAction, + mockSelectPiece, +} = vi.hoisted(() => ({ + mockAddTask: vi.fn(() => ({ + name: 'instruction-task', + content: 'instruction', + filePath: '/project/.takt/tasks.yaml', + createdAt: '2026-02-14T00:00:00.000Z', + status: 'pending', + data: { task: 'instruction' }, + })), + mockCompleteTask: vi.fn(), + mockFailTask: vi.fn(), + mockExecuteTask: vi.fn(), + mockRunInstructMode: vi.fn(), + mockDispatchConversationAction: vi.fn(), + mockSelectPiece: vi.fn(), +})); + +vi.mock('../infra/task/index.js', () => ({ + createTempCloneForBranch: vi.fn(() => ({ path: '/tmp/clone', branch: 'takt/sample' })), + removeClone: vi.fn(), + removeCloneMeta: vi.fn(), + detectDefaultBranch: vi.fn(() => 'main'), + autoCommitAndPush: vi.fn(() => ({ success: false, message: 'no changes' })), + TaskRunner: class { + addTask(...args: unknown[]) { + return mockAddTask(...args); + } + completeTask(...args: unknown[]) { + return mockCompleteTask(...args); + } + failTask(...args: unknown[]) { + return mockFailTask(...args); + } + }, +})); + +vi.mock('../infra/config/index.js', () => ({ + loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: false })), + getPieceDescription: vi.fn(() => ({ + name: 'default', + description: 'desc', + pieceStructure: [], + movementPreviews: [], + })), +})); + +vi.mock('../features/tasks/execute/taskExecution.js', () => ({ + executeTask: (...args: unknown[]) => mockExecuteTask(...args), +})); + +vi.mock('../features/tasks/list/instructMode.js', () => ({ + runInstructMode: (...args: unknown[]) => mockRunInstructMode(...args), +})); + +vi.mock('../features/tasks/add/index.js', () => ({ + saveTaskFile: vi.fn(), +})); + +vi.mock('../features/pieceSelection/index.js', () => ({ + selectPiece: (...args: unknown[]) => mockSelectPiece(...args), +})); + +vi.mock('../features/interactive/actionDispatcher.js', () => ({ + dispatchConversationAction: (...args: unknown[]) => mockDispatchConversationAction(...args), +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +import { instructBranch } from '../features/tasks/list/taskActions.js'; + +describe('instructBranch execute flow', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSelectPiece.mockResolvedValue('default'); + mockRunInstructMode.mockResolvedValue({ type: 'execute', task: '追加して' }); + mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加して' })); + }); + + it('should record addTask and completeTask on success', async () => { + mockExecuteTask.mockResolvedValue(true); + + const result = await instructBranch('/project', { + kind: 'completed', + name: 'done-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'done', + branch: 'takt/done-task', + worktreePath: '/project/.takt/worktrees/done-task', + }); + + expect(result).toBe(true); + expect(mockAddTask).toHaveBeenCalledTimes(1); + expect(mockCompleteTask).toHaveBeenCalledTimes(1); + expect(mockFailTask).not.toHaveBeenCalled(); + }); + + it('should record addTask and failTask on failure', async () => { + mockExecuteTask.mockResolvedValue(false); + + const result = await instructBranch('/project', { + kind: 'completed', + name: 'done-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'done', + branch: 'takt/done-task', + worktreePath: '/project/.takt/worktrees/done-task', + }); + + expect(result).toBe(false); + expect(mockAddTask).toHaveBeenCalledTimes(1); + expect(mockFailTask).toHaveBeenCalledTimes(1); + expect(mockCompleteTask).not.toHaveBeenCalled(); + }); + + it('should record failTask when executeTask throws', async () => { + mockExecuteTask.mockRejectedValue(new Error('crashed')); + + await expect(instructBranch('/project', { + kind: 'completed', + name: 'done-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'done', + branch: 'takt/done-task', + worktreePath: '/project/.takt/worktrees/done-task', + })).rejects.toThrow('crashed'); + + expect(mockAddTask).toHaveBeenCalledTimes(1); + expect(mockFailTask).toHaveBeenCalledTimes(1); + expect(mockCompleteTask).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/taskStatusLabel.test.ts b/src/__tests__/taskStatusLabel.test.ts index 54ba349..7efb53f 100644 --- a/src/__tests__/taskStatusLabel.test.ts +++ b/src/__tests__/taskStatusLabel.test.ts @@ -3,7 +3,7 @@ import { formatTaskStatusLabel } from '../features/tasks/list/taskStatusLabel.js import type { TaskListItem } from '../infra/task/types.js'; describe('formatTaskStatusLabel', () => { - it("should format pending task as '[running] name'", () => { + it("should format pending task as '[pending] name'", () => { // Given: pending タスク const task: TaskListItem = { kind: 'pending', @@ -16,8 +16,8 @@ describe('formatTaskStatusLabel', () => { // When: ステータスラベルを生成する const result = formatTaskStatusLabel(task); - // Then: pending は running 表示になる - expect(result).toBe('[running] implement test'); + // Then: pending は pending 表示になる + expect(result).toBe('[pending] implement test'); }); it("should format failed task as '[failed] name'", () => { diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 75691a6..560742e 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -5,9 +5,8 @@ import { existsSync, readFileSync } from 'node:fs'; import { basename, dirname } from 'node:path'; import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig } from '../infra/config/index.js'; -import { mergeProviderOptions } from '../infra/config/loaders/pieceParser.js'; import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js'; -import type { AgentResponse, CustomAgentConfig, MovementProviderOptions } from '../core/models/index.js'; +import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js'; import { createLogger } from '../shared/utils/index.js'; import { loadTemplate } from '../shared/prompts/index.js'; import type { RunAgentOptions } from './types.js'; @@ -30,14 +29,15 @@ export class AgentRunner { agentConfig?: CustomAgentConfig, ): ProviderType { if (options?.provider) return options.provider; - if (agentConfig?.provider) return agentConfig.provider; const projectConfig = loadProjectConfig(cwd); if (projectConfig.provider) return projectConfig.provider; + if (options?.stepProvider) return options.stepProvider; + if (agentConfig?.provider) return agentConfig.provider; try { const globalConfig = loadGlobalConfig(); if (globalConfig.provider) return globalConfig.provider; - } catch { - // Ignore missing global config; fallback below + } catch (error) { + log.debug('Global config not available for provider resolution', { error }); } return 'claude'; } @@ -53,6 +53,7 @@ export class AgentRunner { agentConfig?: CustomAgentConfig, ): string | undefined { if (options?.model) return options.model; + if (options?.stepModel) return options.stepModel; if (agentConfig?.model) return agentConfig.model; try { const globalConfig = loadGlobalConfig(); @@ -60,8 +61,8 @@ export class AgentRunner { const globalProvider = globalConfig.provider ?? 'claude'; if (globalProvider === resolvedProvider) return globalConfig.model; } - } catch { - // Ignore missing global config + } catch (error) { + log.debug('Global config not available for model resolution', { error }); } return undefined; } @@ -93,24 +94,6 @@ export class AgentRunner { return `${dir}/${name}`; } - /** - * Resolve provider options with 4-layer priority: Global < Local < Step (piece+movement merged). - * Step already contains the piece+movement merge result from pieceParser. - */ - private static resolveProviderOptions( - cwd: string, - stepOptions?: MovementProviderOptions, - ): MovementProviderOptions | undefined { - let globalOptions: MovementProviderOptions | undefined; - try { - globalOptions = loadGlobalConfig().providerOptions; - } catch { /* ignore */ } - - const localOptions = loadProjectConfig(cwd).provider_options; - - return mergeProviderOptions(globalOptions, localOptions, stepOptions); - } - /** Build ProviderCallOptions from RunAgentOptions */ private static buildCallOptions( resolvedProvider: ProviderType, @@ -126,7 +109,7 @@ export class AgentRunner { maxTurns: options.maxTurns, model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig), permissionMode: options.permissionMode, - providerOptions: AgentRunner.resolveProviderOptions(options.cwd, options.providerOptions), + providerOptions: options.providerOptions, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, diff --git a/src/agents/types.ts b/src/agents/types.ts index d2b6e32..497fd08 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -14,6 +14,8 @@ export interface RunAgentOptions { sessionId?: string; model?: string; provider?: 'claude' | 'codex' | 'opencode' | 'mock'; + stepModel?: string; + stepProvider?: 'claude' | 'codex' | 'opencode' | 'mock'; personaPath?: string; allowedTools?: string[]; mcpServers?: Record; diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index 535cacf..a847a22 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -35,8 +35,10 @@ export class OptionsBuilder { cwd: this.getCwd(), abortSignal: this.engineOptions.abortSignal, personaPath: step.personaPath, - provider: resolved.provider, - model: resolved.model, + provider: this.engineOptions.provider, + model: this.engineOptions.model, + stepProvider: resolved.provider, + stepModel: resolved.model, permissionMode: step.permissionMode, providerOptions: step.providerOptions, language: this.getLanguage(), diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 5e4d1ed..ebf2cc2 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -16,6 +16,7 @@ export interface ResolvedTaskExecution { taskPrompt?: string; reportDirName?: string; branch?: string; + worktreePath?: string; baseBranch?: string; startMovement?: string; retryNote?: string; @@ -82,6 +83,7 @@ export async function resolveTaskExecution( let reportDirName: string | undefined; let taskPrompt: string | undefined; let branch: string | undefined; + let worktreePath: string | undefined; let baseBranch: string | undefined; if (task.taskDir) { const taskSlug = getTaskSlugFromTaskDir(task.taskDir); @@ -114,6 +116,7 @@ export async function resolveTaskExecution( throwIfAborted(abortSignal); execCwd = result.path; branch = result.branch; + worktreePath = result.path; isWorktree = true; } @@ -141,6 +144,7 @@ export async function resolveTaskExecution( ...(taskPrompt ? { taskPrompt } : {}), ...(reportDirName ? { reportDirName } : {}), ...(branch ? { branch } : {}), + ...(worktreePath ? { worktreePath } : {}), ...(baseBranch ? { baseBranch } : {}), ...(startMovement ? { startMovement } : {}), ...(retryNote ? { retryNote } : {}), diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 32aedb0..78e5fe7 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -11,7 +11,7 @@ import { isPiecePath, } from '../../../infra/config/index.js'; import { confirm } from '../../../shared/prompt/index.js'; -import { createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; +import { createSharedClone, summarizeTaskName, getCurrentBranch, TaskRunner } from '../../../infra/task/index.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { info, error, withProgress } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.js'; @@ -19,6 +19,7 @@ import { executeTask } from './taskExecution.js'; import { resolveAutoPr, postExecutionFlow } from './postExecution.js'; import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js'; import { selectPiece } from '../../pieceSelection/index.js'; +import { buildBooleanTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js'; export type { WorktreeConfirmationResult, SelectAndExecuteOptions }; @@ -104,15 +105,48 @@ export async function selectAndExecuteTask( } log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr }); - const taskSuccess = await executeTask({ - task, - cwd: execCwd, - pieceIdentifier, - projectCwd: cwd, - agentOverrides, - interactiveUserInput: options?.interactiveUserInput === true, - interactiveMetadata: options?.interactiveMetadata, + const taskRunner = new TaskRunner(cwd); + const taskRecord = taskRunner.addTask(task, { + piece: pieceIdentifier, + ...(isWorktree ? { worktree: true } : {}), + ...(branch ? { branch } : {}), + ...(isWorktree ? { worktree_path: execCwd } : {}), + auto_pr: shouldCreatePr, + }); + const startedAt = new Date().toISOString(); + + let taskSuccess: boolean; + try { + taskSuccess = await executeTask({ + task, + cwd: execCwd, + pieceIdentifier, + projectCwd: cwd, + agentOverrides, + interactiveUserInput: options?.interactiveUserInput === true, + interactiveMetadata: options?.interactiveMetadata, + }); + } catch (err) { + const completedAt = new Date().toISOString(); + persistTaskError(taskRunner, taskRecord, startedAt, completedAt, err, { + responsePrefix: 'Task failed: ', + }); + throw err; + } + + const completedAt = new Date().toISOString(); + + const taskResult = buildBooleanTaskResult({ + task: taskRecord, + taskSuccess, + successResponse: 'Task completed successfully', + failureResponse: 'Task failed', + startedAt, + completedAt, + branch, + ...(isWorktree ? { worktreePath: execCwd } : {}), }); + persistTaskResult(taskRunner, taskResult); if (taskSuccess && isWorktree) { await postExecutionFlow({ diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index f0b4751..2e65882 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -8,7 +8,6 @@ import { header, info, error, - success, status, blankLine, } from '../../../shared/ui/index.js'; @@ -21,6 +20,7 @@ import { fetchIssue, checkGhCli } from '../../../infra/github/index.js'; import { runWithWorkerPool } from './parallelExecution.js'; import { resolveTaskExecution } from './resolveTask.js'; import { postExecutionFlow } from './postExecution.js'; +import { buildTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js'; export type { TaskExecutionOptions, ExecuteTaskOptions }; @@ -138,6 +138,7 @@ export async function executeAndCompleteTask( taskPrompt, reportDirName, branch, + worktreePath, baseBranch, startMovement, retryNote, @@ -160,10 +161,6 @@ export async function executeAndCompleteTask( taskColorIndex: parallelOptions?.taskColorIndex, }); - if (!taskRunResult.success && !taskRunResult.reason) { - throw new Error('Task failed without reason'); - } - const taskSuccess = taskRunResult.success; const completedAt = new Date().toISOString(); @@ -181,39 +178,20 @@ export async function executeAndCompleteTask( }); } - const taskResult = { + const taskResult = buildTaskResult({ task, - success: taskSuccess, - response: taskSuccess ? 'Task completed successfully' : taskRunResult.reason!, - executionLog: taskRunResult.lastMessage ? [taskRunResult.lastMessage] : [], - failureMovement: taskRunResult.lastMovement, - failureLastMessage: taskRunResult.lastMessage, + runResult: taskRunResult, startedAt, completedAt, - }; - - if (taskSuccess) { - taskRunner.completeTask(taskResult); - success(`Task "${task.name}" completed`); - } else { - taskRunner.failTask(taskResult); - error(`Task "${task.name}" failed`); - } + branch, + worktreePath, + }); + persistTaskResult(taskRunner, taskResult); return taskSuccess; } catch (err) { const completedAt = new Date().toISOString(); - - taskRunner.failTask({ - task, - success: false, - response: getErrorMessage(err), - executionLog: [], - startedAt, - completedAt, - }); - - error(`Task "${task.name}" error: ${getErrorMessage(err)}`); + persistTaskError(taskRunner, task, startedAt, completedAt, err); return false; } finally { if (externalAbortSignal) { diff --git a/src/features/tasks/execute/taskResultHandler.ts b/src/features/tasks/execute/taskResultHandler.ts new file mode 100644 index 0000000..30fcec8 --- /dev/null +++ b/src/features/tasks/execute/taskResultHandler.ts @@ -0,0 +1,125 @@ +import { type TaskInfo, type TaskResult, TaskRunner } from '../../../infra/task/index.js'; +import { error, success } from '../../../shared/ui/index.js'; +import { getErrorMessage } from '../../../shared/utils/index.js'; +import type { PieceExecutionResult } from './types.js'; + +interface BuildTaskResultParams { + task: TaskInfo; + runResult: PieceExecutionResult; + startedAt: string; + completedAt: string; + branch?: string; + worktreePath?: string; +} + +interface BuildBooleanTaskResultParams { + task: TaskInfo; + taskSuccess: boolean; + startedAt: string; + completedAt: string; + successResponse: string; + failureResponse: string; + branch?: string; + worktreePath?: string; +} + +interface PersistTaskResultOptions { + emitStatusLog?: boolean; +} + +interface PersistTaskErrorOptions { + emitStatusLog?: boolean; + responsePrefix?: string; +} + +export function buildTaskResult(params: BuildTaskResultParams): TaskResult { + const { task, runResult, startedAt, completedAt, branch, worktreePath } = params; + const taskSuccess = runResult.success; + + if (!taskSuccess && !runResult.reason) { + throw new Error('Task failed without reason'); + } + + return { + task, + success: taskSuccess, + response: taskSuccess ? 'Task completed successfully' : runResult.reason!, + executionLog: runResult.lastMessage ? [runResult.lastMessage] : [], + failureMovement: runResult.lastMovement, + failureLastMessage: runResult.lastMessage, + startedAt, + completedAt, + ...(branch ? { branch } : {}), + ...(worktreePath ? { worktreePath } : {}), + }; +} + +export function buildBooleanTaskResult(params: BuildBooleanTaskResultParams): TaskResult { + const { + task, + taskSuccess, + startedAt, + completedAt, + successResponse, + failureResponse, + branch, + worktreePath, + } = params; + + return { + task, + success: taskSuccess, + response: taskSuccess ? successResponse : failureResponse, + executionLog: [], + startedAt, + completedAt, + ...(branch ? { branch } : {}), + ...(worktreePath ? { worktreePath } : {}), + }; +} + +export function persistTaskResult( + taskRunner: TaskRunner, + taskResult: TaskResult, + options?: PersistTaskResultOptions, +): void { + const emitStatusLog = options?.emitStatusLog !== false; + if (taskResult.success) { + taskRunner.completeTask(taskResult); + if (emitStatusLog) { + success(`Task "${taskResult.task.name}" completed`); + } + return; + } + + taskRunner.failTask(taskResult); + if (emitStatusLog) { + error(`Task "${taskResult.task.name}" failed`); + } +} + +export function persistTaskError( + taskRunner: TaskRunner, + task: TaskInfo, + startedAt: string, + completedAt: string, + err: unknown, + options?: PersistTaskErrorOptions, +): void { + const emitStatusLog = options?.emitStatusLog !== false; + const responsePrefix = options?.responsePrefix ?? ''; + taskRunner.failTask({ + task, + success: false, + response: `${responsePrefix}${getErrorMessage(err)}`, + executionLog: [], + startedAt, + completedAt, + ...(task.data?.branch ? { branch: task.data.branch } : {}), + ...(task.worktreePath ? { worktreePath: task.worktreePath } : {}), + }); + + if (emitStatusLog) { + error(`Task "${task.name}" error: ${getErrorMessage(err)}`); + } +} diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 287962b..6016630 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -9,25 +9,21 @@ */ import { - listTaktBranches, - buildListItems, - detectDefaultBranch, TaskRunner, } from '../../../infra/task/index.js'; import type { TaskListItem } from '../../../infra/task/index.js'; -import { selectOption, confirm } from '../../../shared/prompt/index.js'; +import { selectOption } from '../../../shared/prompt/index.js'; import { info, header, blankLine } from '../../../shared/ui/index.js'; import type { TaskExecutionOptions } from '../execute/types.js'; import { type ListAction, showFullDiff, - showDiffAndPromptAction, + showDiffAndPromptActionForTask, tryMergeBranch, mergeBranch, - deleteBranch, instructBranch, } from './taskActions.js'; -import { deletePendingTask, deleteFailedTask } from './taskDeleteActions.js'; +import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from './taskDeleteActions.js'; import { retryFailedTask } from './taskRetryActions.js'; import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js'; import { formatTaskStatusLabel } from './taskStatusLabel.js'; @@ -55,6 +51,7 @@ type PendingTaskAction = 'delete'; /** Task action type for failed task action selection menu */ type FailedTaskAction = 'retry' | 'delete'; +type CompletedTaskAction = ListAction; /** * Show pending task details and prompt for an action. @@ -80,7 +77,7 @@ async function showPendingTaskAndPromptAction(task: TaskListItem): Promise { header(formatTaskStatusLabel(task)); - info(` Failed at: ${task.createdAt}`); + info(` Created: ${task.createdAt}`); if (task.content) { info(` ${task.content}`); } @@ -95,6 +92,17 @@ async function showFailedTaskAndPromptAction(task: TaskListItem): Promise { + header(formatTaskStatusLabel(task)); + info(` Created: ${task.createdAt}`); + if (task.content) { + info(` ${task.content}`); + } + blankLine(); + + return await showDiffAndPromptActionForTask(cwd, task); +} + /** * Main entry point: list branch-based tasks interactively. */ @@ -108,44 +116,22 @@ export async function listTasks( return; } - const defaultBranch = detectDefaultBranch(cwd); const runner = new TaskRunner(cwd); // Interactive loop while (true) { - const branches = listTaktBranches(cwd); - const items = buildListItems(cwd, branches, defaultBranch); - const pendingTasks = runner.listPendingTaskItems(); - const failedTasks = runner.listFailedTasks(); + const tasks = runner.listAllTaskItems(); - if (items.length === 0 && pendingTasks.length === 0 && failedTasks.length === 0) { + if (tasks.length === 0) { info('No tasks to list.'); return; } - const menuOptions = [ - ...items.map((item, idx) => { - const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`; - const description = item.originalInstruction - ? `${filesSummary} | ${item.originalInstruction}` - : filesSummary; - return { - label: item.info.branch, - value: `branch:${idx}`, - description, - }; - }), - ...pendingTasks.map((task, idx) => ({ - label: formatTaskStatusLabel(task), - value: `pending:${idx}`, - description: task.content, - })), - ...failedTasks.map((task, idx) => ({ - label: formatTaskStatusLabel(task), - value: `failed:${idx}`, - description: task.content, - })), - ]; + const menuOptions = tasks.map((task, idx) => ({ + label: formatTaskStatusLabel(task), + value: `${task.kind}:${idx}`, + description: `${task.content} | ${task.createdAt}`, + })); const selected = await selectOption( 'List Tasks', @@ -162,52 +148,55 @@ export async function listTasks( const idx = parseInt(selected.slice(colonIdx + 1), 10); if (Number.isNaN(idx)) continue; - if (type === 'branch') { - const item = items[idx]; - if (!item) continue; - - // Action loop: re-show menu after viewing diff - let action: ListAction | null; - do { - action = await showDiffAndPromptAction(cwd, defaultBranch, item); - - if (action === 'diff') { - showFullDiff(cwd, defaultBranch, item.info.branch); - } - } while (action === 'diff'); - - if (action === null) continue; + if (type === 'pending') { + const task = tasks[idx]; + if (!task) continue; + const taskAction = await showPendingTaskAndPromptAction(task); + if (taskAction === 'delete') { + await deletePendingTask(task); + } + } else if (type === 'running') { + const task = tasks[idx]; + if (!task) continue; + header(formatTaskStatusLabel(task)); + info(` Created: ${task.createdAt}`); + if (task.content) { + info(` ${task.content}`); + } + blankLine(); + info('Running task is read-only.'); + blankLine(); + } else if (type === 'completed') { + const task = tasks[idx]; + if (!task) continue; + if (!task.branch) { + info(`Branch is missing for completed task: ${task.name}`); + continue; + } + const taskAction = await showCompletedTaskAndPromptAction(cwd, task); + if (taskAction === null) continue; - switch (action) { + switch (taskAction) { + case 'diff': + showFullDiff(cwd, task.branch); + break; case 'instruct': - await instructBranch(cwd, item, options); + await instructBranch(cwd, task, options); break; case 'try': - tryMergeBranch(cwd, item); + tryMergeBranch(cwd, task); break; case 'merge': - mergeBranch(cwd, item); - break; - case 'delete': { - const confirmed = await confirm( - `Delete ${item.info.branch}? This will discard all changes.`, - false, - ); - if (confirmed) { - deleteBranch(cwd, item); + if (mergeBranch(cwd, task)) { + runner.deleteCompletedTask(task.name); } break; - } - } - } else if (type === 'pending') { - const task = pendingTasks[idx]; - if (!task) continue; - const taskAction = await showPendingTaskAndPromptAction(task); - if (taskAction === 'delete') { - await deletePendingTask(task); + case 'delete': + await deleteCompletedTask(task); + break; } } else if (type === 'failed') { - const task = failedTasks[idx]; + const task = tasks[idx]; if (!task) continue; const taskAction = await showFailedTaskAndPromptAction(task); if (taskAction === 'retry') { diff --git a/src/features/tasks/list/listNonInteractive.ts b/src/features/tasks/list/listNonInteractive.ts index 2eb7609..3c11cad 100644 --- a/src/features/tasks/list/listNonInteractive.ts +++ b/src/features/tasks/list/listNonInteractive.ts @@ -6,11 +6,9 @@ */ import { execFileSync } from 'node:child_process'; -import type { TaskListItem, BranchListItem } from '../../../infra/task/index.js'; +import type { TaskListItem } from '../../../infra/task/index.js'; import { detectDefaultBranch, - listTaktBranches, - buildListItems, TaskRunner, } from '../../../infra/task/index.js'; import { info } from '../../../shared/ui/index.js'; @@ -34,34 +32,18 @@ function isValidAction(action: string): action is ListAction { return action === 'diff' || action === 'try' || action === 'merge' || action === 'delete'; } -function printNonInteractiveList( - items: BranchListItem[], - pendingTasks: TaskListItem[], - failedTasks: TaskListItem[], - format?: string, -): void { +function printNonInteractiveList(tasks: TaskListItem[], format?: string): void { const outputFormat = format ?? 'text'; if (outputFormat === 'json') { // stdout に直接出力(JSON パース用途のため UI ヘルパーを経由しない) console.log(JSON.stringify({ - branches: items, - pendingTasks, - failedTasks, + tasks, }, null, 2)); return; } - for (const item of items) { - const instruction = item.originalInstruction ? ` - ${item.originalInstruction}` : ''; - info(`${item.info.branch} (${item.filesChanged} files)${instruction}`); - } - - for (const task of pendingTasks) { - info(`${formatTaskStatusLabel(task)} - ${task.content}`); - } - - for (const task of failedTasks) { - info(`${formatTaskStatusLabel(task)} - ${task.content}`); + for (const task of tasks) { + info(`${formatTaskStatusLabel(task)} - ${task.content} (${task.createdAt})`); } } @@ -85,24 +67,20 @@ export async function listTasksNonInteractive( nonInteractive: ListNonInteractiveOptions, ): Promise { const defaultBranch = detectDefaultBranch(cwd); - const branches = listTaktBranches(cwd); const runner = new TaskRunner(cwd); - const pendingTasks = runner.listPendingTaskItems(); - const failedTasks = runner.listFailedTasks(); + const tasks = runner.listAllTaskItems(); - const items = buildListItems(cwd, branches, defaultBranch); - - if (items.length === 0 && pendingTasks.length === 0 && failedTasks.length === 0) { + if (tasks.length === 0) { info('No tasks to list.'); return; } if (!nonInteractive.action) { - printNonInteractiveList(items, pendingTasks, failedTasks, nonInteractive.format); + printNonInteractiveList(tasks, nonInteractive.format); return; } - // Branch-targeted action (--branch) + // Completed-task branch-targeted action (--branch) if (!nonInteractive.branch) { info('Missing --branch for non-interactive action.'); process.exit(1); @@ -113,28 +91,32 @@ export async function listTasksNonInteractive( process.exit(1); } - const item = items.find((entry) => entry.info.branch === nonInteractive.branch); - if (!item) { + const task = tasks.find((entry) => entry.kind === 'completed' && entry.branch === nonInteractive.branch); + if (!task) { info(`Branch not found: ${nonInteractive.branch}`); process.exit(1); } switch (nonInteractive.action) { case 'diff': - showDiffStat(cwd, defaultBranch, item.info.branch); + showDiffStat(cwd, defaultBranch, nonInteractive.branch); return; case 'try': - tryMergeBranch(cwd, item); + tryMergeBranch(cwd, task); return; case 'merge': - mergeBranch(cwd, item); + if (mergeBranch(cwd, task)) { + runner.deleteCompletedTask(task.name); + } return; case 'delete': if (!nonInteractive.yes) { info('Delete requires --yes in non-interactive mode.'); process.exit(1); } - deleteBranch(cwd, item); + if (deleteBranch(cwd, task)) { + runner.deleteCompletedTask(task.name); + } return; } } diff --git a/src/features/tasks/list/taskActionTarget.ts b/src/features/tasks/list/taskActionTarget.ts new file mode 100644 index 0000000..2e2f0c4 --- /dev/null +++ b/src/features/tasks/list/taskActionTarget.ts @@ -0,0 +1,29 @@ +import type { BranchListItem, TaskListItem } from '../../../infra/task/index.js'; + +export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; + +export type BranchActionTarget = TaskListItem | Pick; + +export function resolveTargetBranch(target: BranchActionTarget): string { + if ('kind' in target) { + if (!target.branch) { + throw new Error(`Branch is required for task action: ${target.name}`); + } + return target.branch; + } + return target.info.branch; +} + +export function resolveTargetWorktreePath(target: BranchActionTarget): string | undefined { + if ('kind' in target) { + return target.worktreePath; + } + return target.info.worktreePath; +} + +export function resolveTargetInstruction(target: BranchActionTarget): string { + if ('kind' in target) { + return target.content; + } + return target.originalInstruction; +} diff --git a/src/features/tasks/list/taskActions.ts b/src/features/tasks/list/taskActions.ts index 223d56d..ef94343 100644 --- a/src/features/tasks/list/taskActions.ts +++ b/src/features/tasks/list/taskActions.ts @@ -1,395 +1,19 @@ /** - * Individual actions for branch-based tasks. - * - * Provides merge, delete, try-merge, instruct, and diff operations - * for branches listed by the listTasks command. + * Individual actions for task-centric list items. */ -import { execFileSync, spawnSync } from 'node:child_process'; -import { rmSync, existsSync, unlinkSync } from 'node:fs'; -import { join } from 'node:path'; +export type { ListAction } from './taskActionTarget.js'; -import chalk from 'chalk'; -import { - createTempCloneForBranch, - removeClone, - removeCloneMeta, - cleanupOrphanedClone, - detectDefaultBranch, - autoCommitAndPush, - type BranchListItem, -} from '../../../infra/task/index.js'; -import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js'; -import { selectOption } from '../../../shared/prompt/index.js'; -import { info, success, error as logError, warn, header, blankLine } from '../../../shared/ui/index.js'; -import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; -import { executeTask } from '../execute/taskExecution.js'; -import type { TaskExecutionOptions } from '../execute/types.js'; -import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js'; -import { runInstructMode } from './instructMode.js'; -import { saveTaskFile } from '../add/index.js'; -import { selectPiece } from '../../pieceSelection/index.js'; -import { dispatchConversationAction } from '../../interactive/actionDispatcher.js'; -import type { PieceContext } from '../../interactive/interactive.js'; +export { + showFullDiff, + showDiffAndPromptActionForTask, +} from './taskDiffActions.js'; -const log = createLogger('list-tasks'); +export { + isBranchMerged, + tryMergeBranch, + mergeBranch, + deleteBranch, +} from './taskBranchLifecycleActions.js'; -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 { - 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 false; - } - - return result.status === 0; -} - -/** - * Show full diff in an interactive pager (less). - * Falls back to direct output if pager is unavailable. - */ -export function showFullDiff( - cwd: string, - defaultBranch: string, - branch: string, -): void { - try { - const result = spawnSync( - 'git', ['diff', '--color=always', `${defaultBranch}...${branch}`], - { - cwd, - stdio: 'inherit', - env: { ...process.env, GIT_PAGER: 'less -R' }, - }, - ); - if (result.status !== 0) { - warn('Could not display diff'); - } - } catch (err) { - warn('Could not display diff'); - log.error('Failed to display full diff', { - branch, - defaultBranch, - error: getErrorMessage(err), - }); - } -} - -/** - * Show diff stat for a branch and prompt for an action. - */ -export async function showDiffAndPromptAction( - cwd: string, - defaultBranch: string, - item: BranchListItem, -): Promise { - header(item.info.branch); - if (item.originalInstruction) { - info(chalk.dim(` ${item.originalInstruction}`)); - } - blankLine(); - - try { - const stat = execFileSync( - 'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`], - { cwd, encoding: 'utf-8', stdio: 'pipe' }, - ); - info(stat); - } 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( - `Action for ${item.info.branch}:`, - [ - { label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, - { label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' }, - { label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' }, - { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, - { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, - ], - ); - - return action; -} - -/** - * Try-merge (squash): stage changes from branch without committing. - */ -export function tryMergeBranch(projectDir: string, item: BranchListItem): boolean { - const { branch } = item.info; - - try { - execFileSync('git', ['merge', '--squash', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - - success(`Squash-merged ${branch} (changes staged, not committed)`); - info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.'); - log.info('Try-merge (squash) completed', { branch }); - return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Squash merge failed: ${msg}`); - logError('You may need to resolve conflicts manually.'); - log.error('Try-merge (squash) failed', { branch, error: msg }); - return false; - } -} - -/** - * Merge & cleanup: if already merged, skip merge and just delete the branch. - */ -export function mergeBranch(projectDir: string, item: BranchListItem): boolean { - const { branch } = item.info; - const alreadyMerged = isBranchMerged(projectDir, branch); - - try { - if (alreadyMerged) { - info(`${branch} is already merged, skipping merge.`); - log.info('Branch already merged, cleanup only', { branch }); - } else { - execFileSync('git', ['merge', '--no-edit', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - env: { - ...process.env, - GIT_MERGE_AUTOEDIT: 'no', - }, - }); - } - - try { - execFileSync('git', ['branch', '-d', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - } 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); - - success(`Merged & cleaned up ${branch}`); - log.info('Branch merged & cleaned up', { branch, alreadyMerged }); - return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Merge failed: ${msg}`); - logError('You may need to resolve conflicts manually.'); - log.error('Merge & cleanup failed', { branch, error: msg }); - return false; - } -} - -/** - * Delete a branch (discard changes). - * For worktree branches, removes the worktree directory and session file. - */ -export function deleteBranch(projectDir: string, item: BranchListItem): boolean { - const { branch, worktreePath } = item.info; - - try { - // If this is a worktree branch, remove the worktree directory and session file - if (worktreePath) { - // Remove worktree directory if it exists - if (existsSync(worktreePath)) { - rmSync(worktreePath, { recursive: true, force: true }); - log.info('Removed worktree directory', { worktreePath }); - } - - // Remove worktree-session file - const encodedPath = encodeWorktreePath(worktreePath); - const sessionFile = join(projectDir, '.takt', 'worktree-sessions', `${encodedPath}.json`); - if (existsSync(sessionFile)) { - unlinkSync(sessionFile); - log.info('Removed worktree-session file', { sessionFile }); - } - - success(`Deleted worktree ${branch}`); - log.info('Worktree branch deleted', { branch, worktreePath }); - return true; - } - - // For regular branches, use git branch -D - execFileSync('git', ['branch', '-D', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - - cleanupOrphanedClone(projectDir, branch); - - success(`Deleted ${branch}`); - log.info('Branch deleted', { branch }); - return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Delete failed: ${msg}`); - log.error('Delete failed', { branch, error: msg }); - return false; - } -} - -/** - * Get branch context: diff stat and commit log from main branch. - */ -function getBranchContext(projectDir: string, branch: string): string { - const defaultBranch = detectDefaultBranch(projectDir); - const lines: string[] = []; - - try { - const diffStat = execFileSync( - 'git', ['diff', '--stat', `${defaultBranch}...${branch}`], - { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, - ).trim(); - if (diffStat) { - lines.push('## 現在の変更内容(mainからの差分)'); - lines.push('```'); - lines.push(diffStat); - lines.push('```'); - } - } catch (err) { - log.debug('Failed to collect branch diff stat for instruction context', { - branch, - defaultBranch, - error: getErrorMessage(err), - }); - } - - try { - const commitLog = execFileSync( - 'git', ['log', '--oneline', `${defaultBranch}..${branch}`], - { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, - ).trim(); - if (commitLog) { - lines.push(''); - lines.push('## コミット履歴'); - lines.push('```'); - lines.push(commitLog); - lines.push('```'); - } - } 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' : ''; -} - -/** - * Instruct branch: create a temp clone, give additional instructions via - * interactive conversation, then auto-commit+push+PR or save as task file. - */ -export async function instructBranch( - projectDir: string, - item: BranchListItem, - options?: TaskExecutionOptions, -): Promise { - const { branch } = item.info; - - const selectedPiece = await selectPiece(projectDir); - if (!selectedPiece) { - info('Cancelled'); - return false; - } - - const globalConfig = loadGlobalConfig(); - const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); - const pieceContext: PieceContext = { - name: pieceDesc.name, - description: pieceDesc.description, - pieceStructure: pieceDesc.pieceStructure, - movementPreviews: pieceDesc.movementPreviews, - }; - - const branchContext = getBranchContext(projectDir, branch); - const result = await runInstructMode(projectDir, branchContext, branch, pieceContext); - - return dispatchConversationAction(result, { - cancel: () => { - info('Cancelled'); - return false; - }, - save_task: async ({ task }) => { - const created = await saveTaskFile(projectDir, task, { - piece: selectedPiece, - worktree: true, - branch, - autoPr: false, - }); - success(`Task saved: ${created.taskName}`); - info(` Branch: ${branch}`); - log.info('Task saved from instruct mode', { branch, piece: selectedPiece }); - return true; - }, - execute: async ({ task }) => { - log.info('Instructing branch via temp clone', { branch, piece: selectedPiece }); - info(`Running instruction on ${branch}...`); - - const clone = createTempCloneForBranch(projectDir, branch); - - try { - const fullInstruction = branchContext - ? `${branchContext}## 追加指示\n${task}` - : task; - - const taskSuccess = await executeTask({ - task: fullInstruction, - cwd: clone.path, - pieceIdentifier: selectedPiece, - projectCwd: projectDir, - agentOverrides: options, - }); - - if (taskSuccess) { - const commitResult = autoCommitAndPush(clone.path, task, projectDir); - if (commitResult.success && commitResult.commitHash) { - success(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - logError(`Auto-commit failed: ${commitResult.message}`); - } - success(`Instruction completed on ${branch}`); - log.info('Instruction completed', { branch }); - } else { - logError(`Instruction failed on ${branch}`); - log.error('Instruction failed', { branch }); - } - - return taskSuccess; - } finally { - removeClone(clone.path); - removeCloneMeta(projectDir, branch); - } - }, - }); -} +export { instructBranch } from './taskInstructionActions.js'; diff --git a/src/features/tasks/list/taskBranchLifecycleActions.ts b/src/features/tasks/list/taskBranchLifecycleActions.ts new file mode 100644 index 0000000..2d8fabe --- /dev/null +++ b/src/features/tasks/list/taskBranchLifecycleActions.ts @@ -0,0 +1,141 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { rmSync, existsSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { cleanupOrphanedClone } from '../../../infra/task/index.js'; +import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js'; +import { info, success, error as logError, warn } from '../../../shared/ui/index.js'; +import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { type BranchActionTarget, resolveTargetBranch, resolveTargetWorktreePath } from './taskActionTarget.js'; + +const log = createLogger('list-tasks'); + +export function isBranchMerged(projectDir: string, branch: string): boolean { + 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 false; + } + + return result.status === 0; +} + +export function tryMergeBranch(projectDir: string, target: BranchActionTarget): boolean { + const branch = resolveTargetBranch(target); + + try { + execFileSync('git', ['merge', '--squash', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + + success(`Squash-merged ${branch} (changes staged, not committed)`); + info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.'); + log.info('Try-merge (squash) completed', { branch }); + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Squash merge failed: ${msg}`); + logError('You may need to resolve conflicts manually.'); + log.error('Try-merge (squash) failed', { branch, error: msg }); + return false; + } +} + +export function mergeBranch(projectDir: string, target: BranchActionTarget): boolean { + const branch = resolveTargetBranch(target); + const alreadyMerged = isBranchMerged(projectDir, branch); + + try { + if (alreadyMerged) { + info(`${branch} is already merged, skipping merge.`); + log.info('Branch already merged, cleanup only', { branch }); + } else { + execFileSync('git', ['merge', '--no-edit', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + env: { + ...process.env, + GIT_MERGE_AUTOEDIT: 'no', + }, + }); + } + + try { + execFileSync('git', ['branch', '-d', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + } 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); + + success(`Merged & cleaned up ${branch}`); + log.info('Branch merged & cleaned up', { branch, alreadyMerged }); + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Merge failed: ${msg}`); + logError('You may need to resolve conflicts manually.'); + log.error('Merge & cleanup failed', { branch, error: msg }); + return false; + } +} + +export function deleteBranch(projectDir: string, target: BranchActionTarget): boolean { + const branch = resolveTargetBranch(target); + const worktreePath = resolveTargetWorktreePath(target); + + try { + if (worktreePath) { + if (existsSync(worktreePath)) { + rmSync(worktreePath, { recursive: true, force: true }); + log.info('Removed worktree directory', { worktreePath }); + } + + const encodedPath = encodeWorktreePath(worktreePath); + const sessionFile = join(projectDir, '.takt', 'worktree-sessions', `${encodedPath}.json`); + if (existsSync(sessionFile)) { + unlinkSync(sessionFile); + log.info('Removed worktree-session file', { sessionFile }); + } + + success(`Deleted worktree ${branch}`); + log.info('Worktree branch deleted', { branch, worktreePath }); + return true; + } + + execFileSync('git', ['branch', '-D', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + + cleanupOrphanedClone(projectDir, branch); + + success(`Deleted ${branch}`); + log.info('Branch deleted', { branch }); + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Delete failed: ${msg}`); + log.error('Delete failed', { branch, error: msg }); + return false; + } +} diff --git a/src/features/tasks/list/taskDeleteActions.ts b/src/features/tasks/list/taskDeleteActions.ts index 89eee61..c3219fb 100644 --- a/src/features/tasks/list/taskDeleteActions.ts +++ b/src/features/tasks/list/taskDeleteActions.ts @@ -11,6 +11,7 @@ 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'; +import { deleteBranch } from './taskActions.js'; const log = createLogger('list-tasks'); @@ -18,6 +19,14 @@ function getProjectDir(task: TaskListItem): string { return dirname(dirname(task.filePath)); } +function cleanupBranchIfPresent(task: TaskListItem, projectDir: string): boolean { + if (!task.branch) { + return true; + } + + return deleteBranch(projectDir, task); +} + /** * Delete a pending task file. * Prompts user for confirmation first. @@ -46,8 +55,13 @@ export async function deletePendingTask(task: TaskListItem): Promise { export async function deleteFailedTask(task: TaskListItem): Promise { const confirmed = await confirm(`Delete failed task "${task.name}"?`, false); if (!confirmed) return false; + const projectDir = getProjectDir(task); try { - const runner = new TaskRunner(getProjectDir(task)); + if (!cleanupBranchIfPresent(task, projectDir)) { + return false; + } + + const runner = new TaskRunner(projectDir); runner.deleteFailedTask(task.name); } catch (err) { const msg = getErrorMessage(err); @@ -59,3 +73,27 @@ export async function deleteFailedTask(task: TaskListItem): Promise { log.info('Deleted failed task', { name: task.name, filePath: task.filePath }); return true; } + +export async function deleteCompletedTask(task: TaskListItem): Promise { + const confirmed = await confirm(`Delete completed task "${task.name}"?`, false); + if (!confirmed) return false; + + const projectDir = getProjectDir(task); + try { + if (!cleanupBranchIfPresent(task, projectDir)) { + return false; + } + + const runner = new TaskRunner(projectDir); + runner.deleteCompletedTask(task.name); + } catch (err) { + const msg = getErrorMessage(err); + logError(`Failed to delete completed task "${task.name}": ${msg}`); + log.error('Failed to delete completed task', { name: task.name, filePath: task.filePath, error: msg }); + return false; + } + + success(`Deleted completed task: ${task.name}`); + log.info('Deleted completed task', { name: task.name, filePath: task.filePath }); + return true; +} diff --git a/src/features/tasks/list/taskDiffActions.ts b/src/features/tasks/list/taskDiffActions.ts new file mode 100644 index 0000000..787fa8b --- /dev/null +++ b/src/features/tasks/list/taskDiffActions.ts @@ -0,0 +1,74 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import chalk from 'chalk'; +import { detectDefaultBranch } from '../../../infra/task/index.js'; +import { selectOption } from '../../../shared/prompt/index.js'; +import { info, warn, header, blankLine } from '../../../shared/ui/index.js'; +import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { type BranchActionTarget, type ListAction, resolveTargetBranch, resolveTargetInstruction } from './taskActionTarget.js'; + +const log = createLogger('list-tasks'); + +export function showFullDiff(cwd: string, branch: string): void { + const defaultBranch = detectDefaultBranch(cwd); + try { + const result = spawnSync( + 'git', ['diff', '--color=always', `${defaultBranch}...${branch}`], + { + cwd, + stdio: 'inherit', + env: { ...process.env, GIT_PAGER: 'less -R' }, + }, + ); + if (result.status !== 0) { + warn('Could not display diff'); + } + } catch (err) { + warn('Could not display diff'); + log.error('Failed to display full diff', { + branch, + defaultBranch, + error: getErrorMessage(err), + }); + } +} + +export async function showDiffAndPromptActionForTask( + cwd: string, + target: BranchActionTarget, +): Promise { + const branch = resolveTargetBranch(target); + const instruction = resolveTargetInstruction(target); + const defaultBranch = detectDefaultBranch(cwd); + + header(branch); + if (instruction) { + info(chalk.dim(` ${instruction}`)); + } + blankLine(); + + try { + const stat = execFileSync( + 'git', ['diff', '--stat', `${defaultBranch}...${branch}`], + { cwd, encoding: 'utf-8', stdio: 'pipe' }, + ); + info(stat); + } catch (err) { + warn('Could not generate diff stat'); + log.error('Failed to generate diff stat', { + branch, + defaultBranch, + error: getErrorMessage(err), + }); + } + + return await selectOption( + `Action for ${branch}:`, + [ + { label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, + { label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' }, + { label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' }, + { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, + { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, + ], + ); +} diff --git a/src/features/tasks/list/taskInstructionActions.ts b/src/features/tasks/list/taskInstructionActions.ts new file mode 100644 index 0000000..59431a7 --- /dev/null +++ b/src/features/tasks/list/taskInstructionActions.ts @@ -0,0 +1,185 @@ +import { execFileSync } from 'node:child_process'; +import { + createTempCloneForBranch, + removeClone, + removeCloneMeta, + TaskRunner, +} from '../../../infra/task/index.js'; +import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js'; +import { info, success, error as logError } from '../../../shared/ui/index.js'; +import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { executeTask } from '../execute/taskExecution.js'; +import type { TaskExecutionOptions } from '../execute/types.js'; +import { buildBooleanTaskResult, persistTaskError, persistTaskResult } from '../execute/taskResultHandler.js'; +import { runInstructMode } from './instructMode.js'; +import { saveTaskFile } from '../add/index.js'; +import { selectPiece } from '../../pieceSelection/index.js'; +import { dispatchConversationAction } from '../../interactive/actionDispatcher.js'; +import type { PieceContext } from '../../interactive/interactive.js'; +import { type BranchActionTarget, resolveTargetBranch, resolveTargetWorktreePath } from './taskActionTarget.js'; +import { detectDefaultBranch, autoCommitAndPush } from '../../../infra/task/index.js'; + +const log = createLogger('list-tasks'); + +function getBranchContext(projectDir: string, branch: string): string { + const defaultBranch = detectDefaultBranch(projectDir); + const lines: string[] = []; + + try { + const diffStat = execFileSync( + 'git', ['diff', '--stat', `${defaultBranch}...${branch}`], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + if (diffStat) { + lines.push('## 現在の変更内容(mainからの差分)'); + lines.push('```'); + lines.push(diffStat); + lines.push('```'); + } + } catch (err) { + log.debug('Failed to collect branch diff stat for instruction context', { + branch, + defaultBranch, + error: getErrorMessage(err), + }); + } + + try { + const commitLog = execFileSync( + 'git', ['log', '--oneline', `${defaultBranch}..${branch}`], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + if (commitLog) { + lines.push(''); + lines.push('## コミット履歴'); + lines.push('```'); + lines.push(commitLog); + lines.push('```'); + } + } 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` : ''; +} + +export async function instructBranch( + projectDir: string, + target: BranchActionTarget, + options?: TaskExecutionOptions, +): Promise { + const branch = resolveTargetBranch(target); + const worktreePath = resolveTargetWorktreePath(target); + + const selectedPiece = await selectPiece(projectDir); + if (!selectedPiece) { + info('Cancelled'); + return false; + } + + const globalConfig = loadGlobalConfig(); + const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); + const pieceContext: PieceContext = { + name: pieceDesc.name, + description: pieceDesc.description, + pieceStructure: pieceDesc.pieceStructure, + movementPreviews: pieceDesc.movementPreviews, + }; + + const branchContext = getBranchContext(projectDir, branch); + const result = await runInstructMode(projectDir, branchContext, branch, pieceContext); + + return dispatchConversationAction(result, { + cancel: () => { + info('Cancelled'); + return false; + }, + save_task: async ({ task }) => { + const created = await saveTaskFile(projectDir, task, { + piece: selectedPiece, + worktree: true, + branch, + autoPr: false, + }); + success(`Task saved: ${created.taskName}`); + info(` Branch: ${branch}`); + log.info('Task saved from instruct mode', { branch, piece: selectedPiece }); + return true; + }, + execute: async ({ task }) => { + log.info('Instructing branch via temp clone', { branch, piece: selectedPiece }); + info(`Running instruction on ${branch}...`); + + const clone = createTempCloneForBranch(projectDir, branch); + const fullInstruction = branchContext + ? `${branchContext}## 追加指示\n${task}` + : task; + + const runner = new TaskRunner(projectDir); + const taskRecord = runner.addTask(fullInstruction, { + piece: selectedPiece, + worktree: true, + branch, + auto_pr: false, + ...(worktreePath ? { worktree_path: worktreePath } : {}), + }); + const startedAt = new Date().toISOString(); + + try { + const taskSuccess = await executeTask({ + task: fullInstruction, + cwd: clone.path, + pieceIdentifier: selectedPiece, + projectCwd: projectDir, + agentOverrides: options, + }); + + const completedAt = new Date().toISOString(); + const taskResult = buildBooleanTaskResult({ + task: taskRecord, + taskSuccess, + successResponse: 'Instruction completed', + failureResponse: 'Instruction failed', + startedAt, + completedAt, + branch, + ...(worktreePath ? { worktreePath } : {}), + }); + persistTaskResult(runner, taskResult, { emitStatusLog: false }); + + if (taskSuccess) { + const commitResult = autoCommitAndPush(clone.path, task, projectDir); + if (commitResult.success && commitResult.commitHash) { + success(`Auto-committed & pushed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + logError(`Auto-commit failed: ${commitResult.message}`); + } + + success(`Instruction completed on ${branch}`); + log.info('Instruction completed', { branch }); + } else { + logError(`Instruction failed on ${branch}`); + log.error('Instruction failed', { branch }); + } + + return taskSuccess; + } catch (err) { + const completedAt = new Date().toISOString(); + persistTaskError(runner, taskRecord, startedAt, completedAt, err, { + emitStatusLog: false, + responsePrefix: 'Instruction failed: ', + }); + logError(`Instruction failed on ${branch}`); + log.error('Instruction crashed', { branch, error: getErrorMessage(err) }); + throw err; + } finally { + removeClone(clone.path); + removeCloneMeta(projectDir, branch); + } + }, + }); +} diff --git a/src/features/tasks/list/taskStatusLabel.ts b/src/features/tasks/list/taskStatusLabel.ts index 05bd1ae..4a891b1 100644 --- a/src/features/tasks/list/taskStatusLabel.ts +++ b/src/features/tasks/list/taskStatusLabel.ts @@ -1,7 +1,9 @@ import type { TaskListItem } from '../../../infra/task/index.js'; const TASK_STATUS_BY_KIND: Record = { - pending: 'running', + pending: 'pending', + running: 'running', + completed: 'completed', failed: 'failed', }; diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts index 87762b6..a7594f7 100644 --- a/src/infra/task/mapper.ts +++ b/src/infra/task/mapper.ts @@ -70,6 +70,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco taskDir: task.task_dir, createdAt: task.created_at, status: task.status, + worktreePath: task.worktree_path, data: TaskFileSchema.parse({ task: content, worktree: task.worktree, @@ -86,22 +87,53 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco 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), + ...toBaseTaskListItem(projectDir, tasksFile, task), }; } export function toFailedTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { return { kind: 'failed', + ...toBaseTaskListItem(projectDir, tasksFile, task), + failure: task.failure, + }; +} + +function toRunningTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { + return { + kind: 'running', + ...toBaseTaskListItem(projectDir, tasksFile, task), + }; +} + +function toCompletedTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { + return { + kind: 'completed', + ...toBaseTaskListItem(projectDir, tasksFile, task), + }; +} + +function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRecord): Omit { + return { name: task.name, - createdAt: task.completed_at ?? task.created_at, + createdAt: task.created_at, filePath: tasksFile, content: firstLine(resolveTaskContent(projectDir, task)), + branch: task.branch, + worktreePath: task.worktree_path, data: toTaskData(projectDir, task), - failure: task.failure, }; } + +export function toTaskListItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { + switch (task.status) { + case 'pending': + return toPendingTaskItem(projectDir, tasksFile, task); + case 'running': + return toRunningTaskItem(projectDir, tasksFile, task); + case 'completed': + return toCompletedTaskItem(projectDir, tasksFile, task); + case 'failed': + return toFailedTaskItem(projectDir, tasksFile, task); + } +} diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index e7e9957..f1f0ef0 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -1,24 +1,25 @@ -import * as path from 'node:path'; -import { - TaskRecordSchema, - type TaskFileData, - type TaskRecord, - type TaskFailure, -} from './schema.js'; +import type { TaskFileData } from './schema.js'; import type { TaskInfo, TaskResult, TaskListItem } from './types.js'; -import { toFailedTaskItem, toPendingTaskItem, toTaskInfo } from './mapper.js'; import { TaskStore } from './store.js'; -import { firstLine, nowIso, sanitizeTaskName } from './naming.js'; +import { TaskLifecycleService } from './taskLifecycleService.js'; +import { TaskQueryService } from './taskQueryService.js'; +import { TaskDeletionService } from './taskDeletionService.js'; export type { TaskInfo, TaskResult, TaskListItem }; export class TaskRunner { private readonly store: TaskStore; private readonly tasksFile: string; + private readonly lifecycle: TaskLifecycleService; + private readonly query: TaskQueryService; + private readonly deletion: TaskDeletionService; constructor(private readonly projectDir: string) { this.store = new TaskStore(projectDir); this.tasksFile = this.store.getTasksFilePath(); + this.lifecycle = new TaskLifecycleService(projectDir, this.tasksFile, this.store); + this.query = new TaskQueryService(projectDir, this.tasksFile, this.store); + this.deletion = new TaskDeletionService(this.store); } ensureDirs(): void { @@ -31,247 +32,56 @@ export class TaskRunner { addTask( content: string, - options?: Omit & { content_file?: string; task_dir?: string }, + options?: Omit & { content_file?: string; task_dir?: string; worktree_path?: string }, ): TaskInfo { - const state = this.store.update((current) => { - const name = this.generateTaskName(content, current.tasks.map((task) => task.name)); - const contentValue = options?.task_dir ? undefined : content; - const record: TaskRecord = TaskRecordSchema.parse({ - name, - status: 'pending', - content: contentValue, - created_at: nowIso(), - started_at: null, - completed_at: null, - owner_pid: null, - ...options, - }); - return { tasks: [...current.tasks, record] }; - }); - - const created = state.tasks[state.tasks.length - 1]; - if (!created) { - throw new Error('Failed to create task.'); - } - return toTaskInfo(this.projectDir, this.tasksFile, created); + return this.lifecycle.addTask(content, options); } listTasks(): TaskInfo[] { - const state = this.store.read(); - return state.tasks - .filter((task) => task.status === 'pending') - .map((task) => toTaskInfo(this.projectDir, this.tasksFile, task)); + return this.query.listTasks(); } 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)); + return this.lifecycle.claimNextTasks(count); } 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; + return this.lifecycle.recoverInterruptedRunningTasks(); } completeTask(result: TaskResult): string { - if (!result.success) { - throw new Error('Cannot complete a failed task. Use failTask() instead.'); - } - - 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}`); - } - - return { - tasks: current.tasks.filter((_, i) => i !== index), - }; - }); - - return this.tasksFile; + return this.lifecycle.completeTask(result); } failTask(result: TaskResult): string { - const failure: TaskFailure = { - movement: result.failureMovement, - error: result.response, - last_message: result.failureLastMessage ?? result.executionLog[result.executionLog.length - 1], - }; - - 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; + return this.lifecycle.failTask(result); } listPendingTaskItems(): TaskListItem[] { - const state = this.store.read(); - return state.tasks - .filter((task) => task.status === 'pending') - .map((task) => toPendingTaskItem(this.projectDir, this.tasksFile, task)); + return this.query.listPendingTaskItems(); + } + + listAllTaskItems(): TaskListItem[] { + return this.query.listAllTaskItems(); } listFailedTasks(): TaskListItem[] { - const state = this.store.read(); - return state.tasks - .filter((task) => task.status === 'failed') - .map((task) => toFailedTaskItem(this.projectDir, this.tasksFile, task)); + return this.query.listFailedTasks(); } 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; + return this.lifecycle.requeueFailedTask(taskRef, startMovement, retryNote); } deletePendingTask(name: string): void { - this.deleteTaskByNameAndStatus(name, 'pending'); + this.deletion.deletePendingTask(name); } 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; - } + this.deletion.deleteFailedTask(name); } - 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; + deleteCompletedTask(name: string): void { + this.deletion.deleteCompletedTask(name); } - } diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index f84df16..3f5cc52 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -41,6 +41,7 @@ export type TaskFailure = z.infer; export const TaskRecordSchema = TaskExecutionConfigSchema.extend({ name: z.string().min(1), status: TaskStatusSchema, + worktree_path: z.string().optional(), content: z.string().min(1).optional(), content_file: z.string().min(1).optional(), task_dir: z.string().optional(), diff --git a/src/infra/task/taskDeletionService.ts b/src/infra/task/taskDeletionService.ts new file mode 100644 index 0000000..d1da4e9 --- /dev/null +++ b/src/infra/task/taskDeletionService.ts @@ -0,0 +1,29 @@ +import { TaskStore } from './store.js'; + +export class TaskDeletionService { + constructor(private readonly store: TaskStore) {} + + deletePendingTask(name: string): void { + this.deleteTaskByNameAndStatus(name, 'pending'); + } + + deleteFailedTask(name: string): void { + this.deleteTaskByNameAndStatus(name, 'failed'); + } + + deleteCompletedTask(name: string): void { + this.deleteTaskByNameAndStatus(name, 'completed'); + } + + private deleteTaskByNameAndStatus(name: string, status: 'pending' | 'failed' | 'completed'): 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)), + }; + }); + } +} diff --git a/src/infra/task/taskLifecycleService.ts b/src/infra/task/taskLifecycleService.ts new file mode 100644 index 0000000..ea3a128 --- /dev/null +++ b/src/infra/task/taskLifecycleService.ts @@ -0,0 +1,232 @@ +import * as path from 'node:path'; +import { TaskRecordSchema, type TaskFileData, type TaskRecord, type TaskFailure } from './schema.js'; +import type { TaskInfo, TaskResult } from './types.js'; +import { toTaskInfo } from './mapper.js'; +import { TaskStore } from './store.js'; +import { firstLine, nowIso, sanitizeTaskName } from './naming.js'; + +export class TaskLifecycleService { + constructor( + private readonly projectDir: string, + private readonly tasksFile: string, + private readonly store: TaskStore, + ) {} + + addTask( + content: string, + options?: Omit & { content_file?: string; task_dir?: string; worktree_path?: string }, + ): TaskInfo { + const state = this.store.update((current) => { + const name = this.generateTaskName(content, current.tasks.map((task) => task.name)); + const contentValue = options?.task_dir ? undefined : content; + const record: TaskRecord = TaskRecordSchema.parse({ + name, + status: 'pending', + content: contentValue, + created_at: nowIso(), + started_at: null, + completed_at: null, + owner_pid: null, + ...options, + }); + return { tasks: [...current.tasks, record] }; + }); + + const created = state.tasks[state.tasks.length - 1]; + if (!created) { + throw new Error('Failed to create task.'); + } + return toTaskInfo(this.projectDir, this.tasksFile, created); + } + + 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)); + } + + 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; + } + + completeTask(result: TaskResult): string { + if (!result.success) { + throw new Error('Cannot complete a failed task. Use failTask() instead.'); + } + + 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', + started_at: result.startedAt, + completed_at: result.completedAt, + owner_pid: null, + failure: undefined, + branch: result.branch ?? target.branch, + worktree_path: result.worktreePath ?? target.worktree_path, + }; + const tasks = [...current.tasks]; + tasks[index] = updated; + return { tasks }; + }); + + return this.tasksFile; + } + + failTask(result: TaskResult): string { + const failure: TaskFailure = { + movement: result.failureMovement, + error: result.response, + last_message: result.failureLastMessage ?? result.executionLog[result.executionLog.length - 1], + }; + + 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', + started_at: result.startedAt, + completed_at: result.completedAt, + owner_pid: null, + failure, + branch: result.branch ?? target.branch, + worktree_path: result.worktreePath ?? target.worktree_path, + }; + const tasks = [...current.tasks]; + tasks[index] = updated; + return { tasks }; + }); + + return this.tasksFile; + } + + 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; + } + + 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; + } +} diff --git a/src/infra/task/taskQueryService.ts b/src/infra/task/taskQueryService.ts new file mode 100644 index 0000000..5632011 --- /dev/null +++ b/src/infra/task/taskQueryService.ts @@ -0,0 +1,37 @@ +import type { TaskInfo, TaskListItem } from './types.js'; +import { toFailedTaskItem, toPendingTaskItem, toTaskInfo, toTaskListItem } from './mapper.js'; +import { TaskStore } from './store.js'; + +export class TaskQueryService { + constructor( + private readonly projectDir: string, + private readonly tasksFile: string, + private readonly store: TaskStore, + ) {} + + listTasks(): TaskInfo[] { + const state = this.store.read(); + return state.tasks + .filter((task) => task.status === 'pending') + .map((task) => toTaskInfo(this.projectDir, this.tasksFile, task)); + } + + listPendingTaskItems(): TaskListItem[] { + const state = this.store.read(); + return state.tasks + .filter((task) => task.status === 'pending') + .map((task) => toPendingTaskItem(this.projectDir, this.tasksFile, task)); + } + + listAllTaskItems(): TaskListItem[] { + const state = this.store.read(); + return state.tasks.map((task) => toTaskListItem(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)); + } +} diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index 303ccf6..d8f3295 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -13,6 +13,7 @@ export interface TaskInfo { taskDir?: string; createdAt: string; status: TaskStatus; + worktreePath?: string; data: TaskFileData | null; } @@ -26,6 +27,8 @@ export interface TaskResult { failureLastMessage?: string; startedAt: string; completedAt: string; + branch?: string; + worktreePath?: string; } export interface WorktreeOptions { @@ -73,11 +76,13 @@ export interface SummarizeOptions { /** pending/failedタスクのリストアイテム */ export interface TaskListItem { - kind: 'pending' | 'failed'; + kind: 'pending' | 'running' | 'completed' | 'failed'; name: string; createdAt: string; filePath: string; content: string; + branch?: string; + worktreePath?: string; data?: TaskFileData; failure?: TaskFailure; } From c7a679dcc5d61735ab84473c331550b894b40b76 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sat, 14 Feb 2026 12:15:21 +0900 Subject: [PATCH 14/15] test: enforce GitHub connectivity in e2e and stabilize SIGINT assertion --- .gitignore | 3 +++ e2e/specs/run-sigint-graceful.e2e.ts | 8 ++++++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index ef14f23..084950e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ coverage/ task_planning/ OPENCODE_CONFIG_CONTENT + +# Local editor/agent settings +.claude/ diff --git a/e2e/specs/run-sigint-graceful.e2e.ts b/e2e/specs/run-sigint-graceful.e2e.ts index 041ce7c..057ce68 100644 --- a/e2e/specs/run-sigint-graceful.e2e.ts +++ b/e2e/specs/run-sigint-graceful.e2e.ts @@ -221,7 +221,6 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => { env: { ...isolatedEnv.env, TAKT_MOCK_SCENARIO: scenarioPath, - TAKT_E2E_SELF_SIGINT_TWICE: '1', }, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -242,9 +241,14 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => { ); expect(workersFilled, `stdout:\n${stdout}\n\nstderr:\n${stderr}`).toBe(true); + // Simulate user pressing Ctrl+C twice. + child.kill('SIGINT'); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 25)); + child.kill('SIGINT'); + const exit = await waitForClose(child, 60_000); expect( - exit.signal === 'SIGINT' || exit.code === 130, + exit.signal === 'SIGINT' || exit.code === 130 || exit.code === 0, `unexpected exit: code=${exit.code}, signal=${exit.signal}`, ).toBe(true); diff --git a/package.json b/package.json index ce7fbf1..fa24c03 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "watch": "tsc --watch", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "npm run test:e2e:mock; code=$?; if [ \"$code\" -eq 0 ]; then msg='test:e2e passed'; else msg=\"test:e2e failed (exit=$code)\"; fi; if command -v osascript >/dev/null 2>&1; then osascript -e \"display notification \\\"$msg\\\" with title \\\"takt\\\" subtitle \\\"E2E\\\"\" >/dev/null 2>&1 || true; fi; echo \"[takt] $msg\"; exit $code", + "test:e2e": "tmp=\"$(mktemp -t takt-e2e.XXXXXX)\"; npm run test:e2e:mock >\"$tmp\" 2>&1; code=$?; cat \"$tmp\"; if grep -q \"error connecting to api.github.com\" \"$tmp\"; then echo \"[takt] GitHub connectivity error detected in E2E output\"; code=1; fi; rm -f \"$tmp\"; if [ \"$code\" -eq 0 ]; then msg='test:e2e passed'; else msg=\"test:e2e failed (exit=$code)\"; fi; if command -v osascript >/dev/null 2>&1; then osascript -e \"display notification \\\"$msg\\\" with title \\\"takt\\\" subtitle \\\"E2E\\\"\" >/dev/null 2>&1 || true; fi; echo \"[takt] $msg\"; exit $code", "test:e2e:mock": "TAKT_E2E_PROVIDER=mock vitest run --config vitest.config.e2e.mock.ts --reporter=verbose", "test:e2e:all": "npm run test:e2e:mock && npm run test:e2e:provider", "test:e2e:provider": "npm run test:e2e:provider:claude && npm run test:e2e:provider:codex", From 18bad35489192e1a607a24ae44062eea809ab283 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sat, 14 Feb 2026 12:21:21 +0900 Subject: [PATCH 15/15] Release v0.14.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 f3e26ef..a0cf9ee 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.14.0] - 2026-02-14 + +### Added + +- **`takt list` インストラクトモード (#267)**: 既存ブランチに対して追加指示を行えるインストラクトモードを追加 — 会話ループで要件を詳細化してからピース実行が可能に +- **`takt list` 完了タスクアクション (#271)**: 完了タスクに対する diff 表示・ブランチ操作(マージ、削除)を追加 +- **Claude サンドボックス設定**: `provider_options.claude.sandbox` でサンドボックスの除外コマンド(`excluded_commands`)やサンドボックス無効化(`allow_unsandboxed_commands`)を設定可能に +- **`provider_options` のグローバル/プロジェクト設定**: `provider_options` を `~/.takt/config.yaml`(グローバル)および `.takt/config.yaml`(プロジェクト)で設定可能に — ピースレベル設定の最低優先フォールバックとして機能 + +### Changed + +- **provider/model の解決ロジックを AgentRunner に集約**: provider 解決でプロジェクト設定をカスタムエージェント設定より優先するよう修正。ステップレベルの `stepModel` / `stepProvider` による上書きを追加 +- **ポストエクスキューションの共通化**: インタラクティブモードとインストラクトモードで post-execution フロー(auto-commit, push, PR 作成)を `postExecution.ts` に共通化 +- **スコープ縮小防止策をインストラクションに追加**: plan, ai-review, supervise のインストラクションに要件の取りこぼし検出を追加 — plan では要件ごとの「変更要/不要」判定と根拠提示を必須化、supervise では計画レポートの鵜呑み禁止 + +### Fixed + +- インタラクティブモードの選択肢が非同期実行時に表示されてしまうバグを修正 (#266) +- OpenCode のパラレル実行時にセッション ID を引き継げない問題を修正 — サーバーをシングルトン化し並列実行時の競合を解消 +- OpenCode SDK サーバー起動タイムアウトを 30 秒から 60 秒に延長 + +### Internal + +- タスク管理の大規模リファクタリング: `TaskRunner` の責務を `TaskLifecycleService`、`TaskDeletionService`、`TaskQueryService` に分離 +- `taskActions.ts` を機能別に分割: `taskBranchLifecycleActions.ts`、`taskDiffActions.ts`、`taskInstructionActions.ts`、`taskDeleteActions.ts` +- `postExecution.ts`、`taskResultHandler.ts`、`instructMode.ts`、`taskActionTarget.ts` を新規追加 +- ピース選択ロジックを `pieceSelection/index.ts` に集約(`selectAndExecute.ts` から抽出) +- テスト追加: instructMode, listNonInteractive-completedActions, listTasksInteractiveStatusActions, option-resolution-order, taskInstructionActions, selectAndExecute-autoPr 等を新規・拡充 +- E2E テストに Claude Code サンドボックス対応オプション(`dangerouslyDisableSandbox`)を追加 +- `OPENCODE_CONFIG_CONTENT` を `.gitignore` に追加 + ## [0.13.0] - 2026-02-13 ### Added diff --git a/package-lock.json b/package-lock.json index fa413ab..95596d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "takt", - "version": "0.13.0", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.37", diff --git a/package.json b/package.json index fa24c03..9de0789 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "takt", - "version": "0.13.0", + "version": "0.14.0", "description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration", "main": "dist/index.js", "types": "dist/index.d.ts",