Merge pull request #465 from nrslib/takt/420/remove-default-piece-switch

feat: デフォルトピースの概念と takt switch コマンドを削除
This commit is contained in:
nrs 2026-03-04 18:02:28 +09:00 committed by GitHub
parent 9fc8ab73fd
commit 4f02c20c1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 498 additions and 587 deletions

View File

@ -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 watch` | Watch `.takt/tasks/` and auto-execute tasks (resident process) |
| `takt add [task]` | Add a new task via AI conversation | | `takt add [task]` | Add a new task via AI conversation |
| `takt list` | List task branches (merge, delete, retry) | | `takt list` | List task branches (merge, delete, retry) |
| `takt switch [piece]` | Switch piece interactively |
| `takt clear` | Clear agent conversation sessions (reset state) | | `takt clear` | Clear agent conversation sessions (reset state) |
| `takt eject [type] [name]` | Copy builtin piece or facet for customization (`--global` for ~/.takt/) | | `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 | | `takt prompt [piece]` | Preview assembled prompts for each movement and phase |

View File

@ -51,4 +51,36 @@ describe('E2E: Help command (takt --help)', () => {
const output = result.stdout.toLowerCase(); const output = result.stdout.toLowerCase();
expect(output).toMatch(/run|task|pending/); 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);
});
}); });

View File

@ -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);
});
});

View File

@ -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 configuredPiecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
const projectConfigDir = join(testRepo.path, '.takt'); const projectConfigDir = join(testRepo.path, '.takt');
@ -70,9 +70,8 @@ describe('E2E: Config priority (piece / autoPr)', () => {
timeout: 240_000, timeout: 240_000,
}); });
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(1);
expect(result.stdout).toContain(`Running piece: ${configuredPiecePath}`); expect(`${result.stdout}${result.stderr}`).toContain('piece');
expect(result.stdout).toContain(`Piece '${configuredPiecePath}' completed`);
}, 240_000); }, 240_000);
it('should default auto_pr to true when unset in config/env', () => { it('should default auto_pr to true when unset in config/env', () => {

View File

@ -81,15 +81,11 @@ vi.mock('../infra/task/index.js', () => ({
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })), 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' })), resolveConfigValues: vi.fn(() => ({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' })),
resolveConfigValue: vi.fn(() => undefined),
loadPersonaSessions: vi.fn(() => ({})), loadPersonaSessions: vi.fn(() => ({})),
})); }));
vi.mock('../shared/constants.js', () => ({
DEFAULT_PIECE_NAME: 'default',
}));
const mockOpts: Record<string, unknown> = {}; const mockOpts: Record<string, unknown> = {};
vi.mock('../app/cli/program.js', () => { 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 { selectAndExecuteTask, determinePiece, saveTaskFromInteractive } from '../features/tasks/index.js';
import { interactiveMode } from '../features/interactive/index.js'; import { interactiveMode } from '../features/interactive/index.js';
import { executePipeline } from '../features/pipeline/index.js'; import { executePipeline } from '../features/pipeline/index.js';
import { resolveConfigValue } from '../infra/config/index.js';
import { executeDefaultAction } from '../app/cli/routing.js'; import { executeDefaultAction } from '../app/cli/routing.js';
import { error as logError } from '../shared/ui/index.js'; import { error as logError } from '../shared/ui/index.js';
import type { InteractiveModeResult } from '../features/interactive/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 mockDeterminePiece = vi.mocked(determinePiece);
const mockInteractiveMode = vi.mocked(interactiveMode); const mockInteractiveMode = vi.mocked(interactiveMode);
const mockExecutePipeline = vi.mocked(executePipeline); const mockExecutePipeline = vi.mocked(executePipeline);
const mockResolveConfigValue = vi.mocked(resolveConfigValue);
const mockLogError = vi.mocked(logError); const mockLogError = vi.mocked(logError);
const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive); const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive);
@ -148,7 +142,6 @@ beforeEach(() => {
} }
mockDeterminePiece.mockResolvedValue('default'); mockDeterminePiece.mockResolvedValue('default');
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' }); mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' });
mockResolveConfigValue.mockImplementation((_: string, key: string) => (key === 'piece' ? 'default' : false));
mockListAllTaskItems.mockReturnValue([]); mockListAllTaskItems.mockReturnValue([]);
mockIsStaleRunningTask.mockReturnValue(false); mockIsStaleRunningTask.mockReturnValue(false);
}); });
@ -343,6 +336,7 @@ describe('PR resolution in routing', () => {
Object.defineProperty(programModule, 'pipelineMode', { value: true, writable: true }); Object.defineProperty(programModule, 'pipelineMode', { value: true, writable: true });
mockOpts.pr = 456; mockOpts.pr = 456;
mockOpts.piece = 'default';
mockExecutePipeline.mockResolvedValue(0); mockExecutePipeline.mockResolvedValue(0);
// When // When
@ -359,22 +353,24 @@ describe('PR resolution in routing', () => {
Object.defineProperty(programModule, 'pipelineMode', { value: originalPipelineMode, writable: true }); 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 programModule = await import('../app/cli/program.js');
const originalPipelineMode = programModule.pipelineMode; const originalPipelineMode = programModule.pipelineMode;
Object.defineProperty(programModule, 'pipelineMode', { value: true, writable: true }); Object.defineProperty(programModule, 'pipelineMode', { value: true, writable: true });
mockOpts.pr = 456; mockOpts.pr = 456;
mockExecutePipeline.mockResolvedValue(0); const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
mockResolveConfigValue.mockImplementation((_: string, key: string) => (key === 'piece' ? undefined : false)); throw new Error('process.exit called');
});
await executeDefaultAction(); await expect(executeDefaultAction()).rejects.toThrow('process.exit called');
expect(mockExecutePipeline).toHaveBeenCalledWith( expect(mockExit).toHaveBeenCalledWith(1);
expect.objectContaining({ expect(mockLogError).toHaveBeenCalledWith(
piece: 'default', expect.stringContaining('piece'),
}),
); );
expect(mockExecutePipeline).not.toHaveBeenCalled();
mockExit.mockRestore();
Object.defineProperty(programModule, 'pipelineMode', { value: originalPipelineMode, writable: true }); Object.defineProperty(programModule, 'pipelineMode', { value: originalPipelineMode, writable: true });
}); });

View File

@ -8,7 +8,8 @@
*/ */
import { describe, it, expect } from 'vitest'; 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('isDirectTask', () => {
describe('slash prefixed inputs', () => { 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',
});
});
});

View File

