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

View File

@ -51,4 +51,36 @@ describe('E2E: Help command (takt --help)', () => {
const output = result.stdout.toLowerCase();
expect(output).toMatch(/run|task|pending/);
});
it('should show prompt argument help without current-piece wording', () => {
// Given: a local repo with isolated env
// When: running takt prompt --help
const result = runTakt({
args: ['prompt', '--help'],
cwd: repo.path,
env: isolatedEnv.env,
});
// Then: prompt help uses explicit default piece wording
expect(result.exitCode).toBe(0);
expect(result.stdout).toMatch(/defaults to ["']default["']/i);
expect(result.stdout).not.toMatch(/defaults to current/i);
});
it('should fail with unknown command for removed switch subcommand', () => {
// Given: a local repo with isolated env
// When: running removed takt switch command
const result = runTakt({
args: ['switch'],
cwd: repo.path,
env: isolatedEnv.env,
});
// Then: command exits non-zero and reports unknown command
const combined = `${result.stdout}\n${result.stderr}`;
expect(result.exitCode).not.toBe(0);
expect(combined).toMatch(/unknown command/i);
});
});

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

View File

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

View File

@ -8,7 +8,8 @@
*/
import { describe, it, expect } from 'vitest';
import { isDirectTask } from '../app/cli/helpers.js';
import type { Command } from 'commander';
import { isDirectTask, resolveAgentOverrides, resolveRemovedRootCommand, resolveSlashFallbackTask } from '../app/cli/helpers.js';
describe('isDirectTask', () => {
describe('slash prefixed inputs', () => {
@ -103,3 +104,55 @@ describe('isDirectTask', () => {
});
});
});
describe('resolveSlashFallbackTask', () => {
it('returns raw argv as task for unknown slash command', () => {
const task = resolveSlashFallbackTask(['/foo', '--bar'], ['run', 'add', 'watch']);
expect(task).toBe('/foo --bar');
});
it('returns null for known slash command', () => {
const task = resolveSlashFallbackTask(['/run', '--help'], ['run', 'add', 'watch']);
expect(task).toBeNull();
});
it('returns null when first argument is not slash-prefixed', () => {
const task = resolveSlashFallbackTask(['run', '/foo'], ['run', 'add', 'watch']);
expect(task).toBeNull();
});
});
describe('resolveRemovedRootCommand', () => {
it('returns removed command when first argument is switch', () => {
expect(resolveRemovedRootCommand(['switch'])).toBe('switch');
});
it('returns null when first argument is a valid command', () => {
expect(resolveRemovedRootCommand(['run'])).toBeNull();
});
it('returns null when argument only contains removed command in later position', () => {
expect(resolveRemovedRootCommand(['--help', 'switch'])).toBeNull();
});
});
describe('resolveAgentOverrides', () => {
it('returns undefined when provider and model are both missing', () => {
const program = {
opts: () => ({}),
} as unknown as Command;
expect(resolveAgentOverrides(program)).toBeUndefined();
});
it('returns provider/model pair when one or both are provided', () => {
const program = {
opts: () => ({ provider: 'codex', model: 'gpt-5' }),
} as unknown as Command;
expect(resolveAgentOverrides(program)).toEqual({
provider: 'codex',
model: 'gpt-5',
});
});
});

View File

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

View File

