Merge pull request #275 from nrslib/release/v0.15.0

Release v0.15.0
This commit is contained in:
nrs 2026-02-15 06:19:30 +09:00 committed by GitHub
commit dcfcd377be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 846 additions and 9 deletions

View File

@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.15.0] - 2026-02-15
### Added
- **ランタイム環境プリセット**: `piece_config.runtime.prepare` およびグローバル設定の `runtime.prepare` で、ピース実行前に環境準備スクリプトを自動実行可能に — ビルトインプリセット(`gradle`, `node`)で依存解決・キャッシュ設定を `.runtime/` ディレクトリに隔離
- **ループモニターの judge インストラクション**: `loop_monitors` の judge 設定で `instruction_template` フィールドをサポート — ループ判定の指示をインストラクションファセットとして外部化し、ビルトインピースexpert, expert-cqrsに適用
### Internal
- ランタイム環境関連のテスト追加runtime-environment, globalConfig-defaults, models, provider-options-piece-parser
- provider e2e テスト追加runtime-config-provider
## [0.14.0] - 2026-02-14
### Added

View File

@ -578,6 +578,12 @@ concurrency: 1 # Parallel task count for takt run (1-10, default: 1 =
task_poll_interval_ms: 500 # Polling interval for new tasks during takt run (100-5000, default: 500)
interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, default: 3)
# Runtime environment defaults (applies to all pieces unless piece_config.runtime overrides)
# runtime:
# prepare:
# - gradle # Prepare Gradle cache/config in .runtime/
# - node # Prepare npm cache in .runtime/
# Per-persona provider overrides (optional)
# Route specific personas to different providers without duplicating pieces
# persona_providers:
@ -817,8 +823,14 @@ piece_config:
network_access: true
opencode:
network_access: true
runtime:
prepare:
- gradle
- node
```
Runtime `prepare` entries can be builtin presets (`gradle`, `node`) or paths to custom shell scripts. Scripts receive `TAKT_RUNTIME_ROOT` and related env vars, and can export additional variables via stdout.
## API Usage Example
```typescript

View File

@ -45,6 +45,12 @@ provider: claude
# Prevent macOS idle sleep during execution using caffeinate (default: false)
# prevent_sleep: false
# Runtime environment defaults (applies to all pieces unless piece_config.runtime overrides)
# runtime:
# prepare:
# - gradle
# - node
# ── Parallel Execution (takt run) ──
# Number of tasks to run concurrently (1 = sequential, max: 10)

View File

@ -0,0 +1,12 @@
The ai_review ↔ ai_fix loop has repeated {cycle_count} times.
Review the reports from each cycle and determine whether this loop
is healthy (making progress) or unproductive (repeating the same issues).
**Reports to reference:**
- AI Review results: {report:03-ai-review.md}
**Judgment criteria:**
- Are new issues being found/fixed in each cycle?
- Are the same findings being repeated?
- Are fixes actually being applied?

View File

@ -9,6 +9,19 @@ piece_config:
max_movements: 30
initial_movement: plan
loop_monitors:
- cycle:
- ai_review
- ai_fix
threshold: 3
judge:
persona: supervisor
instruction_template: loop-monitor-ai-fix
rules:
- condition: Healthy (making progress)
next: ai_review
- condition: Unproductive (same findings repeated or fixes not reflected)
next: ai_no_fix
movements:
- name: plan
edit: false

View File

@ -9,6 +9,19 @@ piece_config:
max_movements: 30
initial_movement: plan
loop_monitors:
- cycle:
- ai_review
- ai_fix
threshold: 3
judge:
persona: supervisor
instruction_template: loop-monitor-ai-fix
rules:
- condition: Healthy (making progress)
next: ai_review
- condition: Unproductive (same findings repeated or fixes not reflected)
next: ai_no_fix
movements:
- name: plan
edit: false

View File

@ -45,6 +45,12 @@ provider: claude
# macOS のアイドルスリープを防止(デフォルト: false
# prevent_sleep: false
# 実行時ランタイム環境のデフォルトpiece_config.runtime があればそちらを優先)
# runtime:
# prepare:
# - gradle
# - node
# ── 並列実行 (takt run) ──
# タスクの同時実行数1 = 逐次実行、最大: 10

View File

@ -0,0 +1,12 @@
ai_review と ai_fix のループが {cycle_count} 回繰り返されました。
各サイクルのレポートを確認し、このループが健全(進捗がある)か、
非生産的(同じ問題を繰り返している)かを判断してください。
**参照するレポート:**
- AIレビュー結果: {report:03-ai-review.md}
**判断基準:**
- 各サイクルで新しい問題が発見・修正されているか
- 同じ指摘が繰り返されていないか
- 修正が実際に反映されているか

View File

@ -9,6 +9,19 @@ piece_config:
max_movements: 30
initial_movement: plan
loop_monitors:
- cycle:
- ai_review
- ai_fix
threshold: 3
judge:
persona: supervisor
instruction_template: loop-monitor-ai-fix
rules:
- condition: 健全(進捗あり)
next: ai_review
- condition: 非生産的(同じ指摘の反復・修正未反映)
next: ai_no_fix
movements:
- name: plan
edit: false

View File