@ -45,7 +45,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({
vi.mock('../infra/config/project/projectConfig.js', async (importOriginal) => ({ vi.mock('../infra/config/project/projectConfig.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()), ...(await importOriginal<Record<string, unknown>>()),
loadProjectConfig: vi.fn(() => ({ piece: 'default' })), loadProjectConfig: vi.fn(() => ({})),
})); }));
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
@ -59,7 +59,7 @@ const mockLoadProjectConfig = vi.mocked(loadProjectConfig);
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockLoadProjectConfig.mockReturnValue({ piece: 'default' }); mockLoadProjectConfig.mockReturnValue({});
}); });
describe('cloneAndIsolate git config propagation', () => { describe('cloneAndIsolate git config propagation', () => {
@ -598,7 +598,7 @@ describe('clone submodule arguments', () => {
} }
it('should append recurse flag when submodules is all', () => { it('should append recurse flag when submodules is all', () => {
mockLoadProjectConfig.mockReturnValue({ piece: 'default', submodules: 'all' }); mockLoadProjectConfig.mockReturnValue({ submodules: 'all' });
const cloneCalls = setupCloneArgsCapture(); const cloneCalls = setupCloneArgsCapture();
createSharedClone('/project', { createSharedClone('/project', {
@ -611,7 +611,7 @@ describe('clone submodule arguments', () => {
}); });
it('should append path-scoped recurse flags when submodules is explicit list', () => { 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(); const cloneCalls = setupCloneArgsCapture();
createSharedClone('/project', { createSharedClone('/project', {
@ -629,7 +629,7 @@ describe('clone submodule arguments', () => {
}); });
it('should append recurse flag when withSubmodules is true and submodules is unset', () => { 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(); const cloneCalls = setupCloneArgsCapture();
createSharedClone('/project', { createSharedClone('/project', {
@ -647,7 +647,7 @@ describe('clone submodule arguments', () => {
}); });
it('should keep existing clone args when submodule acquisition is disabled', () => { it('should keep existing clone args when submodule acquisition is disabled', () => {
mockLoadProjectConfig.mockReturnValue({ piece: 'default', withSubmodules: false }); mockLoadProjectConfig.mockReturnValue({ withSubmodules: false });
const cloneCalls = setupCloneArgsCapture(); const cloneCalls = setupCloneArgsCapture();
createSharedClone('/project', { createSharedClone('/project', {

View File

@ -3,8 +3,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockOpts: Record<string, unknown> = {}; const mockOpts: Record<string, unknown> = {};
const mockAddTask = vi.fn(); const mockAddTask = vi.fn();
const { rootCommand, commandActions } = vi.hoisted(() => { const { rootCommand, commandActions, commandMocks } = vi.hoisted(() => {
const commandActions = new Map<string, (...args: unknown[]) => void>(); const commandActions = new Map<string, (...args: unknown[]) => void>();
const commandMocks = new Map<string, Record<string, unknown>>();
function createCommandMock(actionKey: string): { function createCommandMock(actionKey: string): {
description: ReturnType<typeof vi.fn>; description: ReturnType<typeof vi.fn>;
@ -20,6 +21,7 @@ const { rootCommand, commandActions } = vi.hoisted(() => {
option: vi.fn().mockReturnThis(), option: vi.fn().mockReturnThis(),
opts: vi.fn(() => mockOpts), opts: vi.fn(() => mockOpts),
}; };
commandMocks.set(actionKey, command);
command.command = vi.fn((subName: string) => createCommandMock(`${actionKey}.${subName}`)); command.command = vi.fn((subName: string) => createCommandMock(`${actionKey}.${subName}`));
command.action = vi.fn((action: (...args: unknown[]) => void) => { command.action = vi.fn((action: (...args: unknown[]) => void) => {
@ -40,6 +42,7 @@ const { rootCommand, commandActions } = vi.hoisted(() => {
return { return {
rootCommand: createCommandMock('root'), rootCommand: createCommandMock('root'),
commandActions, commandActions,
commandMocks,
}; };
}); });
@ -71,7 +74,6 @@ vi.mock('../features/tasks/index.js', () => ({
vi.mock('../features/config/index.js', () => ({ vi.mock('../features/config/index.js', () => ({
clearPersonaSessions: vi.fn(), clearPersonaSessions: vi.fn(),
switchPiece: vi.fn(),
ejectBuiltin: vi.fn(), ejectBuiltin: vi.fn(),
ejectFacet: vi.fn(), ejectFacet: vi.fn(),
parseFacetType: vi.fn(), parseFacetType: vi.fn(),
@ -112,7 +114,7 @@ import '../app/cli/commands.js';
describe('CLI add command', () => { describe('CLI add command', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); mockAddTask.mockClear();
for (const key of Object.keys(mockOpts)) { for (const key of Object.keys(mockOpts)) {
delete mockOpts[key]; delete mockOpts[key];
} }
@ -141,4 +143,20 @@ describe('CLI add command', () => {
expect(mockAddTask).toHaveBeenCalledWith('/test/cwd', 'Regular task', undefined); 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")',
);
});
}); });

View File

@ -13,7 +13,6 @@ import {
loadPiece, loadPiece,
listPieces, listPieces,
loadPersonaPromptFromPath, loadPersonaPromptFromPath,
setCurrentPiece,
getProjectConfigDir, getProjectConfigDir,
getBuiltinPersonasDir, getBuiltinPersonasDir,
loadInputHistory, 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', () => { describe('loadProjectConfig provider_options', () => {
let testDir: string; let testDir: string;
@ -413,7 +349,6 @@ describe('loadProjectConfig provider_options', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), [ writeFileSync(join(projectConfigDir, 'config.yaml'), [
'piece: default',
'provider_options:', 'provider_options:',
' codex:', ' codex:',
' network_access: true', ' network_access: true',
@ -450,7 +385,6 @@ describe('loadProjectConfig provider_options', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), [ writeFileSync(join(projectConfigDir, 'config.yaml'), [
'piece: default',
'provider:', 'provider:',
' type: claude', ' type: claude',
' network_access: true', ' network_access: true',
@ -463,7 +397,6 @@ describe('loadProjectConfig provider_options', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), [ writeFileSync(join(projectConfigDir, 'config.yaml'), [
'piece: default',
'provider:', 'provider:',
' type: codex', ' type: codex',
' model: gpt-5.3', ' model: gpt-5.3',
@ -483,7 +416,6 @@ describe('loadProjectConfig provider_options', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), [ writeFileSync(join(projectConfigDir, 'config.yaml'), [
'piece: default',
'provider:', 'provider:',
' type: codex', ' type: codex',
' sandbox:', ' sandbox:',
@ -497,7 +429,6 @@ describe('loadProjectConfig provider_options', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), [ writeFileSync(join(projectConfigDir, 'config.yaml'), [
'piece: default',
'provider:', 'provider:',
' type: codex', ' type: codex',
' unknown_option: true', ' unknown_option: true',
@ -510,7 +441,6 @@ describe('loadProjectConfig provider_options', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), [ writeFileSync(join(projectConfigDir, 'config.yaml'), [
'piece: default',
'provider: invalid-provider', 'provider: invalid-provider',
].join('\n')); ].join('\n'));
@ -571,7 +501,6 @@ describe('analytics config resolution', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), [ writeFileSync(join(projectConfigDir, 'config.yaml'), [
'piece: default',
'analytics:', 'analytics:',
' enabled: false', ' enabled: false',
' events_path: .takt/project-analytics/events', ' events_path: .takt/project-analytics/events',
@ -614,7 +543,6 @@ describe('analytics config resolution', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), [ writeFileSync(join(projectConfigDir, 'config.yaml'), [
'piece: default',
'analytics:', 'analytics:',
' events_path: /tmp/project-analytics', ' events_path: /tmp/project-analytics',
' retention_days: 14', ' retention_days: 14',
@ -664,7 +592,7 @@ describe('isVerboseMode', () => {
it('should return project verbose when project config has verbose: true', () => { it('should return project verbose when project config has verbose: true', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); 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!; const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true }); mkdirSync(globalConfigDir, { recursive: true });
@ -676,7 +604,7 @@ describe('isVerboseMode', () => {
it('should return project verbose when project config has verbose: false', () => { it('should return project verbose when project config has verbose: false', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); 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!; const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true }); mkdirSync(globalConfigDir, { recursive: true });
@ -688,7 +616,7 @@ describe('isVerboseMode', () => {
it('should use default verbose=false when project verbose is not set', () => { it('should use default verbose=false when project verbose is not set', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\n'); writeFileSync(join(projectConfigDir, 'config.yaml'), '');
const globalConfigDir = process.env.TAKT_CONFIG_DIR!; const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true }); mkdirSync(globalConfigDir, { recursive: true });
@ -704,7 +632,7 @@ describe('isVerboseMode', () => {
it('should prioritize TAKT_VERBOSE over project and global config', () => { it('should prioritize TAKT_VERBOSE over project and global config', () => {
const projectConfigDir = getProjectConfigDir(testDir); const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true }); 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!; const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true }); mkdirSync(globalConfigDir, { recursive: true });
@ -954,7 +882,7 @@ describe('saveProjectConfig - gitignore copy', () => {
}); });
it('should copy .gitignore when creating new config', () => { it('should copy .gitignore when creating new config', () => {
setCurrentPiece(testDir, 'test'); saveProjectConfig(testDir, {});
const configDir = getProjectConfigDir(testDir); const configDir = getProjectConfigDir(testDir);
const gitignorePath = join(configDir, '.gitignore'); const gitignorePath = join(configDir, '.gitignore');
@ -966,10 +894,10 @@ describe('saveProjectConfig - gitignore copy', () => {
// Create config directory without .gitignore // Create config directory without .gitignore
const configDir = getProjectConfigDir(testDir); const configDir = getProjectConfigDir(testDir);
mkdirSync(configDir, { recursive: true }); mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'piece: existing\n'); writeFileSync(join(configDir, 'config.yaml'), '');
// Save config should still copy .gitignore // Save config should still copy .gitignore
setCurrentPiece(testDir, 'updated'); saveProjectConfig(testDir, {});
const gitignorePath = join(configDir, '.gitignore'); const gitignorePath = join(configDir, '.gitignore');
expect(existsSync(gitignorePath)).toBe(true); expect(existsSync(gitignorePath)).toBe(true);
@ -981,7 +909,7 @@ describe('saveProjectConfig - gitignore copy', () => {
const customContent = '# Custom gitignore\nmy-custom-file'; const customContent = '# Custom gitignore\nmy-custom-file';
writeFileSync(join(configDir, '.gitignore'), customContent); writeFileSync(join(configDir, '.gitignore'), customContent);
setCurrentPiece(testDir, 'test'); saveProjectConfig(testDir, {});
const gitignorePath = join(configDir, '.gitignore'); const gitignorePath = join(configDir, '.gitignore');
const content = readFileSync(gitignorePath, 'utf-8'); 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', () => { it('should persist autoPr as auto_pr and reload correctly', () => {
saveProjectConfig(testDir, { piece: 'default', autoPr: true }); saveProjectConfig(testDir, { autoPr: true });
const saved = loadProjectConfig(testDir); const saved = loadProjectConfig(testDir);
@ -1445,7 +1373,7 @@ describe('saveProjectConfig snake_case denormalization', () => {
}); });
it('should persist draftPr as draft_pr and reload correctly', () => { it('should persist draftPr as draft_pr and reload correctly', () => {
saveProjectConfig(testDir, { piece: 'default', draftPr: true }); saveProjectConfig(testDir, { draftPr: true });
const saved = loadProjectConfig(testDir); const saved = loadProjectConfig(testDir);
@ -1454,7 +1382,7 @@ describe('saveProjectConfig snake_case denormalization', () => {
}); });
it('should persist baseBranch as base_branch and reload correctly', () => { it('should persist baseBranch as base_branch and reload correctly', () => {
saveProjectConfig(testDir, { piece: 'default', baseBranch: 'main' }); saveProjectConfig(testDir, { baseBranch: 'main' });
const saved = loadProjectConfig(testDir); const saved = loadProjectConfig(testDir);
@ -1463,7 +1391,7 @@ describe('saveProjectConfig snake_case denormalization', () => {
}); });
it('should persist withSubmodules as with_submodules and reload correctly', () => { it('should persist withSubmodules as with_submodules and reload correctly', () => {
saveProjectConfig(testDir, { piece: 'default', withSubmodules: true }); saveProjectConfig(testDir, { withSubmodules: true });
const saved = loadProjectConfig(testDir); 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', () => { 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 projectConfigDir = getProjectConfigDir(testDir);
const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8'); 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', () => { it('should persist concurrency and reload correctly', () => {
saveProjectConfig(testDir, { piece: 'default', concurrency: 3 }); saveProjectConfig(testDir, { concurrency: 3 });
const saved = loadProjectConfig(testDir); const saved = loadProjectConfig(testDir);
@ -1493,7 +1421,7 @@ describe('saveProjectConfig snake_case denormalization', () => {
}); });
it('should not write camelCase keys to YAML file', () => { 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 projectConfigDir = getProjectConfigDir(testDir);
const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8'); const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8');

View File

@ -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', () => { it('should resolve migrated keys from global when project config does not set them', () => {
writeFileSync( writeFileSync(
join(projectDir, '.takt', 'config.yaml'), join(projectDir, '.takt', 'config.yaml'),
'piece: default\n', '',
'utf-8', 'utf-8',
); );
invalidateGlobalConfigCache(); 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', () => { it('should mark migrated key source as global when only global defines the key', () => {
writeFileSync( writeFileSync(
join(projectDir, '.takt', 'config.yaml'), join(projectDir, '.takt', 'config.yaml'),
'piece: default\n', '',
'utf-8', 'utf-8',
); );
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();

View File

@ -36,6 +36,8 @@ import { TaskRunner } from '../infra/task/index.js';
import { runAgent } from '../agents/runner.js'; import { runAgent } from '../agents/runner.js';
import { invalidateGlobalConfigCache } from '../infra/config/index.js'; import { invalidateGlobalConfigCache } from '../infra/config/index.js';
const runAllTasksNoPiece = runAllTasks as (projectCwd: string) => ReturnType<typeof runAllTasks>;
interface TestEnv { interface TestEnv {
root: string; root: string;
projectDir: string; projectDir: string;
@ -107,7 +109,7 @@ describe('IT: runAllTasks provider_options reflection', () => {
vi.mocked(runAgent).mockResolvedValue(mockDoneResponse()); vi.mocked(runAgent).mockResolvedValue(mockDoneResponse());
const runner = new TaskRunner(env.projectDir); const runner = new TaskRunner(env.projectDir);
runner.addTask('test task'); runner.addTask('test task', { piece: 'run-config-it' });
}); });
afterEach(() => { afterEach(() => {
@ -137,7 +139,7 @@ describe('IT: runAllTasks provider_options reflection', () => {
' network_access: false', ' network_access: false',
].join('\n')); ].join('\n'));
await runAllTasks(env.projectDir, 'run-config-it'); await runAllTasksNoPiece(env.projectDir);
const options = vi.mocked(runAgent).mock.calls[0]?.[2]; const options = vi.mocked(runAgent).mock.calls[0]?.[2];
expect(options?.providerOptions).toEqual({ expect(options?.providerOptions).toEqual({
@ -159,7 +161,7 @@ describe('IT: runAllTasks provider_options reflection', () => {
process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = 'true'; process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = 'true';
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
await runAllTasks(env.projectDir, 'run-config-it'); await runAllTasksNoPiece(env.projectDir);
const options = vi.mocked(runAgent).mock.calls[0]?.[2]; const options = vi.mocked(runAgent).mock.calls[0]?.[2];
expect(options?.providerOptions).toEqual({ expect(options?.providerOptions).toEqual({
@ -167,4 +169,3 @@ describe('IT: runAllTasks provider_options reflection', () => {
}); });
}); });
}); });

View File

@ -50,6 +50,9 @@ function createTask(name: string): TaskInfo {
name, name,
content: `Task: ${name}`, content: `Task: ${name}`,
filePath: `/tasks/${name}.yaml`, 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)[] = []; const receivedSignals: (AbortSignal | undefined)[] = [];
mockExecuteAndCompleteTask.mockImplementation( 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); receivedSignals.push(parallelOpts?.abortSignal);
return Promise.resolve(true); return Promise.resolve(true);
}, },
); );
// When // 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 // Then: AbortSignal is passed even with concurrency=1
expect(receivedSignals).toHaveLength(1); expect(receivedSignals).toHaveLength(1);
@ -109,7 +112,7 @@ describe('worker pool: abort signal propagation', () => {
let capturedSignal: AbortSignal | undefined; let capturedSignal: AbortSignal | undefined;
mockExecuteAndCompleteTask.mockImplementation( 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; capturedSignal = parallelOpts?.abortSignal;
return new Promise((resolve) => { return new Promise((resolve) => {
// Wait long enough for SIGINT to fire // Wait long enough for SIGINT to fire
@ -119,7 +122,7 @@ describe('worker pool: abort signal propagation', () => {
); );
// Start execution // 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 // Wait for task to start
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
@ -149,25 +152,25 @@ describe('worker pool: abort signal propagation', () => {
const receivedSignalsPar: (AbortSignal | undefined)[] = []; const receivedSignalsPar: (AbortSignal | undefined)[] = [];
mockExecuteAndCompleteTask.mockImplementation( 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); receivedSignalsSeq.push(parallelOpts?.abortSignal);
return Promise.resolve(true); return Promise.resolve(true);
}, },
); );
// Sequential mode // 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.mockClear();
mockExecuteAndCompleteTask.mockImplementation( 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); receivedSignalsPar.push(parallelOpts?.abortSignal);
return Promise.resolve(true); return Promise.resolve(true);
}, },
); );
// Parallel mode // 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 // Then: Both modes pass AbortSignal
for (const signal of receivedSignalsSeq) { for (const signal of receivedSignalsSeq) {

View File

@ -252,22 +252,19 @@ describe('2-stage category selection helpers', () => {
describe('buildTopLevelSelectOptions', () => { describe('buildTopLevelSelectOptions', () => {
it('should encode categories with prefix in value', () => { 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')); const categoryOption = options.find((o) => o.label.includes('frontend'));
expect(categoryOption).toBeDefined(); expect(categoryOption).toBeDefined();
expect(categoryOption!.value).toBe('__category__:frontend'); expect(categoryOption!.value).toBe('__category__:frontend');
}); });
it('should mark current piece', () => { it('should not include legacy current markers in labels or values', () => {
const options = buildTopLevelSelectOptions(items, 'simple'); const options = buildTopLevelSelectOptions(items);
const simpleOption = options.find((o) => o.value === 'simple'); const labels = options.map((o) => o.label);
expect(simpleOption!.label).toContain('(current)'); const values = options.map((o) => o.value);
});
it('should mark category containing current piece', () => { expect(labels.some((label) => label.includes('(current)'))).toBe(false);
const options = buildTopLevelSelectOptions(items, 'frontend/react'); expect(values).not.toContain('__current__');
const frontendOption = options.find((o) => o.value === '__category__:frontend');
expect(frontendOption!.label).toContain('(current)');
}); });
}); });
@ -283,21 +280,15 @@ describe('2-stage category selection helpers', () => {
describe('buildCategoryPieceOptions', () => { describe('buildCategoryPieceOptions', () => {
it('should return options for pieces in a category', () => { 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).not.toBeNull();
expect(options).toHaveLength(2); expect(options).toHaveLength(2);
expect(options![0]!.value).toBe('frontend/react'); expect(options![0]!.value).toBe('frontend/react');
expect(options![0]!.label).toBe('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', () => { it('should return null for non-existent category', () => {
expect(buildCategoryPieceOptions(items, 'nonexistent', '')).toBeNull(); expect(buildCategoryPieceOptions(items, 'nonexistent')).toBeNull();
}); });
}); });
}); });

View File

@ -39,8 +39,6 @@ const configMock = vi.hoisted(() => ({
loadAllPiecesWithSources: vi.fn(), loadAllPiecesWithSources: vi.fn(),
getPieceCategories: vi.fn(), getPieceCategories: vi.fn(),
buildCategorizedPieces: vi.fn(), buildCategorizedPieces: vi.fn(),
getCurrentPiece: vi.fn(),
resolveConfigValue: vi.fn(),
})); }));
vi.mock('../infra/config/index.js', () => configMock); vi.mock('../infra/config/index.js', () => configMock);
@ -63,7 +61,7 @@ describe('selectPieceFromEntries', () => {
.mockResolvedValueOnce('custom') .mockResolvedValueOnce('custom')
.mockResolvedValueOnce('custom-flow'); .mockResolvedValueOnce('custom-flow');
const selected = await selectPieceFromEntries(entries, ''); const selected = await selectPieceFromEntries(entries);
expect(selected).toBe('custom-flow'); expect(selected).toBe('custom-flow');
expect(selectOptionMock).toHaveBeenCalledTimes(2); expect(selectOptionMock).toHaveBeenCalledTimes(2);
}); });
@ -75,7 +73,7 @@ describe('selectPieceFromEntries', () => {
selectOptionMock.mockResolvedValueOnce('builtin-flow'); selectOptionMock.mockResolvedValueOnce('builtin-flow');
const selected = await selectPieceFromEntries(entries, ''); const selected = await selectPieceFromEntries(entries);
expect(selected).toBe('builtin-flow'); expect(selected).toBe('builtin-flow');
expect(selectOptionMock).toHaveBeenCalledTimes(1); expect(selectOptionMock).toHaveBeenCalledTimes(1);
}); });
@ -116,19 +114,24 @@ describe('selectPieceFromCategorizedPieces', () => {
missingPieces: [], 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 firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
const labels = firstCallOptions.map((o) => o.label); 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('My Pieces'))).toBe(true);
expect(labels.some((l) => l.includes('Quick Start'))).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']; bookmarkState.bookmarks = ['research'];
const categorized: CategorizedPieces = { const categorized: CategorizedPieces = {
@ -142,17 +145,15 @@ describe('selectPieceFromCategorizedPieces', () => {
missingPieces: [], missingPieces: [],
}; };
selectOptionMock.mockResolvedValueOnce('__current__'); selectOptionMock.mockResolvedValueOnce('research');
const selected = await selectPieceFromCategorizedPieces(categorized, 'default'); const selected = await selectPieceFromCategorizedPieces(categorized);
expect(selected).toBe('default'); expect(selected).toBe('research');
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[]; const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
const labels = firstCallOptions.map((o) => o.label); const labels = firstCallOptions.map((o) => o.label);
// Current piece first, bookmarks second, categories after expect(labels.some((l) => l.includes('research [*]'))).toBe(true);
expect(labels[0]).toBe('🎼 default (current)');
expect(labels[1]).toBe('🎼 research [*]');
}); });
it('should navigate into a category and select a piece', async () => { it('should navigate into a category and select a piece', async () => {
@ -171,7 +172,7 @@ describe('selectPieceFromCategorizedPieces', () => {
.mockResolvedValueOnce('__custom_category__:Dev') .mockResolvedValueOnce('__custom_category__:Dev')
.mockResolvedValueOnce('my-piece'); .mockResolvedValueOnce('my-piece');
const selected = await selectPieceFromCategorizedPieces(categorized, ''); const selected = await selectPieceFromCategorizedPieces(categorized);
expect(selected).toBe('my-piece'); expect(selected).toBe('my-piece');
}); });
@ -200,7 +201,7 @@ describe('selectPieceFromCategorizedPieces', () => {
.mockResolvedValueOnce('__category__:Quick Start') .mockResolvedValueOnce('__category__:Quick Start')
.mockResolvedValueOnce('hybrid-default'); .mockResolvedValueOnce('hybrid-default');
const selected = await selectPieceFromCategorizedPieces(categorized, ''); const selected = await selectPieceFromCategorizedPieces(categorized);
expect(selected).toBe('hybrid-default'); expect(selected).toBe('hybrid-default');
expect(selectOptionMock).toHaveBeenCalledTimes(3); expect(selectOptionMock).toHaveBeenCalledTimes(3);
}); });
@ -228,7 +229,7 @@ describe('selectPieceFromCategorizedPieces', () => {
.mockResolvedValueOnce('__custom_category__:Dev') .mockResolvedValueOnce('__custom_category__:Dev')
.mockResolvedValueOnce('base-piece'); .mockResolvedValueOnce('base-piece');
const selected = await selectPieceFromCategorizedPieces(categorized, ''); const selected = await selectPieceFromCategorizedPieces(categorized);
expect(selected).toBe('base-piece'); expect(selected).toBe('base-piece');
// Second call should show Advanced subcategory AND base-piece at same level // Second call should show Advanced subcategory AND base-piece at same level
@ -268,7 +269,7 @@ describe('selectPieceFromCategorizedPieces', () => {
.mockResolvedValueOnce('__category__:Quick Start') .mockResolvedValueOnce('__category__:Quick Start')
.mockResolvedValueOnce('default'); .mockResolvedValueOnce('default');
const selected = await selectPieceFromCategorizedPieces(categorized, ''); const selected = await selectPieceFromCategorizedPieces(categorized);
expect(selected).toBe('default'); expect(selected).toBe('default');
expect(selectOptionMock).toHaveBeenCalledTimes(3); expect(selectOptionMock).toHaveBeenCalledTimes(3);
}); });
@ -294,7 +295,7 @@ describe('selectPieceFromCategorizedPieces', () => {
selectOptionMock.mockResolvedValueOnce(null); selectOptionMock.mockResolvedValueOnce(null);
await selectPieceFromCategorizedPieces(categorized, ''); await selectPieceFromCategorizedPieces(categorized);
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[]; const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
const labels = firstCallOptions.map((o) => o.label); const labels = firstCallOptions.map((o) => o.label);
@ -317,13 +318,11 @@ describe('selectPiece', () => {
configMock.loadAllPiecesWithSources.mockReset(); configMock.loadAllPiecesWithSources.mockReset();
configMock.getPieceCategories.mockReset(); configMock.getPieceCategories.mockReset();
configMock.buildCategorizedPieces.mockReset(); configMock.buildCategorizedPieces.mockReset();
configMock.resolveConfigValue.mockReset();
}); });
it('should return default piece when no pieces found and fallbackToDefault is true', async () => { it('should return default piece when no pieces found and fallbackToDefault is true', async () => {
configMock.getPieceCategories.mockReturnValue(null); configMock.getPieceCategories.mockReturnValue(null);
configMock.listPieces.mockReturnValue([]); configMock.listPieces.mockReturnValue([]);
configMock.resolveConfigValue.mockReturnValue('default');
const result = await selectPiece('/cwd'); const result = await selectPiece('/cwd');
@ -333,7 +332,6 @@ describe('selectPiece', () => {
it('should return null when no pieces found and fallbackToDefault is false', async () => { it('should return null when no pieces found and fallbackToDefault is false', async () => {
configMock.getPieceCategories.mockReturnValue(null); configMock.getPieceCategories.mockReturnValue(null);
configMock.listPieces.mockReturnValue([]); configMock.listPieces.mockReturnValue([]);
configMock.resolveConfigValue.mockReturnValue('default');
const result = await selectPiece('/cwd', { fallbackToDefault: false }); const result = await selectPiece('/cwd', { fallbackToDefault: false });
@ -346,7 +344,6 @@ describe('selectPiece', () => {
configMock.listPieceEntries.mockReturnValue([ configMock.listPieceEntries.mockReturnValue([
{ name: 'only-piece', path: '/tmp/only-piece.yaml', source: 'user' }, { name: 'only-piece', path: '/tmp/only-piece.yaml', source: 'user' },
]); ]);
configMock.resolveConfigValue.mockReturnValue('only-piece');
selectOptionMock.mockResolvedValueOnce('only-piece'); selectOptionMock.mockResolvedValueOnce('only-piece');
const result = await selectPiece('/cwd'); const result = await selectPiece('/cwd');
@ -366,9 +363,10 @@ describe('selectPiece', () => {
configMock.getPieceCategories.mockReturnValue({ categories: ['Dev'] }); configMock.getPieceCategories.mockReturnValue({ categories: ['Dev'] });
configMock.loadAllPiecesWithSources.mockReturnValue(pieceMap); configMock.loadAllPiecesWithSources.mockReturnValue(pieceMap);
configMock.buildCategorizedPieces.mockReturnValue(categorized); 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'); const result = await selectPiece('/cwd');
@ -376,30 +374,10 @@ describe('selectPiece', () => {
expect(configMock.buildCategorizedPieces).toHaveBeenCalled(); 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 () => { it('should use directory-based selection when no category config', async () => {
configMock.getPieceCategories.mockReturnValue(null); configMock.getPieceCategories.mockReturnValue(null);
configMock.listPieces.mockReturnValue(['piece-a', 'piece-b']); configMock.listPieces.mockReturnValue(['piece-a', 'piece-b']);
configMock.listPieceEntries.mockReturnValue(entries); configMock.listPieceEntries.mockReturnValue(entries);
configMock.resolveConfigValue.mockReturnValue('piece-a');
selectOptionMock selectOptionMock
.mockResolvedValueOnce('custom') .mockResolvedValueOnce('custom')

View File

@ -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.',
);
});
});

View File

@ -26,7 +26,6 @@ describe('project provider_profiles', () => {
writeFileSync( writeFileSync(
join(taktDir, 'config.yaml'), join(taktDir, 'config.yaml'),
[ [
'piece: default',
'provider_profiles:', 'provider_profiles:',
' codex:', ' codex:',
' default_permission_mode: full', ' default_permission_mode: full',
@ -44,7 +43,6 @@ describe('project provider_profiles', () => {
it('saves providerProfiles as provider_profiles', () => { it('saves providerProfiles as provider_profiles', () => {
saveProjectConfig(testDir, { saveProjectConfig(testDir, {
piece: 'default',
providerProfiles: { providerProfiles: {
codex: { codex: {
defaultPermissionMode: 'full', defaultPermissionMode: 'full',

View File

@ -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 () => { it('should fail fast when migrated fallback loader is missing and migrated key is resolved', async () => {
vi.doMock('../infra/config/project/projectConfig.js', () => ({ vi.doMock('../infra/config/project/projectConfig.js', () => ({
loadProjectConfig: () => ({ piece: 'default' }), loadProjectConfig: () => ({}),
})); }));
vi.doMock('../infra/config/global/globalConfig.js', () => ({ vi.doMock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: () => ({ language: 'en' }), loadGlobalConfig: () => ({ language: 'en' }),

View File

@ -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', () => { describe('verbose', () => {
it('should resolve verbose to false via resolver default when not set anywhere', () => { it('should resolve verbose to false via resolver default when not set anywhere', () => {
const value = resolveConfigValue(projectDir, 'verbose'); const value = resolveConfigValue(projectDir, 'verbose');
@ -116,7 +85,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
const configDir = getProjectConfigDir(projectDir); const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true }); 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'); const value = resolveConfigValue(projectDir, 'verbose');
expect(value).toBe(true); 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', () => { it('should resolve migrated non-default keys as undefined when project keys are unset', () => {
const configDir = getProjectConfigDir(projectDir); const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true }); 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( writeFileSync(
globalConfigPath, globalConfigPath,
['language: en'].join('\n'), ['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', () => { it('should resolve default-backed migrated keys from defaults when project keys are unset', () => {
const configDir = getProjectConfigDir(projectDir); const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true }); 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( writeFileSync(
globalConfigPath, globalConfigPath,
['language: en'].join('\n'), ['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', () => { it('should resolve all migrated keys from project or defaults when project config has no migrated keys', () => {
const configDir = getProjectConfigDir(projectDir); const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true }); 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( writeFileSync(
globalConfigPath, globalConfigPath,
['language: en'].join('\n'), ['language: en'].join('\n'),

View File

@ -21,26 +21,57 @@ function createTempProjectDir(): string {
return root; return root;
} }
function createTask(overrides: Partial<TaskInfo>): TaskInfo { function createTask(overrides: Partial<TaskInfo> = {}): TaskInfo {
const baseData = { task: 'Run task', piece: 'default' } as NonNullable<TaskInfo['data']>;
const data = overrides.data === undefined
? baseData
: overrides.data === null
? null
: ({
...baseData,
...(overrides.data as Record<string, unknown>),
} as NonNullable<TaskInfo['data']>);
return { return {
filePath: '/tasks/task.yaml', filePath: '/tasks/task.yaml',
name: 'task-name', name: 'task-name',
content: 'Run task', content: 'Run task',
createdAt: '2026-01-01T00:00:00.000Z', createdAt: '2026-01-01T00:00:00.000Z',
status: 'pending', status: 'pending',
data: { task: 'Run task' },
...overrides, ...overrides,
data,
}; };
} }
const resolveTaskExecutionWithPiece = resolveTaskExecution as (task: TaskInfo, projectCwd: string) => ReturnType<typeof resolveTaskExecution>;
describe('resolveTaskExecution', () => { 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 root = createTempProjectDir();
const task = createTask({ data: null }); 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<TaskInfo['data']>,
});
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, execCwd: root,
execPiece: 'default', execPiece: 'default',
isWorktree: false, 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'); const expectedReportOrderPath = path.join(root, '.takt', 'runs', 'issue-task-123', 'context', 'task', 'order.md');
expect(result).toMatchObject({ expect(result).toMatchObject({
@ -107,7 +138,7 @@ describe('resolveTaskExecution', () => {
branch: 'feature/base-branch', branch: 'feature/base-branch',
}); });
const result = await resolveTaskExecution(task, root, 'default'); const result = await resolveTaskExecutionWithPiece(task, root);
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main'); expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
expect(mockCreateSharedClone).toHaveBeenCalledWith( expect(mockCreateSharedClone).toHaveBeenCalledWith(
@ -147,7 +178,7 @@ describe('resolveTaskExecution', () => {
branch: 'feature/base-branch', 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<string, unknown> | undefined; const cloneOptions = mockCreateSharedClone.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main'); expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
@ -187,7 +218,7 @@ describe('resolveTaskExecution', () => {
branch: 'feature/base-branch', 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<string, unknown> | undefined; const cloneOptions = mockCreateSharedClone.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined); expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined);
@ -228,7 +259,7 @@ describe('resolveTaskExecution', () => {
branch: 'feature/base-branch', branch: 'feature/base-branch',
}); });
const result = await resolveTaskExecution(task, root, 'default'); const result = await resolveTaskExecutionWithPiece(task, root);
expect(result.execCwd).toBe(worktreePath); expect(result.execCwd).toBe(worktreePath);
expect(result.isWorktree).toBe(true); expect(result.isWorktree).toBe(true);
@ -264,7 +295,7 @@ describe('resolveTaskExecution', () => {
branch: 'feature/base-branch', branch: 'feature/base-branch',
}); });
const result = await resolveTaskExecution(task, root, 'default'); const result = await resolveTaskExecutionWithPiece(task, root);
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main'); expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
expect(mockCreateSharedClone).not.toHaveBeenCalled(); expect(mockCreateSharedClone).not.toHaveBeenCalled();
@ -300,7 +331,7 @@ describe('resolveTaskExecution', () => {
branch: 'feature/base-branch', branch: 'feature/base-branch',
}); });
const result = await resolveTaskExecution(task, root, 'default'); const result = await resolveTaskExecutionWithPiece(task, root);
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined); expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined);
expect(mockCreateSharedClone).not.toHaveBeenCalled(); 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.draftPr).toBe(true);
expect(result.autoPr).toBe(true); expect(result.autoPr).toBe(true);

View File

@ -40,6 +40,13 @@ vi.mock('../infra/config/index.js', () => ({
} }
return result; return result;
}, },
resolvePieceConfigValue: (_projectDir: string, key: string) => {
const raw = mockLoadConfigRaw() as Record<string, unknown>;
const config = ('global' in raw && 'project' in raw)
? { ...raw.global as Record<string, unknown>, ...raw.project as Record<string, unknown> }
: { ...raw, provider: 'claude', verbose: false };
return config[key];
},
resolveConfigValueWithSource: (_projectDir: string, key: string) => { resolveConfigValueWithSource: (_projectDir: string, key: string) => {
const raw = mockLoadConfigRaw() as Record<string, unknown>; const raw = mockLoadConfigRaw() as Record<string, unknown>;
const config = ('global' in raw && 'project' in raw) const config = ('global' in raw && 'project' in raw)
@ -188,7 +195,10 @@ function createTask(name: string): TaskInfo {
filePath: `/tasks/${name}.yaml`, filePath: `/tasks/${name}.yaml`,
createdAt: '2026-02-09T00:00:00.000Z', createdAt: '2026-02-09T00:00:00.000Z',
status: 'pending', status: 'pending',
data: null, data: {
task: `Task: ${name}`,
piece: 'default',
},
}; };
} }

View File

@ -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');
});
});

View File

@ -77,9 +77,17 @@ const createTask = (name: string): TaskInfo => ({
filePath: `/tasks/${name}.yaml`, filePath: `/tasks/${name}.yaml`,
createdAt: '2026-02-16T00:00:00.000Z', createdAt: '2026-02-16T00:00:00.000Z',
status: 'pending', 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<boolean>;
describe('executeAndCompleteTask', () => { describe('executeAndCompleteTask', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -130,12 +138,18 @@ describe('executeAndCompleteTask', () => {
const abortController = new AbortController(); const abortController = new AbortController();
// When // When
await executeAndCompleteTask(task, {} as never, '/project', 'default', undefined, { await executeAndCompleteTaskWithoutPiece(
abortSignal: abortController.signal, task,
taskPrefix: taskDisplayLabel, {} as never,
taskColorIndex: 0, '/project',
taskDisplayLabel, undefined,
}); {
abortSignal: abortController.signal,
taskPrefix: taskDisplayLabel,
taskColorIndex: 0,
taskDisplayLabel,
},
);
// Then: executePiece receives the propagated display label. // Then: executePiece receives the propagated display label.
expect(mockExecutePiece).toHaveBeenCalledTimes(1); expect(mockExecutePiece).toHaveBeenCalledTimes(1);
@ -223,7 +237,7 @@ describe('executeAndCompleteTask', () => {
mockPostExecutionFlow.mockResolvedValue({ prFailed: true, prError: 'Base ref must be a branch' }); mockPostExecutionFlow.mockResolvedValue({ prFailed: true, prError: 'Base ref must be a branch' });
// When // 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) // Then: code succeeded, task is marked as pr_failed (not failed)
expect(result).toBe(true); expect(result).toBe(true);
@ -262,7 +276,7 @@ describe('executeAndCompleteTask', () => {
mockPostExecutionFlow.mockResolvedValue({ prUrl: 'https://github.com/org/repo/pull/1' }); mockPostExecutionFlow.mockResolvedValue({ prUrl: 'https://github.com/org/repo/pull/1' });
// When // 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 // Then: task should be marked as completed
expect(result).toBe(true); expect(result).toBe(true);

View File

@ -158,6 +158,33 @@ describe('instructBranch direct execution flow', () => {
expect(mockExecuteAndCompleteTask).toHaveBeenCalled(); 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 () => { it('should set generated instruction as retry note when no existing note', async () => {
await instructBranch('/project', { await instructBranch('/project', {
kind: 'completed', kind: 'completed',

View File

@ -179,6 +179,25 @@ describe('retryFailedTask', () => {
expect(mockExecuteAndCompleteTask).toHaveBeenCalled(); 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 () => { it('should pass failed movement as default to selectOptionWithDefault', async () => {
const task = makeFailedTask(); // failure.movement = 'review' const task = makeFailedTask(); // failure.movement = 'review'

View File

@ -14,7 +14,6 @@ const {
mockSuccess, mockSuccess,
mockWarn, mockWarn,
mockError, mockError,
mockResolveConfigValue,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockRecoverInterruptedRunningTasks: vi.fn(), mockRecoverInterruptedRunningTasks: vi.fn(),
mockGetTasksFilePath: vi.fn(), mockGetTasksFilePath: vi.fn(),
@ -28,7 +27,6 @@ const {
mockSuccess: vi.fn(), mockSuccess: vi.fn(),
mockWarn: vi.fn(), mockWarn: vi.fn(),
mockError: vi.fn(), mockError: vi.fn(),
mockResolveConfigValue: vi.fn(),
})); }));
vi.mock('../infra/task/index.js', () => ({ vi.mock('../infra/task/index.js', () => ({
@ -60,16 +58,11 @@ vi.mock('../shared/i18n/index.js', () => ({
getLabel: vi.fn((key: string) => key), getLabel: vi.fn((key: string) => key),
})); }));
vi.mock('../infra/config/index.js', () => ({
resolveConfigValue: mockResolveConfigValue,
}));
import { watchTasks } from '../features/tasks/watch/index.js'; import { watchTasks } from '../features/tasks/watch/index.js';
describe('watchTasks', () => { describe('watchTasks', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockResolveConfigValue.mockReturnValue('default');
mockRecoverInterruptedRunningTasks.mockReturnValue(0); mockRecoverInterruptedRunningTasks.mockReturnValue(0);
mockGetTasksFilePath.mockReturnValue('/project/.takt/tasks.yaml'); mockGetTasksFilePath.mockReturnValue('/project/.takt/tasks.yaml');
mockExecuteAndCompleteTask.mockResolvedValue(true); mockExecuteAndCompleteTask.mockResolvedValue(true);
@ -97,17 +90,13 @@ describe('watchTasks', () => {
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
}); });
it('piece設定が未定義の場合はデフォルトpiece名を使う', async () => { it('executeAndCompleteTask を watch ループで呼び出す', async () => {
mockResolveConfigValue.mockReturnValue(undefined);
await watchTasks('/project'); await watchTasks('/project');
expect(mockInfo).toHaveBeenCalledWith('Piece: default');
expect(mockExecuteAndCompleteTask).toHaveBeenCalledWith( expect(mockExecuteAndCompleteTask).toHaveBeenCalledWith(
expect.any(Object), expect.any(Object),
expect.any(Object), expect.any(Object),
'/project', '/project',
'default',
undefined, undefined,
); );
}); });

View File

@ -54,6 +54,7 @@ function createTask(name: string, options?: { issue?: number }): TaskInfo {
status: 'pending', status: 'pending',
data: { data: {
task: `Task: ${name}`, task: `Task: ${name}`,
piece: 'default',
...(options?.issue !== undefined ? { issue: options.issue } : {}), ...(options?.issue !== undefined ? { issue: options.issue } : {}),
}, },
}; };
@ -85,7 +86,7 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // 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 // Then
expect(result).toEqual({ success: 2, fail: 0 }); expect(result).toEqual({ success: 2, fail: 0 });
@ -102,7 +103,7 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // 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 // Then
expect(result).toEqual({ success: 2, fail: 1 }); expect(result).toEqual({ success: 2, fail: 1 });
@ -119,7 +120,7 @@ describe('runWithWorkerPool', () => {
}); });
// When // 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 // Then: Task names appear in prefixed stdout output
writeSpy.mockRestore(); writeSpy.mockRestore();
@ -136,11 +137,11 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // 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 // Then
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[4];
expect(parallelOpts).toMatchObject({ expect(parallelOpts).toMatchObject({
abortSignal: expect.any(AbortSignal), abortSignal: expect.any(AbortSignal),
taskPrefix: 'my-task', taskPrefix: 'my-task',
@ -161,7 +162,7 @@ describe('runWithWorkerPool', () => {
}); });
// When // 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 // Then: Issue label is used instead of truncated task name
writeSpy.mockRestore(); writeSpy.mockRestore();
@ -170,7 +171,7 @@ describe('runWithWorkerPool', () => {
expect(allOutput).not.toContain('[#123]'); expect(allOutput).not.toContain('[#123]');
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[4];
expect(parallelOpts).toEqual({ expect(parallelOpts).toEqual({
abortSignal: expect.any(AbortSignal), abortSignal: expect.any(AbortSignal),
taskPrefix: `#${issueNumber}`, taskPrefix: `#${issueNumber}`,
@ -185,11 +186,11 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // 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 // Then
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[4];
expect(parallelOpts).toMatchObject({ expect(parallelOpts).toMatchObject({
abortSignal: expect.any(AbortSignal), abortSignal: expect.any(AbortSignal),
taskPrefix: undefined, taskPrefix: undefined,
@ -205,7 +206,7 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([[task2]]); const runner = createMockTaskRunner([[task2]]);
// When // 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 // Then
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(2); expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(2);
@ -233,7 +234,7 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // 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 // Then: Never exceeded concurrency of 2
expect(maxActive).toBeLessThanOrEqual(2); expect(maxActive).toBeLessThanOrEqual(2);
@ -246,13 +247,13 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
const receivedSignals: (AbortSignal | undefined)[] = []; const receivedSignals: (AbortSignal | undefined)[] = [];
mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _piece, _opts, parallelOpts) => { mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _opts, parallelOpts) => {
receivedSignals.push(parallelOpts?.abortSignal); receivedSignals.push(parallelOpts?.abortSignal);
return Promise.resolve(true); return Promise.resolve(true);
}); });
// When // 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 // Then: All tasks received the same AbortSignal
expect(receivedSignals).toHaveLength(3); expect(receivedSignals).toHaveLength(3);
@ -268,7 +269,7 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // 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 // Then
expect(result).toEqual({ success: 0, fail: 0 }); expect(result).toEqual({ success: 0, fail: 0 });
@ -282,7 +283,7 @@ describe('runWithWorkerPool', () => {
const runner = createMockTaskRunner([]); const runner = createMockTaskRunner([]);
// When // 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 // Then: Treated as failure
expect(result).toEqual({ success: 0, fail: 1 }); expect(result).toEqual({ success: 0, fail: 1 });
@ -295,7 +296,7 @@ describe('runWithWorkerPool', () => {
const deferred: Array<() => void> = []; const deferred: Array<() => void> = [];
const startedSignals: AbortSignal[] = []; const startedSignals: AbortSignal[] = [];
mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _piece, _opts, parallelOpts) => { mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _opts, parallelOpts) => {
const signal = parallelOpts?.abortSignal; const signal = parallelOpts?.abortSignal;
if (signal) startedSignals.push(signal); if (signal) startedSignals.push(signal);
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
@ -308,7 +309,7 @@ describe('runWithWorkerPool', () => {
}); });
const resultPromise = 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)); await new Promise((resolve) => setTimeout(resolve, 10));
@ -366,7 +367,7 @@ describe('runWithWorkerPool', () => {
// When: pollIntervalMs=30 so polling fires before task1 completes (80ms) // When: pollIntervalMs=30 so polling fires before task1 completes (80ms)
const result = await runWithWorkerPool( 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 // Then: Both tasks were executed
@ -399,7 +400,7 @@ describe('runWithWorkerPool', () => {
// When // When
const result = await runWithWorkerPool( 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 // 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 // When: Task completes before poll timer fires; cancel() cleans up timer
const result = await runWithWorkerPool( 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()) // Then: Result is returned without hanging (timer was cleaned up by cancel())

View File

@ -99,6 +99,7 @@ function writeExceededRecord(testDir: string, overrides: Record<string, unknown>
name: 'task-a', name: 'task-a',
status: 'exceeded', status: 'exceeded',
content: 'Do work', content: 'Do work',
piece: 'test-piece',
created_at: '2026-02-09T00:00:00.000Z', created_at: '2026-02-09T00:00:00.000Z',
started_at: '2026-02-09T00:01:00.000Z', started_at: '2026-02-09T00:01:00.000Z',
completed_at: '2026-02-09T00:05: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 () => { it('scenario 1: task transitions to exceeded status when executePiece returns exceeded result', async () => {
// Given: a pending task // Given: a pending task
runner.addTask('Do work'); runner.addTask('Do work', { piece: 'test-piece' });
const [task] = runner.claimNextTasks(1); const [task] = runner.claimNextTasks(1);
if (!task) throw new Error('No task claimed'); 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 // 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) // Then: returns false (task did not succeed)
expect(result).toBe(false); 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 () => { it('scenario 2: exceeded metadata is recorded in tasks.yaml for resumption', async () => {
// Given: a pending task // Given: a pending task
runner.addTask('Do work'); runner.addTask('Do work', { piece: 'test-piece' });
const [task] = runner.claimNextTasks(1); const [task] = runner.claimNextTasks(1);
if (!task) throw new Error('No task claimed'); 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 // 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 // Then: YAML contains the three resumption fields
const file = loadTasksFile(testDir); const file = loadTasksFile(testDir);
@ -267,7 +268,7 @@ describe('シナリオ3・4: requeue → re-execution passes exceeded metadata t
vi.mocked(executePiece).mockResolvedValueOnce({ success: true }); vi.mocked(executePiece).mockResolvedValueOnce({ success: true });
// When: executeAndCompleteTask runs the requeued task // 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 // Then: executePiece received the correct exceeded override options
expect(vi.mocked(executePiece)).toHaveBeenCalledOnce(); 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 }); vi.mocked(executePiece).mockResolvedValueOnce({ success: true });
// When: executeAndCompleteTask runs the requeued task // 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 // Then: executePiece received startMovement='implement' to resume from where it stopped
expect(vi.mocked(executePiece)).toHaveBeenCalledOnce(); expect(vi.mocked(executePiece)).toHaveBeenCalledOnce();

View File

@ -1,7 +1,7 @@
/** /**
* CLI subcommand definitions * 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'; import { join } from 'node:path';
@ -9,7 +9,7 @@ import { clearPersonaSessions, resolveConfigValue } from '../../infra/config/ind
import { getGlobalConfigDir } from '../../infra/config/paths.js'; import { getGlobalConfigDir } from '../../infra/config/paths.js';
import { success, info } from '../../shared/ui/index.js'; import { success, info } from '../../shared/ui/index.js';
import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/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 { previewPrompts } from '../../features/prompt/index.js';
import { showCatalog } from '../../features/catalog/index.js'; import { showCatalog } from '../../features/catalog/index.js';
import { computeReviewMetrics, formatReviewMetrics, parseSinceDuration, purgeOldEvents } from '../../features/analytics/index.js'; import { computeReviewMetrics, formatReviewMetrics, parseSinceDuration, purgeOldEvents } from '../../features/analytics/index.js';
@ -23,8 +23,7 @@ program
.command('run') .command('run')
.description('Run all pending tasks from .takt/tasks.yaml') .description('Run all pending tasks from .takt/tasks.yaml')
.action(async () => { .action(async () => {
const piece = resolveConfigValue(resolvedCwd, 'piece'); await runAllTasks(resolvedCwd, resolveAgentOverrides(program));
await runAllTasks(resolvedCwd, piece, resolveAgentOverrides(program));
}); });
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 program
.command('clear') .command('clear')
.description('Clear agent conversation sessions') .description('Clear agent conversation sessions')
@ -124,7 +115,7 @@ reset
program program
.command('prompt') .command('prompt')
.description('Preview assembled prompts for each movement and phase') .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) => { .action(async (piece?: string) => {
await previewPrompts(resolvedCwd, piece); await previewPrompts(resolvedCwd, piece);
}); });

View File

@ -9,6 +9,8 @@ import type { TaskExecutionOptions } from '../../features/tasks/index.js';
import type { ProviderType } from '../../infra/providers/index.js'; import type { ProviderType } from '../../infra/providers/index.js';
import { isIssueReference } from '../../infra/github/index.js'; import { isIssueReference } from '../../infra/github/index.js';
const REMOVED_ROOT_COMMANDS = new Set(['switch']);
/** /**
* Resolve --provider and --model options into TaskExecutionOptions. * Resolve --provider and --model options into TaskExecutionOptions.
* Returns undefined if neither is specified. * Returns undefined if neither is specified.
@ -42,3 +44,25 @@ export function resolveAgentOverrides(program: Command): TaskExecutionOptions |
export function isDirectTask(input: string): boolean { export function isDirectTask(input: string): boolean {
return isIssueReference(input) || input.trim().split(/\s+/).every((t: string) => isIssueReference(t)); 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;
}

View File

@ -9,6 +9,7 @@
import { checkForUpdates } from '../../shared/utils/index.js'; import { checkForUpdates } from '../../shared/utils/index.js';
import { getErrorMessage } from '../../shared/utils/error.js'; import { getErrorMessage } from '../../shared/utils/error.js';
import { error as errorLog } from '../../shared/ui/index.js'; import { error as errorLog } from '../../shared/ui/index.js';
import { resolveRemovedRootCommand, resolveSlashFallbackTask } from './helpers.js';
checkForUpdates(); checkForUpdates();
@ -19,20 +20,20 @@ import { executeDefaultAction } from './routing.js';
(async () => { (async () => {
const args = process.argv.slice(2); 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 const knownCommands = program.commands.map((cmd) => cmd.name());
if (firstArg?.startsWith('/')) { const slashFallbackTask = resolveSlashFallbackTask(args, knownCommands);
const commandName = firstArg.slice(1);
const knownCommands = program.commands.map((cmd) => cmd.name());
if (!knownCommands.includes(commandName)) { if (slashFallbackTask !== null) {
// Treat as task instruction await runPreActionHook();
const task = args.join(' '); await executeDefaultAction(slashFallbackTask);
await runPreActionHook(); process.exit(0);
await executeDefaultAction(task);
process.exit(0);
}
} }
// Normal parsing for all other cases (including '#' prefixed inputs) // Normal parsing for all other cases (including '#' prefixed inputs)

View File

@ -19,7 +19,6 @@ import { program, resolvedCwd, pipelineMode } from './program.js';
import { resolveAgentOverrides } from './helpers.js'; import { resolveAgentOverrides } from './helpers.js';
import { loadTaskHistory } from './taskHistory.js'; import { loadTaskHistory } from './taskHistory.js';
import { resolveIssueInput, resolvePrInput } from './routing-inputs.js'; import { resolveIssueInput, resolvePrInput } from './routing-inputs.js';
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
export async function executeDefaultAction(task?: string): Promise<void> { export async function executeDefaultAction(task?: string): Promise<void> {
const opts = program.opts(); const opts = program.opts();
if (!pipelineMode && (opts.autoPr === true || opts.draft === true)) { if (!pipelineMode && (opts.autoPr === true || opts.draft === true)) {
@ -39,9 +38,11 @@ export async function executeDefaultAction(task?: string): Promise<void> {
process.exit(1); process.exit(1);
} }
const agentOverrides = resolveAgentOverrides(program); const agentOverrides = resolveAgentOverrides(program);
const resolvedPipelinePiece = (opts.piece as string | undefined) const resolvedPipelinePiece = opts.piece as string | undefined;
?? resolveConfigValue(resolvedCwd, 'piece') if (pipelineMode && resolvedPipelinePiece === undefined) {
?? DEFAULT_PIECE_NAME; logError('--piece (-w) is required in pipeline mode');
process.exit(1);
}
const resolvedPipelineAutoPr = opts.autoPr === true const resolvedPipelineAutoPr = opts.autoPr === true
? true ? true
: (resolveConfigValue(resolvedCwd, 'autoPr') ?? false); : (resolveConfigValue(resolvedCwd, 'autoPr') ?? false);
@ -57,7 +58,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
issueNumber, issueNumber,
prNumber, prNumber,
task: opts.task as string | undefined, task: opts.task as string | undefined,
piece: resolvedPipelinePiece, piece: resolvedPipelinePiece!,
branch: opts.branch as string | undefined, branch: opts.branch as string | undefined,
autoPr: resolvedPipelineAutoPr, autoPr: resolvedPipelineAutoPr,
draftPr: resolvedPipelineDraftPr, draftPr: resolvedPipelineDraftPr,

View File

@ -92,8 +92,6 @@ export interface PersistedGlobalConfig {
language: Language; language: Language;
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
model?: string; model?: string;
/** Default piece name for new tasks (resolved via config layers: project > global > 'default') */
piece?: string;
/** @globalOnly */ /** @globalOnly */
observability?: ObservabilityConfig; observability?: ObservabilityConfig;
analytics?: AnalyticsConfig; analytics?: AnalyticsConfig;
@ -181,7 +179,6 @@ export interface PersistedGlobalConfig {
/** Project-level configuration */ /** Project-level configuration */
export interface ProjectConfig { export interface ProjectConfig {
piece?: string;
verbose?: boolean; verbose?: boolean;
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
model?: string; model?: string;

View File

@ -500,8 +500,6 @@ export const GlobalConfigSchema = z.object({
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
provider: ProviderReferenceSchema.optional().default('claude'), provider: ProviderReferenceSchema.optional().default('claude'),
model: z.string().optional(), model: z.string().optional(),
/** Default piece name for new tasks */
piece: z.string().optional(),
observability: ObservabilityConfigSchema.optional(), observability: ObservabilityConfigSchema.optional(),
analytics: AnalyticsConfigSchema.optional(), analytics: AnalyticsConfigSchema.optional(),
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ /** 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 */ /** Project config schema */
export const ProjectConfigSchema = z.object({ export const ProjectConfigSchema = z.object({
piece: z.string().optional(),
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional(), log_level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
verbose: z.boolean().optional(), verbose: z.boolean().optional(),
provider: ProviderReferenceSchema.optional(), provider: ProviderReferenceSchema.optional(),

View File

@ -2,7 +2,6 @@
* Config feature exports * Config feature exports
*/ */
export { switchPiece } from './switchPiece.js';
export { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES } from './ejectBuiltin.js'; export { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES } from './ejectBuiltin.js';
export { resetCategoriesToDefault } from './resetCategories.js'; export { resetCategoriesToDefault } from './resetCategories.js';
export { resetConfigToDefault } from './resetConfig.js'; export { resetConfigToDefault } from './resetConfig.js';

View File

@ -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<boolean> {
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;
}

View File

@ -16,7 +16,6 @@ import {
loadAllPiecesWithSources, loadAllPiecesWithSources,
getPieceCategories, getPieceCategories,
buildCategorizedPieces, buildCategorizedPieces,
resolveConfigValue,
type PieceDirEntry, type PieceDirEntry,
type PieceCategoryNode, type PieceCategoryNode,
type CategorizedPieces, type CategorizedPieces,
@ -71,16 +70,12 @@ const CATEGORY_VALUE_PREFIX = '__category__:';
*/ */
export function buildTopLevelSelectOptions( export function buildTopLevelSelectOptions(
items: PieceSelectionItem[], items: PieceSelectionItem[],
currentPiece: string,
): SelectionOption[] { ): SelectionOption[] {
return items.map((item) => { return items.map((item) => {
if (item.type === 'piece') { if (item.type === 'piece') {
const isCurrent = item.name === currentPiece; return { label: item.name, value: item.name };
const label = isCurrent ? `${item.name} (current)` : item.name;
return { label, value: item.name };
} }
const containsCurrent = item.pieces.some((w) => w === currentPiece); const label = `📁 ${item.name}/`;
const label = containsCurrent ? `📁 ${item.name}/ (current)` : `📁 ${item.name}/`;
return { label, value: `${CATEGORY_VALUE_PREFIX}${item.name}` }; return { label, value: `${CATEGORY_VALUE_PREFIX}${item.name}` };
}); });
} }
@ -102,7 +97,6 @@ export function parseCategorySelection(selected: string): string | null {
export function buildCategoryPieceOptions( export function buildCategoryPieceOptions(
items: PieceSelectionItem[], items: PieceSelectionItem[],
categoryName: string, categoryName: string,
currentPiece: string,
): SelectionOption[] | null { ): SelectionOption[] | null {
const categoryItem = items.find( const categoryItem = items.find(
(item) => item.type === 'category' && item.name === categoryName, (item) => item.type === 'category' && item.name === categoryName,
@ -111,9 +105,7 @@ export function buildCategoryPieceOptions(
return categoryItem.pieces.map((qualifiedName) => { return categoryItem.pieces.map((qualifiedName) => {
const displayName = qualifiedName.split('/').pop() ?? qualifiedName; const displayName = qualifiedName.split('/').pop() ?? qualifiedName;
const isCurrent = qualifiedName === currentPiece; return { label: displayName, value: qualifiedName };
const label = isCurrent ? `${displayName} (current)` : displayName;
return { label, 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( function buildCategoryLevelOptions(
categories: PieceCategoryNode[], categories: PieceCategoryNode[],
pieces: string[], pieces: string[],
currentPiece: string,
): { ): {
options: SelectionOption[]; options: SelectionOption[];
categoryMap: Map<string, PieceCategoryNode>; categoryMap: Map<string, PieceCategoryNode>;
@ -167,18 +150,14 @@ function buildCategoryLevelOptions(
const categoryMap = new Map<string, PieceCategoryNode>(); const categoryMap = new Map<string, PieceCategoryNode>();
for (const category of categories) { for (const category of categories) {
const containsCurrent = currentPiece.length > 0 && categoryContainsPiece(category, currentPiece); const label = `📁 ${category.name}/`;
const label = containsCurrent
? `📁 ${category.name}/ (current)`
: `📁 ${category.name}/`;
const value = `${CATEGORY_VALUE_PREFIX}${category.name}`; const value = `${CATEGORY_VALUE_PREFIX}${category.name}`;
options.push({ label, value }); options.push({ label, value });
categoryMap.set(category.name, category); categoryMap.set(category.name, category);
} }
for (const pieceName of pieces) { for (const pieceName of pieces) {
const isCurrent = pieceName === currentPiece; const label = `🎼 ${pieceName}`;
const label = isCurrent ? `🎼 ${pieceName} (current)` : `🎼 ${pieceName}`;
options.push({ label, value: pieceName }); options.push({ label, value: pieceName });
} }
@ -187,7 +166,6 @@ function buildCategoryLevelOptions(
async function selectPieceFromCategoryTree( async function selectPieceFromCategoryTree(
categories: PieceCategoryNode[], categories: PieceCategoryNode[],
currentPiece: string,
hasSourceSelection: boolean, hasSourceSelection: boolean,
rootPieces: string[] = [], rootPieces: string[] = [],
): Promise<string | null> { ): Promise<string | null> {
@ -207,7 +185,6 @@ async function selectPieceFromCategoryTree(
const { options, categoryMap } = buildCategoryLevelOptions( const { options, categoryMap } = buildCategoryLevelOptions(
currentCategories, currentCategories,
currentPieces, currentPieces,
currentPiece,
); );
if (options.length === 0) { if (options.length === 0) {
@ -268,33 +245,21 @@ async function selectPieceFromCategoryTree(
} }
} }
const CURRENT_PIECE_VALUE = '__current__';
const CUSTOM_CATEGORY_PREFIX = '__custom_category__:'; const CUSTOM_CATEGORY_PREFIX = '__custom_category__:';
type TopLevelSelection = type TopLevelSelection =
| { type: 'current' }
| { type: 'piece'; name: string } | { type: 'piece'; name: string }
| { type: 'category'; node: PieceCategoryNode }; | { type: 'category'; node: PieceCategoryNode };
async function selectTopLevelPieceOption( async function selectTopLevelPieceOption(
categorized: CategorizedPieces, categorized: CategorizedPieces,
currentPiece: string,
): Promise<TopLevelSelection | null> { ): Promise<TopLevelSelection | null> {
const buildOptions = (): SelectOptionItem<string>[] => { const buildOptions = (): SelectOptionItem<string>[] => {
const options: SelectOptionItem<string>[] = []; const options: SelectOptionItem<string>[] = [];
const bookmarkedPieces = getBookmarkedPieces(); const bookmarkedPieces = getBookmarkedPieces();
// 1. Current piece
if (currentPiece) {
options.push({
label: `🎼 ${currentPiece} (current)`,
value: CURRENT_PIECE_VALUE,
});
}
// 2. Bookmarked pieces (individual items) // 2. Bookmarked pieces (individual items)
for (const pieceName of bookmarkedPieces) { for (const pieceName of bookmarkedPieces) {
if (pieceName === currentPiece) continue;
options.push({ options.push({
label: `🎼 ${pieceName} [*]`, label: `🎼 ${pieceName} [*]`,
value: pieceName, value: pieceName,
@ -316,7 +281,7 @@ async function selectTopLevelPieceOption(
const result = await selectOption<string>('Select piece:', buildOptions(), { const result = await selectOption<string>('Select piece:', buildOptions(), {
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => { onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
if (value === CURRENT_PIECE_VALUE || value.startsWith(CUSTOM_CATEGORY_PREFIX)) { if (value.startsWith(CUSTOM_CATEGORY_PREFIX)) {
return null; return null;
} }
@ -336,10 +301,6 @@ async function selectTopLevelPieceOption(
if (!result) return null; if (!result) return null;
if (result === CURRENT_PIECE_VALUE) {
return { type: 'current' };
}
if (result.startsWith(CUSTOM_CATEGORY_PREFIX)) { if (result.startsWith(CUSTOM_CATEGORY_PREFIX)) {
const categoryName = result.slice(CUSTOM_CATEGORY_PREFIX.length); const categoryName = result.slice(CUSTOM_CATEGORY_PREFIX.length);
const node = categorized.categories.find(c => c.name === categoryName); const node = categorized.categories.find(c => c.name === categoryName);
@ -355,20 +316,16 @@ async function selectTopLevelPieceOption(
*/ */
export async function selectPieceFromCategorizedPieces( export async function selectPieceFromCategorizedPieces(
categorized: CategorizedPieces, categorized: CategorizedPieces,
currentPiece: string,
): Promise<string | null> { ): Promise<string | null> {
while (true) { while (true) {
const selection = await selectTopLevelPieceOption(categorized, currentPiece); const selection = await selectTopLevelPieceOption(categorized);
if (!selection) return null; if (!selection) return null;
if (selection.type === 'current') return currentPiece;
if (selection.type === 'piece') return selection.name; if (selection.type === 'piece') return selection.name;
if (selection.type === 'category') { if (selection.type === 'category') {
const piece = await selectPieceFromCategoryTree( const piece = await selectPieceFromCategoryTree(
selection.node.children, selection.node.children,
currentPiece,
true, true,
selection.node.pieces, selection.node.pieces,
); );
@ -380,7 +337,6 @@ export async function selectPieceFromCategorizedPieces(
async function selectPieceFromEntriesWithCategories( async function selectPieceFromEntriesWithCategories(
entries: PieceDirEntry[], entries: PieceDirEntry[],
currentPiece: string,
): Promise<string | null> { ): Promise<string | null> {
if (entries.length === 0) return null; if (entries.length === 0) return null;
@ -390,7 +346,7 @@ async function selectPieceFromEntriesWithCategories(
if (!hasCategories) { if (!hasCategories) {
const baseOptions: SelectionOption[] = availablePieces.map((name) => ({ const baseOptions: SelectionOption[] = availablePieces.map((name) => ({
label: name === currentPiece ? `🎼 ${name} (current)` : `🎼 ${name}`, label: `🎼 ${name}`,
value: name, value: name,
})); }));
@ -415,7 +371,7 @@ async function selectPieceFromEntriesWithCategories(
// Loop until user selects a piece or cancels at top level // Loop until user selects a piece or cancels at top level
while (true) { while (true) {
const buildTopLevelOptions = (): SelectionOption[] => const buildTopLevelOptions = (): SelectionOption[] =>
applyBookmarks(buildTopLevelSelectOptions(items, currentPiece), getBookmarkedPieces()); applyBookmarks(buildTopLevelSelectOptions(items), getBookmarkedPieces());
const selected = await selectOption<string>('Select piece:', buildTopLevelOptions(), { const selected = await selectOption<string>('Select piece:', buildTopLevelOptions(), {
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => { onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
@ -441,7 +397,7 @@ async function selectPieceFromEntriesWithCategories(
const categoryName = parseCategorySelection(selected); const categoryName = parseCategorySelection(selected);
if (categoryName) { if (categoryName) {
const categoryOptions = buildCategoryPieceOptions(items, categoryName, currentPiece); const categoryOptions = buildCategoryPieceOptions(items, categoryName);
if (!categoryOptions) continue; if (!categoryOptions) continue;
const buildCategoryOptions = (): SelectionOption[] => const buildCategoryOptions = (): SelectionOption[] =>
@ -476,7 +432,6 @@ async function selectPieceFromEntriesWithCategories(
*/ */
export async function selectPieceFromEntries( export async function selectPieceFromEntries(
entries: PieceDirEntry[], entries: PieceDirEntry[],
currentPiece: string,
): Promise<string | null> { ): Promise<string | null> {
const builtinEntries = entries.filter((entry) => entry.source === 'builtin'); const builtinEntries = entries.filter((entry) => entry.source === 'builtin');
const customEntries = 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; if (!selectedSource) return null;
const sourceEntries = selectedSource === 'custom' ? customEntries : builtinEntries; const sourceEntries = selectedSource === 'custom' ? customEntries : builtinEntries;
return selectPieceFromEntriesWithCategories(sourceEntries, currentPiece); return selectPieceFromEntriesWithCategories(sourceEntries);
} }
const entriesToUse = customEntries.length > 0 ? customEntries : builtinEntries; const entriesToUse = customEntries.length > 0 ? customEntries : builtinEntries;
return selectPieceFromEntriesWithCategories(entriesToUse, currentPiece); return selectPieceFromEntriesWithCategories(entriesToUse);
} }
export interface SelectPieceOptions { export interface SelectPieceOptions {
@ -505,7 +460,6 @@ export async function selectPiece(
): Promise<string | null> { ): Promise<string | null> {
const fallbackToDefault = options?.fallbackToDefault !== false; const fallbackToDefault = options?.fallbackToDefault !== false;
const categoryConfig = getPieceCategories(cwd); const categoryConfig = getPieceCategories(cwd);
const currentPiece = resolveConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME;
if (categoryConfig) { if (categoryConfig) {
const allPieces = loadAllPiecesWithSources(cwd); const allPieces = loadAllPiecesWithSources(cwd);
@ -519,7 +473,7 @@ export async function selectPiece(
} }
const categorized = buildCategorizedPieces(allPieces, categoryConfig, cwd); const categorized = buildCategorizedPieces(allPieces, categoryConfig, cwd);
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user')); warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
return selectPieceFromCategorizedPieces(categorized, currentPiece); return selectPieceFromCategorizedPieces(categorized);
} }
const availablePieces = listPieces(cwd); const availablePieces = listPieces(cwd);
@ -533,5 +487,5 @@ export async function selectPiece(
} }
const entries = listPieceEntries(cwd); const entries = listPieceEntries(cwd);
return selectPieceFromEntries(entries, currentPiece); return selectPieceFromEntries(entries);
} }

View File

@ -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. * the Phase 1, Phase 2, and Phase 3 prompts with sample variable values.
*/ */
export async function previewPrompts(cwd: string, pieceIdentifier?: string): Promise<void> { export async function previewPrompts(cwd: string, pieceIdentifier?: string): Promise<void> {
const identifier = pieceIdentifier ?? resolvePieceConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME; const identifier = pieceIdentifier ?? DEFAULT_PIECE_NAME;
const config = loadPieceByIdentifier(identifier, cwd); const config = loadPieceByIdentifier(identifier, cwd);
if (!config) { if (!config) {

View File

@ -93,7 +93,6 @@ export async function runWithWorkerPool(
initialTasks: TaskInfo[], initialTasks: TaskInfo[],
concurrency: number, concurrency: number,
cwd: string, cwd: string,
pieceName: string,
options: TaskExecutionOptions | undefined, options: TaskExecutionOptions | undefined,
pollIntervalMs: number, pollIntervalMs: number,
): Promise<WorkerPoolResult> { ): Promise<WorkerPoolResult> {
@ -119,7 +118,7 @@ export async function runWithWorkerPool(
try { try {
while (queue.length > 0 || active.size > 0) { while (queue.length > 0 || active.size > 0) {
if (!abortController.signal.aborted) { 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) { if ((selfSigintOnce || selfSigintTwice) && !selfSigintInjected && active.size > 0) {
selfSigintInjected = true; selfSigintInjected = true;
process.emit('SIGINT'); process.emit('SIGINT');
@ -197,7 +196,6 @@ function fillSlots(
concurrency: number, concurrency: number,
taskRunner: TaskRunner, taskRunner: TaskRunner,
cwd: string, cwd: string,
pieceName: string,
options: TaskExecutionOptions | undefined, options: TaskExecutionOptions | undefined,
abortController: AbortController, abortController: AbortController,
colorCounter: { value: number }, colorCounter: { value: number },
@ -223,7 +221,7 @@ function fillSlots(
info(`=== Task: ${task.name} ===`); info(`=== Task: ${task.name} ===`);
} }
const promise = executeAndCompleteTask(task, taskRunner, cwd, pieceName, options, { const promise = executeAndCompleteTask(task, taskRunner, cwd, options, {
abortSignal: abortController.signal, abortSignal: abortController.signal,
taskPrefix: isParallel ? taskPrefix : undefined, taskPrefix: isParallel ? taskPrefix : undefined,
taskColorIndex: isParallel ? colorIndex : undefined, taskColorIndex: isParallel ? colorIndex : undefined,

View File

@ -87,14 +87,17 @@ export function resolveTaskIssue(issueNumber: number | undefined): Issue[] | und
export async function resolveTaskExecution( export async function resolveTaskExecution(
task: TaskInfo, task: TaskInfo,
defaultCwd: string, defaultCwd: string,
defaultPiece: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<ResolvedTaskExecution> { ): Promise<ResolvedTaskExecution> {
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
const data = task.data; const data = task.data;
if (!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; let execCwd = defaultCwd;
@ -153,7 +156,7 @@ export async function resolveTaskExecution(
taskPrompt = stageTaskSpecForExecution(defaultCwd, execCwd, task.taskDir, reportDirName); taskPrompt = stageTaskSpecForExecution(defaultCwd, execCwd, task.taskDir, reportDirName);
} }
const execPiece = data.piece || defaultPiece; const execPiece = data.piece;
const startMovement = data.start_movement; const startMovement = data.start_movement;
const retryNote = data.retry_note; const retryNote = data.retry_note;
const maxMovementsOverride = data.exceeded_max_movements; const maxMovementsOverride = data.exceeded_max_movements;

View File

@ -14,7 +14,6 @@ import {
import { createLogger, getErrorMessage, getSlackWebhookUrl, notifyError, notifySuccess, sendSlackNotification, buildSlackRunSummary } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage, getSlackWebhookUrl, notifyError, notifySuccess, sendSlackNotification, buildSlackRunSummary } from '../../../shared/utils/index.js';
import { getLabel } from '../../../shared/i18n/index.js'; import { getLabel } from '../../../shared/i18n/index.js';
import { executePiece } from './pieceExecution.js'; import { executePiece } from './pieceExecution.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js'; import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
import { runWithWorkerPool } from './parallelExecution.js'; import { runWithWorkerPool } from './parallelExecution.js';
import { resolveTaskExecution, resolveTaskIssue } from './resolveTask.js'; import { resolveTaskExecution, resolveTaskIssue } from './resolveTask.js';
@ -26,6 +25,13 @@ export type { TaskExecutionOptions, ExecuteTaskOptions };
const log = createLogger('task'); const log = createLogger('task');
type TaskExecutionParallelOptions = {
abortSignal?: AbortSignal;
taskPrefix?: string;
taskColorIndex?: number;
taskDisplayLabel?: string;
};
async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> { async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> {
const { const {
task, task,
@ -54,7 +60,7 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
} else { } else {
error(`Piece "${pieceIdentifier}" not found.`); error(`Piece "${pieceIdentifier}" not found.`);
info('Available pieces are in ~/.takt/pieces/ or .takt/pieces/'); info('Available pieces are in ~/.takt/pieces/ or .takt/pieces/');
info('Use "takt switch" to select a piece.'); info('Specify a valid piece when creating tasks (e.g., via "takt add").');
return { success: false, reason: `Piece "${pieceIdentifier}" not found.` }; return { success: false, reason: `Piece "${pieceIdentifier}" not found.` };
} }
} }
@ -109,9 +115,8 @@ export async function executeAndCompleteTask(
task: TaskInfo, task: TaskInfo,
taskRunner: TaskRunner, taskRunner: TaskRunner,
cwd: string, cwd: string,
pieceName: string, taskExecutionOptions?: TaskExecutionOptions,
options?: TaskExecutionOptions, parallelOptions?: TaskExecutionParallelOptions,
parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number; taskDisplayLabel?: string },
): Promise<boolean> { ): Promise<boolean> {
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const taskAbortController = new AbortController(); const taskAbortController = new AbortController();
@ -147,7 +152,7 @@ export async function executeAndCompleteTask(
issueNumber, issueNumber,
maxMovementsOverride, maxMovementsOverride,
initialIterationOverride, 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 // cwd is always the project root; pass it as projectCwd so reports/sessions go there
const taskRunResult = await executeTaskWithResult({ const taskRunResult = await executeTaskWithResult({
@ -155,7 +160,7 @@ export async function executeAndCompleteTask(
cwd: execCwd, cwd: execCwd,
pieceIdentifier: execPiece, pieceIdentifier: execPiece,
projectCwd: cwd, projectCwd: cwd,
agentOverrides: options, agentOverrides: taskExecutionOptions,
startMovement, startMovement,
retryNote, retryNote,
reportDirName, reportDirName,
@ -233,7 +238,6 @@ export async function executeAndCompleteTask(
*/ */
export async function runAllTasks( export async function runAllTasks(
cwd: string, cwd: string,
pieceName: string = DEFAULT_PIECE_NAME,
options?: TaskExecutionOptions, options?: TaskExecutionOptions,
): Promise<void> { ): Promise<void> {
const taskRunner = new TaskRunner(cwd); const taskRunner = new TaskRunner(cwd);
@ -286,7 +290,14 @@ export async function runAllTasks(
}; };
try { 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; const totalCount = result.success + result.fail;
blankLine(); blankLine();

View File

@ -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,
},
};
}

View File

@ -27,6 +27,7 @@ import {
selectRunSessionContext, selectRunSessionContext,
} from './requeueHelpers.js'; } from './requeueHelpers.js';
import { executeAndCompleteTask } from '../execute/taskExecution.js'; import { executeAndCompleteTask } from '../execute/taskExecution.js';
import { prepareTaskForExecution } from './prepareTaskForExecution.js';
const log = createLogger('list-tasks'); const log = createLogger('list-tasks');
@ -128,6 +129,7 @@ export async function instructBranch(
const retryNote = appendRetryNote(target.data?.retry_note, instruction); const retryNote = appendRetryNote(target.data?.retry_note, instruction);
const runner = new TaskRunner(projectDir); const runner = new TaskRunner(projectDir);
const taskInfo = runner.startReExecution(target.name, ['completed', 'failed'], undefined, retryNote); const taskInfo = runner.startReExecution(target.name, ['completed', 'failed'], undefined, retryNote);
const taskForExecution = prepareTaskForExecution(taskInfo, selectedPiece);
log.info('Starting re-execution of instructed task', { log.info('Starting re-execution of instructed task', {
name: target.name, name: target.name,
@ -136,7 +138,7 @@ export async function instructBranch(
piece: selectedPiece, piece: selectedPiece,
}); });
return executeAndCompleteTask(taskInfo, runner, projectDir, selectedPiece); return executeAndCompleteTask(taskForExecution, runner, projectDir);
}; };
return dispatchConversationAction(result, { return dispatchConversationAction(result, {

View File

@ -32,6 +32,7 @@ import {
DEPRECATED_PROVIDER_CONFIG_WARNING, DEPRECATED_PROVIDER_CONFIG_WARNING,
hasDeprecatedProviderConfig, hasDeprecatedProviderConfig,
} from './requeueHelpers.js'; } from './requeueHelpers.js';
import { prepareTaskForExecution } from './prepareTaskForExecution.js';
const log = createLogger('list-tasks'); const log = createLogger('list-tasks');
@ -227,6 +228,7 @@ export async function retryFailedTask(
} }
const taskInfo = runner.startReExecution(task.name, ['failed'], startMovement, retryNote); const taskInfo = runner.startReExecution(task.name, ['failed'], startMovement, retryNote);
const taskForExecution = prepareTaskForExecution(taskInfo, selectedPiece);
log.info('Starting re-execution of failed task', { log.info('Starting re-execution of failed task', {
name: task.name, name: task.name,
@ -234,5 +236,5 @@ export async function retryFailedTask(
startMovement, startMovement,
}); });
return executeAndCompleteTask(taskInfo, runner, projectDir, selectedPiece); return executeAndCompleteTask(taskForExecution, runner, projectDir);
} }

View File

@ -6,7 +6,6 @@
*/ */
import { TaskRunner, type TaskInfo, TaskWatcher } from '../../../infra/task/index.js'; import { TaskRunner, type TaskInfo, TaskWatcher } from '../../../infra/task/index.js';
import { resolveConfigValue } from '../../../infra/config/index.js';
import { import {
header, header,
info, info,
@ -18,14 +17,12 @@ import { executeAndCompleteTask } from '../execute/taskExecution.js';
import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; import { EXIT_SIGINT } from '../../../shared/exitCodes.js';
import { ShutdownManager } from '../execute/shutdownManager.js'; import { ShutdownManager } from '../execute/shutdownManager.js';
import type { TaskExecutionOptions } from '../execute/types.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. * Watch for tasks and execute them as they appear.
* Runs until Ctrl+C. * Runs until Ctrl+C.
*/ */
export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> { export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> {
const pieceName = resolveConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME;
const taskRunner = new TaskRunner(cwd); const taskRunner = new TaskRunner(cwd);
const watcher = new TaskWatcher(cwd); const watcher = new TaskWatcher(cwd);
const recovered = taskRunner.recoverInterruptedRunningTasks(); const recovered = taskRunner.recoverInterruptedRunningTasks();
@ -35,7 +32,6 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P
let failCount = 0; let failCount = 0;
header('TAKT Watch Mode'); header('TAKT Watch Mode');
info(`Piece: ${pieceName}`);
info(`Watching: ${taskRunner.getTasksFilePath()}`); info(`Watching: ${taskRunner.getTasksFilePath()}`);
if (recovered > 0) { if (recovered > 0) {
info(`Recovered ${recovered} interrupted running task(s) to pending.`); 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} ===`); info(`=== Task ${taskCount}: ${task.name} ===`);
blankLine(); blankLine();
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, pieceName, options); const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, options);
if (taskSuccess) { if (taskSuccess) {
successCount++; successCount++;

View File

@ -32,7 +32,6 @@ export type { PieceSource, PieceWithSource, PieceDirEntry } from './infra/config
export { export {
saveProjectConfig, saveProjectConfig,
updateProjectConfig, updateProjectConfig,
setCurrentPiece,
isVerboseMode, isVerboseMode,
type ProjectLocalConfig, type ProjectLocalConfig,
} from './infra/config/project/index.js'; } from './infra/config/project/index.js';

View File

@ -126,7 +126,6 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
]; ];
const PROJECT_ENV_SPECS: readonly EnvSpec[] = [ const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'piece', type: 'string' },
{ path: 'log_level', type: 'string' }, { path: 'log_level', type: 'string' },
{ path: 'provider', type: 'string' }, { path: 'provider', type: 'string' },
{ path: 'model', type: 'string' }, { path: 'model', type: 'string' },

View File

@ -79,7 +79,6 @@ export class GlobalConfigManager {
language: parsed.language, language: parsed.language,
provider: normalizedProvider.provider, provider: normalizedProvider.provider,
model: normalizedProvider.model, model: normalizedProvider.model,
piece: parsed.piece,
observability: parsed.observability ? { observability: parsed.observability ? {
providerEvents: parsed.observability.provider_events, providerEvents: parsed.observability.provider_events,
} : undefined, } : undefined,
@ -149,9 +148,6 @@ export class GlobalConfigManager {
if (config.model) { if (config.model) {
raw.model = config.model; raw.model = config.model;
} }
if (config.piece) {
raw.piece = config.piece;
}
if (config.observability && config.observability.providerEvents !== undefined) { if (config.observability && config.observability.providerEvents !== undefined) {
raw.observability = { raw.observability = {
provider_events: config.observability.providerEvents, provider_events: config.observability.providerEvents,

View File

@ -145,7 +145,6 @@ export {
loadProjectConfig, loadProjectConfig,
saveProjectConfig, saveProjectConfig,
updateProjectConfig, updateProjectConfig,
setCurrentPiece,
type ProjectLocalConfig, type ProjectLocalConfig,
} from './project/projectConfig.js'; } from './project/projectConfig.js';
export { export {

View File

@ -6,7 +6,6 @@ export {
loadProjectConfig, loadProjectConfig,
saveProjectConfig, saveProjectConfig,
updateProjectConfig, updateProjectConfig,
setCurrentPiece,
type ProjectLocalConfig, type ProjectLocalConfig,
} from './projectConfig.js'; } from './projectConfig.js';
export { export {

View File

@ -283,7 +283,3 @@ export function updateProjectConfig<K extends keyof ProjectLocalConfig>(
config[key] = value; config[key] = value;
saveProjectConfig(projectDir, config); saveProjectConfig(projectDir, config);
} }
export function setCurrentPiece(projectDir: string, piece: string): void {
updateProjectConfig(projectDir, 'piece', piece);
}

View File

@ -75,7 +75,6 @@ const MIGRATED_PROJECT_LOCAL_CONFIG_KEY_SET = new Set(
); );
const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = { const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = {
piece: { layers: ['local', 'global'] },
provider: { provider: {
layers: ['local', 'piece', 'global'], layers: ['local', 'piece', 'global'],
pieceValue: (pieceContext) => pieceContext?.provider, pieceValue: (pieceContext) => pieceContext?.provider,

View File

@ -5,7 +5,6 @@ import type { MigratedProjectLocalConfigKey } from './migratedProjectLocalKeys.j
export interface LoadedConfig export interface LoadedConfig
extends PersistedGlobalConfig, extends PersistedGlobalConfig,
Pick<ProjectLocalConfig, MigratedProjectLocalConfigKey> { Pick<ProjectLocalConfig, MigratedProjectLocalConfigKey> {
piece?: string;
logLevel: NonNullable<ProjectLocalConfig['logLevel']>; logLevel: NonNullable<ProjectLocalConfig['logLevel']>;
minimalOutput: NonNullable<ProjectLocalConfig['minimalOutput']>; minimalOutput: NonNullable<ProjectLocalConfig['minimalOutput']>;
verbose: NonNullable<ProjectLocalConfig['verbose']>; verbose: NonNullable<ProjectLocalConfig['verbose']>;

View File

@ -14,8 +14,6 @@ import type {
/** Project configuration stored in .takt/config.yaml */ /** Project configuration stored in .takt/config.yaml */
export interface ProjectLocalConfig { export interface ProjectLocalConfig {
/** Current piece name */
piece?: string;
/** Provider selection for agent runtime */ /** Provider selection for agent runtime */
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
/** Model selection for agent runtime */ /** Model selection for agent runtime */