@ -3,8 +3,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockOpts: Record<string, unknown> = {};
const mockAddTask = vi.fn();
const { rootCommand, commandActions } = vi.hoisted(() => {
const { rootCommand, commandActions, commandMocks } = vi.hoisted(() => {
const commandActions = new Map<string, (...args: unknown[]) => void>();
const commandMocks = new Map<string, Record<string, unknown>>();
function createCommandMock(actionKey: string): {
description: ReturnType<typeof vi.fn>;
@ -20,6 +21,7 @@ const { rootCommand, commandActions } = vi.hoisted(() => {
option: vi.fn().mockReturnThis(),
opts: vi.fn(() => mockOpts),
};
commandMocks.set(actionKey, command);
command.command = vi.fn((subName: string) => createCommandMock(`${actionKey}.${subName}`));
command.action = vi.fn((action: (...args: unknown[]) => void) => {
@ -40,6 +42,7 @@ const { rootCommand, commandActions } = vi.hoisted(() => {
return {
rootCommand: createCommandMock('root'),
commandActions,
commandMocks,
};
});
@ -71,7 +74,6 @@ vi.mock('../features/tasks/index.js', () => ({
vi.mock('../features/config/index.js', () => ({
clearPersonaSessions: vi.fn(),
switchPiece: vi.fn(),
ejectBuiltin: vi.fn(),
ejectFacet: vi.fn(),
parseFacetType: vi.fn(),
@ -112,7 +114,7 @@ import '../app/cli/commands.js';
describe('CLI add command', () => {
beforeEach(() => {
vi.clearAllMocks();
mockAddTask.mockClear();
for (const key of Object.keys(mockOpts)) {
delete mockOpts[key];
}
@ -141,4 +143,20 @@ describe('CLI add command', () => {
expect(mockAddTask).toHaveBeenCalledWith('/test/cwd', 'Regular task', undefined);
});
});
it('should not register switch command', () => {
const calledCommandNames = rootCommand.command.mock.calls
.map((call: unknown[]) => call[0] as string);
expect(calledCommandNames).not.toContain('switch');
});
it('should describe prompt piece argument as defaulting to "default"', () => {
const promptCommand = commandMocks.get('root.prompt');
expect(promptCommand).toBeTruthy();
expect(promptCommand?.argument).toHaveBeenCalledWith(
'[piece]',
'Piece name or path (defaults to "default")',
);
});
});

View File

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

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', () => {
writeFileSync(
join(projectDir, '.takt', 'config.yaml'),
'piece: default\n',
'',
'utf-8',
);
invalidateGlobalConfigCache();
@ -142,7 +142,7 @@ describe('IT: migrated config keys should prefer project over global', () => {
it('should mark migrated key source as global when only global defines the key', () => {
writeFileSync(
join(projectDir, '.takt', 'config.yaml'),
'piece: default\n',
'',
'utf-8',
);
invalidateGlobalConfigCache();

View File

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

View File

@ -50,6 +50,9 @@ function createTask(name: string): TaskInfo {
name,
content: `Task: ${name}`,
filePath: `/tasks/${name}.yaml`,
createdAt: '2026-01-01T00:00:00.000Z',
status: 'pending',
data: { task: `Task: ${name}`, piece: 'default' },
};
}
@ -88,14 +91,14 @@ describe('worker pool: abort signal propagation', () => {
const receivedSignals: (AbortSignal | undefined)[] = [];
mockExecuteAndCompleteTask.mockImplementation(
(_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
(_task: unknown, _runner: unknown, _cwd: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
receivedSignals.push(parallelOpts?.abortSignal);
return Promise.resolve(true);
},
);
// When
await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, 50);
await runWithWorkerPool(runner as never, tasks, 1, '/cwd', undefined, 50);
// Then: AbortSignal is passed even with concurrency=1
expect(receivedSignals).toHaveLength(1);
@ -109,7 +112,7 @@ describe('worker pool: abort signal propagation', () => {
let capturedSignal: AbortSignal | undefined;
mockExecuteAndCompleteTask.mockImplementation(
(_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
(_task: unknown, _runner: unknown, _cwd: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
capturedSignal = parallelOpts?.abortSignal;
return new Promise((resolve) => {
// Wait long enough for SIGINT to fire
@ -119,7 +122,7 @@ describe('worker pool: abort signal propagation', () => {
);
// Start execution
const resultPromise = runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, 50);
const resultPromise = runWithWorkerPool(runner as never, tasks, 1, '/cwd', undefined, 50);
// Wait for task to start
await new Promise((resolve) => setTimeout(resolve, 20));
@ -149,25 +152,25 @@ describe('worker pool: abort signal propagation', () => {
const receivedSignalsPar: (AbortSignal | undefined)[] = [];
mockExecuteAndCompleteTask.mockImplementation(
(_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
(_task: unknown, _runner: unknown, _cwd: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
receivedSignalsSeq.push(parallelOpts?.abortSignal);
return Promise.resolve(true);
},
);
// Sequential mode
await runWithWorkerPool(runner as never, [...tasks], 1, '/cwd', 'default', undefined, 50);
await runWithWorkerPool(runner as never, [...tasks], 1, '/cwd', undefined, 50);
mockExecuteAndCompleteTask.mockClear();
mockExecuteAndCompleteTask.mockImplementation(
(_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
(_task: unknown, _runner: unknown, _cwd: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
receivedSignalsPar.push(parallelOpts?.abortSignal);
return Promise.resolve(true);
},
);
// Parallel mode
await runWithWorkerPool(runner as never, [...tasks], 2, '/cwd', 'default', undefined, 50);
await runWithWorkerPool(runner as never, [...tasks], 2, '/cwd', undefined, 50);
// Then: Both modes pass AbortSignal
for (const signal of receivedSignalsSeq) {

View File

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

View File

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

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

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 () => {
vi.doMock('../infra/config/project/projectConfig.js', () => ({
loadProjectConfig: () => ({ piece: 'default' }),
loadProjectConfig: () => ({}),
}));
vi.doMock('../infra/config/global/globalConfig.js', () => ({
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', () => {
it('should resolve verbose to false via resolver default when not set anywhere', () => {
const value = resolveConfigValue(projectDir, 'verbose');
@ -116,7 +85,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\nverbose: true\n');
writeFileSync(join(configDir, 'config.yaml'), 'verbose: true\n');
const value = resolveConfigValue(projectDir, 'verbose');
expect(value).toBe(true);
@ -227,7 +196,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
it('should resolve migrated non-default keys as undefined when project keys are unset', () => {
const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8');
writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8');
writeFileSync(
globalConfigPath,
['language: en'].join('\n'),
@ -256,7 +225,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
it('should resolve default-backed migrated keys from defaults when project keys are unset', () => {
const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8');
writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8');
writeFileSync(
globalConfigPath,
['language: en'].join('\n'),
@ -344,7 +313,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
it('should resolve all migrated keys from project or defaults when project config has no migrated keys', () => {
const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8');
writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8');
writeFileSync(
globalConfigPath,
['language: en'].join('\n'),

View File

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

View File

@ -40,6 +40,13 @@ vi.mock('../infra/config/index.js', () => ({
}
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) => {
const raw = mockLoadConfigRaw() as Record<string, unknown>;
const config = ('global' in raw && 'project' in raw)
@ -188,7 +195,10 @@ function createTask(name: string): TaskInfo {
filePath: `/tasks/${name}.yaml`,
createdAt: '2026-02-09T00:00:00.000Z',
status: 'pending',
data: null,
data: {
task: `Task: ${name}`,
piece: 'default',
},
};
}

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

View File

@ -158,6 +158,33 @@ describe('instructBranch direct execution flow', () => {
expect(mockExecuteAndCompleteTask).toHaveBeenCalled();
});
it('should execute with selected piece without mutating taskInfo', async () => {
mockSelectPiece.mockResolvedValue('selected-piece');
const originalTaskInfo = {
name: 'done-task',
content: 'done',
data: { task: 'done', piece: 'original-piece' },
};
mockStartReExecution.mockReturnValue(originalTaskInfo);
await instructBranch('/project', {
kind: 'completed',
name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done' },
});
const executeArg = mockExecuteAndCompleteTask.mock.calls[0]?.[0];
expect(executeArg).not.toBe(originalTaskInfo);
expect(executeArg.data).not.toBe(originalTaskInfo.data);
expect(executeArg.data.piece).toBe('selected-piece');
expect(originalTaskInfo.data.piece).toBe('original-piece');
});
it('should set generated instruction as retry note when no existing note', async () => {
await instructBranch('/project', {
kind: 'completed',

View File

@ -179,6 +179,25 @@ describe('retryFailedTask', () => {
expect(mockExecuteAndCompleteTask).toHaveBeenCalled();
});
it('should execute with selected piece without mutating taskInfo', async () => {
mockSelectPiece.mockResolvedValue('selected-piece');
const originalTaskInfo = {
name: 'my-task',
content: 'Do something',
data: { task: 'Do something', piece: 'original-piece' },
};
mockStartReExecution.mockReturnValue(originalTaskInfo);
const task = makeFailedTask();
await retryFailedTask(task, '/project');
const executeArg = mockExecuteAndCompleteTask.mock.calls[0]?.[0];
expect(executeArg).not.toBe(originalTaskInfo);
expect(executeArg.data).not.toBe(originalTaskInfo.data);
expect(executeArg.data.piece).toBe('selected-piece');
expect(originalTaskInfo.data.piece).toBe('original-piece');
});
it('should pass failed movement as default to selectOptionWithDefault', async () => {
const task = makeFailedTask(); // failure.movement = 'review'

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
/**
* CLI subcommand definitions
*
* Registers all named subcommands (run, watch, add, list, switch, clear, eject, prompt, catalog).
* Registers all named subcommands (run, watch, add, list, clear, eject, prompt, catalog).
*/
import { join } from 'node:path';
@ -9,7 +9,7 @@ import { clearPersonaSessions, resolveConfigValue } from '../../infra/config/ind
import { getGlobalConfigDir } from '../../infra/config/paths.js';
import { success, info } from '../../shared/ui/index.js';
import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js';
import { switchPiece, ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, resetConfigToDefault, deploySkill } from '../../features/config/index.js';
import { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, resetConfigToDefault, deploySkill } from '../../features/config/index.js';
import { previewPrompts } from '../../features/prompt/index.js';
import { showCatalog } from '../../features/catalog/index.js';
import { computeReviewMetrics, formatReviewMetrics, parseSinceDuration, purgeOldEvents } from '../../features/analytics/index.js';
@ -23,8 +23,7 @@ program
.command('run')
.description('Run all pending tasks from .takt/tasks.yaml')
.action(async () => {
const piece = resolveConfigValue(resolvedCwd, 'piece');
await runAllTasks(resolvedCwd, piece, resolveAgentOverrides(program));
await runAllTasks(resolvedCwd, resolveAgentOverrides(program));
});
program
@ -65,14 +64,6 @@ program
);
});
program
.command('switch')
.description('Switch piece interactively')
.argument('[piece]', 'Piece name')
.action(async (piece?: string) => {
await switchPiece(resolvedCwd, piece);
});
program
.command('clear')
.description('Clear agent conversation sessions')
@ -124,7 +115,7 @@ reset
program
.command('prompt')
.description('Preview assembled prompts for each movement and phase')
.argument('[piece]', 'Piece name or path (defaults to current)')
.argument('[piece]', 'Piece name or path (defaults to "default")')
.action(async (piece?: string) => {
await previewPrompts(resolvedCwd, piece);
});

View File

@ -9,6 +9,8 @@ import type { TaskExecutionOptions } from '../../features/tasks/index.js';
import type { ProviderType } from '../../infra/providers/index.js';
import { isIssueReference } from '../../infra/github/index.js';
const REMOVED_ROOT_COMMANDS = new Set(['switch']);
/**
* Resolve --provider and --model options into TaskExecutionOptions.
* Returns undefined if neither is specified.
@ -42,3 +44,25 @@ export function resolveAgentOverrides(program: Command): TaskExecutionOptions |
export function isDirectTask(input: string): boolean {
return isIssueReference(input) || input.trim().split(/\s+/).every((t: string) => isIssueReference(t));
}
export function resolveSlashFallbackTask(args: string[], knownCommands: string[]): string | null {
const firstArg = args[0];
if (!firstArg?.startsWith('/')) {
return null;
}
const commandName = firstArg.slice(1);
if (knownCommands.includes(commandName)) {
return null;
}
return args.join(' ');
}
export function resolveRemovedRootCommand(args: string[]): string | null {
const firstArg = args[0];
if (!firstArg) {
return null;
}
return REMOVED_ROOT_COMMANDS.has(firstArg) ? firstArg : null;
}

View File

@ -9,6 +9,7 @@
import { checkForUpdates } from '../../shared/utils/index.js';
import { getErrorMessage } from '../../shared/utils/error.js';
import { error as errorLog } from '../../shared/ui/index.js';
import { resolveRemovedRootCommand, resolveSlashFallbackTask } from './helpers.js';
checkForUpdates();
@ -19,20 +20,20 @@ import { executeDefaultAction } from './routing.js';
(async () => {
const args = process.argv.slice(2);
const firstArg = args[0];
// Handle '/' prefixed inputs that are not known commands
if (firstArg?.startsWith('/')) {
const commandName = firstArg.slice(1);
const knownCommands = program.commands.map((cmd) => cmd.name());
if (!knownCommands.includes(commandName)) {
// Treat as task instruction
const task = args.join(' ');
await runPreActionHook();
await executeDefaultAction(task);
process.exit(0);
const { operands } = program.parseOptions(args);
const removedRootCommand = resolveRemovedRootCommand(operands);
if (removedRootCommand !== null) {
errorLog(`error: unknown command '${removedRootCommand}'`);
process.exit(1);
}
const knownCommands = program.commands.map((cmd) => cmd.name());
const slashFallbackTask = resolveSlashFallbackTask(args, knownCommands);
if (slashFallbackTask !== null) {
await runPreActionHook();
await executeDefaultAction(slashFallbackTask);
process.exit(0);
}
// 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 { loadTaskHistory } from './taskHistory.js';
import { resolveIssueInput, resolvePrInput } from './routing-inputs.js';
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
export async function executeDefaultAction(task?: string): Promise<void> {
const opts = program.opts();
if (!pipelineMode && (opts.autoPr === true || opts.draft === true)) {
@ -39,9 +38,11 @@ export async function executeDefaultAction(task?: string): Promise<void> {
process.exit(1);
}
const agentOverrides = resolveAgentOverrides(program);
const resolvedPipelinePiece = (opts.piece as string | undefined)
?? resolveConfigValue(resolvedCwd, 'piece')
?? DEFAULT_PIECE_NAME;
const resolvedPipelinePiece = opts.piece as string | undefined;
if (pipelineMode && resolvedPipelinePiece === undefined) {
logError('--piece (-w) is required in pipeline mode');
process.exit(1);
}
const resolvedPipelineAutoPr = opts.autoPr === true
? true
: (resolveConfigValue(resolvedCwd, 'autoPr') ?? false);
@ -57,7 +58,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
issueNumber,
prNumber,
task: opts.task as string | undefined,
piece: resolvedPipelinePiece,
piece: resolvedPipelinePiece!,
branch: opts.branch as string | undefined,
autoPr: resolvedPipelineAutoPr,
draftPr: resolvedPipelineDraftPr,

View File

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

View File

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

View File

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

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

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.
*/
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);
if (!config) {

View File

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

View File

@ -87,14 +87,17 @@ export function resolveTaskIssue(issueNumber: number | undefined): Issue[] | und
export async function resolveTaskExecution(
task: TaskInfo,
defaultCwd: string,
defaultPiece: string,
abortSignal?: AbortSignal,
): Promise<ResolvedTaskExecution> {
throwIfAborted(abortSignal);
const data = task.data;
if (!data) {
return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false, autoPr: false, draftPr: false };
throw new Error(`Task "${task.name}" is missing required data, including piece.`);
}
if (!data.piece || typeof data.piece !== 'string' || data.piece.trim() === '') {
throw new Error(`Task "${task.name}" is missing required piece.`);
}
let execCwd = defaultCwd;
@ -153,7 +156,7 @@ export async function resolveTaskExecution(
taskPrompt = stageTaskSpecForExecution(defaultCwd, execCwd, task.taskDir, reportDirName);
}
const execPiece = data.piece || defaultPiece;
const execPiece = data.piece;
const startMovement = data.start_movement;
const retryNote = data.retry_note;
const maxMovementsOverride = data.exceeded_max_movements;

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -283,7 +283,3 @@ export function updateProjectConfig<K extends keyof ProjectLocalConfig>(
config[key] = value;
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> }> = {
piece: { layers: ['local', 'global'] },
provider: {
layers: ['local', 'piece', 'global'],
pieceValue: (pieceContext) => pieceContext?.provider,

View File

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

View File

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