@ -9,6 +9,19 @@ piece_config:
max_movements: 30
initial_movement: plan
loop_monitors:
- cycle:
- ai_review
- ai_fix
threshold: 3
judge:
persona: supervisor
instruction_template: loop-monitor-ai-fix
rules:
- condition: 健全(進捗あり)
next: ai_review
- condition: 非生産的(同じ指摘の反復・修正未反映)
next: ai_no_fix
movements:
- name: plan
edit: false

View File

@ -578,6 +578,12 @@ concurrency: 1 # takt run の並列タスク数1-10、デフォル
task_poll_interval_ms: 500 # takt run 中の新タスク検出ポーリング間隔100-5000、デフォルト: 500
interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数0-10、デフォルト: 3
# ランタイム環境デフォルトpiece_config.runtime で上書き可能)
# runtime:
# prepare:
# - gradle # Gradle のキャッシュ/設定を .runtime/ に準備
# - node # npm キャッシュを .runtime/ に準備
# ペルソナ別プロバイダー設定(オプション)
# ピースを複製せずに特定のペルソナを異なるプロバイダーにルーティング
# persona_providers:
@ -817,8 +823,14 @@ piece_config:
network_access: true
opencode:
network_access: true
runtime:
prepare:
- gradle
- node
```
`runtime.prepare` にはビルトインプリセット(`gradle``node`)またはカスタムシェルスクリプトのパスを指定できます。スクリプトは `TAKT_RUNTIME_ROOT` などの環境変数を受け取り、stdout で追加の環境変数をエクスポートできます。
## API使用例
```typescript

View File

