diff --git a/CLAUDE.md b/CLAUDE.md index 8db11a5..44aba48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,6 @@ TAKT (TAKT Agent Koordination Topology) is a multi-agent orchestration system fo | `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (resident process) | | `takt add [task]` | Add a new task via AI conversation | | `takt list` | List task branches (merge, delete, retry) | -| `takt switch [piece]` | Switch piece interactively | | `takt clear` | Clear agent conversation sessions (reset state) | | `takt eject [type] [name]` | Copy builtin piece or facet for customization (`--global` for ~/.takt/) | | `takt prompt [piece]` | Preview assembled prompts for each movement and phase | diff --git a/e2e/specs/cli-help.e2e.ts b/e2e/specs/cli-help.e2e.ts index 6d0a03e..a674009 100644 --- a/e2e/specs/cli-help.e2e.ts +++ b/e2e/specs/cli-help.e2e.ts @@ -51,4 +51,36 @@ describe('E2E: Help command (takt --help)', () => { const output = result.stdout.toLowerCase(); expect(output).toMatch(/run|task|pending/); }); + + it('should show prompt argument help without current-piece wording', () => { + // Given: a local repo with isolated env + + // When: running takt prompt --help + const result = runTakt({ + args: ['prompt', '--help'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: prompt help uses explicit default piece wording + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/defaults to ["']default["']/i); + expect(result.stdout).not.toMatch(/defaults to current/i); + }); + + it('should fail with unknown command for removed switch subcommand', () => { + // Given: a local repo with isolated env + + // When: running removed takt switch command + const result = runTakt({ + args: ['switch'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: command exits non-zero and reports unknown command + const combined = `${result.stdout}\n${result.stderr}`; + expect(result.exitCode).not.toBe(0); + expect(combined).toMatch(/unknown command/i); + }); }); diff --git a/e2e/specs/cli-switch.e2e.ts b/e2e/specs/cli-switch.e2e.ts deleted file mode 100644 index 2efa979..0000000 --- a/e2e/specs/cli-switch.e2e.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; -import { runTakt } from '../helpers/takt-runner'; -import { createLocalRepo, type LocalRepo } from '../helpers/test-repo'; - -// E2E更新時は docs/testing/e2e.md も更新すること -describe('E2E: Switch piece command (takt switch)', () => { - let isolatedEnv: IsolatedEnv; - let repo: LocalRepo; - - beforeEach(() => { - isolatedEnv = createIsolatedEnv(); - repo = createLocalRepo(); - }); - - afterEach(() => { - try { repo.cleanup(); } catch { /* best-effort */ } - try { isolatedEnv.cleanup(); } catch { /* best-effort */ } - }); - - it('should switch piece when a valid piece name is given', () => { - // Given: a local repo with isolated env - - // When: running takt switch default - const result = runTakt({ - args: ['switch', 'default'], - cwd: repo.path, - env: isolatedEnv.env, - }); - - // Then: exits successfully - expect(result.exitCode).toBe(0); - const output = result.stdout.toLowerCase(); - expect(output).toMatch(/default|switched|piece/); - }); - - it('should error when a nonexistent piece name is given', () => { - // Given: a local repo with isolated env - - // When: running takt switch with a nonexistent piece name - const result = runTakt({ - args: ['switch', 'nonexistent-piece-xyz'], - cwd: repo.path, - env: isolatedEnv.env, - }); - - // Then: error output - const combined = result.stdout + result.stderr; - expect(combined).toMatch(/not found|error|does not exist/i); - }); -}); diff --git a/e2e/specs/config-priority.e2e.ts b/e2e/specs/config-priority.e2e.ts index 9a4d991..7547bc8 100644 --- a/e2e/specs/config-priority.e2e.ts +++ b/e2e/specs/config-priority.e2e.ts @@ -44,7 +44,7 @@ describe('E2E: Config priority (piece / autoPr)', () => { } }); - it('should use configured piece in pipeline when --piece is omitted', () => { + it('should require --piece in pipeline even when config has piece', () => { const configuredPiecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); const projectConfigDir = join(testRepo.path, '.takt'); @@ -70,9 +70,8 @@ describe('E2E: Config priority (piece / autoPr)', () => { timeout: 240_000, }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(`Running piece: ${configuredPiecePath}`); - expect(result.stdout).toContain(`Piece '${configuredPiecePath}' completed`); + expect(result.exitCode).toBe(1); + expect(`${result.stdout}${result.stderr}`).toContain('piece'); }, 240_000); it('should default auto_pr to true when unset in config/env', () => { diff --git a/src/__tests__/cli-routing-pr-resolve.test.ts b/src/__tests__/cli-routing-pr-resolve.test.ts index 01add68..7f5746b 100644 --- a/src/__tests__/cli-routing-pr-resolve.test.ts +++ b/src/__tests__/cli-routing-pr-resolve.test.ts @@ -81,15 +81,11 @@ vi.mock('../infra/task/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({ getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })), - resolveConfigValue: vi.fn((_: string, key: string) => (key === 'piece' ? 'default' : false)), resolveConfigValues: vi.fn(() => ({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' })), + resolveConfigValue: vi.fn(() => undefined), loadPersonaSessions: vi.fn(() => ({})), })); -vi.mock('../shared/constants.js', () => ({ - DEFAULT_PIECE_NAME: 'default', -})); - const mockOpts: Record = {}; vi.mock('../app/cli/program.js', () => { @@ -113,7 +109,6 @@ vi.mock('../app/cli/helpers.js', () => ({ import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive } from '../features/tasks/index.js'; import { interactiveMode } from '../features/interactive/index.js'; import { executePipeline } from '../features/pipeline/index.js'; -import { resolveConfigValue } from '../infra/config/index.js'; import { executeDefaultAction } from '../app/cli/routing.js'; import { error as logError } from '../shared/ui/index.js'; import type { InteractiveModeResult } from '../features/interactive/index.js'; @@ -123,7 +118,6 @@ const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask); const mockDeterminePiece = vi.mocked(determinePiece); const mockInteractiveMode = vi.mocked(interactiveMode); const mockExecutePipeline = vi.mocked(executePipeline); -const mockResolveConfigValue = vi.mocked(resolveConfigValue); const mockLogError = vi.mocked(logError); const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive); @@ -148,7 +142,6 @@ beforeEach(() => { } mockDeterminePiece.mockResolvedValue('default'); mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' }); - mockResolveConfigValue.mockImplementation((_: string, key: string) => (key === 'piece' ? 'default' : false)); mockListAllTaskItems.mockReturnValue([]); mockIsStaleRunningTask.mockReturnValue(false); }); @@ -343,6 +336,7 @@ describe('PR resolution in routing', () => { Object.defineProperty(programModule, 'pipelineMode', { value: true, writable: true }); mockOpts.pr = 456; + mockOpts.piece = 'default'; mockExecutePipeline.mockResolvedValue(0); // When @@ -359,22 +353,24 @@ describe('PR resolution in routing', () => { Object.defineProperty(programModule, 'pipelineMode', { value: originalPipelineMode, writable: true }); }); - it('should use DEFAULT_PIECE_NAME when resolved piece is undefined', async () => { + it('should exit with error when piece is omitted in pipeline mode', async () => { const programModule = await import('../app/cli/program.js'); const originalPipelineMode = programModule.pipelineMode; Object.defineProperty(programModule, 'pipelineMode', { value: true, writable: true }); mockOpts.pr = 456; - mockExecutePipeline.mockResolvedValue(0); - mockResolveConfigValue.mockImplementation((_: string, key: string) => (key === 'piece' ? undefined : false)); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); - await executeDefaultAction(); + await expect(executeDefaultAction()).rejects.toThrow('process.exit called'); - expect(mockExecutePipeline).toHaveBeenCalledWith( - expect.objectContaining({ - piece: 'default', - }), + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockLogError).toHaveBeenCalledWith( + expect.stringContaining('piece'), ); + expect(mockExecutePipeline).not.toHaveBeenCalled(); + mockExit.mockRestore(); Object.defineProperty(programModule, 'pipelineMode', { value: originalPipelineMode, writable: true }); }); diff --git a/src/__tests__/cli-slash-hash.test.ts b/src/__tests__/cli-slash-hash.test.ts index ab3ceb2..f6dbc3a 100644 --- a/src/__tests__/cli-slash-hash.test.ts +++ b/src/__tests__/cli-slash-hash.test.ts @@ -8,7 +8,8 @@ */ import { describe, it, expect } from 'vitest'; -import { isDirectTask } from '../app/cli/helpers.js'; +import type { Command } from 'commander'; +import { isDirectTask, resolveAgentOverrides, resolveRemovedRootCommand, resolveSlashFallbackTask } from '../app/cli/helpers.js'; describe('isDirectTask', () => { describe('slash prefixed inputs', () => { @@ -103,3 +104,55 @@ describe('isDirectTask', () => { }); }); }); + +describe('resolveSlashFallbackTask', () => { + it('returns raw argv as task for unknown slash command', () => { + const task = resolveSlashFallbackTask(['/foo', '--bar'], ['run', 'add', 'watch']); + expect(task).toBe('/foo --bar'); + }); + + it('returns null for known slash command', () => { + const task = resolveSlashFallbackTask(['/run', '--help'], ['run', 'add', 'watch']); + expect(task).toBeNull(); + }); + + it('returns null when first argument is not slash-prefixed', () => { + const task = resolveSlashFallbackTask(['run', '/foo'], ['run', 'add', 'watch']); + expect(task).toBeNull(); + }); +}); + +describe('resolveRemovedRootCommand', () => { + it('returns removed command when first argument is switch', () => { + expect(resolveRemovedRootCommand(['switch'])).toBe('switch'); + }); + + it('returns null when first argument is a valid command', () => { + expect(resolveRemovedRootCommand(['run'])).toBeNull(); + }); + + it('returns null when argument only contains removed command in later position', () => { + expect(resolveRemovedRootCommand(['--help', 'switch'])).toBeNull(); + }); +}); + +describe('resolveAgentOverrides', () => { + it('returns undefined when provider and model are both missing', () => { + const program = { + opts: () => ({}), + } as unknown as Command; + + expect(resolveAgentOverrides(program)).toBeUndefined(); + }); + + it('returns provider/model pair when one or both are provided', () => { + const program = { + opts: () => ({ provider: 'codex', model: 'gpt-5' }), + } as unknown as Command; + + expect(resolveAgentOverrides(program)).toEqual({ + provider: 'codex', + model: 'gpt-5', + }); + }); +}); diff --git a/src/__tests__/clone.test.ts b/src/__tests__/clone.test.ts index 970f1d4..2d62b69 100644 --- a/src/__tests__/clone.test.ts +++ b/src/__tests__/clone.test.ts @@ -45,7 +45,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({ vi.mock('../infra/config/project/projectConfig.js', async (importOriginal) => ({ ...(await importOriginal>()), - loadProjectConfig: vi.fn(() => ({ piece: 'default' })), + loadProjectConfig: vi.fn(() => ({})), })); import { execFileSync } from 'node:child_process'; @@ -59,7 +59,7 @@ const mockLoadProjectConfig = vi.mocked(loadProjectConfig); beforeEach(() => { vi.clearAllMocks(); - mockLoadProjectConfig.mockReturnValue({ piece: 'default' }); + mockLoadProjectConfig.mockReturnValue({}); }); describe('cloneAndIsolate git config propagation', () => { @@ -598,7 +598,7 @@ describe('clone submodule arguments', () => { } it('should append recurse flag when submodules is all', () => { - mockLoadProjectConfig.mockReturnValue({ piece: 'default', submodules: 'all' }); + mockLoadProjectConfig.mockReturnValue({ submodules: 'all' }); const cloneCalls = setupCloneArgsCapture(); createSharedClone('/project', { @@ -611,7 +611,7 @@ describe('clone submodule arguments', () => { }); it('should append path-scoped recurse flags when submodules is explicit list', () => { - mockLoadProjectConfig.mockReturnValue({ piece: 'default', submodules: ['path/a', 'path/b'] }); + mockLoadProjectConfig.mockReturnValue({ submodules: ['path/a', 'path/b'] }); const cloneCalls = setupCloneArgsCapture(); createSharedClone('/project', { @@ -629,7 +629,7 @@ describe('clone submodule arguments', () => { }); it('should append recurse flag when withSubmodules is true and submodules is unset', () => { - mockLoadProjectConfig.mockReturnValue({ piece: 'default', withSubmodules: true }); + mockLoadProjectConfig.mockReturnValue({ withSubmodules: true }); const cloneCalls = setupCloneArgsCapture(); createSharedClone('/project', { @@ -647,7 +647,7 @@ describe('clone submodule arguments', () => { }); it('should keep existing clone args when submodule acquisition is disabled', () => { - mockLoadProjectConfig.mockReturnValue({ piece: 'default', withSubmodules: false }); + mockLoadProjectConfig.mockReturnValue({ withSubmodules: false }); const cloneCalls = setupCloneArgsCapture(); createSharedClone('/project', { diff --git a/src/__tests__/commands-add.test.ts b/src/__tests__/commands-add.test.ts index 3ed2fb9..508f081 100644 --- a/src/__tests__/commands-add.test.ts +++ b/src/__tests__/commands-add.test.ts @@ -3,8 +3,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockOpts: Record = {}; const mockAddTask = vi.fn(); -const { rootCommand, commandActions } = vi.hoisted(() => { +const { rootCommand, commandActions, commandMocks } = vi.hoisted(() => { const commandActions = new Map void>(); + const commandMocks = new Map>(); function createCommandMock(actionKey: string): { description: ReturnType; @@ -20,6 +21,7 @@ const { rootCommand, commandActions } = vi.hoisted(() => { option: vi.fn().mockReturnThis(), opts: vi.fn(() => mockOpts), }; + commandMocks.set(actionKey, command); command.command = vi.fn((subName: string) => createCommandMock(`${actionKey}.${subName}`)); command.action = vi.fn((action: (...args: unknown[]) => void) => { @@ -40,6 +42,7 @@ const { rootCommand, commandActions } = vi.hoisted(() => { return { rootCommand: createCommandMock('root'), commandActions, + commandMocks, }; }); @@ -71,7 +74,6 @@ vi.mock('../features/tasks/index.js', () => ({ vi.mock('../features/config/index.js', () => ({ clearPersonaSessions: vi.fn(), - switchPiece: vi.fn(), ejectBuiltin: vi.fn(), ejectFacet: vi.fn(), parseFacetType: vi.fn(), @@ -112,7 +114,7 @@ import '../app/cli/commands.js'; describe('CLI add command', () => { beforeEach(() => { - vi.clearAllMocks(); + mockAddTask.mockClear(); for (const key of Object.keys(mockOpts)) { delete mockOpts[key]; } @@ -141,4 +143,20 @@ describe('CLI add command', () => { expect(mockAddTask).toHaveBeenCalledWith('/test/cwd', 'Regular task', undefined); }); }); + + it('should not register switch command', () => { + const calledCommandNames = rootCommand.command.mock.calls + .map((call: unknown[]) => call[0] as string); + + expect(calledCommandNames).not.toContain('switch'); + }); + + it('should describe prompt piece argument as defaulting to "default"', () => { + const promptCommand = commandMocks.get('root.prompt'); + expect(promptCommand).toBeTruthy(); + expect(promptCommand?.argument).toHaveBeenCalledWith( + '[piece]', + 'Piece name or path (defaults to "default")', + ); + }); }); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 63a5956..8ec5911 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -13,7 +13,6 @@ import { loadPiece, listPieces, loadPersonaPromptFromPath, - setCurrentPiece, getProjectConfigDir, getBuiltinPersonasDir, loadInputHistory, @@ -332,69 +331,6 @@ describe('loadPersonaPromptFromPath (builtin paths)', () => { }); }); -describe('setCurrentPiece', () => { - let testDir: string; - - beforeEach(() => { - testDir = join(tmpdir(), `takt-test-${randomUUID()}`); - mkdirSync(testDir, { recursive: true }); - }); - - afterEach(() => { - if (existsSync(testDir)) { - rmSync(testDir, { recursive: true, force: true }); - } - }); - - it('should save piece name to config.yaml', () => { - setCurrentPiece(testDir, 'my-piece'); - - const config = loadProjectConfig(testDir); - - expect(config.piece).toBe('my-piece'); - }); - - it('should create config directory if not exists', () => { - const configDir = getProjectConfigDir(testDir); - expect(existsSync(configDir)).toBe(false); - - setCurrentPiece(testDir, 'test'); - - expect(existsSync(configDir)).toBe(true); - }); - - it('should overwrite existing piece name', () => { - setCurrentPiece(testDir, 'first'); - setCurrentPiece(testDir, 'second'); - - const piece = loadProjectConfig(testDir).piece; - - expect(piece).toBe('second'); - }); - - it('should preserve provider_options when updating piece', () => { - const configDir = getProjectConfigDir(testDir); - mkdirSync(configDir, { recursive: true }); - writeFileSync( - join(configDir, 'config.yaml'), - [ - 'piece: first', - 'provider_options:', - ' codex:', - ' network_access: true', - ].join('\n'), - 'utf-8', - ); - - setCurrentPiece(testDir, 'updated'); - - const saved = readFileSync(join(configDir, 'config.yaml'), 'utf-8'); - expect(saved).toContain('piece: updated'); - expect(saved).toContain('provider_options:'); - expect(saved).toContain('network_access: true'); - }); -}); - describe('loadProjectConfig provider_options', () => { let testDir: string; @@ -413,7 +349,6 @@ describe('loadProjectConfig provider_options', () => { const projectConfigDir = getProjectConfigDir(testDir); mkdirSync(projectConfigDir, { recursive: true }); writeFileSync(join(projectConfigDir, 'config.yaml'), [ - 'piece: default', 'provider_options:', ' codex:', ' network_access: true', @@ -450,7 +385,6 @@ describe('loadProjectConfig provider_options', () => { const projectConfigDir = getProjectConfigDir(testDir); mkdirSync(projectConfigDir, { recursive: true }); writeFileSync(join(projectConfigDir, 'config.yaml'), [ - 'piece: default', 'provider:', ' type: claude', ' network_access: true', @@ -463,7 +397,6 @@ describe('loadProjectConfig provider_options', () => { const projectConfigDir = getProjectConfigDir(testDir); mkdirSync(projectConfigDir, { recursive: true }); writeFileSync(join(projectConfigDir, 'config.yaml'), [ - 'piece: default', 'provider:', ' type: codex', ' model: gpt-5.3', @@ -483,7 +416,6 @@ describe('loadProjectConfig provider_options', () => { const projectConfigDir = getProjectConfigDir(testDir); mkdirSync(projectConfigDir, { recursive: true }); writeFileSync(join(projectConfigDir, 'config.yaml'), [ - 'piece: default', 'provider:', ' type: codex', ' sandbox:', @@ -497,7 +429,6 @@ describe('loadProjectConfig provider_options', () => { const projectConfigDir = getProjectConfigDir(testDir); mkdirSync(projectConfigDir, { recursive: true }); writeFileSync(join(projectConfigDir, 'config.yaml'), [ - 'piece: default', 'provider:', ' type: codex', ' unknown_option: true', @@ -510,7 +441,6 @@ describe('loadProjectConfig provider_options', () => { const projectConfigDir = getProjectConfigDir(testDir); mkdirSync(projectConfigDir, { recursive: true }); writeFileSync(join(projectConfigDir, 'config.yaml'), [ - 'piece: default', 'provider: invalid-provider', ].join('\n')); @@ -571,7 +501,6 @@ describe('analytics config resolution', () => { const projectConfigDir = getProjectConfigDir(testDir); mkdirSync(projectConfigDir, { recursive: true }); writeFileSync(join(projectConfigDir, 'config.yaml'), [ - 'piece: default', 'analytics:', ' enabled: false', ' events_path: .takt/project-analytics/events', @@ -614,7 +543,6 @@ describe('analytics config resolution', () => { const projectConfigDir = getProjectConfigDir(testDir); mkdirSync(projectConfigDir, { recursive: true }); writeFileSync(join(projectConfigDir, 'config.yaml'), [ - 'piece: default', 'analytics:', ' events_path: /tmp/project-analytics', ' retention_days: 14', @@ -664,7 +592,7 @@ describe('isVerboseMode', () => { it('should return project verbose when project config has verbose: true', () => { const projectConfigDir = getProjectConfigDir(testDir); mkdirSync(projectConfigDir, { recursive: true }); - writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: true\n'); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'verbose: true\n'); const globalConfigDir = process.env.TAKT_CONFIG_DIR!; mkdirSync(globalConfigDir, { recursive: true }); @@ -676,7 +604,7 @@ describe('isVerboseMode', () => { it('should return project verbose when project config has verbose: false', () => { const projectConfigDir = getProjectConfigDir(testDir); mkdirSync(projectConfigDir, { recursive: true }); - writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: false\n'); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'verbose: false\n'); const globalConfigDir = process.env.TAKT_CONFIG_DIR!; mkdirSync(globalConfigDir, { recursive: true }); @@ -688,7 +616,7 @@ describe('isVerboseMode', () => { it('should use default verbose=false when project verbose is not set', () => { const projectConfigDir = getProjectConfigDir(testDir); mkdirSync(projectConfigDir, { recursive: true }); - writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\n'); + writeFileSync(join(projectConfigDir, 'config.yaml'), ''); const globalConfigDir = process.env.TAKT_CONFIG_DIR!; mkdirSync(globalConfigDir, { recursive: true }); @@ -704,7 +632,7 @@ describe('isVerboseMode', () => { it('should prioritize TAKT_VERBOSE over project and global config', () => { const projectConfigDir = getProjectConfigDir(testDir); mkdirSync(projectConfigDir, { recursive: true }); - writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: false\n'); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'verbose: false\n'); const globalConfigDir = process.env.TAKT_CONFIG_DIR!; mkdirSync(globalConfigDir, { recursive: true }); @@ -954,7 +882,7 @@ describe('saveProjectConfig - gitignore copy', () => { }); it('should copy .gitignore when creating new config', () => { - setCurrentPiece(testDir, 'test'); + saveProjectConfig(testDir, {}); const configDir = getProjectConfigDir(testDir); const gitignorePath = join(configDir, '.gitignore'); @@ -966,10 +894,10 @@ describe('saveProjectConfig - gitignore copy', () => { // Create config directory without .gitignore const configDir = getProjectConfigDir(testDir); mkdirSync(configDir, { recursive: true }); - writeFileSync(join(configDir, 'config.yaml'), 'piece: existing\n'); + writeFileSync(join(configDir, 'config.yaml'), ''); // Save config should still copy .gitignore - setCurrentPiece(testDir, 'updated'); + saveProjectConfig(testDir, {}); const gitignorePath = join(configDir, '.gitignore'); expect(existsSync(gitignorePath)).toBe(true); @@ -981,7 +909,7 @@ describe('saveProjectConfig - gitignore copy', () => { const customContent = '# Custom gitignore\nmy-custom-file'; writeFileSync(join(configDir, '.gitignore'), customContent); - setCurrentPiece(testDir, 'test'); + saveProjectConfig(testDir, {}); const gitignorePath = join(configDir, '.gitignore'); const content = readFileSync(gitignorePath, 'utf-8'); @@ -1436,7 +1364,7 @@ describe('saveProjectConfig snake_case denormalization', () => { }); it('should persist autoPr as auto_pr and reload correctly', () => { - saveProjectConfig(testDir, { piece: 'default', autoPr: true }); + saveProjectConfig(testDir, { autoPr: true }); const saved = loadProjectConfig(testDir); @@ -1445,7 +1373,7 @@ describe('saveProjectConfig snake_case denormalization', () => { }); it('should persist draftPr as draft_pr and reload correctly', () => { - saveProjectConfig(testDir, { piece: 'default', draftPr: true }); + saveProjectConfig(testDir, { draftPr: true }); const saved = loadProjectConfig(testDir); @@ -1454,7 +1382,7 @@ describe('saveProjectConfig snake_case denormalization', () => { }); it('should persist baseBranch as base_branch and reload correctly', () => { - saveProjectConfig(testDir, { piece: 'default', baseBranch: 'main' }); + saveProjectConfig(testDir, { baseBranch: 'main' }); const saved = loadProjectConfig(testDir); @@ -1463,7 +1391,7 @@ describe('saveProjectConfig snake_case denormalization', () => { }); it('should persist withSubmodules as with_submodules and reload correctly', () => { - saveProjectConfig(testDir, { piece: 'default', withSubmodules: true }); + saveProjectConfig(testDir, { withSubmodules: true }); const saved = loadProjectConfig(testDir); @@ -1472,7 +1400,7 @@ describe('saveProjectConfig snake_case denormalization', () => { }); it('should persist submodules and ignore with_submodules when both are provided', () => { - saveProjectConfig(testDir, { piece: 'default', submodules: ['path/a'], withSubmodules: true }); + saveProjectConfig(testDir, { submodules: ['path/a'], withSubmodules: true }); const projectConfigDir = getProjectConfigDir(testDir); const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8'); @@ -1485,7 +1413,7 @@ describe('saveProjectConfig snake_case denormalization', () => { }); it('should persist concurrency and reload correctly', () => { - saveProjectConfig(testDir, { piece: 'default', concurrency: 3 }); + saveProjectConfig(testDir, { concurrency: 3 }); const saved = loadProjectConfig(testDir); @@ -1493,7 +1421,7 @@ describe('saveProjectConfig snake_case denormalization', () => { }); it('should not write camelCase keys to YAML file', () => { - saveProjectConfig(testDir, { piece: 'default', autoPr: true, draftPr: false, baseBranch: 'develop' }); + saveProjectConfig(testDir, { autoPr: true, draftPr: false, baseBranch: 'develop' }); const projectConfigDir = getProjectConfigDir(testDir); const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8'); diff --git a/src/__tests__/it-config-project-local-priority.test.ts b/src/__tests__/it-config-project-local-priority.test.ts index f1a251e..cf60a46 100644 --- a/src/__tests__/it-config-project-local-priority.test.ts +++ b/src/__tests__/it-config-project-local-priority.test.ts @@ -108,7 +108,7 @@ describe('IT: migrated config keys should prefer project over global', () => { it('should resolve migrated keys from global when project config does not set them', () => { writeFileSync( join(projectDir, '.takt', 'config.yaml'), - 'piece: default\n', + '', 'utf-8', ); invalidateGlobalConfigCache(); @@ -142,7 +142,7 @@ describe('IT: migrated config keys should prefer project over global', () => { it('should mark migrated key source as global when only global defines the key', () => { writeFileSync( join(projectDir, '.takt', 'config.yaml'), - 'piece: default\n', + '', 'utf-8', ); invalidateGlobalConfigCache(); diff --git a/src/__tests__/it-run-config-provider-options.test.ts b/src/__tests__/it-run-config-provider-options.test.ts index 4572f9a..401c4b6 100644 --- a/src/__tests__/it-run-config-provider-options.test.ts +++ b/src/__tests__/it-run-config-provider-options.test.ts @@ -36,6 +36,8 @@ import { TaskRunner } from '../infra/task/index.js'; import { runAgent } from '../agents/runner.js'; import { invalidateGlobalConfigCache } from '../infra/config/index.js'; +const runAllTasksNoPiece = runAllTasks as (projectCwd: string) => ReturnType; + interface TestEnv { root: string; projectDir: string; @@ -107,7 +109,7 @@ describe('IT: runAllTasks provider_options reflection', () => { vi.mocked(runAgent).mockResolvedValue(mockDoneResponse()); const runner = new TaskRunner(env.projectDir); - runner.addTask('test task'); + runner.addTask('test task', { piece: 'run-config-it' }); }); afterEach(() => { @@ -137,7 +139,7 @@ describe('IT: runAllTasks provider_options reflection', () => { ' network_access: false', ].join('\n')); - await runAllTasks(env.projectDir, 'run-config-it'); + await runAllTasksNoPiece(env.projectDir); const options = vi.mocked(runAgent).mock.calls[0]?.[2]; expect(options?.providerOptions).toEqual({ @@ -159,7 +161,7 @@ describe('IT: runAllTasks provider_options reflection', () => { process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = 'true'; invalidateGlobalConfigCache(); - await runAllTasks(env.projectDir, 'run-config-it'); + await runAllTasksNoPiece(env.projectDir); const options = vi.mocked(runAgent).mock.calls[0]?.[2]; expect(options?.providerOptions).toEqual({ @@ -167,4 +169,3 @@ describe('IT: runAllTasks provider_options reflection', () => { }); }); }); - diff --git a/src/__tests__/it-sigint-worker-pool.test.ts b/src/__tests__/it-sigint-worker-pool.test.ts index f3831c4..80d234a 100644 --- a/src/__tests__/it-sigint-worker-pool.test.ts +++ b/src/__tests__/it-sigint-worker-pool.test.ts @@ -50,6 +50,9 @@ function createTask(name: string): TaskInfo { name, content: `Task: ${name}`, filePath: `/tasks/${name}.yaml`, + createdAt: '2026-01-01T00:00:00.000Z', + status: 'pending', + data: { task: `Task: ${name}`, piece: 'default' }, }; } @@ -88,14 +91,14 @@ describe('worker pool: abort signal propagation', () => { const receivedSignals: (AbortSignal | undefined)[] = []; mockExecuteAndCompleteTask.mockImplementation( - (_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => { + (_task: unknown, _runner: unknown, _cwd: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => { receivedSignals.push(parallelOpts?.abortSignal); return Promise.resolve(true); }, ); // When - await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, 50); + await runWithWorkerPool(runner as never, tasks, 1, '/cwd', undefined, 50); // Then: AbortSignal is passed even with concurrency=1 expect(receivedSignals).toHaveLength(1); @@ -109,7 +112,7 @@ describe('worker pool: abort signal propagation', () => { let capturedSignal: AbortSignal | undefined; mockExecuteAndCompleteTask.mockImplementation( - (_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => { + (_task: unknown, _runner: unknown, _cwd: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => { capturedSignal = parallelOpts?.abortSignal; return new Promise((resolve) => { // Wait long enough for SIGINT to fire @@ -119,7 +122,7 @@ describe('worker pool: abort signal propagation', () => { ); // Start execution - const resultPromise = runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, 50); + const resultPromise = runWithWorkerPool(runner as never, tasks, 1, '/cwd', undefined, 50); // Wait for task to start await new Promise((resolve) => setTimeout(resolve, 20)); @@ -149,25 +152,25 @@ describe('worker pool: abort signal propagation', () => { const receivedSignalsPar: (AbortSignal | undefined)[] = []; mockExecuteAndCompleteTask.mockImplementation( - (_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => { + (_task: unknown, _runner: unknown, _cwd: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => { receivedSignalsSeq.push(parallelOpts?.abortSignal); return Promise.resolve(true); }, ); // Sequential mode - await runWithWorkerPool(runner as never, [...tasks], 1, '/cwd', 'default', undefined, 50); + await runWithWorkerPool(runner as never, [...tasks], 1, '/cwd', undefined, 50); mockExecuteAndCompleteTask.mockClear(); mockExecuteAndCompleteTask.mockImplementation( - (_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => { + (_task: unknown, _runner: unknown, _cwd: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => { receivedSignalsPar.push(parallelOpts?.abortSignal); return Promise.resolve(true); }, ); // Parallel mode - await runWithWorkerPool(runner as never, [...tasks], 2, '/cwd', 'default', undefined, 50); + await runWithWorkerPool(runner as never, [...tasks], 2, '/cwd', undefined, 50); // Then: Both modes pass AbortSignal for (const signal of receivedSignalsSeq) { diff --git a/src/__tests__/piece-categories.test.ts b/src/__tests__/piece-categories.test.ts index b025782..e73e4b8 100644 --- a/src/__tests__/piece-categories.test.ts +++ b/src/__tests__/piece-categories.test.ts @@ -252,22 +252,19 @@ describe('2-stage category selection helpers', () => { describe('buildTopLevelSelectOptions', () => { it('should encode categories with prefix in value', () => { - const options = buildTopLevelSelectOptions(items, ''); + const options = buildTopLevelSelectOptions(items); const categoryOption = options.find((o) => o.label.includes('frontend')); expect(categoryOption).toBeDefined(); expect(categoryOption!.value).toBe('__category__:frontend'); }); - it('should mark current piece', () => { - const options = buildTopLevelSelectOptions(items, 'simple'); - const simpleOption = options.find((o) => o.value === 'simple'); - expect(simpleOption!.label).toContain('(current)'); - }); + it('should not include legacy current markers in labels or values', () => { + const options = buildTopLevelSelectOptions(items); + const labels = options.map((o) => o.label); + const values = options.map((o) => o.value); - it('should mark category containing current piece', () => { - const options = buildTopLevelSelectOptions(items, 'frontend/react'); - const frontendOption = options.find((o) => o.value === '__category__:frontend'); - expect(frontendOption!.label).toContain('(current)'); + expect(labels.some((label) => label.includes('(current)'))).toBe(false); + expect(values).not.toContain('__current__'); }); }); @@ -283,21 +280,15 @@ describe('2-stage category selection helpers', () => { describe('buildCategoryPieceOptions', () => { it('should return options for pieces in a category', () => { - const options = buildCategoryPieceOptions(items, 'frontend', ''); + const options = buildCategoryPieceOptions(items, 'frontend'); expect(options).not.toBeNull(); expect(options).toHaveLength(2); expect(options![0]!.value).toBe('frontend/react'); expect(options![0]!.label).toBe('react'); }); - it('should mark current piece in category', () => { - const options = buildCategoryPieceOptions(items, 'frontend', 'frontend/vue'); - const vueOption = options!.find((o) => o.value === 'frontend/vue'); - expect(vueOption!.label).toContain('(current)'); - }); - it('should return null for non-existent category', () => { - expect(buildCategoryPieceOptions(items, 'nonexistent', '')).toBeNull(); + expect(buildCategoryPieceOptions(items, 'nonexistent')).toBeNull(); }); }); }); diff --git a/src/__tests__/piece-selection.test.ts b/src/__tests__/piece-selection.test.ts index 34ee192..09affa0 100644 --- a/src/__tests__/piece-selection.test.ts +++ b/src/__tests__/piece-selection.test.ts @@ -39,8 +39,6 @@ const configMock = vi.hoisted(() => ({ loadAllPiecesWithSources: vi.fn(), getPieceCategories: vi.fn(), buildCategorizedPieces: vi.fn(), - getCurrentPiece: vi.fn(), - resolveConfigValue: vi.fn(), })); vi.mock('../infra/config/index.js', () => configMock); @@ -63,7 +61,7 @@ describe('selectPieceFromEntries', () => { .mockResolvedValueOnce('custom') .mockResolvedValueOnce('custom-flow'); - const selected = await selectPieceFromEntries(entries, ''); + const selected = await selectPieceFromEntries(entries); expect(selected).toBe('custom-flow'); expect(selectOptionMock).toHaveBeenCalledTimes(2); }); @@ -75,7 +73,7 @@ describe('selectPieceFromEntries', () => { selectOptionMock.mockResolvedValueOnce('builtin-flow'); - const selected = await selectPieceFromEntries(entries, ''); + const selected = await selectPieceFromEntries(entries); expect(selected).toBe('builtin-flow'); expect(selectOptionMock).toHaveBeenCalledTimes(1); }); @@ -116,19 +114,24 @@ describe('selectPieceFromCategorizedPieces', () => { missingPieces: [], }; - selectOptionMock.mockResolvedValueOnce('__current__'); + selectOptionMock + .mockResolvedValueOnce('__custom_category__:My Pieces') + .mockResolvedValueOnce('my-piece'); - await selectPieceFromCategorizedPieces(categorized, 'my-piece'); + await selectPieceFromCategorizedPieces(categorized); const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[]; const labels = firstCallOptions.map((o) => o.label); + const values = firstCallOptions.map((o) => o.value); - expect(labels[0]).toBe('🎼 my-piece (current)'); + expect(labels.some((l) => l.includes('My Pieces'))).toBe(true); expect(labels.some((l) => l.includes('My Pieces'))).toBe(true); expect(labels.some((l) => l.includes('Quick Start'))).toBe(true); + expect(labels.some((l) => l.includes('(current)'))).toBe(false); + expect(values).not.toContain('__current__'); }); - it('should show current piece and bookmarks above categories', async () => { + it('should show bookmarked pieces', async () => { bookmarkState.bookmarks = ['research']; const categorized: CategorizedPieces = { @@ -142,17 +145,15 @@ describe('selectPieceFromCategorizedPieces', () => { missingPieces: [], }; - selectOptionMock.mockResolvedValueOnce('__current__'); + selectOptionMock.mockResolvedValueOnce('research'); - const selected = await selectPieceFromCategorizedPieces(categorized, 'default'); - expect(selected).toBe('default'); + const selected = await selectPieceFromCategorizedPieces(categorized); + expect(selected).toBe('research'); const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[]; const labels = firstCallOptions.map((o) => o.label); - // Current piece first, bookmarks second, categories after - expect(labels[0]).toBe('🎼 default (current)'); - expect(labels[1]).toBe('🎼 research [*]'); + expect(labels.some((l) => l.includes('research [*]'))).toBe(true); }); it('should navigate into a category and select a piece', async () => { @@ -171,7 +172,7 @@ describe('selectPieceFromCategorizedPieces', () => { .mockResolvedValueOnce('__custom_category__:Dev') .mockResolvedValueOnce('my-piece'); - const selected = await selectPieceFromCategorizedPieces(categorized, ''); + const selected = await selectPieceFromCategorizedPieces(categorized); expect(selected).toBe('my-piece'); }); @@ -200,7 +201,7 @@ describe('selectPieceFromCategorizedPieces', () => { .mockResolvedValueOnce('__category__:Quick Start') .mockResolvedValueOnce('hybrid-default'); - const selected = await selectPieceFromCategorizedPieces(categorized, ''); + const selected = await selectPieceFromCategorizedPieces(categorized); expect(selected).toBe('hybrid-default'); expect(selectOptionMock).toHaveBeenCalledTimes(3); }); @@ -228,7 +229,7 @@ describe('selectPieceFromCategorizedPieces', () => { .mockResolvedValueOnce('__custom_category__:Dev') .mockResolvedValueOnce('base-piece'); - const selected = await selectPieceFromCategorizedPieces(categorized, ''); + const selected = await selectPieceFromCategorizedPieces(categorized); expect(selected).toBe('base-piece'); // Second call should show Advanced subcategory AND base-piece at same level @@ -268,7 +269,7 @@ describe('selectPieceFromCategorizedPieces', () => { .mockResolvedValueOnce('__category__:Quick Start') .mockResolvedValueOnce('default'); - const selected = await selectPieceFromCategorizedPieces(categorized, ''); + const selected = await selectPieceFromCategorizedPieces(categorized); expect(selected).toBe('default'); expect(selectOptionMock).toHaveBeenCalledTimes(3); }); @@ -294,7 +295,7 @@ describe('selectPieceFromCategorizedPieces', () => { selectOptionMock.mockResolvedValueOnce(null); - await selectPieceFromCategorizedPieces(categorized, ''); + await selectPieceFromCategorizedPieces(categorized); const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[]; const labels = firstCallOptions.map((o) => o.label); @@ -317,13 +318,11 @@ describe('selectPiece', () => { configMock.loadAllPiecesWithSources.mockReset(); configMock.getPieceCategories.mockReset(); configMock.buildCategorizedPieces.mockReset(); - configMock.resolveConfigValue.mockReset(); }); it('should return default piece when no pieces found and fallbackToDefault is true', async () => { configMock.getPieceCategories.mockReturnValue(null); configMock.listPieces.mockReturnValue([]); - configMock.resolveConfigValue.mockReturnValue('default'); const result = await selectPiece('/cwd'); @@ -333,7 +332,6 @@ describe('selectPiece', () => { it('should return null when no pieces found and fallbackToDefault is false', async () => { configMock.getPieceCategories.mockReturnValue(null); configMock.listPieces.mockReturnValue([]); - configMock.resolveConfigValue.mockReturnValue('default'); const result = await selectPiece('/cwd', { fallbackToDefault: false }); @@ -346,7 +344,6 @@ describe('selectPiece', () => { configMock.listPieceEntries.mockReturnValue([ { name: 'only-piece', path: '/tmp/only-piece.yaml', source: 'user' }, ]); - configMock.resolveConfigValue.mockReturnValue('only-piece'); selectOptionMock.mockResolvedValueOnce('only-piece'); const result = await selectPiece('/cwd'); @@ -366,9 +363,10 @@ describe('selectPiece', () => { configMock.getPieceCategories.mockReturnValue({ categories: ['Dev'] }); configMock.loadAllPiecesWithSources.mockReturnValue(pieceMap); configMock.buildCategorizedPieces.mockReturnValue(categorized); - configMock.resolveConfigValue.mockReturnValue('my-piece'); - selectOptionMock.mockResolvedValueOnce('__current__'); + selectOptionMock + .mockResolvedValueOnce('__custom_category__:Dev') + .mockResolvedValueOnce('my-piece'); const result = await selectPiece('/cwd'); @@ -376,30 +374,10 @@ describe('selectPiece', () => { expect(configMock.buildCategorizedPieces).toHaveBeenCalled(); }); - it('should fall back to default current piece when config piece is undefined', async () => { - const pieceMap = createPieceMap([{ name: 'default', source: 'builtin' }]); - const categorized: CategorizedPieces = { - categories: [{ name: 'Quick Start', pieces: ['default'], children: [] }], - allPieces: pieceMap, - missingPieces: [], - }; - - configMock.getPieceCategories.mockReturnValue({ categories: ['Quick Start'] }); - configMock.loadAllPiecesWithSources.mockReturnValue(pieceMap); - configMock.buildCategorizedPieces.mockReturnValue(categorized); - configMock.resolveConfigValue.mockReturnValue(undefined); - selectOptionMock.mockResolvedValueOnce('__current__'); - - const result = await selectPiece('/cwd'); - - expect(result).toBe('default'); - }); - 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.resolveConfigValue.mockReturnValue('piece-a'); selectOptionMock .mockResolvedValueOnce('custom') diff --git a/src/__tests__/prepareTaskForExecution.test.ts b/src/__tests__/prepareTaskForExecution.test.ts new file mode 100644 index 0000000..3316c0b --- /dev/null +++ b/src/__tests__/prepareTaskForExecution.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { prepareTaskForExecution } from '../features/tasks/list/prepareTaskForExecution.js'; +import type { TaskInfo } from '../infra/task/types.js'; + +function createTaskInfo(data: TaskInfo['data']): TaskInfo { + return { + filePath: '/project/.takt/tasks.yaml', + name: 'task-1', + content: 'task content', + createdAt: '2026-03-04T00:00:00.000Z', + status: 'running', + data, + }; +} + +describe('prepareTaskForExecution', () => { + it('returns copied task with selected piece', () => { + const original = createTaskInfo({ task: 'task content', piece: 'original-piece' }); + + const prepared = prepareTaskForExecution(original, 'selected-piece'); + + expect(prepared).not.toBe(original); + expect(prepared.data).not.toBe(original.data); + expect(prepared.data?.piece).toBe('selected-piece'); + expect(original.data?.piece).toBe('original-piece'); + }); + + it('throws when task data is missing', () => { + const original = createTaskInfo(null); + + expect(() => prepareTaskForExecution(original, 'selected-piece')).toThrow( + 'Task "task-1" is missing required data.', + ); + }); +}); diff --git a/src/__tests__/project-provider-profiles.test.ts b/src/__tests__/project-provider-profiles.test.ts index 84f7179..778c7f1 100644 --- a/src/__tests__/project-provider-profiles.test.ts +++ b/src/__tests__/project-provider-profiles.test.ts @@ -26,7 +26,6 @@ describe('project provider_profiles', () => { writeFileSync( join(taktDir, 'config.yaml'), [ - 'piece: default', 'provider_profiles:', ' codex:', ' default_permission_mode: full', @@ -44,7 +43,6 @@ describe('project provider_profiles', () => { it('saves providerProfiles as provider_profiles', () => { saveProjectConfig(testDir, { - piece: 'default', providerProfiles: { codex: { defaultPermissionMode: 'full', diff --git a/src/__tests__/resolveConfigValue-call-chain.test.ts b/src/__tests__/resolveConfigValue-call-chain.test.ts index 8e0fdb5..d64ef1f 100644 --- a/src/__tests__/resolveConfigValue-call-chain.test.ts +++ b/src/__tests__/resolveConfigValue-call-chain.test.ts @@ -9,7 +9,7 @@ describe('resolveConfigValue call-chain contract', () => { it('should fail fast when migrated fallback loader is missing and migrated key is resolved', async () => { vi.doMock('../infra/config/project/projectConfig.js', () => ({ - loadProjectConfig: () => ({ piece: 'default' }), + loadProjectConfig: () => ({}), })); vi.doMock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: () => ({ language: 'en' }), diff --git a/src/__tests__/resolveConfigValue-no-defaultValue.test.ts b/src/__tests__/resolveConfigValue-no-defaultValue.test.ts index 10c94ab..7c4cf93 100644 --- a/src/__tests__/resolveConfigValue-no-defaultValue.test.ts +++ b/src/__tests__/resolveConfigValue-no-defaultValue.test.ts @@ -57,37 +57,6 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => { } }); - describe('piece', () => { - it('should resolve piece as undefined when not set in project or global config', () => { - const value = resolveConfigValue(projectDir, 'piece'); - expect(value).toBeUndefined(); - }); - - it('should report source as default when piece is not set anywhere', () => { - const result = resolveConfigValueWithSource(projectDir, 'piece'); - expect(result.value).toBeUndefined(); - expect(result.source).toBe('default'); - }); - - it('should resolve explicit project piece over default', () => { - const configDir = getProjectConfigDir(projectDir); - mkdirSync(configDir, { recursive: true }); - writeFileSync(join(configDir, 'config.yaml'), 'piece: custom-piece\n'); - - const value = resolveConfigValue(projectDir, 'piece'); - expect(value).toBe('custom-piece'); - }); - - it('should resolve piece from global config when global has it', () => { - writeFileSync(globalConfigPath, 'language: en\npiece: global-piece\n', 'utf-8'); - invalidateGlobalConfigCache(); - - const result = resolveConfigValueWithSource(projectDir, 'piece'); - expect(result.value).toBe('global-piece'); - expect(result.source).toBe('global'); - }); - }); - describe('verbose', () => { it('should resolve verbose to false via resolver default when not set anywhere', () => { const value = resolveConfigValue(projectDir, 'verbose'); @@ -116,7 +85,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => { const configDir = getProjectConfigDir(projectDir); mkdirSync(configDir, { recursive: true }); - writeFileSync(join(configDir, 'config.yaml'), 'piece: default\nverbose: true\n'); + writeFileSync(join(configDir, 'config.yaml'), 'verbose: true\n'); const value = resolveConfigValue(projectDir, 'verbose'); expect(value).toBe(true); @@ -227,7 +196,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => { it('should resolve migrated non-default keys as undefined when project keys are unset', () => { const configDir = getProjectConfigDir(projectDir); mkdirSync(configDir, { recursive: true }); - writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8'); + writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8'); writeFileSync( globalConfigPath, ['language: en'].join('\n'), @@ -256,7 +225,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => { it('should resolve default-backed migrated keys from defaults when project keys are unset', () => { const configDir = getProjectConfigDir(projectDir); mkdirSync(configDir, { recursive: true }); - writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8'); + writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8'); writeFileSync( globalConfigPath, ['language: en'].join('\n'), @@ -344,7 +313,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => { it('should resolve all migrated keys from project or defaults when project config has no migrated keys', () => { const configDir = getProjectConfigDir(projectDir); mkdirSync(configDir, { recursive: true }); - writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8'); + writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8'); writeFileSync( globalConfigPath, ['language: en'].join('\n'), diff --git a/src/__tests__/resolveTask.test.ts b/src/__tests__/resolveTask.test.ts index 0f46f08..83977f4 100644 --- a/src/__tests__/resolveTask.test.ts +++ b/src/__tests__/resolveTask.test.ts @@ -21,26 +21,57 @@ function createTempProjectDir(): string { return root; } -function createTask(overrides: Partial): TaskInfo { +function createTask(overrides: Partial = {}): TaskInfo { + const baseData = { task: 'Run task', piece: 'default' } as NonNullable; + const data = overrides.data === undefined + ? baseData + : overrides.data === null + ? null + : ({ + ...baseData, + ...(overrides.data as Record), + } as NonNullable); + return { filePath: '/tasks/task.yaml', name: 'task-name', content: 'Run task', createdAt: '2026-01-01T00:00:00.000Z', status: 'pending', - data: { task: 'Run task' }, ...overrides, + data, }; } +const resolveTaskExecutionWithPiece = resolveTaskExecution as (task: TaskInfo, projectCwd: string) => ReturnType; + describe('resolveTaskExecution', () => { - it('should return defaults when task data is null', async () => { + it('should throw when task data is null', async () => { const root = createTempProjectDir(); const task = createTask({ data: null }); - const result = await resolveTaskExecution(task, root, 'default'); + await expect(resolveTaskExecutionWithPiece(task, root)).rejects.toThrow(); + }); - expect(result).toEqual({ + it('should throw when task data does not include piece', async () => { + const root = createTempProjectDir(); + const task = createTask({ + data: ({ + task: 'Run task without piece', + piece: undefined, + } as unknown) as NonNullable, + }); + + await expect(resolveTaskExecutionWithPiece(task, root)).rejects.toThrow(); + }); + + it('should return defaults for valid task data', async () => { + const root = createTempProjectDir(); + const task = createTask(); + + const result = await resolveTaskExecutionWithPiece(task, root); + + expect(result).toMatchObject({ execCwd: root, execPiece: 'default', isWorktree: false, @@ -66,7 +97,7 @@ describe('resolveTaskExecution', () => { }, }); - const result = await resolveTaskExecution(task, root, 'default'); + const result = await resolveTaskExecutionWithPiece(task, root); const expectedReportOrderPath = path.join(root, '.takt', 'runs', 'issue-task-123', 'context', 'task', 'order.md'); expect(result).toMatchObject({ @@ -107,7 +138,7 @@ describe('resolveTaskExecution', () => { branch: 'feature/base-branch', }); - const result = await resolveTaskExecution(task, root, 'default'); + const result = await resolveTaskExecutionWithPiece(task, root); expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main'); expect(mockCreateSharedClone).toHaveBeenCalledWith( @@ -147,7 +178,7 @@ describe('resolveTaskExecution', () => { branch: 'feature/base-branch', }); - const result = await resolveTaskExecution(task, root, 'default'); + const result = await resolveTaskExecutionWithPiece(task, root); const cloneOptions = mockCreateSharedClone.mock.calls[0]?.[1] as Record | undefined; expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main'); @@ -187,7 +218,7 @@ describe('resolveTaskExecution', () => { branch: 'feature/base-branch', }); - const result = await resolveTaskExecution(task, root, 'default'); + const result = await resolveTaskExecutionWithPiece(task, root); const cloneOptions = mockCreateSharedClone.mock.calls[0]?.[1] as Record | undefined; expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined); @@ -228,7 +259,7 @@ describe('resolveTaskExecution', () => { branch: 'feature/base-branch', }); - const result = await resolveTaskExecution(task, root, 'default'); + const result = await resolveTaskExecutionWithPiece(task, root); expect(result.execCwd).toBe(worktreePath); expect(result.isWorktree).toBe(true); @@ -264,7 +295,7 @@ describe('resolveTaskExecution', () => { branch: 'feature/base-branch', }); - const result = await resolveTaskExecution(task, root, 'default'); + const result = await resolveTaskExecutionWithPiece(task, root); expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main'); expect(mockCreateSharedClone).not.toHaveBeenCalled(); @@ -300,7 +331,7 @@ describe('resolveTaskExecution', () => { branch: 'feature/base-branch', }); - const result = await resolveTaskExecution(task, root, 'default'); + const result = await resolveTaskExecutionWithPiece(task, root); expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined); expect(mockCreateSharedClone).not.toHaveBeenCalled(); @@ -322,7 +353,7 @@ describe('resolveTaskExecution', () => { }, }); - const result = await resolveTaskExecution(task, root, 'default'); + const result = await resolveTaskExecutionWithPiece(task, root); expect(result.draftPr).toBe(true); expect(result.autoPr).toBe(true); diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 347ce97..6dd0929 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -40,6 +40,13 @@ vi.mock('../infra/config/index.js', () => ({ } return result; }, + resolvePieceConfigValue: (_projectDir: string, key: string) => { + const raw = mockLoadConfigRaw() as Record; + const config = ('global' in raw && 'project' in raw) + ? { ...raw.global as Record, ...raw.project as Record } + : { ...raw, provider: 'claude', verbose: false }; + return config[key]; + }, resolveConfigValueWithSource: (_projectDir: string, key: string) => { const raw = mockLoadConfigRaw() as Record; const config = ('global' in raw && 'project' in raw) @@ -188,7 +195,10 @@ function createTask(name: string): TaskInfo { filePath: `/tasks/${name}.yaml`, createdAt: '2026-02-09T00:00:00.000Z', status: 'pending', - data: null, + data: { + task: `Task: ${name}`, + piece: 'default', + }, }; } diff --git a/src/__tests__/switchPiece.test.ts b/src/__tests__/switchPiece.test.ts deleted file mode 100644 index 266c936..0000000 --- a/src/__tests__/switchPiece.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Tests for switchPiece behavior. - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('../infra/config/index.js', () => ({ - loadPiece: vi.fn(() => null), - resolveConfigValue: vi.fn(() => 'default'), - setCurrentPiece: vi.fn(), -})); - -vi.mock('../features/pieceSelection/index.js', () => ({ - selectPiece: vi.fn(), -})); - -vi.mock('../shared/ui/index.js', () => ({ - info: vi.fn(), - success: vi.fn(), - error: vi.fn(), -})); - -import { resolveConfigValue, loadPiece, setCurrentPiece } from '../infra/config/index.js'; -import { selectPiece } from '../features/pieceSelection/index.js'; -import { switchPiece } from '../features/config/switchPiece.js'; - -const mockResolveConfigValue = vi.mocked(resolveConfigValue); -const mockLoadPiece = vi.mocked(loadPiece); -const mockSetCurrentPiece = vi.mocked(setCurrentPiece); -const mockSelectPiece = vi.mocked(selectPiece); - -describe('switchPiece', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockResolveConfigValue.mockReturnValue('default'); - }); - - it('should call selectPiece with fallbackToDefault: false', async () => { - mockSelectPiece.mockResolvedValue(null); - - const switched = await switchPiece('/project'); - - expect(switched).toBe(false); - 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/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index bc3350d..2727e4a 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -77,9 +77,17 @@ const createTask = (name: string): TaskInfo => ({ filePath: `/tasks/${name}.yaml`, createdAt: '2026-02-16T00:00:00.000Z', status: 'pending', - data: { task: `Task: ${name}` }, + data: { task: `Task: ${name}`, piece: 'default' }, }); +const executeAndCompleteTaskWithoutPiece = executeAndCompleteTask as ( + task: TaskInfo, + taskRunner: unknown, + projectCwd: string, + executeOptions?: unknown, + parallelOptions?: unknown, +) => Promise; + describe('executeAndCompleteTask', () => { beforeEach(() => { vi.clearAllMocks(); @@ -130,12 +138,18 @@ describe('executeAndCompleteTask', () => { const abortController = new AbortController(); // When - await executeAndCompleteTask(task, {} as never, '/project', 'default', undefined, { - abortSignal: abortController.signal, - taskPrefix: taskDisplayLabel, - taskColorIndex: 0, - taskDisplayLabel, - }); + await executeAndCompleteTaskWithoutPiece( + task, + {} as never, + '/project', + undefined, + { + abortSignal: abortController.signal, + taskPrefix: taskDisplayLabel, + taskColorIndex: 0, + taskDisplayLabel, + }, + ); // Then: executePiece receives the propagated display label. expect(mockExecutePiece).toHaveBeenCalledTimes(1); @@ -223,7 +237,7 @@ describe('executeAndCompleteTask', () => { mockPostExecutionFlow.mockResolvedValue({ prFailed: true, prError: 'Base ref must be a branch' }); // When - const result = await executeAndCompleteTask(task, {} as never, '/project', 'default'); + const result = await executeAndCompleteTaskWithoutPiece(task, {} as never, '/project'); // Then: code succeeded, task is marked as pr_failed (not failed) expect(result).toBe(true); @@ -262,7 +276,7 @@ describe('executeAndCompleteTask', () => { mockPostExecutionFlow.mockResolvedValue({ prUrl: 'https://github.com/org/repo/pull/1' }); // When - const result = await executeAndCompleteTask(task, {} as never, '/project', 'default'); + const result = await executeAndCompleteTaskWithoutPiece(task, {} as never, '/project'); // Then: task should be marked as completed expect(result).toBe(true); diff --git a/src/__tests__/taskInstructionActions.test.ts b/src/__tests__/taskInstructionActions.test.ts index d42b36f..5571941 100644 --- a/src/__tests__/taskInstructionActions.test.ts +++ b/src/__tests__/taskInstructionActions.test.ts @@ -158,6 +158,33 @@ describe('instructBranch direct execution flow', () => { expect(mockExecuteAndCompleteTask).toHaveBeenCalled(); }); + it('should execute with selected piece without mutating taskInfo', async () => { + mockSelectPiece.mockResolvedValue('selected-piece'); + const originalTaskInfo = { + name: 'done-task', + content: 'done', + data: { task: 'done', piece: 'original-piece' }, + }; + mockStartReExecution.mockReturnValue(originalTaskInfo); + + 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', + data: { task: 'done' }, + }); + + const executeArg = mockExecuteAndCompleteTask.mock.calls[0]?.[0]; + expect(executeArg).not.toBe(originalTaskInfo); + expect(executeArg.data).not.toBe(originalTaskInfo.data); + expect(executeArg.data.piece).toBe('selected-piece'); + expect(originalTaskInfo.data.piece).toBe('original-piece'); + }); + it('should set generated instruction as retry note when no existing note', async () => { await instructBranch('/project', { kind: 'completed', diff --git a/src/__tests__/taskRetryActions.test.ts b/src/__tests__/taskRetryActions.test.ts index 10c960d..286c235 100644 --- a/src/__tests__/taskRetryActions.test.ts +++ b/src/__tests__/taskRetryActions.test.ts @@ -179,6 +179,25 @@ describe('retryFailedTask', () => { expect(mockExecuteAndCompleteTask).toHaveBeenCalled(); }); + it('should execute with selected piece without mutating taskInfo', async () => { + mockSelectPiece.mockResolvedValue('selected-piece'); + const originalTaskInfo = { + name: 'my-task', + content: 'Do something', + data: { task: 'Do something', piece: 'original-piece' }, + }; + mockStartReExecution.mockReturnValue(originalTaskInfo); + const task = makeFailedTask(); + + await retryFailedTask(task, '/project'); + + const executeArg = mockExecuteAndCompleteTask.mock.calls[0]?.[0]; + expect(executeArg).not.toBe(originalTaskInfo); + expect(executeArg.data).not.toBe(originalTaskInfo.data); + expect(executeArg.data.piece).toBe('selected-piece'); + expect(originalTaskInfo.data.piece).toBe('original-piece'); + }); + it('should pass failed movement as default to selectOptionWithDefault', async () => { const task = makeFailedTask(); // failure.movement = 'review' diff --git a/src/__tests__/watchTasks.test.ts b/src/__tests__/watchTasks.test.ts index a63c77c..ad25045 100644 --- a/src/__tests__/watchTasks.test.ts +++ b/src/__tests__/watchTasks.test.ts @@ -14,7 +14,6 @@ const { mockSuccess, mockWarn, mockError, - mockResolveConfigValue, } = vi.hoisted(() => ({ mockRecoverInterruptedRunningTasks: vi.fn(), mockGetTasksFilePath: vi.fn(), @@ -28,7 +27,6 @@ const { mockSuccess: vi.fn(), mockWarn: vi.fn(), mockError: vi.fn(), - mockResolveConfigValue: vi.fn(), })); vi.mock('../infra/task/index.js', () => ({ @@ -60,16 +58,11 @@ vi.mock('../shared/i18n/index.js', () => ({ getLabel: vi.fn((key: string) => key), })); -vi.mock('../infra/config/index.js', () => ({ - resolveConfigValue: mockResolveConfigValue, -})); - import { watchTasks } from '../features/tasks/watch/index.js'; describe('watchTasks', () => { beforeEach(() => { vi.clearAllMocks(); - mockResolveConfigValue.mockReturnValue('default'); mockRecoverInterruptedRunningTasks.mockReturnValue(0); mockGetTasksFilePath.mockReturnValue('/project/.takt/tasks.yaml'); mockExecuteAndCompleteTask.mockResolvedValue(true); @@ -97,17 +90,13 @@ describe('watchTasks', () => { expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); }); - it('piece設定が未定義の場合はデフォルトpiece名を使う', async () => { - mockResolveConfigValue.mockReturnValue(undefined); - + it('executeAndCompleteTask を watch ループで呼び出す', async () => { await watchTasks('/project'); - expect(mockInfo).toHaveBeenCalledWith('Piece: default'); expect(mockExecuteAndCompleteTask).toHaveBeenCalledWith( expect.any(Object), expect.any(Object), '/project', - 'default', undefined, ); }); diff --git a/src/__tests__/workerPool.test.ts b/src/__tests__/workerPool.test.ts index c35dbb1..cdfd6bd 100644 --- a/src/__tests__/workerPool.test.ts +++ b/src/__tests__/workerPool.test.ts @@ -54,6 +54,7 @@ function createTask(name: string, options?: { issue?: number }): TaskInfo { status: 'pending', data: { task: `Task: ${name}`, + piece: 'default', ...(options?.issue !== undefined ? { issue: options.issue } : {}), }, }; @@ -85,7 +86,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - const result = await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); + const result = await runWithWorkerPool(runner as never, tasks, 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS); // Then expect(result).toEqual({ success: 2, fail: 0 }); @@ -102,7 +103,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - const result = await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); + const result = await runWithWorkerPool(runner as never, tasks, 3, '/cwd', undefined, TEST_POLL_INTERVAL_MS); // Then expect(result).toEqual({ success: 2, fail: 1 }); @@ -119,7 +120,7 @@ describe('runWithWorkerPool', () => { }); // When - await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); + await runWithWorkerPool(runner as never, tasks, 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS); // Then: Task names appear in prefixed stdout output writeSpy.mockRestore(); @@ -136,11 +137,11 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); + await runWithWorkerPool(runner as never, tasks, 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS); // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); - const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; + const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[4]; expect(parallelOpts).toMatchObject({ abortSignal: expect.any(AbortSignal), taskPrefix: 'my-task', @@ -161,7 +162,7 @@ describe('runWithWorkerPool', () => { }); // When - await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); + await runWithWorkerPool(runner as never, tasks, 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS); // Then: Issue label is used instead of truncated task name writeSpy.mockRestore(); @@ -170,7 +171,7 @@ describe('runWithWorkerPool', () => { expect(allOutput).not.toContain('[#123]'); expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); - const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; + const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[4]; expect(parallelOpts).toEqual({ abortSignal: expect.any(AbortSignal), taskPrefix: `#${issueNumber}`, @@ -185,11 +186,11 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); + await runWithWorkerPool(runner as never, tasks, 1, '/cwd', undefined, TEST_POLL_INTERVAL_MS); // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); - const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; + const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[4]; expect(parallelOpts).toMatchObject({ abortSignal: expect.any(AbortSignal), taskPrefix: undefined, @@ -205,7 +206,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([[task2]]); // When - await runWithWorkerPool(runner as never, [task1], 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); + await runWithWorkerPool(runner as never, [task1], 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS); // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(2); @@ -233,7 +234,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); + await runWithWorkerPool(runner as never, tasks, 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS); // Then: Never exceeded concurrency of 2 expect(maxActive).toBeLessThanOrEqual(2); @@ -246,13 +247,13 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); const receivedSignals: (AbortSignal | undefined)[] = []; - mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _piece, _opts, parallelOpts) => { + mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _opts, parallelOpts) => { receivedSignals.push(parallelOpts?.abortSignal); return Promise.resolve(true); }); // When - await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); + await runWithWorkerPool(runner as never, tasks, 3, '/cwd', undefined, TEST_POLL_INTERVAL_MS); // Then: All tasks received the same AbortSignal expect(receivedSignals).toHaveLength(3); @@ -268,7 +269,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - const result = await runWithWorkerPool(runner as never, [], 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); + const result = await runWithWorkerPool(runner as never, [], 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS); // Then expect(result).toEqual({ success: 0, fail: 0 }); @@ -282,7 +283,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - const result = await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); + const result = await runWithWorkerPool(runner as never, tasks, 1, '/cwd', undefined, TEST_POLL_INTERVAL_MS); // Then: Treated as failure expect(result).toEqual({ success: 0, fail: 1 }); @@ -295,7 +296,7 @@ describe('runWithWorkerPool', () => { const deferred: Array<() => void> = []; const startedSignals: AbortSignal[] = []; - mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _piece, _opts, parallelOpts) => { + mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _opts, parallelOpts) => { const signal = parallelOpts?.abortSignal; if (signal) startedSignals.push(signal); return new Promise((resolve) => { @@ -308,7 +309,7 @@ describe('runWithWorkerPool', () => { }); const resultPromise = runWithWorkerPool( - runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS, + runner as never, tasks, 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS, ); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -366,7 +367,7 @@ describe('runWithWorkerPool', () => { // When: pollIntervalMs=30 so polling fires before task1 completes (80ms) const result = await runWithWorkerPool( - runner as never, [task1], 2, '/cwd', 'default', undefined, 30, + runner as never, [task1], 2, '/cwd', undefined, 30, ); // Then: Both tasks were executed @@ -399,7 +400,7 @@ describe('runWithWorkerPool', () => { // When const result = await runWithWorkerPool( - runner as never, [task1], 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS, + runner as never, [task1], 1, '/cwd', undefined, TEST_POLL_INTERVAL_MS, ); // Then: Tasks executed sequentially — task2 starts after task1 ends @@ -423,7 +424,7 @@ describe('runWithWorkerPool', () => { // When: Task completes before poll timer fires; cancel() cleans up timer const result = await runWithWorkerPool( - runner as never, [task1], 1, '/cwd', 'default', undefined, 5000, + runner as never, [task1], 1, '/cwd', undefined, 5000, ); // Then: Result is returned without hanging (timer was cleaned up by cancel()) diff --git a/src/__tests__/worktree-exceeded-requeue.test.ts b/src/__tests__/worktree-exceeded-requeue.test.ts index 7cbebce..4fa4571 100644 --- a/src/__tests__/worktree-exceeded-requeue.test.ts +++ b/src/__tests__/worktree-exceeded-requeue.test.ts @@ -99,6 +99,7 @@ function writeExceededRecord(testDir: string, overrides: Record name: 'task-a', status: 'exceeded', content: 'Do work', + piece: 'test-piece', created_at: '2026-02-09T00:00:00.000Z', started_at: '2026-02-09T00:01:00.000Z', completed_at: '2026-02-09T00:05:00.000Z', @@ -166,7 +167,7 @@ describe('シナリオ1・2: exceeded status transition via executeAndCompleteTa it('scenario 1: task transitions to exceeded status when executePiece returns exceeded result', async () => { // Given: a pending task - runner.addTask('Do work'); + runner.addTask('Do work', { piece: 'test-piece' }); const [task] = runner.claimNextTasks(1); if (!task) throw new Error('No task claimed'); @@ -182,7 +183,7 @@ describe('シナリオ1・2: exceeded status transition via executeAndCompleteTa }); // When: executeAndCompleteTask processes the exceeded result - const result = await executeAndCompleteTask(task, runner, testDir, 'test-piece'); + const result = await executeAndCompleteTask(task, runner, testDir); // Then: returns false (task did not succeed) expect(result).toBe(false); @@ -196,7 +197,7 @@ describe('シナリオ1・2: exceeded status transition via executeAndCompleteTa it('scenario 2: exceeded metadata is recorded in tasks.yaml for resumption', async () => { // Given: a pending task - runner.addTask('Do work'); + runner.addTask('Do work', { piece: 'test-piece' }); const [task] = runner.claimNextTasks(1); if (!task) throw new Error('No task claimed'); @@ -212,7 +213,7 @@ describe('シナリオ1・2: exceeded status transition via executeAndCompleteTa }); // When: executeAndCompleteTask records the exceeded result - await executeAndCompleteTask(task, runner, testDir, 'test-piece'); + await executeAndCompleteTask(task, runner, testDir); // Then: YAML contains the three resumption fields const file = loadTasksFile(testDir); @@ -267,7 +268,7 @@ describe('シナリオ3・4: requeue → re-execution passes exceeded metadata t vi.mocked(executePiece).mockResolvedValueOnce({ success: true }); // When: executeAndCompleteTask runs the requeued task - await executeAndCompleteTask(task, runner, testDir, 'test-piece'); + await executeAndCompleteTask(task, runner, testDir); // Then: executePiece received the correct exceeded override options expect(vi.mocked(executePiece)).toHaveBeenCalledOnce(); @@ -297,7 +298,7 @@ describe('シナリオ3・4: requeue → re-execution passes exceeded metadata t vi.mocked(executePiece).mockResolvedValueOnce({ success: true }); // When: executeAndCompleteTask runs the requeued task - await executeAndCompleteTask(task, runner, testDir, 'test-piece'); + await executeAndCompleteTask(task, runner, testDir); // Then: executePiece received startMovement='implement' to resume from where it stopped expect(vi.mocked(executePiece)).toHaveBeenCalledOnce(); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 5e6b880..8ed174c 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -1,7 +1,7 @@ /** * CLI subcommand definitions * - * Registers all named subcommands (run, watch, add, list, switch, clear, eject, prompt, catalog). + * Registers all named subcommands (run, watch, add, list, clear, eject, prompt, catalog). */ import { join } from 'node:path'; @@ -9,7 +9,7 @@ import { clearPersonaSessions, resolveConfigValue } from '../../infra/config/ind import { getGlobalConfigDir } from '../../infra/config/paths.js'; import { success, info } from '../../shared/ui/index.js'; import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js'; -import { switchPiece, ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, resetConfigToDefault, deploySkill } from '../../features/config/index.js'; +import { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, resetConfigToDefault, deploySkill } from '../../features/config/index.js'; import { previewPrompts } from '../../features/prompt/index.js'; import { showCatalog } from '../../features/catalog/index.js'; import { computeReviewMetrics, formatReviewMetrics, parseSinceDuration, purgeOldEvents } from '../../features/analytics/index.js'; @@ -23,8 +23,7 @@ program .command('run') .description('Run all pending tasks from .takt/tasks.yaml') .action(async () => { - const piece = resolveConfigValue(resolvedCwd, 'piece'); - await runAllTasks(resolvedCwd, piece, resolveAgentOverrides(program)); + await runAllTasks(resolvedCwd, resolveAgentOverrides(program)); }); program @@ -65,14 +64,6 @@ program ); }); -program - .command('switch') - .description('Switch piece interactively') - .argument('[piece]', 'Piece name') - .action(async (piece?: string) => { - await switchPiece(resolvedCwd, piece); - }); - program .command('clear') .description('Clear agent conversation sessions') @@ -124,7 +115,7 @@ reset program .command('prompt') .description('Preview assembled prompts for each movement and phase') - .argument('[piece]', 'Piece name or path (defaults to current)') + .argument('[piece]', 'Piece name or path (defaults to "default")') .action(async (piece?: string) => { await previewPrompts(resolvedCwd, piece); }); diff --git a/src/app/cli/helpers.ts b/src/app/cli/helpers.ts index ccbf448..0296381 100644 --- a/src/app/cli/helpers.ts +++ b/src/app/cli/helpers.ts @@ -9,6 +9,8 @@ import type { TaskExecutionOptions } from '../../features/tasks/index.js'; import type { ProviderType } from '../../infra/providers/index.js'; import { isIssueReference } from '../../infra/github/index.js'; +const REMOVED_ROOT_COMMANDS = new Set(['switch']); + /** * Resolve --provider and --model options into TaskExecutionOptions. * Returns undefined if neither is specified. @@ -42,3 +44,25 @@ export function resolveAgentOverrides(program: Command): TaskExecutionOptions | export function isDirectTask(input: string): boolean { return isIssueReference(input) || input.trim().split(/\s+/).every((t: string) => isIssueReference(t)); } + +export function resolveSlashFallbackTask(args: string[], knownCommands: string[]): string | null { + const firstArg = args[0]; + if (!firstArg?.startsWith('/')) { + return null; + } + + const commandName = firstArg.slice(1); + if (knownCommands.includes(commandName)) { + return null; + } + + return args.join(' '); +} + +export function resolveRemovedRootCommand(args: string[]): string | null { + const firstArg = args[0]; + if (!firstArg) { + return null; + } + return REMOVED_ROOT_COMMANDS.has(firstArg) ? firstArg : null; +} diff --git a/src/app/cli/index.ts b/src/app/cli/index.ts index eaf6e81..742bc49 100644 --- a/src/app/cli/index.ts +++ b/src/app/cli/index.ts @@ -9,6 +9,7 @@ import { checkForUpdates } from '../../shared/utils/index.js'; import { getErrorMessage } from '../../shared/utils/error.js'; import { error as errorLog } from '../../shared/ui/index.js'; +import { resolveRemovedRootCommand, resolveSlashFallbackTask } from './helpers.js'; checkForUpdates(); @@ -19,20 +20,20 @@ import { executeDefaultAction } from './routing.js'; (async () => { const args = process.argv.slice(2); - const firstArg = args[0]; + const { operands } = program.parseOptions(args); + const removedRootCommand = resolveRemovedRootCommand(operands); + if (removedRootCommand !== null) { + errorLog(`error: unknown command '${removedRootCommand}'`); + process.exit(1); + } - // Handle '/' prefixed inputs that are not known commands - if (firstArg?.startsWith('/')) { - const commandName = firstArg.slice(1); - const knownCommands = program.commands.map((cmd) => cmd.name()); + const knownCommands = program.commands.map((cmd) => cmd.name()); + const slashFallbackTask = resolveSlashFallbackTask(args, knownCommands); - if (!knownCommands.includes(commandName)) { - // Treat as task instruction - const task = args.join(' '); - await runPreActionHook(); - await executeDefaultAction(task); - process.exit(0); - } + if (slashFallbackTask !== null) { + await runPreActionHook(); + await executeDefaultAction(slashFallbackTask); + process.exit(0); } // Normal parsing for all other cases (including '#' prefixed inputs) diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index e3a9da6..f5500d5 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -19,7 +19,6 @@ import { program, resolvedCwd, pipelineMode } from './program.js'; import { resolveAgentOverrides } from './helpers.js'; import { loadTaskHistory } from './taskHistory.js'; import { resolveIssueInput, resolvePrInput } from './routing-inputs.js'; -import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; export async function executeDefaultAction(task?: string): Promise { const opts = program.opts(); if (!pipelineMode && (opts.autoPr === true || opts.draft === true)) { @@ -39,9 +38,11 @@ export async function executeDefaultAction(task?: string): Promise { process.exit(1); } const agentOverrides = resolveAgentOverrides(program); - const resolvedPipelinePiece = (opts.piece as string | undefined) - ?? resolveConfigValue(resolvedCwd, 'piece') - ?? DEFAULT_PIECE_NAME; + const resolvedPipelinePiece = opts.piece as string | undefined; + if (pipelineMode && resolvedPipelinePiece === undefined) { + logError('--piece (-w) is required in pipeline mode'); + process.exit(1); + } const resolvedPipelineAutoPr = opts.autoPr === true ? true : (resolveConfigValue(resolvedCwd, 'autoPr') ?? false); @@ -57,7 +58,7 @@ export async function executeDefaultAction(task?: string): Promise { issueNumber, prNumber, task: opts.task as string | undefined, - piece: resolvedPipelinePiece, + piece: resolvedPipelinePiece!, branch: opts.branch as string | undefined, autoPr: resolvedPipelineAutoPr, draftPr: resolvedPipelineDraftPr, diff --git a/src/core/models/persisted-global-config.ts b/src/core/models/persisted-global-config.ts index 7473910..b9c7098 100644 --- a/src/core/models/persisted-global-config.ts +++ b/src/core/models/persisted-global-config.ts @@ -92,8 +92,6 @@ export interface PersistedGlobalConfig { language: Language; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; model?: string; - /** Default piece name for new tasks (resolved via config layers: project > global > 'default') */ - piece?: string; /** @globalOnly */ observability?: ObservabilityConfig; analytics?: AnalyticsConfig; @@ -181,7 +179,6 @@ export interface PersistedGlobalConfig { /** Project-level configuration */ export interface ProjectConfig { - piece?: string; verbose?: boolean; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; model?: string; diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 615642c..a428382 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -500,8 +500,6 @@ export const GlobalConfigSchema = z.object({ language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), provider: ProviderReferenceSchema.optional().default('claude'), model: z.string().optional(), - /** Default piece name for new tasks */ - piece: z.string().optional(), observability: ObservabilityConfigSchema.optional(), analytics: AnalyticsConfigSchema.optional(), /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ @@ -572,7 +570,6 @@ export const GlobalConfigSchema = z.object({ /** Project config schema */ export const ProjectConfigSchema = z.object({ - piece: z.string().optional(), log_level: z.enum(['debug', 'info', 'warn', 'error']).optional(), verbose: z.boolean().optional(), provider: ProviderReferenceSchema.optional(), diff --git a/src/features/config/index.ts b/src/features/config/index.ts index db73c75..2041cc5 100644 --- a/src/features/config/index.ts +++ b/src/features/config/index.ts @@ -2,7 +2,6 @@ * Config feature exports */ -export { switchPiece } from './switchPiece.js'; export { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES } from './ejectBuiltin.js'; export { resetCategoriesToDefault } from './resetCategories.js'; export { resetConfigToDefault } from './resetConfig.js'; diff --git a/src/features/config/switchPiece.ts b/src/features/config/switchPiece.ts deleted file mode 100644 index 59d0fe5..0000000 --- a/src/features/config/switchPiece.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Piece switching command - */ - -import { - loadPiece, - resolveConfigValue, - setCurrentPiece, -} from '../../infra/config/index.js'; -import { info, success, error } from '../../shared/ui/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 { - if (!pieceName) { - const current = resolveConfigValue(cwd, 'piece'); - info(`Current piece: ${current}`); - - const selected = await selectPiece(cwd, { fallbackToDefault: false }); - if (!selected) { - info('Cancelled'); - return false; - } - - pieceName = selected; - } - - // Check if piece exists - const config = loadPiece(pieceName, cwd); - - if (!config) { - error(`Piece "${pieceName}" not found`); - return false; - } - - // Save to project config - setCurrentPiece(cwd, pieceName); - success(`Switched to piece: ${pieceName}`); - - return true; -} diff --git a/src/features/pieceSelection/index.ts b/src/features/pieceSelection/index.ts index dac1de9..5ac1934 100644 --- a/src/features/pieceSelection/index.ts +++ b/src/features/pieceSelection/index.ts @@ -16,7 +16,6 @@ import { loadAllPiecesWithSources, getPieceCategories, buildCategorizedPieces, - resolveConfigValue, type PieceDirEntry, type PieceCategoryNode, type CategorizedPieces, @@ -71,16 +70,12 @@ const CATEGORY_VALUE_PREFIX = '__category__:'; */ export function buildTopLevelSelectOptions( items: PieceSelectionItem[], - currentPiece: string, ): SelectionOption[] { return items.map((item) => { if (item.type === 'piece') { - const isCurrent = item.name === currentPiece; - const label = isCurrent ? `${item.name} (current)` : item.name; - return { label, value: item.name }; + return { label: item.name, value: item.name }; } - const containsCurrent = item.pieces.some((w) => w === currentPiece); - const label = containsCurrent ? `📁 ${item.name}/ (current)` : `📁 ${item.name}/`; + const label = `📁 ${item.name}/`; return { label, value: `${CATEGORY_VALUE_PREFIX}${item.name}` }; }); } @@ -102,7 +97,6 @@ export function parseCategorySelection(selected: string): string | null { export function buildCategoryPieceOptions( items: PieceSelectionItem[], categoryName: string, - currentPiece: string, ): SelectionOption[] | null { const categoryItem = items.find( (item) => item.type === 'category' && item.name === categoryName, @@ -111,9 +105,7 @@ export function buildCategoryPieceOptions( return categoryItem.pieces.map((qualifiedName) => { const displayName = qualifiedName.split('/').pop() ?? qualifiedName; - const isCurrent = qualifiedName === currentPiece; - const label = isCurrent ? `${displayName} (current)` : displayName; - return { label, value: qualifiedName }; + return { label: displayName, value: qualifiedName }; }); } @@ -147,18 +139,9 @@ export function warnMissingPieces(missing: MissingPiece[]): void { } } -function categoryContainsPiece(node: PieceCategoryNode, piece: string): boolean { - if (node.pieces.includes(piece)) return true; - for (const child of node.children) { - if (categoryContainsPiece(child, piece)) return true; - } - return false; -} - function buildCategoryLevelOptions( categories: PieceCategoryNode[], pieces: string[], - currentPiece: string, ): { options: SelectionOption[]; categoryMap: Map; @@ -167,18 +150,14 @@ function buildCategoryLevelOptions( const categoryMap = new Map(); for (const category of categories) { - const containsCurrent = currentPiece.length > 0 && categoryContainsPiece(category, currentPiece); - const label = containsCurrent - ? `📁 ${category.name}/ (current)` - : `📁 ${category.name}/`; + const label = `📁 ${category.name}/`; const value = `${CATEGORY_VALUE_PREFIX}${category.name}`; options.push({ label, value }); categoryMap.set(category.name, category); } for (const pieceName of pieces) { - const isCurrent = pieceName === currentPiece; - const label = isCurrent ? `🎼 ${pieceName} (current)` : `🎼 ${pieceName}`; + const label = `🎼 ${pieceName}`; options.push({ label, value: pieceName }); } @@ -187,7 +166,6 @@ function buildCategoryLevelOptions( async function selectPieceFromCategoryTree( categories: PieceCategoryNode[], - currentPiece: string, hasSourceSelection: boolean, rootPieces: string[] = [], ): Promise { @@ -207,7 +185,6 @@ async function selectPieceFromCategoryTree( const { options, categoryMap } = buildCategoryLevelOptions( currentCategories, currentPieces, - currentPiece, ); if (options.length === 0) { @@ -268,33 +245,21 @@ async function selectPieceFromCategoryTree( } } -const CURRENT_PIECE_VALUE = '__current__'; const CUSTOM_CATEGORY_PREFIX = '__custom_category__:'; type TopLevelSelection = - | { type: 'current' } | { type: 'piece'; name: string } | { type: 'category'; node: PieceCategoryNode }; async function selectTopLevelPieceOption( categorized: CategorizedPieces, - currentPiece: string, ): Promise { const buildOptions = (): SelectOptionItem[] => { const options: SelectOptionItem[] = []; const bookmarkedPieces = getBookmarkedPieces(); - // 1. Current piece - if (currentPiece) { - options.push({ - label: `🎼 ${currentPiece} (current)`, - value: CURRENT_PIECE_VALUE, - }); - } - // 2. Bookmarked pieces (individual items) for (const pieceName of bookmarkedPieces) { - if (pieceName === currentPiece) continue; options.push({ label: `🎼 ${pieceName} [*]`, value: pieceName, @@ -316,7 +281,7 @@ async function selectTopLevelPieceOption( const result = await selectOption('Select piece:', buildOptions(), { onKeyPress: (key: string, value: string): SelectOptionItem[] | null => { - if (value === CURRENT_PIECE_VALUE || value.startsWith(CUSTOM_CATEGORY_PREFIX)) { + if (value.startsWith(CUSTOM_CATEGORY_PREFIX)) { return null; } @@ -336,10 +301,6 @@ async function selectTopLevelPieceOption( if (!result) return null; - if (result === CURRENT_PIECE_VALUE) { - return { type: 'current' }; - } - if (result.startsWith(CUSTOM_CATEGORY_PREFIX)) { const categoryName = result.slice(CUSTOM_CATEGORY_PREFIX.length); const node = categorized.categories.find(c => c.name === categoryName); @@ -355,20 +316,16 @@ async function selectTopLevelPieceOption( */ export async function selectPieceFromCategorizedPieces( categorized: CategorizedPieces, - currentPiece: string, ): Promise { while (true) { - const selection = await selectTopLevelPieceOption(categorized, currentPiece); + const selection = await selectTopLevelPieceOption(categorized); if (!selection) return null; - if (selection.type === 'current') return currentPiece; - if (selection.type === 'piece') return selection.name; if (selection.type === 'category') { const piece = await selectPieceFromCategoryTree( selection.node.children, - currentPiece, true, selection.node.pieces, ); @@ -380,7 +337,6 @@ export async function selectPieceFromCategorizedPieces( async function selectPieceFromEntriesWithCategories( entries: PieceDirEntry[], - currentPiece: string, ): Promise { if (entries.length === 0) return null; @@ -390,7 +346,7 @@ async function selectPieceFromEntriesWithCategories( if (!hasCategories) { const baseOptions: SelectionOption[] = availablePieces.map((name) => ({ - label: name === currentPiece ? `🎼 ${name} (current)` : `🎼 ${name}`, + label: `🎼 ${name}`, value: name, })); @@ -415,7 +371,7 @@ async function selectPieceFromEntriesWithCategories( // Loop until user selects a piece or cancels at top level while (true) { const buildTopLevelOptions = (): SelectionOption[] => - applyBookmarks(buildTopLevelSelectOptions(items, currentPiece), getBookmarkedPieces()); + applyBookmarks(buildTopLevelSelectOptions(items), getBookmarkedPieces()); const selected = await selectOption('Select piece:', buildTopLevelOptions(), { onKeyPress: (key: string, value: string): SelectOptionItem[] | null => { @@ -441,7 +397,7 @@ async function selectPieceFromEntriesWithCategories( const categoryName = parseCategorySelection(selected); if (categoryName) { - const categoryOptions = buildCategoryPieceOptions(items, categoryName, currentPiece); + const categoryOptions = buildCategoryPieceOptions(items, categoryName); if (!categoryOptions) continue; const buildCategoryOptions = (): SelectionOption[] => @@ -476,7 +432,6 @@ async function selectPieceFromEntriesWithCategories( */ export async function selectPieceFromEntries( entries: PieceDirEntry[], - currentPiece: string, ): Promise { const builtinEntries = entries.filter((entry) => entry.source === 'builtin'); const customEntries = entries.filter((entry) => entry.source !== 'builtin'); @@ -488,11 +443,11 @@ export async function selectPieceFromEntries( ]); if (!selectedSource) return null; const sourceEntries = selectedSource === 'custom' ? customEntries : builtinEntries; - return selectPieceFromEntriesWithCategories(sourceEntries, currentPiece); + return selectPieceFromEntriesWithCategories(sourceEntries); } const entriesToUse = customEntries.length > 0 ? customEntries : builtinEntries; - return selectPieceFromEntriesWithCategories(entriesToUse, currentPiece); + return selectPieceFromEntriesWithCategories(entriesToUse); } export interface SelectPieceOptions { @@ -505,7 +460,6 @@ export async function selectPiece( ): Promise { const fallbackToDefault = options?.fallbackToDefault !== false; const categoryConfig = getPieceCategories(cwd); - const currentPiece = resolveConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME; if (categoryConfig) { const allPieces = loadAllPiecesWithSources(cwd); @@ -519,7 +473,7 @@ export async function selectPiece( } const categorized = buildCategorizedPieces(allPieces, categoryConfig, cwd); warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user')); - return selectPieceFromCategorizedPieces(categorized, currentPiece); + return selectPieceFromCategorizedPieces(categorized); } const availablePieces = listPieces(cwd); @@ -533,5 +487,5 @@ export async function selectPiece( } const entries = listPieceEntries(cwd); - return selectPieceFromEntries(entries, currentPiece); + return selectPieceFromEntries(entries); } diff --git a/src/features/prompt/preview.ts b/src/features/prompt/preview.ts index 453747c..5a6df8a 100644 --- a/src/features/prompt/preview.ts +++ b/src/features/prompt/preview.ts @@ -22,7 +22,7 @@ import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; * the Phase 1, Phase 2, and Phase 3 prompts with sample variable values. */ export async function previewPrompts(cwd: string, pieceIdentifier?: string): Promise { - const identifier = pieceIdentifier ?? resolvePieceConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME; + const identifier = pieceIdentifier ?? DEFAULT_PIECE_NAME; const config = loadPieceByIdentifier(identifier, cwd); if (!config) { diff --git a/src/features/tasks/execute/parallelExecution.ts b/src/features/tasks/execute/parallelExecution.ts index da47da4..b4f4aa7 100644 --- a/src/features/tasks/execute/parallelExecution.ts +++ b/src/features/tasks/execute/parallelExecution.ts @@ -93,7 +93,6 @@ export async function runWithWorkerPool( initialTasks: TaskInfo[], concurrency: number, cwd: string, - pieceName: string, options: TaskExecutionOptions | undefined, pollIntervalMs: number, ): Promise { @@ -119,7 +118,7 @@ export async function runWithWorkerPool( try { while (queue.length > 0 || active.size > 0) { if (!abortController.signal.aborted) { - fillSlots(queue, active, concurrency, taskRunner, cwd, pieceName, options, abortController, colorCounter); + fillSlots(queue, active, concurrency, taskRunner, cwd, options, abortController, colorCounter); if ((selfSigintOnce || selfSigintTwice) && !selfSigintInjected && active.size > 0) { selfSigintInjected = true; process.emit('SIGINT'); @@ -197,7 +196,6 @@ function fillSlots( concurrency: number, taskRunner: TaskRunner, cwd: string, - pieceName: string, options: TaskExecutionOptions | undefined, abortController: AbortController, colorCounter: { value: number }, @@ -223,7 +221,7 @@ function fillSlots( info(`=== Task: ${task.name} ===`); } - const promise = executeAndCompleteTask(task, taskRunner, cwd, pieceName, options, { + const promise = executeAndCompleteTask(task, taskRunner, cwd, options, { abortSignal: abortController.signal, taskPrefix: isParallel ? taskPrefix : undefined, taskColorIndex: isParallel ? colorIndex : undefined, diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 9449629..1fec5ce 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -87,14 +87,17 @@ export function resolveTaskIssue(issueNumber: number | undefined): Issue[] | und export async function resolveTaskExecution( task: TaskInfo, defaultCwd: string, - defaultPiece: string, abortSignal?: AbortSignal, ): Promise { throwIfAborted(abortSignal); const data = task.data; if (!data) { - return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false, autoPr: false, draftPr: false }; + throw new Error(`Task "${task.name}" is missing required data, including piece.`); + } + + if (!data.piece || typeof data.piece !== 'string' || data.piece.trim() === '') { + throw new Error(`Task "${task.name}" is missing required piece.`); } let execCwd = defaultCwd; @@ -153,7 +156,7 @@ export async function resolveTaskExecution( taskPrompt = stageTaskSpecForExecution(defaultCwd, execCwd, task.taskDir, reportDirName); } - const execPiece = data.piece || defaultPiece; + const execPiece = data.piece; const startMovement = data.start_movement; const retryNote = data.retry_note; const maxMovementsOverride = data.exceeded_max_movements; diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 05219b4..f456bc0 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -14,7 +14,6 @@ import { import { createLogger, getErrorMessage, getSlackWebhookUrl, notifyError, notifySuccess, sendSlackNotification, buildSlackRunSummary } from '../../../shared/utils/index.js'; 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 { runWithWorkerPool } from './parallelExecution.js'; import { resolveTaskExecution, resolveTaskIssue } from './resolveTask.js'; @@ -26,6 +25,13 @@ export type { TaskExecutionOptions, ExecuteTaskOptions }; const log = createLogger('task'); +type TaskExecutionParallelOptions = { + abortSignal?: AbortSignal; + taskPrefix?: string; + taskColorIndex?: number; + taskDisplayLabel?: string; +}; + async function executeTaskWithResult(options: ExecuteTaskOptions): Promise { const { task, @@ -54,7 +60,7 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise { const startedAt = new Date().toISOString(); const taskAbortController = new AbortController(); @@ -147,7 +152,7 @@ export async function executeAndCompleteTask( issueNumber, maxMovementsOverride, initialIterationOverride, - } = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal); + } = await resolveTaskExecution(task, cwd, taskAbortSignal); // cwd is always the project root; pass it as projectCwd so reports/sessions go there const taskRunResult = await executeTaskWithResult({ @@ -155,7 +160,7 @@ export async function executeAndCompleteTask( cwd: execCwd, pieceIdentifier: execPiece, projectCwd: cwd, - agentOverrides: options, + agentOverrides: taskExecutionOptions, startMovement, retryNote, reportDirName, @@ -233,7 +238,6 @@ export async function executeAndCompleteTask( */ export async function runAllTasks( cwd: string, - pieceName: string = DEFAULT_PIECE_NAME, options?: TaskExecutionOptions, ): Promise { const taskRunner = new TaskRunner(cwd); @@ -286,7 +290,14 @@ export async function runAllTasks( }; try { - const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs); + const result = await runWithWorkerPool( + taskRunner, + initialTasks, + concurrency, + cwd, + options, + globalConfig.taskPollIntervalMs, + ); const totalCount = result.success + result.fail; blankLine(); diff --git a/src/features/tasks/list/prepareTaskForExecution.ts b/src/features/tasks/list/prepareTaskForExecution.ts new file mode 100644 index 0000000..dd193e2 --- /dev/null +++ b/src/features/tasks/list/prepareTaskForExecution.ts @@ -0,0 +1,15 @@ +import type { TaskInfo } from '../../../infra/task/index.js'; + +export function prepareTaskForExecution(taskInfo: TaskInfo, selectedPiece: string): TaskInfo { + if (!taskInfo.data) { + throw new Error(`Task "${taskInfo.name}" is missing required data.`); + } + + return { + ...taskInfo, + data: { + ...taskInfo.data, + piece: selectedPiece, + }, + }; +} diff --git a/src/features/tasks/list/taskInstructionActions.ts b/src/features/tasks/list/taskInstructionActions.ts index d4445bd..820c7ff 100644 --- a/src/features/tasks/list/taskInstructionActions.ts +++ b/src/features/tasks/list/taskInstructionActions.ts @@ -27,6 +27,7 @@ import { selectRunSessionContext, } from './requeueHelpers.js'; import { executeAndCompleteTask } from '../execute/taskExecution.js'; +import { prepareTaskForExecution } from './prepareTaskForExecution.js'; const log = createLogger('list-tasks'); @@ -128,6 +129,7 @@ export async function instructBranch( const retryNote = appendRetryNote(target.data?.retry_note, instruction); const runner = new TaskRunner(projectDir); const taskInfo = runner.startReExecution(target.name, ['completed', 'failed'], undefined, retryNote); + const taskForExecution = prepareTaskForExecution(taskInfo, selectedPiece); log.info('Starting re-execution of instructed task', { name: target.name, @@ -136,7 +138,7 @@ export async function instructBranch( piece: selectedPiece, }); - return executeAndCompleteTask(taskInfo, runner, projectDir, selectedPiece); + return executeAndCompleteTask(taskForExecution, runner, projectDir); }; return dispatchConversationAction(result, { diff --git a/src/features/tasks/list/taskRetryActions.ts b/src/features/tasks/list/taskRetryActions.ts index 2ff1d40..b385d6f 100644 --- a/src/features/tasks/list/taskRetryActions.ts +++ b/src/features/tasks/list/taskRetryActions.ts @@ -32,6 +32,7 @@ import { DEPRECATED_PROVIDER_CONFIG_WARNING, hasDeprecatedProviderConfig, } from './requeueHelpers.js'; +import { prepareTaskForExecution } from './prepareTaskForExecution.js'; const log = createLogger('list-tasks'); @@ -227,6 +228,7 @@ export async function retryFailedTask( } const taskInfo = runner.startReExecution(task.name, ['failed'], startMovement, retryNote); + const taskForExecution = prepareTaskForExecution(taskInfo, selectedPiece); log.info('Starting re-execution of failed task', { name: task.name, @@ -234,5 +236,5 @@ export async function retryFailedTask( startMovement, }); - return executeAndCompleteTask(taskInfo, runner, projectDir, selectedPiece); + return executeAndCompleteTask(taskForExecution, runner, projectDir); } diff --git a/src/features/tasks/watch/index.ts b/src/features/tasks/watch/index.ts index 2e5a8fd..ddb098d 100644 --- a/src/features/tasks/watch/index.ts +++ b/src/features/tasks/watch/index.ts @@ -6,7 +6,6 @@ */ import { TaskRunner, type TaskInfo, TaskWatcher } from '../../../infra/task/index.js'; -import { resolveConfigValue } from '../../../infra/config/index.js'; import { header, info, @@ -18,14 +17,12 @@ import { executeAndCompleteTask } from '../execute/taskExecution.js'; import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; import { ShutdownManager } from '../execute/shutdownManager.js'; import type { TaskExecutionOptions } from '../execute/types.js'; -import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; /** * Watch for tasks and execute them as they appear. * Runs until Ctrl+C. */ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise { - const pieceName = resolveConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME; const taskRunner = new TaskRunner(cwd); const watcher = new TaskWatcher(cwd); const recovered = taskRunner.recoverInterruptedRunningTasks(); @@ -35,7 +32,6 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P let failCount = 0; header('TAKT Watch Mode'); - info(`Piece: ${pieceName}`); info(`Watching: ${taskRunner.getTasksFilePath()}`); if (recovered > 0) { info(`Recovered ${recovered} interrupted running task(s) to pending.`); @@ -65,7 +61,7 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P info(`=== Task ${taskCount}: ${task.name} ===`); blankLine(); - const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, pieceName, options); + const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, options); if (taskSuccess) { successCount++; diff --git a/src/index.ts b/src/index.ts index e8ba0ae..adde376 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,6 @@ export type { PieceSource, PieceWithSource, PieceDirEntry } from './infra/config export { saveProjectConfig, updateProjectConfig, - setCurrentPiece, isVerboseMode, type ProjectLocalConfig, } from './infra/config/project/index.js'; diff --git a/src/infra/config/env/config-env-overrides.ts b/src/infra/config/env/config-env-overrides.ts index 45fd57f..edfc8af 100644 --- a/src/infra/config/env/config-env-overrides.ts +++ b/src/infra/config/env/config-env-overrides.ts @@ -126,7 +126,6 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [ ]; const PROJECT_ENV_SPECS: readonly EnvSpec[] = [ - { path: 'piece', type: 'string' }, { path: 'log_level', type: 'string' }, { path: 'provider', type: 'string' }, { path: 'model', type: 'string' }, diff --git a/src/infra/config/global/globalConfigCore.ts b/src/infra/config/global/globalConfigCore.ts index ae2fae2..0953970 100644 --- a/src/infra/config/global/globalConfigCore.ts +++ b/src/infra/config/global/globalConfigCore.ts @@ -79,7 +79,6 @@ export class GlobalConfigManager { language: parsed.language, provider: normalizedProvider.provider, model: normalizedProvider.model, - piece: parsed.piece, observability: parsed.observability ? { providerEvents: parsed.observability.provider_events, } : undefined, @@ -149,9 +148,6 @@ export class GlobalConfigManager { if (config.model) { raw.model = config.model; } - if (config.piece) { - raw.piece = config.piece; - } if (config.observability && config.observability.providerEvents !== undefined) { raw.observability = { provider_events: config.observability.providerEvents, diff --git a/src/infra/config/paths.ts b/src/infra/config/paths.ts index 09ba533..9cbb89a 100644 --- a/src/infra/config/paths.ts +++ b/src/infra/config/paths.ts @@ -145,7 +145,6 @@ export { loadProjectConfig, saveProjectConfig, updateProjectConfig, - setCurrentPiece, type ProjectLocalConfig, } from './project/projectConfig.js'; export { diff --git a/src/infra/config/project/index.ts b/src/infra/config/project/index.ts index db97287..7cf100e 100644 --- a/src/infra/config/project/index.ts +++ b/src/infra/config/project/index.ts @@ -6,7 +6,6 @@ export { loadProjectConfig, saveProjectConfig, updateProjectConfig, - setCurrentPiece, type ProjectLocalConfig, } from './projectConfig.js'; export { diff --git a/src/infra/config/project/projectConfig.ts b/src/infra/config/project/projectConfig.ts index c122eff..e4f06ec 100644 --- a/src/infra/config/project/projectConfig.ts +++ b/src/infra/config/project/projectConfig.ts @@ -283,7 +283,3 @@ export function updateProjectConfig( config[key] = value; saveProjectConfig(projectDir, config); } - -export function setCurrentPiece(projectDir: string, piece: string): void { - updateProjectConfig(projectDir, 'piece', piece); -} diff --git a/src/infra/config/resolveConfigValue.ts b/src/infra/config/resolveConfigValue.ts index 6207bd4..6a31114 100644 --- a/src/infra/config/resolveConfigValue.ts +++ b/src/infra/config/resolveConfigValue.ts @@ -75,7 +75,6 @@ const MIGRATED_PROJECT_LOCAL_CONFIG_KEY_SET = new Set( ); const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule }> = { - piece: { layers: ['local', 'global'] }, provider: { layers: ['local', 'piece', 'global'], pieceValue: (pieceContext) => pieceContext?.provider, diff --git a/src/infra/config/resolvedConfig.ts b/src/infra/config/resolvedConfig.ts index 54fce07..0d0eeb4 100644 --- a/src/infra/config/resolvedConfig.ts +++ b/src/infra/config/resolvedConfig.ts @@ -5,7 +5,6 @@ import type { MigratedProjectLocalConfigKey } from './migratedProjectLocalKeys.j export interface LoadedConfig extends PersistedGlobalConfig, Pick { - piece?: string; logLevel: NonNullable; minimalOutput: NonNullable; verbose: NonNullable; diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index 6aa45ad..e5a6a5d 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -14,8 +14,6 @@ import type { /** Project configuration stored in .takt/config.yaml */ export interface ProjectLocalConfig { - /** Current piece name */ - piece?: string; /** Provider selection for agent runtime */ provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; /** Model selection for agent runtime */