@ -113,6 +113,14 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
- `takt run --provider mock` を起動し、`=== Running Piece:` が出たら `Ctrl+C` を送る。
- 3件目タスク`sigint-c`)が開始されないことを確認する。
- `=== Tasks Summary ===` 以降に新規タスク開始やクローン作成ログが出ないことを確認する。
- Runtime config injection with provider`e2e/specs/runtime-config-provider.e2e.ts`
- 目的: `config.yaml``runtime.prepare` が provider 実行時に反映される正例と、未設定時の失敗再現env未注入を確認。
- LLM: 条件付き(`TAKT_E2E_PROVIDER``claude` / `codex` / `opencode` の場合に実行、未指定時は skip
- 手順(ユーザー行動/コマンド):
- E2E用 `config.yaml``runtime.prepare: [gradle, node]` を設定する。
- `takt --task '<gradle/npm を実行する指示>' --piece e2e/fixtures/pieces/simple.yaml --create-worktree no` を実行する。
- 正例では、作業リポジトリに `.runtime/env.sh``.runtime/{tmp,cache,config,state,gradle,npm}` が作成されていることを確認する。
- 負例(`runtime.prepare` 未設定)では、`GRADLE_USER_HOME is required` と npm キャッシュ書き込み失敗が出力され、`.runtime/env.sh` が生成されないことを確認する。
- List tasks non-interactive`e2e/specs/list-non-interactive.e2e.ts`
- 目的: `takt list` の非対話モードでブランチ操作ができることを確認。
- LLM: 呼び出さないLLM不使用の操作のみ

View File

@ -1,11 +1,31 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
import { createTestRepo, type TestRepo } from '../helpers/test-repo';
import { runTakt } from '../helpers/takt-runner';
function writeCompletedTask(repoPath: string, name: string, branch: string): void {
const taktDir = join(repoPath, '.takt');
mkdirSync(taktDir, { recursive: true });
const now = new Date().toISOString();
writeFileSync(
join(taktDir, 'tasks.yaml'),
[
'tasks:',
` - name: ${name}`,
' status: completed',
` content: "E2E test task for ${name}"`,
` branch: "${branch}"`,
` created_at: "${now}"`,
` started_at: "${now}"`,
` completed_at: "${now}"`,
].join('\n'),
'utf-8',
);
}
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: List tasks non-interactive (takt list)', () => {
let isolatedEnv: IsolatedEnv;
@ -38,6 +58,8 @@ describe('E2E: List tasks non-interactive (takt list)', () => {
execFileSync('git', ['commit', '-m', 'takt: list diff e2e'], { cwd: testRepo.path, stdio: 'pipe' });
execFileSync('git', ['checkout', testRepo.branch], { cwd: testRepo.path, stdio: 'pipe' });
writeCompletedTask(testRepo.path, 'e2e-list-diff', branchName);
const result = runTakt({
args: ['list', '--non-interactive', '--action', 'diff', '--branch', branchName],
cwd: testRepo.path,
@ -58,6 +80,8 @@ describe('E2E: List tasks non-interactive (takt list)', () => {
execFileSync('git', ['commit', '-m', 'takt: list try e2e'], { cwd: testRepo.path, stdio: 'pipe' });
execFileSync('git', ['checkout', testRepo.branch], { cwd: testRepo.path, stdio: 'pipe' });
writeCompletedTask(testRepo.path, 'e2e-list-try', branchName);
const result = runTakt({
args: ['list', '--non-interactive', '--action', 'try', '--branch', branchName],
cwd: testRepo.path,
@ -84,6 +108,8 @@ describe('E2E: List tasks non-interactive (takt list)', () => {
execFileSync('git', ['commit', '-m', 'takt: list merge e2e'], { cwd: testRepo.path, stdio: 'pipe' });
execFileSync('git', ['checkout', testRepo.branch], { cwd: testRepo.path, stdio: 'pipe' });
writeCompletedTask(testRepo.path, 'e2e-list-merge', branchName);
const result = runTakt({
args: ['list', '--non-interactive', '--action', 'merge', '--branch', branchName],
cwd: testRepo.path,
@ -110,6 +136,8 @@ describe('E2E: List tasks non-interactive (takt list)', () => {
execFileSync('git', ['commit', '-m', 'takt: list e2e'], { cwd: testRepo.path, stdio: 'pipe' });
execFileSync('git', ['checkout', testRepo.branch], { cwd: testRepo.path, stdio: 'pipe' });
writeCompletedTask(testRepo.path, 'e2e-list-test', branchName);
const result = runTakt({
args: ['list', '--non-interactive', '--action', 'delete', '--branch', branchName, '--yes'],
cwd: testRepo.path,

View File

@ -0,0 +1,168 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createIsolatedEnv, type IsolatedEnv, updateIsolatedConfig } from '../helpers/isolated-env';
import { runTakt } from '../helpers/takt-runner';
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const provider = process.env.TAKT_E2E_PROVIDER;
const providerEnabled = provider != null && provider !== 'mock';
const providerIt = providerEnabled ? it : it.skip;
// E2E更新時は docs/testing/e2e.md も更新すること
describe('E2E: runtime.prepare with provider', () => {
let isolatedEnv: IsolatedEnv;
let repo: LocalRepo;
beforeEach(() => {
isolatedEnv = createIsolatedEnv();
repo = createLocalRepo();
mkdirSync(join(repo.path, 'scripts'), { recursive: true });
writeFileSync(
join(repo.path, 'gradlew'),
[
'#!/usr/bin/env bash',
'set -euo pipefail',
'if [ -z "${GRADLE_USER_HOME:-}" ]; then echo "GRADLE_USER_HOME is required"; exit 2; fi',
'if [ -z "${TMPDIR:-}" ]; then echo "TMPDIR is required"; exit 3; fi',
'mkdir -p "$GRADLE_USER_HOME"',
'mkdir -p "$TMPDIR"',
'echo "ok" > "$GRADLE_USER_HOME/gradle-ok.txt"',
'echo "ok" > "$TMPDIR/gradle-tmp-ok.txt"',
'echo "BUILD SUCCESSFUL"',
].join('\n'),
'utf-8',
);
chmodSync(join(repo.path, 'gradlew'), 0o755);
writeFileSync(
join(repo.path, 'scripts/check-node-env.js'),
[
"const fs = require('node:fs');",
"const path = require('node:path');",
"const cache = process.env.npm_config_cache;",
"if (!cache) { console.error('npm_config_cache is required'); process.exit(2); }",
"fs.mkdirSync(cache, { recursive: true });",
"fs.writeFileSync(path.join(cache, 'npm-ok.txt'), 'ok');",
"console.log('node-env-ok');",
].join('\n'),
'utf-8',
);
writeFileSync(
join(repo.path, 'package.json'),
JSON.stringify({
name: 'runtime-e2e',
private: true,
version: '1.0.0',
scripts: {
test: 'node scripts/check-node-env.js',
},
}, null, 2),
'utf-8',
);
writeFileSync(
join(repo.path, 'runtime-e2e-piece.yaml'),
[
'name: runtime-e2e',
'description: Runtime env injection verification piece',
'max_movements: 3',
'initial_movement: execute',
'movements:',
' - name: execute',
' edit: false',
' persona: ../fixtures/agents/test-coder.md',
' allowed_tools:',
' - Read',
' - Bash',
' permission_mode: edit',
' instruction_template: |',
' {task}',
' rules:',
' - condition: Task completed',
' next: COMPLETE',
].join('\n'),
'utf-8',
);
});
afterEach(() => {
try { repo.cleanup(); } catch { /* best-effort */ }
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
});
providerIt('should apply runtime.prepare from config.yaml during provider execution', () => {
updateIsolatedConfig(isolatedEnv.taktDir, {
runtime: {
prepare: ['gradle', 'node'],
},
});
const piecePath = join(repo.path, 'runtime-e2e-piece.yaml');
const result = runTakt({
args: [
'--task',
[
'Run `./gradlew test` and `npm test` in the repository root.',
'If both commands succeed, respond exactly with: Task completed',
].join(' '),
'--piece', piecePath,
'--create-worktree', 'no',
],
cwd: repo.path,
env: isolatedEnv.env,
timeout: 240_000,
});
expect(result.exitCode).toBe(0);
const runtimeRoot = join(repo.path, '.runtime');
const envFile = join(runtimeRoot, 'env.sh');
expect(existsSync(runtimeRoot)).toBe(true);
expect(existsSync(join(runtimeRoot, 'tmp'))).toBe(true);
expect(existsSync(join(runtimeRoot, 'cache'))).toBe(true);
expect(existsSync(join(runtimeRoot, 'config'))).toBe(true);
expect(existsSync(join(runtimeRoot, 'state'))).toBe(true);
expect(existsSync(join(runtimeRoot, 'gradle'))).toBe(true);
expect(existsSync(join(runtimeRoot, 'npm'))).toBe(true);
expect(existsSync(join(runtimeRoot, 'gradle', 'gradle-ok.txt'))).toBe(true);
expect(existsSync(join(runtimeRoot, 'npm', 'npm-ok.txt'))).toBe(true);
expect(existsSync(envFile)).toBe(true);
const envContent = readFileSync(envFile, 'utf-8');
expect(envContent).toContain('export TMPDIR=');
expect(envContent).toContain('export GRADLE_USER_HOME=');
expect(envContent).toContain('export npm_config_cache=');
}, 240_000);
providerIt('should reproduce missing runtime env failure when runtime.prepare is unset', () => {
const piecePath = join(repo.path, 'runtime-e2e-piece.yaml');
const result = runTakt({
args: [
'--task',
[
'Run `./gradlew test` and `npm test` in the repository root without setting or overriding environment variables.',
'If both commands succeed, respond exactly with: Task completed',
].join(' '),
'--piece', piecePath,
'--create-worktree', 'no',
],
cwd: repo.path,
env: isolatedEnv.env,
timeout: 240_000,
});
const combined = `${result.stdout}\n${result.stderr}`;
expect(combined).toContain('GRADLE_USER_HOME is required');
const runtimeRoot = join(repo.path, '.runtime');
expect(existsSync(join(runtimeRoot, 'env.sh'))).toBe(false);
}, 240_000);
});

View File

@ -69,7 +69,8 @@ describe('E2E: Task status persistence in tasks.yaml (mock)', () => {
const tasksContent = readFileSync(join(repo.path, '.takt', 'tasks.yaml'), 'utf-8');
const tasks = parseYaml(tasksContent) as { tasks: Array<Record<string, unknown>> };
expect(Array.isArray(tasks.tasks)).toBe(true);
expect(tasks.tasks.length).toBe(0);
expect(tasks.tasks.length).toBe(1);
expect(tasks.tasks[0]?.status).toBe('completed');
}, 240_000);
it('should persist failed status and failure details on failure', () => {

View File

@ -96,6 +96,7 @@ describe('E2E: Watch tasks (takt watch)', () => {
const tasksRaw = readFileSync(tasksFile, 'utf-8');
const parsed = parseYaml(tasksRaw) as { tasks?: Array<{ name?: string; status?: string }> };
const watchTask = parsed.tasks?.find((task) => task.name === 'watch-task');
expect(watchTask).toBeUndefined();
expect(watchTask).toBeDefined();
expect(watchTask!.status).toBe('completed');
}, 240_000);
});

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "takt",
"version": "0.14.0",
"version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "takt",
"version": "0.14.0",
"version": "0.15.0",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.37",

View File

@ -1,6 +1,6 @@
{
"name": "takt",
"version": "0.14.0",
"version": "0.15.0",
"description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@ -10,7 +10,7 @@
"takt-cli": "./dist/app/cli/index.js"
},
"scripts": {
"build": "tsc && mkdir -p dist/shared/prompts/en dist/shared/prompts/ja dist/shared/i18n && cp src/shared/prompts/en/*.md dist/shared/prompts/en/ && cp src/shared/prompts/ja/*.md dist/shared/prompts/ja/ && cp src/shared/i18n/labels_en.yaml src/shared/i18n/labels_ja.yaml dist/shared/i18n/",
"build": "tsc && mkdir -p dist/shared/prompts/en dist/shared/prompts/ja dist/shared/i18n dist/core/runtime/presets && cp src/shared/prompts/en/*.md dist/shared/prompts/en/ && cp src/shared/prompts/ja/*.md dist/shared/prompts/ja/ && cp src/shared/i18n/labels_en.yaml src/shared/i18n/labels_ja.yaml dist/shared/i18n/ && cp src/core/runtime/presets/*.sh dist/core/runtime/presets/",
"watch": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",

View File

@ -483,6 +483,41 @@ describe('loadGlobalConfig', () => {
});
});
describe('runtime', () => {
it('should load runtime.prepare from config.yaml', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'runtime:',
' prepare:',
' - gradle',
' - node',
].join('\n'),
'utf-8',
);
const config = loadGlobalConfig();
expect(config.runtime).toEqual({ prepare: ['gradle', 'node'] });
});
it('should save and reload runtime.prepare', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
config.runtime = { prepare: ['gradle', 'node'] };
saveGlobalConfig(config);
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.runtime).toEqual({ prepare: ['gradle', 'node'] });
});
});
describe('provider/model compatibility validation', () => {
it('should throw when provider is codex but model is a Claude alias (opus)', () => {
const taktDir = join(testHomeDir, '.takt');

View File

@ -153,6 +153,30 @@ describe('PieceConfigRawSchema', () => {
});
});
it('should parse piece-level piece_config.runtime.prepare', () => {
const config = {
name: 'test-piece',
piece_config: {
runtime: {
prepare: ['gradle', 'node'],
},
},
movements: [
{
name: 'implement',
instruction: '{task}',
},
],
};
const result = PieceConfigRawSchema.parse(config);
expect(result.piece_config).toEqual({
runtime: {
prepare: ['gradle', 'node'],
},
});
});
it('should allow omitting permission_mode', () => {
const config = {
name: 'test-piece',

View File

@ -91,6 +91,29 @@ describe('normalizePieceConfig provider_options', () => {
},
});
});
it('piece-level runtime.prepare を正規化し重複を除去する', () => {
const raw = {
name: 'runtime-prepare',
piece_config: {
runtime: {
prepare: ['gradle', 'node', 'gradle'],
},
},
movements: [
{
name: 'implement',
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, process.cwd());
expect(config.runtime).toEqual({
prepare: ['gradle', 'node'],
});
});
});
describe('mergeProviderOptions', () => {

View File

@ -0,0 +1,113 @@
import { mkdtempSync, existsSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it } from 'vitest';
import { prepareRuntimeEnvironment, resolveRuntimeConfig } from '../core/runtime/runtime-environment.js';
describe('prepareRuntimeEnvironment', () => {
const tempDirs: string[] = [];
const systemTmpDir = tmpdir();
const envKeys = [
'TMPDIR',
'XDG_CACHE_HOME',
'XDG_CONFIG_HOME',
'XDG_STATE_HOME',
'CI',
'JAVA_TOOL_OPTIONS',
'GRADLE_USER_HOME',
'npm_config_cache',
] as const;
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
for (const key of envKeys) {
const value = originalEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it('should return undefined when runtime.prepare is not set', () => {
const cwd = mkdtempSync(join(systemTmpDir, 'takt-runtime-env-'));
tempDirs.push(cwd);
const result = prepareRuntimeEnvironment(cwd, undefined);
expect(result).toBeUndefined();
});
it('should create .runtime files and inject tool-specific env', () => {
const cwd = mkdtempSync(join(systemTmpDir, 'takt-runtime-env-'));
tempDirs.push(cwd);
const result = prepareRuntimeEnvironment(cwd, {
prepare: ['gradle', 'node'],
});
expect(result).toBeDefined();
expect(result?.prepare).toEqual(['gradle', 'node']);
const runtimeRoot = join(cwd, '.runtime');
expect(existsSync(runtimeRoot)).toBe(true);
expect(existsSync(join(runtimeRoot, 'tmp'))).toBe(true);
expect(existsSync(join(runtimeRoot, 'cache'))).toBe(true);
expect(existsSync(join(runtimeRoot, 'config'))).toBe(true);
expect(existsSync(join(runtimeRoot, 'state'))).toBe(true);
expect(existsSync(join(runtimeRoot, 'gradle'))).toBe(true);
expect(existsSync(join(runtimeRoot, 'npm'))).toBe(true);
const envFile = join(runtimeRoot, 'env.sh');
expect(existsSync(envFile)).toBe(true);
const envContent = readFileSync(envFile, 'utf-8');
expect(envContent).toContain('export TMPDIR=');
expect(envContent).toContain('export GRADLE_USER_HOME=');
expect(envContent).toContain('export npm_config_cache=');
});
it('should execute custom prepare script path and merge exported env', () => {
const cwd = mkdtempSync(join(systemTmpDir, 'takt-runtime-env-'));
tempDirs.push(cwd);
const scriptPath = join(cwd, 'prepare-custom.sh');
writeFileSync(scriptPath, [
'#!/usr/bin/env bash',
'set -euo pipefail',
'runtime_root="${TAKT_RUNTIME_ROOT:?}"',
'custom_dir="$runtime_root/custom-cache"',
'mkdir -p "$custom_dir"',
'echo "CUSTOM_CACHE_DIR=$custom_dir"',
].join('\n'), 'utf-8');
chmodSync(scriptPath, 0o755);
const result = prepareRuntimeEnvironment(cwd, {
prepare: [scriptPath],
});
expect(result).toBeDefined();
expect(result?.injectedEnv.CUSTOM_CACHE_DIR).toBe(join(cwd, '.runtime', 'custom-cache'));
expect(existsSync(join(cwd, '.runtime', 'custom-cache'))).toBe(true);
});
});
describe('resolveRuntimeConfig', () => {
it('should use piece runtime when both global and piece are defined', () => {
const resolved = resolveRuntimeConfig(
{ prepare: ['gradle', 'node'] },
{ prepare: ['node', 'pnpm'] },
);
expect(resolved).toEqual({ prepare: ['node', 'pnpm'] });
});
it('should fallback to global runtime when piece runtime is missing', () => {
const resolved = resolveRuntimeConfig(
{ prepare: ['gradle', 'node', 'gradle'] },
undefined,
);
expect(resolved).toEqual({ prepare: ['gradle', 'node'] });
});
});

View File

@ -2,7 +2,7 @@
* Configuration types (global and project)
*/
import type { MovementProviderOptions } from './piece-types.js';
import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.js';
/** Custom agent configuration */
export interface CustomAgentConfig {
@ -90,6 +90,8 @@ export interface GlobalConfig {
personaProviders?: Record<string, 'claude' | 'codex' | 'opencode' | 'mock'>;
/** Global provider-specific options (lowest priority) */
providerOptions?: MovementProviderOptions;
/** Global runtime environment defaults (can be overridden by piece runtime) */
runtime?: PieceRuntimeConfig;
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
branchNameStrategy?: 'romaji' | 'ai';
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */

View File

@ -8,6 +8,9 @@ export type {
OutputContractItem,
OutputContractEntry,
McpServerConfig,
RuntimePreparePreset,
RuntimePrepareEntry,
PieceRuntimeConfig,
AgentResponse,
SessionState,
PartDefinition,

View File

@ -92,6 +92,17 @@ export interface OpenCodeProviderOptions {
networkAccess?: boolean;
}
/** Runtime prepare preset identifiers */
export type RuntimePreparePreset = 'gradle' | 'node';
/** Runtime prepare entry: preset name or executable script path */
export type RuntimePrepareEntry = RuntimePreparePreset | string;
/** Piece-level runtime environment settings */
export interface PieceRuntimeConfig {
/** Preset(s) or script path(s) to prepare .runtime and inject env vars */
prepare?: RuntimePrepareEntry[];
}
/** Claude sandbox settings (maps to SDK SandboxSettings) */
export interface ClaudeSandboxSettings {
/** Allow all Bash commands to run outside the sandbox */
@ -237,6 +248,8 @@ export interface PieceConfig {
description?: string;
/** Piece-level default provider options (used as movement defaults) */
providerOptions?: MovementProviderOptions;
/** Piece-level runtime preparation and env injection settings */
runtime?: PieceRuntimeConfig;
/** Persona definitions — map of name to file path or inline content (raw, not content-resolved) */
personas?: Record<string, string>;
/** Resolved policy definitions — map of name to file content (resolved at parse time) */

View File

@ -78,9 +78,23 @@ export const MovementProviderOptionsSchema = z.object({
}).optional(),
}).optional();
/** Runtime prepare preset identifiers */
export const RuntimePreparePresetSchema = z.enum(['gradle', 'node']);
/** Runtime prepare entry: preset name or script path */
export const RuntimePrepareEntrySchema = z.union([
RuntimePreparePresetSchema,
z.string().min(1),
]);
/** Piece-level runtime settings */
export const RuntimeConfigSchema = z.object({
prepare: z.array(RuntimePrepareEntrySchema).optional(),
}).optional();
/** Piece-level provider options schema */
export const PieceProviderOptionsSchema = z.object({
provider_options: MovementProviderOptionsSchema,
runtime: RuntimeConfigSchema,
}).optional();
/**
@ -425,6 +439,8 @@ export const GlobalConfigSchema = z.object({
persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(),
/** Global provider-specific options (lowest priority) */
provider_options: MovementProviderOptionsSchema,
/** Global runtime defaults (piece runtime overrides this) */
runtime: RuntimeConfigSchema,
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
branch_name_strategy: z.enum(['romaji', 'ai']).optional(),
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */

View File

@ -37,6 +37,9 @@ export type {
OutputContractItem,
OutputContractEntry,
McpServerConfig,
RuntimePreparePreset,
RuntimePrepareEntry,
PieceRuntimeConfig,
MovementProviderOptions,
PieceMovement,
ArpeggioMovementConfig,

View File

@ -33,6 +33,7 @@ import { ParallelRunner } from './ParallelRunner.js';
import { ArpeggioRunner } from './ArpeggioRunner.js';
import { TeamLeaderRunner } from './TeamLeaderRunner.js';
import { buildRunPaths, type RunPaths } from '../run/run-paths.js';
import { prepareRuntimeEnvironment } from '../../runtime/runtime-environment.js';
const log = createLogger('engine');
@ -89,6 +90,7 @@ export class PieceEngine extends EventEmitter {
this.runPaths = buildRunPaths(this.cwd, reportDirName);
this.reportDir = this.runPaths.reportsRel;
this.ensureRunDirsExist();
this.applyRuntimeEnvironment('init');
this.validateConfig();
this.state = createInitialState(config, options);
this.detectRuleIndex = options.detectRuleIndex ?? (() => {
@ -215,6 +217,20 @@ export class PieceEngine extends EventEmitter {
}
}
private applyRuntimeEnvironment(stage: 'init' | 'movement'): void {
const prepared = prepareRuntimeEnvironment(this.cwd, this.config.runtime);
if (!prepared) return;
log.info('Runtime environment prepared', {
stage,
runtimeRoot: prepared.runtimeRoot,
envFile: prepared.envFile,
prepare: prepared.prepare,
tmpdir: prepared.injectedEnv.TMPDIR,
gradleUserHome: prepared.injectedEnv.GRADLE_USER_HOME,
npmCache: prepared.injectedEnv.npm_config_cache,
});
}
/** Validate piece configuration at construction time */
private validateConfig(): void {
const initialMovement = this.config.movements.find((s) => s.name === this.config.initialMovement);
@ -538,6 +554,7 @@ export class PieceEngine extends EventEmitter {
}
const movement = this.getMovement(this.state.currentMovement);
this.applyRuntimeEnvironment('movement');
const loopCheck = this.loopDetector.check(movement.name);
if (loopCheck.shouldWarn) {
@ -676,6 +693,7 @@ export class PieceEngine extends EventEmitter {
loopDetected?: boolean;
}> {
const movement = this.getMovement(this.state.currentMovement);
this.applyRuntimeEnvironment('movement');
const loopCheck = this.loopDetector.check(movement.name);
if (loopCheck.shouldAbort) {

View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
runtime_root="${TAKT_RUNTIME_ROOT:?TAKT_RUNTIME_ROOT is required}"
runtime_tmp="${TAKT_RUNTIME_TMP:-$runtime_root/tmp}"
gradle_home="$runtime_root/gradle"
mkdir -p "$runtime_tmp" "$gradle_home"
echo "GRADLE_USER_HOME=$gradle_home"
echo "TMPDIR=$runtime_tmp"

View File

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
runtime_root="${TAKT_RUNTIME_ROOT:?TAKT_RUNTIME_ROOT is required}"
runtime_tmp="${TAKT_RUNTIME_TMP:-$runtime_root/tmp}"
npm_cache="$runtime_root/npm"
mkdir -p "$runtime_tmp" "$npm_cache"
echo "npm_config_cache=$npm_cache"
echo "TMPDIR=$runtime_tmp"

View File

@ -0,0 +1,208 @@
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
import { join, resolve, isAbsolute, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import type { PieceRuntimeConfig, RuntimePrepareEntry, RuntimePreparePreset } from '../models/piece-types.js';
export interface RuntimeEnvironmentResult {
runtimeRoot: string;
envFile: string;
prepare: RuntimePrepareEntry[];
injectedEnv: Record<string, string>;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PRESET_SCRIPT_DIR = join(__dirname, 'presets');
const PRESET_SCRIPT_MAP: Record<RuntimePreparePreset, string> = {
gradle: join(PRESET_SCRIPT_DIR, 'prepare-gradle.sh'),
node: join(PRESET_SCRIPT_DIR, 'prepare-node.sh'),
};
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function createBaseEnvironment(runtimeRoot: string): Record<string, string> {
return {
TMPDIR: join(runtimeRoot, 'tmp'),
XDG_CACHE_HOME: join(runtimeRoot, 'cache'),
XDG_CONFIG_HOME: join(runtimeRoot, 'config'),
XDG_STATE_HOME: join(runtimeRoot, 'state'),
CI: 'true',
};
}
function appendJavaTmpdirOption(base: string | undefined, tmpDir: string): string {
const option = `-Djava.io.tmpdir=${tmpDir}`;
if (!base || base.trim().length === 0) return option;
if (base.includes(option)) return base;
return `${base} ${option}`.trim();
}
function parseScriptOutput(stdout: string): Record<string, string> {
const env: Record<string, string> = {};
const lines = stdout.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const normalized = trimmed.startsWith('export ')
? trimmed.slice('export '.length).trim()
: trimmed;
const eq = normalized.indexOf('=');
if (eq <= 0) continue;
const key = normalized.slice(0, eq).trim();
const value = normalized.slice(eq + 1).trim();
if (!key) continue;
env[key] = value.replace(/^['"]|['"]$/g, '');
}
return env;
}
function resolvePrepareScript(cwd: string, entry: RuntimePrepareEntry): string {
if (entry === 'gradle' || entry === 'node') {
return PRESET_SCRIPT_MAP[entry];
}
return isAbsolute(entry) ? entry : resolve(cwd, entry);
}
function runPrepareScript(
cwd: string,
scriptPath: string,
runtimeRoot: string,
env: Record<string, string>,
): Record<string, string> {
if (!existsSync(scriptPath)) {
throw new Error(`Runtime prepare script not found: ${scriptPath}`);
}
const result = spawnSync('bash', [scriptPath], {
cwd,
env: {
...process.env,
...env,
TAKT_RUNTIME_ROOT: runtimeRoot,
TAKT_RUNTIME_TMP: join(runtimeRoot, 'tmp'),
TAKT_RUNTIME_CACHE: join(runtimeRoot, 'cache'),
TAKT_RUNTIME_CONFIG: join(runtimeRoot, 'config'),
TAKT_RUNTIME_STATE: join(runtimeRoot, 'state'),
},
encoding: 'utf-8',
});
if (result.status !== 0) {
const stderr = (result.stderr ?? '').trim();
throw new Error(`Runtime prepare script failed: ${scriptPath}${stderr ? ` (${stderr})` : ''}`);
}
return parseScriptOutput(result.stdout ?? '');
}
function buildInjectedEnvironment(
cwd: string,
runtimeRoot: string,
prepareEntries: RuntimePrepareEntry[],
): Record<string, string> {
const env: Record<string, string> = {
...createBaseEnvironment(runtimeRoot),
};
for (const entry of prepareEntries) {
const scriptPath = resolvePrepareScript(cwd, entry);
const scriptEnv = runPrepareScript(cwd, scriptPath, runtimeRoot, env);
Object.assign(env, scriptEnv);
}
if (prepareEntries.includes('gradle')) {
const tmpDir = env.TMPDIR ?? join(runtimeRoot, 'tmp');
env.JAVA_TOOL_OPTIONS = appendJavaTmpdirOption(process.env['JAVA_TOOL_OPTIONS'], tmpDir);
}
if (prepareEntries.includes('gradle') && !env.GRADLE_USER_HOME) {
env.GRADLE_USER_HOME = join(runtimeRoot, 'gradle');
}
if (prepareEntries.includes('node') && !env.npm_config_cache) {
env.npm_config_cache = join(runtimeRoot, 'npm');
}
return env;
}
function ensureRuntimeDirectories(runtimeRoot: string, env: Record<string, string>): void {
const dirs = new Set<string>([
runtimeRoot,
join(runtimeRoot, 'tmp'),
join(runtimeRoot, 'cache'),
join(runtimeRoot, 'config'),
join(runtimeRoot, 'state'),
]);
for (const value of Object.values(env)) {
if (!value || value === 'true') continue;
if (value.startsWith(runtimeRoot)) {
dirs.add(value);
}
}
for (const dir of dirs) {
mkdirSync(dir, { recursive: true });
}
}
function writeRuntimeEnvFile(envFile: string, env: Record<string, string>): void {
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
];
for (const [key, value] of Object.entries(env)) {
lines.push(`export ${key}=${shellQuote(value)}`);
}
lines.push('');
writeFileSync(envFile, lines.join('\n'), 'utf-8');
}
function dedupePrepare(entries: RuntimePrepareEntry[]): RuntimePrepareEntry[] {
return [...new Set(entries)];
}
export function resolveRuntimeConfig(
globalRuntime: PieceRuntimeConfig | undefined,
pieceRuntime: PieceRuntimeConfig | undefined,
): PieceRuntimeConfig | undefined {
const prepare = pieceRuntime?.prepare?.length
? pieceRuntime.prepare
: globalRuntime?.prepare;
if (!prepare || prepare.length === 0) {
return undefined;
}
return { prepare: dedupePrepare(prepare) };
}
export function prepareRuntimeEnvironment(
cwd: string,
runtime: PieceRuntimeConfig | undefined,
): RuntimeEnvironmentResult | undefined {
const prepareEntries = runtime?.prepare;
if (!prepareEntries || prepareEntries.length === 0) {
return undefined;
}
const deduped = dedupePrepare(prepareEntries);
const runtimeRoot = join(cwd, '.runtime');
const envFile = join(runtimeRoot, 'env.sh');
const injectedEnv = buildInjectedEnvironment(cwd, runtimeRoot, deduped);
ensureRuntimeDirectories(runtimeRoot, injectedEnv);
writeRuntimeEnvFile(envFile, injectedEnv);
for (const [key, value] of Object.entries(injectedEnv)) {
process.env[key] = value;
}
return {
runtimeRoot,
envFile,
prepare: deduped,
injectedEnv,
};
}

View File

@ -70,6 +70,7 @@ import { EXIT_SIGINT } from '../../../shared/exitCodes.js';
import { ShutdownManager } from './shutdownManager.js';
import { buildRunPaths } from '../../../core/piece/run/run-paths.js';
import { resolveMovementProviderModel } from '../../../core/piece/provider-resolution.js';
import { resolveRuntimeConfig } from '../../../core/runtime/runtime-environment.js';
import { writeFileAtomic, ensureDir } from '../../../infra/config/index.js';
const log = createLogger('piece');
@ -319,6 +320,10 @@ export async function executePiece(
const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false;
const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false;
const currentProvider = globalConfig.provider ?? 'claude';
const effectivePieceConfig: PieceConfig = {
...pieceConfig,
runtime: resolveRuntimeConfig(globalConfig.runtime, pieceConfig.runtime),
};
const providerEventLogger = createProviderEventLogger({
logsDir: runPaths.logsAbs,
sessionId: pieceSessionId,
@ -424,7 +429,7 @@ export async function executePiece(
const runAbortController = new AbortController();
try {
engine = new PieceEngine(pieceConfig, cwd, task, {
engine = new PieceEngine(effectivePieceConfig, cwd, task, {
abortSignal: runAbortController.signal,
onStream: providerEventLogger.wrapCallback(streamHandler),
onUserInput,

View File

@ -126,6 +126,9 @@ export class GlobalConfigManager {
pieceCategoriesFile: parsed.piece_categories_file,
personaProviders: parsed.persona_providers,
providerOptions: normalizeProviderOptions(parsed.provider_options),
runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0
? { prepare: [...new Set(parsed.runtime.prepare)] }
: undefined,
branchNameStrategy: parsed.branch_name_strategy,
preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound,
@ -210,6 +213,11 @@ export class GlobalConfigManager {
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
raw.persona_providers = config.personaProviders;
}
if (config.runtime?.prepare && config.runtime.prepare.length > 0) {
raw.runtime = {
prepare: [...new Set(config.runtime.prepare)],
};
}
if (config.branchNameStrategy) {
raw.branch_name_strategy = config.branchNameStrategy;
}

View File

@ -23,8 +23,10 @@ import {
} from './resource-resolver.js';
type RawStep = z.output<typeof PieceMovementRawSchema>;
type RawPiece = z.output<typeof PieceConfigRawSchema>;
import type { MovementProviderOptions } from '../../../core/models/piece-types.js';
import type { PieceRuntimeConfig } from '../../../core/models/piece-types.js';
/** Convert raw YAML provider_options (snake_case) to internal format (camelCase). */
export function normalizeProviderOptions(
@ -81,6 +83,16 @@ export function mergeProviderOptions(
return Object.keys(result).length > 0 ? result : undefined;
}
function normalizeRuntimeConfig(raw: RawPiece['piece_config']): PieceRuntimeConfig | undefined {
const prepare = raw?.runtime?.prepare;
if (!prepare || prepare.length === 0) {
return undefined;
}
return {
prepare: [...new Set(prepare)],
};
}
/** Check if a raw output contract item is the object form (has 'name' property). */
function isOutputContractItem(raw: unknown): raw is { name: string; order?: string; format?: string } {
return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw;
@ -389,6 +401,7 @@ export function normalizePieceConfig(
};
const pieceProviderOptions = normalizeProviderOptions(parsed.piece_config?.provider_options as RawStep['provider_options']);
const pieceRuntime = normalizeRuntimeConfig(parsed.piece_config);
const movements: PieceMovement[] = parsed.movements.map((step) =>
normalizeStepFromRaw(step, pieceDir, sections, pieceProviderOptions, context),
@ -401,6 +414,7 @@ export function normalizePieceConfig(
name: parsed.name,
description: parsed.description,
providerOptions: pieceProviderOptions,
runtime: pieceRuntime,
personas: parsed.personas,
policies: resolvedPolicies,
knowledge: resolvedKnowledge,