From f8b9d4607f73b6e1dffebee01b53aa266bf882e1 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 07:50:56 +0900 Subject: [PATCH 01/45] takt: github-issue-193-takt-add-issue (#199) --- src/__tests__/addTask.test.ts | 31 +++++++- .../cli-routing-issue-resolve.test.ts | 33 ++++++++- src/__tests__/createIssueFromTask.test.ts | 36 +++++++++ src/__tests__/saveTaskFile.test.ts | 12 +++ src/app/cli/routing.ts | 4 +- src/features/tasks/add/index.ts | 74 +++++++++++-------- src/features/tasks/index.ts | 2 +- 7 files changed, 157 insertions(+), 35 deletions(-) diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 8c645a3b..5bba9ba6 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -66,7 +66,7 @@ vi.mock('../infra/github/issue.js', () => ({ import { interactiveMode } from '../features/interactive/index.js'; import { promptInput, confirm } from '../shared/prompt/index.js'; import { determinePiece } from '../features/tasks/execute/selectAndExecute.js'; -import { resolveIssueTask } from '../infra/github/issue.js'; +import { resolveIssueTask, createIssue } from '../infra/github/issue.js'; import { addTask } from '../features/tasks/index.js'; const mockResolveIssueTask = vi.mocked(resolveIssueTask); @@ -74,6 +74,7 @@ const mockInteractiveMode = vi.mocked(interactiveMode); const mockPromptInput = vi.mocked(promptInput); const mockConfirm = vi.mocked(confirm); const mockDeterminePiece = vi.mocked(determinePiece); +const mockCreateIssue = vi.mocked(createIssue); let testDir: string; @@ -138,4 +139,32 @@ describe('addTask', () => { expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); }); + + it('should create issue and save task when create_issue action is chosen', async () => { + // Given + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature' }); + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/55' }); + mockConfirm.mockResolvedValue(false); + + // When + await addTask(testDir); + + // Then + const tasks = loadTasks(testDir).tasks; + expect(tasks).toHaveLength(1); + expect(tasks[0]?.issue).toBe(55); + expect(tasks[0]?.content).toContain('New feature'); + }); + + it('should not save task when issue creation fails in create_issue action', async () => { + // Given + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature' }); + mockCreateIssue.mockReturnValue({ success: false, error: 'auth failed' }); + + // When + await addTask(testDir); + + // Then + expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); + }); }); diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index 41c53b18..b7ed5d69 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -36,7 +36,7 @@ vi.mock('../features/tasks/index.js', () => ({ selectAndExecuteTask: vi.fn(), determinePiece: vi.fn(), saveTaskFromInteractive: vi.fn(), - createIssueFromTask: vi.fn(), + createIssueAndSaveTask: vi.fn(), })); vi.mock('../features/pipeline/index.js', () => ({ @@ -83,7 +83,7 @@ vi.mock('../app/cli/helpers.js', () => ({ })); import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; -import { selectAndExecuteTask, determinePiece } from '../features/tasks/index.js'; +import { selectAndExecuteTask, determinePiece, createIssueAndSaveTask } from '../features/tasks/index.js'; import { interactiveMode } from '../features/interactive/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; import { executeDefaultAction } from '../app/cli/routing.js'; @@ -95,6 +95,7 @@ const mockFormatIssueAsTask = vi.mocked(formatIssueAsTask); const mockParseIssueNumbers = vi.mocked(parseIssueNumbers); const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask); const mockDeterminePiece = vi.mocked(determinePiece); +const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask); const mockInteractiveMode = vi.mocked(interactiveMode); const mockIsDirectTask = vi.mocked(isDirectTask); @@ -261,4 +262,32 @@ describe('Issue resolution in routing', () => { expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); }); }); + + describe('create_issue action', () => { + it('should delegate to createIssueAndSaveTask with cwd, task, and pieceId', async () => { + // Given + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); + + // When + await executeDefaultAction(); + + // Then: createIssueAndSaveTask should be called with correct args + expect(mockCreateIssueAndSaveTask).toHaveBeenCalledWith( + '/test/cwd', + 'New feature request', + 'default', + ); + }); + + it('should not call selectAndExecuteTask when create_issue action is chosen', async () => { + // Given + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); + + // When + await executeDefaultAction(); + + // Then: selectAndExecuteTask should NOT be called + expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/__tests__/createIssueFromTask.test.ts b/src/__tests__/createIssueFromTask.test.ts index 2d15a875..e31d3ed8 100644 --- a/src/__tests__/createIssueFromTask.test.ts +++ b/src/__tests__/createIssueFromTask.test.ts @@ -114,6 +114,42 @@ describe('createIssueFromTask', () => { expect(mockSuccess).not.toHaveBeenCalled(); }); + describe('return value', () => { + it('should return issue number when creation succeeds', () => { + // Given + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/42' }); + + // When + const result = createIssueFromTask('Test task'); + + // Then + expect(result).toBe(42); + }); + + it('should return undefined when creation fails', () => { + // Given + mockCreateIssue.mockReturnValue({ success: false, error: 'auth failed' }); + + // When + const result = createIssueFromTask('Test task'); + + // Then + expect(result).toBeUndefined(); + }); + + it('should return undefined and display error when URL has non-numeric suffix', () => { + // Given + mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/abc' }); + + // When + const result = createIssueFromTask('Test task'); + + // Then + expect(result).toBeUndefined(); + expect(mockError).toHaveBeenCalledWith('Failed to extract issue number from URL'); + }); + }); + it('should use first line as title and full text as body for multi-line task', () => { // Given: multi-line task const task = 'First line title\nSecond line details\nThird line more info'; diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 51078ae8..0e179e3c 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -122,4 +122,16 @@ describe('saveTaskFromInteractive', () => { expect(mockInfo).toHaveBeenCalledWith(' Piece: review'); }); + + it('should record issue number in tasks.yaml when issue option is provided', async () => { + // Given: user declines worktree + mockConfirm.mockResolvedValueOnce(false); + + // When + await saveTaskFromInteractive(testDir, 'Fix login bug', 'default', { issue: 42 }); + + // Then + const task = loadTasks(testDir).tasks[0]!; + expect(task.issue).toBe(42); + }); }); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 53dff764..5d49ffa0 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -9,7 +9,7 @@ import { info, error } from '../../shared/ui/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { getLabel } from '../../shared/i18n/index.js'; import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js'; -import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; +import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueAndSaveTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; import { executePipeline } from '../../features/pipeline/index.js'; import { interactiveMode, @@ -188,7 +188,7 @@ export async function executeDefaultAction(task?: string): Promise { break; case 'create_issue': - createIssueFromTask(result.task); + await createIssueAndSaveTask(resolvedCwd, result.task, pieceId); break; case 'save_task': diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index e7a4c944..6d3ef39f 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -48,15 +48,22 @@ export async function saveTaskFile( * Extracts the first line as the issue title (truncated to 100 chars), * uses the full task as the body, and displays success/error messages. */ -export function createIssueFromTask(task: string): void { +export function createIssueFromTask(task: string): number | undefined { info('Creating GitHub Issue...'); const firstLine = task.split('\n')[0] || task; const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine; const issueResult = createIssue({ title, body: task }); if (issueResult.success) { success(`Issue created: ${issueResult.url}`); + const num = Number(issueResult.url!.split('/').pop()); + if (Number.isNaN(num)) { + error('Failed to extract issue number from URL'); + return undefined; + } + return num; } else { error(`Failed to create issue: ${issueResult.error}`); + return undefined; } } @@ -66,6 +73,38 @@ interface WorktreeSettings { autoPr?: boolean; } +function displayTaskCreationResult( + created: { taskName: string; tasksFile: string }, + settings: WorktreeSettings, + piece?: string, +): void { + success(`Task created: ${created.taskName}`); + info(` File: ${created.tasksFile}`); + if (settings.worktree) { + info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); + } + if (settings.branch) { + info(` Branch: ${settings.branch}`); + } + if (settings.autoPr) { + info(` Auto-PR: yes`); + } + if (piece) info(` Piece: ${piece}`); +} + +/** + * Create a GitHub Issue and save the task to .takt/tasks.yaml. + * + * Combines issue creation and task saving into a single workflow. + * If issue creation fails, no task is saved. + */ +export async function createIssueAndSaveTask(cwd: string, task: string, piece?: string): Promise { + const issueNumber = createIssueFromTask(task); + if (issueNumber !== undefined) { + await saveTaskFromInteractive(cwd, task, piece, { issue: issueNumber }); + } +} + async function promptWorktreeSettings(): Promise { const useWorktree = await confirm('Create worktree?', true); if (!useWorktree) { @@ -91,21 +130,11 @@ export async function saveTaskFromInteractive( cwd: string, task: string, piece?: string, + options?: { issue?: number }, ): Promise { const settings = await promptWorktreeSettings(); - const created = await saveTaskFile(cwd, task, { piece, ...settings }); - success(`Task created: ${created.taskName}`); - info(` File: ${created.tasksFile}`); - if (settings.worktree) { - info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); - } - if (settings.branch) { - info(` Branch: ${settings.branch}`); - } - if (settings.autoPr) { - info(` Auto-PR: yes`); - } - if (piece) info(` Piece: ${piece}`); + const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings }); + displayTaskCreationResult(created, settings, piece); } /** @@ -161,7 +190,7 @@ export async function addTask(cwd: string, task?: string): Promise { const result = await interactiveMode(cwd, undefined, pieceContext); if (result.action === 'create_issue') { - createIssueFromTask(result.task); + await createIssueAndSaveTask(cwd, result.task, piece); return; } @@ -184,18 +213,5 @@ export async function addTask(cwd: string, task?: string): Promise { ...settings, }); - success(`Task created: ${created.taskName}`); - info(` File: ${created.tasksFile}`); - if (settings.worktree) { - info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); - } - if (settings.branch) { - info(` Branch: ${settings.branch}`); - } - if (settings.autoPr) { - info(` Auto-PR: yes`); - } - if (piece) { - info(` Piece: ${piece}`); - } + displayTaskCreationResult(created, settings, piece); } diff --git a/src/features/tasks/index.ts b/src/features/tasks/index.ts index e276807a..f413860e 100644 --- a/src/features/tasks/index.ts +++ b/src/features/tasks/index.ts @@ -14,7 +14,7 @@ export { type SelectAndExecuteOptions, type WorktreeConfirmationResult, } from './execute/selectAndExecute.js'; -export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask } from './add/index.js'; +export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask, createIssueAndSaveTask } from './add/index.js'; export { watchTasks } from './watch/index.js'; export { listTasks, From cf9a59c41c359400f31187fc3f9f2a9176355622 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:02:16 +0900 Subject: [PATCH 02/45] =?UTF-8?q?=E4=B8=80=E6=99=82=E7=9A=84=E3=81=AB?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/fixtures/pieces/broken.yaml | 5 + e2e/fixtures/pieces/mock-max-iter.yaml | 27 +++ e2e/fixtures/pieces/mock-no-match.yaml | 15 ++ e2e/fixtures/pieces/mock-two-step.yaml | 27 +++ e2e/fixtures/scenarios/max-iter-loop.json | 18 ++ e2e/fixtures/scenarios/no-match.json | 6 + e2e/fixtures/scenarios/one-entry-only.json | 6 + e2e/fixtures/scenarios/run-three-tasks.json | 14 ++ e2e/fixtures/scenarios/run-with-failure.json | 14 ++ e2e/fixtures/scenarios/two-step-done.json | 10 + e2e/specs/cli-catalog.e2e.ts | 85 +++++++++ e2e/specs/cli-clear.e2e.ts | 55 ++++++ e2e/specs/cli-help.e2e.ts | 73 ++++++++ e2e/specs/cli-prompt.e2e.ts | 76 ++++++++ e2e/specs/cli-switch.e2e.ts | 70 +++++++ e2e/specs/error-handling.e2e.ts | 157 ++++++++++++++++ e2e/specs/piece-error-handling.e2e.ts | 124 +++++++++++++ e2e/specs/provider-error.e2e.ts | 133 +++++++++++++ e2e/specs/quiet-mode.e2e.ts | 72 ++++++++ e2e/specs/run-multiple-tasks.e2e.ts | 185 +++++++++++++++++++ e2e/specs/task-content-file.e2e.ts | 136 ++++++++++++++ vitest.config.e2e.mock.ts | 11 ++ 22 files changed, 1319 insertions(+) create mode 100644 e2e/fixtures/pieces/broken.yaml create mode 100644 e2e/fixtures/pieces/mock-max-iter.yaml create mode 100644 e2e/fixtures/pieces/mock-no-match.yaml create mode 100644 e2e/fixtures/pieces/mock-two-step.yaml create mode 100644 e2e/fixtures/scenarios/max-iter-loop.json create mode 100644 e2e/fixtures/scenarios/no-match.json create mode 100644 e2e/fixtures/scenarios/one-entry-only.json create mode 100644 e2e/fixtures/scenarios/run-three-tasks.json create mode 100644 e2e/fixtures/scenarios/run-with-failure.json create mode 100644 e2e/fixtures/scenarios/two-step-done.json create mode 100644 e2e/specs/cli-catalog.e2e.ts create mode 100644 e2e/specs/cli-clear.e2e.ts create mode 100644 e2e/specs/cli-help.e2e.ts create mode 100644 e2e/specs/cli-prompt.e2e.ts create mode 100644 e2e/specs/cli-switch.e2e.ts create mode 100644 e2e/specs/error-handling.e2e.ts create mode 100644 e2e/specs/piece-error-handling.e2e.ts create mode 100644 e2e/specs/provider-error.e2e.ts create mode 100644 e2e/specs/quiet-mode.e2e.ts create mode 100644 e2e/specs/run-multiple-tasks.e2e.ts create mode 100644 e2e/specs/task-content-file.e2e.ts diff --git a/e2e/fixtures/pieces/broken.yaml b/e2e/fixtures/pieces/broken.yaml new file mode 100644 index 00000000..3cc96424 --- /dev/null +++ b/e2e/fixtures/pieces/broken.yaml @@ -0,0 +1,5 @@ +name: broken + this is not valid YAML + - indentation: [wrong + movements: + broken: {{{ diff --git a/e2e/fixtures/pieces/mock-max-iter.yaml b/e2e/fixtures/pieces/mock-max-iter.yaml new file mode 100644 index 00000000..5e1f0614 --- /dev/null +++ b/e2e/fixtures/pieces/mock-max-iter.yaml @@ -0,0 +1,27 @@ +name: e2e-mock-max-iter +description: Piece with max_iterations=2 that loops between two steps + +max_iterations: 2 + +initial_movement: step-a + +movements: + - name: step-a + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + {task} + rules: + - condition: Done + next: step-b + + - name: step-b + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + Continue the task. + rules: + - condition: Done + next: step-a diff --git a/e2e/fixtures/pieces/mock-no-match.yaml b/e2e/fixtures/pieces/mock-no-match.yaml new file mode 100644 index 00000000..69e4771a --- /dev/null +++ b/e2e/fixtures/pieces/mock-no-match.yaml @@ -0,0 +1,15 @@ +name: e2e-mock-no-match +description: Piece with a strict rule condition that will not match mock output + +max_iterations: 3 + +movements: + - name: execute + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + {task} + rules: + - condition: SpecificMatchThatWillNotOccur + next: COMPLETE diff --git a/e2e/fixtures/pieces/mock-two-step.yaml b/e2e/fixtures/pieces/mock-two-step.yaml new file mode 100644 index 00000000..c302fd01 --- /dev/null +++ b/e2e/fixtures/pieces/mock-two-step.yaml @@ -0,0 +1,27 @@ +name: e2e-mock-two-step +description: Two-step sequential piece for E2E testing + +max_iterations: 5 + +initial_movement: step-1 + +movements: + - name: step-1 + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + {task} + rules: + - condition: Done + next: step-2 + + - name: step-2 + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + Continue the task. + rules: + - condition: Done + next: COMPLETE diff --git a/e2e/fixtures/scenarios/max-iter-loop.json b/e2e/fixtures/scenarios/max-iter-loop.json new file mode 100644 index 00000000..0befd974 --- /dev/null +++ b/e2e/fixtures/scenarios/max-iter-loop.json @@ -0,0 +1,18 @@ +[ + { + "status": "done", + "content": "Step A output." + }, + { + "status": "done", + "content": "Step B output." + }, + { + "status": "done", + "content": "Step A output again." + }, + { + "status": "done", + "content": "Step B output again." + } +] diff --git a/e2e/fixtures/scenarios/no-match.json b/e2e/fixtures/scenarios/no-match.json new file mode 100644 index 00000000..c70694c8 --- /dev/null +++ b/e2e/fixtures/scenarios/no-match.json @@ -0,0 +1,6 @@ +[ + { + "status": "error", + "content": "Simulated failure: API error during execution" + } +] diff --git a/e2e/fixtures/scenarios/one-entry-only.json b/e2e/fixtures/scenarios/one-entry-only.json new file mode 100644 index 00000000..c406d0f8 --- /dev/null +++ b/e2e/fixtures/scenarios/one-entry-only.json @@ -0,0 +1,6 @@ +[ + { + "status": "done", + "content": "Only entry in scenario." + } +] diff --git a/e2e/fixtures/scenarios/run-three-tasks.json b/e2e/fixtures/scenarios/run-three-tasks.json new file mode 100644 index 00000000..4ed3b69a --- /dev/null +++ b/e2e/fixtures/scenarios/run-three-tasks.json @@ -0,0 +1,14 @@ +[ + { + "status": "done", + "content": "Task 1 completed successfully." + }, + { + "status": "done", + "content": "Task 2 completed successfully." + }, + { + "status": "done", + "content": "Task 3 completed successfully." + } +] diff --git a/e2e/fixtures/scenarios/run-with-failure.json b/e2e/fixtures/scenarios/run-with-failure.json new file mode 100644 index 00000000..ab163164 --- /dev/null +++ b/e2e/fixtures/scenarios/run-with-failure.json @@ -0,0 +1,14 @@ +[ + { + "status": "done", + "content": "Task 1 completed successfully." + }, + { + "status": "error", + "content": "Task 2 encountered an error." + }, + { + "status": "done", + "content": "Task 3 completed successfully." + } +] diff --git a/e2e/fixtures/scenarios/two-step-done.json b/e2e/fixtures/scenarios/two-step-done.json new file mode 100644 index 00000000..7ced60d9 --- /dev/null +++ b/e2e/fixtures/scenarios/two-step-done.json @@ -0,0 +1,10 @@ +[ + { + "status": "done", + "content": "Step 1 output text completed." + }, + { + "status": "done", + "content": "Step 2 output text completed." + } +] diff --git a/e2e/specs/cli-catalog.e2e.ts b/e2e/specs/cli-catalog.e2e.ts new file mode 100644 index 00000000..881cde12 --- /dev/null +++ b/e2e/specs/cli-catalog.e2e.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-catalog-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Catalog command (takt catalog)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should list all facet types when no argument given', () => { + // Given: a local repo with isolated env + + // When: running takt catalog + const result = runTakt({ + args: ['catalog'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains facet type sections + expect(result.exitCode).toBe(0); + const output = result.stdout.toLowerCase(); + expect(output).toMatch(/persona/); + }); + + it('should list facets for a specific type', () => { + // Given: a local repo with isolated env + + // When: running takt catalog personas + const result = runTakt({ + args: ['catalog', 'personas'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains persona names + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/coder/i); + }); + + it('should error for an invalid facet type', () => { + // Given: a local repo with isolated env + + // When: running takt catalog with an invalid type + const result = runTakt({ + args: ['catalog', 'invalidtype'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains an error or lists valid types + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/invalid|not found|valid types|unknown/i); + }); +}); diff --git a/e2e/specs/cli-clear.e2e.ts b/e2e/specs/cli-clear.e2e.ts new file mode 100644 index 00000000..81ccad72 --- /dev/null +++ b/e2e/specs/cli-clear.e2e.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-clear-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Clear sessions command (takt clear)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should clear sessions without error', () => { + // Given: a local repo with isolated env + + // When: running takt clear + const result = runTakt({ + args: ['clear'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: exits cleanly + expect(result.exitCode).toBe(0); + const output = result.stdout.toLowerCase(); + expect(output).toMatch(/clear|session|removed|no session/); + }); +}); diff --git a/e2e/specs/cli-help.e2e.ts b/e2e/specs/cli-help.e2e.ts new file mode 100644 index 00000000..c375f232 --- /dev/null +++ b/e2e/specs/cli-help.e2e.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-help-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Help command (takt --help)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should display subcommand list with --help', () => { + // Given: a local repo with isolated env + + // When: running takt --help + const result = runTakt({ + args: ['--help'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output lists subcommands + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/run/); + expect(result.stdout).toMatch(/add/); + expect(result.stdout).toMatch(/list/); + expect(result.stdout).toMatch(/eject/); + }); + + it('should display run subcommand help with takt run --help', () => { + // Given: a local repo with isolated env + + // When: running takt run --help + const result = runTakt({ + args: ['run', '--help'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains run command description + expect(result.exitCode).toBe(0); + const output = result.stdout.toLowerCase(); + expect(output).toMatch(/run|task|pending/); + }); +}); diff --git a/e2e/specs/cli-prompt.e2e.ts b/e2e/specs/cli-prompt.e2e.ts new file mode 100644 index 00000000..47b78fed --- /dev/null +++ b/e2e/specs/cli-prompt.e2e.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-prompt-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Prompt preview command (takt prompt)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should output prompt preview header and movement info for a piece', () => { + // Given: a piece file path + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + // When: running takt prompt with piece path + const result = runTakt({ + args: ['prompt', piecePath], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains "Prompt Preview" header and movement info + // (may fail on Phase 3 for pieces with tag-based rules, but header is still output) + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/Prompt Preview|Movement 1/i); + }); + + it('should report not found for a nonexistent piece name', () => { + // Given: a nonexistent piece name + + // When: running takt prompt with invalid piece + const result = runTakt({ + args: ['prompt', 'nonexistent-piece-xyz'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: reports piece not found + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found/i); + }); +}); diff --git a/e2e/specs/cli-switch.e2e.ts b/e2e/specs/cli-switch.e2e.ts new file mode 100644 index 00000000..f9d05e85 --- /dev/null +++ b/e2e/specs/cli-switch.e2e.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-switch-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Switch piece command (takt switch)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should switch piece when a valid piece name is given', () => { + // Given: a local repo with isolated env + + // When: running takt switch default + const result = runTakt({ + args: ['switch', 'default'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: exits successfully + expect(result.exitCode).toBe(0); + const output = result.stdout.toLowerCase(); + expect(output).toMatch(/default|switched|piece/); + }); + + it('should error when a nonexistent piece name is given', () => { + // Given: a local repo with isolated env + + // When: running takt switch with a nonexistent piece name + const result = runTakt({ + args: ['switch', 'nonexistent-piece-xyz'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: error output + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found|error|does not exist/i); + }); +}); diff --git a/e2e/specs/error-handling.e2e.ts b/e2e/specs/error-handling.e2e.ts new file mode 100644 index 00000000..9c6cb0dd --- /dev/null +++ b/e2e/specs/error-handling.e2e.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-error-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Error handling edge cases (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should error when --piece points to a nonexistent file path', () => { + // Given: a nonexistent piece file path + + // When: running with a bad piece path + const result = runTakt({ + args: [ + '--task', 'test', + '--piece', '/nonexistent/path/to/piece.yaml', + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits with error + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found|does not exist|ENOENT/i); + }, 240_000); + + it('should report error when --piece specifies a nonexistent piece name', () => { + // Given: a nonexistent piece name + + // When: running with a bad piece name + const result = runTakt({ + args: [ + '--task', 'test', + '--piece', 'nonexistent-piece-name-xyz', + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: output contains error about piece not found + // Note: takt reports the error but currently exits with code 0 + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found/i); + }, 240_000); + + it('should error when --pipeline is used without --task or --issue', () => { + // Given: pipeline mode with no task or issue + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + // When: running in pipeline mode without a task + const result = runTakt({ + args: [ + '--pipeline', + '--piece', piecePath, + '--skip-git', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits with error (should not hang in interactive mode due to TAKT_NO_TTY=1) + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/task|issue|required/i); + }, 240_000); + + it('should error when --create-worktree receives an invalid value', () => { + // Given: invalid worktree value + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + // When: running with invalid worktree option + const result = runTakt({ + args: [ + '--task', 'test', + '--piece', piecePath, + '--create-worktree', 'invalid-value', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits with error or warning about invalid value + const combined = result.stdout + result.stderr; + const hasError = result.exitCode !== 0 || combined.match(/invalid|error|must be/i); + expect(hasError).toBeTruthy(); + }, 240_000); + + it('should error when piece file contains invalid YAML', () => { + // Given: a broken YAML piece file + const brokenPiecePath = resolve(__dirname, '../fixtures/pieces/broken.yaml'); + + // When: running with the broken piece + const result = runTakt({ + args: [ + '--task', 'test', + '--piece', brokenPiecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits with error about parsing + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/parse|invalid|error|validation/i); + }, 240_000); +}); diff --git a/e2e/specs/piece-error-handling.e2e.ts b/e2e/specs/piece-error-handling.e2e.ts new file mode 100644 index 00000000..3654bdda --- /dev/null +++ b/e2e/specs/piece-error-handling.e2e.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-piece-err-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Piece error handling (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should abort when agent returns error status', () => { + // Given: a piece and a scenario that returns error status + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-no-match.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/no-match.json'); + + // When: executing the piece + const result = runTakt({ + args: [ + '--task', 'Test error status abort', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: piece aborts with a non-zero exit code + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/failed|aborted|error/i); + }, 240_000); + + it('should abort when max_iterations is reached', () => { + // Given: a piece with max_iterations=2 that loops between step-a and step-b + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-max-iter.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/max-iter-loop.json'); + + // When: executing the piece + const result = runTakt({ + args: [ + '--task', 'Test max iterations', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: piece aborts due to iteration limit + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/Max iterations|iteration|aborted/i); + }, 240_000); + + it('should pass previous response between sequential steps', () => { + // Given: a two-step piece and a scenario with distinct step outputs + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-two-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/two-step-done.json'); + + // When: executing the piece + const result = runTakt({ + args: [ + '--task', 'Test previous response passing', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: piece completes successfully (both steps execute) + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Piece completed'); + }, 240_000); +}); diff --git a/e2e/specs/provider-error.e2e.ts b/e2e/specs/provider-error.e2e.ts new file mode 100644 index 00000000..0f14542f --- /dev/null +++ b/e2e/specs/provider-error.e2e.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-provider-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Provider error handling (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should override config provider with --provider flag', () => { + // Given: config.yaml has provider: claude, but CLI flag specifies mock + writeFileSync( + join(isolatedEnv.taktDir, 'config.yaml'), + [ + 'provider: claude', + 'language: en', + 'log_level: info', + 'default_piece: default', + ].join('\n'), + ); + + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + + // When: running with --provider mock + const result = runTakt({ + args: [ + '--task', 'Test provider override', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: executes successfully with mock provider + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Piece completed'); + }, 240_000); + + it('should use default mock response when scenario entries are exhausted', () => { + // Given: a two-step piece with only 1 scenario entry + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-two-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/one-entry-only.json'); + + // When: executing the piece (step-2 will have no scenario entry) + const result = runTakt({ + args: [ + '--task', 'Test scenario exhaustion', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: does not crash; either completes or aborts gracefully + const combined = result.stdout + result.stderr; + expect(combined).not.toContain('UnhandledPromiseRejection'); + expect(combined).not.toContain('SIGTERM'); + }, 240_000); + + it('should error when scenario file does not exist', () => { + // Given: TAKT_MOCK_SCENARIO pointing to a non-existent file + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + // When: executing with a bad scenario path + const result = runTakt({ + args: [ + '--task', 'Test bad scenario', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: '/nonexistent/path/scenario.json', + }, + timeout: 240_000, + }); + + // Then: exits with error and clear message + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/[Ss]cenario file not found|ENOENT/); + }, 240_000); +}); diff --git a/e2e/specs/quiet-mode.e2e.ts b/e2e/specs/quiet-mode.e2e.ts new file mode 100644 index 00000000..085fb049 --- /dev/null +++ b/e2e/specs/quiet-mode.e2e.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-quiet-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Quiet mode (--quiet)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should suppress AI stream output in quiet mode', () => { + // Given: a simple piece and scenario + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + + // When: running with --quiet flag + const result = runTakt({ + args: [ + '--task', 'Test quiet mode', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + '--quiet', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: completes successfully; mock content should not appear in output + expect(result.exitCode).toBe(0); + // In quiet mode, the raw mock response text should be suppressed + expect(result.stdout).not.toContain('Mock response for persona'); + }, 240_000); +}); diff --git a/e2e/specs/run-multiple-tasks.e2e.ts b/e2e/specs/run-multiple-tasks.e2e.ts new file mode 100644 index 00000000..14b1e7b4 --- /dev/null +++ b/e2e/specs/run-multiple-tasks.e2e.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-run-multi-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Run multiple tasks (takt run)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + + // Override config to use mock provider + writeFileSync( + join(isolatedEnv.taktDir, 'config.yaml'), + [ + 'provider: mock', + 'language: en', + 'log_level: info', + 'default_piece: default', + ].join('\n'), + ); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should execute all pending tasks sequentially', () => { + // Given: 3 pending tasks in tasks.yaml + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/run-three-tasks.json'); + const now = new Date().toISOString(); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: task-1', + ' status: pending', + ' content: "E2E task 1"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' - name: task-2', + ' status: pending', + ' content: "E2E task 2"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' - name: task-3', + ' status: pending', + ' content: "E2E task 3"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: all 3 tasks complete + expect(result.exitCode).toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toContain('task-1'); + expect(combined).toContain('task-2'); + expect(combined).toContain('task-3'); + }, 240_000); + + it('should continue remaining tasks when one task fails', () => { + // Given: 3 tasks where the 2nd will fail (error status) + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/run-with-failure.json'); + const now = new Date().toISOString(); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: task-ok-1', + ' status: pending', + ' content: "Should succeed"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' - name: task-fail', + ' status: pending', + ' content: "Should fail"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' - name: task-ok-2', + ' status: pending', + ' content: "Should succeed after failure"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: exit code is non-zero (failure occurred), but task-ok-2 was still attempted + const combined = result.stdout + result.stderr; + expect(combined).toContain('task-ok-1'); + expect(combined).toContain('task-fail'); + expect(combined).toContain('task-ok-2'); + }, 240_000); + + it('should exit cleanly when no pending tasks exist', () => { + // Given: an empty tasks.yaml + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + 'tasks: []\n', + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits cleanly with code 0 + expect(result.exitCode).toBe(0); + }, 240_000); +}); diff --git a/e2e/specs/task-content-file.e2e.ts b/e2e/specs/task-content-file.e2e.ts new file mode 100644 index 00000000..d826d863 --- /dev/null +++ b/e2e/specs/task-content-file.e2e.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-contentfile-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Task content_file reference (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + + writeFileSync( + join(isolatedEnv.taktDir, 'config.yaml'), + [ + 'provider: mock', + 'language: en', + 'log_level: info', + 'default_piece: default', + ].join('\n'), + ); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should execute task using content_file reference', () => { + // Given: a task with content_file pointing to an existing file + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + const now = new Date().toISOString(); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + + // Create the content file + writeFileSync( + join(repo.path, 'task-content.txt'), + 'Create a noop file for E2E testing.', + 'utf-8', + ); + + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: content-file-task', + ' status: pending', + ' content_file: "./task-content.txt"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: task executes successfully + expect(result.exitCode).toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toContain('content-file-task'); + }, 240_000); + + it('should fail when content_file references a nonexistent file', () => { + // Given: a task with content_file pointing to a nonexistent file + const now = new Date().toISOString(); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: bad-content-file-task', + ' status: pending', + ' content_file: "./nonexistent-content.txt"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: task fails with a meaningful error + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found|ENOENT|missing|error/i); + }, 240_000); +}); diff --git a/vitest.config.e2e.mock.ts b/vitest.config.e2e.mock.ts index 246ed68b..48171808 100644 --- a/vitest.config.e2e.mock.ts +++ b/vitest.config.e2e.mock.ts @@ -11,6 +11,17 @@ export default defineConfig({ 'e2e/specs/list-non-interactive.e2e.ts', 'e2e/specs/multi-step-parallel.e2e.ts', 'e2e/specs/run-sigint-graceful.e2e.ts', + 'e2e/specs/piece-error-handling.e2e.ts', + 'e2e/specs/run-multiple-tasks.e2e.ts', + 'e2e/specs/provider-error.e2e.ts', + 'e2e/specs/error-handling.e2e.ts', + 'e2e/specs/cli-catalog.e2e.ts', + 'e2e/specs/cli-prompt.e2e.ts', + 'e2e/specs/cli-switch.e2e.ts', + 'e2e/specs/cli-help.e2e.ts', + 'e2e/specs/cli-clear.e2e.ts', + 'e2e/specs/quiet-mode.e2e.ts', + 'e2e/specs/task-content-file.e2e.ts', ], environment: 'node', globals: false, From 7e15691ba2f8c8b0870617c3166f8a7c7f616256 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:37:15 +0900 Subject: [PATCH 03/45] github-issue-200-arpeggio (#203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: stable release時にnext dist-tagを自動同期 * takt: github-issue-200-arpeggio --- .github/workflows/auto-tag.yml | 37 +- src/__tests__/arpeggio-csv.test.ts | 136 +++++++ .../arpeggio-data-source-factory.test.ts | 50 +++ src/__tests__/arpeggio-merge.test.ts | 108 ++++++ src/__tests__/arpeggio-schema.test.ts | 332 ++++++++++++++++++ src/__tests__/arpeggio-template.test.ts | 83 +++++ src/__tests__/engine-arpeggio.test.ts | 275 +++++++++++++++ src/core/models/index.ts | 2 + src/core/models/piece-types.ts | 36 ++ src/core/models/schemas.ts | 42 +++ src/core/models/types.ts | 2 + src/core/piece/arpeggio/csv-data-source.ts | 133 +++++++ .../piece/arpeggio/data-source-factory.ts | 41 +++ src/core/piece/arpeggio/merge.ts | 78 ++++ src/core/piece/arpeggio/template.ts | 72 ++++ src/core/piece/arpeggio/types.ts | 46 +++ src/core/piece/engine/ArpeggioRunner.ts | 268 ++++++++++++++ src/core/piece/engine/PieceEngine.ts | 29 +- src/core/piece/engine/index.ts | 1 + src/infra/config/loaders/pieceParser.ts | 38 +- 20 files changed, 1802 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/arpeggio-csv.test.ts create mode 100644 src/__tests__/arpeggio-data-source-factory.test.ts create mode 100644 src/__tests__/arpeggio-merge.test.ts create mode 100644 src/__tests__/arpeggio-schema.test.ts create mode 100644 src/__tests__/arpeggio-template.test.ts create mode 100644 src/__tests__/engine-arpeggio.test.ts create mode 100644 src/core/piece/arpeggio/csv-data-source.ts create mode 100644 src/core/piece/arpeggio/data-source-factory.ts create mode 100644 src/core/piece/arpeggio/merge.ts create mode 100644 src/core/piece/arpeggio/template.ts create mode 100644 src/core/piece/arpeggio/types.ts create mode 100644 src/core/piece/engine/ArpeggioRunner.ts diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index bab7fad3..502d777f 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -52,12 +52,47 @@ jobs: run: | VERSION="${{ needs.tag.outputs.tag }}" VERSION="${VERSION#v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" if echo "$VERSION" | grep -qE '(alpha|beta|rc|next)'; then echo "tag=next" >> "$GITHUB_OUTPUT" else echo "tag=latest" >> "$GITHUB_OUTPUT" fi - - run: npm publish --tag ${{ steps.npm-tag.outputs.tag }} + - name: Publish package + run: npm publish --tag ${{ steps.npm-tag.outputs.tag }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Sync next tag on stable release + if: steps.npm-tag.outputs.tag == 'latest' + run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") + VERSION="${{ steps.npm-tag.outputs.version }}" + + for attempt in 1 2 3; do + if npm dist-tag add "${PACKAGE_NAME}@${VERSION}" next; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to sync next tag after 3 attempts." + exit 1 + fi + sleep $((attempt * 5)) + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Verify dist-tags + run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") + LATEST=$(npm view "${PACKAGE_NAME}" dist-tags.latest) + NEXT=$(npm view "${PACKAGE_NAME}" dist-tags.next || true) + + echo "latest=${LATEST}" + echo "next=${NEXT}" + + if [ "${{ steps.npm-tag.outputs.tag }}" = "latest" ] && [ "${LATEST}" != "${NEXT}" ]; then + echo "Expected next to match latest on stable release, but they differ." + exit 1 + fi diff --git a/src/__tests__/arpeggio-csv.test.ts b/src/__tests__/arpeggio-csv.test.ts new file mode 100644 index 00000000..9d517937 --- /dev/null +++ b/src/__tests__/arpeggio-csv.test.ts @@ -0,0 +1,136 @@ +/** + * Tests for CSV data source parsing and batch reading. + */ + +import { describe, it, expect } from 'vitest'; +import { parseCsv, CsvDataSource } from '../core/piece/arpeggio/csv-data-source.js'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; + +describe('parseCsv', () => { + it('should parse simple CSV content', () => { + const csv = 'name,age\nAlice,30\nBob,25'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['name', 'age'], + ['Alice', '30'], + ['Bob', '25'], + ]); + }); + + it('should handle quoted fields', () => { + const csv = 'name,description\nAlice,"Hello, World"\nBob,"Line1"'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['name', 'description'], + ['Alice', 'Hello, World'], + ['Bob', 'Line1'], + ]); + }); + + it('should handle escaped quotes (double quotes)', () => { + const csv = 'name,value\nAlice,"He said ""hello"""\nBob,simple'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['name', 'value'], + ['Alice', 'He said "hello"'], + ['Bob', 'simple'], + ]); + }); + + it('should handle CRLF line endings', () => { + const csv = 'name,age\r\nAlice,30\r\nBob,25'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['name', 'age'], + ['Alice', '30'], + ['Bob', '25'], + ]); + }); + + it('should handle bare CR line endings', () => { + const csv = 'name,age\rAlice,30\rBob,25'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['name', 'age'], + ['Alice', '30'], + ['Bob', '25'], + ]); + }); + + it('should handle empty fields', () => { + const csv = 'a,b,c\n1,,3\n,,'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['a', 'b', 'c'], + ['1', '', '3'], + ['', '', ''], + ]); + }); + + it('should handle newlines within quoted fields', () => { + const csv = 'name,bio\nAlice,"Line1\nLine2"\nBob,simple'; + const result = parseCsv(csv); + expect(result).toEqual([ + ['name', 'bio'], + ['Alice', 'Line1\nLine2'], + ['Bob', 'simple'], + ]); + }); +}); + +describe('CsvDataSource', () => { + function createTempCsv(content: string): string { + const dir = join(tmpdir(), `takt-csv-test-${randomUUID()}`); + mkdirSync(dir, { recursive: true }); + const filePath = join(dir, 'test.csv'); + writeFileSync(filePath, content, 'utf-8'); + return filePath; + } + + it('should read batches with batch_size 1', async () => { + const filePath = createTempCsv('name,age\nAlice,30\nBob,25\nCharlie,35'); + const source = new CsvDataSource(filePath); + const batches = await source.readBatches(1); + + expect(batches).toHaveLength(3); + expect(batches[0]!.rows).toEqual([{ name: 'Alice', age: '30' }]); + expect(batches[0]!.batchIndex).toBe(0); + expect(batches[0]!.totalBatches).toBe(3); + expect(batches[1]!.rows).toEqual([{ name: 'Bob', age: '25' }]); + expect(batches[2]!.rows).toEqual([{ name: 'Charlie', age: '35' }]); + }); + + it('should read batches with batch_size 2', async () => { + const filePath = createTempCsv('name,age\nAlice,30\nBob,25\nCharlie,35'); + const source = new CsvDataSource(filePath); + const batches = await source.readBatches(2); + + expect(batches).toHaveLength(2); + expect(batches[0]!.rows).toEqual([ + { name: 'Alice', age: '30' }, + { name: 'Bob', age: '25' }, + ]); + expect(batches[0]!.totalBatches).toBe(2); + expect(batches[1]!.rows).toEqual([ + { name: 'Charlie', age: '35' }, + ]); + }); + + it('should throw when CSV has no data rows', async () => { + const filePath = createTempCsv('name,age'); + const source = new CsvDataSource(filePath); + await expect(source.readBatches(1)).rejects.toThrow('CSV file has no data rows'); + }); + + it('should handle missing columns by returning empty string', async () => { + const filePath = createTempCsv('a,b,c\n1,2\n3'); + const source = new CsvDataSource(filePath); + const batches = await source.readBatches(1); + + expect(batches[0]!.rows).toEqual([{ a: '1', b: '2', c: '' }]); + expect(batches[1]!.rows).toEqual([{ a: '3', b: '', c: '' }]); + }); +}); diff --git a/src/__tests__/arpeggio-data-source-factory.test.ts b/src/__tests__/arpeggio-data-source-factory.test.ts new file mode 100644 index 00000000..b3bc896c --- /dev/null +++ b/src/__tests__/arpeggio-data-source-factory.test.ts @@ -0,0 +1,50 @@ +/** + * Tests for the arpeggio data source factory. + * + * Covers: + * - Built-in 'csv' source type returns CsvDataSource + * - Custom module: valid default export returns a data source + * - Custom module: non-function default export throws + * - Custom module: missing default export throws + */ + +import { describe, it, expect } from 'vitest'; +import { createDataSource } from '../core/piece/arpeggio/data-source-factory.js'; +import { CsvDataSource } from '../core/piece/arpeggio/csv-data-source.js'; + +describe('createDataSource', () => { + it('should return a CsvDataSource for built-in "csv" type', async () => { + const source = await createDataSource('csv', '/path/to/data.csv'); + expect(source).toBeInstanceOf(CsvDataSource); + }); + + it('should return a valid data source from a custom module with correct default export', async () => { + const tempModulePath = new URL( + 'data:text/javascript,export default function(path) { return { readBatches: async () => [] }; }' + ).href; + + const source = await createDataSource(tempModulePath, '/some/path'); + expect(source).toBeDefined(); + expect(typeof source.readBatches).toBe('function'); + }); + + it('should throw when custom module does not export a default function', async () => { + const tempModulePath = new URL( + 'data:text/javascript,export default "not-a-function"' + ).href; + + await expect(createDataSource(tempModulePath, '/some/path')).rejects.toThrow( + /must export a default factory function/ + ); + }); + + it('should throw when custom module has no default export', async () => { + const tempModulePath = new URL( + 'data:text/javascript,export const foo = 42' + ).href; + + await expect(createDataSource(tempModulePath, '/some/path')).rejects.toThrow( + /must export a default factory function/ + ); + }); +}); diff --git a/src/__tests__/arpeggio-merge.test.ts b/src/__tests__/arpeggio-merge.test.ts new file mode 100644 index 00000000..a27f8c56 --- /dev/null +++ b/src/__tests__/arpeggio-merge.test.ts @@ -0,0 +1,108 @@ +/** + * Tests for arpeggio merge processing. + */ + +import { describe, it, expect } from 'vitest'; +import { buildMergeFn } from '../core/piece/arpeggio/merge.js'; +import type { ArpeggioMergeMovementConfig } from '../core/piece/arpeggio/types.js'; +import type { BatchResult } from '../core/piece/arpeggio/types.js'; + +function makeResult(batchIndex: number, content: string, success = true): BatchResult { + return { batchIndex, content, success }; +} + +function makeFailedResult(batchIndex: number, error: string): BatchResult { + return { batchIndex, content: '', success: false, error }; +} + +describe('buildMergeFn', () => { + describe('concat strategy', () => { + it('should concatenate results with default separator (newline)', async () => { + const config: ArpeggioMergeMovementConfig = { strategy: 'concat' }; + const mergeFn = await buildMergeFn(config); + const results = [ + makeResult(0, 'Result A'), + makeResult(1, 'Result B'), + makeResult(2, 'Result C'), + ]; + expect(mergeFn(results)).toBe('Result A\nResult B\nResult C'); + }); + + it('should concatenate results with custom separator', async () => { + const config: ArpeggioMergeMovementConfig = { strategy: 'concat', separator: '\n---\n' }; + const mergeFn = await buildMergeFn(config); + const results = [ + makeResult(0, 'A'), + makeResult(1, 'B'), + ]; + expect(mergeFn(results)).toBe('A\n---\nB'); + }); + + it('should sort results by batch index', async () => { + const config: ArpeggioMergeMovementConfig = { strategy: 'concat' }; + const mergeFn = await buildMergeFn(config); + const results = [ + makeResult(2, 'C'), + makeResult(0, 'A'), + makeResult(1, 'B'), + ]; + expect(mergeFn(results)).toBe('A\nB\nC'); + }); + + it('should filter out failed results', async () => { + const config: ArpeggioMergeMovementConfig = { strategy: 'concat' }; + const mergeFn = await buildMergeFn(config); + const results = [ + makeResult(0, 'A'), + makeFailedResult(1, 'oops'), + makeResult(2, 'C'), + ]; + expect(mergeFn(results)).toBe('A\nC'); + }); + + it('should return empty string when all results failed', async () => { + const config: ArpeggioMergeMovementConfig = { strategy: 'concat' }; + const mergeFn = await buildMergeFn(config); + const results = [ + makeFailedResult(0, 'error1'), + makeFailedResult(1, 'error2'), + ]; + expect(mergeFn(results)).toBe(''); + }); + }); + + describe('custom strategy with inline_js', () => { + it('should execute inline JS merge function', async () => { + const config: ArpeggioMergeMovementConfig = { + strategy: 'custom', + inlineJs: 'return results.filter(r => r.success).map(r => r.content.toUpperCase()).join(", ");', + }; + const mergeFn = await buildMergeFn(config); + const results = [ + makeResult(0, 'hello'), + makeResult(1, 'world'), + ]; + expect(mergeFn(results)).toBe('HELLO, WORLD'); + }); + + it('should throw when inline JS returns non-string', async () => { + const config: ArpeggioMergeMovementConfig = { + strategy: 'custom', + inlineJs: 'return 42;', + }; + const mergeFn = await buildMergeFn(config); + expect(() => mergeFn([makeResult(0, 'test')])).toThrow( + 'Inline JS merge function must return a string, got number' + ); + }); + }); + + describe('custom strategy validation', () => { + it('should throw when custom strategy has neither inline_js nor file', async () => { + const config: ArpeggioMergeMovementConfig = { strategy: 'custom' }; + await expect(buildMergeFn(config)).rejects.toThrow( + 'Custom merge strategy requires either inline_js or file path' + ); + }); + }); +}); diff --git a/src/__tests__/arpeggio-schema.test.ts b/src/__tests__/arpeggio-schema.test.ts new file mode 100644 index 00000000..e121945b --- /dev/null +++ b/src/__tests__/arpeggio-schema.test.ts @@ -0,0 +1,332 @@ +/** + * Tests for Arpeggio-related Zod schemas. + * + * Covers: + * - ArpeggioMergeRawSchema cross-validation (.refine()) + * - ArpeggioConfigRawSchema required fields and defaults + * - PieceMovementRawSchema with arpeggio field + */ + +import { describe, it, expect } from 'vitest'; +import { + ArpeggioMergeRawSchema, + ArpeggioConfigRawSchema, + PieceMovementRawSchema, +} from '../core/models/index.js'; + +describe('ArpeggioMergeRawSchema', () => { + it('should accept concat strategy without inline_js or file', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'concat', + }); + expect(result.success).toBe(true); + }); + + it('should accept concat strategy with separator', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'concat', + separator: '\n---\n', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.separator).toBe('\n---\n'); + } + }); + + it('should default strategy to concat when omitted', () => { + const result = ArpeggioMergeRawSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.strategy).toBe('concat'); + } + }); + + it('should accept custom strategy with inline_js', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'custom', + inline_js: 'return results.map(r => r.content).join(",");', + }); + expect(result.success).toBe(true); + }); + + it('should accept custom strategy with file', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'custom', + file: './merge.js', + }); + expect(result.success).toBe(true); + }); + + it('should reject custom strategy without inline_js or file', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'custom', + }); + expect(result.success).toBe(false); + }); + + it('should reject concat strategy with inline_js', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'concat', + inline_js: 'return "hello";', + }); + expect(result.success).toBe(false); + }); + + it('should reject concat strategy with file', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'concat', + file: './merge.js', + }); + expect(result.success).toBe(false); + }); + + it('should reject invalid strategy value', () => { + const result = ArpeggioMergeRawSchema.safeParse({ + strategy: 'invalid', + }); + expect(result.success).toBe(false); + }); +}); + +describe('ArpeggioConfigRawSchema', () => { + const validConfig = { + source: 'csv', + source_path: './data.csv', + template: './template.md', + }; + + it('should accept a valid minimal config', () => { + const result = ArpeggioConfigRawSchema.safeParse(validConfig); + expect(result.success).toBe(true); + }); + + it('should apply default values for optional fields', () => { + const result = ArpeggioConfigRawSchema.safeParse(validConfig); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.batch_size).toBe(1); + expect(result.data.concurrency).toBe(1); + expect(result.data.max_retries).toBe(2); + expect(result.data.retry_delay_ms).toBe(1000); + } + }); + + it('should accept explicit values overriding defaults', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + batch_size: 5, + concurrency: 3, + max_retries: 4, + retry_delay_ms: 2000, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.batch_size).toBe(5); + expect(result.data.concurrency).toBe(3); + expect(result.data.max_retries).toBe(4); + expect(result.data.retry_delay_ms).toBe(2000); + } + }); + + it('should accept config with merge field', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + merge: { strategy: 'concat', separator: '---' }, + }); + expect(result.success).toBe(true); + }); + + it('should accept config with output_path', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + output_path: './output.txt', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.output_path).toBe('./output.txt'); + } + }); + + it('should reject when source is empty', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + source: '', + }); + expect(result.success).toBe(false); + }); + + it('should reject when source is missing', () => { + const { source: _, ...noSource } = validConfig; + const result = ArpeggioConfigRawSchema.safeParse(noSource); + expect(result.success).toBe(false); + }); + + it('should reject when source_path is empty', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + source_path: '', + }); + expect(result.success).toBe(false); + }); + + it('should reject when source_path is missing', () => { + const { source_path: _, ...noSourcePath } = validConfig; + const result = ArpeggioConfigRawSchema.safeParse(noSourcePath); + expect(result.success).toBe(false); + }); + + it('should reject when template is empty', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + template: '', + }); + expect(result.success).toBe(false); + }); + + it('should reject when template is missing', () => { + const { template: _, ...noTemplate } = validConfig; + const result = ArpeggioConfigRawSchema.safeParse(noTemplate); + expect(result.success).toBe(false); + }); + + it('should reject batch_size of 0', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + batch_size: 0, + }); + expect(result.success).toBe(false); + }); + + it('should reject negative batch_size', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + batch_size: -1, + }); + expect(result.success).toBe(false); + }); + + it('should reject concurrency of 0', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + concurrency: 0, + }); + expect(result.success).toBe(false); + }); + + it('should reject negative concurrency', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + concurrency: -1, + }); + expect(result.success).toBe(false); + }); + + it('should reject negative max_retries', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + max_retries: -1, + }); + expect(result.success).toBe(false); + }); + + it('should accept max_retries of 0', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + max_retries: 0, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.max_retries).toBe(0); + } + }); + + it('should reject non-integer batch_size', () => { + const result = ArpeggioConfigRawSchema.safeParse({ + ...validConfig, + batch_size: 1.5, + }); + expect(result.success).toBe(false); + }); +}); + +describe('PieceMovementRawSchema with arpeggio', () => { + it('should accept a movement with arpeggio config', () => { + const raw = { + name: 'batch-process', + arpeggio: { + source: 'csv', + source_path: './data.csv', + template: './prompt.md', + }, + }; + + const result = PieceMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.arpeggio).toBeDefined(); + expect(result.data.arpeggio!.source).toBe('csv'); + } + }); + + it('should accept a movement with arpeggio and rules', () => { + const raw = { + name: 'batch-process', + arpeggio: { + source: 'csv', + source_path: './data.csv', + template: './prompt.md', + batch_size: 2, + concurrency: 3, + }, + rules: [ + { condition: 'All processed', next: 'COMPLETE' }, + { condition: 'Errors found', next: 'fix' }, + ], + }; + + const result = PieceMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.arpeggio!.batch_size).toBe(2); + expect(result.data.arpeggio!.concurrency).toBe(3); + expect(result.data.rules).toHaveLength(2); + } + }); + + it('should accept a movement without arpeggio (normal movement)', () => { + const raw = { + name: 'normal-step', + persona: 'coder.md', + instruction_template: 'Do work', + }; + + const result = PieceMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.arpeggio).toBeUndefined(); + } + }); + + it('should accept a movement with arpeggio including custom merge', () => { + const raw = { + name: 'custom-merge-step', + arpeggio: { + source: 'csv', + source_path: './data.csv', + template: './prompt.md', + merge: { + strategy: 'custom', + inline_js: 'return results.map(r => r.content).join(", ");', + }, + output_path: './output.txt', + }, + }; + + const result = PieceMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.arpeggio!.merge).toBeDefined(); + expect(result.data.arpeggio!.output_path).toBe('./output.txt'); + } + }); +}); diff --git a/src/__tests__/arpeggio-template.test.ts b/src/__tests__/arpeggio-template.test.ts new file mode 100644 index 00000000..1fcc8d38 --- /dev/null +++ b/src/__tests__/arpeggio-template.test.ts @@ -0,0 +1,83 @@ +/** + * Tests for arpeggio template expansion. + */ + +import { describe, it, expect } from 'vitest'; +import { expandTemplate } from '../core/piece/arpeggio/template.js'; +import type { DataBatch } from '../core/piece/arpeggio/types.js'; + +function makeBatch(rows: Record[], batchIndex = 0, totalBatches = 1): DataBatch { + return { rows, batchIndex, totalBatches }; +} + +describe('expandTemplate', () => { + it('should expand {line:1} with formatted row data', () => { + const batch = makeBatch([{ name: 'Alice', age: '30' }]); + const result = expandTemplate('Process this: {line:1}', batch); + expect(result).toBe('Process this: name: Alice\nage: 30'); + }); + + it('should expand {line:1} and {line:2} for multi-row batches', () => { + const batch = makeBatch([ + { name: 'Alice', age: '30' }, + { name: 'Bob', age: '25' }, + ]); + const result = expandTemplate('Row 1: {line:1}\nRow 2: {line:2}', batch); + expect(result).toBe('Row 1: name: Alice\nage: 30\nRow 2: name: Bob\nage: 25'); + }); + + it('should expand {col:N:name} with specific column values', () => { + const batch = makeBatch([{ name: 'Alice', age: '30', city: 'Tokyo' }]); + const result = expandTemplate('Name: {col:1:name}, City: {col:1:city}', batch); + expect(result).toBe('Name: Alice, City: Tokyo'); + }); + + it('should expand {batch_index} and {total_batches}', () => { + const batch = makeBatch([{ name: 'Alice' }], 2, 5); + const result = expandTemplate('Batch {batch_index} of {total_batches}', batch); + expect(result).toBe('Batch 2 of 5'); + }); + + it('should expand all placeholder types in a single template', () => { + const batch = makeBatch([ + { name: 'Alice', role: 'dev' }, + { name: 'Bob', role: 'pm' }, + ], 0, 3); + const template = 'Batch {batch_index}/{total_batches}\nFirst: {col:1:name}\nSecond: {line:2}'; + const result = expandTemplate(template, batch); + expect(result).toBe('Batch 0/3\nFirst: Alice\nSecond: name: Bob\nrole: pm'); + }); + + it('should throw when {line:N} references out-of-range row', () => { + const batch = makeBatch([{ name: 'Alice' }]); + expect(() => expandTemplate('{line:2}', batch)).toThrow( + 'Template placeholder {line:2} references row 2 but batch has 1 rows' + ); + }); + + it('should throw when {col:N:name} references out-of-range row', () => { + const batch = makeBatch([{ name: 'Alice' }]); + expect(() => expandTemplate('{col:2:name}', batch)).toThrow( + 'Template placeholder {col:2:name} references row 2 but batch has 1 rows' + ); + }); + + it('should throw when {col:N:name} references unknown column', () => { + const batch = makeBatch([{ name: 'Alice' }]); + expect(() => expandTemplate('{col:1:missing}', batch)).toThrow( + 'Template placeholder {col:1:missing} references unknown column "missing"' + ); + }); + + it('should handle templates with no placeholders', () => { + const batch = makeBatch([{ name: 'Alice' }]); + const result = expandTemplate('No placeholders here', batch); + expect(result).toBe('No placeholders here'); + }); + + it('should handle multiple occurrences of the same placeholder', () => { + const batch = makeBatch([{ name: 'Alice' }], 1, 3); + const result = expandTemplate('{batch_index} and {batch_index}', batch); + expect(result).toBe('1 and 1'); + }); +}); diff --git a/src/__tests__/engine-arpeggio.test.ts b/src/__tests__/engine-arpeggio.test.ts new file mode 100644 index 00000000..6b5618c2 --- /dev/null +++ b/src/__tests__/engine-arpeggio.test.ts @@ -0,0 +1,275 @@ +/** + * Integration tests for arpeggio movement execution via PieceEngine. + * + * Tests the full pipeline: CSV → template expansion → LLM → merge → rule evaluation. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +// Mock external dependencies before importing +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../core/piece/evaluation/index.js', () => ({ + detectMatchedRule: vi.fn(), + evaluateAggregateConditions: vi.fn(), +})); + +vi.mock('../core/piece/phase-runner.js', () => ({ + needsStatusJudgmentPhase: vi.fn().mockReturnValue(false), + runReportPhase: vi.fn().mockResolvedValue(undefined), + runStatusJudgmentPhase: vi.fn().mockResolvedValue(''), +})); + +vi.mock('../shared/utils/index.js', async () => { + const actual = await vi.importActual('../shared/utils/index.js'); + return { + ...actual, + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), + }; +}); + +import { runAgent } from '../agents/runner.js'; +import { detectMatchedRule } from '../core/piece/evaluation/index.js'; +import { PieceEngine } from '../core/piece/engine/PieceEngine.js'; +import type { PieceConfig, PieceMovement, AgentResponse, ArpeggioMovementConfig } from '../core/models/index.js'; +import type { PieceEngineOptions } from '../core/piece/types.js'; +import { + makeResponse, + makeMovement, + makeRule, + createTestTmpDir, + cleanupPieceEngine, +} from './engine-test-helpers.js'; +import type { RuleMatch } from '../core/piece/index.js'; + +function createArpeggioTestDir(): { tmpDir: string; csvPath: string; templatePath: string } { + const tmpDir = createTestTmpDir(); + const csvPath = join(tmpDir, 'data.csv'); + const templatePath = join(tmpDir, 'template.md'); + + writeFileSync(csvPath, 'name,task\nAlice,review\nBob,implement\nCharlie,test', 'utf-8'); + writeFileSync(templatePath, 'Process {line:1}', 'utf-8'); + + return { tmpDir, csvPath, templatePath }; +} + +function createArpeggioConfig(csvPath: string, templatePath: string, overrides: Partial = {}): ArpeggioMovementConfig { + return { + source: 'csv', + sourcePath: csvPath, + batchSize: 1, + concurrency: 1, + templatePath, + merge: { strategy: 'concat' }, + maxRetries: 0, + retryDelayMs: 0, + ...overrides, + }; +} + +function buildArpeggioPieceConfig(arpeggioConfig: ArpeggioMovementConfig, tmpDir: string): PieceConfig { + return { + name: 'test-arpeggio', + description: 'Test arpeggio piece', + maxIterations: 10, + initialMovement: 'process', + movements: [ + { + ...makeMovement('process', { + rules: [ + makeRule('Processing complete', 'COMPLETE'), + makeRule('Processing failed', 'ABORT'), + ], + }), + arpeggio: arpeggioConfig, + }, + ], + }; +} + +function createEngineOptions(tmpDir: string): PieceEngineOptions { + return { + projectCwd: tmpDir, + detectRuleIndex: () => 0, + callAiJudge: async () => 0, + }; +} + +describe('ArpeggioRunner integration', () => { + let engine: PieceEngine | undefined; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(detectMatchedRule).mockResolvedValue(undefined); + }); + + afterEach(() => { + if (engine) { + cleanupPieceEngine(engine); + engine = undefined; + } + }); + + it('should process CSV data and merge results', async () => { + const { tmpDir, csvPath, templatePath } = createArpeggioTestDir(); + const arpeggioConfig = createArpeggioConfig(csvPath, templatePath); + const config = buildArpeggioPieceConfig(arpeggioConfig, tmpDir); + + // Mock agent to return batch-specific responses + const mockAgent = vi.mocked(runAgent); + mockAgent + .mockResolvedValueOnce(makeResponse({ content: 'Processed Alice' })) + .mockResolvedValueOnce(makeResponse({ content: 'Processed Bob' })) + .mockResolvedValueOnce(makeResponse({ content: 'Processed Charlie' })); + + // Mock rule detection for the merged result + vi.mocked(detectMatchedRule).mockResolvedValueOnce({ + index: 0, + method: 'phase1_tag', + }); + + engine = new PieceEngine(config, tmpDir, 'test task', createEngineOptions(tmpDir)); + const state = await engine.run(); + + expect(state.status).toBe('completed'); + expect(mockAgent).toHaveBeenCalledTimes(3); + + // Verify merged content in movement output + const output = state.movementOutputs.get('process'); + expect(output).toBeDefined(); + expect(output!.content).toBe('Processed Alice\nProcessed Bob\nProcessed Charlie'); + }); + + it('should handle batch_size > 1', async () => { + const tmpDir = createTestTmpDir(); + const csvPath = join(tmpDir, 'data.csv'); + const templatePath = join(tmpDir, 'batch-template.md'); + // 4 rows so batch_size=2 gives exactly 2 batches with 2 rows each + writeFileSync(csvPath, 'name,task\nAlice,review\nBob,implement\nCharlie,test\nDave,deploy', 'utf-8'); + writeFileSync(templatePath, 'Row1: {line:1}\nRow2: {line:2}', 'utf-8'); + + const arpeggioConfig = createArpeggioConfig(csvPath, templatePath, { batchSize: 2 }); + const config = buildArpeggioPieceConfig(arpeggioConfig, tmpDir); + + const mockAgent = vi.mocked(runAgent); + mockAgent + .mockResolvedValueOnce(makeResponse({ content: 'Batch 0 result' })) + .mockResolvedValueOnce(makeResponse({ content: 'Batch 1 result' })); + + vi.mocked(detectMatchedRule).mockResolvedValueOnce({ + index: 0, + method: 'phase1_tag', + }); + + engine = new PieceEngine(config, tmpDir, 'test task', createEngineOptions(tmpDir)); + const state = await engine.run(); + + expect(state.status).toBe('completed'); + // 4 rows / batch_size 2 = 2 batches + expect(mockAgent).toHaveBeenCalledTimes(2); + }); + + it('should abort when a batch fails and retries are exhausted', async () => { + const { tmpDir, csvPath, templatePath } = createArpeggioTestDir(); + const arpeggioConfig = createArpeggioConfig(csvPath, templatePath, { + maxRetries: 1, + retryDelayMs: 0, + }); + const config = buildArpeggioPieceConfig(arpeggioConfig, tmpDir); + + const mockAgent = vi.mocked(runAgent); + // First batch succeeds + mockAgent.mockResolvedValueOnce(makeResponse({ content: 'OK' })); + // Second batch fails twice (initial + 1 retry) + mockAgent.mockResolvedValueOnce(makeResponse({ status: 'error', error: 'fail1' })); + mockAgent.mockResolvedValueOnce(makeResponse({ status: 'error', error: 'fail2' })); + // Third batch succeeds + mockAgent.mockResolvedValueOnce(makeResponse({ content: 'OK' })); + + engine = new PieceEngine(config, tmpDir, 'test task', createEngineOptions(tmpDir)); + const state = await engine.run(); + + expect(state.status).toBe('aborted'); + }); + + it('should write output file when output_path is configured', async () => { + const { tmpDir, csvPath, templatePath } = createArpeggioTestDir(); + const outputPath = join(tmpDir, 'output.txt'); + const arpeggioConfig = createArpeggioConfig(csvPath, templatePath, { outputPath }); + const config = buildArpeggioPieceConfig(arpeggioConfig, tmpDir); + + const mockAgent = vi.mocked(runAgent); + mockAgent + .mockResolvedValueOnce(makeResponse({ content: 'Result A' })) + .mockResolvedValueOnce(makeResponse({ content: 'Result B' })) + .mockResolvedValueOnce(makeResponse({ content: 'Result C' })); + + vi.mocked(detectMatchedRule).mockResolvedValueOnce({ + index: 0, + method: 'phase1_tag', + }); + + engine = new PieceEngine(config, tmpDir, 'test task', createEngineOptions(tmpDir)); + await engine.run(); + + const { readFileSync } = await import('node:fs'); + const outputContent = readFileSync(outputPath, 'utf-8'); + expect(outputContent).toBe('Result A\nResult B\nResult C'); + }); + + it('should handle concurrency > 1', async () => { + const { tmpDir, csvPath, templatePath } = createArpeggioTestDir(); + const arpeggioConfig = createArpeggioConfig(csvPath, templatePath, { concurrency: 3 }); + const config = buildArpeggioPieceConfig(arpeggioConfig, tmpDir); + + const mockAgent = vi.mocked(runAgent); + mockAgent + .mockResolvedValueOnce(makeResponse({ content: 'A' })) + .mockResolvedValueOnce(makeResponse({ content: 'B' })) + .mockResolvedValueOnce(makeResponse({ content: 'C' })); + + vi.mocked(detectMatchedRule).mockResolvedValueOnce({ + index: 0, + method: 'phase1_tag', + }); + + engine = new PieceEngine(config, tmpDir, 'test task', createEngineOptions(tmpDir)); + const state = await engine.run(); + + expect(state.status).toBe('completed'); + expect(mockAgent).toHaveBeenCalledTimes(3); + }); + + it('should use custom merge function when configured', async () => { + const { tmpDir, csvPath, templatePath } = createArpeggioTestDir(); + const arpeggioConfig = createArpeggioConfig(csvPath, templatePath, { + merge: { + strategy: 'custom', + inlineJs: 'return results.filter(r => r.success).map(r => r.content).join(" | ");', + }, + }); + const config = buildArpeggioPieceConfig(arpeggioConfig, tmpDir); + + const mockAgent = vi.mocked(runAgent); + mockAgent + .mockResolvedValueOnce(makeResponse({ content: 'X' })) + .mockResolvedValueOnce(makeResponse({ content: 'Y' })) + .mockResolvedValueOnce(makeResponse({ content: 'Z' })); + + vi.mocked(detectMatchedRule).mockResolvedValueOnce({ + index: 0, + method: 'phase1_tag', + }); + + engine = new PieceEngine(config, tmpDir, 'test task', createEngineOptions(tmpDir)); + const state = await engine.run(); + + expect(state.status).toBe('completed'); + const output = state.movementOutputs.get('process'); + expect(output!.content).toBe('X | Y | Z'); + }); +}); diff --git a/src/core/models/index.ts b/src/core/models/index.ts index 166becde..bf9b5ef6 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -12,6 +12,8 @@ export type { SessionState, PieceRule, PieceMovement, + ArpeggioMovementConfig, + ArpeggioMergeMovementConfig, LoopDetectionConfig, LoopMonitorConfig, LoopMonitorJudge, diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index aec81474..f1d4d42d 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -114,12 +114,48 @@ export interface PieceMovement { passPreviousResponse: boolean; /** Sub-movements to execute in parallel. When set, this movement runs all sub-movements concurrently. */ parallel?: PieceMovement[]; + /** Arpeggio configuration for data-driven batch processing. When set, this movement reads from a data source, expands templates, and calls LLM per batch. */ + arpeggio?: ArpeggioMovementConfig; /** Resolved policy content strings (from piece-level policies map, resolved at parse time) */ policyContents?: string[]; /** Resolved knowledge content strings (from piece-level knowledge map, resolved at parse time) */ knowledgeContents?: string[]; } +/** Merge configuration for arpeggio results */ +export interface ArpeggioMergeMovementConfig { + /** Merge strategy: 'concat' (default), 'custom' */ + readonly strategy: 'concat' | 'custom'; + /** Inline JS merge function body (for custom strategy) */ + readonly inlineJs?: string; + /** Path to external JS merge file (for custom strategy, resolved to absolute) */ + readonly filePath?: string; + /** Separator for concat strategy (default: '\n') */ + readonly separator?: string; +} + +/** Arpeggio configuration for data-driven batch processing movements */ +export interface ArpeggioMovementConfig { + /** Data source type (e.g., 'csv') */ + readonly source: string; + /** Path to the data source file (resolved to absolute) */ + readonly sourcePath: string; + /** Number of rows per batch (default: 1) */ + readonly batchSize: number; + /** Number of concurrent LLM calls (default: 1) */ + readonly concurrency: number; + /** Path to prompt template file (resolved to absolute) */ + readonly templatePath: string; + /** Merge configuration */ + readonly merge: ArpeggioMergeMovementConfig; + /** Maximum retry attempts per batch (default: 2) */ + readonly maxRetries: number; + /** Delay between retries in ms (default: 1000) */ + readonly retryDelayMs: number; + /** Optional output file path (resolved to absolute) */ + readonly outputPath?: string; +} + /** Loop detection configuration */ export interface LoopDetectionConfig { /** Maximum consecutive runs of the same step before triggering (default: 10) */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index df0d6b8c..8345a474 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -130,6 +130,46 @@ export const PieceRuleSchema = z.object({ interactive_only: z.boolean().optional(), }); +/** Arpeggio merge configuration schema */ +export const ArpeggioMergeRawSchema = z.object({ + /** Merge strategy: 'concat' or 'custom' */ + strategy: z.enum(['concat', 'custom']).optional().default('concat'), + /** Inline JS function body for custom merge */ + inline_js: z.string().optional(), + /** External JS file path for custom merge */ + file: z.string().optional(), + /** Separator for concat strategy */ + separator: z.string().optional(), +}).refine( + (data) => data.strategy !== 'custom' || data.inline_js != null || data.file != null, + { message: "Custom merge strategy requires either 'inline_js' or 'file'" } +).refine( + (data) => data.strategy !== 'concat' || (data.inline_js == null && data.file == null), + { message: "Concat merge strategy does not accept 'inline_js' or 'file'" } +); + +/** Arpeggio configuration schema for data-driven batch processing */ +export const ArpeggioConfigRawSchema = z.object({ + /** Data source type (e.g., 'csv') */ + source: z.string().min(1), + /** Path to the data source file */ + source_path: z.string().min(1), + /** Number of rows per batch (default: 1) */ + batch_size: z.number().int().positive().optional().default(1), + /** Number of concurrent LLM calls (default: 1) */ + concurrency: z.number().int().positive().optional().default(1), + /** Path to prompt template file */ + template: z.string().min(1), + /** Merge configuration */ + merge: ArpeggioMergeRawSchema.optional(), + /** Maximum retry attempts per batch (default: 2) */ + max_retries: z.number().int().min(0).optional().default(2), + /** Delay between retries in ms (default: 1000) */ + retry_delay_ms: z.number().int().min(0).optional().default(1000), + /** Optional output file path */ + output_path: z.string().optional(), +}); + /** Sub-movement schema for parallel execution */ export const ParallelSubMovementRawSchema = z.object({ name: z.string().min(1), @@ -190,6 +230,8 @@ export const PieceMovementRawSchema = z.object({ pass_previous_response: z.boolean().optional().default(true), /** Sub-movements to execute in parallel */ parallel: z.array(ParallelSubMovementRawSchema).optional(), + /** Arpeggio configuration for data-driven batch processing */ + arpeggio: ArpeggioConfigRawSchema.optional(), }); /** Loop monitor rule schema */ diff --git a/src/core/models/types.ts b/src/core/models/types.ts index b13a24a6..a5787525 100644 --- a/src/core/models/types.ts +++ b/src/core/models/types.ts @@ -31,6 +31,8 @@ export type { OutputContractEntry, McpServerConfig, PieceMovement, + ArpeggioMovementConfig, + ArpeggioMergeMovementConfig, LoopDetectionConfig, LoopMonitorConfig, LoopMonitorJudge, diff --git a/src/core/piece/arpeggio/csv-data-source.ts b/src/core/piece/arpeggio/csv-data-source.ts new file mode 100644 index 00000000..ca17755b --- /dev/null +++ b/src/core/piece/arpeggio/csv-data-source.ts @@ -0,0 +1,133 @@ +/** + * CSV data source for arpeggio movements. + * + * Reads CSV files and returns data in batches for template expansion. + * Handles quoted fields, escaped quotes, and various line endings. + */ + +import { readFileSync } from 'node:fs'; +import type { ArpeggioDataSource, DataBatch, DataRow } from './types.js'; + +/** Parse a CSV string into an array of string arrays (rows of fields) */ +export function parseCsv(content: string): string[][] { + const rows: string[][] = []; + let currentRow: string[] = []; + let currentField = ''; + let inQuotes = false; + let i = 0; + + while (i < content.length) { + const char = content[i]!; + + if (inQuotes) { + if (char === '"') { + // Check for escaped quote ("") + if (i + 1 < content.length && content[i + 1] === '"') { + currentField += '"'; + i += 2; + continue; + } + // End of quoted field + inQuotes = false; + i++; + continue; + } + currentField += char; + i++; + continue; + } + + if (char === '"' && currentField.length === 0) { + inQuotes = true; + i++; + continue; + } + + if (char === ',') { + currentRow.push(currentField); + currentField = ''; + i++; + continue; + } + + if (char === '\r') { + // Handle \r\n and bare \r + currentRow.push(currentField); + currentField = ''; + rows.push(currentRow); + currentRow = []; + if (i + 1 < content.length && content[i + 1] === '\n') { + i += 2; + } else { + i++; + } + continue; + } + + if (char === '\n') { + currentRow.push(currentField); + currentField = ''; + rows.push(currentRow); + currentRow = []; + i++; + continue; + } + + currentField += char; + i++; + } + + // Handle last field/row + if (currentField.length > 0 || currentRow.length > 0) { + currentRow.push(currentField); + rows.push(currentRow); + } + + return rows; +} + +/** Convert parsed CSV rows into DataRow objects using the header row */ +function rowsToDataRows(headers: readonly string[], dataRows: readonly string[][]): DataRow[] { + return dataRows.map((row) => { + const dataRow: DataRow = {}; + for (let col = 0; col < headers.length; col++) { + const header = headers[col]!; + dataRow[header] = row[col] ?? ''; + } + return dataRow; + }); +} + +/** Split an array into chunks of the given size */ +function chunk(array: readonly T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; +} + +export class CsvDataSource implements ArpeggioDataSource { + constructor(private readonly filePath: string) {} + + async readBatches(batchSize: number): Promise { + const content = readFileSync(this.filePath, 'utf-8'); + const parsed = parseCsv(content); + + if (parsed.length < 2) { + throw new Error(`CSV file has no data rows: ${this.filePath}`); + } + + const headers = parsed[0]!; + const dataRowArrays = parsed.slice(1); + const dataRows = rowsToDataRows(headers, dataRowArrays); + const chunks = chunk(dataRows, batchSize); + const totalBatches = chunks.length; + + return chunks.map((rows, index) => ({ + rows, + batchIndex: index, + totalBatches, + })); + } +} diff --git a/src/core/piece/arpeggio/data-source-factory.ts b/src/core/piece/arpeggio/data-source-factory.ts new file mode 100644 index 00000000..32161614 --- /dev/null +++ b/src/core/piece/arpeggio/data-source-factory.ts @@ -0,0 +1,41 @@ +/** + * Factory for creating data source instances. + * + * Maps source type names to their implementations. + * Built-in: 'csv'. Users can extend with custom JS modules. + */ + +import type { ArpeggioDataSource } from './types.js'; +import { CsvDataSource } from './csv-data-source.js'; + +/** Built-in data source type mapping */ +const BUILTIN_SOURCES: Record ArpeggioDataSource> = { + csv: (path) => new CsvDataSource(path), +}; + +/** + * Create a data source instance by type and path. + * + * For built-in types ('csv'), uses the registered factory. + * For custom types, loads from the source type as a JS module path. + */ +export async function createDataSource( + sourceType: string, + sourcePath: string, +): Promise { + const builtinFactory = BUILTIN_SOURCES[sourceType]; + if (builtinFactory) { + return builtinFactory(sourcePath); + } + + // Custom data source: sourceType is a path to a JS module that exports a factory + const module = await import(sourceType) as { + default?: (path: string) => ArpeggioDataSource; + }; + if (typeof module.default !== 'function') { + throw new Error( + `Custom data source module "${sourceType}" must export a default factory function` + ); + } + return module.default(sourcePath); +} diff --git a/src/core/piece/arpeggio/merge.ts b/src/core/piece/arpeggio/merge.ts new file mode 100644 index 00000000..6636b6e6 --- /dev/null +++ b/src/core/piece/arpeggio/merge.ts @@ -0,0 +1,78 @@ +/** + * Merge processing for arpeggio batch results. + * + * Supports two merge strategies: + * - 'concat': Simple concatenation with configurable separator + * - 'custom': User-provided merge function (inline JS or external file) + */ + +import { writeFileSync } from 'node:fs'; +import type { ArpeggioMergeMovementConfig, BatchResult, MergeFn } from './types.js'; + +/** Create a concat merge function with the given separator */ +function createConcatMerge(separator: string): MergeFn { + return (results) => + results + .filter((r) => r.success) + .sort((a, b) => a.batchIndex - b.batchIndex) + .map((r) => r.content) + .join(separator); +} + +/** + * Create a merge function from inline JavaScript. + * + * The inline JS receives `results` as the function parameter (readonly BatchResult[]). + * It must return a string. + */ +function createInlineJsMerge(jsBody: string): MergeFn { + const fn = new Function('results', jsBody) as MergeFn; + return (results) => { + const output = fn(results); + if (typeof output !== 'string') { + throw new Error(`Inline JS merge function must return a string, got ${typeof output}`); + } + return output; + }; +} + +/** + * Create a merge function from an external JS file. + * + * The file must export a default function: (results: BatchResult[]) => string + */ +async function createFileMerge(filePath: string): Promise { + const module = await import(filePath) as { default?: MergeFn }; + if (typeof module.default !== 'function') { + throw new Error(`Merge file "${filePath}" must export a default function`); + } + return module.default; +} + +/** + * Build a merge function from the arpeggio merge configuration. + * + * For 'concat' strategy: returns a simple join function. + * For 'custom' strategy: loads from inline JS or external file. + */ +export async function buildMergeFn(config: ArpeggioMergeMovementConfig): Promise { + if (config.strategy === 'concat') { + return createConcatMerge(config.separator ?? '\n'); + } + + // Custom strategy + if (config.inlineJs) { + return createInlineJsMerge(config.inlineJs); + } + + if (config.filePath) { + return createFileMerge(config.filePath); + } + + throw new Error('Custom merge strategy requires either inline_js or file path'); +} + +/** Write merged output to a file if output_path is configured */ +export function writeMergedOutput(outputPath: string, content: string): void { + writeFileSync(outputPath, content, 'utf-8'); +} diff --git a/src/core/piece/arpeggio/template.ts b/src/core/piece/arpeggio/template.ts new file mode 100644 index 00000000..7e407a2f --- /dev/null +++ b/src/core/piece/arpeggio/template.ts @@ -0,0 +1,72 @@ +/** + * Template expansion for arpeggio movements. + * + * Expands placeholders in prompt templates using data from batches: + * - {line:N} — entire row N as "key: value" pairs (1-based) + * - {col:N:name} — specific column value from row N (1-based) + * - {batch_index} — 0-based batch index + * - {total_batches} — total number of batches + */ + +import { readFileSync } from 'node:fs'; +import type { DataBatch, DataRow } from './types.js'; + +/** Format a single data row as "key: value" lines */ +function formatRow(row: DataRow): string { + return Object.entries(row) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); +} + +/** + * Expand placeholders in a template string using batch data. + * + * Supported placeholders: + * - {line:N} — Row N (1-based) formatted as "key: value" lines + * - {col:N:name} — Column "name" from row N (1-based) + * - {batch_index} — 0-based batch index + * - {total_batches} — Total number of batches + */ +export function expandTemplate(template: string, batch: DataBatch): string { + let result = template; + + // Replace {batch_index} and {total_batches} + result = result.replace(/\{batch_index\}/g, String(batch.batchIndex)); + result = result.replace(/\{total_batches\}/g, String(batch.totalBatches)); + + // Replace {col:N:name} — must be done before {line:N} to avoid partial matches + result = result.replace(/\{col:(\d+):(\w+)\}/g, (_match, indexStr: string, colName: string) => { + const rowIndex = parseInt(indexStr, 10) - 1; + if (rowIndex < 0 || rowIndex >= batch.rows.length) { + throw new Error( + `Template placeholder {col:${indexStr}:${colName}} references row ${indexStr} but batch has ${batch.rows.length} rows` + ); + } + const row = batch.rows[rowIndex]!; + const value = row[colName]; + if (value === undefined) { + throw new Error( + `Template placeholder {col:${indexStr}:${colName}} references unknown column "${colName}"` + ); + } + return value; + }); + + // Replace {line:N} + result = result.replace(/\{line:(\d+)\}/g, (_match, indexStr: string) => { + const rowIndex = parseInt(indexStr, 10) - 1; + if (rowIndex < 0 || rowIndex >= batch.rows.length) { + throw new Error( + `Template placeholder {line:${indexStr}} references row ${indexStr} but batch has ${batch.rows.length} rows` + ); + } + return formatRow(batch.rows[rowIndex]!); + }); + + return result; +} + +/** Load a template file and return its content */ +export function loadTemplate(templatePath: string): string { + return readFileSync(templatePath, 'utf-8'); +} diff --git a/src/core/piece/arpeggio/types.ts b/src/core/piece/arpeggio/types.ts new file mode 100644 index 00000000..63bb0006 --- /dev/null +++ b/src/core/piece/arpeggio/types.ts @@ -0,0 +1,46 @@ +/** + * Arpeggio movement internal type definitions. + * + * Configuration types (ArpeggioMovementConfig, ArpeggioMergeMovementConfig) + * live in models/piece-types.ts as part of PieceMovement. + * This file defines runtime types used internally by the arpeggio module. + */ + +export type { + ArpeggioMovementConfig, + ArpeggioMergeMovementConfig, +} from '../../models/piece-types.js'; + +/** A single row of data from a data source (column name → value) */ +export type DataRow = Record; + +/** A batch of rows read from a data source */ +export interface DataBatch { + /** The rows in this batch */ + readonly rows: readonly DataRow[]; + /** 0-based index of this batch in the overall data set */ + readonly batchIndex: number; + /** Total number of batches (known after full read) */ + readonly totalBatches: number; +} + +/** Interface for data source implementations */ +export interface ArpeggioDataSource { + /** Read all batches from the data source. Returns an array of DataBatch. */ + readBatches(batchSize: number): Promise; +} + +/** Result of a single LLM call for one batch */ +export interface BatchResult { + /** 0-based index of the batch */ + readonly batchIndex: number; + /** LLM response content */ + readonly content: string; + /** Whether this result was successful */ + readonly success: boolean; + /** Error message if failed */ + readonly error?: string; +} + +/** Merge function signature: takes all batch results, returns merged string */ +export type MergeFn = (results: readonly BatchResult[]) => string; diff --git a/src/core/piece/engine/ArpeggioRunner.ts b/src/core/piece/engine/ArpeggioRunner.ts new file mode 100644 index 00000000..2d9d0144 --- /dev/null +++ b/src/core/piece/engine/ArpeggioRunner.ts @@ -0,0 +1,268 @@ +/** + * Executes arpeggio piece movements: data-driven batch processing. + * + * Reads data from a source, expands templates with batch data, + * calls LLM for each batch (with concurrency control), + * merges results, and returns an aggregated response. + */ + +import type { + PieceMovement, + PieceState, + AgentResponse, +} from '../../models/types.js'; +import type { ArpeggioMovementConfig, BatchResult, DataBatch } from '../arpeggio/types.js'; +import { createDataSource } from '../arpeggio/data-source-factory.js'; +import { loadTemplate, expandTemplate } from '../arpeggio/template.js'; +import { buildMergeFn, writeMergedOutput } from '../arpeggio/merge.js'; +import { runAgent, type RunAgentOptions } from '../../../agents/runner.js'; +import { detectMatchedRule } from '../evaluation/index.js'; +import { incrementMovementIteration } from './state-manager.js'; +import { createLogger } from '../../../shared/utils/index.js'; +import type { OptionsBuilder } from './OptionsBuilder.js'; +import type { PhaseName } from '../types.js'; + +const log = createLogger('arpeggio-runner'); + +export interface ArpeggioRunnerDeps { + readonly optionsBuilder: OptionsBuilder; + readonly getCwd: () => string; + readonly getInteractive: () => boolean; + readonly detectRuleIndex: (content: string, movementName: string) => number; + readonly callAiJudge: ( + agentOutput: string, + conditions: Array<{ index: number; text: string }>, + options: { cwd: string } + ) => Promise; + readonly onPhaseStart?: (step: PieceMovement, phase: 1 | 2 | 3, phaseName: PhaseName, instruction: string) => void; + readonly onPhaseComplete?: (step: PieceMovement, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void; +} + +/** + * Simple semaphore for controlling concurrency. + * Limits the number of concurrent async operations. + */ +class Semaphore { + private running = 0; + private readonly waiting: Array<() => void> = []; + + constructor(private readonly maxConcurrency: number) {} + + async acquire(): Promise { + if (this.running < this.maxConcurrency) { + this.running++; + return; + } + return new Promise((resolve) => { + this.waiting.push(resolve); + }); + } + + release(): void { + if (this.waiting.length > 0) { + const next = this.waiting.shift()!; + next(); + } else { + this.running--; + } + } +} + +/** Execute a single batch with retry logic */ +async function executeBatchWithRetry( + batch: DataBatch, + template: string, + persona: string | undefined, + agentOptions: RunAgentOptions, + maxRetries: number, + retryDelayMs: number, +): Promise { + const prompt = expandTemplate(template, batch); + let lastError: string | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await runAgent(persona, prompt, agentOptions); + if (response.status === 'error') { + lastError = response.error ?? response.content ?? 'Agent returned error status'; + log.info('Batch execution failed, retrying', { + batchIndex: batch.batchIndex, + attempt: attempt + 1, + maxRetries, + error: lastError, + }); + if (attempt < maxRetries) { + await delay(retryDelayMs); + continue; + } + return { + batchIndex: batch.batchIndex, + content: '', + success: false, + error: lastError, + }; + } + return { + batchIndex: batch.batchIndex, + content: response.content, + success: true, + }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + log.info('Batch execution threw, retrying', { + batchIndex: batch.batchIndex, + attempt: attempt + 1, + maxRetries, + error: lastError, + }); + if (attempt < maxRetries) { + await delay(retryDelayMs); + continue; + } + } + } + + return { + batchIndex: batch.batchIndex, + content: '', + success: false, + error: lastError, + }; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class ArpeggioRunner { + constructor( + private readonly deps: ArpeggioRunnerDeps, + ) {} + + /** + * Run an arpeggio movement: read data, expand templates, call LLM, + * merge results, and return an aggregated response. + */ + async runArpeggioMovement( + step: PieceMovement, + state: PieceState, + ): Promise<{ response: AgentResponse; instruction: string }> { + const arpeggioConfig = step.arpeggio; + if (!arpeggioConfig) { + throw new Error(`Movement "${step.name}" has no arpeggio configuration`); + } + + const movementIteration = incrementMovementIteration(state, step.name); + log.debug('Running arpeggio movement', { + movement: step.name, + source: arpeggioConfig.source, + batchSize: arpeggioConfig.batchSize, + concurrency: arpeggioConfig.concurrency, + movementIteration, + }); + + const dataSource = await createDataSource(arpeggioConfig.source, arpeggioConfig.sourcePath); + const batches = await dataSource.readBatches(arpeggioConfig.batchSize); + + if (batches.length === 0) { + throw new Error(`Data source returned no batches for movement "${step.name}"`); + } + + log.info('Arpeggio data loaded', { + movement: step.name, + batchCount: batches.length, + batchSize: arpeggioConfig.batchSize, + }); + + const template = loadTemplate(arpeggioConfig.templatePath); + + const agentOptions = this.deps.optionsBuilder.buildAgentOptions(step); + const semaphore = new Semaphore(arpeggioConfig.concurrency); + const results = await this.executeBatches( + batches, + template, + step, + agentOptions, + arpeggioConfig, + semaphore, + ); + + const failedBatches = results.filter((r) => !r.success); + if (failedBatches.length > 0) { + const errorDetails = failedBatches + .map((r) => `batch ${r.batchIndex}: ${r.error}`) + .join('; '); + throw new Error( + `Arpeggio movement "${step.name}" failed: ${failedBatches.length}/${results.length} batches failed (${errorDetails})` + ); + } + + const mergeFn = await buildMergeFn(arpeggioConfig.merge); + const mergedContent = mergeFn(results); + + if (arpeggioConfig.outputPath) { + writeMergedOutput(arpeggioConfig.outputPath, mergedContent); + log.info('Arpeggio output written', { outputPath: arpeggioConfig.outputPath }); + } + + const ruleCtx = { + state, + cwd: this.deps.getCwd(), + interactive: this.deps.getInteractive(), + detectRuleIndex: this.deps.detectRuleIndex, + callAiJudge: this.deps.callAiJudge, + }; + const match = await detectMatchedRule(step, mergedContent, '', ruleCtx); + + const aggregatedResponse: AgentResponse = { + persona: step.name, + status: 'done', + content: mergedContent, + timestamp: new Date(), + ...(match && { matchedRuleIndex: match.index, matchedRuleMethod: match.method }), + }; + + state.movementOutputs.set(step.name, aggregatedResponse); + state.lastOutput = aggregatedResponse; + + const instruction = `[Arpeggio] ${step.name}: ${batches.length} batches, source=${arpeggioConfig.source}`; + + return { response: aggregatedResponse, instruction }; + } + + /** Execute all batches with concurrency control */ + private async executeBatches( + batches: readonly DataBatch[], + template: string, + step: PieceMovement, + agentOptions: RunAgentOptions, + config: ArpeggioMovementConfig, + semaphore: Semaphore, + ): Promise { + const promises = batches.map(async (batch) => { + await semaphore.acquire(); + try { + this.deps.onPhaseStart?.(step, 1, 'execute', `[Arpeggio batch ${batch.batchIndex + 1}/${batch.totalBatches}]`); + const result = await executeBatchWithRetry( + batch, + template, + step.persona, + agentOptions, + config.maxRetries, + config.retryDelayMs, + ); + this.deps.onPhaseComplete?.( + step, 1, 'execute', + result.content, + result.success ? 'done' : 'error', + result.error, + ); + return result; + } finally { + semaphore.release(); + } + }); + + return Promise.all(promises); + } +} diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index d7f5071f..ec86e8c1 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -31,6 +31,7 @@ import { generateReportDir, getErrorMessage, createLogger } from '../../../share import { OptionsBuilder } from './OptionsBuilder.js'; import { MovementExecutor } from './MovementExecutor.js'; import { ParallelRunner } from './ParallelRunner.js'; +import { ArpeggioRunner } from './ArpeggioRunner.js'; const log = createLogger('engine'); @@ -60,6 +61,7 @@ export class PieceEngine extends EventEmitter { private readonly optionsBuilder: OptionsBuilder; private readonly movementExecutor: MovementExecutor; private readonly parallelRunner: ParallelRunner; + private readonly arpeggioRunner: ArpeggioRunner; private readonly detectRuleIndex: (content: string, movementName: string) => number; private readonly callAiJudge: ( agentOutput: string, @@ -139,6 +141,20 @@ export class PieceEngine extends EventEmitter { }, }); + this.arpeggioRunner = new ArpeggioRunner({ + optionsBuilder: this.optionsBuilder, + getCwd: () => this.cwd, + getInteractive: () => this.options.interactive === true, + detectRuleIndex: this.detectRuleIndex, + callAiJudge: this.callAiJudge, + onPhaseStart: (step, phase, phaseName, instruction) => { + this.emit('phase:start', step, phase, phaseName, instruction); + }, + onPhaseComplete: (step, phase, phaseName, content, phaseStatus, error) => { + this.emit('phase:complete', step, phase, phaseName, content, phaseStatus, error); + }, + }); + log.debug('PieceEngine initialized', { piece: config.name, movements: config.movements.map(s => s.name), @@ -290,7 +306,7 @@ export class PieceEngine extends EventEmitter { } } - /** Run a single movement (delegates to ParallelRunner if movement has parallel sub-movements) */ + /** Run a single movement (delegates to ParallelRunner, ArpeggioRunner, or MovementExecutor) */ private async runMovement(step: PieceMovement, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> { const updateSession = this.updatePersonaSession.bind(this); let result: { response: AgentResponse; instruction: string }; @@ -299,6 +315,10 @@ export class PieceEngine extends EventEmitter { result = await this.parallelRunner.runParallelMovement( step, this.state, this.task, this.config.maxIterations, updateSession, ); + } else if (step.arpeggio) { + result = await this.arpeggioRunner.runArpeggioMovement( + step, this.state, + ); } else { result = await this.movementExecutor.runNormalMovement( step, this.state, this.task, this.config.maxIterations, updateSession, prebuiltInstruction, @@ -492,10 +512,11 @@ export class PieceEngine extends EventEmitter { this.state.iteration++; - // Build instruction before emitting movement:start so listeners can log it - const isParallel = movement.parallel && movement.parallel.length > 0; + // Build instruction before emitting movement:start so listeners can log it. + // Parallel and arpeggio movements handle iteration incrementing internally. + const isDelegated = (movement.parallel && movement.parallel.length > 0) || !!movement.arpeggio; let prebuiltInstruction: string | undefined; - if (!isParallel) { + if (!isDelegated) { const movementIteration = incrementMovementIteration(this.state, movement.name); prebuiltInstruction = this.movementExecutor.buildInstruction( movement, movementIteration, this.state, this.task, this.config.maxIterations, diff --git a/src/core/piece/engine/index.ts b/src/core/piece/engine/index.ts index f94c0b43..505be712 100644 --- a/src/core/piece/engine/index.ts +++ b/src/core/piece/engine/index.ts @@ -8,6 +8,7 @@ export { PieceEngine } from './PieceEngine.js'; export { MovementExecutor } from './MovementExecutor.js'; export type { MovementExecutorDeps } from './MovementExecutor.js'; export { ParallelRunner } from './ParallelRunner.js'; +export { ArpeggioRunner } from './ArpeggioRunner.js'; export { OptionsBuilder } from './OptionsBuilder.js'; export { CycleDetector } from './cycle-detector.js'; export type { CycleCheckResult } from './cycle-detector.js'; diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 87d5039b..800c8708 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -6,11 +6,11 @@ */ import { readFileSync, existsSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; import type { z } from 'zod'; import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js'; -import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractLabelPath, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge } from '../../../core/models/index.js'; +import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractLabelPath, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge, ArpeggioMovementConfig, ArpeggioMergeMovementConfig } from '../../../core/models/index.js'; import { getLanguage } from '../global/globalConfig.js'; import { type PieceSections, @@ -150,6 +150,35 @@ function normalizeRule(r: { }; } +/** Normalize raw arpeggio config from YAML into internal format. */ +function normalizeArpeggio( + raw: RawStep['arpeggio'], + pieceDir: string, +): ArpeggioMovementConfig | undefined { + if (!raw) return undefined; + + const merge: ArpeggioMergeMovementConfig = raw.merge + ? { + strategy: raw.merge.strategy, + inlineJs: raw.merge.inline_js, + filePath: raw.merge.file ? resolve(pieceDir, raw.merge.file) : undefined, + separator: raw.merge.separator, + } + : { strategy: 'concat' }; + + return { + source: raw.source, + sourcePath: resolve(pieceDir, raw.source_path), + batchSize: raw.batch_size, + concurrency: raw.concurrency, + templatePath: resolve(pieceDir, raw.template), + merge, + maxRetries: raw.max_retries, + retryDelayMs: raw.retry_delay_ms, + outputPath: raw.output_path ? resolve(pieceDir, raw.output_path) : undefined, + }; +} + /** Normalize a raw step into internal PieceMovement format. */ function normalizeStepFromRaw( step: RawStep, @@ -203,6 +232,11 @@ function normalizeStepFromRaw( result.parallel = step.parallel.map((sub: RawStep) => normalizeStepFromRaw(sub, pieceDir, sections, context)); } + const arpeggioConfig = normalizeArpeggio(step.arpeggio, pieceDir); + if (arpeggioConfig) { + result.arpeggio = arpeggioConfig; + } + return result; } From 6b207e0c74c6c72105b87c68f70e8cd518afa532 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:37:33 +0900 Subject: [PATCH 04/45] github-issue-201-completetask-completed-tasks-yaml (#202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: stable release時にnext dist-tagを自動同期 * takt: github-issue-201-completetask-completed-tasks-yaml --- src/__tests__/task.test.ts | 24 ++++++++++++++++++++++-- src/infra/task/runner.ts | 12 ++---------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/__tests__/task.test.ts b/src/__tests__/task.test.ts index d5dfe14f..5068f915 100644 --- a/src/__tests__/task.test.ts +++ b/src/__tests__/task.test.ts @@ -180,7 +180,7 @@ describe('TaskRunner (tasks.yaml)', () => { expect(() => runner.listTasks()).toThrow(/ENOENT|no such file/i); }); - it('should mark claimed task as completed', () => { + it('should remove completed task record from tasks.yaml', () => { runner.addTask('Task A'); const task = runner.claimNextTasks(1)[0]!; @@ -194,7 +194,27 @@ describe('TaskRunner (tasks.yaml)', () => { }); const file = loadTasksFile(testDir); - expect(file.tasks[0]?.status).toBe('completed'); + expect(file.tasks).toHaveLength(0); + }); + + it('should remove only the completed task when multiple tasks exist', () => { + runner.addTask('Task A'); + runner.addTask('Task B'); + const task = runner.claimNextTasks(1)[0]!; + + runner.completeTask({ + task, + success: true, + response: 'Done', + executionLog: [], + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + }); + + const file = loadTasksFile(testDir); + expect(file.tasks).toHaveLength(1); + expect(file.tasks[0]?.name).toContain('task-b'); + expect(file.tasks[0]?.status).toBe('pending'); }); it('should mark claimed task as failed with failure detail', () => { diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index 6c049964..4b10ea43 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -119,17 +119,9 @@ export class TaskRunner { throw new Error(`Task not found: ${result.task.name}`); } - const target = current.tasks[index]!; - const updated: TaskRecord = { - ...target, - status: 'completed', - completed_at: result.completedAt, - owner_pid: null, - failure: undefined, + return { + tasks: current.tasks.filter((_, i) => i !== index), }; - const tasks = [...current.tasks]; - tasks[index] = updated; - return { tasks }; }); return this.tasksFile; From 8cb3c87801fc81ad8127255b6a5c606ed96b112f Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:26:37 +0900 Subject: [PATCH 05/45] takt: github-issue-204-takt-tasks (#205) --- README.md | 61 +++++++++++-------- builtins/project/tasks/TASK-FORMAT | 61 +++++++++++-------- docs/README.ja.md | 61 +++++++++++-------- docs/testing/e2e.md | 10 +-- e2e/specs/add.e2e.ts | 8 ++- src/__tests__/addTask.test.ts | 14 ++++- src/__tests__/engine-worktree-report.test.ts | 43 +++++++++++++ src/__tests__/saveTaskFile.test.ts | 17 +++++- src/__tests__/task-schema.test.ts | 32 +++++++++- src/__tests__/task.test.ts | 43 +++++++++++-- src/__tests__/taskExecution.test.ts | 48 +++++++++++++++ src/app/cli/commands.ts | 2 +- src/core/piece/engine/PieceEngine.ts | 8 ++- src/core/piece/types.ts | 2 + src/features/tasks/add/index.ts | 26 +++++++- .../tasks/execute/parallelExecution.ts | 2 +- src/features/tasks/execute/pieceExecution.ts | 1 + src/features/tasks/execute/resolveTask.ts | 23 ++++++- src/features/tasks/execute/taskExecution.ts | 8 ++- src/features/tasks/execute/types.ts | 4 ++ src/features/tasks/list/index.ts | 2 +- src/infra/task/mapper.ts | 28 +++++++++ src/infra/task/runner.ts | 8 ++- src/infra/task/schema.ts | 25 ++++++-- src/infra/task/types.ts | 1 + src/shared/utils/index.ts | 1 + src/shared/utils/taskPaths.ts | 20 ++++++ 27 files changed, 453 insertions(+), 106 deletions(-) create mode 100644 src/shared/utils/taskPaths.ts diff --git a/README.md b/README.md index 2964cca8..b843c140 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ takt #6 --auto-pr ### Task Management (add / run / watch / list) -Batch processing using task files (`.takt/tasks/`). Useful for accumulating multiple tasks and executing them together later. +Batch processing using `.takt/tasks.yaml` with task directories under `.takt/tasks/{slug}/`. Useful for accumulating multiple tasks and executing them together later. #### Add Task (`takt add`) @@ -201,14 +201,14 @@ takt add #28 #### Execute Tasks (`takt run`) ```bash -# Execute all pending tasks in .takt/tasks/ +# Execute all pending tasks in .takt/tasks.yaml takt run ``` #### Watch Tasks (`takt watch`) ```bash -# Monitor .takt/tasks/ and auto-execute tasks (resident process) +# Monitor .takt/tasks.yaml and auto-execute tasks (resident process) takt watch ``` @@ -225,6 +225,13 @@ takt list --non-interactive --action delete --branch takt/my-branch --yes takt list --non-interactive --format json ``` +#### Task Directory Workflow (Create / Run / Verify) + +1. Run `takt add` and confirm a pending record is created in `.takt/tasks.yaml`. +2. Open the generated `.takt/tasks/{slug}/order.md` and add detailed specifications/references as needed. +3. Run `takt run` (or `takt watch`) to execute pending tasks from `tasks.yaml`. +4. Verify outputs in `.takt/reports/{slug}/` using the same slug as `task_dir`. + ### Pipeline Mode (for CI/Automation) Specifying `--pipeline` enables non-interactive pipeline mode. Automatically creates branch → runs piece → commits & pushes. Suitable for CI/CD automation. @@ -532,8 +539,8 @@ The model string is passed to the Codex SDK. If unspecified, defaults to `codex` .takt/ # Project-level configuration ├── config.yaml # Project config (current piece, etc.) -├── tasks/ # Pending task files (.yaml, .md) -├── completed/ # Completed tasks and reports +├── tasks/ # Task input directories (.takt/tasks/{slug}/order.md, etc.) +├── tasks.yaml # Pending tasks metadata (task_dir, piece, worktree, etc.) ├── reports/ # Execution reports (auto-generated) │ └── {timestamp}-{slug}/ └── logs/ # NDJSON format session logs @@ -625,33 +632,39 @@ Priority: Environment variables > `config.yaml` settings ## Detailed Guides -### Task File Formats +### Task Directory Format -TAKT supports batch processing with task files in `.takt/tasks/`. Both `.yaml`/`.yml` and `.md` file formats are supported. +TAKT stores task metadata in `.takt/tasks.yaml`, and each task's long specification in `.takt/tasks/{slug}/`. -**YAML format** (recommended, supports worktree/branch/piece options): +**Recommended layout**: -```yaml -# .takt/tasks/add-auth.yaml -task: "Add authentication feature" -worktree: true # Execute in isolated shared clone -branch: "feat/add-auth" # Branch name (auto-generated if omitted) -piece: "default" # Piece specification (uses current if omitted) +```text +.takt/ + tasks/ + 20260201-015714-foptng/ + order.md + schema.sql + wireframe.png + tasks.yaml + reports/ + 20260201-015714-foptng/ ``` -**Markdown format** (simple, backward compatible): - -```markdown -# .takt/tasks/add-login-feature.md +**tasks.yaml record**: -Add login feature to the application. - -Requirements: -- Username and password fields -- Form validation -- Error handling on failure +```yaml +tasks: + - name: add-auth-feature + status: pending + task_dir: .takt/tasks/20260201-015714-foptng + piece: default + created_at: "2026-02-01T01:57:14.000Z" + started_at: null + completed_at: null ``` +`takt add` creates `.takt/tasks/{slug}/order.md` automatically and saves `task_dir` to `tasks.yaml`. + #### Isolated Execution with Shared Clone Specifying `worktree` in YAML task files executes each task in an isolated clone created with `git clone --shared`, keeping your main working directory clean: diff --git a/builtins/project/tasks/TASK-FORMAT b/builtins/project/tasks/TASK-FORMAT index fcf87400..2a9650ac 100644 --- a/builtins/project/tasks/TASK-FORMAT +++ b/builtins/project/tasks/TASK-FORMAT @@ -1,37 +1,48 @@ -TAKT Task File Format -===================== +TAKT Task Directory Format +========================== -Tasks placed in this directory (.takt/tasks/) will be processed by TAKT. +`.takt/tasks/` is the task input directory. Each task uses one subdirectory. -## YAML Format (Recommended) +## Directory Layout (Recommended) - # .takt/tasks/my-task.yaml - task: "Task description" - worktree: true # (optional) true | "/path/to/dir" - branch: "feat/my-feature" # (optional) branch name - piece: "default" # (optional) piece name + .takt/ + tasks/ + 20260201-015714-foptng/ + order.md + schema.sql + wireframe.png -Fields: - task (required) Task description (string) - worktree (optional) true: create shared clone, "/path": clone at path - branch (optional) Branch name (auto-generated if omitted: takt/{timestamp}-{slug}) - piece (optional) Piece name (uses current piece if omitted) +- Directory name should match the report directory slug. +- `order.md` is required. +- Other files are optional reference materials. + +## tasks.yaml Format -## Markdown Format (Simple) +Store task metadata in `.takt/tasks.yaml`, and point to the task directory with `task_dir`. - # .takt/tasks/my-task.md + tasks: + - name: add-auth-feature + status: pending + task_dir: .takt/tasks/20260201-015714-foptng + piece: default + created_at: "2026-02-01T01:57:14.000Z" + started_at: null + completed_at: null - Entire file content becomes the task description. - Supports multiline. No structured options available. +Fields: + task_dir (recommended) Path to task directory that contains `order.md` + content (legacy) Inline task text (kept for compatibility) + content_file (legacy) Path to task text file (kept for compatibility) -## Supported Extensions +## Command Behavior - .yaml, .yml -> YAML format (parsed and validated) - .md -> Markdown format (plain text, backward compatible) +- `takt add` creates `.takt/tasks/{slug}/order.md` automatically. +- `takt run` and `takt watch` read `.takt/tasks.yaml` and resolve `task_dir`. +- Report output is written to `.takt/reports/{slug}/`. ## Commands - takt /add-task Add a task interactively - takt /run-tasks Run all pending tasks - takt /watch Watch and auto-run tasks - takt /list-tasks List task branches (merge/delete) + takt add Add a task and create task directory + takt run Run all pending tasks in tasks.yaml + takt watch Watch tasks.yaml and run pending tasks + takt list List task branches (merge/delete) diff --git a/docs/README.ja.md b/docs/README.ja.md index c9b689c4..38717bc6 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -186,7 +186,7 @@ takt #6 --auto-pr ### タスク管理(add / run / watch / list) -タスクファイル(`.takt/tasks/`)を使ったバッチ処理。複数のタスクを積んでおいて、後でまとめて実行する使い方に便利です。 +`.takt/tasks.yaml` と `.takt/tasks/{slug}/` を使ったバッチ処理。複数のタスクを積んでおいて、後でまとめて実行する使い方に便利です。 #### タスクを追加(`takt add`) @@ -201,14 +201,14 @@ takt add #28 #### タスクを実行(`takt run`) ```bash -# .takt/tasks/ の保留中タスクをすべて実行 +# .takt/tasks.yaml の保留中タスクをすべて実行 takt run ``` #### タスクを監視(`takt watch`) ```bash -# .takt/tasks/ を監視してタスクを自動実行(常駐プロセス) +# .takt/tasks.yaml を監視してタスクを自動実行(常駐プロセス) takt watch ``` @@ -225,6 +225,13 @@ takt list --non-interactive --action delete --branch takt/my-branch --yes takt list --non-interactive --format json ``` +#### タスクディレクトリ運用(作成・実行・確認) + +1. `takt add` を実行して `.takt/tasks.yaml` に pending レコードが作られることを確認する。 +2. 生成された `.takt/tasks/{slug}/order.md` を開き、必要なら仕様や参考資料を追記する。 +3. `takt run`(または `takt watch`)で `tasks.yaml` の pending タスクを実行する。 +4. `task_dir` と同じスラッグの `.takt/reports/{slug}/` を確認する。 + ### パイプラインモード(CI/自動化向け) `--pipeline` を指定すると非対話のパイプラインモードに入ります。ブランチ作成 → ピース実行 → commit & push を自動で行います。CI/CD での自動化に適しています。 @@ -532,8 +539,8 @@ Claude Code はエイリアス(`opus`、`sonnet`、`haiku`、`opusplan`、`def .takt/ # プロジェクトレベルの設定 ├── config.yaml # プロジェクト設定(現在のピース等) -├── tasks/ # 保留中のタスクファイル(.yaml, .md) -├── completed/ # 完了したタスクとレポート +├── tasks/ # タスク入力ディレクトリ(.takt/tasks/{slug}/order.md など) +├── tasks.yaml # 保留中タスクのメタデータ(task_dir, piece, worktree など) ├── reports/ # 実行レポート(自動生成) │ └── {timestamp}-{slug}/ └── logs/ # NDJSON 形式のセッションログ @@ -625,33 +632,39 @@ anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合 ## 詳細ガイド -### タスクファイルの形式 +### タスクディレクトリ形式 -TAKT は `.takt/tasks/` 内のタスクファイルによるバッチ処理をサポートしています。`.yaml`/`.yml` と `.md` の両方のファイル形式に対応しています。 +TAKT は `.takt/tasks.yaml` にタスクのメタデータを保存し、長文仕様は `.takt/tasks/{slug}/` に分離して管理します。 -**YAML形式**(推奨、worktree/branch/pieceオプション対応): +**推奨構成**: -```yaml -# .takt/tasks/add-auth.yaml -task: "認証機能を追加する" -worktree: true # 隔離された共有クローンで実行 -branch: "feat/add-auth" # ブランチ名(省略時は自動生成) -piece: "default" # ピース指定(省略時は現在のもの) +```text +.takt/ + tasks/ + 20260201-015714-foptng/ + order.md + schema.sql + wireframe.png + tasks.yaml + reports/ + 20260201-015714-foptng/ ``` -**Markdown形式**(シンプル、後方互換): - -```markdown -# .takt/tasks/add-login-feature.md +**tasks.yaml レコード例**: -アプリケーションにログイン機能を追加する。 - -要件: -- ユーザー名とパスワードフィールド -- フォームバリデーション -- 失敗時のエラーハンドリング +```yaml +tasks: + - name: add-auth-feature + status: pending + task_dir: .takt/tasks/20260201-015714-foptng + piece: default + created_at: "2026-02-01T01:57:14.000Z" + started_at: null + completed_at: null ``` +`takt add` は `.takt/tasks/{slug}/order.md` を自動生成し、`tasks.yaml` には `task_dir` を保存します。 + #### 共有クローンによる隔離実行 YAMLタスクファイルで`worktree`を指定すると、各タスクを`git clone --shared`で作成した隔離クローンで実行し、メインの作業ディレクトリをクリーンに保てます: diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index 331d1e23..1ebd72c3 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -26,13 +26,13 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 ## シナリオ一覧 - Add task and run(`e2e/specs/add-and-run.e2e.ts`) - - 目的: `.takt/tasks/` にタスクYAMLを配置し、`takt run` が実行できることを確認。 + - 目的: `.takt/tasks.yaml` に pending タスクを配置し、`takt run` が実行できることを確認。 - LLM: 条件付き(`TAKT_E2E_PROVIDER` が `claude` / `codex` の場合に呼び出す) - 手順(ユーザー行動/コマンド): - - `.takt/tasks/e2e-test-task.yaml` にタスクを作成(`piece` は `e2e/fixtures/pieces/simple.yaml` を指定)。 + - `.takt/tasks.yaml` にタスクを作成(`piece` は `e2e/fixtures/pieces/simple.yaml` を指定)。 - `takt run` を実行する。 - `README.md` に行が追加されることを確認する。 - - タスクファイルが `tasks/` から移動されることを確認する。 + - 実行後にタスクが `tasks.yaml` から消えることを確認する。 - Worktree/Clone isolation(`e2e/specs/worktree.e2e.ts`) - 目的: `--create-worktree yes` 指定で隔離環境に実行されることを確認。 - LLM: 条件付き(`TAKT_E2E_PROVIDER` が `claude` / `codex` の場合に呼び出す) @@ -83,13 +83,13 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - `gh issue create ...` でIssueを作成する。 - `TAKT_MOCK_SCENARIO=e2e/fixtures/scenarios/add-task.json` を設定する。 - `takt add '#'` を実行し、`Create worktree?` に `n` で回答する。 - - `.takt/tasks/` にYAMLが生成されることを確認する。 + - `.takt/tasks.yaml` に `task_dir` が保存され、`.takt/tasks/{slug}/order.md` が生成されることを確認する。 - Watch tasks(`e2e/specs/watch.e2e.ts`) - 目的: `takt watch` が監視中に追加されたタスクを実行できることを確認。 - LLM: 呼び出さない(`--provider mock` 固定) - 手順(ユーザー行動/コマンド): - `takt watch --provider mock` を起動する。 - - `.takt/tasks/` にタスクYAMLを追加する(`piece` に `e2e/fixtures/pieces/mock-single-step.yaml` を指定)。 + - `.takt/tasks.yaml` に pending タスクを追加する(`piece` に `e2e/fixtures/pieces/mock-single-step.yaml` を指定)。 - 出力に `Task "watch-task" completed` が含まれることを確認する。 - `Ctrl+C` で終了する。 - Run tasks graceful shutdown on SIGINT(`e2e/specs/run-sigint-graceful.e2e.ts`) diff --git a/e2e/specs/add.e2e.ts b/e2e/specs/add.e2e.ts index a4bb0316..f2f26f5c 100644 --- a/e2e/specs/add.e2e.ts +++ b/e2e/specs/add.e2e.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse as parseYaml } from 'yaml'; @@ -87,8 +87,12 @@ describe('E2E: Add task from GitHub issue (takt add)', () => { const tasksFile = join(testRepo.path, '.takt', 'tasks.yaml'); const content = readFileSync(tasksFile, 'utf-8'); - const parsed = parseYaml(content) as { tasks?: Array<{ issue?: number }> }; + const parsed = parseYaml(content) as { tasks?: Array<{ issue?: number; task_dir?: string }> }; expect(parsed.tasks?.length).toBe(1); expect(parsed.tasks?.[0]?.issue).toBe(Number(issueNumber)); + expect(parsed.tasks?.[0]?.task_dir).toBeTypeOf('string'); + const orderPath = join(testRepo.path, String(parsed.tasks?.[0]?.task_dir), 'order.md'); + expect(existsSync(orderPath)).toBe(true); + expect(readFileSync(orderPath, 'utf-8')).toContain('E2E Add Issue'); }, 240_000); }); diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 5bba9ba6..8b94428a 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -97,6 +97,10 @@ afterEach(() => { }); describe('addTask', () => { + function readOrderContent(dir: string, taskDir: unknown): string { + return fs.readFileSync(path.join(dir, String(taskDir), 'order.md'), 'utf-8'); + } + it('should create task entry from interactive result', async () => { mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' }); @@ -104,7 +108,9 @@ describe('addTask', () => { const tasks = loadTasks(testDir).tasks; expect(tasks).toHaveLength(1); - expect(tasks[0]?.content).toContain('JWT認証を実装する'); + expect(tasks[0]?.content).toBeUndefined(); + expect(tasks[0]?.task_dir).toBeTypeOf('string'); + expect(readOrderContent(testDir, tasks[0]?.task_dir)).toContain('JWT認証を実装する'); expect(tasks[0]?.piece).toBe('default'); }); @@ -128,7 +134,8 @@ describe('addTask', () => { expect(mockInteractiveMode).not.toHaveBeenCalled(); const task = loadTasks(testDir).tasks[0]!; - expect(task.content).toContain('Fix login timeout'); + expect(task.content).toBeUndefined(); + expect(readOrderContent(testDir, task.task_dir)).toContain('Fix login timeout'); expect(task.issue).toBe(99); }); @@ -153,7 +160,8 @@ describe('addTask', () => { const tasks = loadTasks(testDir).tasks; expect(tasks).toHaveLength(1); expect(tasks[0]?.issue).toBe(55); - expect(tasks[0]?.content).toContain('New feature'); + expect(tasks[0]?.content).toBeUndefined(); + expect(readOrderContent(testDir, tasks[0]?.task_dir)).toContain('New feature'); }); it('should not save task when issue creation fails in create_issue action', async () => { diff --git a/src/__tests__/engine-worktree-report.test.ts b/src/__tests__/engine-worktree-report.test.ts index 52b43781..92bb2633 100644 --- a/src/__tests__/engine-worktree-report.test.ts +++ b/src/__tests__/engine-worktree-report.test.ts @@ -198,4 +198,47 @@ describe('PieceEngine: worktree reportDir resolution', () => { const expectedPath = join(normalDir, '.takt/reports/test-report-dir'); expect(phaseCtx.reportDir).toBe(expectedPath); }); + + it('should use explicit reportDirName when provided', async () => { + const normalDir = projectCwd; + const config = buildSimpleConfig(); + const engine = new PieceEngine(config, normalDir, 'test task', { + projectCwd: normalDir, + reportDirName: '20260201-015714-foptng', + }); + + mockRunAgentSequence([ + makeResponse({ persona: 'review', content: 'Review done' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'tag' as const }, + ]); + + await engine.run(); + + const reportPhaseMock = vi.mocked(runReportPhase); + expect(reportPhaseMock).toHaveBeenCalled(); + const phaseCtx = reportPhaseMock.mock.calls[0][2] as { reportDir: string }; + expect(phaseCtx.reportDir).toBe(join(normalDir, '.takt/reports/20260201-015714-foptng')); + }); + + it('should reject invalid explicit reportDirName', () => { + const normalDir = projectCwd; + const config = buildSimpleConfig(); + + expect(() => new PieceEngine(config, normalDir, 'test task', { + projectCwd: normalDir, + reportDirName: '..', + })).toThrow('Invalid reportDirName: ..'); + + expect(() => new PieceEngine(config, normalDir, 'test task', { + projectCwd: normalDir, + reportDirName: '.', + })).toThrow('Invalid reportDirName: .'); + + expect(() => new PieceEngine(config, normalDir, 'test task', { + projectCwd: normalDir, + reportDirName: '', + })).toThrow('Invalid reportDirName: '); + }); }); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 0e179e3c..f4edfb10 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -42,10 +42,13 @@ function loadTasks(testDir: string): { tasks: Array> } { beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-02-10T04:40:00.000Z')); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-')); }); afterEach(() => { + vi.useRealTimers(); if (testDir && fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true }); } @@ -61,7 +64,11 @@ describe('saveTaskFile', () => { const tasks = loadTasks(testDir).tasks; expect(tasks).toHaveLength(1); - expect(tasks[0]?.content).toContain('Implement feature X'); + expect(tasks[0]?.content).toBeUndefined(); + expect(tasks[0]?.task_dir).toBeTypeOf('string'); + const taskDir = path.join(testDir, String(tasks[0]?.task_dir)); + expect(fs.existsSync(path.join(taskDir, 'order.md'))).toBe(true); + expect(fs.readFileSync(path.join(taskDir, 'order.md'), 'utf-8')).toContain('Implement feature X'); }); it('should include optional fields', async () => { @@ -79,6 +86,7 @@ describe('saveTaskFile', () => { expect(task.worktree).toBe(true); expect(task.branch).toBe('feat/my-branch'); expect(task.auto_pr).toBe(false); + expect(task.task_dir).toBeTypeOf('string'); }); it('should generate unique names on duplicates', async () => { @@ -86,6 +94,13 @@ describe('saveTaskFile', () => { const second = await saveTaskFile(testDir, 'Same title'); expect(first.taskName).not.toBe(second.taskName); + + const tasks = loadTasks(testDir).tasks; + expect(tasks).toHaveLength(2); + expect(tasks[0]?.task_dir).toBe('.takt/tasks/20260210-044000-same-title'); + expect(tasks[1]?.task_dir).toBe('.takt/tasks/20260210-044000-same-title-2'); + expect(fs.readFileSync(path.join(testDir, String(tasks[0]?.task_dir), 'order.md'), 'utf-8')).toContain('Same title'); + expect(fs.readFileSync(path.join(testDir, String(tasks[1]?.task_dir), 'order.md'), 'utf-8')).toContain('Same title'); }); }); diff --git a/src/__tests__/task-schema.test.ts b/src/__tests__/task-schema.test.ts index c0971f2c..dc2550cc 100644 --- a/src/__tests__/task-schema.test.ts +++ b/src/__tests__/task-schema.test.ts @@ -216,9 +216,39 @@ describe('TaskRecordSchema', () => { expect(() => TaskRecordSchema.parse(record)).not.toThrow(); }); - it('should reject record with neither content nor content_file', () => { + it('should accept record with task_dir', () => { + const record = { ...makePendingRecord(), content: undefined, task_dir: '.takt/tasks/20260201-000000-task' }; + expect(() => TaskRecordSchema.parse(record)).not.toThrow(); + }); + + it('should reject record with neither content, content_file, nor task_dir', () => { const record = { ...makePendingRecord(), content: undefined }; expect(() => TaskRecordSchema.parse(record)).toThrow(); }); + + it('should reject record with both content and task_dir', () => { + const record = { ...makePendingRecord(), task_dir: '.takt/tasks/20260201-000000-task' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject record with invalid task_dir format', () => { + const record = { ...makePendingRecord(), content: undefined, task_dir: '.takt/reports/invalid' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject record with parent-directory task_dir', () => { + const record = { ...makePendingRecord(), content: undefined, task_dir: '.takt/tasks/..' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject record with empty content', () => { + const record = { ...makePendingRecord(), content: '' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject record with empty content_file', () => { + const record = { ...makePendingRecord(), content: undefined, content_file: '' }; + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); }); }); diff --git a/src/__tests__/task.test.ts b/src/__tests__/task.test.ts index 5068f915..d7153905 100644 --- a/src/__tests__/task.test.ts +++ b/src/__tests__/task.test.ts @@ -161,14 +161,49 @@ describe('TaskRunner (tasks.yaml)', () => { expect(tasks[0]?.content).toBe('Absolute task content'); }); - it('should prefer inline content over content_file', () => { + it('should build task instruction from task_dir and expose taskDir on TaskInfo', () => { + mkdirSync(join(testDir, '.takt', 'tasks', '20260201-000000-demo'), { recursive: true }); + writeFileSync( + join(testDir, '.takt', 'tasks', '20260201-000000-demo', 'order.md'), + 'Detailed long spec', + 'utf-8', + ); writeTasksFile(testDir, [createPendingRecord({ - content: 'Inline content', - content_file: 'missing-content-file.txt', + content: undefined, + task_dir: '.takt/tasks/20260201-000000-demo', })]); const tasks = runner.listTasks(); - expect(tasks[0]?.content).toBe('Inline content'); + expect(tasks[0]?.taskDir).toBe('.takt/tasks/20260201-000000-demo'); + expect(tasks[0]?.content).toContain('Implement using only the files'); + expect(tasks[0]?.content).toContain('.takt/tasks/20260201-000000-demo'); + expect(tasks[0]?.content).toContain('.takt/tasks/20260201-000000-demo/order.md'); + }); + + it('should throw when task_dir order.md is missing', () => { + mkdirSync(join(testDir, '.takt', 'tasks', '20260201-000000-missing'), { recursive: true }); + writeTasksFile(testDir, [createPendingRecord({ + content: undefined, + task_dir: '.takt/tasks/20260201-000000-missing', + })]); + + expect(() => runner.listTasks()).toThrow(/Task spec file is missing/i); + }); + + it('should reset tasks file when both content and content_file are set', () => { + writeTasksFile(testDir, [{ + name: 'task-a', + status: 'pending', + content: 'Inline content', + content_file: 'missing-content-file.txt', + created_at: '2026-02-09T00:00:00.000Z', + started_at: null, + completed_at: null, + owner_pid: null, + }]); + + expect(runner.listTasks()).toEqual([]); + expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(false); }); it('should throw when content_file target is missing', () => { diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 5d5eb249..68a08b19 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -511,4 +511,52 @@ describe('resolveTaskExecution', () => { expect(mockSummarizeTaskName).not.toHaveBeenCalled(); expect(mockCreateSharedClone).not.toHaveBeenCalled(); }); + + it('should return reportDirName from taskDir basename', async () => { + const task: TaskInfo = { + name: 'task-with-dir', + content: 'Task content', + taskDir: '.takt/tasks/20260201-015714-foptng', + filePath: '/tasks/task.yaml', + data: { + task: 'Task content', + }, + }; + + const result = await resolveTaskExecution(task, '/project', 'default'); + + expect(result.reportDirName).toBe('20260201-015714-foptng'); + }); + + it('should throw when taskDir format is invalid', async () => { + const task: TaskInfo = { + name: 'task-with-invalid-dir', + content: 'Task content', + taskDir: '.takt/reports/20260201-015714-foptng', + filePath: '/tasks/task.yaml', + data: { + task: 'Task content', + }, + }; + + await expect(resolveTaskExecution(task, '/project', 'default')).rejects.toThrow( + 'Invalid task_dir format: .takt/reports/20260201-015714-foptng', + ); + }); + + it('should throw when taskDir contains parent directory segment', async () => { + const task: TaskInfo = { + name: 'task-with-parent-dir', + content: 'Task content', + taskDir: '.takt/tasks/..', + filePath: '/tasks/task.yaml', + data: { + task: 'Task content', + }, + }; + + await expect(resolveTaskExecution(task, '/project', 'default')).rejects.toThrow( + 'Invalid task_dir format: .takt/tasks/..', + ); + }); }); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 0b6ad5d0..215e1b81 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -15,7 +15,7 @@ import { resolveAgentOverrides } from './helpers.js'; program .command('run') - .description('Run all pending tasks from .takt/tasks/') + .description('Run all pending tasks from .takt/tasks.yaml') .action(async () => { const piece = getCurrentPiece(resolvedCwd); await runAllTasks(resolvedCwd, piece, resolveAgentOverrides(program)); diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index ec86e8c1..648c6722 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -27,7 +27,7 @@ import { addUserInput as addUserInputToState, incrementMovementIteration, } from './state-manager.js'; -import { generateReportDir, getErrorMessage, createLogger } from '../../../shared/utils/index.js'; +import { generateReportDir, getErrorMessage, createLogger, isValidReportDirName } from '../../../shared/utils/index.js'; import { OptionsBuilder } from './OptionsBuilder.js'; import { MovementExecutor } from './MovementExecutor.js'; import { ParallelRunner } from './ParallelRunner.js'; @@ -79,7 +79,11 @@ export class PieceEngine extends EventEmitter { this.options = options; this.loopDetector = new LoopDetector(config.loopDetection); this.cycleDetector = new CycleDetector(config.loopMonitors ?? []); - this.reportDir = `.takt/reports/${generateReportDir(task)}`; + if (options.reportDirName !== undefined && !isValidReportDirName(options.reportDirName)) { + throw new Error(`Invalid reportDirName: ${options.reportDirName}`); + } + const reportDirName = options.reportDirName ?? generateReportDir(task); + this.reportDir = `.takt/reports/${reportDirName}`; this.ensureReportDirExists(); this.validateConfig(); this.state = createInitialState(config, options); diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index 82b681c7..f24edd26 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -190,6 +190,8 @@ export interface PieceEngineOptions { startMovement?: string; /** Retry note explaining why task is being retried */ retryNote?: string; + /** Override report directory name (without parent path). */ + reportDirName?: string; /** Task name prefix for parallel task execution output */ taskPrefix?: string; /** Color index for task prefix (cycled across tasks) */ diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 6d3ef39f..1b80800c 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -6,17 +6,30 @@ */ import * as path from 'node:path'; +import * as fs from 'node:fs'; import { promptInput, confirm } from '../../../shared/prompt/index.js'; import { success, info, error } from '../../../shared/ui/index.js'; import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js'; import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.js'; import { determinePiece } from '../execute/selectAndExecute.js'; -import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js'; import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js'; import { interactiveMode } from '../../interactive/index.js'; const log = createLogger('add-task'); +function resolveUniqueTaskSlug(cwd: string, baseSlug: string): string { + let sequence = 1; + let slug = baseSlug; + let taskDir = path.join(cwd, '.takt', 'tasks', slug); + while (fs.existsSync(taskDir)) { + sequence += 1; + slug = `${baseSlug}-${sequence}`; + taskDir = path.join(cwd, '.takt', 'tasks', slug); + } + return slug; +} + /** * Save a task entry to .takt/tasks.yaml. * @@ -29,6 +42,12 @@ export async function saveTaskFile( options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean }, ): Promise<{ taskName: string; tasksFile: string }> { const runner = new TaskRunner(cwd); + const taskSlug = resolveUniqueTaskSlug(cwd, generateReportDir(taskContent)); + const taskDir = path.join(cwd, '.takt', 'tasks', taskSlug); + const taskDirRelative = `.takt/tasks/${taskSlug}`; + const orderPath = path.join(taskDir, 'order.md'); + fs.mkdirSync(taskDir, { recursive: true }); + fs.writeFileSync(orderPath, taskContent, 'utf-8'); const config: Omit = { ...(options?.worktree !== undefined && { worktree: options.worktree }), ...(options?.branch && { branch: options.branch }), @@ -36,7 +55,10 @@ export async function saveTaskFile( ...(options?.issue !== undefined && { issue: options.issue }), ...(options?.autoPr !== undefined && { auto_pr: options.autoPr }), }; - const created = runner.addTask(taskContent, config); + const created = runner.addTask(taskContent, { + ...config, + task_dir: taskDirRelative, + }); const tasksFile = path.join(cwd, '.takt', 'tasks.yaml'); log.info('Task created', { taskName: created.name, tasksFile, config }); return { taskName: created.name, tasksFile }; diff --git a/src/features/tasks/execute/parallelExecution.ts b/src/features/tasks/execute/parallelExecution.ts index ee65819c..2967c568 100644 --- a/src/features/tasks/execute/parallelExecution.ts +++ b/src/features/tasks/execute/parallelExecution.ts @@ -7,7 +7,7 @@ * (concurrency>1) execution through the same code path. * * Polls for newly added tasks at a configurable interval so that tasks - * added to .takt/tasks/ during execution are picked up without waiting + * added to .takt/tasks.yaml during execution are picked up without waiting * for an active task to complete. */ diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 99ece9fa..6f966a94 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -347,6 +347,7 @@ export async function executePiece( callAiJudge, startMovement: options.startMovement, retryNote: options.retryNote, + reportDirName: options.reportDirName, taskPrefix: options.taskPrefix, taskColorIndex: options.taskColorIndex, }); diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 8a74c934..632b0b74 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -5,11 +5,13 @@ import { loadGlobalConfig } from '../../../infra/config/index.js'; import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; import { info } from '../../../shared/ui/index.js'; +import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js'; export interface ResolvedTaskExecution { execCwd: string; execPiece: string; isWorktree: boolean; + reportDirName?: string; branch?: string; baseBranch?: string; startMovement?: string; @@ -44,8 +46,16 @@ export async function resolveTaskExecution( let execCwd = defaultCwd; let isWorktree = false; + let reportDirName: string | undefined; let branch: string | undefined; let baseBranch: string | undefined; + if (task.taskDir) { + const taskSlug = getTaskSlugFromTaskDir(task.taskDir); + if (!taskSlug) { + throw new Error(`Invalid task_dir format: ${task.taskDir}`); + } + reportDirName = taskSlug; + } if (data.worktree) { throwIfAborted(abortSignal); @@ -80,5 +90,16 @@ export async function resolveTaskExecution( autoPr = globalConfig.autoPr; } - return { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber: data.issue }; + return { + execCwd, + execPiece, + isWorktree, + ...(reportDirName ? { reportDirName } : {}), + ...(branch ? { branch } : {}), + ...(baseBranch ? { baseBranch } : {}), + ...(startMovement ? { startMovement } : {}), + ...(retryNote ? { retryNote } : {}), + ...(autoPr !== undefined ? { autoPr } : {}), + ...(data.issue !== undefined ? { issueNumber: data.issue } : {}), + }; } diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 876652cf..b0da2aeb 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -49,7 +49,7 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType { - const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, abortSignal, taskPrefix, taskColorIndex } = options; + const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, reportDirName, abortSignal, taskPrefix, taskColorIndex } = options; const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd); if (!pieceConfig) { @@ -80,6 +80,7 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise1) execution through the same code path. diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index 5a8d946c..37896cd4 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -42,6 +42,8 @@ export interface PieceExecutionOptions { startMovement?: string; /** Retry note explaining why task is being retried */ retryNote?: string; + /** Override report directory name (e.g. "20260201-015714-foptng") */ + reportDirName?: string; /** External abort signal for parallel execution — when provided, SIGINT handling is delegated to caller */ abortSignal?: AbortSignal; /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ @@ -74,6 +76,8 @@ export interface ExecuteTaskOptions { startMovement?: string; /** Retry note explaining why task is being retried */ retryNote?: string; + /** Override report directory name (e.g. "20260201-015714-foptng") */ + reportDirName?: string; /** External abort signal for parallel execution — when provided, SIGINT handling is delegated to caller */ abortSignal?: AbortSignal; /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 15756458..4d267320 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -2,7 +2,7 @@ * List tasks command — main entry point. * * Interactive UI for reviewing branch-based task results, - * pending tasks (.takt/tasks/), and failed tasks (.takt/failed/). + * pending tasks (.takt/tasks.yaml), and failed tasks. * Individual actions (merge, delete, instruct, diff) are in taskActions.ts. * Task delete actions are in taskDeleteActions.ts. * Non-interactive mode is in listNonInteractive.ts. diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts index ded75cf6..87762b60 100644 --- a/src/infra/task/mapper.ts +++ b/src/infra/task/mapper.ts @@ -7,10 +7,37 @@ function firstLine(content: string): string { return content.trim().split('\n')[0]?.slice(0, 80) ?? ''; } +function toDisplayPath(projectDir: string, targetPath: string): string { + const relativePath = path.relative(projectDir, targetPath); + if (!relativePath || relativePath.startsWith('..')) { + return targetPath; + } + return relativePath; +} + +function buildTaskDirInstruction(projectDir: string, taskDirPath: string, orderFilePath: string): string { + const displayTaskDir = toDisplayPath(projectDir, taskDirPath); + const displayOrderFile = toDisplayPath(projectDir, orderFilePath); + return [ + `Implement using only the files in \`${displayTaskDir}\`.`, + `Primary spec: \`${displayOrderFile}\`.`, + 'Use report files in Report Directory as primary execution history.', + 'Do not rely on previous response or conversation summary.', + ].join('\n'); +} + export function resolveTaskContent(projectDir: string, task: TaskRecord): string { if (task.content) { return task.content; } + if (task.task_dir) { + const taskDirPath = path.join(projectDir, task.task_dir); + const orderFilePath = path.join(taskDirPath, 'order.md'); + if (!fs.existsSync(orderFilePath)) { + throw new Error(`Task spec file is missing: ${orderFilePath}`); + } + return buildTaskDirInstruction(projectDir, taskDirPath, orderFilePath); + } if (!task.content_file) { throw new Error(`Task content is missing: ${task.name}`); } @@ -40,6 +67,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco filePath: tasksFile, name: task.name, content, + taskDir: task.task_dir, createdAt: task.created_at, status: task.status, data: TaskFileSchema.parse({ diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index 4b10ea43..e7e9957d 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -29,13 +29,17 @@ export class TaskRunner { return this.tasksFile; } - addTask(content: string, options?: Omit): TaskInfo { + addTask( + content: string, + options?: Omit & { content_file?: string; task_dir?: string }, + ): TaskInfo { const state = this.store.update((current) => { const name = this.generateTaskName(content, current.tasks.map((task) => task.name)); + const contentValue = options?.task_dir ? undefined : content; const record: TaskRecord = TaskRecordSchema.parse({ name, status: 'pending', - content, + content: contentValue, created_at: nowIso(), started_at: null, completed_at: null, diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index a884f0f8..f84df168 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -3,6 +3,7 @@ */ import { z } from 'zod/v4'; +import { isValidTaskDir } from '../../shared/utils/taskPaths.js'; /** * Per-task execution config schema. @@ -40,19 +41,35 @@ export type TaskFailure = z.infer; export const TaskRecordSchema = TaskExecutionConfigSchema.extend({ name: z.string().min(1), status: TaskStatusSchema, - content: z.string().optional(), - content_file: z.string().optional(), + content: z.string().min(1).optional(), + content_file: z.string().min(1).optional(), + task_dir: z.string().optional(), created_at: z.string().min(1), started_at: z.string().nullable(), completed_at: z.string().nullable(), owner_pid: z.number().int().positive().nullable().optional(), failure: TaskFailureSchema.optional(), }).superRefine((value, ctx) => { - if (!value.content && !value.content_file) { + const sourceFields = [value.content, value.content_file, value.task_dir].filter((field) => field !== undefined); + if (sourceFields.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['content'], - message: 'Either content or content_file is required.', + message: 'Either content, content_file, or task_dir is required.', + }); + } + if (sourceFields.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['content'], + message: 'Exactly one of content, content_file, or task_dir must be set.', + }); + } + if (value.task_dir !== undefined && !isValidTaskDir(value.task_dir)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['task_dir'], + message: 'task_dir must match .takt/tasks/ format.', }); } diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index 2968a7cf..303ccf60 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -10,6 +10,7 @@ export interface TaskInfo { filePath: string; name: string; content: string; + taskDir?: string; createdAt: string; status: TaskStatus; data: TaskFileData | null; diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 1eb7ab44..24859d54 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -8,6 +8,7 @@ export * from './notification.js'; export * from './reportDir.js'; export * from './sleep.js'; export * from './slug.js'; +export * from './taskPaths.js'; export * from './text.js'; export * from './types.js'; export * from './updateNotifier.js'; diff --git a/src/shared/utils/taskPaths.ts b/src/shared/utils/taskPaths.ts new file mode 100644 index 00000000..b12d5826 --- /dev/null +++ b/src/shared/utils/taskPaths.ts @@ -0,0 +1,20 @@ +const TASK_SLUG_PATTERN = + '[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf](?:[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf-]*[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf])?'; +const TASK_DIR_PREFIX = '.takt/tasks/'; +const TASK_DIR_PATTERN = new RegExp(`^\\.takt/tasks/${TASK_SLUG_PATTERN}$`); +const REPORT_DIR_NAME_PATTERN = new RegExp(`^${TASK_SLUG_PATTERN}$`); + +export function isValidTaskDir(taskDir: string): boolean { + return TASK_DIR_PATTERN.test(taskDir); +} + +export function getTaskSlugFromTaskDir(taskDir: string): string | undefined { + if (!isValidTaskDir(taskDir)) { + return undefined; + } + return taskDir.slice(TASK_DIR_PREFIX.length); +} + +export function isValidReportDirName(reportDirName: string): boolean { + return REPORT_DIR_NAME_PATTERN.test(reportDirName); +} From 38e69564be38ee6ea620de346372740e6baf202d Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:25:38 +0900 Subject: [PATCH 06/45] =?UTF-8?q?feat:=20frontend=E7=89=B9=E5=8C=96?= =?UTF-8?q?=E3=83=94=E3=83=BC=E3=82=B9=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97?= =?UTF-8?q?=E4=B8=A6=E5=88=97arch-review=E3=82=92=E5=B0=8E=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- builtins/en/pieces/frontend.yaml | 286 +++++++++++++++++++++++++++++++ builtins/ja/pieces/frontend.yaml | 286 +++++++++++++++++++++++++++++++ 2 files changed, 572 insertions(+) create mode 100644 builtins/en/pieces/frontend.yaml create mode 100644 builtins/ja/pieces/frontend.yaml diff --git a/builtins/en/pieces/frontend.yaml b/builtins/en/pieces/frontend.yaml new file mode 100644 index 00000000..ff319d3f --- /dev/null +++ b/builtins/en/pieces/frontend.yaml @@ -0,0 +1,286 @@ +name: frontend +description: Frontend, Security, QA Expert Review +max_iterations: 30 +initial_movement: plan +movements: + - name: plan + edit: false + persona: planner + allowed_tools: + - Read + - Glob + - Grep + - Bash + - WebSearch + - WebFetch + instruction: plan + rules: + - condition: Task analysis and planning is complete + next: implement + - condition: Requirements are unclear and planning cannot proceed + next: ABORT + output_contracts: + report: + - name: 00-plan.md + format: plan + - name: implement + edit: true + persona: coder + pass_previous_response: false + policy: + - coding + - testing + session: refresh + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + instruction: implement + rules: + - condition: Implementation is complete + next: ai_review + - condition: No implementation (report only) + next: ai_review + - condition: Cannot proceed with implementation + next: ai_review + - condition: User input required + next: implement + requires_user_input: true + interactive_only: true + output_contracts: + report: + - Scope: 01-coder-scope.md + - Decisions: 02-coder-decisions.md + - name: ai_review + edit: false + persona: ai-antipattern-reviewer + policy: + - review + - ai-antipattern + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + instruction: ai-review + rules: + - condition: No AI-specific issues found + next: reviewers + - condition: AI-specific issues detected + next: ai_fix + output_contracts: + report: + - name: 03-ai-review.md + format: ai-review + - name: ai_fix + edit: true + persona: coder + pass_previous_response: false + policy: + - coding + - testing + session: refresh + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + instruction: ai-fix + rules: + - condition: AI Reviewer's issues have been fixed + next: ai_review + - condition: No fix needed (verified target files/spec) + next: ai_no_fix + - condition: Unable to proceed with fixes + next: ai_no_fix + - name: ai_no_fix + edit: false + persona: architecture-reviewer + policy: review + allowed_tools: + - Read + - Glob + - Grep + rules: + - condition: ai_review's findings are valid (fix required) + next: ai_fix + - condition: ai_fix's judgment is valid (no fix needed) + next: reviewers + instruction: arbitrate + - name: reviewers + parallel: + - name: arch-review + edit: false + persona: architecture-reviewer + policy: review + knowledge: + - architecture + - frontend + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-arch + output_contracts: + report: + - name: 04-architect-review.md + format: architecture-review + - name: frontend-review + edit: false + persona: frontend-reviewer + policy: review + knowledge: frontend + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-frontend + output_contracts: + report: + - name: 05-frontend-review.md + format: frontend-review + - name: security-review + edit: false + persona: security-reviewer + policy: review + knowledge: security + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-security + output_contracts: + report: + - name: 06-security-review.md + format: security-review + - name: qa-review + edit: false + persona: qa-reviewer + policy: + - review + - qa + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-qa + output_contracts: + report: + - name: 07-qa-review.md + format: qa-review + rules: + - condition: all("approved") + next: supervise + - condition: any("needs_fix") + next: fix + - name: fix + edit: true + persona: coder + pass_previous_response: false + policy: + - coding + - testing + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + permission_mode: edit + rules: + - condition: Fix complete + next: reviewers + - condition: Cannot proceed, insufficient info + next: plan + instruction: fix + - name: supervise + edit: false + persona: expert-supervisor + policy: review + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + instruction: supervise + rules: + - condition: All validations pass and ready to merge + next: COMPLETE + - condition: Issues detected during final review + next: fix_supervisor + output_contracts: + report: + - Validation: 08-supervisor-validation.md + - Summary: summary.md + - name: fix_supervisor + edit: true + persona: coder + pass_previous_response: false + policy: + - coding + - testing + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + instruction: fix-supervisor + rules: + - condition: Supervisor's issues have been fixed + next: supervise + - condition: Unable to proceed with fixes + next: plan diff --git a/builtins/ja/pieces/frontend.yaml b/builtins/ja/pieces/frontend.yaml new file mode 100644 index 00000000..92af0d6a --- /dev/null +++ b/builtins/ja/pieces/frontend.yaml @@ -0,0 +1,286 @@ +name: frontend +description: フロントエンド・セキュリティ・QA専門家レビュー +max_iterations: 30 +initial_movement: plan +movements: + - name: plan + edit: false + persona: planner + allowed_tools: + - Read + - Glob + - Grep + - Bash + - WebSearch + - WebFetch + instruction: plan + rules: + - condition: タスク分析と計画が完了した + next: implement + - condition: 要件が不明確で計画を立てられない + next: ABORT + output_contracts: + report: + - name: 00-plan.md + format: plan + - name: implement + edit: true + persona: coder + pass_previous_response: false + policy: + - coding + - testing + session: refresh + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + instruction: implement + rules: + - condition: 実装が完了した + next: ai_review + - condition: 実装未着手(レポートのみ) + next: ai_review + - condition: 実装を進行できない + next: ai_review + - condition: ユーザー入力が必要 + next: implement + requires_user_input: true + interactive_only: true + output_contracts: + report: + - Scope: 01-coder-scope.md + - Decisions: 02-coder-decisions.md + - name: ai_review + edit: false + persona: ai-antipattern-reviewer + policy: + - review + - ai-antipattern + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + instruction: ai-review + rules: + - condition: AI特有の問題が見つからない + next: reviewers + - condition: AI特有の問題が検出された + next: ai_fix + output_contracts: + report: + - name: 03-ai-review.md + format: ai-review + - name: ai_fix + edit: true + persona: coder + pass_previous_response: false + policy: + - coding + - testing + session: refresh + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + instruction: ai-fix + rules: + - condition: AI Reviewerの指摘に対する修正が完了した + next: ai_review + - condition: 修正不要(指摘対象ファイル/仕様の確認済み) + next: ai_no_fix + - condition: 修正を進行できない + next: ai_no_fix + - name: ai_no_fix + edit: false + persona: architecture-reviewer + policy: review + allowed_tools: + - Read + - Glob + - Grep + rules: + - condition: ai_reviewの指摘が妥当(修正すべき) + next: ai_fix + - condition: ai_fixの判断が妥当(修正不要) + next: reviewers + instruction: arbitrate + - name: reviewers + parallel: + - name: arch-review + edit: false + persona: architecture-reviewer + policy: review + knowledge: + - architecture + - frontend + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-arch + output_contracts: + report: + - name: 04-architect-review.md + format: architecture-review + - name: frontend-review + edit: false + persona: frontend-reviewer + policy: review + knowledge: frontend + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-frontend + output_contracts: + report: + - name: 05-frontend-review.md + format: frontend-review + - name: security-review + edit: false + persona: security-reviewer + policy: review + knowledge: security + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-security + output_contracts: + report: + - name: 06-security-review.md + format: security-review + - name: qa-review + edit: false + persona: qa-reviewer + policy: + - review + - qa + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + rules: + - condition: approved + - condition: needs_fix + instruction: review-qa + output_contracts: + report: + - name: 07-qa-review.md + format: qa-review + rules: + - condition: all("approved") + next: supervise + - condition: any("needs_fix") + next: fix + - name: fix + edit: true + persona: coder + pass_previous_response: false + policy: + - coding + - testing + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + permission_mode: edit + rules: + - condition: 修正が完了した + next: reviewers + - condition: 修正を進行できない + next: plan + instruction: fix + - name: supervise + edit: false + persona: expert-supervisor + policy: review + allowed_tools: + - Read + - Glob + - Grep + - WebSearch + - WebFetch + instruction: supervise + rules: + - condition: すべての検証が完了し、マージ可能な状態である + next: COMPLETE + - condition: 問題が検出された + next: fix_supervisor + output_contracts: + report: + - Validation: 08-supervisor-validation.md + - Summary: summary.md + - name: fix_supervisor + edit: true + persona: coder + pass_previous_response: false + policy: + - coding + - testing + knowledge: + - frontend + - security + - architecture + allowed_tools: + - Read + - Glob + - Grep + - Edit + - Write + - Bash + - WebSearch + - WebFetch + instruction: fix-supervisor + rules: + - condition: 監督者の指摘に対する修正が完了した + next: supervise + - condition: 修正を進行できない + next: plan From e6ccebfe1830046472862321b6e284474a993e77 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:31:07 +0900 Subject: [PATCH 07/45] =?UTF-8?q?chore:=20piece=E3=82=AB=E3=83=86=E3=82=B4?= =?UTF-8?q?=E3=83=AA=E3=81=AEja/en=E4=B8=A6=E3=81=B3=E3=81=A8=E8=A1=A8?= =?UTF-8?q?=E8=A8=98=E3=82=92=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- builtins/en/piece-categories.yaml | 22 ++++++++++++---------- builtins/ja/piece-categories.yaml | 22 ++++++++++++---------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/builtins/en/piece-categories.yaml b/builtins/en/piece-categories.yaml index 69719ce6..27db8db3 100644 --- a/builtins/en/piece-categories.yaml +++ b/builtins/en/piece-categories.yaml @@ -6,6 +6,18 @@ piece_categories: - coding - minimal - compound-eye + 🎨 Frontend: + pieces: + - frontend + ⚙️ Backend: {} + 🔧 Expert: + Full Stack: + pieces: + - expert + - expert-cqrs + 🛠️ Refactoring: + pieces: + - structural-reform 🔍 Review: pieces: - review-fix-minimal @@ -14,16 +26,6 @@ piece_categories: pieces: - unit-test - e2e-test - 🎨 Frontend: {} - ⚙️ Backend: {} - 🔧 Expert: - Full Stack: - pieces: - - expert - - expert-cqrs - Refactoring: - pieces: - - structural-reform Others: pieces: - research diff --git a/builtins/ja/piece-categories.yaml b/builtins/ja/piece-categories.yaml index 41ca3e2a..5858f9b6 100644 --- a/builtins/ja/piece-categories.yaml +++ b/builtins/ja/piece-categories.yaml @@ -6,6 +6,18 @@ piece_categories: - coding - minimal - compound-eye + 🎨 フロントエンド: + pieces: + - frontend + ⚙️ バックエンド: {} + 🔧 エキスパート: + フルスタック: + pieces: + - expert + - expert-cqrs + 🛠️ リファクタリング: + pieces: + - structural-reform 🔍 レビュー: pieces: - review-fix-minimal @@ -14,16 +26,6 @@ piece_categories: pieces: - unit-test - e2e-test - 🎨 フロントエンド: {} - ⚙️ バックエンド: {} - 🔧 エキスパート: - フルスタック: - pieces: - - expert - - expert-cqrs - リファクタリング: - pieces: - - structural-reform その他: pieces: - research From 9c4408909d1610c78d81ee6688cbbbbdb2812707 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:33:38 +0900 Subject: [PATCH 08/45] takt: github-issue-207-previous-response-source-path (#210) --- README.md | 28 +-- builtins/ja/INSTRUCTION_STYLE_GUIDE.md | 4 +- builtins/ja/PERSONA_STYLE_GUIDE.md | 2 +- builtins/ja/POLICY_STYLE_GUIDE.md | 2 +- builtins/project/dotgitignore | 2 +- builtins/project/tasks/TASK-FORMAT | 2 +- builtins/skill/SKILL.md | 10 +- builtins/skill/references/engine.md | 26 ++- docs/README.ja.md | 28 +-- docs/data-flow.md | 5 +- docs/pieces.md | 2 +- src/__tests__/debug.test.ts | 9 +- src/__tests__/engine-arpeggio.test.ts | 9 +- src/__tests__/engine-parallel.test.ts | 43 +++- src/__tests__/engine-report.test.ts | 6 +- src/__tests__/engine-test-helpers.ts | 25 ++- src/__tests__/engine-worktree-report.test.ts | 72 +++++-- src/__tests__/escape.test.ts | 17 ++ src/__tests__/instructionBuilder.test.ts | 166 +++++++++++++--- src/__tests__/it-instruction-builder.test.ts | 8 +- src/__tests__/it-notification-sound.test.ts | 35 ++-- src/__tests__/it-pipeline-modes.test.ts | 1 - src/__tests__/it-pipeline.test.ts | 5 +- src/__tests__/it-sigint-interrupt.test.ts | 37 ++-- src/__tests__/it-stage-and-commit.test.ts | 14 +- .../pieceExecution-debug-prompts.test.ts | 80 +++++++- src/__tests__/run-paths.test.ts | 19 ++ src/__tests__/session.test.ts | 154 +++------------ src/core/models/piece-types.ts | 2 + src/core/piece/engine/ArpeggioRunner.ts | 8 + src/core/piece/engine/MovementExecutor.ts | 96 ++++++++- src/core/piece/engine/ParallelRunner.ts | 6 + src/core/piece/engine/PieceEngine.ts | 30 ++- src/core/piece/engine/state-manager.ts | 1 + .../piece/instruction/InstructionBuilder.ts | 91 ++++++++- src/core/piece/instruction/escape.ts | 7 +- .../piece/instruction/instruction-context.ts | 8 + src/core/piece/run/run-paths.ts | 52 +++++ src/features/prompt/preview.ts | 4 +- src/features/tasks/execute/pieceExecution.ts | 185 ++++++++++++------ src/infra/fs/index.ts | 2 - src/infra/fs/session.ts | 59 +----- .../prompts/en/perform_phase1_message.md | 2 + .../prompts/ja/perform_phase1_message.md | 2 + src/shared/utils/debug.ts | 3 +- src/shared/utils/types.ts | 14 -- 46 files changed, 962 insertions(+), 421 deletions(-) create mode 100644 src/__tests__/run-paths.test.ts create mode 100644 src/core/piece/run/run-paths.ts diff --git a/README.md b/README.md index b843c140..71160a73 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ takt list --non-interactive --format json 1. Run `takt add` and confirm a pending record is created in `.takt/tasks.yaml`. 2. Open the generated `.takt/tasks/{slug}/order.md` and add detailed specifications/references as needed. 3. Run `takt run` (or `takt watch`) to execute pending tasks from `tasks.yaml`. -4. Verify outputs in `.takt/reports/{slug}/` using the same slug as `task_dir`. +4. Verify outputs in `.takt/runs/{slug}/reports/` using the same slug as `task_dir`. ### Pipeline Mode (for CI/Automation) @@ -541,12 +541,12 @@ The model string is passed to the Codex SDK. If unspecified, defaults to `codex` ├── config.yaml # Project config (current piece, etc.) ├── tasks/ # Task input directories (.takt/tasks/{slug}/order.md, etc.) ├── tasks.yaml # Pending tasks metadata (task_dir, piece, worktree, etc.) -├── reports/ # Execution reports (auto-generated) -│ └── {timestamp}-{slug}/ -└── logs/ # NDJSON format session logs - ├── latest.json # Pointer to current/latest session - ├── previous.json # Pointer to previous session - └── {sessionId}.jsonl # NDJSON session log per piece execution +└── runs/ # Run-scoped artifacts + └── {slug}/ + ├── reports/ # Execution reports (auto-generated) + ├── context/ # knowledge/policy/previous_response snapshots + ├── logs/ # NDJSON session logs for this run + └── meta.json # Run metadata ``` Builtin resources are embedded in the npm package (`builtins/`). User files in `~/.takt/` take priority. @@ -646,8 +646,9 @@ TAKT stores task metadata in `.takt/tasks.yaml`, and each task's long specificat schema.sql wireframe.png tasks.yaml - reports/ + runs/ 20260201-015714-foptng/ + reports/ ``` **tasks.yaml record**: @@ -680,15 +681,14 @@ Clones are ephemeral. After task completion, they auto-commit + push, then delet ### Session Logs -TAKT writes session logs in NDJSON (`.jsonl`) format to `.takt/logs/`. Each record is atomically appended, so partial logs are preserved even if the process crashes, and you can track in real-time with `tail -f`. +TAKT writes session logs in NDJSON (`.jsonl`) format to `.takt/runs/{slug}/logs/`. Each record is atomically appended, so partial logs are preserved even if the process crashes, and you can track in real-time with `tail -f`. -- `.takt/logs/latest.json` - Pointer to current (or latest) session -- `.takt/logs/previous.json` - Pointer to previous session -- `.takt/logs/{sessionId}.jsonl` - NDJSON session log per piece execution +- `.takt/runs/{slug}/logs/{sessionId}.jsonl` - NDJSON session log per piece execution +- `.takt/runs/{slug}/meta.json` - Run metadata (`task`, `piece`, `start/end`, `status`, etc.) Record types: `piece_start`, `step_start`, `step_complete`, `piece_complete`, `piece_abort` -Agents can read `previous.json` to inherit context from the previous execution. Session continuation is automatic — just run `takt "task"` to continue from the previous session. +The latest previous response is stored at `.takt/runs/{slug}/context/previous_responses/latest.md` and inherited automatically. ### Adding Custom Pieces @@ -757,7 +757,7 @@ Variables available in `instruction_template`: | `{movement_iteration}` | Per-movement iteration count (times this movement has been executed) | | `{previous_response}` | Output from previous movement (auto-injected if not in template) | | `{user_inputs}` | Additional user inputs during piece (auto-injected if not in template) | -| `{report_dir}` | Report directory path (e.g., `.takt/reports/20250126-143052-task-summary`) | +| `{report_dir}` | Report directory path (e.g., `.takt/runs/20250126-143052-task-summary/reports`) | | `{report:filename}` | Expands to `{report_dir}/filename` (e.g., `{report:00-plan.md}`) | ### Piece Design diff --git a/builtins/ja/INSTRUCTION_STYLE_GUIDE.md b/builtins/ja/INSTRUCTION_STYLE_GUIDE.md index 35a3eada..d0cd662b 100644 --- a/builtins/ja/INSTRUCTION_STYLE_GUIDE.md +++ b/builtins/ja/INSTRUCTION_STYLE_GUIDE.md @@ -84,7 +84,7 @@ InstructionBuilder が instruction_template 内の `{変数名}` を展開する | `{iteration}` | ピース全体のイテレーション数 | | `{max_iterations}` | 最大イテレーション数 | | `{movement_iteration}` | ムーブメント単位のイテレーション数 | -| `{report_dir}` | レポートディレクトリ名 | +| `{report_dir}` | レポートディレクトリ名(`.takt/runs/{slug}/reports`) | | `{report:filename}` | 指定レポートの内容展開(ファイルが存在する場合) | | `{cycle_count}` | ループモニターで検出されたサイクル回数(`loop_monitors` 専用) | @@ -222,7 +222,7 @@ InstructionBuilder が instruction_template 内の `{変数名}` を展開する # 非許容 **参照するレポート:** -- .takt/reports/20250101-task/ai-review.md ← パスのハードコード +- .takt/runs/20250101-task/reports/ai-review.md ← パスのハードコード ``` --- diff --git a/builtins/ja/PERSONA_STYLE_GUIDE.md b/builtins/ja/PERSONA_STYLE_GUIDE.md index fd06f785..f61f3553 100644 --- a/builtins/ja/PERSONA_STYLE_GUIDE.md +++ b/builtins/ja/PERSONA_STYLE_GUIDE.md @@ -157,7 +157,7 @@ 1. **ポリシーの詳細ルール**: コード例・判定基準・例外リスト等の詳細はポリシーの責務(1行の行動指針は行動姿勢に記載してよい) 2. **ピース固有の概念**: ムーブメント名、レポートファイル名、ステップ間ルーティング -3. **ツール固有の環境情報**: `.takt/reports/` 等のディレクトリパス、テンプレート変数(`{report_dir}` 等) +3. **ツール固有の環境情報**: `.takt/runs/` 等のディレクトリパス、テンプレート変数(`{report_dir}` 等) 4. **実行手順**: 「まず〜を読み、次に〜を実行」のような手順はinstruction_templateの責務 ### 例外: ドメイン知識としての重複 diff --git a/builtins/ja/POLICY_STYLE_GUIDE.md b/builtins/ja/POLICY_STYLE_GUIDE.md index 3468483f..655b8c80 100644 --- a/builtins/ja/POLICY_STYLE_GUIDE.md +++ b/builtins/ja/POLICY_STYLE_GUIDE.md @@ -100,7 +100,7 @@ 1. **特定エージェント固有の知識**: Architecture Reviewer だけが使う検出手法等 2. **ピース固有の概念**: ムーブメント名、レポートファイル名 -3. **ツール固有のパス**: `.takt/reports/` 等の具体的なディレクトリパス +3. **ツール固有のパス**: `.takt/runs/` 等の具体的なディレクトリパス 4. **実行手順**: どのファイルを読め、何を実行しろ等 --- diff --git a/builtins/project/dotgitignore b/builtins/project/dotgitignore index 41d2f612..71aba952 100644 --- a/builtins/project/dotgitignore +++ b/builtins/project/dotgitignore @@ -1,6 +1,6 @@ # Temporary files logs/ -reports/ +runs/ completed/ tasks/ worktrees/ diff --git a/builtins/project/tasks/TASK-FORMAT b/builtins/project/tasks/TASK-FORMAT index 2a9650ac..f4638087 100644 --- a/builtins/project/tasks/TASK-FORMAT +++ b/builtins/project/tasks/TASK-FORMAT @@ -38,7 +38,7 @@ Fields: - `takt add` creates `.takt/tasks/{slug}/order.md` automatically. - `takt run` and `takt watch` read `.takt/tasks.yaml` and resolve `task_dir`. -- Report output is written to `.takt/reports/{slug}/`. +- Report output is written to `.takt/runs/{slug}/reports/`. ## Commands diff --git a/builtins/skill/SKILL.md b/builtins/skill/SKILL.md index 28460f18..e89477b4 100644 --- a/builtins/skill/SKILL.md +++ b/builtins/skill/SKILL.md @@ -116,7 +116,15 @@ TeamCreate tool を呼ぶ: - `permission_mode = コマンドで解析された権限モード("bypassPermissions" / "acceptEdits" / "default")` - `movement_history = []`(遷移履歴。Loop Monitor 用) -**レポートディレクトリ**: いずれかの movement に `report` フィールドがある場合、`.takt/reports/{YYYYMMDD-HHmmss}-{slug}/` を作成し、パスを `report_dir` 変数に保持する。 +**実行ディレクトリ**: いずれかの movement に `report` フィールドがある場合、`.takt/runs/{YYYYMMDD-HHmmss}-{slug}/` を作成し、以下を配置する。 +- `reports/`(レポート出力) +- `context/knowledge/`(Knowledge スナップショット) +- `context/policy/`(Policy スナップショット) +- `context/previous_responses/`(Previous Response 履歴 + `latest.md`) +- `logs/`(実行ログ) +- `meta.json`(run メタデータ) + +レポート出力先パスを `report_dir` 変数(`.takt/runs/{slug}/reports`)として保持する。 次に **手順 5** に進む。 diff --git a/builtins/skill/references/engine.md b/builtins/skill/references/engine.md index ef8e3f5e..df58ab0c 100644 --- a/builtins/skill/references/engine.md +++ b/builtins/skill/references/engine.md @@ -148,7 +148,7 @@ movement の `instruction:` キーから指示テンプレートファイルを | `{iteration}` | ピース全体のイテレーション数(1始まり) | | `{max_iterations}` | ピースの max_iterations 値 | | `{movement_iteration}` | この movement が実行された回数(1始まり) | -| `{report_dir}` | レポートディレクトリパス | +| `{report_dir}` | レポートディレクトリパス(`.takt/runs/{slug}/reports`) | | `{report:ファイル名}` | 指定レポートファイルの内容(Read で取得) | ### {report:ファイル名} の処理 @@ -212,7 +212,10 @@ report: チームメイトの出力からレポート内容を抽出し、Write tool でレポートディレクトリに保存する。 **この作業は Team Lead(あなた)が行う。** チームメイトの出力を受け取った後に実施する。 -**レポートディレクトリ**: `.takt/reports/{timestamp}-{slug}/` に作成する。 +**実行ディレクトリ**: `.takt/runs/{timestamp}-{slug}/` に作成する。 +- レポートは `.takt/runs/{timestamp}-{slug}/reports/` に保存する。 +- `Knowledge` / `Policy` / `Previous Response` は `.takt/runs/{timestamp}-{slug}/context/` 配下に保存する。 +- 最新の previous response は `.takt/runs/{timestamp}-{slug}/context/previous_responses/latest.md` とする。 - `{timestamp}`: `YYYYMMDD-HHmmss` 形式 - `{slug}`: タスク内容の先頭30文字をスラグ化 @@ -358,17 +361,24 @@ loop_monitors: d. judge の出力を judge の `rules` で評価する e. マッチした rule の `next` に遷移する(通常のルール評価をオーバーライドする) -## レポート管理 +## 実行アーティファクト管理 -### レポートディレクトリの作成 +### 実行ディレクトリの作成 -ピース実行開始時にレポートディレクトリを作成する: +ピース実行開始時に実行ディレクトリを作成する: ``` -.takt/reports/{YYYYMMDD-HHmmss}-{slug}/ +.takt/runs/{YYYYMMDD-HHmmss}-{slug}/ + reports/ + context/ + knowledge/ + policy/ + previous_responses/ + logs/ + meta.json ``` -このパスを `{report_dir}` 変数として全 movement から参照可能にする。 +このうち `reports/` のパスを `{report_dir}` 変数として全 movement から参照可能にする。 ### レポートの保存 @@ -392,7 +402,7 @@ loop_monitors: ↓ TeamCreate でチーム作成 ↓ -レポートディレクトリ作成 +実行ディレクトリ作成 ↓ initial_movement を取得 ↓ diff --git a/docs/README.ja.md b/docs/README.ja.md index 38717bc6..6877a16e 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -230,7 +230,7 @@ takt list --non-interactive --format json 1. `takt add` を実行して `.takt/tasks.yaml` に pending レコードが作られることを確認する。 2. 生成された `.takt/tasks/{slug}/order.md` を開き、必要なら仕様や参考資料を追記する。 3. `takt run`(または `takt watch`)で `tasks.yaml` の pending タスクを実行する。 -4. `task_dir` と同じスラッグの `.takt/reports/{slug}/` を確認する。 +4. `task_dir` と同じスラッグの `.takt/runs/{slug}/reports/` を確認する。 ### パイプラインモード(CI/自動化向け) @@ -541,12 +541,12 @@ Claude Code はエイリアス(`opus`、`sonnet`、`haiku`、`opusplan`、`def ├── config.yaml # プロジェクト設定(現在のピース等) ├── tasks/ # タスク入力ディレクトリ(.takt/tasks/{slug}/order.md など) ├── tasks.yaml # 保留中タスクのメタデータ(task_dir, piece, worktree など) -├── reports/ # 実行レポート(自動生成) -│ └── {timestamp}-{slug}/ -└── logs/ # NDJSON 形式のセッションログ - ├── latest.json # 現在/最新セッションへのポインタ - ├── previous.json # 前回セッションへのポインタ - └── {sessionId}.jsonl # ピース実行ごとの NDJSON セッションログ +└── runs/ # 実行単位の成果物 + └── {slug}/ + ├── reports/ # 実行レポート(自動生成) + ├── context/ # knowledge/policy/previous_response のスナップショット + ├── logs/ # この実行専用の NDJSON セッションログ + └── meta.json # run メタデータ ``` ビルトインリソースはnpmパッケージ(`builtins/`)に埋め込まれています。`~/.takt/` のユーザーファイルが優先されます。 @@ -646,8 +646,9 @@ TAKT は `.takt/tasks.yaml` にタスクのメタデータを保存し、長文 schema.sql wireframe.png tasks.yaml - reports/ + runs/ 20260201-015714-foptng/ + reports/ ``` **tasks.yaml レコード例**: @@ -680,15 +681,14 @@ YAMLタスクファイルで`worktree`を指定すると、各タスクを`git c ### セッションログ -TAKTはセッションログをNDJSON(`.jsonl`)形式で`.takt/logs/`に書き込みます。各レコードはアトミックに追記されるため、プロセスが途中でクラッシュしても部分的なログが保持され、`tail -f`でリアルタイムに追跡できます。 +TAKTはセッションログをNDJSON(`.jsonl`)形式で`.takt/runs/{slug}/logs/`に書き込みます。各レコードはアトミックに追記されるため、プロセスが途中でクラッシュしても部分的なログが保持され、`tail -f`でリアルタイムに追跡できます。 -- `.takt/logs/latest.json` - 現在(または最新の)セッションへのポインタ -- `.takt/logs/previous.json` - 前回セッションへのポインタ -- `.takt/logs/{sessionId}.jsonl` - ピース実行ごとのNDJSONセッションログ +- `.takt/runs/{slug}/logs/{sessionId}.jsonl` - ピース実行ごとのNDJSONセッションログ +- `.takt/runs/{slug}/meta.json` - run メタデータ(`task`, `piece`, `start/end`, `status` など) レコード種別: `piece_start`, `step_start`, `step_complete`, `piece_complete`, `piece_abort` -エージェントは`previous.json`を読み取って前回の実行コンテキストを引き継ぐことができます。セッション継続は自動的に行われます — `takt "タスク"`を実行するだけで前回のセッションから続行されます。 +最新の previous response は `.takt/runs/{slug}/context/previous_responses/latest.md` に保存され、実行時に自動的に引き継がれます。 ### カスタムピースの追加 @@ -757,7 +757,7 @@ personas: | `{movement_iteration}` | ムーブメントごとのイテレーション数(このムーブメントが実行された回数) | | `{previous_response}` | 前のムーブメントの出力(テンプレートになければ自動注入) | | `{user_inputs}` | ピース中の追加ユーザー入力(テンプレートになければ自動注入) | -| `{report_dir}` | レポートディレクトリパス(例: `.takt/reports/20250126-143052-task-summary`) | +| `{report_dir}` | レポートディレクトリパス(例: `.takt/runs/20250126-143052-task-summary/reports`) | | `{report:filename}` | `{report_dir}/filename` に展開(例: `{report:00-plan.md}`) | ### ピースの設計 diff --git a/docs/data-flow.md b/docs/data-flow.md index 9ed61c87..e67e7160 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -431,7 +431,7 @@ TAKTのデータフローは以下の7つの主要なレイヤーで構成され 2. **ログ初期化**: - `createSessionLog()`: セッションログオブジェクト作成 - `initNdjsonLog()`: NDJSON形式のログファイル初期化 - - `updateLatestPointer()`: `latest.json` ポインタ更新 + - `meta.json` 更新: 実行ステータス(running/completed/aborted)と時刻を保存 3. **PieceEngine初期化**: ```typescript @@ -619,6 +619,7 @@ const match = await detectMatchedRule(step, response.content, tagContent, {...}) - Step Iteration (per-step) - Step name - Report Directory/File info + - Run Source Paths (`.takt/runs/{slug}/context/...`) 3. **User Request** (タスク本文): - `{task}` プレースホルダーがテンプレートにない場合のみ自動注入 @@ -626,6 +627,8 @@ const match = await detectMatchedRule(step, response.content, tagContent, {...}) 4. **Previous Response** (前ステップの出力): - `step.passPreviousResponse === true` かつ - `{previous_response}` プレースホルダーがテンプレートにない場合のみ自動注入 + - 長さ制御(2000 chars)と `...TRUNCATED...` を適用 + - Source Path を常時注入 5. **Additional User Inputs** (blocked時の追加入力): - `{user_inputs}` プレースホルダーがテンプレートにない場合のみ自動注入 diff --git a/docs/pieces.md b/docs/pieces.md index ea193e64..6c28c4dc 100644 --- a/docs/pieces.md +++ b/docs/pieces.md @@ -59,7 +59,7 @@ steps: | `{step_iteration}` | Per-step iteration count (how many times THIS step has run) | | `{previous_response}` | Previous step's output (auto-injected if not in template) | | `{user_inputs}` | Additional user inputs during piece (auto-injected if not in template) | -| `{report_dir}` | Report directory path (e.g., `.takt/reports/20250126-143052-task-summary`) | +| `{report_dir}` | Report directory path (e.g., `.takt/runs/20250126-143052-task-summary/reports`) | | `{report:filename}` | Resolves to `{report_dir}/filename` (e.g., `{report:00-plan.md}`) | > **Note**: `{task}`, `{previous_response}`, and `{user_inputs}` are auto-injected into instructions. You only need explicit placeholders if you want to control their position in the template. diff --git a/src/__tests__/debug.test.ts b/src/__tests__/debug.test.ts index 05aea164..7e80bdf0 100644 --- a/src/__tests__/debug.test.ts +++ b/src/__tests__/debug.test.ts @@ -63,7 +63,7 @@ describe('debug logging', () => { } }); - it('should write debug log to project .takt/logs/ directory', () => { + it('should write debug log to project .takt/runs/*/logs/ directory', () => { const projectDir = join(tmpdir(), 'takt-test-debug-project-' + Date.now()); mkdirSync(projectDir, { recursive: true }); @@ -71,7 +71,9 @@ describe('debug logging', () => { initDebugLogger({ enabled: true }, projectDir); const logFile = getDebugLogFile(); expect(logFile).not.toBeNull(); - expect(logFile!).toContain(join(projectDir, '.takt', 'logs')); + expect(logFile!).toContain(join(projectDir, '.takt', 'runs')); + expect(logFile!).toContain(`${join(projectDir, '.takt', 'runs')}/`); + expect(logFile!).toContain('/logs/'); expect(logFile!).toMatch(/debug-.*\.log$/); expect(existsSync(logFile!)).toBe(true); } finally { @@ -86,7 +88,8 @@ describe('debug logging', () => { try { initDebugLogger({ enabled: true }, projectDir); const promptsLogFile = resolvePromptsLogFilePath(); - expect(promptsLogFile).toContain(join(projectDir, '.takt', 'logs')); + expect(promptsLogFile).toContain(join(projectDir, '.takt', 'runs')); + expect(promptsLogFile).toContain('/logs/'); expect(promptsLogFile).toMatch(/debug-.*-prompts\.jsonl$/); expect(existsSync(promptsLogFile)).toBe(true); } finally { diff --git a/src/__tests__/engine-arpeggio.test.ts b/src/__tests__/engine-arpeggio.test.ts index 6b5618c2..501790d5 100644 --- a/src/__tests__/engine-arpeggio.test.ts +++ b/src/__tests__/engine-arpeggio.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { writeFileSync, mkdirSync } from 'node:fs'; +import { writeFileSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; // Mock external dependencies before importing @@ -94,6 +94,7 @@ function buildArpeggioPieceConfig(arpeggioConfig: ArpeggioMovementConfig, tmpDir function createEngineOptions(tmpDir: string): PieceEngineOptions { return { projectCwd: tmpDir, + reportDirName: 'test-report-dir', detectRuleIndex: () => 0, callAiJudge: async () => 0, }; @@ -142,6 +143,12 @@ describe('ArpeggioRunner integration', () => { const output = state.movementOutputs.get('process'); expect(output).toBeDefined(); expect(output!.content).toBe('Processed Alice\nProcessed Bob\nProcessed Charlie'); + + const previousDir = join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'previous_responses'); + const previousFiles = readdirSync(previousDir); + expect(state.previousResponseSourcePath).toMatch(/^\.takt\/runs\/test-report-dir\/context\/previous_responses\/process\.1\.\d{8}T\d{6}Z\.md$/); + expect(previousFiles).toContain('latest.md'); + expect(readFileSync(join(previousDir, 'latest.md'), 'utf-8')).toBe('Processed Alice\nProcessed Bob\nProcessed Charlie'); }); it('should handle batch_size > 1', async () => { diff --git a/src/__tests__/engine-parallel.test.ts b/src/__tests__/engine-parallel.test.ts index 11b88cff..bb5cf774 100644 --- a/src/__tests__/engine-parallel.test.ts +++ b/src/__tests__/engine-parallel.test.ts @@ -8,7 +8,8 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { existsSync, rmSync } from 'node:fs'; +import { existsSync, rmSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; // --- Mock setup (must be before imports that use these modules) --- @@ -128,6 +129,46 @@ describe('PieceEngine Integration: Parallel Movement Aggregation', () => { expect(state.movementOutputs.get('security-review')!.content).toBe('Sec content'); }); + it('should persist aggregated previous_response snapshot for parallel parent movement', async () => { + const config = buildDefaultPieceConfig(); + const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan' }), + makeResponse({ persona: 'implement', content: 'Impl' }), + makeResponse({ persona: 'ai_review', content: 'OK' }), + makeResponse({ persona: 'arch-review', content: 'Arch content' }), + makeResponse({ persona: 'security-review', content: 'Sec content' }), + makeResponse({ persona: 'supervise', content: 'Pass' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'aggregate' }, + { index: 0, method: 'phase1_tag' }, + ]); + + const state = await engine.run(); + const reviewersOutput = state.movementOutputs.get('reviewers')!.content; + const previousDir = join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'previous_responses'); + const previousFiles = readdirSync(previousDir); + + expect(state.previousResponseSourcePath).toMatch(/^\.takt\/runs\/test-report-dir\/context\/previous_responses\/supervise\.1\.\d{8}T\d{6}Z\.md$/); + expect(previousFiles).toContain('latest.md'); + expect(previousFiles.some((name) => /^reviewers\.1\.\d{8}T\d{6}Z\.md$/.test(name))).toBe(true); + expect(readFileSync(join(previousDir, 'latest.md'), 'utf-8')).toBe('Pass'); + expect( + previousFiles.some((name) => { + if (!/^reviewers\.1\.\d{8}T\d{6}Z\.md$/.test(name)) return false; + return readFileSync(join(previousDir, name), 'utf-8') === reviewersOutput; + }) + ).toBe(true); + }); + it('should execute sub-movements concurrently (both runAgent calls happen)', async () => { const config = buildDefaultPieceConfig(); const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); diff --git a/src/__tests__/engine-report.test.ts b/src/__tests__/engine-report.test.ts index 46339999..5b813443 100644 --- a/src/__tests__/engine-report.test.ts +++ b/src/__tests__/engine-report.test.ts @@ -15,7 +15,7 @@ import type { PieceMovement, OutputContractItem, OutputContractLabelPath, Output * Extracted emitMovementReports logic for unit testing. * Mirrors engine.ts emitMovementReports + emitIfReportExists. * - * reportDir already includes the `.takt/reports/` prefix (set by engine constructor). + * reportDir already includes the `.takt/runs/{slug}/reports` path (set by engine constructor). */ function emitMovementReports( emitter: EventEmitter, @@ -59,8 +59,8 @@ function createMovement(overrides: Partial = {}): PieceMovement { describe('emitMovementReports', () => { let tmpDir: string; let reportBaseDir: string; - // reportDir now includes .takt/reports/ prefix (matches engine constructor behavior) - const reportDirName = '.takt/reports/test-report-dir'; + // reportDir now includes .takt/runs/{slug}/reports path (matches engine constructor behavior) + const reportDirName = '.takt/runs/test-report-dir/reports'; beforeEach(() => { tmpDir = join(tmpdir(), `takt-report-test-${Date.now()}`); diff --git a/src/__tests__/engine-test-helpers.ts b/src/__tests__/engine-test-helpers.ts index 7112df8a..d438c112 100644 --- a/src/__tests__/engine-test-helpers.ts +++ b/src/__tests__/engine-test-helpers.ts @@ -154,13 +154,17 @@ export function mockDetectMatchedRuleSequence(matches: (RuleMatch | undefined)[] // --- Test environment setup --- /** - * Create a temporary directory with the required .takt/reports structure. + * Create a temporary directory with the required .takt/runs structure. * Returns the tmpDir path. Caller is responsible for cleanup. */ export function createTestTmpDir(): string { const tmpDir = join(tmpdir(), `takt-engine-test-${randomUUID()}`); mkdirSync(tmpDir, { recursive: true }); - mkdirSync(join(tmpDir, '.takt', 'reports', 'test-report-dir'), { recursive: true }); + mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'reports'), { recursive: true }); + mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'knowledge'), { recursive: true }); + mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'policy'), { recursive: true }); + mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'context', 'previous_responses'), { recursive: true }); + mkdirSync(join(tmpDir, '.takt', 'runs', 'test-report-dir', 'logs'), { recursive: true }); return tmpDir; } @@ -178,8 +182,21 @@ export function applyDefaultMocks(): void { * Clean up PieceEngine instances to prevent EventEmitter memory leaks. * Call this in afterEach to ensure all event listeners are removed. */ -export function cleanupPieceEngine(engine: any): void { - if (engine && typeof engine.removeAllListeners === 'function') { +type ListenerCleanupTarget = { + removeAllListeners: () => void; +}; + +function isListenerCleanupTarget(value: unknown): value is ListenerCleanupTarget { + return ( + typeof value === 'object' && + value !== null && + 'removeAllListeners' in value && + typeof value.removeAllListeners === 'function' + ); +} + +export function cleanupPieceEngine(engine: unknown): void { + if (isListenerCleanupTarget(engine)) { engine.removeAllListeners(); } } diff --git a/src/__tests__/engine-worktree-report.test.ts b/src/__tests__/engine-worktree-report.test.ts index 92bb2633..dc83c50c 100644 --- a/src/__tests__/engine-worktree-report.test.ts +++ b/src/__tests__/engine-worktree-report.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { existsSync, rmSync, mkdirSync } from 'node:fs'; +import { existsSync, rmSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; @@ -51,11 +51,11 @@ function createWorktreeDirs(): { projectCwd: string; cloneCwd: string } { const projectCwd = join(base, 'project'); const cloneCwd = join(base, 'clone'); - // Project side: real .takt/reports directory (for non-worktree tests) - mkdirSync(join(projectCwd, '.takt', 'reports', 'test-report-dir'), { recursive: true }); + // Project side: real .takt/runs directory (for non-worktree tests) + mkdirSync(join(projectCwd, '.takt', 'runs', 'test-report-dir', 'reports'), { recursive: true }); - // Clone side: .takt/reports directory (reports now written directly to clone) - mkdirSync(join(cloneCwd, '.takt', 'reports', 'test-report-dir'), { recursive: true }); + // Clone side: .takt/runs directory (reports now written directly to clone) + mkdirSync(join(cloneCwd, '.takt', 'runs', 'test-report-dir', 'reports'), { recursive: true }); return { projectCwd, cloneCwd }; } @@ -121,8 +121,8 @@ describe('PieceEngine: worktree reportDir resolution', () => { // reportDir should be resolved from cloneCwd (cwd), not projectCwd // This prevents agents from discovering the main repository path via instruction - const expectedPath = join(cloneCwd, '.takt/reports/test-report-dir'); - const unexpectedPath = join(projectCwd, '.takt/reports/test-report-dir'); + const expectedPath = join(cloneCwd, '.takt/runs/test-report-dir/reports'); + const unexpectedPath = join(projectCwd, '.takt/runs/test-report-dir/reports'); expect(phaseCtx.reportDir).toBe(expectedPath); expect(phaseCtx.reportDir).not.toBe(unexpectedPath); @@ -166,10 +166,10 @@ describe('PieceEngine: worktree reportDir resolution', () => { expect(runAgentMock).toHaveBeenCalled(); const instruction = runAgentMock.mock.calls[0][1] as string; - const expectedPath = join(cloneCwd, '.takt/reports/test-report-dir'); + const expectedPath = join(cloneCwd, '.takt/runs/test-report-dir/reports'); expect(instruction).toContain(expectedPath); // In worktree mode, projectCwd path should NOT appear in instruction - expect(instruction).not.toContain(join(projectCwd, '.takt/reports/test-report-dir')); + expect(instruction).not.toContain(join(projectCwd, '.takt/runs/test-report-dir/reports')); }); it('should use same path in non-worktree mode (cwd === projectCwd)', async () => { @@ -195,7 +195,7 @@ describe('PieceEngine: worktree reportDir resolution', () => { expect(reportPhaseMock).toHaveBeenCalled(); const phaseCtx = reportPhaseMock.mock.calls[0][2] as { reportDir: string }; - const expectedPath = join(normalDir, '.takt/reports/test-report-dir'); + const expectedPath = join(normalDir, '.takt/runs/test-report-dir/reports'); expect(phaseCtx.reportDir).toBe(expectedPath); }); @@ -219,7 +219,7 @@ describe('PieceEngine: worktree reportDir resolution', () => { const reportPhaseMock = vi.mocked(runReportPhase); expect(reportPhaseMock).toHaveBeenCalled(); const phaseCtx = reportPhaseMock.mock.calls[0][2] as { reportDir: string }; - expect(phaseCtx.reportDir).toBe(join(normalDir, '.takt/reports/20260201-015714-foptng')); + expect(phaseCtx.reportDir).toBe(join(normalDir, '.takt/runs/20260201-015714-foptng/reports')); }); it('should reject invalid explicit reportDirName', () => { @@ -241,4 +241,54 @@ describe('PieceEngine: worktree reportDir resolution', () => { reportDirName: '', })).toThrow('Invalid reportDirName: '); }); + + it('should persist context snapshots and update latest previous response', async () => { + const normalDir = projectCwd; + const config: PieceConfig = { + name: 'snapshot-test', + description: 'Test', + maxIterations: 10, + initialMovement: 'implement', + movements: [ + makeMovement('implement', { + policyContents: ['Policy content'], + knowledgeContents: ['Knowledge content'], + rules: [makeRule('go-review', 'review')], + }), + makeMovement('review', { + rules: [makeRule('approved', 'COMPLETE')], + }), + ], + }; + const engine = new PieceEngine(config, normalDir, 'test task', { + projectCwd: normalDir, + reportDirName: 'test-report-dir', + }); + + mockRunAgentSequence([ + makeResponse({ persona: 'implement', content: 'implement output' }), + makeResponse({ persona: 'review', content: 'review output' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'tag' as const }, + { index: 0, method: 'tag' as const }, + ]); + + await engine.run(); + + const base = join(normalDir, '.takt', 'runs', 'test-report-dir', 'context'); + const knowledgeDir = join(base, 'knowledge'); + const policyDir = join(base, 'policy'); + const previousResponsesDir = join(base, 'previous_responses'); + + const knowledgeFiles = readdirSync(knowledgeDir); + const policyFiles = readdirSync(policyDir); + const previousResponseFiles = readdirSync(previousResponsesDir); + + expect(knowledgeFiles.some((name) => name.endsWith('.md'))).toBe(true); + expect(policyFiles.some((name) => name.endsWith('.md'))).toBe(true); + expect(previousResponseFiles).toContain('latest.md'); + expect(previousResponseFiles.filter((name) => name.endsWith('.md')).length).toBe(3); + expect(readFileSync(join(previousResponsesDir, 'latest.md'), 'utf-8')).toBe('review output'); + }); }); diff --git a/src/__tests__/escape.test.ts b/src/__tests__/escape.test.ts index e850fa3e..6200983d 100644 --- a/src/__tests__/escape.test.ts +++ b/src/__tests__/escape.test.ts @@ -112,6 +112,23 @@ describe('replaceTemplatePlaceholders', () => { expect(result).toBe('Previous: previous output text'); }); + it('should prefer preprocessed previous response text when provided', () => { + const step = makeMovement({ passPreviousResponse: true }); + const ctx = makeContext({ + previousOutput: { + persona: 'coder', + status: 'done', + content: 'raw previous output', + timestamp: new Date(), + }, + previousResponseText: 'processed previous output', + }); + const template = 'Previous: {previous_response}'; + + const result = replaceTemplatePlaceholders(template, step, ctx); + expect(result).toBe('Previous: processed previous output'); + }); + it('should replace {previous_response} with empty string when no previous output', () => { const step = makeMovement({ passPreviousResponse: true }); const ctx = makeContext(); diff --git a/src/__tests__/instructionBuilder.test.ts b/src/__tests__/instructionBuilder.test.ts index 6740e877..e78a8683 100644 --- a/src/__tests__/instructionBuilder.test.ts +++ b/src/__tests__/instructionBuilder.test.ts @@ -128,13 +128,13 @@ describe('instruction-builder', () => { ); const context = createMinimalContext({ cwd: '/project', - reportDir: '/project/.takt/reports/20260128-test-report', + reportDir: '/project/.takt/runs/20260128-test-report/reports', }); const result = buildInstruction(step, context); expect(result).toContain( - '- Report Directory: /project/.takt/reports/20260128-test-report/' + '- Report Directory: /project/.takt/runs/20260128-test-report/reports/' ); }); @@ -145,14 +145,14 @@ describe('instruction-builder', () => { const context = createMinimalContext({ cwd: '/clone/my-task', projectCwd: '/project', - reportDir: '/project/.takt/reports/20260128-worktree-report', + reportDir: '/project/.takt/runs/20260128-worktree-report/reports', }); const result = buildInstruction(step, context); // reportDir is now absolute, pointing to projectCwd expect(result).toContain( - '- Report: /project/.takt/reports/20260128-worktree-report/00-plan.md' + '- Report: /project/.takt/runs/20260128-worktree-report/reports/00-plan.md' ); expect(result).toContain('Working Directory: /clone/my-task'); }); @@ -164,13 +164,13 @@ describe('instruction-builder', () => { const context = createMinimalContext({ projectCwd: '/project', cwd: '/worktree', - reportDir: '/project/.takt/reports/20260128-multi', + reportDir: '/project/.takt/runs/20260128-multi/reports', }); const result = buildInstruction(step, context); - expect(result).toContain('/project/.takt/reports/20260128-multi/01-scope.md'); - expect(result).toContain('/project/.takt/reports/20260128-multi/02-decisions.md'); + expect(result).toContain('/project/.takt/runs/20260128-multi/reports/01-scope.md'); + expect(result).toContain('/project/.takt/runs/20260128-multi/reports/02-decisions.md'); }); it('should replace standalone {report_dir} with absolute path', () => { @@ -178,12 +178,108 @@ describe('instruction-builder', () => { 'Report dir name: {report_dir}' ); const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260128-standalone', + reportDir: '/project/.takt/runs/20260128-standalone/reports', }); const result = buildInstruction(step, context); - expect(result).toContain('Report dir name: /project/.takt/reports/20260128-standalone'); + expect(result).toContain('Report dir name: /project/.takt/runs/20260128-standalone/reports'); + }); + }); + + describe('context length control and source path injection', () => { + it('should truncate previous response and inject source path with conflict notice', () => { + const step = createMinimalStep('Continue work'); + step.passPreviousResponse = true; + const longResponse = 'x'.repeat(2100); + const context = createMinimalContext({ + previousOutput: { + persona: 'coder', + status: 'done', + content: longResponse, + timestamp: new Date(), + }, + previousResponseSourcePath: '.takt/runs/test/context/previous_responses/latest.md', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('...TRUNCATED...'); + expect(result).toContain('Source: .takt/runs/test/context/previous_responses/latest.md'); + expect(result).toContain('If prompt content conflicts with source files, source files take precedence.'); + }); + + it('should always inject source paths when content is not truncated', () => { + const step = createMinimalStep('Do work'); + step.passPreviousResponse = true; + const context = createMinimalContext({ + previousOutput: { + persona: 'reviewer', + status: 'done', + content: 'short previous response', + timestamp: new Date(), + }, + previousResponseSourcePath: '.takt/runs/test/context/previous_responses/latest.md', + knowledgeContents: ['short knowledge'], + knowledgeSourcePath: '.takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md', + policyContents: ['short policy'], + policySourcePath: '.takt/runs/test/context/policy/implement.1.20260210T010203Z.md', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('Knowledge Source: .takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md'); + expect(result).toContain('Policy Source: .takt/runs/test/context/policy/implement.1.20260210T010203Z.md'); + expect(result).toContain('Source: .takt/runs/test/context/previous_responses/latest.md'); + expect(result).not.toContain('...TRUNCATED...'); + expect(result).not.toContain('Knowledge is truncated.'); + expect(result).not.toContain('Policy is authoritative. If truncated'); + expect(result).not.toContain('Previous Response is truncated.'); + }); + + it('should not truncate when content length is exactly 2000 chars', () => { + const step = createMinimalStep('Do work'); + step.passPreviousResponse = true; + const exactBoundary = 'x'.repeat(2000); + const context = createMinimalContext({ + previousOutput: { + persona: 'reviewer', + status: 'done', + content: exactBoundary, + timestamp: new Date(), + }, + previousResponseSourcePath: '.takt/runs/test/context/previous_responses/latest.md', + knowledgeContents: [exactBoundary], + knowledgeSourcePath: '.takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md', + policyContents: [exactBoundary], + policySourcePath: '.takt/runs/test/context/policy/implement.1.20260210T010203Z.md', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('Knowledge Source: .takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md'); + expect(result).toContain('Policy Source: .takt/runs/test/context/policy/implement.1.20260210T010203Z.md'); + expect(result).toContain('Source: .takt/runs/test/context/previous_responses/latest.md'); + expect(result).not.toContain('...TRUNCATED...'); + }); + + it('should inject required truncated warning and source path for knowledge/policy', () => { + const step = createMinimalStep('Do work'); + const longKnowledge = 'k'.repeat(2200); + const longPolicy = 'p'.repeat(2200); + const context = createMinimalContext({ + knowledgeContents: [longKnowledge], + knowledgeSourcePath: '.takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md', + policyContents: [longPolicy], + policySourcePath: '.takt/runs/test/context/policy/implement.1.20260210T010203Z.md', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('Knowledge is truncated. You MUST consult the source files before making decisions.'); + expect(result).toContain('Policy is authoritative. If truncated, you MUST read the full policy file and follow it strictly.'); + expect(result).toContain('Knowledge Source: .takt/runs/test/context/knowledge/implement.1.20260210T010203Z.md'); + expect(result).toContain('Policy Source: .takt/runs/test/context/policy/implement.1.20260210T010203Z.md'); }); }); @@ -380,7 +476,7 @@ describe('instruction-builder', () => { step.name = 'plan'; step.outputContracts = [{ name: '00-plan.md' }]; const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); @@ -399,7 +495,7 @@ describe('instruction-builder', () => { { label: 'Decisions', path: '02-decisions.md' }, ]; const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); @@ -414,7 +510,7 @@ describe('instruction-builder', () => { const step = createMinimalStep('Do work'); step.outputContracts = [{ name: '00-plan.md' }]; const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); @@ -559,7 +655,7 @@ describe('instruction-builder', () => { const step = createMinimalStep('Do work'); step.outputContracts = [{ name: '00-plan.md' }]; const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); @@ -579,7 +675,7 @@ describe('instruction-builder', () => { const step = createMinimalStep('Do work'); step.outputContracts = [{ name: '00-plan.md', format: '**Format:**\n# Plan' }]; const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); @@ -595,7 +691,7 @@ describe('instruction-builder', () => { order: 'Custom order instruction', }]; const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); @@ -607,13 +703,13 @@ describe('instruction-builder', () => { it('should still replace {report:filename} in instruction_template', () => { const step = createMinimalStep('Write to {report:00-plan.md}'); const context = createMinimalContext({ - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', language: 'en', }); const result = buildInstruction(step, context); - expect(result).toContain('Write to /project/.takt/reports/20260129-test/00-plan.md'); + expect(result).toContain('Write to /project/.takt/runs/20260129-test/reports/00-plan.md'); expect(result).not.toContain('{report:00-plan.md}'); }); }); @@ -622,7 +718,7 @@ describe('instruction-builder', () => { function createReportContext(overrides: Partial = {}): ReportInstructionContext { return { cwd: '/project', - reportDir: '/project/.takt/reports/20260129-test', + reportDir: '/project/.takt/runs/20260129-test/reports', movementIteration: 1, language: 'en', ...overrides, @@ -663,12 +759,12 @@ describe('instruction-builder', () => { it('should include report directory and file for string report', () => { const step = createMinimalStep('Do work'); step.outputContracts = [{ name: '00-plan.md' }]; - const ctx = createReportContext({ reportDir: '/project/.takt/reports/20260130-test' }); + const ctx = createReportContext({ reportDir: '/project/.takt/runs/20260130-test/reports' }); const result = buildReportInstruction(step, ctx); - expect(result).toContain('- Report Directory: /project/.takt/reports/20260130-test/'); - expect(result).toContain('- Report File: /project/.takt/reports/20260130-test/00-plan.md'); + expect(result).toContain('- Report Directory: /project/.takt/runs/20260130-test/reports/'); + expect(result).toContain('- Report File: /project/.takt/runs/20260130-test/reports/00-plan.md'); }); it('should include report files for OutputContractEntry[] report', () => { @@ -681,10 +777,10 @@ describe('instruction-builder', () => { const result = buildReportInstruction(step, ctx); - expect(result).toContain('- Report Directory: /project/.takt/reports/20260129-test/'); + expect(result).toContain('- Report Directory: /project/.takt/runs/20260129-test/reports/'); expect(result).toContain('- Report Files:'); - expect(result).toContain(' - Scope: /project/.takt/reports/20260129-test/01-scope.md'); - expect(result).toContain(' - Decisions: /project/.takt/reports/20260129-test/02-decisions.md'); + expect(result).toContain(' - Scope: /project/.takt/runs/20260129-test/reports/01-scope.md'); + expect(result).toContain(' - Decisions: /project/.takt/runs/20260129-test/reports/02-decisions.md'); }); it('should include report file for OutputContractItem report', () => { @@ -694,7 +790,7 @@ describe('instruction-builder', () => { const result = buildReportInstruction(step, ctx); - expect(result).toContain('- Report File: /project/.takt/reports/20260129-test/00-plan.md'); + expect(result).toContain('- Report File: /project/.takt/runs/20260129-test/reports/00-plan.md'); }); it('should include auto-generated report output instruction', () => { @@ -719,7 +815,7 @@ describe('instruction-builder', () => { const result = buildReportInstruction(step, ctx); - expect(result).toContain('Output to /project/.takt/reports/20260129-test/00-plan.md file.'); + expect(result).toContain('Output to /project/.takt/runs/20260129-test/reports/00-plan.md file.'); expect(result).not.toContain('**Report output:**'); }); @@ -895,6 +991,24 @@ describe('instruction-builder', () => { expect(result).toContain('## Feedback\nReview feedback here'); }); + it('should apply truncation and source path when {previous_response} placeholder is used', () => { + const step = createMinimalStep('## Feedback\n{previous_response}\n\nFix the issues.'); + step.passPreviousResponse = true; + const context = createMinimalContext({ + previousOutput: { content: 'x'.repeat(2100), tag: '[TEST:1]' }, + previousResponseSourcePath: '.takt/runs/test/context/previous_responses/latest.md', + language: 'en', + }); + + const result = buildInstruction(step, context); + + expect(result).not.toContain('## Previous Response\n'); + expect(result).toContain('## Feedback'); + expect(result).toContain('...TRUNCATED...'); + expect(result).toContain('Source: .takt/runs/test/context/previous_responses/latest.md'); + expect(result).toContain('If prompt content conflicts with source files, source files take precedence.'); + }); + it('should skip auto-injected Additional User Inputs when template contains {user_inputs}', () => { const step = createMinimalStep('Inputs: {user_inputs}'); const context = createMinimalContext({ diff --git a/src/__tests__/it-instruction-builder.test.ts b/src/__tests__/it-instruction-builder.test.ts index db161019..6f1350d6 100644 --- a/src/__tests__/it-instruction-builder.test.ts +++ b/src/__tests__/it-instruction-builder.test.ts @@ -203,11 +203,11 @@ describe('Instruction Builder IT: report_dir expansion', () => { const step = makeMovement({ instructionTemplate: 'Read the plan from {report_dir}/00-plan.md', }); - const ctx = makeContext({ reportDir: '/tmp/test-project/.takt/reports/20250126-task' }); + const ctx = makeContext({ reportDir: '/tmp/test-project/.takt/runs/20250126-task/reports' }); const result = buildInstruction(step, ctx); - expect(result).toContain('Read the plan from /tmp/test-project/.takt/reports/20250126-task/00-plan.md'); + expect(result).toContain('Read the plan from /tmp/test-project/.takt/runs/20250126-task/reports/00-plan.md'); }); it('should replace {report:filename} with full path', () => { @@ -289,13 +289,13 @@ describe('Instruction Builder IT: buildReportInstruction', () => { const result = buildReportInstruction(step, { cwd: '/tmp/test', - reportDir: '/tmp/test/.takt/reports/test-dir', + reportDir: '/tmp/test/.takt/runs/test-dir/reports', movementIteration: 1, language: 'en', }); expect(result).toContain('00-plan.md'); - expect(result).toContain('/tmp/test/.takt/reports/test-dir'); + expect(result).toContain('/tmp/test/.takt/runs/test-dir/reports'); expect(result).toContain('report'); }); diff --git a/src/__tests__/it-notification-sound.test.ts b/src/__tests__/it-notification-sound.test.ts index 6de13dea..4632c560 100644 --- a/src/__tests__/it-notification-sound.test.ts +++ b/src/__tests__/it-notification-sound.test.ts @@ -117,6 +117,8 @@ vi.mock('../infra/config/index.js', () => ({ updateWorktreeSession: vi.fn(), loadGlobalConfig: mockLoadGlobalConfig, saveSessionState: vi.fn(), + ensureDir: vi.fn(), + writeFileAtomic: vi.fn(), })); vi.mock('../shared/context.js', () => ({ @@ -148,23 +150,30 @@ vi.mock('../infra/fs/index.js', () => ({ status: _status, endTime: new Date().toISOString(), })), - updateLatestPointer: vi.fn(), initNdjsonLog: vi.fn().mockReturnValue('/tmp/test-log.jsonl'), appendNdjsonLine: vi.fn(), })); -vi.mock('../shared/utils/index.js', () => ({ - createLogger: vi.fn().mockReturnValue({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - notifySuccess: mockNotifySuccess, - notifyError: mockNotifyError, - playWarningSound: mockPlayWarningSound, - preventSleep: vi.fn(), -})); +vi.mock('../shared/utils/index.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createLogger: vi.fn().mockReturnValue({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + notifySuccess: mockNotifySuccess, + notifyError: mockNotifyError, + playWarningSound: mockPlayWarningSound, + preventSleep: vi.fn(), + isDebugEnabled: vi.fn().mockReturnValue(false), + writePromptLog: vi.fn(), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), + isValidReportDirName: vi.fn().mockImplementation((value: string) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)), + }; +}); vi.mock('../shared/prompt/index.js', () => ({ selectOption: mockSelectOption, diff --git a/src/__tests__/it-pipeline-modes.test.ts b/src/__tests__/it-pipeline-modes.test.ts index 235d3307..c01cc8f8 100644 --- a/src/__tests__/it-pipeline-modes.test.ts +++ b/src/__tests__/it-pipeline-modes.test.ts @@ -96,7 +96,6 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ iterations: 0, }), finalizeSessionLog: vi.fn().mockImplementation((log, status) => ({ ...log, status })), - updateLatestPointer: vi.fn(), initNdjsonLog: vi.fn().mockReturnValue('/tmp/test.ndjson'), appendNdjsonLine: vi.fn(), generateReportDir: vi.fn().mockReturnValue('test-report-dir'), diff --git a/src/__tests__/it-pipeline.test.ts b/src/__tests__/it-pipeline.test.ts index c118fd4f..65c3899c 100644 --- a/src/__tests__/it-pipeline.test.ts +++ b/src/__tests__/it-pipeline.test.ts @@ -78,7 +78,6 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ iterations: 0, }), finalizeSessionLog: vi.fn().mockImplementation((log, status) => ({ ...log, status })), - updateLatestPointer: vi.fn(), initNdjsonLog: vi.fn().mockReturnValue('/tmp/test.ndjson'), appendNdjsonLine: vi.fn(), generateReportDir: vi.fn().mockReturnValue('test-report-dir'), @@ -139,8 +138,8 @@ import { executePipeline } from '../features/pipeline/index.js'; function createTestPieceDir(): { dir: string; piecePath: string } { const dir = mkdtempSync(join(tmpdir(), 'takt-it-pipeline-')); - // Create .takt/reports structure - mkdirSync(join(dir, '.takt', 'reports', 'test-report-dir'), { recursive: true }); + // Create .takt/runs structure + mkdirSync(join(dir, '.takt', 'runs', 'test-report-dir', 'reports'), { recursive: true }); // Create persona prompt files const personasDir = join(dir, 'personas'); diff --git a/src/__tests__/it-sigint-interrupt.test.ts b/src/__tests__/it-sigint-interrupt.test.ts index ab3d86be..6eb31c57 100644 --- a/src/__tests__/it-sigint-interrupt.test.ts +++ b/src/__tests__/it-sigint-interrupt.test.ts @@ -83,6 +83,8 @@ vi.mock('../infra/config/index.js', () => ({ updateWorktreeSession: vi.fn(), loadGlobalConfig: vi.fn().mockReturnValue({ provider: 'claude' }), saveSessionState: vi.fn(), + ensureDir: vi.fn(), + writeFileAtomic: vi.fn(), })); vi.mock('../shared/context.js', () => ({ @@ -114,25 +116,30 @@ vi.mock('../infra/fs/index.js', () => ({ status: _status, endTime: new Date().toISOString(), })), - updateLatestPointer: vi.fn(), initNdjsonLog: vi.fn().mockReturnValue('/tmp/test-log.jsonl'), appendNdjsonLine: vi.fn(), })); -vi.mock('../shared/utils/index.js', () => ({ - createLogger: vi.fn().mockReturnValue({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - notifySuccess: vi.fn(), - notifyError: vi.fn(), - playWarningSound: vi.fn(), - preventSleep: vi.fn(), - isDebugEnabled: vi.fn().mockReturnValue(false), - writePromptLog: vi.fn(), -})); +vi.mock('../shared/utils/index.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createLogger: vi.fn().mockReturnValue({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + notifySuccess: vi.fn(), + notifyError: vi.fn(), + playWarningSound: vi.fn(), + preventSleep: vi.fn(), + isDebugEnabled: vi.fn().mockReturnValue(false), + writePromptLog: vi.fn(), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), + isValidReportDirName: vi.fn().mockImplementation((value: string) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)), + }; +}); vi.mock('../shared/prompt/index.js', () => ({ selectOption: vi.fn(), diff --git a/src/__tests__/it-stage-and-commit.test.ts b/src/__tests__/it-stage-and-commit.test.ts index 496b7bd4..4648dc1d 100644 --- a/src/__tests__/it-stage-and-commit.test.ts +++ b/src/__tests__/it-stage-and-commit.test.ts @@ -2,7 +2,7 @@ * Integration test for stageAndCommit * * Tests that gitignored files are NOT included in commits. - * Regression test for c89ac4c where `git add -f .takt/reports/` caused + * Regression test for c89ac4c where `git add -f .takt/runs/` caused * gitignored report files to be committed. */ @@ -36,15 +36,15 @@ describe('stageAndCommit', () => { } }); - it('should not commit gitignored .takt/reports/ files', () => { + it('should not commit gitignored .takt/runs/ files', () => { // Setup: .takt/ is gitignored writeFileSync(join(testDir, '.gitignore'), '.takt/\n'); execFileSync('git', ['add', '.gitignore'], { cwd: testDir }); execFileSync('git', ['commit', '-m', 'Add gitignore'], { cwd: testDir }); - // Create .takt/reports/ with a report file - mkdirSync(join(testDir, '.takt', 'reports', 'test-report'), { recursive: true }); - writeFileSync(join(testDir, '.takt', 'reports', 'test-report', '00-plan.md'), '# Plan'); + // Create .takt/runs/ with a report file + mkdirSync(join(testDir, '.takt', 'runs', 'test-report', 'reports'), { recursive: true }); + writeFileSync(join(testDir, '.takt', 'runs', 'test-report', 'reports', '00-plan.md'), '# Plan'); // Also create a tracked file change to ensure commit happens writeFileSync(join(testDir, 'src.ts'), 'export const x = 1;'); @@ -52,7 +52,7 @@ describe('stageAndCommit', () => { const hash = stageAndCommit(testDir, 'test commit'); expect(hash).toBeDefined(); - // Verify .takt/reports/ is NOT in the commit + // Verify .takt/runs/ is NOT in the commit const committedFiles = execFileSync('git', ['diff-tree', '--no-commit-id', '-r', '--name-only', 'HEAD'], { cwd: testDir, encoding: 'utf-8', @@ -60,7 +60,7 @@ describe('stageAndCommit', () => { }).trim(); expect(committedFiles).toContain('src.ts'); - expect(committedFiles).not.toContain('.takt/reports/'); + expect(committedFiles).not.toContain('.takt/runs/'); }); it('should commit normally when no gitignored files exist', () => { diff --git a/src/__tests__/pieceExecution-debug-prompts.test.ts b/src/__tests__/pieceExecution-debug-prompts.test.ts index c99bc19e..281c86e8 100644 --- a/src/__tests__/pieceExecution-debug-prompts.test.ts +++ b/src/__tests__/pieceExecution-debug-prompts.test.ts @@ -18,6 +18,9 @@ const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted(( constructor(config: PieceConfig, _cwd: string, task: string, _options: unknown) { super(); + if (task === 'constructor-throw-task') { + throw new Error('mock constructor failure'); + } this.config = config; this.task = task; } @@ -27,6 +30,7 @@ const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted(( async run(): Promise<{ status: string; iteration: number }> { const step = this.config.movements[0]!; const timestamp = new Date('2026-02-07T00:00:00.000Z'); + const shouldAbort = this.task === 'abort-task'; const shouldRepeatMovement = this.task === 'repeat-movement-task'; this.emit('movement:start', step, 1, 'movement instruction'); @@ -57,8 +61,11 @@ const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted(( 'movement instruction repeat' ); } + if (shouldAbort) { + this.emit('piece:abort', { status: 'aborted', iteration: 1 }, 'user_interrupted'); + return { status: 'aborted', iteration: shouldRepeatMovement ? 2 : 1 }; + } this.emit('piece:complete', { status: 'completed', iteration: 1 }); - return { status: 'completed', iteration: shouldRepeatMovement ? 2 : 1 }; } } @@ -83,6 +90,8 @@ vi.mock('../infra/config/index.js', () => ({ updateWorktreeSession: vi.fn(), loadGlobalConfig: vi.fn().mockReturnValue({ provider: 'claude' }), saveSessionState: vi.fn(), + ensureDir: vi.fn(), + writeFileAtomic: vi.fn(), })); vi.mock('../shared/context.js', () => ({ @@ -114,7 +123,6 @@ vi.mock('../infra/fs/index.js', () => ({ status, endTime: new Date().toISOString(), })), - updateLatestPointer: vi.fn(), initNdjsonLog: vi.fn().mockReturnValue('/tmp/test-log.jsonl'), appendNdjsonLine: vi.fn(), })); @@ -131,6 +139,8 @@ vi.mock('../shared/utils/index.js', () => ({ preventSleep: vi.fn(), isDebugEnabled: mockIsDebugEnabled, writePromptLog: mockWritePromptLog, + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), + isValidReportDirName: vi.fn().mockImplementation((value: string) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)), })); vi.mock('../shared/prompt/index.js', () => ({ @@ -147,6 +157,7 @@ vi.mock('../shared/exitCodes.js', () => ({ })); import { executePiece } from '../features/tasks/execute/pieceExecution.js'; +import { ensureDir, writeFileAtomic } from '../infra/config/index.js'; describe('executePiece debug prompts logging', () => { beforeEach(() => { @@ -232,4 +243,69 @@ describe('executePiece debug prompts logging', () => { }) ).rejects.toThrow('taskPrefix and taskColorIndex must be provided together'); }); + + it('should fail fast for invalid reportDirName before run directory writes', async () => { + await expect( + executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + reportDirName: '..', + }) + ).rejects.toThrow('Invalid reportDirName: ..'); + + expect(vi.mocked(ensureDir)).not.toHaveBeenCalled(); + expect(vi.mocked(writeFileAtomic)).not.toHaveBeenCalled(); + }); + + it('should update meta status from running to completed', async () => { + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + reportDirName: 'test-report-dir', + }); + + const calls = vi.mocked(writeFileAtomic).mock.calls; + expect(calls).toHaveLength(2); + + const firstMeta = JSON.parse(String(calls[0]![1])) as { status: string; endTime?: string }; + const secondMeta = JSON.parse(String(calls[1]![1])) as { status: string; endTime?: string }; + expect(firstMeta.status).toBe('running'); + expect(firstMeta.endTime).toBeUndefined(); + expect(secondMeta.status).toBe('completed'); + expect(secondMeta.endTime).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('should update meta status from running to aborted', async () => { + await executePiece(makeConfig(), 'abort-task', '/tmp/project', { + projectCwd: '/tmp/project', + reportDirName: 'test-report-dir', + }); + + const calls = vi.mocked(writeFileAtomic).mock.calls; + expect(calls).toHaveLength(2); + + const firstMeta = JSON.parse(String(calls[0]![1])) as { status: string; endTime?: string }; + const secondMeta = JSON.parse(String(calls[1]![1])) as { status: string; endTime?: string }; + expect(firstMeta.status).toBe('running'); + expect(firstMeta.endTime).toBeUndefined(); + expect(secondMeta.status).toBe('aborted'); + expect(secondMeta.endTime).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('should finalize meta as aborted when PieceEngine constructor throws', async () => { + await expect( + executePiece(makeConfig(), 'constructor-throw-task', '/tmp/project', { + projectCwd: '/tmp/project', + reportDirName: 'test-report-dir', + }) + ).rejects.toThrow('mock constructor failure'); + + const calls = vi.mocked(writeFileAtomic).mock.calls; + expect(calls).toHaveLength(2); + + const firstMeta = JSON.parse(String(calls[0]![1])) as { status: string; endTime?: string }; + const secondMeta = JSON.parse(String(calls[1]![1])) as { status: string; endTime?: string }; + expect(firstMeta.status).toBe('running'); + expect(firstMeta.endTime).toBeUndefined(); + expect(secondMeta.status).toBe('aborted'); + expect(secondMeta.endTime).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); }); diff --git a/src/__tests__/run-paths.test.ts b/src/__tests__/run-paths.test.ts new file mode 100644 index 00000000..ba1965f1 --- /dev/null +++ b/src/__tests__/run-paths.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { buildRunPaths } from '../core/piece/run/run-paths.js'; + +describe('buildRunPaths', () => { + it('should build run-scoped relative and absolute paths', () => { + const paths = buildRunPaths('/tmp/project', '20260210-demo-task'); + + expect(paths.runRootRel).toBe('.takt/runs/20260210-demo-task'); + expect(paths.reportsRel).toBe('.takt/runs/20260210-demo-task/reports'); + expect(paths.contextKnowledgeRel).toBe('.takt/runs/20260210-demo-task/context/knowledge'); + expect(paths.contextPolicyRel).toBe('.takt/runs/20260210-demo-task/context/policy'); + expect(paths.contextPreviousResponsesRel).toBe('.takt/runs/20260210-demo-task/context/previous_responses'); + expect(paths.logsRel).toBe('.takt/runs/20260210-demo-task/logs'); + expect(paths.metaRel).toBe('.takt/runs/20260210-demo-task/meta.json'); + + expect(paths.reportsAbs).toBe('/tmp/project/.takt/runs/20260210-demo-task/reports'); + expect(paths.metaAbs).toBe('/tmp/project/.takt/runs/20260210-demo-task/meta.json'); + }); +}); diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index c4e123b3..7496defc 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -7,14 +7,11 @@ import { existsSync, readFileSync, mkdirSync, rmSync, writeFileSync } from 'node import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { - createSessionLog, - updateLatestPointer, initNdjsonLog, appendNdjsonLine, loadNdjsonLog, loadSessionLog, extractFailureInfo, - type LatestLogPointer, type SessionLog, type NdjsonRecord, type NdjsonStepComplete, @@ -26,121 +23,18 @@ import { type NdjsonInteractiveEnd, } from '../infra/fs/session.js'; -/** Create a temp project directory with .takt/logs structure */ +/** Create a temp project directory for each test */ function createTempProject(): string { const dir = join(tmpdir(), `takt-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); mkdirSync(dir, { recursive: true }); return dir; } -describe('updateLatestPointer', () => { - let projectDir: string; - - beforeEach(() => { - projectDir = createTempProject(); - }); - - afterEach(() => { - rmSync(projectDir, { recursive: true, force: true }); - }); - - it('should create latest.json with pointer data', () => { - const log = createSessionLog('my task', projectDir, 'default'); - const sessionId = 'abc-123'; - - updateLatestPointer(log, sessionId, projectDir); - - const latestPath = join(projectDir, '.takt', 'logs', 'latest.json'); - expect(existsSync(latestPath)).toBe(true); - - const pointer = JSON.parse(readFileSync(latestPath, 'utf-8')) as LatestLogPointer; - expect(pointer.sessionId).toBe('abc-123'); - expect(pointer.logFile).toBe('abc-123.jsonl'); - expect(pointer.task).toBe('my task'); - expect(pointer.pieceName).toBe('default'); - expect(pointer.status).toBe('running'); - expect(pointer.iterations).toBe(0); - expect(pointer.startTime).toBeDefined(); - expect(pointer.updatedAt).toBeDefined(); - }); - - it('should not create previous.json when copyToPrevious is false', () => { - const log = createSessionLog('task', projectDir, 'wf'); - updateLatestPointer(log, 'sid-1', projectDir); - - const previousPath = join(projectDir, '.takt', 'logs', 'previous.json'); - expect(existsSync(previousPath)).toBe(false); - }); - - it('should not create previous.json when copyToPrevious is true but latest.json does not exist', () => { - const log = createSessionLog('task', projectDir, 'wf'); - updateLatestPointer(log, 'sid-1', projectDir, { copyToPrevious: true }); - - const previousPath = join(projectDir, '.takt', 'logs', 'previous.json'); - // latest.json didn't exist before this call, so previous.json should not be created - expect(existsSync(previousPath)).toBe(false); - }); - - it('should copy latest.json to previous.json when copyToPrevious is true and latest exists', () => { - const log1 = createSessionLog('first task', projectDir, 'wf1'); - updateLatestPointer(log1, 'sid-first', projectDir); - - // Simulate a second piece starting - const log2 = createSessionLog('second task', projectDir, 'wf2'); - updateLatestPointer(log2, 'sid-second', projectDir, { copyToPrevious: true }); - - const logsDir = join(projectDir, '.takt', 'logs'); - const latest = JSON.parse(readFileSync(join(logsDir, 'latest.json'), 'utf-8')) as LatestLogPointer; - const previous = JSON.parse(readFileSync(join(logsDir, 'previous.json'), 'utf-8')) as LatestLogPointer; - - // latest should point to second session - expect(latest.sessionId).toBe('sid-second'); - expect(latest.task).toBe('second task'); - - // previous should point to first session - expect(previous.sessionId).toBe('sid-first'); - expect(previous.task).toBe('first task'); - }); - - it('should not update previous.json on step-complete calls (no copyToPrevious)', () => { - // Piece 1 creates latest - const log1 = createSessionLog('first', projectDir, 'wf'); - updateLatestPointer(log1, 'sid-1', projectDir); - - // Piece 2 starts → copies latest to previous - const log2 = createSessionLog('second', projectDir, 'wf'); - updateLatestPointer(log2, 'sid-2', projectDir, { copyToPrevious: true }); - - // Step completes → updates only latest (no copyToPrevious) - log2.iterations = 1; - updateLatestPointer(log2, 'sid-2', projectDir); - - const logsDir = join(projectDir, '.takt', 'logs'); - const previous = JSON.parse(readFileSync(join(logsDir, 'previous.json'), 'utf-8')) as LatestLogPointer; - - // previous should still point to first session - expect(previous.sessionId).toBe('sid-1'); - }); - - it('should update iterations and status in latest.json on subsequent calls', () => { - const log = createSessionLog('task', projectDir, 'wf'); - updateLatestPointer(log, 'sid-1', projectDir, { copyToPrevious: true }); - - // Simulate step completion - log.iterations = 2; - updateLatestPointer(log, 'sid-1', projectDir); - - // Simulate piece completion - log.status = 'completed'; - log.iterations = 3; - updateLatestPointer(log, 'sid-1', projectDir); - - const latestPath = join(projectDir, '.takt', 'logs', 'latest.json'); - const pointer = JSON.parse(readFileSync(latestPath, 'utf-8')) as LatestLogPointer; - expect(pointer.status).toBe('completed'); - expect(pointer.iterations).toBe(3); - }); -}); +function initTestNdjsonLog(sessionId: string, task: string, pieceName: string, projectDir: string): string { + const logsDir = join(projectDir, '.takt', 'runs', 'test-run', 'logs'); + mkdirSync(logsDir, { recursive: true }); + return initNdjsonLog(sessionId, task, pieceName, { logsDir }); +} describe('NDJSON log', () => { let projectDir: string; @@ -155,7 +49,7 @@ describe('NDJSON log', () => { describe('initNdjsonLog', () => { it('should create a .jsonl file with piece_start record', () => { - const filepath = initNdjsonLog('sess-001', 'my task', 'default', projectDir); + const filepath = initTestNdjsonLog('sess-001', 'my task', 'default', projectDir); expect(filepath).toContain('sess-001.jsonl'); expect(existsSync(filepath)).toBe(true); @@ -176,7 +70,7 @@ describe('NDJSON log', () => { describe('appendNdjsonLine', () => { it('should append records as individual lines', () => { - const filepath = initNdjsonLog('sess-002', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-002', 'task', 'wf', projectDir); const stepStart: NdjsonRecord = { type: 'step_start', @@ -224,7 +118,7 @@ describe('NDJSON log', () => { describe('loadNdjsonLog', () => { it('should reconstruct SessionLog from NDJSON file', () => { - const filepath = initNdjsonLog('sess-003', 'build app', 'default', projectDir); + const filepath = initTestNdjsonLog('sess-003', 'build app', 'default', projectDir); // Add step_start + step_complete appendNdjsonLine(filepath, { @@ -270,7 +164,7 @@ describe('NDJSON log', () => { }); it('should handle aborted piece', () => { - const filepath = initNdjsonLog('sess-004', 'failing task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-004', 'failing task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'step_start', @@ -321,7 +215,7 @@ describe('NDJSON log', () => { }); it('should skip step_start records when reconstructing SessionLog', () => { - const filepath = initNdjsonLog('sess-005', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-005', 'task', 'wf', projectDir); // Add various records appendNdjsonLine(filepath, { @@ -358,7 +252,7 @@ describe('NDJSON log', () => { describe('loadSessionLog with .jsonl extension', () => { it('should delegate to loadNdjsonLog for .jsonl files', () => { - const filepath = initNdjsonLog('sess-006', 'jsonl task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-006', 'jsonl task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'step_complete', @@ -406,7 +300,7 @@ describe('NDJSON log', () => { describe('appendNdjsonLine real-time characteristics', () => { it('should append without overwriting previous content', () => { - const filepath = initNdjsonLog('sess-007', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-007', 'task', 'wf', projectDir); // Read after init const after1 = readFileSync(filepath, 'utf-8').trim().split('\n'); @@ -428,7 +322,7 @@ describe('NDJSON log', () => { }); it('should produce valid JSON on each line', () => { - const filepath = initNdjsonLog('sess-008', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-008', 'task', 'wf', projectDir); for (let i = 0; i < 5; i++) { appendNdjsonLine(filepath, { @@ -453,7 +347,7 @@ describe('NDJSON log', () => { describe('phase NDJSON records', () => { it('should serialize and append phase_start records', () => { - const filepath = initNdjsonLog('sess-phase-001', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-phase-001', 'task', 'wf', projectDir); const record: NdjsonPhaseStart = { type: 'phase_start', @@ -480,7 +374,7 @@ describe('NDJSON log', () => { }); it('should serialize and append phase_complete records', () => { - const filepath = initNdjsonLog('sess-phase-002', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-phase-002', 'task', 'wf', projectDir); const record: NdjsonPhaseComplete = { type: 'phase_complete', @@ -509,7 +403,7 @@ describe('NDJSON log', () => { }); it('should serialize phase_complete with error', () => { - const filepath = initNdjsonLog('sess-phase-003', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-phase-003', 'task', 'wf', projectDir); const record: NdjsonPhaseComplete = { type: 'phase_complete', @@ -534,7 +428,7 @@ describe('NDJSON log', () => { }); it('should be skipped by loadNdjsonLog (default case)', () => { - const filepath = initNdjsonLog('sess-phase-004', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-phase-004', 'task', 'wf', projectDir); // Add phase records appendNdjsonLine(filepath, { @@ -577,7 +471,7 @@ describe('NDJSON log', () => { describe('interactive NDJSON records', () => { it('should serialize and append interactive_start records', () => { - const filepath = initNdjsonLog('sess-interactive-001', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-interactive-001', 'task', 'wf', projectDir); const record: NdjsonInteractiveStart = { type: 'interactive_start', @@ -597,7 +491,7 @@ describe('NDJSON log', () => { }); it('should serialize and append interactive_end records', () => { - const filepath = initNdjsonLog('sess-interactive-002', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-interactive-002', 'task', 'wf', projectDir); const record: NdjsonInteractiveEnd = { type: 'interactive_end', @@ -620,7 +514,7 @@ describe('NDJSON log', () => { }); it('should be skipped by loadNdjsonLog (default case)', () => { - const filepath = initNdjsonLog('sess-interactive-003', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-interactive-003', 'task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'interactive_start', @@ -647,7 +541,7 @@ describe('NDJSON log', () => { }); it('should extract failure info from aborted piece log', () => { - const filepath = initNdjsonLog('20260205-120000-abc123', 'failing task', 'wf', projectDir); + const filepath = initTestNdjsonLog('20260205-120000-abc123', 'failing task', 'wf', projectDir); // Add step_start for plan appendNdjsonLine(filepath, { @@ -696,7 +590,7 @@ describe('NDJSON log', () => { }); it('should handle log with only completed movements (no abort)', () => { - const filepath = initNdjsonLog('sess-success-001', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-success-001', 'task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'step_start', @@ -731,7 +625,7 @@ describe('NDJSON log', () => { }); it('should handle log with no step_complete records', () => { - const filepath = initNdjsonLog('sess-fail-early-001', 'task', 'wf', projectDir); + const filepath = initTestNdjsonLog('sess-fail-early-001', 'task', 'wf', projectDir); appendNdjsonLine(filepath, { type: 'step_start', diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index f1d4d42d..bd5636db 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -233,6 +233,8 @@ export interface PieceState { movementOutputs: Map; /** Most recent movement output (used for Previous Response injection) */ lastOutput?: AgentResponse; + /** Source path of the latest previous response snapshot */ + previousResponseSourcePath?: string; userInputs: string[]; personaSessions: Map; /** Per-movement iteration counters (how many times each movement has been executed) */ diff --git a/src/core/piece/engine/ArpeggioRunner.ts b/src/core/piece/engine/ArpeggioRunner.ts index 2d9d0144..017c247a 100644 --- a/src/core/piece/engine/ArpeggioRunner.ts +++ b/src/core/piece/engine/ArpeggioRunner.ts @@ -20,12 +20,14 @@ import { detectMatchedRule } from '../evaluation/index.js'; import { incrementMovementIteration } from './state-manager.js'; import { createLogger } from '../../../shared/utils/index.js'; import type { OptionsBuilder } from './OptionsBuilder.js'; +import type { MovementExecutor } from './MovementExecutor.js'; import type { PhaseName } from '../types.js'; const log = createLogger('arpeggio-runner'); export interface ArpeggioRunnerDeps { readonly optionsBuilder: OptionsBuilder; + readonly movementExecutor: MovementExecutor; readonly getCwd: () => string; readonly getInteractive: () => boolean; readonly detectRuleIndex: (content: string, movementName: string) => number; @@ -224,6 +226,12 @@ export class ArpeggioRunner { state.movementOutputs.set(step.name, aggregatedResponse); state.lastOutput = aggregatedResponse; + this.deps.movementExecutor.persistPreviousResponseSnapshot( + state, + step.name, + movementIteration, + aggregatedResponse.content, + ); const instruction = `[Arpeggio] ${step.name}: ${batches.length} batches, source=${arpeggioConfig.source}`; diff --git a/src/core/piece/engine/MovementExecutor.ts b/src/core/piece/engine/MovementExecutor.ts index f8c1b668..d794b1a8 100644 --- a/src/core/piece/engine/MovementExecutor.ts +++ b/src/core/piece/engine/MovementExecutor.ts @@ -6,7 +6,7 @@ * Phase 3: Status judgment (no tools, optional) */ -import { existsSync } from 'node:fs'; +import { existsSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { PieceMovement, @@ -23,6 +23,7 @@ import { buildSessionKey } from '../session-key.js'; import { incrementMovementIteration, getPreviousOutput } from './state-manager.js'; import { createLogger } from '../../../shared/utils/index.js'; import type { OptionsBuilder } from './OptionsBuilder.js'; +import type { RunPaths } from '../run/run-paths.js'; const log = createLogger('movement-executor'); @@ -31,6 +32,7 @@ export interface MovementExecutorDeps { readonly getCwd: () => string; readonly getProjectCwd: () => string; readonly getReportDir: () => string; + readonly getRunPaths: () => RunPaths; readonly getLanguage: () => Language | undefined; readonly getInteractive: () => boolean; readonly getPieceMovements: () => ReadonlyArray<{ name: string; description?: string }>; @@ -52,6 +54,77 @@ export class MovementExecutor { private readonly deps: MovementExecutorDeps, ) {} + private static buildTimestamp(): string { + return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); + } + + private writeSnapshot( + content: string, + directoryRel: string, + filename: string, + ): string { + const absPath = join(this.deps.getCwd(), directoryRel, filename); + writeFileSync(absPath, content, 'utf-8'); + return `${directoryRel}/${filename}`; + } + + private writeFacetSnapshot( + facet: 'knowledge' | 'policy', + movementName: string, + movementIteration: number, + contents: string[] | undefined, + ): { content: string[]; sourcePath: string } | undefined { + if (!contents || contents.length === 0) return undefined; + const merged = contents.join('\n\n---\n\n'); + const timestamp = MovementExecutor.buildTimestamp(); + const runPaths = this.deps.getRunPaths(); + const directoryRel = facet === 'knowledge' + ? runPaths.contextKnowledgeRel + : runPaths.contextPolicyRel; + const sourcePath = this.writeSnapshot( + merged, + directoryRel, + `${movementName}.${movementIteration}.${timestamp}.md`, + ); + return { content: [merged], sourcePath }; + } + + private ensurePreviousResponseSnapshot( + state: PieceState, + movementName: string, + movementIteration: number, + ): void { + if (!state.lastOutput || state.previousResponseSourcePath) return; + const timestamp = MovementExecutor.buildTimestamp(); + const runPaths = this.deps.getRunPaths(); + const fileName = `${movementName}.${movementIteration}.${timestamp}.md`; + const sourcePath = this.writeSnapshot( + state.lastOutput.content, + runPaths.contextPreviousResponsesRel, + fileName, + ); + this.writeSnapshot( + state.lastOutput.content, + runPaths.contextPreviousResponsesRel, + 'latest.md', + ); + state.previousResponseSourcePath = sourcePath; + } + + persistPreviousResponseSnapshot( + state: PieceState, + movementName: string, + movementIteration: number, + content: string, + ): void { + const timestamp = MovementExecutor.buildTimestamp(); + const runPaths = this.deps.getRunPaths(); + const fileName = `${movementName}.${movementIteration}.${timestamp}.md`; + const sourcePath = this.writeSnapshot(content, runPaths.contextPreviousResponsesRel, fileName); + this.writeSnapshot(content, runPaths.contextPreviousResponsesRel, 'latest.md'); + state.previousResponseSourcePath = sourcePath; + } + /** Build Phase 1 instruction from template */ buildInstruction( step: PieceMovement, @@ -60,6 +133,19 @@ export class MovementExecutor { task: string, maxIterations: number, ): string { + this.ensurePreviousResponseSnapshot(state, step.name, movementIteration); + const policySnapshot = this.writeFacetSnapshot( + 'policy', + step.name, + movementIteration, + step.policyContents, + ); + const knowledgeSnapshot = this.writeFacetSnapshot( + 'knowledge', + step.name, + movementIteration, + step.knowledgeContents, + ); const pieceMovements = this.deps.getPieceMovements(); return new InstructionBuilder(step, { task, @@ -78,8 +164,11 @@ export class MovementExecutor { pieceName: this.deps.getPieceName(), pieceDescription: this.deps.getPieceDescription(), retryNote: this.deps.getRetryNote(), - policyContents: step.policyContents, - knowledgeContents: step.knowledgeContents, + policyContents: policySnapshot?.content ?? step.policyContents, + policySourcePath: policySnapshot?.sourcePath, + knowledgeContents: knowledgeSnapshot?.content ?? step.knowledgeContents, + knowledgeSourcePath: knowledgeSnapshot?.sourcePath, + previousResponseSourcePath: state.previousResponseSourcePath, }).build(); } @@ -144,6 +233,7 @@ export class MovementExecutor { state.movementOutputs.set(step.name, response); state.lastOutput = response; + this.persistPreviousResponseSnapshot(state, step.name, movementIteration, response.content); this.emitMovementReports(step); return { response, instruction }; } diff --git a/src/core/piece/engine/ParallelRunner.ts b/src/core/piece/engine/ParallelRunner.ts index de0ca707..62e3c75d 100644 --- a/src/core/piece/engine/ParallelRunner.ts +++ b/src/core/piece/engine/ParallelRunner.ts @@ -192,6 +192,12 @@ export class ParallelRunner { state.movementOutputs.set(step.name, aggregatedResponse); state.lastOutput = aggregatedResponse; + this.deps.movementExecutor.persistPreviousResponseSnapshot( + state, + step.name, + movementIteration, + aggregatedResponse.content, + ); this.deps.movementExecutor.emitMovementReports(step); return { response: aggregatedResponse, instruction: aggregatedInstruction }; } diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index 648c6722..b47831ac 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -8,7 +8,6 @@ import { EventEmitter } from 'node:events'; import { mkdirSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; import type { PieceConfig, PieceState, @@ -32,6 +31,7 @@ import { OptionsBuilder } from './OptionsBuilder.js'; import { MovementExecutor } from './MovementExecutor.js'; import { ParallelRunner } from './ParallelRunner.js'; import { ArpeggioRunner } from './ArpeggioRunner.js'; +import { buildRunPaths, type RunPaths } from '../run/run-paths.js'; const log = createLogger('engine'); @@ -56,6 +56,7 @@ export class PieceEngine extends EventEmitter { private loopDetector: LoopDetector; private cycleDetector: CycleDetector; private reportDir: string; + private runPaths: RunPaths; private abortRequested = false; private readonly optionsBuilder: OptionsBuilder; @@ -83,8 +84,9 @@ export class PieceEngine extends EventEmitter { throw new Error(`Invalid reportDirName: ${options.reportDirName}`); } const reportDirName = options.reportDirName ?? generateReportDir(task); - this.reportDir = `.takt/reports/${reportDirName}`; - this.ensureReportDirExists(); + this.runPaths = buildRunPaths(this.cwd, reportDirName); + this.reportDir = this.runPaths.reportsRel; + this.ensureRunDirsExist(); this.validateConfig(); this.state = createInitialState(config, options); this.detectRuleIndex = options.detectRuleIndex ?? (() => { @@ -112,6 +114,7 @@ export class PieceEngine extends EventEmitter { getCwd: () => this.cwd, getProjectCwd: () => this.projectCwd, getReportDir: () => this.reportDir, + getRunPaths: () => this.runPaths, getLanguage: () => this.options.language, getInteractive: () => this.options.interactive === true, getPieceMovements: () => this.config.movements.map(s => ({ name: s.name, description: s.description })), @@ -147,6 +150,7 @@ export class PieceEngine extends EventEmitter { this.arpeggioRunner = new ArpeggioRunner({ optionsBuilder: this.optionsBuilder, + movementExecutor: this.movementExecutor, getCwd: () => this.cwd, getInteractive: () => this.options.interactive === true, detectRuleIndex: this.detectRuleIndex, @@ -175,11 +179,21 @@ export class PieceEngine extends EventEmitter { } } - /** Ensure report directory exists (in cwd, which is clone dir in worktree mode) */ - private ensureReportDirExists(): void { - const reportDirPath = join(this.cwd, this.reportDir); - if (!existsSync(reportDirPath)) { - mkdirSync(reportDirPath, { recursive: true }); + /** Ensure run directories exist (in cwd, which is clone dir in worktree mode) */ + private ensureRunDirsExist(): void { + const requiredDirs = [ + this.runPaths.runRootAbs, + this.runPaths.reportsAbs, + this.runPaths.contextAbs, + this.runPaths.contextKnowledgeAbs, + this.runPaths.contextPolicyAbs, + this.runPaths.contextPreviousResponsesAbs, + this.runPaths.logsAbs, + ]; + for (const dir of requiredDirs) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } } } diff --git a/src/core/piece/engine/state-manager.ts b/src/core/piece/engine/state-manager.ts index 2d23aafb..fc593557 100644 --- a/src/core/piece/engine/state-manager.ts +++ b/src/core/piece/engine/state-manager.ts @@ -40,6 +40,7 @@ export class StateManager { iteration: 0, movementOutputs: new Map(), lastOutput: undefined, + previousResponseSourcePath: undefined, userInputs, personaSessions, movementIterations: new Map(), diff --git a/src/core/piece/instruction/InstructionBuilder.ts b/src/core/piece/instruction/InstructionBuilder.ts index 0e73c00f..27ce3761 100644 --- a/src/core/piece/instruction/InstructionBuilder.ts +++ b/src/core/piece/instruction/InstructionBuilder.ts @@ -11,6 +11,72 @@ import { buildEditRule } from './instruction-context.js'; import { escapeTemplateChars, replaceTemplatePlaceholders } from './escape.js'; import { loadTemplate } from '../../../shared/prompts/index.js'; +const CONTEXT_MAX_CHARS = 2000; + +interface PreparedContextBlock { + readonly content: string; + readonly truncated: boolean; +} + +function trimContextContent(content: string): PreparedContextBlock { + if (content.length <= CONTEXT_MAX_CHARS) { + return { content, truncated: false }; + } + return { + content: `${content.slice(0, CONTEXT_MAX_CHARS)}\n...TRUNCATED...`, + truncated: true, + }; +} + +function renderConflictNotice(): string { + return 'If prompt content conflicts with source files, source files take precedence.'; +} + +function prepareKnowledgeContent(content: string, sourcePath?: string): string { + const prepared = trimContextContent(content); + const lines: string[] = [prepared.content]; + if (prepared.truncated && sourcePath) { + lines.push( + '', + `Knowledge is truncated. You MUST consult the source files before making decisions. Source: ${sourcePath}`, + ); + } + if (sourcePath) { + lines.push('', `Knowledge Source: ${sourcePath}`); + } + lines.push('', renderConflictNotice()); + return lines.join('\n'); +} + +function preparePolicyContent(content: string, sourcePath?: string): string { + const prepared = trimContextContent(content); + const lines: string[] = [prepared.content]; + if (prepared.truncated && sourcePath) { + lines.push( + '', + `Policy is authoritative. If truncated, you MUST read the full policy file and follow it strictly. Source: ${sourcePath}`, + ); + } + if (sourcePath) { + lines.push('', `Policy Source: ${sourcePath}`); + } + lines.push('', renderConflictNotice()); + return lines.join('\n'); +} + +function preparePreviousResponseContent(content: string, sourcePath?: string): string { + const prepared = trimContextContent(content); + const lines: string[] = [prepared.content]; + if (prepared.truncated && sourcePath) { + lines.push('', `Previous Response is truncated. Source: ${sourcePath}`); + } + if (sourcePath) { + lines.push('', `Source: ${sourcePath}`); + } + lines.push('', renderConflictNotice()); + return lines.join('\n'); +} + /** * Check if an output contract entry is the item form (OutputContractItem). */ @@ -72,8 +138,14 @@ export class InstructionBuilder { this.context.previousOutput && !hasPreviousResponsePlaceholder ); - const previousResponse = hasPreviousResponse && this.context.previousOutput - ? escapeTemplateChars(this.context.previousOutput.content) + const previousResponsePrepared = this.step.passPreviousResponse && this.context.previousOutput + ? preparePreviousResponseContent( + this.context.previousOutput.content, + this.context.previousResponseSourcePath, + ) + : ''; + const previousResponse = hasPreviousResponse + ? escapeTemplateChars(previousResponsePrepared) : ''; // User Inputs @@ -86,7 +158,10 @@ export class InstructionBuilder { const instructions = replaceTemplatePlaceholders( this.step.instructionTemplate, this.step, - this.context, + { + ...this.context, + previousResponseText: previousResponsePrepared || undefined, + }, ); // Piece name and description @@ -101,12 +176,18 @@ export class InstructionBuilder { // Policy injection (top + bottom reminder per "Lost in the Middle" research) const policyContents = this.context.policyContents ?? this.step.policyContents; const hasPolicy = !!(policyContents && policyContents.length > 0); - const policyContent = hasPolicy ? policyContents!.join('\n\n---\n\n') : ''; + const policyJoined = hasPolicy ? policyContents!.join('\n\n---\n\n') : ''; + const policyContent = hasPolicy + ? preparePolicyContent(policyJoined, this.context.policySourcePath) + : ''; // Knowledge injection (domain-specific knowledge, no reminder needed) const knowledgeContents = this.context.knowledgeContents ?? this.step.knowledgeContents; const hasKnowledge = !!(knowledgeContents && knowledgeContents.length > 0); - const knowledgeContent = hasKnowledge ? knowledgeContents!.join('\n\n---\n\n') : ''; + const knowledgeJoined = hasKnowledge ? knowledgeContents!.join('\n\n---\n\n') : ''; + const knowledgeContent = hasKnowledge + ? prepareKnowledgeContent(knowledgeJoined, this.context.knowledgeSourcePath) + : ''; // Quality gates injection (AI directives for movement completion) const hasQualityGates = !!(this.step.qualityGates && this.step.qualityGates.length > 0); diff --git a/src/core/piece/instruction/escape.ts b/src/core/piece/instruction/escape.ts index fbf67186..4a2b7df6 100644 --- a/src/core/piece/instruction/escape.ts +++ b/src/core/piece/instruction/escape.ts @@ -37,7 +37,12 @@ export function replaceTemplatePlaceholders( // Replace {previous_response} if (step.passPreviousResponse) { - if (context.previousOutput) { + if (context.previousResponseText !== undefined) { + result = result.replace( + /\{previous_response\}/g, + escapeTemplateChars(context.previousResponseText), + ); + } else if (context.previousOutput) { result = result.replace( /\{previous_response\}/g, escapeTemplateChars(context.previousOutput.content), diff --git a/src/core/piece/instruction/instruction-context.ts b/src/core/piece/instruction/instruction-context.ts index 0814c94f..739d91b6 100644 --- a/src/core/piece/instruction/instruction-context.ts +++ b/src/core/piece/instruction/instruction-context.ts @@ -26,6 +26,10 @@ export interface InstructionContext { userInputs: string[]; /** Previous movement output if available */ previousOutput?: AgentResponse; + /** Source path for previous response snapshot */ + previousResponseSourcePath?: string; + /** Preprocessed previous response text for template placeholder replacement */ + previousResponseText?: string; /** Report directory path */ reportDir?: string; /** Language for metadata rendering. Defaults to 'en'. */ @@ -44,8 +48,12 @@ export interface InstructionContext { retryNote?: string; /** Resolved policy content strings for injection into instruction */ policyContents?: string[]; + /** Source path for policy snapshot */ + policySourcePath?: string; /** Resolved knowledge content strings for injection into instruction */ knowledgeContents?: string[]; + /** Source path for knowledge snapshot */ + knowledgeSourcePath?: string; } /** diff --git a/src/core/piece/run/run-paths.ts b/src/core/piece/run/run-paths.ts new file mode 100644 index 00000000..b8e5ac21 --- /dev/null +++ b/src/core/piece/run/run-paths.ts @@ -0,0 +1,52 @@ +import { join } from 'node:path'; + +export interface RunPaths { + readonly slug: string; + readonly runRootRel: string; + readonly reportsRel: string; + readonly contextRel: string; + readonly contextKnowledgeRel: string; + readonly contextPolicyRel: string; + readonly contextPreviousResponsesRel: string; + readonly logsRel: string; + readonly metaRel: string; + readonly runRootAbs: string; + readonly reportsAbs: string; + readonly contextAbs: string; + readonly contextKnowledgeAbs: string; + readonly contextPolicyAbs: string; + readonly contextPreviousResponsesAbs: string; + readonly logsAbs: string; + readonly metaAbs: string; +} + +export function buildRunPaths(cwd: string, slug: string): RunPaths { + const runRootRel = `.takt/runs/${slug}`; + const reportsRel = `${runRootRel}/reports`; + const contextRel = `${runRootRel}/context`; + const contextKnowledgeRel = `${contextRel}/knowledge`; + const contextPolicyRel = `${contextRel}/policy`; + const contextPreviousResponsesRel = `${contextRel}/previous_responses`; + const logsRel = `${runRootRel}/logs`; + const metaRel = `${runRootRel}/meta.json`; + + return { + slug, + runRootRel, + reportsRel, + contextRel, + contextKnowledgeRel, + contextPolicyRel, + contextPreviousResponsesRel, + logsRel, + metaRel, + runRootAbs: join(cwd, runRootRel), + reportsAbs: join(cwd, reportsRel), + contextAbs: join(cwd, contextRel), + contextKnowledgeAbs: join(cwd, contextKnowledgeRel), + contextPolicyAbs: join(cwd, contextPolicyRel), + contextPreviousResponsesAbs: join(cwd, contextPreviousResponsesRel), + logsAbs: join(cwd, logsRel), + metaAbs: join(cwd, metaRel), + }; +} diff --git a/src/features/prompt/preview.ts b/src/features/prompt/preview.ts index 5d62a5b1..42f64ef6 100644 --- a/src/features/prompt/preview.ts +++ b/src/features/prompt/preview.ts @@ -55,7 +55,7 @@ export async function previewPrompts(cwd: string, pieceIdentifier?: string): Pro userInputs: [], pieceMovements: config.movements, currentMovementIndex: i, - reportDir: movement.outputContracts && movement.outputContracts.length > 0 ? '.takt/reports/preview' : undefined, + reportDir: movement.outputContracts && movement.outputContracts.length > 0 ? '.takt/runs/preview/reports' : undefined, language, }; @@ -67,7 +67,7 @@ export async function previewPrompts(cwd: string, pieceIdentifier?: string): Pro if (movement.outputContracts && movement.outputContracts.length > 0) { const reportBuilder = new ReportInstructionBuilder(movement, { cwd, - reportDir: '.takt/reports/preview', + reportDir: '.takt/runs/preview/reports', movementIteration: 1, language, }); diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 6f966a94..a7c756f3 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -35,7 +35,6 @@ import { generateSessionId, createSessionLog, finalizeSessionLog, - updateLatestPointer, initNdjsonLog, appendNdjsonLine, type NdjsonStepStart, @@ -55,11 +54,15 @@ import { playWarningSound, isDebugEnabled, writePromptLog, + generateReportDir, + isValidReportDirName, } from '../../../shared/utils/index.js'; import type { PromptLogRecord } from '../../../shared/utils/index.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { getLabel } from '../../../shared/i18n/index.js'; import { installSigIntHandler } from './sigintHandler.js'; +import { buildRunPaths } from '../../../core/piece/run/run-paths.js'; +import { writeFileAtomic, ensureDir } from '../../../infra/config/index.js'; const log = createLogger('piece'); @@ -78,6 +81,20 @@ interface OutputFns { logLine: (text: string) => void; } +interface RunMeta { + task: string; + piece: string; + runSlug: string; + runRoot: string; + reportDirectory: string; + contextDirectory: string; + logsDirectory: string; + status: 'running' | 'completed' | 'aborted'; + startTime: string; + endTime?: string; + iterations?: number; +} + function assertTaskPrefixPair( taskPrefix: string | undefined, taskColorIndex: number | undefined @@ -206,11 +223,42 @@ export async function executePiece( out.header(`${headerPrefix} ${pieceConfig.name}`); const pieceSessionId = generateSessionId(); + const runSlug = options.reportDirName ?? generateReportDir(task); + if (!isValidReportDirName(runSlug)) { + throw new Error(`Invalid reportDirName: ${runSlug}`); + } + const runPaths = buildRunPaths(cwd, runSlug); + + const runMeta: RunMeta = { + task, + piece: pieceConfig.name, + runSlug: runPaths.slug, + runRoot: runPaths.runRootRel, + reportDirectory: runPaths.reportsRel, + contextDirectory: runPaths.contextRel, + logsDirectory: runPaths.logsRel, + status: 'running', + startTime: new Date().toISOString(), + }; + ensureDir(runPaths.runRootAbs); + writeFileAtomic(runPaths.metaAbs, JSON.stringify(runMeta, null, 2)); + let isMetaFinalized = false; + const finalizeRunMeta = (status: 'completed' | 'aborted', iterations?: number): void => { + writeFileAtomic(runPaths.metaAbs, JSON.stringify({ + ...runMeta, + status, + endTime: new Date().toISOString(), + ...(iterations != null ? { iterations } : {}), + } satisfies RunMeta, null, 2)); + isMetaFinalized = true; + }; + let sessionLog = createSessionLog(task, projectCwd, pieceConfig.name); - // Initialize NDJSON log file + pointer at piece start - const ndjsonLogPath = initNdjsonLog(pieceSessionId, task, pieceConfig.name, projectCwd); - updateLatestPointer(sessionLog, pieceSessionId, projectCwd, { copyToPrevious: true }); + // Initialize NDJSON log file at run-scoped logs directory + const ndjsonLogPath = initNdjsonLog(pieceSessionId, task, pieceConfig.name, { + logsDir: runPaths.logsAbs, + }); // Write interactive mode records if interactive mode was used before this piece if (options.interactiveMetadata) { @@ -330,36 +378,41 @@ export async function executePiece( } : undefined; - const engine = new PieceEngine(pieceConfig, cwd, task, { - abortSignal: options.abortSignal, - onStream: streamHandler, - onUserInput, - initialSessions: savedSessions, - onSessionUpdate: sessionUpdateHandler, - onIterationLimit: iterationLimitHandler, - projectCwd, - language: options.language, - provider: options.provider, - model: options.model, - personaProviders: options.personaProviders, - interactive: interactiveUserInput, - detectRuleIndex, - callAiJudge, - startMovement: options.startMovement, - retryNote: options.retryNote, - reportDirName: options.reportDirName, - taskPrefix: options.taskPrefix, - taskColorIndex: options.taskColorIndex, - }); - let abortReason: string | undefined; let lastMovementContent: string | undefined; let lastMovementName: string | undefined; let currentIteration = 0; const phasePrompts = new Map(); const movementIterations = new Map(); + let engine: PieceEngine | null = null; + let onAbortSignal: (() => void) | undefined; + let sigintCleanup: (() => void) | undefined; + let onEpipe: ((err: NodeJS.ErrnoException) => void) | undefined; - engine.on('phase:start', (step, phase, phaseName, instruction) => { + try { + engine = new PieceEngine(pieceConfig, cwd, task, { + abortSignal: options.abortSignal, + onStream: streamHandler, + onUserInput, + initialSessions: savedSessions, + onSessionUpdate: sessionUpdateHandler, + onIterationLimit: iterationLimitHandler, + projectCwd, + language: options.language, + provider: options.provider, + model: options.model, + personaProviders: options.personaProviders, + interactive: interactiveUserInput, + detectRuleIndex, + callAiJudge, + startMovement: options.startMovement, + retryNote: options.retryNote, + reportDirName: runSlug, + taskPrefix: options.taskPrefix, + taskColorIndex: options.taskColorIndex, + }); + + engine.on('phase:start', (step, phase, phaseName, instruction) => { log.debug('Phase starting', { step: step.name, phase, phaseName }); const record: NdjsonPhaseStart = { type: 'phase_start', @@ -376,7 +429,7 @@ export async function executePiece( } }); - engine.on('phase:complete', (step, phase, phaseName, content, phaseStatus, phaseError) => { + engine.on('phase:complete', (step, phase, phaseName, content, phaseStatus, phaseError) => { log.debug('Phase completed', { step: step.name, phase, phaseName, status: phaseStatus }); const record: NdjsonPhaseComplete = { type: 'phase_complete', @@ -409,7 +462,7 @@ export async function executePiece( } }); - engine.on('movement:start', (step, iteration, instruction) => { + engine.on('movement:start', (step, iteration, instruction) => { log.debug('Movement starting', { step: step.name, persona: step.personaDisplayName, iteration }); currentIteration = iteration; const movementIteration = (movementIterations.get(step.name) ?? 0) + 1; @@ -457,7 +510,7 @@ export async function executePiece( }); - engine.on('movement:complete', (step, response, instruction) => { + engine.on('movement:complete', (step, response, instruction) => { log.debug('Movement completed', { step: step.name, status: response.status, @@ -516,16 +569,15 @@ export async function executePiece( // Update in-memory log for pointer metadata (immutable) sessionLog = { ...sessionLog, iterations: sessionLog.iterations + 1 }; - updateLatestPointer(sessionLog, pieceSessionId, projectCwd); }); - engine.on('movement:report', (_step, filePath, fileName) => { + engine.on('movement:report', (_step, filePath, fileName) => { const content = readFileSync(filePath, 'utf-8'); out.logLine(`\n📄 Report: ${fileName}\n`); out.logLine(content); }); - engine.on('piece:complete', (state) => { + engine.on('piece:complete', (state) => { log.info('Piece completed successfully', { iterations: state.iteration }); sessionLog = finalizeSessionLog(sessionLog, 'completed'); @@ -536,7 +588,7 @@ export async function executePiece( endTime: new Date().toISOString(), }; appendNdjsonLine(ndjsonLogPath, record); - updateLatestPointer(sessionLog, pieceSessionId, projectCwd); + finalizeRunMeta('completed', state.iteration); // Save session state for next interactive mode try { @@ -565,7 +617,7 @@ export async function executePiece( } }); - engine.on('piece:abort', (state, reason) => { + engine.on('piece:abort', (state, reason) => { interruptAllQueries(); log.error('Piece aborted', { reason, iterations: state.iteration }); if (displayRef.current) { @@ -584,7 +636,7 @@ export async function executePiece( endTime: new Date().toISOString(), }; appendNdjsonLine(ndjsonLogPath, record); - updateLatestPointer(sessionLog, pieceSessionId, projectCwd); + finalizeRunMeta('aborted', state.iteration); // Save session state for next interactive mode try { @@ -613,36 +665,34 @@ export async function executePiece( } }); - // Suppress EPIPE errors from SDK child process stdin after interrupt. - // When interruptAllQueries() kills the child process, the SDK may still - // try to write to the dead process's stdin pipe, causing an unhandled - // EPIPE error on the Socket. This handler catches it gracefully. - const onEpipe = (err: NodeJS.ErrnoException) => { - if (err.code === 'EPIPE') return; - throw err; - }; - - const abortEngine = () => { - process.on('uncaughtException', onEpipe); - interruptAllQueries(); - engine.abort(); - }; - - // SIGINT handling: when abortSignal is provided (parallel mode), delegate to caller - const useExternalAbort = Boolean(options.abortSignal); + // Suppress EPIPE errors from SDK child process stdin after interrupt. + // When interruptAllQueries() kills the child process, the SDK may still + // try to write to the dead process's stdin pipe, causing an unhandled + // EPIPE error on the Socket. This handler catches it gracefully. + onEpipe = (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') return; + throw err; + }; - let onAbortSignal: (() => void) | undefined; - let sigintCleanup: (() => void) | undefined; + const abortEngine = () => { + if (!engine || !onEpipe) { + throw new Error('Abort handler invoked before PieceEngine initialization'); + } + process.on('uncaughtException', onEpipe); + interruptAllQueries(); + engine.abort(); + }; - if (useExternalAbort) { - onAbortSignal = abortEngine; - options.abortSignal!.addEventListener('abort', onAbortSignal, { once: true }); - } else { - const handler = installSigIntHandler(abortEngine); - sigintCleanup = handler.cleanup; - } + // SIGINT handling: when abortSignal is provided (parallel mode), delegate to caller + const useExternalAbort = Boolean(options.abortSignal); + if (useExternalAbort) { + onAbortSignal = abortEngine; + options.abortSignal!.addEventListener('abort', onAbortSignal, { once: true }); + } else { + const handler = installSigIntHandler(abortEngine); + sigintCleanup = handler.cleanup; + } - try { const finalState = await engine.run(); return { @@ -651,12 +701,19 @@ export async function executePiece( lastMovement: lastMovementName, lastMessage: lastMovementContent, }; + } catch (error) { + if (!isMetaFinalized) { + finalizeRunMeta('aborted'); + } + throw error; } finally { prefixWriter?.flush(); sigintCleanup?.(); if (onAbortSignal && options.abortSignal) { options.abortSignal.removeEventListener('abort', onAbortSignal); } - process.removeListener('uncaughtException', onEpipe); + if (onEpipe) { + process.removeListener('uncaughtException', onEpipe); + } } } diff --git a/src/infra/fs/index.ts b/src/infra/fs/index.ts index ee350db0..1b143ae2 100644 --- a/src/infra/fs/index.ts +++ b/src/infra/fs/index.ts @@ -14,7 +14,6 @@ export type { NdjsonInteractiveStart, NdjsonInteractiveEnd, NdjsonRecord, - LatestLogPointer, } from './session.js'; export { @@ -28,5 +27,4 @@ export { finalizeSessionLog, loadSessionLog, loadProjectContext, - updateLatestPointer, } from './session.js'; diff --git a/src/infra/fs/session.ts b/src/infra/fs/session.ts index ca1ed833..2e4660ed 100644 --- a/src/infra/fs/session.ts +++ b/src/infra/fs/session.ts @@ -2,15 +2,14 @@ * Session management utilities */ -import { existsSync, readFileSync, copyFileSync, appendFileSync } from 'node:fs'; +import { existsSync, readFileSync, appendFileSync } from 'node:fs'; import { join } from 'node:path'; -import { getProjectLogsDir, getGlobalLogsDir, ensureDir, writeFileAtomic } from '../config/index.js'; +import { ensureDir } from '../config/index.js'; import { generateReportDir as buildReportDir } from '../../shared/utils/index.js'; import type { SessionLog, NdjsonRecord, NdjsonPieceStart, - LatestLogPointer, } from '../../shared/utils/index.js'; export type { @@ -25,7 +24,6 @@ export type { NdjsonInteractiveStart, NdjsonInteractiveEnd, NdjsonRecord, - LatestLogPointer, } from '../../shared/utils/index.js'; /** Failure information extracted from session log */ @@ -44,7 +42,7 @@ export interface FailureInfo { /** * Manages session lifecycle: ID generation, NDJSON logging, - * session log creation/loading, and latest pointer maintenance. + * and session log creation/loading. */ export class SessionManager { /** Append a single NDJSON line to a log file */ @@ -58,11 +56,9 @@ export class SessionManager { sessionId: string, task: string, pieceName: string, - projectDir?: string, + options: { logsDir: string }, ): string { - const logsDir = projectDir - ? getProjectLogsDir(projectDir) - : getGlobalLogsDir(); + const { logsDir } = options; ensureDir(logsDir); const filepath = join(logsDir, `${sessionId}.jsonl`); @@ -218,38 +214,6 @@ export class SessionManager { return contextParts.join('\n\n---\n\n'); } - /** Update latest.json pointer file */ - updateLatestPointer( - log: SessionLog, - sessionId: string, - projectDir?: string, - options?: { copyToPrevious?: boolean }, - ): void { - const logsDir = projectDir - ? getProjectLogsDir(projectDir) - : getGlobalLogsDir(); - ensureDir(logsDir); - - const latestPath = join(logsDir, 'latest.json'); - const previousPath = join(logsDir, 'previous.json'); - - if (options?.copyToPrevious && existsSync(latestPath)) { - copyFileSync(latestPath, previousPath); - } - - const pointer: LatestLogPointer = { - sessionId, - logFile: `${sessionId}.jsonl`, - task: log.task, - pieceName: log.pieceName, - status: log.status, - startTime: log.startTime, - updatedAt: new Date().toISOString(), - iterations: log.iterations, - }; - - writeFileAtomic(latestPath, JSON.stringify(pointer, null, 2)); - } } const defaultManager = new SessionManager(); @@ -262,9 +226,9 @@ export function initNdjsonLog( sessionId: string, task: string, pieceName: string, - projectDir?: string, + options: { logsDir: string }, ): string { - return defaultManager.initNdjsonLog(sessionId, task, pieceName, projectDir); + return defaultManager.initNdjsonLog(sessionId, task, pieceName, options); } @@ -304,15 +268,6 @@ export function loadProjectContext(projectDir: string): string { return defaultManager.loadProjectContext(projectDir); } -export function updateLatestPointer( - log: SessionLog, - sessionId: string, - projectDir?: string, - options?: { copyToPrevious?: boolean }, -): void { - defaultManager.updateLatestPointer(log, sessionId, projectDir, options); -} - /** * Extract failure information from an NDJSON session log file. * diff --git a/src/shared/prompts/en/perform_phase1_message.md b/src/shared/prompts/en/perform_phase1_message.md index 121d124f..9a64a397 100644 --- a/src/shared/prompts/en/perform_phase1_message.md +++ b/src/shared/prompts/en/perform_phase1_message.md @@ -22,6 +22,7 @@ Note: This section is metadata. Follow the language used in the rest of the prom ## Knowledge The following knowledge is domain-specific information for this movement. Use it as reference. +Knowledge may be truncated. Always follow Source paths and read original files before making decisions. {{knowledgeContent}} {{/if}} @@ -72,6 +73,7 @@ Before completing this movement, ensure the following requirements are met: ## Policy The following policies are behavioral standards applied to this movement. You MUST comply with them. +Policy is authoritative. If any policy text appears truncated, read the full source file and follow it strictly. {{policyContent}} {{/if}} diff --git a/src/shared/prompts/ja/perform_phase1_message.md b/src/shared/prompts/ja/perform_phase1_message.md index 52bef048..47118404 100644 --- a/src/shared/prompts/ja/perform_phase1_message.md +++ b/src/shared/prompts/ja/perform_phase1_message.md @@ -21,6 +21,7 @@ ## Knowledge 以下のナレッジはこのムーブメントに適用されるドメイン固有の知識です。参考にしてください。 +Knowledge はトリミングされる場合があります。Source Path に従い、判断前に必ず元ファイルを確認してください。 {{knowledgeContent}} {{/if}} @@ -71,6 +72,7 @@ ## Policy 以下のポリシーはこのムーブメントに適用される行動規範です。必ず遵守してください。 +Policy は最優先です。トリミングされている場合は必ず Source Path の全文を確認して厳密に従ってください。 {{policyContent}} {{/if}} diff --git a/src/shared/utils/debug.ts b/src/shared/utils/debug.ts index c3f8b7cd..c64c1c49 100644 --- a/src/shared/utils/debug.ts +++ b/src/shared/utils/debug.ts @@ -43,7 +43,8 @@ export class DebugLogger { /** Get default debug log file prefix */ private static getDefaultLogPrefix(projectDir: string): string { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - return join(projectDir, '.takt', 'logs', `debug-${timestamp}`); + const runSlug = `debug-${timestamp}`; + return join(projectDir, '.takt', 'runs', runSlug, 'logs', runSlug); } /** Initialize debug logger from config */ diff --git a/src/shared/utils/types.ts b/src/shared/utils/types.ts index 3e4b24fb..2f33f52f 100644 --- a/src/shared/utils/types.ts +++ b/src/shared/utils/types.ts @@ -116,20 +116,6 @@ export type NdjsonRecord = | NdjsonInteractiveStart | NdjsonInteractiveEnd; -// --- Conversation log types --- - -/** Pointer metadata for latest/previous log files */ -export interface LatestLogPointer { - sessionId: string; - logFile: string; - task: string; - pieceName: string; - status: SessionLog['status']; - startTime: string; - updatedAt: string; - iterations: number; -} - /** Record for debug prompt/response log (debug-*-prompts.jsonl) */ export interface PromptLogRecord { movement: string; From b25e9a78ab7cef3c2e4d12fcabb750f94dfafea7 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:31:03 +0900 Subject: [PATCH 09/45] =?UTF-8?q?fix:=20callAiJudge=E3=82=92=E3=83=97?= =?UTF-8?q?=E3=83=AD=E3=83=90=E3=82=A4=E3=83=80=E3=83=BC=E3=82=B7=E3=82=B9?= =?UTF-8?q?=E3=83=86=E3=83=A0=E7=B5=8C=E7=94=B1=E3=81=AB=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=EF=BC=88Codex=E5=AF=BE=E5=BF=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit callAiJudgeがinfra/claude/にハードコードされており、Codexプロバイダー使用時に judge評価が動作しなかった。agents/ai-judge.tsに移動し、runAgent経由で プロバイダーを正しく解決するように修正。 --- package.json | 2 +- src/__tests__/ai-judge.test.ts | 2 +- src/__tests__/it-error-recovery.test.ts | 7 +- src/__tests__/it-notification-sound.test.ts | 5 +- src/__tests__/it-piece-execution.test.ts | 9 +-- src/__tests__/it-piece-patterns.test.ts | 7 +- src/__tests__/it-pipeline-modes.test.ts | 4 +- src/__tests__/it-pipeline.test.ts | 6 +- src/__tests__/it-sigint-interrupt.test.ts | 5 +- .../it-three-phase-execution.test.ts | 7 +- .../pieceExecution-debug-prompts.test.ts | 5 +- src/__tests__/runAllTasks-concurrency.test.ts | 5 +- src/agents/ai-judge.ts | 67 +++++++++++++++++ src/agents/index.ts | 1 + src/features/tasks/execute/pieceExecution.ts | 3 +- src/index.ts | 3 - src/infra/claude/client.ts | 72 ------------------- src/infra/claude/index.ts | 3 - 18 files changed, 110 insertions(+), 103 deletions(-) create mode 100644 src/agents/ai-judge.ts diff --git a/package.json b/package.json index f414b82c..a9b37634 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "takt", - "version": "0.11.0", + "version": "0.11.1", "description": "TAKT: Task Agent Koordination Tool - AI Agent Piece Orchestration", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/__tests__/ai-judge.test.ts b/src/__tests__/ai-judge.test.ts index 19ed04bc..1ae7cf18 100644 --- a/src/__tests__/ai-judge.test.ts +++ b/src/__tests__/ai-judge.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect } from 'vitest'; -import { detectJudgeIndex, buildJudgePrompt } from '../infra/claude/client.js'; +import { detectJudgeIndex, buildJudgePrompt } from '../agents/ai-judge.js'; describe('detectJudgeIndex', () => { it('should detect [JUDGE:1] as index 0', () => { diff --git a/src/__tests__/it-error-recovery.test.ts b/src/__tests__/it-error-recovery.test.ts index 1ab9fc18..5b49af33 100644 --- a/src/__tests__/it-error-recovery.test.ts +++ b/src/__tests__/it-error-recovery.test.ts @@ -14,12 +14,13 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { setMockScenario, resetScenario } from '../infra/mock/index.js'; import type { PieceConfig, PieceMovement, PieceRule } from '../core/models/index.js'; -import { callAiJudge, detectRuleIndex } from '../infra/claude/index.js'; +import { detectRuleIndex } from '../infra/claude/index.js'; +import { callAiJudge } from '../agents/ai-judge.js'; // --- Mocks --- -vi.mock('../infra/claude/client.js', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('../agents/ai-judge.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, callAiJudge: vi.fn().mockResolvedValue(-1), diff --git a/src/__tests__/it-notification-sound.test.ts b/src/__tests__/it-notification-sound.test.ts index 4632c560..3ba80d24 100644 --- a/src/__tests__/it-notification-sound.test.ts +++ b/src/__tests__/it-notification-sound.test.ts @@ -105,11 +105,14 @@ vi.mock('../core/piece/index.js', () => ({ })); vi.mock('../infra/claude/index.js', () => ({ - callAiJudge: vi.fn(), detectRuleIndex: vi.fn(), interruptAllQueries: mockInterruptAllQueries, })); +vi.mock('../agents/ai-judge.js', () => ({ + callAiJudge: vi.fn(), +})); + vi.mock('../infra/config/index.js', () => ({ loadPersonaSessions: vi.fn().mockReturnValue({}), updatePersonaSession: vi.fn(), diff --git a/src/__tests__/it-piece-execution.test.ts b/src/__tests__/it-piece-execution.test.ts index cf1b3021..a836d505 100644 --- a/src/__tests__/it-piece-execution.test.ts +++ b/src/__tests__/it-piece-execution.test.ts @@ -15,15 +15,16 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { setMockScenario, resetScenario } from '../infra/mock/index.js'; import type { PieceConfig, PieceMovement, PieceRule } from '../core/models/index.js'; -import { callAiJudge, detectRuleIndex } from '../infra/claude/index.js'; +import { detectRuleIndex } from '../infra/claude/index.js'; +import { callAiJudge } from '../agents/ai-judge.js'; // --- Mocks (minimal — only infrastructure, not core logic) --- -// Safety net: prevent callAiJudge from calling real Claude CLI. +// Safety net: prevent callAiJudge from calling real agent. // Tag-based detection should always match in these tests; if it doesn't, // this mock surfaces the failure immediately instead of timing out. -vi.mock('../infra/claude/client.js', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('../agents/ai-judge.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, callAiJudge: vi.fn().mockResolvedValue(-1), diff --git a/src/__tests__/it-piece-patterns.test.ts b/src/__tests__/it-piece-patterns.test.ts index 15d0cf12..4ea6d597 100644 --- a/src/__tests__/it-piece-patterns.test.ts +++ b/src/__tests__/it-piece-patterns.test.ts @@ -13,12 +13,13 @@ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { setMockScenario, resetScenario } from '../infra/mock/index.js'; -import { callAiJudge, detectRuleIndex } from '../infra/claude/index.js'; +import { detectRuleIndex } from '../infra/claude/index.js'; +import { callAiJudge } from '../agents/ai-judge.js'; // --- Mocks --- -vi.mock('../infra/claude/client.js', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('../agents/ai-judge.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, callAiJudge: vi.fn().mockImplementation(async (content: string, conditions: { index: number; text: string }[]) => { diff --git a/src/__tests__/it-pipeline-modes.test.ts b/src/__tests__/it-pipeline-modes.test.ts index c01cc8f8..c2712625 100644 --- a/src/__tests__/it-pipeline-modes.test.ts +++ b/src/__tests__/it-pipeline-modes.test.ts @@ -31,8 +31,8 @@ const { mockPushBranch: vi.fn(), })); -vi.mock('../infra/claude/client.js', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('../agents/ai-judge.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, callAiJudge: vi.fn().mockImplementation(async (content: string, conditions: { index: number; text: string }[]) => { diff --git a/src/__tests__/it-pipeline.test.ts b/src/__tests__/it-pipeline.test.ts index 65c3899c..ddac9a44 100644 --- a/src/__tests__/it-pipeline.test.ts +++ b/src/__tests__/it-pipeline.test.ts @@ -16,9 +16,9 @@ import { setMockScenario, resetScenario } from '../infra/mock/index.js'; // --- Mocks --- -// Safety net: prevent callAiJudge from calling real Claude CLI. -vi.mock('../infra/claude/client.js', async (importOriginal) => { - const original = await importOriginal(); +// Safety net: prevent callAiJudge from calling real agent. +vi.mock('../agents/ai-judge.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, callAiJudge: vi.fn().mockImplementation(async (content: string, conditions: { index: number; text: string }[]) => { diff --git a/src/__tests__/it-sigint-interrupt.test.ts b/src/__tests__/it-sigint-interrupt.test.ts index 6eb31c57..a8652b10 100644 --- a/src/__tests__/it-sigint-interrupt.test.ts +++ b/src/__tests__/it-sigint-interrupt.test.ts @@ -71,11 +71,14 @@ vi.mock('../core/piece/index.js', () => ({ })); vi.mock('../infra/claude/index.js', () => ({ - callAiJudge: vi.fn(), detectRuleIndex: vi.fn(), interruptAllQueries: mockInterruptAllQueries, })); +vi.mock('../agents/ai-judge.js', () => ({ + callAiJudge: vi.fn(), +})); + vi.mock('../infra/config/index.js', () => ({ loadPersonaSessions: vi.fn().mockReturnValue({}), updatePersonaSession: vi.fn(), diff --git a/src/__tests__/it-three-phase-execution.test.ts b/src/__tests__/it-three-phase-execution.test.ts index 40db6c62..c87380b3 100644 --- a/src/__tests__/it-three-phase-execution.test.ts +++ b/src/__tests__/it-three-phase-execution.test.ts @@ -15,12 +15,13 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { setMockScenario, resetScenario } from '../infra/mock/index.js'; import type { PieceConfig, PieceMovement, PieceRule } from '../core/models/index.js'; -import { callAiJudge, detectRuleIndex } from '../infra/claude/index.js'; +import { detectRuleIndex } from '../infra/claude/index.js'; +import { callAiJudge } from '../agents/ai-judge.js'; // --- Mocks --- -vi.mock('../infra/claude/client.js', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('../agents/ai-judge.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, callAiJudge: vi.fn().mockResolvedValue(-1), diff --git a/src/__tests__/pieceExecution-debug-prompts.test.ts b/src/__tests__/pieceExecution-debug-prompts.test.ts index 281c86e8..8a84df1b 100644 --- a/src/__tests__/pieceExecution-debug-prompts.test.ts +++ b/src/__tests__/pieceExecution-debug-prompts.test.ts @@ -78,11 +78,14 @@ vi.mock('../core/piece/index.js', () => ({ })); vi.mock('../infra/claude/index.js', () => ({ - callAiJudge: vi.fn(), detectRuleIndex: vi.fn(), interruptAllQueries: vi.fn(), })); +vi.mock('../agents/ai-judge.js', () => ({ + callAiJudge: vi.fn(), +})); + vi.mock('../infra/config/index.js', () => ({ loadPersonaSessions: vi.fn().mockReturnValue({}), updatePersonaSession: vi.fn(), diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 677e7866..e7ec26c9 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -98,10 +98,13 @@ vi.mock('../infra/github/index.js', () => ({ vi.mock('../infra/claude/index.js', () => ({ interruptAllQueries: vi.fn(), - callAiJudge: vi.fn(), detectRuleIndex: vi.fn(), })); +vi.mock('../agents/ai-judge.js', () => ({ + callAiJudge: vi.fn(), +})); + vi.mock('../shared/exitCodes.js', () => ({ EXIT_SIGINT: 130, })); diff --git a/src/agents/ai-judge.ts b/src/agents/ai-judge.ts new file mode 100644 index 00000000..1adc4398 --- /dev/null +++ b/src/agents/ai-judge.ts @@ -0,0 +1,67 @@ +/** + * AI judge - provider-aware rule condition evaluator + * + * Evaluates agent output against ai() conditions using the configured provider. + * Uses runAgent (which resolves provider from config) instead of hardcoded Claude. + */ + +import type { AiJudgeCaller, AiJudgeCondition } from '../core/piece/types.js'; +import { loadTemplate } from '../shared/prompts/index.js'; +import { createLogger } from '../shared/utils/index.js'; +import { runAgent } from './runner.js'; + +const log = createLogger('ai-judge'); + +/** + * Detect judge rule index from [JUDGE:N] tag pattern. + * Returns 0-based rule index, or -1 if no match. + */ +export function detectJudgeIndex(content: string): number { + const regex = /\[JUDGE:(\d+)\]/i; + const match = content.match(regex); + if (match?.[1]) { + const index = Number.parseInt(match[1], 10) - 1; + return index >= 0 ? index : -1; + } + return -1; +} + +/** + * Build the prompt for the AI judge that evaluates agent output against ai() conditions. + */ +export function buildJudgePrompt( + agentOutput: string, + aiConditions: AiJudgeCondition[], +): string { + const conditionList = aiConditions + .map((c) => `| ${c.index + 1} | ${c.text} |`) + .join('\n'); + + return loadTemplate('perform_judge_message', 'en', { agentOutput, conditionList }); +} + +/** + * Call AI judge to evaluate agent output against ai() conditions. + * Uses the provider system (via runAgent) for correct provider resolution. + * Returns 0-based index of the matched ai() condition, or -1 if no match. + */ +export const callAiJudge: AiJudgeCaller = async ( + agentOutput: string, + conditions: AiJudgeCondition[], + options: { cwd: string }, +): Promise => { + const prompt = buildJudgePrompt(agentOutput, conditions); + + const response = await runAgent(undefined, prompt, { + cwd: options.cwd, + maxTurns: 1, + allowedTools: [], + }); + + if (response.status !== 'done') { + log.error('AI judge call failed', { error: response.error }); + return -1; + } + + return detectJudgeIndex(response.content); +}; diff --git a/src/agents/index.ts b/src/agents/index.ts index ebbf3920..6adc5d91 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -3,4 +3,5 @@ */ export { AgentRunner, runAgent } from './runner.js'; +export { callAiJudge, detectJudgeIndex, buildJudgePrompt } from './ai-judge.js'; export type { RunAgentOptions, StreamCallback } from './types.js'; diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index a7c756f3..2b75e59b 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -6,7 +6,8 @@ import { readFileSync } from 'node:fs'; import { PieceEngine, type IterationLimitRequest, type UserInputRequest } from '../../../core/piece/index.js'; import type { PieceConfig } from '../../../core/models/index.js'; import type { PieceExecutionResult, PieceExecutionOptions } from './types.js'; -import { callAiJudge, detectRuleIndex, interruptAllQueries } from '../../../infra/claude/index.js'; +import { detectRuleIndex, interruptAllQueries } from '../../../infra/claude/index.js'; +import { callAiJudge } from '../../../agents/ai-judge.js'; export type { PieceExecutionResult, PieceExecutionOptions }; diff --git a/src/index.ts b/src/index.ts index 3cbd6e3b..4731f7d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,10 +65,7 @@ export { callClaudeCustom, callClaudeAgent, callClaudeSkill, - callAiJudge, detectRuleIndex, - detectJudgeIndex, - buildJudgePrompt, isRegexSafe, } from './infra/claude/index.js'; export type { diff --git a/src/infra/claude/client.ts b/src/infra/claude/client.ts index 85fc418c..be473d15 100644 --- a/src/infra/claude/client.ts +++ b/src/infra/claude/client.ts @@ -154,60 +154,6 @@ export class ClaudeClient { }; } - /** - * Detect judge rule index from [JUDGE:N] tag pattern. - * Returns 0-based rule index, or -1 if no match. - */ - static detectJudgeIndex(content: string): number { - const regex = /\[JUDGE:(\d+)\]/i; - const match = content.match(regex); - if (match?.[1]) { - const index = Number.parseInt(match[1], 10) - 1; - return index >= 0 ? index : -1; - } - return -1; - } - - /** - * Build the prompt for the AI judge that evaluates agent output against ai() conditions. - */ - static buildJudgePrompt( - agentOutput: string, - aiConditions: { index: number; text: string }[], - ): string { - const conditionList = aiConditions - .map((c) => `| ${c.index + 1} | ${c.text} |`) - .join('\n'); - - return loadTemplate('perform_judge_message', 'en', { agentOutput, conditionList }); - } - - /** - * Call AI judge to evaluate agent output against ai() conditions. - * Uses a lightweight model (haiku) for cost efficiency. - * Returns 0-based index of the matched ai() condition, or -1 if no match. - */ - async callAiJudge( - agentOutput: string, - aiConditions: { index: number; text: string }[], - options: { cwd: string }, - ): Promise { - const prompt = ClaudeClient.buildJudgePrompt(agentOutput, aiConditions); - - const spawnOptions: ClaudeSpawnOptions = { - cwd: options.cwd, - model: 'haiku', - maxTurns: 1, - }; - - const result = await executeClaudeCli(prompt, spawnOptions); - if (!result.success) { - log.error('AI judge call failed', { error: result.error }); - return -1; - } - - return ClaudeClient.detectJudgeIndex(result.content); - } } // ---- Module-level functions ---- @@ -247,21 +193,3 @@ export async function callClaudeSkill( return defaultClient.callSkill(skillName, prompt, options); } -export function detectJudgeIndex(content: string): number { - return ClaudeClient.detectJudgeIndex(content); -} - -export function buildJudgePrompt( - agentOutput: string, - aiConditions: { index: number; text: string }[], -): string { - return ClaudeClient.buildJudgePrompt(agentOutput, aiConditions); -} - -export async function callAiJudge( - agentOutput: string, - aiConditions: { index: number; text: string }[], - options: { cwd: string }, -): Promise { - return defaultClient.callAiJudge(agentOutput, aiConditions, options); -} diff --git a/src/infra/claude/index.ts b/src/infra/claude/index.ts index 4c21f589..8e8060db 100644 --- a/src/infra/claude/index.ts +++ b/src/infra/claude/index.ts @@ -67,9 +67,6 @@ export { callClaudeCustom, callClaudeAgent, callClaudeSkill, - callAiJudge, detectRuleIndex, - detectJudgeIndex, - buildJudgePrompt, isRegexSafe, } from './client.js'; From f08c66cb638034a973c4b03c7182e320581622f4 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:34:46 +0900 Subject: [PATCH 10/45] Release v0.11.1 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 856b7393..7289ea7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.11.1] - 2026-02-10 + +### Fixed + +- AI Judge がプロバイダーシステムを経由するよう修正 — `callAiJudge` を Claude 固定実装からプロバイダー経由(`runAgent`)に変更し、Codex プロバイダーでも AI 判定が正しく動作するように +- 実行指示が長大化する問題を緩和 — implement/fix 系ムーブメントで `pass_previous_response: false` を設定し、Report Directory 内のレポートを一次情報として優先する指示に変更(en/ja 両対応) + +### Internal + +- stable release 時に npm の `next` dist-tag を `latest` と自動同期するよう CI ワークフローを改善(リトライ付き) + ## [0.11.0] - 2026-02-10 ### Added From 194610018a6e35b6906a00bd682ad634ef5bacc5 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:58:38 +0900 Subject: [PATCH 11/45] takt/#209/update review history logs (#213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: callAiJudgeをプロバイダーシステム経由に変更(Codex対応) callAiJudgeがinfra/claude/にハードコードされており、Codexプロバイダー使用時に judge評価が動作しなかった。agents/ai-judge.tsに移動し、runAgent経由で プロバイダーを正しく解決するように修正。 * takt: github-issue-209 --- CLAUDE.md | 8 +- src/__tests__/engine-test-helpers.test.ts | 21 +++ src/__tests__/engine-test-helpers.ts | 28 ++-- src/__tests__/instruction-helpers.test.ts | 3 +- src/__tests__/instructionBuilder.test.ts | 6 +- src/__tests__/judgment-fallback.test.ts | 46 ++++++ .../phase-runner-report-history.test.ts | 143 ++++++++++++++++++ .../piece/instruction/InstructionBuilder.ts | 8 +- src/core/piece/phase-runner.ts | 46 +++++- 9 files changed, 277 insertions(+), 32 deletions(-) create mode 100644 src/__tests__/engine-test-helpers.test.ts create mode 100644 src/__tests__/phase-runner-report-history.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index d695cf48..c4b2a609 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -406,7 +406,7 @@ Key constraints: - **Ephemeral lifecycle**: Clone is created → task runs → auto-commit + push → clone is deleted. Branches are the single source of truth. - **Session isolation**: Claude Code sessions are stored per-cwd in `~/.claude/projects/{encoded-path}/`. Sessions from the main project cannot be resumed in a clone. The engine skips session resume when `cwd !== projectCwd`. - **No node_modules**: Clones only contain tracked files. `node_modules/` is absent. -- **Dual cwd**: `cwd` = clone path (where agents run), `projectCwd` = project root. Reports write to `cwd/.takt/reports/` (clone) to prevent agents from discovering the main repository. Logs and session data write to `projectCwd`. +- **Dual cwd**: `cwd` = clone path (where agents run), `projectCwd` = project root. Reports write to `cwd/.takt/runs/{slug}/reports/` (clone) to prevent agents from discovering the main repository. Logs and session data write to `projectCwd`. - **List**: Use `takt list` to list branches. Instruct action creates a temporary clone for the branch, executes, pushes, then removes the clone. ## Error Propagation @@ -455,10 +455,10 @@ Debug logs are written to `.takt/logs/debug.log` (ndjson format). Log levels: `d - If persona file doesn't exist, the persona string is used as inline system prompt **Report directory structure:** -- Report dirs are created at `.takt/reports/{timestamp}-{slug}/` +- Report dirs are created at `.takt/runs/{timestamp}-{slug}/reports/` - Report files specified in `step.report` are written relative to report dir - Report dir path is available as `{report_dir}` variable in instruction templates -- When `cwd !== projectCwd` (worktree execution), reports write to `cwd/.takt/reports/` (clone dir) to prevent agents from discovering the main repository path +- When `cwd !== projectCwd` (worktree execution), reports write to `cwd/.takt/runs/{slug}/reports/` (clone dir) to prevent agents from discovering the main repository path **Session continuity across phases:** - Agent sessions persist across Phase 1 → Phase 2 → Phase 3 for context continuity @@ -470,7 +470,7 @@ Debug logs are written to `.takt/logs/debug.log` (ndjson format). Log levels: `d - `git clone --shared` creates independent `.git` directory (not `git worktree`) - Clone cwd ≠ project cwd: agents work in clone, reports write to clone, logs write to project - Session resume is skipped when `cwd !== projectCwd` to avoid cross-directory contamination -- Reports write to `cwd/.takt/reports/` (clone) to prevent agents from discovering the main repository path via instruction +- Reports write to `cwd/.takt/runs/{slug}/reports/` (clone) to prevent agents from discovering the main repository path via instruction - Clones are ephemeral: created → task runs → auto-commit + push → deleted - Use `takt list` to manage task branches after clone deletion diff --git a/src/__tests__/engine-test-helpers.test.ts b/src/__tests__/engine-test-helpers.test.ts new file mode 100644 index 00000000..f917be1a --- /dev/null +++ b/src/__tests__/engine-test-helpers.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { cleanupPieceEngine } from './engine-test-helpers.js'; + +describe('cleanupPieceEngine', () => { + it('should remove all listeners when engine has removeAllListeners function', () => { + const removeAllListeners = vi.fn(); + const engine = { removeAllListeners }; + + cleanupPieceEngine(engine); + + expect(removeAllListeners).toHaveBeenCalledOnce(); + }); + + it('should not throw when engine does not have removeAllListeners function', () => { + expect(() => cleanupPieceEngine({})).not.toThrow(); + expect(() => cleanupPieceEngine(null)).not.toThrow(); + expect(() => cleanupPieceEngine(undefined)).not.toThrow(); + expect(() => cleanupPieceEngine({ removeAllListeners: 'no-op' })).not.toThrow(); + }); +}); diff --git a/src/__tests__/engine-test-helpers.ts b/src/__tests__/engine-test-helpers.ts index d438c112..e658a5d6 100644 --- a/src/__tests__/engine-test-helpers.ts +++ b/src/__tests__/engine-test-helpers.ts @@ -178,25 +178,27 @@ export function applyDefaultMocks(): void { vi.mocked(generateReportDir).mockReturnValue('test-report-dir'); } -/** - * Clean up PieceEngine instances to prevent EventEmitter memory leaks. - * Call this in afterEach to ensure all event listeners are removed. - */ -type ListenerCleanupTarget = { +type RemovableListeners = { removeAllListeners: () => void; }; -function isListenerCleanupTarget(value: unknown): value is ListenerCleanupTarget { - return ( - typeof value === 'object' && - value !== null && - 'removeAllListeners' in value && - typeof value.removeAllListeners === 'function' - ); +function hasRemovableListeners(value: unknown): value is RemovableListeners { + if (!value || typeof value !== 'object') { + return false; + } + if (!('removeAllListeners' in value)) { + return false; + } + const candidate = value as { removeAllListeners: unknown }; + return typeof candidate.removeAllListeners === 'function'; } +/** + * Clean up PieceEngine instances to prevent EventEmitter memory leaks. + * Call this in afterEach to ensure all event listeners are removed. + */ export function cleanupPieceEngine(engine: unknown): void { - if (isListenerCleanupTarget(engine)) { + if (hasRemovableListeners(engine)) { engine.removeAllListeners(); } } diff --git a/src/__tests__/instruction-helpers.test.ts b/src/__tests__/instruction-helpers.test.ts index 0b9adffe..cec104f6 100644 --- a/src/__tests__/instruction-helpers.test.ts +++ b/src/__tests__/instruction-helpers.test.ts @@ -101,7 +101,7 @@ describe('renderReportOutputInstruction', () => { const result = renderReportOutputInstruction(step, ctx, 'en'); expect(result).toContain('Report output'); expect(result).toContain('Report File'); - expect(result).toContain('Iteration 2'); + expect(result).toContain('Move current content to `logs/reports-history/`'); }); it('should render English multi-file instruction', () => { @@ -121,6 +121,7 @@ describe('renderReportOutputInstruction', () => { const result = renderReportOutputInstruction(step, ctx, 'ja'); expect(result).toContain('レポート出力'); expect(result).toContain('Report File'); + expect(result).toContain('`logs/reports-history/`'); }); it('should render Japanese multi-file instruction', () => { diff --git a/src/__tests__/instructionBuilder.test.ts b/src/__tests__/instructionBuilder.test.ts index e78a8683..d0b81d09 100644 --- a/src/__tests__/instructionBuilder.test.ts +++ b/src/__tests__/instructionBuilder.test.ts @@ -802,7 +802,7 @@ describe('instruction-builder', () => { expect(result).toContain('**Report output:** Output to the `Report File` specified above.'); expect(result).toContain('- If file does not exist: Create new file'); - expect(result).toContain('Append with `## Iteration 1` section'); + expect(result).toContain('- If file exists: Move current content to `logs/reports-history/` and overwrite with latest report'); }); it('should include explicit order instead of auto-generated', () => { @@ -833,14 +833,14 @@ describe('instruction-builder', () => { expect(result).toContain('# Plan'); }); - it('should replace {movement_iteration} in report output instruction', () => { + it('should include overwrite-and-archive rule in report output instruction', () => { const step = createMinimalStep('Do work'); step.outputContracts = [{ name: '00-plan.md' }]; const ctx = createReportContext({ movementIteration: 5 }); const result = buildReportInstruction(step, ctx); - expect(result).toContain('Append with `## Iteration 5` section'); + expect(result).toContain('Move current content to `logs/reports-history/` and overwrite with latest report'); }); it('should include instruction body text', () => { diff --git a/src/__tests__/judgment-fallback.test.ts b/src/__tests__/judgment-fallback.test.ts index e7d80bf2..0d7d5606 100644 --- a/src/__tests__/judgment-fallback.test.ts +++ b/src/__tests__/judgment-fallback.test.ts @@ -3,8 +3,12 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import type { PieceMovement } from '../core/models/types.js'; import type { JudgmentContext } from '../core/piece/judgment/FallbackStrategy.js'; +import { runAgent } from '../agents/runner.js'; import { AutoSelectStrategy, ReportBasedStrategy, @@ -88,6 +92,48 @@ describe('JudgmentStrategies', () => { // mockStep has no outputContracts field → getReportFiles returns [] expect(strategy.canApply(mockContext)).toBe(false); }); + + it('should use only latest report file from reports directory', async () => { + const tmpRoot = mkdtempSync(join(tmpdir(), 'takt-judgment-report-')); + try { + const reportDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'reports'); + const historyDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'logs', 'reports-history'); + mkdirSync(reportDir, { recursive: true }); + mkdirSync(historyDir, { recursive: true }); + + const latestFile = '05-architect-review.md'; + writeFileSync(join(reportDir, latestFile), 'LATEST-ONLY-CONTENT'); + writeFileSync(join(historyDir, '05-architect-review.20260210T061143Z.md'), 'OLD-HISTORY-CONTENT'); + + const stepWithOutputContracts: PieceMovement = { + ...mockStep, + outputContracts: [{ name: latestFile }], + }; + + const runAgentMock = vi.mocked(runAgent); + runAgentMock.mockResolvedValue({ + persona: 'conductor', + status: 'done', + content: '[TEST-MOVEMENT:1]', + timestamp: new Date('2026-02-10T07:11:43Z'), + }); + + const strategy = new ReportBasedStrategy(); + const result = await strategy.execute({ + ...mockContext, + step: stepWithOutputContracts, + reportDir, + }); + + expect(result.success).toBe(true); + expect(runAgentMock).toHaveBeenCalledTimes(1); + const instruction = runAgentMock.mock.calls[0]?.[1]; + expect(instruction).toContain('LATEST-ONLY-CONTENT'); + expect(instruction).not.toContain('OLD-HISTORY-CONTENT'); + } finally { + rmSync(tmpRoot, { recursive: true, force: true }); + } + }); }); describe('ResponseBasedStrategy', () => { diff --git a/src/__tests__/phase-runner-report-history.test.ts b/src/__tests__/phase-runner-report-history.test.ts new file mode 100644 index 00000000..f2960669 --- /dev/null +++ b/src/__tests__/phase-runner-report-history.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { runReportPhase, type PhaseRunnerContext } from '../core/piece/phase-runner.js'; +import type { PieceMovement } from '../core/models/types.js'; + +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +import { runAgent } from '../agents/runner.js'; + +function createStep(fileName: string): PieceMovement { + return { + name: 'reviewers', + personaDisplayName: 'Reviewers', + instructionTemplate: 'review', + passPreviousResponse: false, + outputContracts: [{ name: fileName }], + }; +} + +function createContext(reportDir: string): PhaseRunnerContext { + let currentSessionId = 'session-1'; + return { + cwd: reportDir, + reportDir, + getSessionId: (_persona: string) => currentSessionId, + buildResumeOptions: ( + _step, + _sessionId, + _overrides, + ) => ({ cwd: reportDir }), + updatePersonaSession: (_persona, sessionId) => { + if (sessionId) { + currentSessionId = sessionId; + } + }, + }; +} + +describe('runReportPhase report history behavior', () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = mkdtempSync(join(tmpdir(), 'takt-report-history-')); + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + if (existsSync(tmpRoot)) { + rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + it('should overwrite report file and archive previous content to reports-history', async () => { + // Given + const reportDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'reports'); + const step = createStep('05-architect-review.md'); + const ctx = createContext(reportDir); + const runAgentMock = vi.mocked(runAgent); + runAgentMock + .mockResolvedValueOnce({ + persona: 'reviewers', + status: 'done', + content: 'First review result', + timestamp: new Date('2026-02-10T06:11:43Z'), + sessionId: 'session-2', + }) + .mockResolvedValueOnce({ + persona: 'reviewers', + status: 'done', + content: 'Second review result', + timestamp: new Date('2026-02-10T06:14:37Z'), + sessionId: 'session-3', + }); + + // When + await runReportPhase(step, 1, ctx); + await runReportPhase(step, 2, ctx); + + // Then + const latestPath = join(reportDir, '05-architect-review.md'); + const latestContent = readFileSync(latestPath, 'utf-8'); + expect(latestContent).toBe('Second review result'); + + const historyDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'logs', 'reports-history'); + const historyFiles = readdirSync(historyDir); + expect(historyFiles).toHaveLength(1); + expect(historyFiles[0]).toMatch(/^05-architect-review\.\d{8}T\d{6}Z\.md$/); + + const archivedContent = readFileSync(join(historyDir, historyFiles[0]!), 'utf-8'); + expect(archivedContent).toBe('First review result'); + }); + + it('should add sequence suffix when history file name collides in the same second', async () => { + // Given + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-02-10T06:11:43Z')); + + const reportDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'reports'); + const step = createStep('06-qa-review.md'); + const ctx = createContext(reportDir); + const runAgentMock = vi.mocked(runAgent); + runAgentMock + .mockResolvedValueOnce({ + persona: 'reviewers', + status: 'done', + content: 'v1', + timestamp: new Date('2026-02-10T06:11:43Z'), + sessionId: 'session-2', + }) + .mockResolvedValueOnce({ + persona: 'reviewers', + status: 'done', + content: 'v2', + timestamp: new Date('2026-02-10T06:11:43Z'), + sessionId: 'session-3', + }) + .mockResolvedValueOnce({ + persona: 'reviewers', + status: 'done', + content: 'v3', + timestamp: new Date('2026-02-10T06:11:43Z'), + sessionId: 'session-4', + }); + + // When + await runReportPhase(step, 1, ctx); + await runReportPhase(step, 2, ctx); + await runReportPhase(step, 3, ctx); + + // Then + const historyDir = join(tmpRoot, '.takt', 'runs', 'sample-run', 'logs', 'reports-history'); + const historyFiles = readdirSync(historyDir).sort(); + expect(historyFiles).toEqual([ + '06-qa-review.20260210T061143Z.1.md', + '06-qa-review.20260210T061143Z.md', + ]); + }); +}); diff --git a/src/core/piece/instruction/InstructionBuilder.ts b/src/core/piece/instruction/InstructionBuilder.ts index 27ce3761..30b0fb5a 100644 --- a/src/core/piece/instruction/InstructionBuilder.ts +++ b/src/core/piece/instruction/InstructionBuilder.ts @@ -298,21 +298,21 @@ export function renderReportOutputInstruction( let heading: string; let createRule: string; - let appendRule: string; + let overwriteRule: string; if (language === 'ja') { heading = isMulti ? '**レポート出力:** Report Files に出力してください。' : '**レポート出力:** `Report File` に出力してください。'; createRule = '- ファイルが存在しない場合: 新規作成'; - appendRule = `- ファイルが存在する場合: \`## Iteration ${context.movementIteration}\` セクションを追記`; + overwriteRule = '- ファイルが存在する場合: 既存内容を `logs/reports-history/` に退避し、最新内容で上書き'; } else { heading = isMulti ? '**Report output:** Output to the `Report Files` specified above.' : '**Report output:** Output to the `Report File` specified above.'; createRule = '- If file does not exist: Create new file'; - appendRule = `- If file exists: Append with \`## Iteration ${context.movementIteration}\` section`; + overwriteRule = '- If file exists: Move current content to `logs/reports-history/` and overwrite with latest report'; } - return `${heading}\n${createRule}\n${appendRule}`; + return `${heading}\n${createRule}\n${overwriteRule}`; } diff --git a/src/core/piece/phase-runner.ts b/src/core/piece/phase-runner.ts index 21b2e62a..ac784b59 100644 --- a/src/core/piece/phase-runner.ts +++ b/src/core/piece/phase-runner.ts @@ -5,8 +5,8 @@ * as session-resume operations. */ -import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, resolve, sep } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, parse, resolve, sep } from 'node:path'; import type { PieceMovement, Language } from '../models/types.js'; import type { PhaseName } from './types.js'; import { runAgent, type RunAgentOptions } from '../../agents/runner.js'; @@ -49,6 +49,41 @@ export function needsStatusJudgmentPhase(step: PieceMovement): boolean { return hasTagBasedRules(step); } +function formatHistoryTimestamp(date: Date): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hour = String(date.getUTCHours()).padStart(2, '0'); + const minute = String(date.getUTCMinutes()).padStart(2, '0'); + const second = String(date.getUTCSeconds()).padStart(2, '0'); + return `${year}${month}${day}T${hour}${minute}${second}Z`; +} + +function buildHistoryFileName(fileName: string, timestamp: string, sequence: number): string { + const parsed = parse(fileName); + const duplicateSuffix = sequence === 0 ? '' : `.${sequence}`; + return `${parsed.name}.${timestamp}${duplicateSuffix}${parsed.ext}`; +} + +function backupExistingReport(reportDir: string, fileName: string, targetPath: string): void { + if (!existsSync(targetPath)) { + return; + } + + const currentContent = readFileSync(targetPath, 'utf-8'); + const historyDir = resolve(reportDir, '..', 'logs', 'reports-history'); + mkdirSync(historyDir, { recursive: true }); + + const timestamp = formatHistoryTimestamp(new Date()); + let sequence = 0; + let historyPath = resolve(historyDir, buildHistoryFileName(fileName, timestamp, sequence)); + while (existsSync(historyPath)) { + sequence += 1; + historyPath = resolve(historyDir, buildHistoryFileName(fileName, timestamp, sequence)); + } + + writeFileSync(historyPath, currentContent); +} function writeReportFile(reportDir: string, fileName: string, content: string): void { const baseDir = resolve(reportDir); @@ -58,11 +93,8 @@ function writeReportFile(reportDir: string, fileName: string, content: string): throw new Error(`Report file path escapes report directory: ${fileName}`); } mkdirSync(dirname(targetPath), { recursive: true }); - if (existsSync(targetPath)) { - appendFileSync(targetPath, `\n\n${content}`); - } else { - writeFileSync(targetPath, content); - } + backupExistingReport(baseDir, fileName, targetPath); + writeFileSync(targetPath, content); } /** From 6e67f864f58f4c0b55f79e1e98f58c4b836ee501 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:03:17 +0900 Subject: [PATCH 12/45] takt: github-issue-198-e2e-config-yaml (#208) --- README.md | 6 + docs/README.ja.md | 6 + docs/testing/e2e.md | 7 + e2e/fixtures/config.e2e.yaml | 11 ++ e2e/helpers/isolated-env.ts | 95 +++++++-- e2e/specs/add.e2e.ts | 22 +-- e2e/specs/provider-error.e2e.ts | 18 +- e2e/specs/run-multiple-tasks.e2e.ts | 18 +- e2e/specs/run-sigint-graceful.e2e.ts | 24 ++- e2e/specs/task-content-file.e2e.ts | 18 +- src/__tests__/e2e-helpers.test.ts | 115 ++++++++++- src/__tests__/globalConfig-defaults.test.ts | 53 +++++ src/__tests__/it-notification-sound.test.ts | 49 +++++ src/__tests__/runAllTasks-concurrency.test.ts | 182 +++++++++++++++++- src/core/models/global-config.ts | 16 ++ src/core/models/schemas.ts | 8 + src/features/tasks/execute/pieceExecution.ts | 10 +- src/features/tasks/execute/taskExecution.ts | 38 +++- src/infra/config/global/globalConfig.ts | 28 +++ src/shared/i18n/labels_en.yaml | 4 + src/shared/i18n/labels_ja.yaml | 4 + 21 files changed, 642 insertions(+), 90 deletions(-) create mode 100644 e2e/fixtures/config.e2e.yaml diff --git a/README.md b/README.md index 71160a73..397b5349 100644 --- a/README.md +++ b/README.md @@ -565,6 +565,12 @@ model: sonnet # Default model (optional) branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow) prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate) notification_sound: true # Enable/disable notification sounds +notification_sound_events: # Optional per-event toggles + iteration_limit: false + piece_complete: true + piece_abort: true + run_complete: true # Enabled by default; set false to disable + run_abort: true # Enabled by default; set false to disable concurrency: 1 # Parallel task count for takt run (1-10, default: 1 = sequential) 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) diff --git a/docs/README.ja.md b/docs/README.ja.md index 6877a16e..91652676 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -565,6 +565,12 @@ model: sonnet # デフォルトモデル(オプション) branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速) prevent_sleep: false # macOS の実行中スリープ防止(caffeinate) notification_sound: true # 通知音の有効/無効 +notification_sound_events: # タイミング別の通知音制御 + iteration_limit: false + piece_complete: true + piece_abort: true + run_complete: true # 未設定時は有効。false を指定すると無効 + run_abort: true # 未設定時は有効。false を指定すると無効 concurrency: 1 # takt run の並列タスク数(1-10、デフォルト: 1 = 逐次実行) task_poll_interval_ms: 500 # takt run 中の新タスク検出ポーリング間隔(100-5000、デフォルト: 500) interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数(0-10、デフォルト: 3) diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index 1ebd72c3..1f91ed76 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -13,6 +13,13 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - リポジトリクローン: `$(os.tmpdir())/takt-e2e-repo-/` - 実行環境: `$(os.tmpdir())/takt-e2e--/` +## E2E用config.yaml +- E2Eのグローバル設定は `e2e/fixtures/config.e2e.yaml` を基準に生成する。 +- `createIsolatedEnv()` は毎回一時ディレクトリ配下(`$TAKT_CONFIG_DIR/config.yaml`)にこの基準設定を書き出す。 +- 通知音は `notification_sound_events` でタイミング別に制御し、E2E既定では道中(`iteration_limit` / `piece_complete` / `piece_abort`)をOFF、全体終了時(`run_complete` / `run_abort`)のみONにする。 +- 各スペックで `provider` や `concurrency` を変更する場合は、`updateIsolatedConfig()` を使って差分のみ上書きする。 +- `~/.takt/config.yaml` はE2Eでは参照されないため、通常実行の設定には影響しない。 + ## 実行コマンド - `npm run test:e2e`: E2E全体を実行。 - `npm run test:e2e:mock`: mock固定のE2Eのみ実行。 diff --git a/e2e/fixtures/config.e2e.yaml b/e2e/fixtures/config.e2e.yaml new file mode 100644 index 00000000..6eea1b84 --- /dev/null +++ b/e2e/fixtures/config.e2e.yaml @@ -0,0 +1,11 @@ +provider: claude +language: en +log_level: info +default_piece: default +notification_sound: true +notification_sound_events: + iteration_limit: false + piece_complete: false + piece_abort: false + run_complete: true + run_abort: true diff --git a/e2e/helpers/isolated-env.ts b/e2e/helpers/isolated-env.ts index 5f08be48..2aea4a70 100644 --- a/e2e/helpers/isolated-env.ts +++ b/e2e/helpers/isolated-env.ts @@ -1,6 +1,8 @@ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; export interface IsolatedEnv { runId: string; @@ -9,6 +11,73 @@ export interface IsolatedEnv { cleanup: () => void; } +type E2EConfig = Record; +type NotificationSoundEvents = Record; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const E2E_CONFIG_FIXTURE_PATH = resolve(__dirname, '../fixtures/config.e2e.yaml'); + +function readE2EFixtureConfig(): E2EConfig { + const raw = readFileSync(E2E_CONFIG_FIXTURE_PATH, 'utf-8'); + const parsed = parseYaml(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid E2E config fixture: ${E2E_CONFIG_FIXTURE_PATH}`); + } + return parsed as E2EConfig; +} + +function writeConfigFile(taktDir: string, config: E2EConfig): void { + writeFileSync(join(taktDir, 'config.yaml'), `${stringifyYaml(config)}`); +} + +function parseNotificationSoundEvents( + source: E2EConfig, + sourceName: string, +): NotificationSoundEvents | undefined { + const value = source.notification_sound_events; + if (value === undefined) { + return undefined; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error( + `Invalid notification_sound_events in ${sourceName}: expected object`, + ); + } + return value as NotificationSoundEvents; +} + +function mergeIsolatedConfig( + fixture: E2EConfig, + current: E2EConfig, + patch: E2EConfig, +): E2EConfig { + const merged: E2EConfig = { ...fixture, ...current, ...patch }; + const fixtureEvents = parseNotificationSoundEvents(fixture, 'fixture'); + const currentEvents = parseNotificationSoundEvents(current, 'current config'); + const patchEvents = parseNotificationSoundEvents(patch, 'patch'); + if (!fixtureEvents && !currentEvents && !patchEvents) { + return merged; + } + merged.notification_sound_events = { + ...(fixtureEvents ?? {}), + ...(currentEvents ?? {}), + ...(patchEvents ?? {}), + }; + return merged; +} + +export function updateIsolatedConfig(taktDir: string, patch: E2EConfig): void { + const current = readE2EFixtureConfig(); + const configPath = join(taktDir, 'config.yaml'); + const raw = readFileSync(configPath, 'utf-8'); + const parsed = parseYaml(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid isolated config: ${configPath}`); + } + writeConfigFile(taktDir, mergeIsolatedConfig(current, parsed as E2EConfig, patch)); +} + /** * Create an isolated environment for E2E testing. * @@ -24,18 +93,12 @@ export function createIsolatedEnv(): IsolatedEnv { const gitConfigPath = join(baseDir, '.gitconfig'); // Create TAKT config directory and config.yaml - // Use TAKT_E2E_PROVIDER to match config provider with the actual provider being tested - const configProvider = process.env.TAKT_E2E_PROVIDER ?? 'claude'; mkdirSync(taktDir, { recursive: true }); - writeFileSync( - join(taktDir, 'config.yaml'), - [ - `provider: ${configProvider}`, - 'language: en', - 'log_level: info', - 'default_piece: default', - ].join('\n'), - ); + const baseConfig = readE2EFixtureConfig(); + const config = process.env.TAKT_E2E_PROVIDER + ? { ...baseConfig, provider: process.env.TAKT_E2E_PROVIDER } + : baseConfig; + writeConfigFile(taktDir, config); // Create isolated Git config file writeFileSync( @@ -58,11 +121,7 @@ export function createIsolatedEnv(): IsolatedEnv { taktDir, env, cleanup: () => { - try { - rmSync(baseDir, { recursive: true, force: true }); - } catch { - // Best-effort cleanup; ignore errors (e.g., already deleted) - } + rmSync(baseDir, { recursive: true, force: true }); }, }; } diff --git a/e2e/specs/add.e2e.ts b/e2e/specs/add.e2e.ts index f2f26f5c..bc7979cb 100644 --- a/e2e/specs/add.e2e.ts +++ b/e2e/specs/add.e2e.ts @@ -1,10 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse as parseYaml } from 'yaml'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; import { createTestRepo, type TestRepo } from '../helpers/test-repo'; import { runTakt } from '../helpers/takt-runner'; @@ -22,16 +26,10 @@ describe('E2E: Add task from GitHub issue (takt add)', () => { testRepo = createTestRepo(); // Use mock provider to stabilize summarizer - writeFileSync( - join(isolatedEnv.taktDir, 'config.yaml'), - [ - 'provider: mock', - 'model: mock-model', - 'language: en', - 'log_level: info', - 'default_piece: default', - ].join('\n'), - ); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + model: 'mock-model', + }); const createOutput = execFileSync( 'gh', diff --git a/e2e/specs/provider-error.e2e.ts b/e2e/specs/provider-error.e2e.ts index 0f14542f..e2e6978b 100644 --- a/e2e/specs/provider-error.e2e.ts +++ b/e2e/specs/provider-error.e2e.ts @@ -5,7 +5,11 @@ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; import { runTakt } from '../helpers/takt-runner'; const __filename = fileURLToPath(import.meta.url); @@ -44,15 +48,9 @@ describe('E2E: Provider error handling (mock)', () => { it('should override config provider with --provider flag', () => { // Given: config.yaml has provider: claude, but CLI flag specifies mock - writeFileSync( - join(isolatedEnv.taktDir, 'config.yaml'), - [ - 'provider: claude', - 'language: en', - 'log_level: info', - 'default_piece: default', - ].join('\n'), - ); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'claude', + }); const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); diff --git a/e2e/specs/run-multiple-tasks.e2e.ts b/e2e/specs/run-multiple-tasks.e2e.ts index 14b1e7b4..518db716 100644 --- a/e2e/specs/run-multiple-tasks.e2e.ts +++ b/e2e/specs/run-multiple-tasks.e2e.ts @@ -5,7 +5,11 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; import { runTakt } from '../helpers/takt-runner'; const __filename = fileURLToPath(import.meta.url); @@ -39,15 +43,9 @@ describe('E2E: Run multiple tasks (takt run)', () => { repo = createLocalRepo(); // Override config to use mock provider - writeFileSync( - join(isolatedEnv.taktDir, 'config.yaml'), - [ - 'provider: mock', - 'language: en', - 'log_level: info', - 'default_piece: default', - ].join('\n'), - ); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + }); }); afterEach(() => { diff --git a/e2e/specs/run-sigint-graceful.e2e.ts b/e2e/specs/run-sigint-graceful.e2e.ts index 941baea2..79c2d852 100644 --- a/e2e/specs/run-sigint-graceful.e2e.ts +++ b/e2e/specs/run-sigint-graceful.e2e.ts @@ -3,7 +3,11 @@ import { spawn } from 'node:child_process'; import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; import { createTestRepo, type TestRepo } from '../helpers/test-repo'; const __filename = fileURLToPath(import.meta.url); @@ -50,18 +54,12 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => { isolatedEnv = createIsolatedEnv(); testRepo = createTestRepo(); - writeFileSync( - join(isolatedEnv.taktDir, 'config.yaml'), - [ - 'provider: mock', - 'model: mock-model', - 'language: en', - 'log_level: info', - 'default_piece: default', - 'concurrency: 2', - 'task_poll_interval_ms: 100', - ].join('\n'), - ); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + model: 'mock-model', + concurrency: 2, + task_poll_interval_ms: 100, + }); }); afterEach(() => { diff --git a/e2e/specs/task-content-file.e2e.ts b/e2e/specs/task-content-file.e2e.ts index d826d863..4e79acbf 100644 --- a/e2e/specs/task-content-file.e2e.ts +++ b/e2e/specs/task-content-file.e2e.ts @@ -5,7 +5,11 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; import { runTakt } from '../helpers/takt-runner'; const __filename = fileURLToPath(import.meta.url); @@ -38,15 +42,9 @@ describe('E2E: Task content_file reference (mock)', () => { isolatedEnv = createIsolatedEnv(); repo = createLocalRepo(); - writeFileSync( - join(isolatedEnv.taktDir, 'config.yaml'), - [ - 'provider: mock', - 'language: en', - 'log_level: info', - 'default_piece: default', - ].join('\n'), - ); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + }); }); afterEach(() => { diff --git a/src/__tests__/e2e-helpers.test.ts b/src/__tests__/e2e-helpers.test.ts index c94124e5..63b395d2 100644 --- a/src/__tests__/e2e-helpers.test.ts +++ b/src/__tests__/e2e-helpers.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, afterEach } from 'vitest'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { parse as parseYaml } from 'yaml'; import { injectProviderArgs } from '../../e2e/helpers/takt-runner.js'; -import { createIsolatedEnv } from '../../e2e/helpers/isolated-env.js'; +import { + createIsolatedEnv, + updateIsolatedConfig, +} from '../../e2e/helpers/isolated-env.js'; describe('injectProviderArgs', () => { it('should prepend --provider when provider is specified', () => { @@ -70,4 +75,112 @@ describe('createIsolatedEnv', () => { expect(isolated.env.GIT_CONFIG_GLOBAL).toBeDefined(); expect(isolated.env.GIT_CONFIG_GLOBAL).toContain('takt-e2e-'); }); + + it('should create config.yaml from E2E fixture with notification_sound timing controls', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8'); + const config = parseYaml(configRaw) as Record; + + expect(config.language).toBe('en'); + expect(config.log_level).toBe('info'); + expect(config.default_piece).toBe('default'); + expect(config.notification_sound).toBe(true); + expect(config.notification_sound_events).toEqual({ + iteration_limit: false, + piece_complete: false, + piece_abort: false, + run_complete: true, + run_abort: true, + }); + }); + + it('should override provider in config.yaml when TAKT_E2E_PROVIDER is set', () => { + process.env = { ...originalEnv, TAKT_E2E_PROVIDER: 'mock' }; + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8'); + const config = parseYaml(configRaw) as Record; + expect(config.provider).toBe('mock'); + }); + + it('should preserve base settings when updateIsolatedConfig applies patch', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + updateIsolatedConfig(isolated.taktDir, { + provider: 'mock', + concurrency: 2, + }); + + const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8'); + const config = parseYaml(configRaw) as Record; + + expect(config.provider).toBe('mock'); + expect(config.concurrency).toBe(2); + expect(config.notification_sound).toBe(true); + expect(config.notification_sound_events).toEqual({ + iteration_limit: false, + piece_complete: false, + piece_abort: false, + run_complete: true, + run_abort: true, + }); + expect(config.language).toBe('en'); + }); + + it('should deep-merge notification_sound_events patch and preserve unspecified keys', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + updateIsolatedConfig(isolated.taktDir, { + notification_sound_events: { + run_complete: false, + }, + }); + + const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8'); + const config = parseYaml(configRaw) as Record; + + expect(config.notification_sound_events).toEqual({ + iteration_limit: false, + piece_complete: false, + piece_abort: false, + run_complete: false, + run_abort: true, + }); + }); + + it('should throw when patch.notification_sound_events is not an object', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + expect(() => { + updateIsolatedConfig(isolated.taktDir, { + notification_sound_events: true, + }); + }).toThrow('Invalid notification_sound_events in patch: expected object'); + }); + + it('should throw when current config notification_sound_events is invalid', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + writeFileSync( + `${isolated.taktDir}/config.yaml`, + [ + 'language: en', + 'log_level: info', + 'default_piece: default', + 'notification_sound: true', + 'notification_sound_events: true', + ].join('\n'), + ); + + expect(() => { + updateIsolatedConfig(isolated.taktDir, { provider: 'mock' }); + }).toThrow('Invalid notification_sound_events in current config: expected object'); + }); }); diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 3c62adf5..ec4ec512 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -287,6 +287,59 @@ describe('loadGlobalConfig', () => { expect(config.notificationSound).toBeUndefined(); }); + it('should load notification_sound_events config from config.yaml', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + [ + 'language: en', + 'notification_sound_events:', + ' iteration_limit: false', + ' piece_complete: true', + ' piece_abort: true', + ' run_complete: true', + ' run_abort: false', + ].join('\n'), + 'utf-8', + ); + + const config = loadGlobalConfig(); + expect(config.notificationSoundEvents).toEqual({ + iterationLimit: false, + pieceComplete: true, + pieceAbort: true, + runComplete: true, + runAbort: false, + }); + }); + + it('should save and reload notification_sound_events config', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.notificationSoundEvents = { + iterationLimit: false, + pieceComplete: true, + pieceAbort: false, + runComplete: true, + runAbort: true, + }; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.notificationSoundEvents).toEqual({ + iterationLimit: false, + pieceComplete: true, + pieceAbort: false, + runComplete: true, + runAbort: true, + }); + }); + it('should load interactive_preview_movements config from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); diff --git a/src/__tests__/it-notification-sound.test.ts b/src/__tests__/it-notification-sound.test.ts index 3ba80d24..b23ac93e 100644 --- a/src/__tests__/it-notification-sound.test.ts +++ b/src/__tests__/it-notification-sound.test.ts @@ -282,6 +282,22 @@ describe('executePiece: notification sound behavior', () => { expect(mockNotifySuccess).not.toHaveBeenCalled(); }); + + it('should NOT call notifySuccess when piece_complete event is disabled', async () => { + mockLoadGlobalConfig.mockReturnValue({ + provider: 'claude', + notificationSound: true, + notificationSoundEvents: { pieceComplete: false }, + }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + MockPieceEngine.latestInstance!.complete(); + await resultPromise; + + expect(mockNotifySuccess).not.toHaveBeenCalled(); + }); }); describe('notifyError on piece:abort', () => { @@ -320,6 +336,22 @@ describe('executePiece: notification sound behavior', () => { expect(mockNotifyError).not.toHaveBeenCalled(); }); + + it('should NOT call notifyError when piece_abort event is disabled', async () => { + mockLoadGlobalConfig.mockReturnValue({ + provider: 'claude', + notificationSound: true, + notificationSoundEvents: { pieceAbort: false }, + }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + MockPieceEngine.latestInstance!.abort(); + await resultPromise; + + expect(mockNotifyError).not.toHaveBeenCalled(); + }); }); describe('playWarningSound on iteration limit', () => { @@ -361,5 +393,22 @@ describe('executePiece: notification sound behavior', () => { expect(mockPlayWarningSound).not.toHaveBeenCalled(); }); + + it('should NOT call playWarningSound when iteration_limit event is disabled', async () => { + mockLoadGlobalConfig.mockReturnValue({ + provider: 'claude', + notificationSound: true, + notificationSoundEvents: { iterationLimit: false }, + }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + await MockPieceEngine.latestInstance!.triggerIterationLimit(); + MockPieceEngine.latestInstance!.abort(); + await resultPromise; + + expect(mockPlayWarningSound).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index e7ec26c9..b8dca60a 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -21,10 +21,21 @@ vi.mock('../infra/config/index.js', () => ({ import { loadGlobalConfig } from '../infra/config/index.js'; const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); -const mockClaimNextTasks = vi.fn(); -const mockCompleteTask = vi.fn(); -const mockFailTask = vi.fn(); -const mockRecoverInterruptedRunningTasks = vi.fn(); +const { + mockClaimNextTasks, + mockCompleteTask, + mockFailTask, + mockRecoverInterruptedRunningTasks, + mockNotifySuccess, + mockNotifyError, +} = vi.hoisted(() => ({ + mockClaimNextTasks: vi.fn(), + mockCompleteTask: vi.fn(), + mockFailTask: vi.fn(), + mockRecoverInterruptedRunningTasks: vi.fn(), + mockNotifySuccess: vi.fn(), + mockNotifyError: vi.fn(), +})); vi.mock('../infra/task/index.js', async (importOriginal) => ({ ...(await importOriginal>()), @@ -75,6 +86,8 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ error: vi.fn(), }), getErrorMessage: vi.fn((e) => e.message), + notifySuccess: mockNotifySuccess, + notifyError: mockNotifyError, })); vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ @@ -149,6 +162,8 @@ describe('runAllTasks concurrency', () => { language: 'en', defaultPiece: 'default', logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: true, runAbort: true }, concurrency: 1, taskPollIntervalMs: 500, }); @@ -190,6 +205,8 @@ describe('runAllTasks concurrency', () => { language: 'en', defaultPiece: 'default', logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: true, runAbort: true }, concurrency: 3, taskPollIntervalMs: 500, }); @@ -266,6 +283,7 @@ describe('runAllTasks concurrency', () => { language: 'en', defaultPiece: 'default', logLevel: 'info', + notificationSound: false, concurrency: 1, taskPollIntervalMs: 500, }); @@ -283,6 +301,8 @@ describe('runAllTasks concurrency', () => { (call) => typeof call[0] === 'string' && call[0].startsWith('Concurrency:') ); expect(concurrencyInfoCalls).toHaveLength(0); + expect(mockNotifySuccess).not.toHaveBeenCalled(); + expect(mockNotifyError).not.toHaveBeenCalled(); }); }); @@ -384,6 +404,16 @@ describe('runAllTasks concurrency', () => { it('should count partial failures correctly', async () => { // Given: 3 tasks, 1 fails, 2 succeed + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runAbort: true }, + concurrency: 3, + taskPollIntervalMs: 500, + }); + const task1 = createTask('pass-1'); const task2 = createTask('fail-1'); const task3 = createTask('pass-2'); @@ -406,6 +436,8 @@ describe('runAllTasks concurrency', () => { expect(mockStatus).toHaveBeenCalledWith('Total', '3'); expect(mockStatus).toHaveBeenCalledWith('Success', '2', undefined); expect(mockStatus).toHaveBeenCalledWith('Failed', '1', 'red'); + expect(mockNotifySuccess).not.toHaveBeenCalled(); + expect(mockNotifyError).toHaveBeenCalledTimes(1); }); it('should persist failure reason and movement when piece aborts', async () => { @@ -458,6 +490,8 @@ describe('runAllTasks concurrency', () => { language: 'en', defaultPiece: 'default', logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: true, runAbort: true }, concurrency: 1, taskPollIntervalMs: 500, }); @@ -480,5 +514,145 @@ describe('runAllTasks concurrency', () => { expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal); expect(pieceOptions?.taskPrefix).toBeUndefined(); }); + + it('should only notify once at run completion when multiple tasks succeed', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: true }, + concurrency: 3, + taskPollIntervalMs: 500, + }); + + const task1 = createTask('task-1'); + const task2 = createTask('task-2'); + const task3 = createTask('task-3'); + + mockClaimNextTasks + .mockReturnValueOnce([task1, task2, task3]) + .mockReturnValueOnce([]); + + await runAllTasks('/project'); + + expect(mockNotifySuccess).toHaveBeenCalledTimes(1); + expect(mockNotifyError).not.toHaveBeenCalled(); + }); + + it('should not notify run completion when runComplete is explicitly false', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: false }, + concurrency: 1, + taskPollIntervalMs: 500, + }); + + const task1 = createTask('task-1'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + await runAllTasks('/project'); + + expect(mockNotifySuccess).not.toHaveBeenCalled(); + expect(mockNotifyError).not.toHaveBeenCalled(); + }); + + it('should notify run completion by default when notification_sound_events is not set', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + concurrency: 1, + taskPollIntervalMs: 500, + }); + + const task1 = createTask('task-1'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + await runAllTasks('/project'); + + expect(mockNotifySuccess).toHaveBeenCalledTimes(1); + expect(mockNotifySuccess).toHaveBeenCalledWith('TAKT', 'run.notifyComplete'); + expect(mockNotifyError).not.toHaveBeenCalled(); + }); + + it('should notify run abort by default when notification_sound_events is not set', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + concurrency: 1, + taskPollIntervalMs: 500, + }); + + const task1 = createTask('task-1'); + mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' }); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + await runAllTasks('/project'); + + expect(mockNotifySuccess).not.toHaveBeenCalled(); + expect(mockNotifyError).toHaveBeenCalledTimes(1); + expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort'); + }); + + it('should not notify run abort when runAbort is explicitly false', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runAbort: false }, + concurrency: 1, + taskPollIntervalMs: 500, + }); + + const task1 = createTask('task-1'); + mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' }); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + await runAllTasks('/project'); + + expect(mockNotifySuccess).not.toHaveBeenCalled(); + expect(mockNotifyError).not.toHaveBeenCalled(); + }); + + it('should notify run abort and rethrow when worker pool throws', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runAbort: true }, + concurrency: 1, + taskPollIntervalMs: 500, + }); + + const task1 = createTask('task-1'); + const poolError = new Error('worker pool crashed'); + + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockImplementationOnce(() => { + throw poolError; + }); + + await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed'); + expect(mockNotifyError).toHaveBeenCalledTimes(1); + expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort'); + }); }); }); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 26a7b439..4f7e1680 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -33,6 +33,20 @@ export interface PipelineConfig { prBodyTemplate?: string; } +/** Notification sound toggles per event timing */ +export interface NotificationSoundEventsConfig { + /** Warning when iteration limit is reached */ + iterationLimit?: boolean; + /** Success notification when piece execution completes */ + pieceComplete?: boolean; + /** Error notification when piece execution aborts */ + pieceAbort?: boolean; + /** Success notification when runAllTasks finishes without failures */ + runComplete?: boolean; + /** Error notification when runAllTasks finishes with failures or aborts */ + runAbort?: boolean; +} + /** Global configuration for takt */ export interface GlobalConfig { language: Language; @@ -69,6 +83,8 @@ export interface GlobalConfig { preventSleep?: boolean; /** Enable notification sounds (default: true when undefined) */ notificationSound?: boolean; + /** Notification sound toggles per event timing */ + notificationSoundEvents?: NotificationSoundEventsConfig; /** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ interactivePreviewMovements?: number; /** Number of tasks to run concurrently in takt run (default: 1 = sequential) */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 8345a474..1d4ce4e9 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -369,6 +369,14 @@ export const GlobalConfigSchema = z.object({ prevent_sleep: z.boolean().optional(), /** Enable notification sounds (default: true when undefined) */ notification_sound: z.boolean().optional(), + /** Notification sound toggles per event timing */ + notification_sound_events: z.object({ + iteration_limit: z.boolean().optional(), + piece_complete: z.boolean().optional(), + piece_abort: z.boolean().optional(), + run_complete: z.boolean().optional(), + run_abort: z.boolean().optional(), + }).optional(), /** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3), /** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */ diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 2b75e59b..f87e0206 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -296,6 +296,10 @@ export async function executePiece( const isWorktree = cwd !== projectCwd; const globalConfig = loadGlobalConfig(); const shouldNotify = globalConfig.notificationSound !== false; + const notificationSoundEvents = globalConfig.notificationSoundEvents; + const shouldNotifyIterationLimit = shouldNotify && notificationSoundEvents?.iterationLimit !== false; + const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false; + const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false; const currentProvider = globalConfig.provider ?? 'claude'; // Prevent macOS idle sleep if configured @@ -333,7 +337,7 @@ export async function executePiece( ); out.info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement })); - if (shouldNotify) { + if (shouldNotifyIterationLimit) { playWarningSound(); } @@ -613,7 +617,7 @@ export async function executePiece( out.success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`); out.info(`Session log: ${ndjsonLogPath}`); - if (shouldNotify) { + if (shouldNotifyPieceComplete) { notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) })); } }); @@ -661,7 +665,7 @@ export async function executePiece( out.error(`Piece aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`); out.info(`Session log: ${ndjsonLogPath}`); - if (shouldNotify) { + if (shouldNotifyPieceAbort) { notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason })); } }); diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index b0da2aeb..5c17e3a7 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -12,7 +12,8 @@ import { status, blankLine, } from '../../../shared/ui/index.js'; -import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { createLogger, getErrorMessage, notifyError, notifySuccess } 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'; @@ -241,6 +242,10 @@ export async function runAllTasks( ): Promise { const taskRunner = new TaskRunner(cwd); const globalConfig = loadGlobalConfig(); + const shouldNotifyRunComplete = globalConfig.notificationSound !== false + && globalConfig.notificationSoundEvents?.runComplete !== false; + const shouldNotifyRunAbort = globalConfig.notificationSound !== false + && globalConfig.notificationSoundEvents?.runAbort !== false; const concurrency = globalConfig.concurrency; const recovered = taskRunner.recoverInterruptedRunningTasks(); if (recovered > 0) { @@ -260,15 +265,30 @@ export async function runAllTasks( info(`Concurrency: ${concurrency}`); } - const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs); + try { + const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs); + + const totalCount = result.success + result.fail; + blankLine(); + header('Tasks Summary'); + status('Total', String(totalCount)); + status('Success', String(result.success), result.success === totalCount ? 'green' : undefined); + if (result.fail > 0) { + status('Failed', String(result.fail), 'red'); + if (shouldNotifyRunAbort) { + notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: String(result.fail) })); + } + return; + } - const totalCount = result.success + result.fail; - blankLine(); - header('Tasks Summary'); - status('Total', String(totalCount)); - status('Success', String(result.success), result.success === totalCount ? 'green' : undefined); - if (result.fail > 0) { - status('Failed', String(result.fail), 'red'); + if (shouldNotifyRunComplete) { + notifySuccess('TAKT', getLabel('run.notifyComplete', undefined, { total: String(totalCount) })); + } + } catch (e) { + if (shouldNotifyRunAbort) { + notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: getErrorMessage(e) })); + } + throw e; } } diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 32f4280e..169853b8 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -110,6 +110,13 @@ export class GlobalConfigManager { branchNameStrategy: parsed.branch_name_strategy, preventSleep: parsed.prevent_sleep, notificationSound: parsed.notification_sound, + notificationSoundEvents: parsed.notification_sound_events ? { + iterationLimit: parsed.notification_sound_events.iteration_limit, + pieceComplete: parsed.notification_sound_events.piece_complete, + pieceAbort: parsed.notification_sound_events.piece_abort, + runComplete: parsed.notification_sound_events.run_complete, + runAbort: parsed.notification_sound_events.run_abort, + } : undefined, interactivePreviewMovements: parsed.interactive_preview_movements, concurrency: parsed.concurrency, taskPollIntervalMs: parsed.task_poll_interval_ms, @@ -185,6 +192,27 @@ export class GlobalConfigManager { if (config.notificationSound !== undefined) { raw.notification_sound = config.notificationSound; } + if (config.notificationSoundEvents) { + const eventRaw: Record = {}; + if (config.notificationSoundEvents.iterationLimit !== undefined) { + eventRaw.iteration_limit = config.notificationSoundEvents.iterationLimit; + } + if (config.notificationSoundEvents.pieceComplete !== undefined) { + eventRaw.piece_complete = config.notificationSoundEvents.pieceComplete; + } + if (config.notificationSoundEvents.pieceAbort !== undefined) { + eventRaw.piece_abort = config.notificationSoundEvents.pieceAbort; + } + if (config.notificationSoundEvents.runComplete !== undefined) { + eventRaw.run_complete = config.notificationSoundEvents.runComplete; + } + if (config.notificationSoundEvents.runAbort !== undefined) { + eventRaw.run_abort = config.notificationSoundEvents.runAbort; + } + if (Object.keys(eventRaw).length > 0) { + raw.notification_sound_events = eventRaw; + } + } if (config.interactivePreviewMovements !== undefined) { raw.interactive_preview_movements = config.interactivePreviewMovements; } diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 1fdbba17..ffbd4756 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -58,3 +58,7 @@ piece: notifyAbort: "Aborted: {reason}" sigintGraceful: "Ctrl+C: Aborting piece..." sigintForce: "Ctrl+C: Force exit" + +run: + notifyComplete: "Run complete ({total} tasks)" + notifyAbort: "Run finished with errors ({failed})" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 21af472a..0c508906 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -58,3 +58,7 @@ piece: notifyAbort: "中断: {reason}" sigintGraceful: "Ctrl+C: ピースを中断しています..." sigintForce: "Ctrl+C: 強制終了します" + +run: + notifyComplete: "run完了 ({total} tasks)" + notifyAbort: "runはエラー終了 ({failed})" From d185039c737af81214f7e45f9bc9ee51043ab527 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:10:08 +0900 Subject: [PATCH 13/45] takt: github-issue-194-takt-add (#206) --- src/__tests__/addTask.test.ts | 88 +++++++++++---------------------- src/app/cli/commands.ts | 2 +- src/features/tasks/add/index.ts | 69 +++++++++----------------- 3 files changed, 52 insertions(+), 107 deletions(-) diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 8b94428a..73961875 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -8,11 +8,6 @@ vi.mock('../features/interactive/index.js', () => ({ interactiveMode: vi.fn(), })); -vi.mock('../infra/config/global/globalConfig.js', () => ({ - loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })), - getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), -})); - vi.mock('../shared/prompt/index.js', () => ({ promptInput: vi.fn(), confirm: vi.fn(), @@ -38,15 +33,6 @@ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({ determinePiece: vi.fn(), })); -vi.mock('../infra/config/loaders/pieceResolver.js', () => ({ - getPieceDescription: vi.fn(() => ({ - name: 'default', - description: '', - pieceStructure: '1. implement\n2. review', - movementPreviews: [], - })), -})); - vi.mock('../infra/github/issue.js', () => ({ isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)), resolveIssueTask: vi.fn(), @@ -65,16 +51,17 @@ vi.mock('../infra/github/issue.js', () => ({ import { interactiveMode } from '../features/interactive/index.js'; import { promptInput, confirm } from '../shared/prompt/index.js'; +import { info } from '../shared/ui/index.js'; import { determinePiece } from '../features/tasks/execute/selectAndExecute.js'; -import { resolveIssueTask, createIssue } from '../infra/github/issue.js'; +import { resolveIssueTask } from '../infra/github/issue.js'; import { addTask } from '../features/tasks/index.js'; -const mockResolveIssueTask = vi.mocked(resolveIssueTask); const mockInteractiveMode = vi.mocked(interactiveMode); const mockPromptInput = vi.mocked(promptInput); const mockConfirm = vi.mocked(confirm); +const mockInfo = vi.mocked(info); const mockDeterminePiece = vi.mocked(determinePiece); -const mockCreateIssue = vi.mocked(createIssue); +const mockResolveIssueTask = vi.mocked(resolveIssueTask); let testDir: string; @@ -101,25 +88,38 @@ describe('addTask', () => { return fs.readFileSync(path.join(dir, String(taskDir), 'order.md'), 'utf-8'); } - it('should create task entry from interactive result', async () => { - mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' }); - + it('should show usage and exit when task is missing', async () => { await addTask(testDir); - const tasks = loadTasks(testDir).tasks; - expect(tasks).toHaveLength(1); - expect(tasks[0]?.content).toBeUndefined(); - expect(tasks[0]?.task_dir).toBeTypeOf('string'); - expect(readOrderContent(testDir, tasks[0]?.task_dir)).toContain('JWT認証を実装する'); - expect(tasks[0]?.piece).toBe('default'); + expect(mockInfo).toHaveBeenCalledWith('Usage: takt add '); + expect(mockDeterminePiece).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); + }); + + it('should show usage and exit when task is blank', async () => { + await addTask(testDir, ' '); + + expect(mockInfo).toHaveBeenCalledWith('Usage: takt add '); + expect(mockDeterminePiece).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); + }); + + it('should save plain text task without interactive mode', async () => { + await addTask(testDir, ' JWT認証を実装する '); + + expect(mockInteractiveMode).not.toHaveBeenCalled(); + const task = loadTasks(testDir).tasks[0]!; + expect(task.content).toBeUndefined(); + expect(task.task_dir).toBeTypeOf('string'); + expect(readOrderContent(testDir, task.task_dir)).toContain('JWT認証を実装する'); + expect(task.piece).toBe('default'); }); it('should include worktree settings when enabled', async () => { - mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'Task content' }); mockConfirm.mockResolvedValue(true); mockPromptInput.mockResolvedValueOnce('/custom/path').mockResolvedValueOnce('feat/branch'); - await addTask(testDir); + await addTask(testDir, 'Task content'); const task = loadTasks(testDir).tasks[0]!; expect(task.worktree).toBe('/custom/path'); @@ -128,7 +128,6 @@ describe('addTask', () => { it('should create task from issue reference without interactive mode', async () => { mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout'); - mockConfirm.mockResolvedValue(false); await addTask(testDir, '#99'); @@ -142,37 +141,8 @@ describe('addTask', () => { it('should not create task when piece selection is cancelled', async () => { mockDeterminePiece.mockResolvedValue(null); - await addTask(testDir); - - expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); - }); - - it('should create issue and save task when create_issue action is chosen', async () => { - // Given - mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature' }); - mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/55' }); - mockConfirm.mockResolvedValue(false); - - // When - await addTask(testDir); - - // Then - const tasks = loadTasks(testDir).tasks; - expect(tasks).toHaveLength(1); - expect(tasks[0]?.issue).toBe(55); - expect(tasks[0]?.content).toBeUndefined(); - expect(readOrderContent(testDir, tasks[0]?.task_dir)).toContain('New feature'); - }); - - it('should not save task when issue creation fails in create_issue action', async () => { - // Given - mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature' }); - mockCreateIssue.mockReturnValue({ success: false, error: 'auth failed' }); - - // When - await addTask(testDir); + await addTask(testDir, 'Task content'); - // Then expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); }); }); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 215e1b81..4c12eb10 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -30,7 +30,7 @@ program program .command('add') - .description('Add a new task (interactive AI conversation)') + .description('Add a new task') .argument('[task]', 'Task description or GitHub issue reference (e.g. "#28")') .action(async (task?: string) => { await addTask(resolvedCwd, task); diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 1b80800c..b179b633 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -1,8 +1,7 @@ /** * add command implementation * - * Starts an AI conversation to refine task requirements, - * then appends a task record to .takt/tasks.yaml. + * Appends a task record to .takt/tasks.yaml. */ import * as path from 'node:path'; @@ -10,11 +9,9 @@ import * as fs from 'node:fs'; import { promptInput, confirm } from '../../../shared/prompt/index.js'; import { success, info, error } from '../../../shared/ui/index.js'; import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js'; -import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.js'; import { determinePiece } from '../execute/selectAndExecute.js'; import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js'; import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js'; -import { interactiveMode } from '../../interactive/index.js'; const log = createLogger('add-task'); @@ -163,66 +160,44 @@ export async function saveTaskFromInteractive( * add command handler * * Flow: - * A) Issue参照の場合: issue取得 → ピース選択 → ワークツリー設定 → YAML作成 - * B) それ以外: ピース選択 → AI対話モード → ワークツリー設定 → YAML作成 + * A) 引数なし: Usage表示して終了 + * B) Issue参照の場合: issue取得 → ピース選択 → ワークツリー設定 → YAML作成 + * C) 通常入力: 引数をそのまま保存 */ export async function addTask(cwd: string, task?: string): Promise { - // ピース選択とタスク内容の決定 + const rawTask = task ?? ''; + const trimmedTask = rawTask.trim(); + if (!trimmedTask) { + info('Usage: takt add '); + return; + } + let taskContent: string; let issueNumber: number | undefined; - let piece: string | undefined; - if (task && isIssueReference(task)) { + if (isIssueReference(trimmedTask)) { // Issue reference: fetch issue and use directly as task content info('Fetching GitHub Issue...'); try { - taskContent = resolveIssueTask(task); - const numbers = parseIssueNumbers([task]); + taskContent = resolveIssueTask(trimmedTask); + const numbers = parseIssueNumbers([trimmedTask]); if (numbers.length > 0) { issueNumber = numbers[0]; } } catch (e) { const msg = getErrorMessage(e); - log.error('Failed to fetch GitHub Issue', { task, error: msg }); - info(`Failed to fetch issue ${task}: ${msg}`); - return; - } - - // ピース選択(issue取得成功後) - const pieceId = await determinePiece(cwd); - if (pieceId === null) { - info('Cancelled.'); + log.error('Failed to fetch GitHub Issue', { task: trimmedTask, error: msg }); + info(`Failed to fetch issue ${trimmedTask}: ${msg}`); return; } - piece = pieceId; } else { - // ピース選択を先に行い、結果を対話モードに渡す - const pieceId = await determinePiece(cwd); - if (pieceId === null) { - info('Cancelled.'); - return; - } - piece = pieceId; - - const globalConfig = loadGlobalConfig(); - const previewCount = globalConfig.interactivePreviewMovements; - const pieceContext = getPieceDescription(pieceId, cwd, previewCount); - - // Interactive mode: AI conversation to refine task - const result = await interactiveMode(cwd, undefined, pieceContext); - - if (result.action === 'create_issue') { - await createIssueAndSaveTask(cwd, result.task, piece); - return; - } - - if (result.action !== 'execute' && result.action !== 'save_task') { - info('Cancelled.'); - return; - } + taskContent = rawTask; + } - // interactiveMode already returns a summarized task from conversation - taskContent = result.task; + const piece = await determinePiece(cwd); + if (piece === null) { + info('Cancelled.'); + return; } // 3. ワークツリー/ブランチ/PR設定 From eb32cf0138190ebfacc4b89ca424c80c3a2b45ce Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:18:48 +0900 Subject: [PATCH 14/45] =?UTF-8?q?slug=20=E3=82=A8=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=82=A7=E3=83=B3=E3=83=88=E3=81=8C=E6=9A=B4=E8=B5=B0=E3=81=99?= =?UTF-8?q?=E3=82=8B=E3=81=AE=E3=82=92=E5=AF=BE=E5=87=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/prompts.test.ts | 1 + src/__tests__/summarize.test.ts | 33 +++++++++++-------- src/infra/task/summarize.ts | 5 +-- .../prompts/en/score_slug_user_prompt.md | 12 +++++++ .../prompts/ja/score_slug_user_prompt.md | 12 +++++++ 5 files changed, 47 insertions(+), 16 deletions(-) create mode 100644 src/shared/prompts/en/score_slug_user_prompt.md create mode 100644 src/shared/prompts/ja/score_slug_user_prompt.md diff --git a/src/__tests__/prompts.test.ts b/src/__tests__/prompts.test.ts index f6af374e..55fb8bf0 100644 --- a/src/__tests__/prompts.test.ts +++ b/src/__tests__/prompts.test.ts @@ -130,6 +130,7 @@ describe('template file existence', () => { 'score_interactive_policy', 'score_summary_system_prompt', 'score_slug_system_prompt', + 'score_slug_user_prompt', 'perform_phase1_message', 'perform_phase2_message', 'perform_phase3_message', diff --git a/src/__tests__/summarize.test.ts b/src/__tests__/summarize.test.ts index 20ba8652..db393829 100644 --- a/src/__tests__/summarize.test.ts +++ b/src/__tests__/summarize.test.ts @@ -57,20 +57,25 @@ describe('summarizeTaskName', () => { timestamp: new Date(), }); - // When - const result = await summarizeTaskName('long task name for testing', { cwd: '/project' }); - - // Then - expect(result).toBe('add-auth'); - expect(mockGetProvider).toHaveBeenCalledWith('claude'); - expect(mockProviderCall).toHaveBeenCalledWith( - 'long task name for testing', - expect.objectContaining({ - cwd: '/project', - allowedTools: [], - }) - ); - }); + // When + const result = await summarizeTaskName('long task name for testing', { cwd: '/project' }); + + // Then + expect(result).toBe('add-auth'); + expect(mockGetProvider).toHaveBeenCalledWith('claude'); + const callPrompt = mockProviderCall.mock.calls[0]?.[0]; + expect(callPrompt).toContain('Generate a slug from the task description below.'); + expect(callPrompt).toContain(''); + expect(callPrompt).toContain('long task name for testing'); + expect(callPrompt).toContain(''); + expect(mockProviderCall).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + cwd: '/project', + permissionMode: 'readonly', + }) + ); + }); it('should return AI-generated slug for English task name', async () => { // Given diff --git a/src/infra/task/summarize.ts b/src/infra/task/summarize.ts index 185bd130..a8c80415 100644 --- a/src/infra/task/summarize.ts +++ b/src/infra/task/summarize.ts @@ -70,10 +70,11 @@ export class TaskSummarizer { name: 'summarizer', systemPrompt: loadTemplate('score_slug_system_prompt', 'en'), }); - const response = await agent.call(taskName, { + const prompt = loadTemplate('score_slug_user_prompt', 'en', { taskDescription: taskName }); + const response = await agent.call(prompt, { cwd: options.cwd, model, - allowedTools: [], + permissionMode: 'readonly', }); const slug = sanitizeSlug(response.content); diff --git a/src/shared/prompts/en/score_slug_user_prompt.md b/src/shared/prompts/en/score_slug_user_prompt.md new file mode 100644 index 00000000..bcdd39da --- /dev/null +++ b/src/shared/prompts/en/score_slug_user_prompt.md @@ -0,0 +1,12 @@ + +Generate a slug from the task description below. +Output ONLY the slug text. + + +{{taskDescription}} + diff --git a/src/shared/prompts/ja/score_slug_user_prompt.md b/src/shared/prompts/ja/score_slug_user_prompt.md new file mode 100644 index 00000000..bcdd39da --- /dev/null +++ b/src/shared/prompts/ja/score_slug_user_prompt.md @@ -0,0 +1,12 @@ + +Generate a slug from the task description below. +Output ONLY the slug text. + + +{{taskDescription}} + From 9546806649c12af7ad4475e5b83d076e56e095d6 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:26:38 +0900 Subject: [PATCH 15/45] =?UTF-8?q?=E6=9A=B4=E8=B5=B0=E6=8A=91=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agents/ai-judge.ts | 2 +- src/core/piece/engine/OptionsBuilder.ts | 4 ++-- src/core/piece/judgment/FallbackStrategy.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agents/ai-judge.ts b/src/agents/ai-judge.ts index 1adc4398..178d0724 100644 --- a/src/agents/ai-judge.ts +++ b/src/agents/ai-judge.ts @@ -55,7 +55,7 @@ export const callAiJudge: AiJudgeCaller = async ( const response = await runAgent(undefined, prompt, { cwd: options.cwd, maxTurns: 1, - allowedTools: [], + permissionMode: 'readonly', }); if (response.status !== 'done') { diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index 9114ca04..bec67a40 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -82,8 +82,8 @@ export class OptionsBuilder { ): RunAgentOptions { return { ...this.buildBaseOptions(step), - // Do not pass permission mode in report/status phases. - permissionMode: undefined, + // Report/status phases are read-only regardless of movement settings. + permissionMode: 'readonly', sessionId, allowedTools: overrides.allowedTools, maxTurns: overrides.maxTurns, diff --git a/src/core/piece/judgment/FallbackStrategy.ts b/src/core/piece/judgment/FallbackStrategy.ts index 85f1225c..f3007c88 100644 --- a/src/core/piece/judgment/FallbackStrategy.ts +++ b/src/core/piece/judgment/FallbackStrategy.ts @@ -69,8 +69,8 @@ abstract class JudgmentStrategyBase implements JudgmentStrategy { protected async runConductor(instruction: string, context: JudgmentContext): Promise { const response = await runAgent('conductor', instruction, { cwd: context.cwd, - allowedTools: [], maxTurns: 3, + permissionMode: 'readonly', language: context.language, }); From 79ee3539907d207c723c00c5753335683e7b3d46 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:36:11 +0900 Subject: [PATCH 16/45] chore: add completion logs for branch and issue generation --- src/__tests__/cli-worktree.test.ts | 1 + src/__tests__/taskExecution.test.ts | 1 + src/app/cli/routing.ts | 2 ++ src/features/tasks/add/index.ts | 1 + src/features/tasks/execute/resolveTask.ts | 1 + src/features/tasks/execute/selectAndExecute.ts | 1 + 6 files changed, 7 insertions(+) diff --git a/src/__tests__/cli-worktree.test.ts b/src/__tests__/cli-worktree.test.ts index cd09d5eb..8ebb3cc7 100644 --- a/src/__tests__/cli-worktree.test.ts +++ b/src/__tests__/cli-worktree.test.ts @@ -199,6 +199,7 @@ describe('confirmAndCreateWorktree', () => { // Then expect(mockInfo).toHaveBeenCalledWith('Generating branch name...'); + expect(mockInfo).toHaveBeenCalledWith('Branch name generated: test-task'); }); it('should skip prompt when override is false', async () => { diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 68a08b19..e845b1aa 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -193,6 +193,7 @@ describe('resolveTaskExecution', () => { // Then expect(mockInfo).toHaveBeenCalledWith('Generating branch name...'); + expect(mockInfo).toHaveBeenCalledWith('Branch name generated: test-task'); }); it('should use task content (not name) for AI summarization', async () => { diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 5d49ffa0..2bf757ec 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -46,6 +46,7 @@ function resolveIssueInput( throw new Error(ghStatus.error); } const issue = fetchIssue(issueOption); + info(`GitHub Issue fetched: #${issue.number} ${issue.title}`); return { issues: [issue], initialInput: formatIssueAsTask(issue) }; } @@ -61,6 +62,7 @@ function resolveIssueInput( throw new Error(`Invalid issue reference: ${task}`); } const issues = issueNumbers.map((n) => fetchIssue(n)); + info(`GitHub Issues fetched: ${issues.map((issue) => `#${issue.number}`).join(', ')}`); return { issues, initialInput: issues.map(formatIssueAsTask).join('\n\n---\n\n') }; } diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index b179b633..13d348e2 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -183,6 +183,7 @@ export async function addTask(cwd: string, task?: string): Promise { const numbers = parseIssueNumbers([trimmedTask]); if (numbers.length > 0) { issueNumber = numbers[0]; + info(`GitHub Issue fetched: #${issueNumber}`); } } catch (e) { const msg = getErrorMessage(e); diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 632b0b74..3f6ea7ff 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -62,6 +62,7 @@ export async function resolveTaskExecution( baseBranch = getCurrentBranch(defaultCwd); info('Generating branch name...'); const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd }); + info(`Branch name generated: ${taskSlug}`); throwIfAborted(abortSignal); info('Creating clone...'); diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 94a7c52f..f41ec728 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -115,6 +115,7 @@ export async function confirmAndCreateWorktree( info('Generating branch name...'); const taskSlug = await summarizeTaskName(task, { cwd }); + info(`Branch name generated: ${taskSlug}`); info('Creating clone...'); const result = createSharedClone(cwd, { From 3fa99ae0f7b360c60414b2902b8ae4b98decb504 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:44:42 +0900 Subject: [PATCH 17/45] =?UTF-8?q?progress=E3=82=92=E3=82=8F=E3=81=8B?= =?UTF-8?q?=E3=82=8A=E3=82=84=E3=81=99=E3=81=8F=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/addTask.test.ts | 1 + .../cli-routing-issue-resolve.test.ts | 1 + src/__tests__/cli-worktree.test.ts | 25 +++++++++++------ src/__tests__/taskExecution.test.ts | 25 +++++++++++------ src/app/cli/routing.ts | 24 +++++++++------- src/features/tasks/add/index.ts | 11 +++++--- src/features/tasks/execute/resolveTask.ts | 28 +++++++++++-------- .../tasks/execute/selectAndExecute.ts | 24 +++++++++------- src/shared/ui/Progress.ts | 17 +++++++++++ src/shared/ui/index.ts | 2 ++ 10 files changed, 106 insertions(+), 52 deletions(-) create mode 100644 src/shared/ui/Progress.ts diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 73961875..ae79fbea 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -18,6 +18,7 @@ vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), blankLine: vi.fn(), error: vi.fn(), + withProgress: vi.fn(async (_start, _done, operation) => operation()), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index b7ed5d69..ca3dda0f 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), error: vi.fn(), + withProgress: vi.fn(async (_start, _done, operation) => operation()), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ diff --git a/src/__tests__/cli-worktree.test.ts b/src/__tests__/cli-worktree.test.ts index 8ebb3cc7..a8fbfa5e 100644 --- a/src/__tests__/cli-worktree.test.ts +++ b/src/__tests__/cli-worktree.test.ts @@ -28,14 +28,23 @@ vi.mock('../infra/task/summarize.js', () => ({ summarizeTaskName: vi.fn(), })); -vi.mock('../shared/ui/index.js', () => ({ - info: vi.fn(), - error: vi.fn(), - success: vi.fn(), - header: vi.fn(), - status: vi.fn(), - setLogLevel: vi.fn(), -})); +vi.mock('../shared/ui/index.js', () => { + const info = vi.fn(); + return { + info, + error: vi.fn(), + success: vi.fn(), + header: vi.fn(), + status: vi.fn(), + setLogLevel: vi.fn(), + withProgress: vi.fn(async (start, done, operation) => { + info(start); + const result = await operation(); + info(typeof done === 'function' ? done(result) : done); + return result; + }), + }; +}); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index e845b1aa..60d49d0b 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -40,14 +40,23 @@ vi.mock('../infra/task/summarize.js', async (importOriginal) => ({ summarizeTaskName: vi.fn(), })); -vi.mock('../shared/ui/index.js', () => ({ - header: vi.fn(), - info: vi.fn(), - error: vi.fn(), - success: vi.fn(), - status: vi.fn(), - blankLine: vi.fn(), -})); +vi.mock('../shared/ui/index.js', () => { + const info = vi.fn(); + return { + header: vi.fn(), + info, + error: vi.fn(), + success: vi.fn(), + status: vi.fn(), + blankLine: vi.fn(), + withProgress: vi.fn(async (start, done, operation) => { + info(start); + const result = await operation(); + info(typeof done === 'function' ? done(result) : done); + return result; + }), + }; +}); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 2bf757ec..43394416 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -5,7 +5,7 @@ * pipeline mode, or interactive mode. */ -import { info, error } from '../../shared/ui/index.js'; +import { info, error, withProgress } from '../../shared/ui/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { getLabel } from '../../shared/i18n/index.js'; import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js'; @@ -35,23 +35,24 @@ import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from ' * Returns resolved issues and the formatted task text for interactive mode. * Throws on gh CLI unavailability or fetch failure. */ -function resolveIssueInput( +async function resolveIssueInput( issueOption: number | undefined, task: string | undefined, -): { issues: GitHubIssue[]; initialInput: string } | null { +): Promise<{ issues: GitHubIssue[]; initialInput: string } | null> { if (issueOption) { - info('Fetching GitHub Issue...'); const ghStatus = checkGhCli(); if (!ghStatus.available) { throw new Error(ghStatus.error); } - const issue = fetchIssue(issueOption); - info(`GitHub Issue fetched: #${issue.number} ${issue.title}`); + const issue = await withProgress( + 'Fetching GitHub Issue...', + (fetchedIssue) => `GitHub Issue fetched: #${fetchedIssue.number} ${fetchedIssue.title}`, + async () => fetchIssue(issueOption), + ); return { issues: [issue], initialInput: formatIssueAsTask(issue) }; } if (task && isDirectTask(task)) { - info('Fetching GitHub Issue...'); const ghStatus = checkGhCli(); if (!ghStatus.available) { throw new Error(ghStatus.error); @@ -61,8 +62,11 @@ function resolveIssueInput( if (issueNumbers.length === 0) { throw new Error(`Invalid issue reference: ${task}`); } - const issues = issueNumbers.map((n) => fetchIssue(n)); - info(`GitHub Issues fetched: ${issues.map((issue) => `#${issue.number}`).join(', ')}`); + const issues = await withProgress( + 'Fetching GitHub Issue...', + (fetchedIssues) => `GitHub Issues fetched: ${fetchedIssues.map((issue) => `#${issue.number}`).join(', ')}`, + async () => issueNumbers.map((n) => fetchIssue(n)), + ); return { issues, initialInput: issues.map(formatIssueAsTask).join('\n\n---\n\n') }; } @@ -118,7 +122,7 @@ export async function executeDefaultAction(task?: string): Promise { let initialInput: string | undefined = task; try { - const issueResult = resolveIssueInput(opts.issue as number | undefined, task); + const issueResult = await resolveIssueInput(opts.issue as number | undefined, task); if (issueResult) { selectOptions.issues = issueResult.issues; initialInput = issueResult.initialInput; diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 13d348e2..643c3e5c 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -7,7 +7,7 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import { promptInput, confirm } from '../../../shared/prompt/index.js'; -import { success, info, error } from '../../../shared/ui/index.js'; +import { success, info, error, withProgress } from '../../../shared/ui/index.js'; import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js'; import { determinePiece } from '../execute/selectAndExecute.js'; import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js'; @@ -177,13 +177,16 @@ export async function addTask(cwd: string, task?: string): Promise { if (isIssueReference(trimmedTask)) { // Issue reference: fetch issue and use directly as task content - info('Fetching GitHub Issue...'); try { - taskContent = resolveIssueTask(trimmedTask); const numbers = parseIssueNumbers([trimmedTask]); + const primaryIssueNumber = numbers[0]; + taskContent = await withProgress( + 'Fetching GitHub Issue...', + primaryIssueNumber ? `GitHub Issue fetched: #${primaryIssueNumber}` : 'GitHub Issue fetched', + async () => resolveIssueTask(trimmedTask), + ); if (numbers.length > 0) { issueNumber = numbers[0]; - info(`GitHub Issue fetched: #${issueNumber}`); } } catch (e) { const msg = getErrorMessage(e); diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 3f6ea7ff..d36c439b 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -4,7 +4,7 @@ import { loadGlobalConfig } from '../../../infra/config/index.js'; import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; -import { info } from '../../../shared/ui/index.js'; +import { info, withProgress } from '../../../shared/ui/index.js'; import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js'; export interface ResolvedTaskExecution { @@ -60,23 +60,27 @@ export async function resolveTaskExecution( if (data.worktree) { throwIfAborted(abortSignal); baseBranch = getCurrentBranch(defaultCwd); - info('Generating branch name...'); - const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd }); - info(`Branch name generated: ${taskSlug}`); + const taskSlug = await withProgress( + 'Generating branch name...', + (slug) => `Branch name generated: ${slug}`, + () => summarizeTaskName(task.content, { cwd: defaultCwd }), + ); throwIfAborted(abortSignal); - info('Creating clone...'); - const result = createSharedClone(defaultCwd, { - worktree: data.worktree, - branch: data.branch, - taskSlug, - issueNumber: data.issue, - }); + const result = await withProgress( + 'Creating clone...', + (cloneResult) => `Clone created: ${cloneResult.path} (branch: ${cloneResult.branch})`, + async () => createSharedClone(defaultCwd, { + worktree: data.worktree, + branch: data.branch, + taskSlug, + issueNumber: data.issue, + }), + ); throwIfAborted(abortSignal); execCwd = result.path; branch = result.branch; isWorktree = true; - info(`Clone created: ${result.path} (branch: ${result.branch})`); } const execPiece = data.piece || defaultPiece; diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index f41ec728..5816921b 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -19,7 +19,7 @@ import { import { confirm } from '../../../shared/prompt/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; -import { info, error, success } from '../../../shared/ui/index.js'; +import { info, error, success, withProgress } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.js'; import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js'; import { executeTask } from './taskExecution.js'; @@ -113,16 +113,20 @@ export async function confirmAndCreateWorktree( const baseBranch = getCurrentBranch(cwd); - info('Generating branch name...'); - const taskSlug = await summarizeTaskName(task, { cwd }); - info(`Branch name generated: ${taskSlug}`); + const taskSlug = await withProgress( + 'Generating branch name...', + (slug) => `Branch name generated: ${slug}`, + () => summarizeTaskName(task, { cwd }), + ); - info('Creating clone...'); - const result = createSharedClone(cwd, { - worktree: true, - taskSlug, - }); - info(`Clone created: ${result.path} (branch: ${result.branch})`); + const result = await withProgress( + 'Creating clone...', + (cloneResult) => `Clone created: ${cloneResult.path} (branch: ${cloneResult.branch})`, + async () => createSharedClone(cwd, { + worktree: true, + taskSlug, + }), + ); return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch }; } diff --git a/src/shared/ui/Progress.ts b/src/shared/ui/Progress.ts new file mode 100644 index 00000000..bbb90a06 --- /dev/null +++ b/src/shared/ui/Progress.ts @@ -0,0 +1,17 @@ +import { info } from './LogManager.js'; + +export type ProgressCompletionMessage = string | ((result: T) => string); + +export async function withProgress( + startMessage: string, + completionMessage: ProgressCompletionMessage, + operation: () => Promise, +): Promise { + info(startMessage); + const result = await operation(); + const message = typeof completionMessage === 'function' + ? completionMessage(result) + : completionMessage; + info(message); + return result; +} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index faec0266..79689964 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -31,3 +31,5 @@ export { Spinner } from './Spinner.js'; export { StreamDisplay, type ProgressInfo } from './StreamDisplay.js'; export { TaskPrefixWriter } from './TaskPrefixWriter.js'; + +export { withProgress, type ProgressCompletionMessage } from './Progress.js'; From aeedf87a59224e0a364a9ac43307a0fd5417cb53 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:55:19 +0900 Subject: [PATCH 18/45] fix --- src/features/tasks/execute/resolveTask.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index d36c439b..a63fd252 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -71,7 +71,7 @@ export async function resolveTaskExecution( 'Creating clone...', (cloneResult) => `Clone created: ${cloneResult.path} (branch: ${cloneResult.branch})`, async () => createSharedClone(defaultCwd, { - worktree: data.worktree, + worktree: data.worktree!, branch: data.branch, taskSlug, issueNumber: data.issue, From 0214f7f5e6a02caa8979174433d68bf36a1f89f2 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:58:01 +0900 Subject: [PATCH 19/45] test: add withProgress mock in selectAndExecute autoPr test --- src/__tests__/selectAndExecute-autoPr.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index f4eeb2f4..b11fff32 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -30,6 +30,11 @@ vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), error: vi.fn(), success: vi.fn(), + withProgress: async ( + _startMessage: string, + _completionMessage: string | ((result: T) => string), + operation: () => Promise, + ): Promise => operation(), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ From de6b5b5c2c254ff548f80d8b97d932affe872e0e Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:43:29 +0900 Subject: [PATCH 20/45] takt: github-issue-212-max-iteration-max-movement-ostinato (#217) --- CLAUDE.md | 4 +- README.md | 6 +-- builtins/en/pieces/coding.yaml | 2 +- builtins/en/pieces/compound-eye.yaml | 2 +- builtins/en/pieces/default.yaml | 2 +- builtins/en/pieces/e2e-test.yaml | 2 +- builtins/en/pieces/expert-cqrs.yaml | 2 +- builtins/en/pieces/expert.yaml | 2 +- builtins/en/pieces/frontend.yaml | 2 +- builtins/en/pieces/magi.yaml | 2 +- builtins/en/pieces/minimal.yaml | 2 +- builtins/en/pieces/passthrough.yaml | 2 +- builtins/en/pieces/research.yaml | 8 +-- builtins/en/pieces/review-fix-minimal.yaml | 2 +- builtins/en/pieces/review-only.yaml | 2 +- builtins/en/pieces/structural-reform.yaml | 10 ++-- builtins/en/pieces/unit-test.yaml | 2 +- builtins/ja/INSTRUCTION_STYLE_GUIDE.md | 2 +- builtins/ja/pieces/coding.yaml | 2 +- builtins/ja/pieces/compound-eye.yaml | 2 +- builtins/ja/pieces/default.yaml | 2 +- builtins/ja/pieces/e2e-test.yaml | 2 +- builtins/ja/pieces/expert-cqrs.yaml | 2 +- builtins/ja/pieces/expert.yaml | 2 +- builtins/ja/pieces/frontend.yaml | 2 +- builtins/ja/pieces/magi.yaml | 2 +- builtins/ja/pieces/minimal.yaml | 2 +- builtins/ja/pieces/passthrough.yaml | 2 +- builtins/ja/pieces/research.yaml | 8 +-- builtins/ja/pieces/review-fix-minimal.yaml | 2 +- builtins/ja/pieces/review-only.yaml | 2 +- builtins/ja/pieces/structural-reform.yaml | 10 ++-- builtins/ja/pieces/unit-test.yaml | 2 +- builtins/skill/SKILL.md | 4 +- builtins/skill/references/engine.md | 6 +-- builtins/skill/references/yaml-schema.md | 4 +- docs/README.ja.md | 6 +-- docs/data-flow.md | 6 +-- docs/faceted-prompting.ja.md | 2 +- docs/faceted-prompting.md | 2 +- docs/pieces.md | 8 +-- e2e/fixtures/pieces/mock-max-iter.yaml | 4 +- e2e/fixtures/pieces/mock-no-match.yaml | 2 +- e2e/fixtures/pieces/mock-single-step.yaml | 2 +- e2e/fixtures/pieces/mock-slow-multi-step.yaml | 2 +- e2e/fixtures/pieces/mock-two-step.yaml | 2 +- e2e/fixtures/pieces/multi-step-parallel.yaml | 2 +- e2e/fixtures/pieces/report-judge.yaml | 2 +- e2e/fixtures/pieces/simple.yaml | 2 +- e2e/specs/piece-error-handling.e2e.ts | 8 +-- package-lock.json | 4 +- src/__tests__/StreamDisplay.test.ts | 6 +-- src/__tests__/config.test.ts | 2 +- src/__tests__/engine-abort.test.ts | 2 +- src/__tests__/engine-agent-overrides.test.ts | 6 +-- src/__tests__/engine-arpeggio.test.ts | 2 +- src/__tests__/engine-error.test.ts | 8 +-- src/__tests__/engine-happy-path.test.ts | 8 +-- src/__tests__/engine-loop-monitors.test.ts | 2 +- src/__tests__/engine-parallel-failure.test.ts | 2 +- .../engine-persona-providers.test.ts | 10 ++-- src/__tests__/engine-test-helpers.ts | 2 +- src/__tests__/engine-worktree-report.test.ts | 6 +-- src/__tests__/escape.test.ts | 12 ++--- src/__tests__/i18n.test.ts | 2 +- src/__tests__/instruction-helpers.test.ts | 2 +- src/__tests__/instructionBuilder.test.ts | 10 ++-- src/__tests__/it-error-recovery.test.ts | 4 +- src/__tests__/it-instruction-builder.test.ts | 10 ++-- src/__tests__/it-notification-sound.test.ts | 4 +- src/__tests__/it-piece-execution.test.ts | 8 +-- src/__tests__/it-piece-loader.test.ts | 24 ++++----- src/__tests__/it-pipeline-modes.test.ts | 2 +- src/__tests__/it-pipeline.test.ts | 2 +- src/__tests__/it-sigint-interrupt.test.ts | 2 +- .../it-three-phase-execution.test.ts | 12 ++--- src/__tests__/knowledge.test.ts | 2 +- src/__tests__/models.test.ts | 2 +- src/__tests__/parallel-and-loader.test.ts | 2 +- src/__tests__/parallel-logger.test.ts | 8 +-- src/__tests__/piece-categories.test.ts | 2 +- src/__tests__/piece-category-config.test.ts | 2 +- src/__tests__/piece-selection.test.ts | 2 +- .../pieceExecution-debug-prompts.test.ts | 2 +- src/__tests__/pieceLoader.test.ts | 4 +- src/__tests__/pieceResolver.test.ts | 52 +++++++++---------- src/__tests__/policy-persona.test.ts | 2 +- src/__tests__/review-only-piece.test.ts | 4 +- src/__tests__/runAllTasks-concurrency.test.ts | 2 +- src/__tests__/selectAndExecute-autoPr.test.ts | 2 +- src/__tests__/session.test.ts | 2 +- src/__tests__/state-manager.test.ts | 2 +- src/__tests__/switchPiece.test.ts | 2 +- src/__tests__/task-prefix-writer.test.ts | 2 +- src/__tests__/taskRetryActions.test.ts | 2 +- src/core/models/piece-types.ts | 2 +- src/core/models/schemas.ts | 2 +- src/core/models/session.ts | 4 +- src/core/piece/constants.ts | 2 +- src/core/piece/engine/MovementExecutor.ts | 8 +-- src/core/piece/engine/ParallelRunner.ts | 10 ++-- src/core/piece/engine/PieceEngine.ts | 24 ++++----- src/core/piece/engine/parallel-logger.ts | 12 ++--- .../piece/instruction/InstructionBuilder.ts | 2 +- .../instruction/ReportInstructionBuilder.ts | 2 +- src/core/piece/instruction/escape.ts | 4 +- .../piece/instruction/instruction-context.ts | 4 +- src/core/piece/types.ts | 6 +-- src/features/prompt/preview.ts | 2 +- src/features/tasks/execute/pieceExecution.ts | 10 ++-- src/infra/config/loaders/pieceParser.ts | 2 +- src/shared/i18n/labels_en.yaml | 2 +- src/shared/i18n/labels_ja.yaml | 2 +- src/shared/ui/StreamDisplay.ts | 10 ++-- src/shared/ui/TaskPrefixWriter.ts | 6 +-- 115 files changed, 266 insertions(+), 266 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c4b2a609..f26ee875 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -218,7 +218,7 @@ Builtin resources are embedded in the npm package (`builtins/`). User files in ` ```yaml name: piece-name description: Optional description -max_iterations: 10 +max_movements: 10 initial_step: plan # First step to execute steps: @@ -291,7 +291,7 @@ Key points about parallel steps: |----------|-------------| | `{task}` | Original user request (auto-injected if not in template) | | `{iteration}` | Piece-wide iteration count | -| `{max_iterations}` | Maximum iterations allowed | +| `{max_movements}` | Maximum movements allowed | | `{step_iteration}` | Per-step iteration count | | `{previous_response}` | Previous step output (auto-injected if not in template) | | `{user_inputs}` | Accumulated user inputs (auto-injected if not in template) | diff --git a/README.md b/README.md index 397b5349..d461c4e5 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ TAKT uses YAML-based piece definitions and rule-based routing. Builtin pieces ar ```yaml name: default -max_iterations: 10 +max_movements: 10 initial_movement: plan # Section maps — key: file path (relative to this YAML) @@ -709,7 +709,7 @@ takt eject default # ~/.takt/pieces/my-piece.yaml name: my-piece description: Custom piece -max_iterations: 5 +max_movements: 5 initial_movement: analyze personas: @@ -759,7 +759,7 @@ Variables available in `instruction_template`: |----------|-------------| | `{task}` | Original user request (auto-injected if not in template) | | `{iteration}` | Piece-wide turn count (total steps executed) | -| `{max_iterations}` | Maximum iteration count | +| `{max_movements}` | Maximum iteration count | | `{movement_iteration}` | Per-movement iteration count (times this movement has been executed) | | `{previous_response}` | Output from previous movement (auto-injected if not in template) | | `{user_inputs}` | Additional user inputs during piece (auto-injected if not in template) | diff --git a/builtins/en/pieces/coding.yaml b/builtins/en/pieces/coding.yaml index eea34623..ba288923 100644 --- a/builtins/en/pieces/coding.yaml +++ b/builtins/en/pieces/coding.yaml @@ -1,6 +1,6 @@ name: coding description: Lightweight development piece with planning and parallel reviews (plan -> implement -> parallel review -> complete) -max_iterations: 20 +max_movements: 20 initial_movement: plan movements: - name: plan diff --git a/builtins/en/pieces/compound-eye.yaml b/builtins/en/pieces/compound-eye.yaml index 9be6d4e3..8c37938a 100644 --- a/builtins/en/pieces/compound-eye.yaml +++ b/builtins/en/pieces/compound-eye.yaml @@ -1,6 +1,6 @@ name: compound-eye description: Multi-model review - send the same instruction to Claude and Codex simultaneously, synthesize both responses -max_iterations: 10 +max_movements: 10 initial_movement: evaluate movements: - name: evaluate diff --git a/builtins/en/pieces/default.yaml b/builtins/en/pieces/default.yaml index bd7626e0..05afa4fd 100644 --- a/builtins/en/pieces/default.yaml +++ b/builtins/en/pieces/default.yaml @@ -1,6 +1,6 @@ name: default description: Standard development piece with planning and specialized reviews -max_iterations: 30 +max_movements: 30 initial_movement: plan loop_monitors: - cycle: diff --git a/builtins/en/pieces/e2e-test.yaml b/builtins/en/pieces/e2e-test.yaml index eab791d2..ae582e95 100644 --- a/builtins/en/pieces/e2e-test.yaml +++ b/builtins/en/pieces/e2e-test.yaml @@ -1,6 +1,6 @@ name: e2e-test description: E2E test focused piece (E2E analysis → E2E implementation → review → fix) -max_iterations: 20 +max_movements: 20 initial_movement: plan_test loop_monitors: - cycle: diff --git a/builtins/en/pieces/expert-cqrs.yaml b/builtins/en/pieces/expert-cqrs.yaml index 217e713c..8cc7e621 100644 --- a/builtins/en/pieces/expert-cqrs.yaml +++ b/builtins/en/pieces/expert-cqrs.yaml @@ -1,6 +1,6 @@ name: expert-cqrs description: CQRS+ES, Frontend, Security, QA Expert Review -max_iterations: 30 +max_movements: 30 initial_movement: plan movements: - name: plan diff --git a/builtins/en/pieces/expert.yaml b/builtins/en/pieces/expert.yaml index a3830c55..3ec13b86 100644 --- a/builtins/en/pieces/expert.yaml +++ b/builtins/en/pieces/expert.yaml @@ -1,6 +1,6 @@ name: expert description: Architecture, Frontend, Security, QA Expert Review -max_iterations: 30 +max_movements: 30 initial_movement: plan movements: - name: plan diff --git a/builtins/en/pieces/frontend.yaml b/builtins/en/pieces/frontend.yaml index ff319d3f..d862c548 100644 --- a/builtins/en/pieces/frontend.yaml +++ b/builtins/en/pieces/frontend.yaml @@ -1,6 +1,6 @@ name: frontend description: Frontend, Security, QA Expert Review -max_iterations: 30 +max_movements: 30 initial_movement: plan movements: - name: plan diff --git a/builtins/en/pieces/magi.yaml b/builtins/en/pieces/magi.yaml index f6ee7a6c..76631ed9 100644 --- a/builtins/en/pieces/magi.yaml +++ b/builtins/en/pieces/magi.yaml @@ -1,6 +1,6 @@ name: magi description: MAGI Deliberation System - Analyze from 3 perspectives and decide by majority -max_iterations: 5 +max_movements: 5 initial_movement: melchior movements: - name: melchior diff --git a/builtins/en/pieces/minimal.yaml b/builtins/en/pieces/minimal.yaml index 89017c35..344e95d4 100644 --- a/builtins/en/pieces/minimal.yaml +++ b/builtins/en/pieces/minimal.yaml @@ -1,6 +1,6 @@ name: minimal description: Minimal development piece (implement -> parallel review -> fix if needed -> complete) -max_iterations: 20 +max_movements: 20 initial_movement: implement movements: - name: implement diff --git a/builtins/en/pieces/passthrough.yaml b/builtins/en/pieces/passthrough.yaml index e9ae5e10..f4fbae57 100644 --- a/builtins/en/pieces/passthrough.yaml +++ b/builtins/en/pieces/passthrough.yaml @@ -1,6 +1,6 @@ name: passthrough description: Single-agent thin wrapper. Pass task directly to coder as-is. -max_iterations: 10 +max_movements: 10 initial_movement: execute movements: - name: execute diff --git a/builtins/en/pieces/research.yaml b/builtins/en/pieces/research.yaml index f88ac39b..75ec5c00 100644 --- a/builtins/en/pieces/research.yaml +++ b/builtins/en/pieces/research.yaml @@ -1,6 +1,6 @@ name: research description: Research piece - autonomously executes research without asking questions -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: - name: plan @@ -13,7 +13,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: plan @@ -48,7 +48,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: dig @@ -88,7 +88,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: supervise (research quality evaluation) diff --git a/builtins/en/pieces/review-fix-minimal.yaml b/builtins/en/pieces/review-fix-minimal.yaml index 79dbeab6..26051de5 100644 --- a/builtins/en/pieces/review-fix-minimal.yaml +++ b/builtins/en/pieces/review-fix-minimal.yaml @@ -1,6 +1,6 @@ name: review-fix-minimal description: Review and fix piece for existing code (starts with review, no implementation) -max_iterations: 20 +max_movements: 20 initial_movement: reviewers movements: - name: implement diff --git a/builtins/en/pieces/review-only.yaml b/builtins/en/pieces/review-only.yaml index eabf0313..da4fed89 100644 --- a/builtins/en/pieces/review-only.yaml +++ b/builtins/en/pieces/review-only.yaml @@ -1,6 +1,6 @@ name: review-only description: Review-only piece - reviews code without making edits -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: - name: plan diff --git a/builtins/en/pieces/structural-reform.yaml b/builtins/en/pieces/structural-reform.yaml index bbff4b9d..8a268f4f 100644 --- a/builtins/en/pieces/structural-reform.yaml +++ b/builtins/en/pieces/structural-reform.yaml @@ -1,6 +1,6 @@ name: structural-reform description: Full project review and structural reform - iterative codebase restructuring with staged file splits -max_iterations: 50 +max_movements: 50 initial_movement: review loop_monitors: - cycle: @@ -44,7 +44,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: review (full project review) @@ -126,7 +126,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: plan_reform (reform plan creation) @@ -323,7 +323,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: verify (build and test verification) @@ -378,7 +378,7 @@ movements: - WebFetch instruction_template: | ## Piece Status - - Iteration: {iteration}/{max_iterations} (piece-wide) + - Iteration: {iteration}/{max_movements} (piece-wide) - Movement Iteration: {movement_iteration} (times this movement has run) - Movement: next_target (progress check and next target selection) diff --git a/builtins/en/pieces/unit-test.yaml b/builtins/en/pieces/unit-test.yaml index e8a1dfb3..819e8b42 100644 --- a/builtins/en/pieces/unit-test.yaml +++ b/builtins/en/pieces/unit-test.yaml @@ -1,6 +1,6 @@ name: unit-test description: Unit test focused piece (test analysis → test implementation → review → fix) -max_iterations: 20 +max_movements: 20 initial_movement: plan_test loop_monitors: - cycle: diff --git a/builtins/ja/INSTRUCTION_STYLE_GUIDE.md b/builtins/ja/INSTRUCTION_STYLE_GUIDE.md index d0cd662b..b7bb45f7 100644 --- a/builtins/ja/INSTRUCTION_STYLE_GUIDE.md +++ b/builtins/ja/INSTRUCTION_STYLE_GUIDE.md @@ -82,7 +82,7 @@ InstructionBuilder が instruction_template 内の `{変数名}` を展開する | 変数 | 内容 | |------|------| | `{iteration}` | ピース全体のイテレーション数 | -| `{max_iterations}` | 最大イテレーション数 | +| `{max_movements}` | 最大イテレーション数 | | `{movement_iteration}` | ムーブメント単位のイテレーション数 | | `{report_dir}` | レポートディレクトリ名(`.takt/runs/{slug}/reports`) | | `{report:filename}` | 指定レポートの内容展開(ファイルが存在する場合) | diff --git a/builtins/ja/pieces/coding.yaml b/builtins/ja/pieces/coding.yaml index 44908d07..990ff1b2 100644 --- a/builtins/ja/pieces/coding.yaml +++ b/builtins/ja/pieces/coding.yaml @@ -1,6 +1,6 @@ name: coding description: Lightweight development piece with planning and parallel reviews (plan -> implement -> parallel review -> complete) -max_iterations: 20 +max_movements: 20 initial_movement: plan movements: - name: plan diff --git a/builtins/ja/pieces/compound-eye.yaml b/builtins/ja/pieces/compound-eye.yaml index e4c41bb2..8a94f414 100644 --- a/builtins/ja/pieces/compound-eye.yaml +++ b/builtins/ja/pieces/compound-eye.yaml @@ -1,6 +1,6 @@ name: compound-eye description: 複眼レビュー - 同じ指示を Claude と Codex に同時に投げ、両者の回答を統合する -max_iterations: 10 +max_movements: 10 initial_movement: evaluate movements: diff --git a/builtins/ja/pieces/default.yaml b/builtins/ja/pieces/default.yaml index 3e262839..258cdb0f 100644 --- a/builtins/ja/pieces/default.yaml +++ b/builtins/ja/pieces/default.yaml @@ -1,6 +1,6 @@ name: default description: Standard development piece with planning and specialized reviews -max_iterations: 30 +max_movements: 30 initial_movement: plan loop_monitors: - cycle: diff --git a/builtins/ja/pieces/e2e-test.yaml b/builtins/ja/pieces/e2e-test.yaml index 1ce02622..6096e980 100644 --- a/builtins/ja/pieces/e2e-test.yaml +++ b/builtins/ja/pieces/e2e-test.yaml @@ -1,6 +1,6 @@ name: e2e-test description: E2Eテスト追加に特化したピース(E2E分析→E2E実装→レビュー→修正) -max_iterations: 20 +max_movements: 20 initial_movement: plan_test loop_monitors: - cycle: diff --git a/builtins/ja/pieces/expert-cqrs.yaml b/builtins/ja/pieces/expert-cqrs.yaml index 664375a4..277fd97b 100644 --- a/builtins/ja/pieces/expert-cqrs.yaml +++ b/builtins/ja/pieces/expert-cqrs.yaml @@ -1,6 +1,6 @@ name: expert-cqrs description: CQRS+ES・フロントエンド・セキュリティ・QA専門家レビュー -max_iterations: 30 +max_movements: 30 initial_movement: plan movements: - name: plan diff --git a/builtins/ja/pieces/expert.yaml b/builtins/ja/pieces/expert.yaml index 0290e56d..c5195ea7 100644 --- a/builtins/ja/pieces/expert.yaml +++ b/builtins/ja/pieces/expert.yaml @@ -1,6 +1,6 @@ name: expert description: アーキテクチャ・フロントエンド・セキュリティ・QA専門家レビュー -max_iterations: 30 +max_movements: 30 initial_movement: plan movements: - name: plan diff --git a/builtins/ja/pieces/frontend.yaml b/builtins/ja/pieces/frontend.yaml index 92af0d6a..0d46ff1f 100644 --- a/builtins/ja/pieces/frontend.yaml +++ b/builtins/ja/pieces/frontend.yaml @@ -1,6 +1,6 @@ name: frontend description: フロントエンド・セキュリティ・QA専門家レビュー -max_iterations: 30 +max_movements: 30 initial_movement: plan movements: - name: plan diff --git a/builtins/ja/pieces/magi.yaml b/builtins/ja/pieces/magi.yaml index 679329b9..83614cb1 100644 --- a/builtins/ja/pieces/magi.yaml +++ b/builtins/ja/pieces/magi.yaml @@ -1,6 +1,6 @@ name: magi description: MAGI合議システム - 3つの観点から分析し多数決で判定 -max_iterations: 5 +max_movements: 5 initial_movement: melchior movements: - name: melchior diff --git a/builtins/ja/pieces/minimal.yaml b/builtins/ja/pieces/minimal.yaml index 418f66a4..c190ac9f 100644 --- a/builtins/ja/pieces/minimal.yaml +++ b/builtins/ja/pieces/minimal.yaml @@ -1,6 +1,6 @@ name: minimal description: Minimal development piece (implement -> parallel review -> fix if needed -> complete) -max_iterations: 20 +max_movements: 20 initial_movement: implement movements: - name: implement diff --git a/builtins/ja/pieces/passthrough.yaml b/builtins/ja/pieces/passthrough.yaml index b2b9d486..ac4cb8b1 100644 --- a/builtins/ja/pieces/passthrough.yaml +++ b/builtins/ja/pieces/passthrough.yaml @@ -1,6 +1,6 @@ name: passthrough description: Single-agent thin wrapper. Pass task directly to coder as-is. -max_iterations: 10 +max_movements: 10 initial_movement: execute movements: - name: execute diff --git a/builtins/ja/pieces/research.yaml b/builtins/ja/pieces/research.yaml index 67dbb4c9..78f25216 100644 --- a/builtins/ja/pieces/research.yaml +++ b/builtins/ja/pieces/research.yaml @@ -1,6 +1,6 @@ name: research description: 調査ピース - 質問せずに自律的に調査を実行 -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: - name: plan @@ -13,7 +13,7 @@ movements: - WebFetch instruction_template: | ## ピース状況 - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメント実行回数: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: plan @@ -48,7 +48,7 @@ movements: - WebFetch instruction_template: | ## ピース状況 - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメント実行回数: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: dig @@ -88,7 +88,7 @@ movements: - WebFetch instruction_template: | ## ピース状況 - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメント実行回数: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: supervise (調査品質評価) diff --git a/builtins/ja/pieces/review-fix-minimal.yaml b/builtins/ja/pieces/review-fix-minimal.yaml index e9b0fc99..f9f33003 100644 --- a/builtins/ja/pieces/review-fix-minimal.yaml +++ b/builtins/ja/pieces/review-fix-minimal.yaml @@ -1,6 +1,6 @@ name: review-fix-minimal description: 既存コードのレビューと修正ピース(レビュー開始、実装なし) -max_iterations: 20 +max_movements: 20 initial_movement: reviewers movements: - name: implement diff --git a/builtins/ja/pieces/review-only.yaml b/builtins/ja/pieces/review-only.yaml index 75aea59c..6a8a0d93 100644 --- a/builtins/ja/pieces/review-only.yaml +++ b/builtins/ja/pieces/review-only.yaml @@ -1,6 +1,6 @@ name: review-only description: レビュー専用ピース - コードをレビューするだけで編集は行わない -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: - name: plan diff --git a/builtins/ja/pieces/structural-reform.yaml b/builtins/ja/pieces/structural-reform.yaml index a765026a..3cb04196 100644 --- a/builtins/ja/pieces/structural-reform.yaml +++ b/builtins/ja/pieces/structural-reform.yaml @@ -1,6 +1,6 @@ name: structural-reform description: プロジェクト全体レビューと構造改革 - 段階的なファイル分割による反復的コードベース再構築 -max_iterations: 50 +max_movements: 50 initial_movement: review loop_monitors: - cycle: @@ -44,7 +44,7 @@ movements: - WebFetch instruction_template: | ## ピースステータス - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメントイテレーション: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: review(プロジェクト全体レビュー) @@ -126,7 +126,7 @@ movements: - WebFetch instruction_template: | ## ピースステータス - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメントイテレーション: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: plan_reform(改革計画策定) @@ -323,7 +323,7 @@ movements: - WebFetch instruction_template: | ## ピースステータス - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメントイテレーション: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: verify(ビルド・テスト検証) @@ -378,7 +378,7 @@ movements: - WebFetch instruction_template: | ## ピースステータス - - イテレーション: {iteration}/{max_iterations}(ピース全体) + - イテレーション: {iteration}/{max_movements}(ピース全体) - ムーブメントイテレーション: {movement_iteration}(このムーブメントの実行回数) - ムーブメント: next_target(進捗確認と次ターゲット選択) diff --git a/builtins/ja/pieces/unit-test.yaml b/builtins/ja/pieces/unit-test.yaml index 58598659..3c5e94c6 100644 --- a/builtins/ja/pieces/unit-test.yaml +++ b/builtins/ja/pieces/unit-test.yaml @@ -1,6 +1,6 @@ name: unit-test description: 単体テスト追加に特化したピース(テスト分析→テスト実装→レビュー→修正) -max_iterations: 20 +max_movements: 20 initial_movement: plan_test loop_monitors: - cycle: diff --git a/builtins/skill/SKILL.md b/builtins/skill/SKILL.md index e89477b4..0072aadb 100644 --- a/builtins/skill/SKILL.md +++ b/builtins/skill/SKILL.md @@ -83,7 +83,7 @@ $ARGUMENTS を以下のように解析する: 3. 見つからない場合: 上記2ディレクトリを Glob で列挙し、AskUserQuestion で選択させる YAMLから以下を抽出する(→ references/yaml-schema.md 参照): -- `name`, `max_iterations`, `initial_movement`, `movements` 配列 +- `name`, `max_movements`, `initial_movement`, `movements` 配列 - セクションマップ: `personas`, `policies`, `instructions`, `output_contracts`, `knowledge` ### 手順 2: セクションリソースの事前読み込み @@ -130,7 +130,7 @@ TeamCreate tool を呼ぶ: ### 手順 5: チームメイト起動 -**iteration が max_iterations を超えていたら → 手順 8(ABORT: イテレーション上限)に進む。** +**iteration が max_movements を超えていたら → 手順 8(ABORT: イテレーション上限)に進む。** current_movement のプロンプトを構築する(→ references/engine.md のプロンプト構築を参照)。 diff --git a/builtins/skill/references/engine.md b/builtins/skill/references/engine.md index df58ab0c..2158db01 100644 --- a/builtins/skill/references/engine.md +++ b/builtins/skill/references/engine.md @@ -133,7 +133,7 @@ movement の `instruction:` キーから指示テンプレートファイルを - ワーキングディレクトリ: {cwd} - ピース: {piece_name} - Movement: {movement_name} -- イテレーション: {iteration} / {max_iterations} +- イテレーション: {iteration} / {max_movements} - Movement イテレーション: {movement_iteration} 回目 ``` @@ -146,7 +146,7 @@ movement の `instruction:` キーから指示テンプレートファイルを | `{task}` | ユーザーが入力したタスク内容 | | `{previous_response}` | 前の movement のチームメイト出力 | | `{iteration}` | ピース全体のイテレーション数(1始まり) | -| `{max_iterations}` | ピースの max_iterations 値 | +| `{max_movements}` | ピースの max_movements 値 | | `{movement_iteration}` | この movement が実行された回数(1始まり) | | `{report_dir}` | レポートディレクトリパス(`.takt/runs/{slug}/reports`) | | `{report:ファイル名}` | 指定レポートファイルの内容(Read で取得) | @@ -317,7 +317,7 @@ parallel のサブステップにも同様にタグ出力指示を注入する ### 基本ルール - 同じ movement が連続3回以上実行されたら警告を表示する -- `max_iterations` に到達したら強制終了(ABORT)する +- `max_movements` に到達したら強制終了(ABORT)する ### カウンター管理 diff --git a/builtins/skill/references/yaml-schema.md b/builtins/skill/references/yaml-schema.md index 81094695..54e00d20 100644 --- a/builtins/skill/references/yaml-schema.md +++ b/builtins/skill/references/yaml-schema.md @@ -7,7 +7,7 @@ ```yaml name: piece-name # ピース名(必須) description: 説明テキスト # ピースの説明(任意) -max_iterations: 10 # 最大イテレーション数(必須) +max_movements: 10 # 最大イテレーション数(必須) initial_movement: plan # 最初に実行する movement 名(必須) # セクションマップ(キー → ファイルパスの対応表) @@ -192,7 +192,7 @@ quality_gates: | `{task}` | ユーザーのタスク入力(template に含まれない場合は自動追加) | | `{previous_response}` | 前の movement の出力(pass_previous_response: true 時、自動追加) | | `{iteration}` | ピース全体のイテレーション数 | -| `{max_iterations}` | 最大イテレーション数 | +| `{max_movements}` | 最大イテレーション数 | | `{movement_iteration}` | この movement の実行回数 | | `{report_dir}` | レポートディレクトリ名 | | `{report:ファイル名}` | 指定レポートファイルの内容を展開 | diff --git a/docs/README.ja.md b/docs/README.ja.md index 91652676..258a37a3 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -335,7 +335,7 @@ TAKTはYAMLベースのピース定義とルールベースルーティングを ```yaml name: default -max_iterations: 10 +max_movements: 10 initial_movement: plan # セクションマップ — キー: ファイルパス(このYAMLからの相対パス) @@ -709,7 +709,7 @@ takt eject default # ~/.takt/pieces/my-piece.yaml name: my-piece description: カスタムピース -max_iterations: 5 +max_movements: 5 initial_movement: analyze personas: @@ -759,7 +759,7 @@ personas: |------|------| | `{task}` | 元のユーザーリクエスト(テンプレートになければ自動注入) | | `{iteration}` | ピース全体のターン数(実行された全ムーブメント数) | -| `{max_iterations}` | 最大イテレーション数 | +| `{max_movements}` | 最大イテレーション数 | | `{movement_iteration}` | ムーブメントごとのイテレーション数(このムーブメントが実行された回数) | | `{previous_response}` | 前のムーブメントの出力(テンプレートになければ自動注入) | | `{user_inputs}` | ピース中の追加ユーザー入力(テンプレートになければ自動注入) | diff --git a/docs/data-flow.md b/docs/data-flow.md index e67e7160..25fc1261 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -498,7 +498,7 @@ TAKTのデータフローは以下の7つの主要なレイヤーで構成され while (state.status === 'running') { // 1. Abort & Iteration チェック if (abortRequested) { ... } - if (iteration >= maxIterations) { ... } + if (iteration >= maxMovements) { ... } // 2. ステップ取得 const step = getStep(state.currentStep); @@ -646,7 +646,7 @@ const match = await detectMatchedRule(step, response.content, tagContent, {...}) - `{previous_response}`: 前ステップの出力 - `{user_inputs}`: 追加ユーザー入力 - `{iteration}`: ピース全体のイテレーション -- `{max_iterations}`: 最大イテレーション +- `{max_movements}`: 最大イテレーション - `{step_iteration}`: ステップのイテレーション - `{report_dir}`: レポートディレクトリ @@ -824,7 +824,7 @@ new PieceEngine(pieceConfig, cwd, task, { 1. **コンテキスト収集**: - `task`: 元のユーザーリクエスト - - `iteration`, `maxIterations`: イテレーション情報 + - `iteration`, `maxMovements`: イテレーション情報 - `stepIteration`: ステップごとの実行回数 - `cwd`, `projectCwd`: ディレクトリ情報 - `userInputs`: blocked時の追加入力 diff --git a/docs/faceted-prompting.ja.md b/docs/faceted-prompting.ja.md index c6fe0257..05daba9e 100644 --- a/docs/faceted-prompting.ja.md +++ b/docs/faceted-prompting.ja.md @@ -331,7 +331,7 @@ Faceted Promptingの中核メカニズムは**宣言的な合成**である。 ```yaml name: my-workflow -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: diff --git a/docs/faceted-prompting.md b/docs/faceted-prompting.md index 0336288e..f0100fef 100644 --- a/docs/faceted-prompting.md +++ b/docs/faceted-prompting.md @@ -331,7 +331,7 @@ Key properties: ```yaml name: my-workflow -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: diff --git a/docs/pieces.md b/docs/pieces.md index 6c28c4dc..1663197b 100644 --- a/docs/pieces.md +++ b/docs/pieces.md @@ -25,7 +25,7 @@ A piece is a YAML file that defines a sequence of steps executed by AI agents. E ```yaml name: my-piece description: Optional description -max_iterations: 10 +max_movements: 10 initial_step: first-step # Optional, defaults to first step steps: @@ -55,7 +55,7 @@ steps: |----------|-------------| | `{task}` | Original user request (auto-injected if not in template) | | `{iteration}` | Piece-wide turn count (total steps executed) | -| `{max_iterations}` | Maximum iterations allowed | +| `{max_movements}` | Maximum movements allowed | | `{step_iteration}` | Per-step iteration count (how many times THIS step has run) | | `{previous_response}` | Previous step's output (auto-injected if not in template) | | `{user_inputs}` | Additional user inputs during piece (auto-injected if not in template) | @@ -170,7 +170,7 @@ report: ```yaml name: simple-impl -max_iterations: 5 +max_movements: 5 steps: - name: implement @@ -191,7 +191,7 @@ steps: ```yaml name: with-review -max_iterations: 10 +max_movements: 10 steps: - name: implement diff --git a/e2e/fixtures/pieces/mock-max-iter.yaml b/e2e/fixtures/pieces/mock-max-iter.yaml index 5e1f0614..1912fde9 100644 --- a/e2e/fixtures/pieces/mock-max-iter.yaml +++ b/e2e/fixtures/pieces/mock-max-iter.yaml @@ -1,7 +1,7 @@ name: e2e-mock-max-iter -description: Piece with max_iterations=2 that loops between two steps +description: Piece with max_movements=2 that loops between two steps -max_iterations: 2 +max_movements: 2 initial_movement: step-a diff --git a/e2e/fixtures/pieces/mock-no-match.yaml b/e2e/fixtures/pieces/mock-no-match.yaml index 69e4771a..493bd88b 100644 --- a/e2e/fixtures/pieces/mock-no-match.yaml +++ b/e2e/fixtures/pieces/mock-no-match.yaml @@ -1,7 +1,7 @@ name: e2e-mock-no-match description: Piece with a strict rule condition that will not match mock output -max_iterations: 3 +max_movements: 3 movements: - name: execute diff --git a/e2e/fixtures/pieces/mock-single-step.yaml b/e2e/fixtures/pieces/mock-single-step.yaml index 6ad42fbe..087869c7 100644 --- a/e2e/fixtures/pieces/mock-single-step.yaml +++ b/e2e/fixtures/pieces/mock-single-step.yaml @@ -1,7 +1,7 @@ name: e2e-mock-single description: Minimal mock-only piece for CLI E2E -max_iterations: 3 +max_movements: 3 movements: - name: execute diff --git a/e2e/fixtures/pieces/mock-slow-multi-step.yaml b/e2e/fixtures/pieces/mock-slow-multi-step.yaml index 5e4d8d06..8da8f3fc 100644 --- a/e2e/fixtures/pieces/mock-slow-multi-step.yaml +++ b/e2e/fixtures/pieces/mock-slow-multi-step.yaml @@ -1,7 +1,7 @@ name: e2e-mock-slow-multi-step description: Multi-step mock piece to keep tasks in-flight long enough for SIGINT E2E -max_iterations: 20 +max_movements: 20 initial_movement: step-1 diff --git a/e2e/fixtures/pieces/mock-two-step.yaml b/e2e/fixtures/pieces/mock-two-step.yaml index c302fd01..8090cf46 100644 --- a/e2e/fixtures/pieces/mock-two-step.yaml +++ b/e2e/fixtures/pieces/mock-two-step.yaml @@ -1,7 +1,7 @@ name: e2e-mock-two-step description: Two-step sequential piece for E2E testing -max_iterations: 5 +max_movements: 5 initial_movement: step-1 diff --git a/e2e/fixtures/pieces/multi-step-parallel.yaml b/e2e/fixtures/pieces/multi-step-parallel.yaml index d33354b2..2b25b3e1 100644 --- a/e2e/fixtures/pieces/multi-step-parallel.yaml +++ b/e2e/fixtures/pieces/multi-step-parallel.yaml @@ -1,7 +1,7 @@ name: e2e-multi-step-parallel description: Multi-step piece with parallel sub-movements for E2E testing -max_iterations: 10 +max_movements: 10 initial_movement: plan diff --git a/e2e/fixtures/pieces/report-judge.yaml b/e2e/fixtures/pieces/report-judge.yaml index 4e44c7d0..d8556098 100644 --- a/e2e/fixtures/pieces/report-judge.yaml +++ b/e2e/fixtures/pieces/report-judge.yaml @@ -1,7 +1,7 @@ name: e2e-report-judge description: E2E piece that exercises report + judge phases -max_iterations: 3 +max_movements: 3 movements: - name: execute diff --git a/e2e/fixtures/pieces/simple.yaml b/e2e/fixtures/pieces/simple.yaml index 9619c334..c8f813e0 100644 --- a/e2e/fixtures/pieces/simple.yaml +++ b/e2e/fixtures/pieces/simple.yaml @@ -1,7 +1,7 @@ name: e2e-simple description: Minimal E2E test piece -max_iterations: 5 +max_movements: 5 movements: - name: execute diff --git a/e2e/specs/piece-error-handling.e2e.ts b/e2e/specs/piece-error-handling.e2e.ts index 3654bdda..5badea44 100644 --- a/e2e/specs/piece-error-handling.e2e.ts +++ b/e2e/specs/piece-error-handling.e2e.ts @@ -69,15 +69,15 @@ describe('E2E: Piece error handling (mock)', () => { expect(combined).toMatch(/failed|aborted|error/i); }, 240_000); - it('should abort when max_iterations is reached', () => { - // Given: a piece with max_iterations=2 that loops between step-a and step-b + it('should abort when max_movements is reached', () => { + // Given: a piece with max_movements=2 that loops between step-a and step-b const piecePath = resolve(__dirname, '../fixtures/pieces/mock-max-iter.yaml'); const scenarioPath = resolve(__dirname, '../fixtures/scenarios/max-iter-loop.json'); // When: executing the piece const result = runTakt({ args: [ - '--task', 'Test max iterations', + '--task', 'Test max movements', '--piece', piecePath, '--create-worktree', 'no', '--provider', 'mock', @@ -93,7 +93,7 @@ describe('E2E: Piece error handling (mock)', () => { // Then: piece aborts due to iteration limit expect(result.exitCode).not.toBe(0); const combined = result.stdout + result.stderr; - expect(combined).toMatch(/Max iterations|iteration|aborted/i); + expect(combined).toMatch(/Max movements|iteration|aborted/i); }, 240_000); it('should pass previous response between sequential steps', () => { diff --git a/package-lock.json b/package-lock.json index c0719838..c63fda5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "takt", - "version": "0.11.0", + "version": "0.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.11.0", + "version": "0.11.1", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.37", diff --git a/src/__tests__/StreamDisplay.test.ts b/src/__tests__/StreamDisplay.test.ts index d0d7522e..488365a8 100644 --- a/src/__tests__/StreamDisplay.test.ts +++ b/src/__tests__/StreamDisplay.test.ts @@ -22,7 +22,7 @@ describe('StreamDisplay', () => { describe('progress info display', () => { const progressInfo: ProgressInfo = { iteration: 3, - maxIterations: 10, + maxMovements: 10, movementIndex: 1, totalMovements: 4, }; @@ -253,7 +253,7 @@ describe('StreamDisplay', () => { it('should format progress as (iteration/max) step index/total', () => { const progressInfo: ProgressInfo = { iteration: 5, - maxIterations: 20, + maxMovements: 20, movementIndex: 2, totalMovements: 6, }; @@ -267,7 +267,7 @@ describe('StreamDisplay', () => { it('should convert 0-indexed movementIndex to 1-indexed display', () => { const progressInfo: ProgressInfo = { iteration: 1, - maxIterations: 10, + maxMovements: 10, movementIndex: 0, // First movement (0-indexed) totalMovements: 4, }; diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index e628d92f..5c2f910c 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -188,7 +188,7 @@ describe('loadAllPieces', () => { const samplePiece = ` name: test-piece description: Test piece -max_iterations: 10 +max_movements: 10 movements: - name: step1 persona: coder diff --git a/src/__tests__/engine-abort.test.ts b/src/__tests__/engine-abort.test.ts index 04cb66bf..dae845ca 100644 --- a/src/__tests__/engine-abort.test.ts +++ b/src/__tests__/engine-abort.test.ts @@ -65,7 +65,7 @@ describe('PieceEngine: Abort (SIGINT)', () => { function makeSimpleConfig(): PieceConfig { return { name: 'test', - maxIterations: 10, + maxMovements: 10, initialMovement: 'step1', movements: [ makeMovement('step1', { diff --git a/src/__tests__/engine-agent-overrides.test.ts b/src/__tests__/engine-agent-overrides.test.ts index 6ba737fd..4ba823cb 100644 --- a/src/__tests__/engine-agent-overrides.test.ts +++ b/src/__tests__/engine-agent-overrides.test.ts @@ -54,7 +54,7 @@ describe('PieceEngine agent overrides', () => { name: 'override-test', movements: [movement], initialMovement: 'plan', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ @@ -83,7 +83,7 @@ describe('PieceEngine agent overrides', () => { name: 'override-fallback', movements: [movement], initialMovement: 'plan', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ @@ -114,7 +114,7 @@ describe('PieceEngine agent overrides', () => { name: 'movement-defaults', movements: [movement], initialMovement: 'plan', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ diff --git a/src/__tests__/engine-arpeggio.test.ts b/src/__tests__/engine-arpeggio.test.ts index 501790d5..3523c60f 100644 --- a/src/__tests__/engine-arpeggio.test.ts +++ b/src/__tests__/engine-arpeggio.test.ts @@ -75,7 +75,7 @@ function buildArpeggioPieceConfig(arpeggioConfig: ArpeggioMovementConfig, tmpDir return { name: 'test-arpeggio', description: 'Test arpeggio piece', - maxIterations: 10, + maxMovements: 10, initialMovement: 'process', movements: [ { diff --git a/src/__tests__/engine-error.test.ts b/src/__tests__/engine-error.test.ts index 0c040ba8..d018242b 100644 --- a/src/__tests__/engine-error.test.ts +++ b/src/__tests__/engine-error.test.ts @@ -117,7 +117,7 @@ describe('PieceEngine Integration: Error Handling', () => { describe('Loop detection', () => { it('should abort when loop detected with action: abort', async () => { const config = buildDefaultPieceConfig({ - maxIterations: 100, + maxMovements: 100, loopDetection: { maxConsecutiveSameStep: 3, action: 'abort' }, initialMovement: 'loop-step', movements: [ @@ -156,7 +156,7 @@ describe('PieceEngine Integration: Error Handling', () => { // ===================================================== describe('Iteration limit', () => { it('should abort when max iterations reached without onIterationLimit callback', async () => { - const config = buildDefaultPieceConfig({ maxIterations: 2 }); + const config = buildDefaultPieceConfig({ maxMovements: 2 }); const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ @@ -182,11 +182,11 @@ describe('PieceEngine Integration: Error Handling', () => { expect(limitFn).toHaveBeenCalledWith(2, 2); expect(abortFn).toHaveBeenCalledOnce(); const reason = abortFn.mock.calls[0]![1] as string; - expect(reason).toContain('Max iterations'); + expect(reason).toContain('Max movements'); }); it('should extend iterations when onIterationLimit provides additional iterations', async () => { - const config = buildDefaultPieceConfig({ maxIterations: 2 }); + const config = buildDefaultPieceConfig({ maxMovements: 2 }); const onIterationLimit = vi.fn().mockResolvedValueOnce(10); diff --git a/src/__tests__/engine-happy-path.test.ts b/src/__tests__/engine-happy-path.test.ts index 89a20fa4..d067fa4d 100644 --- a/src/__tests__/engine-happy-path.test.ts +++ b/src/__tests__/engine-happy-path.test.ts @@ -388,7 +388,7 @@ describe('PieceEngine Integration: Happy Path', () => { it('should pass instruction to movement:start for normal movements', async () => { const simpleConfig: PieceConfig = { name: 'test', - maxIterations: 10, + maxMovements: 10, initialMovement: 'plan', movements: [ makeMovement('plan', { @@ -456,7 +456,7 @@ describe('PieceEngine Integration: Happy Path', () => { }); it('should emit iteration:limit when max iterations reached', async () => { - const config = buildDefaultPieceConfig({ maxIterations: 1 }); + const config = buildDefaultPieceConfig({ maxMovements: 1 }); engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); mockRunAgentSequence([ @@ -518,7 +518,7 @@ describe('PieceEngine Integration: Happy Path', () => { it('should emit phase:start and phase:complete events for Phase 1', async () => { const simpleConfig: PieceConfig = { name: 'test', - maxIterations: 10, + maxMovements: 10, initialMovement: 'plan', movements: [ makeMovement('plan', { @@ -609,7 +609,7 @@ describe('PieceEngine Integration: Happy Path', () => { it('should throw when rule references nonexistent movement', () => { const config: PieceConfig = { name: 'test', - maxIterations: 10, + maxMovements: 10, initialMovement: 'step1', movements: [ makeMovement('step1', { diff --git a/src/__tests__/engine-loop-monitors.test.ts b/src/__tests__/engine-loop-monitors.test.ts index 7f9cfa29..e363264b 100644 --- a/src/__tests__/engine-loop-monitors.test.ts +++ b/src/__tests__/engine-loop-monitors.test.ts @@ -60,7 +60,7 @@ function buildConfigWithLoopMonitor( return { name: 'test-loop-monitor', description: 'Test piece with loop monitors', - maxIterations: 30, + maxMovements: 30, initialMovement: 'implement', loopMonitors: [ { diff --git a/src/__tests__/engine-parallel-failure.test.ts b/src/__tests__/engine-parallel-failure.test.ts index 3d3d00e4..a48d6c11 100644 --- a/src/__tests__/engine-parallel-failure.test.ts +++ b/src/__tests__/engine-parallel-failure.test.ts @@ -54,7 +54,7 @@ function buildParallelOnlyConfig(): PieceConfig { return { name: 'test-parallel-failure', description: 'Test parallel failure handling', - maxIterations: 10, + maxMovements: 10, initialMovement: 'reviewers', movements: [ makeMovement('reviewers', { diff --git a/src/__tests__/engine-persona-providers.test.ts b/src/__tests__/engine-persona-providers.test.ts index afb371ca..4dc533c5 100644 --- a/src/__tests__/engine-persona-providers.test.ts +++ b/src/__tests__/engine-persona-providers.test.ts @@ -55,7 +55,7 @@ describe('PieceEngine persona_providers override', () => { name: 'persona-provider-test', movements: [movement], initialMovement: 'implement', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ @@ -84,7 +84,7 @@ describe('PieceEngine persona_providers override', () => { name: 'persona-provider-nomatch', movements: [movement], initialMovement: 'plan', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ @@ -114,7 +114,7 @@ describe('PieceEngine persona_providers override', () => { name: 'movement-over-persona', movements: [movement], initialMovement: 'implement', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ @@ -143,7 +143,7 @@ describe('PieceEngine persona_providers override', () => { name: 'no-persona-providers', movements: [movement], initialMovement: 'plan', - maxIterations: 1, + maxMovements: 1, }; mockRunAgentSequence([ @@ -175,7 +175,7 @@ describe('PieceEngine persona_providers override', () => { name: 'multi-persona-providers', movements: [planMovement, implementMovement], initialMovement: 'plan', - maxIterations: 3, + maxMovements: 3, }; mockRunAgentSequence([ diff --git a/src/__tests__/engine-test-helpers.ts b/src/__tests__/engine-test-helpers.ts index e658a5d6..d8c893fb 100644 --- a/src/__tests__/engine-test-helpers.ts +++ b/src/__tests__/engine-test-helpers.ts @@ -70,7 +70,7 @@ export function buildDefaultPieceConfig(overrides: Partial = {}): P return { name: 'test-default', description: 'Test piece', - maxIterations: 30, + maxMovements: 30, initialMovement: 'plan', movements: [ makeMovement('plan', { diff --git a/src/__tests__/engine-worktree-report.test.ts b/src/__tests__/engine-worktree-report.test.ts index dc83c50c..1021c0a6 100644 --- a/src/__tests__/engine-worktree-report.test.ts +++ b/src/__tests__/engine-worktree-report.test.ts @@ -64,7 +64,7 @@ function buildSimpleConfig(): PieceConfig { return { name: 'worktree-test', description: 'Test piece for worktree', - maxIterations: 10, + maxMovements: 10, initialMovement: 'review', movements: [ makeMovement('review', { @@ -133,7 +133,7 @@ describe('PieceEngine: worktree reportDir resolution', () => { const config: PieceConfig = { name: 'worktree-test', description: 'Test', - maxIterations: 10, + maxMovements: 10, initialMovement: 'review', movements: [ makeMovement('review', { @@ -247,7 +247,7 @@ describe('PieceEngine: worktree reportDir resolution', () => { const config: PieceConfig = { name: 'snapshot-test', description: 'Test', - maxIterations: 10, + maxMovements: 10, initialMovement: 'implement', movements: [ makeMovement('implement', { diff --git a/src/__tests__/escape.test.ts b/src/__tests__/escape.test.ts index 6200983d..081c643c 100644 --- a/src/__tests__/escape.test.ts +++ b/src/__tests__/escape.test.ts @@ -26,7 +26,7 @@ function makeContext(overrides: Partial = {}): InstructionCo return { task: 'test task', iteration: 1, - maxIterations: 10, + maxMovements: 10, movementIteration: 1, cwd: '/tmp/test', projectCwd: '/tmp/project', @@ -78,10 +78,10 @@ describe('replaceTemplatePlaceholders', () => { expect(result).toBe('fix {bug} in code'); }); - it('should replace {iteration} and {max_iterations}', () => { + it('should replace {iteration} and {max_movements}', () => { const step = makeMovement(); - const ctx = makeContext({ iteration: 3, maxIterations: 20 }); - const template = 'Iteration {iteration}/{max_iterations}'; + const ctx = makeContext({ iteration: 3, maxMovements: 20 }); + const template = 'Iteration {iteration}/{max_movements}'; const result = replaceTemplatePlaceholders(template, step, ctx); expect(result).toBe('Iteration 3/20'); @@ -186,11 +186,11 @@ describe('replaceTemplatePlaceholders', () => { const ctx = makeContext({ task: 'test task', iteration: 2, - maxIterations: 5, + maxMovements: 5, movementIteration: 1, reportDir: '/reports', }); - const template = '{task} - iter {iteration}/{max_iterations} - mv {movement_iteration} - dir {report_dir}'; + const template = '{task} - iter {iteration}/{max_movements} - mv {movement_iteration} - dir {report_dir}'; const result = replaceTemplatePlaceholders(template, step, ctx); expect(result).toBe('test task - iter 2/5 - mv 1 - dir /reports'); diff --git a/src/__tests__/i18n.test.ts b/src/__tests__/i18n.test.ts index 149c5306..bed0d128 100644 --- a/src/__tests__/i18n.test.ts +++ b/src/__tests__/i18n.test.ts @@ -37,7 +37,7 @@ describe('getLabel', () => { it('replaces {variableName} placeholders with provided values', () => { const result = getLabel('piece.iterationLimit.maxReached', undefined, { currentIteration: '5', - maxIterations: '10', + maxMovements: '10', }); expect(result).toContain('(5/10)'); }); diff --git a/src/__tests__/instruction-helpers.test.ts b/src/__tests__/instruction-helpers.test.ts index cec104f6..ee75ba90 100644 --- a/src/__tests__/instruction-helpers.test.ts +++ b/src/__tests__/instruction-helpers.test.ts @@ -27,7 +27,7 @@ function makeContext(overrides: Partial = {}): InstructionCo return { task: 'test task', iteration: 1, - maxIterations: 10, + maxMovements: 10, movementIteration: 1, cwd: '/tmp/test', projectCwd: '/tmp/project', diff --git a/src/__tests__/instructionBuilder.test.ts b/src/__tests__/instructionBuilder.test.ts index d0b81d09..d5e0296e 100644 --- a/src/__tests__/instructionBuilder.test.ts +++ b/src/__tests__/instructionBuilder.test.ts @@ -41,7 +41,7 @@ function createMinimalContext(overrides: Partial = {}): Inst return { task: 'Test task', iteration: 1, - maxIterations: 10, + maxMovements: 10, movementIteration: 1, cwd: '/project', projectCwd: '/project', @@ -458,7 +458,7 @@ describe('instruction-builder', () => { step.name = 'implement'; const context = createMinimalContext({ iteration: 3, - maxIterations: 20, + maxMovements: 20, movementIteration: 2, language: 'en', }); @@ -1035,9 +1035,9 @@ describe('instruction-builder', () => { expect(result).toContain('Build the app'); }); - it('should replace {iteration} and {max_iterations}', () => { - const step = createMinimalStep('Step {iteration}/{max_iterations}'); - const context = createMinimalContext({ iteration: 3, maxIterations: 20 }); + it('should replace {iteration} and {max_movements}', () => { + const step = createMinimalStep('Step {iteration}/{max_movements}'); + const context = createMinimalContext({ iteration: 3, maxMovements: 20 }); const result = buildInstruction(step, context); diff --git a/src/__tests__/it-error-recovery.test.ts b/src/__tests__/it-error-recovery.test.ts index 5b49af33..0635fc07 100644 --- a/src/__tests__/it-error-recovery.test.ts +++ b/src/__tests__/it-error-recovery.test.ts @@ -99,11 +99,11 @@ function buildEngineOptions(projectCwd: string) { }; } -function buildPiece(agentPaths: Record, maxIterations: number): PieceConfig { +function buildPiece(agentPaths: Record, maxMovements: number): PieceConfig { return { name: 'it-error', description: 'IT error recovery piece', - maxIterations, + maxMovements, initialMovement: 'plan', movements: [ makeMovement('plan', agentPaths.plan, [ diff --git a/src/__tests__/it-instruction-builder.test.ts b/src/__tests__/it-instruction-builder.test.ts index 6f1350d6..3753b10a 100644 --- a/src/__tests__/it-instruction-builder.test.ts +++ b/src/__tests__/it-instruction-builder.test.ts @@ -57,7 +57,7 @@ function makeContext(overrides: Partial = {}): InstructionCo return { task: 'Test task description', iteration: 3, - maxIterations: 30, + maxMovements: 30, movementIteration: 2, cwd: '/tmp/test-project', projectCwd: '/tmp/test-project', @@ -176,11 +176,11 @@ describe('Instruction Builder IT: user_inputs auto-injection', () => { }); describe('Instruction Builder IT: iteration variables', () => { - it('should replace {iteration}, {max_iterations}, {movement_iteration} in template', () => { + it('should replace {iteration}, {max_movements}, {movement_iteration} in template', () => { const step = makeMovement({ - instructionTemplate: 'Iter: {iteration}/{max_iterations}, movement iter: {movement_iteration}', + instructionTemplate: 'Iter: {iteration}/{max_movements}, movement iter: {movement_iteration}', }); - const ctx = makeContext({ iteration: 5, maxIterations: 30, movementIteration: 2 }); + const ctx = makeContext({ iteration: 5, maxMovements: 30, movementIteration: 2 }); const result = buildInstruction(step, ctx); @@ -189,7 +189,7 @@ describe('Instruction Builder IT: iteration variables', () => { it('should include iteration in Piece Context section', () => { const step = makeMovement(); - const ctx = makeContext({ iteration: 7, maxIterations: 20, movementIteration: 3 }); + const ctx = makeContext({ iteration: 7, maxMovements: 20, movementIteration: 3 }); const result = buildInstruction(step, ctx); diff --git a/src/__tests__/it-notification-sound.test.ts b/src/__tests__/it-notification-sound.test.ts index b23ac93e..ce54a0f8 100644 --- a/src/__tests__/it-notification-sound.test.ts +++ b/src/__tests__/it-notification-sound.test.ts @@ -74,7 +74,7 @@ const { if (this.onIterationLimit) { await this.onIterationLimit({ currentIteration: 10, - maxIterations: 10, + maxMovements: 10, currentMovement: 'step1', }); } @@ -201,7 +201,7 @@ import type { PieceConfig } from '../core/models/index.js'; function makeConfig(): PieceConfig { return { name: 'test-notify', - maxIterations: 10, + maxMovements: 10, initialMovement: 'step1', movements: [ { diff --git a/src/__tests__/it-piece-execution.test.ts b/src/__tests__/it-piece-execution.test.ts index a836d505..3539bb74 100644 --- a/src/__tests__/it-piece-execution.test.ts +++ b/src/__tests__/it-piece-execution.test.ts @@ -105,7 +105,7 @@ function buildSimplePiece(agentPaths: Record): PieceConfig { return { name: 'it-simple', description: 'IT simple piece', - maxIterations: 15, + maxMovements: 15, initialMovement: 'plan', movements: [ makeMovement('plan', agentPaths.planner, [ @@ -128,7 +128,7 @@ function buildLoopPiece(agentPaths: Record): PieceConfig { return { name: 'it-loop', description: 'IT piece with fix loop', - maxIterations: 20, + maxMovements: 20, initialMovement: 'plan', movements: [ makeMovement('plan', agentPaths.planner, [ @@ -286,7 +286,7 @@ describe('Piece Engine IT: Max Iterations', () => { rmSync(testDir, { recursive: true, force: true }); }); - it('should abort when maxIterations exceeded in infinite loop', async () => { + it('should abort when maxMovements exceeded in infinite loop', async () => { // Create an infinite loop: plan always goes to implement, implement always goes back to plan const infiniteScenario = Array.from({ length: 10 }, (_, i) => ({ status: 'done' as const, @@ -295,7 +295,7 @@ describe('Piece Engine IT: Max Iterations', () => { setMockScenario(infiniteScenario); const config = buildSimplePiece(agentPaths); - config.maxIterations = 5; + config.maxMovements = 5; const engine = new PieceEngine(config, testDir, 'Looping task', { ...buildEngineOptions(testDir), diff --git a/src/__tests__/it-piece-loader.test.ts b/src/__tests__/it-piece-loader.test.ts index de34adbc..3ab53ac6 100644 --- a/src/__tests__/it-piece-loader.test.ts +++ b/src/__tests__/it-piece-loader.test.ts @@ -58,7 +58,7 @@ describe('Piece Loader IT: builtin piece loading', () => { expect(config!.name).toBe(name); expect(config!.movements.length).toBeGreaterThan(0); expect(config!.initialMovement).toBeDefined(); - expect(config!.maxIterations).toBeGreaterThan(0); + expect(config!.maxMovements).toBeGreaterThan(0); }); } @@ -123,7 +123,7 @@ describe('Piece Loader IT: project-local piece override', () => { writeFileSync(join(piecesDir, 'custom-wf.yaml'), ` name: custom-wf description: Custom project piece -max_iterations: 5 +max_movements: 5 initial_movement: start movements: @@ -250,11 +250,11 @@ describe('Piece Loader IT: piece config validation', () => { rmSync(testDir, { recursive: true, force: true }); }); - it('should set max_iterations from YAML', () => { + it('should set max_movements from YAML', () => { const config = loadPiece('minimal', testDir); expect(config).not.toBeNull(); - expect(typeof config!.maxIterations).toBe('number'); - expect(config!.maxIterations).toBeGreaterThan(0); + expect(typeof config!.maxMovements).toBe('number'); + expect(config!.maxMovements).toBeGreaterThan(0); }); it('should set initial_movement from YAML', () => { @@ -397,7 +397,7 @@ describe('Piece Loader IT: quality_gates loading', () => { writeFileSync(join(piecesDir, 'with-gates.yaml'), ` name: with-gates description: Piece with quality gates -max_iterations: 5 +max_movements: 5 initial_movement: implement movements: @@ -434,7 +434,7 @@ movements: writeFileSync(join(piecesDir, 'no-gates.yaml'), ` name: no-gates description: Piece without quality gates -max_iterations: 5 +max_movements: 5 initial_movement: implement movements: @@ -461,7 +461,7 @@ movements: writeFileSync(join(piecesDir, 'empty-gates.yaml'), ` name: empty-gates description: Piece with empty quality gates -max_iterations: 5 +max_movements: 5 initial_movement: implement movements: @@ -501,7 +501,7 @@ describe('Piece Loader IT: mcp_servers parsing', () => { writeFileSync(join(piecesDir, 'with-mcp.yaml'), ` name: with-mcp description: Piece with MCP servers -max_iterations: 5 +max_movements: 5 initial_movement: e2e-test movements: @@ -541,7 +541,7 @@ movements: writeFileSync(join(piecesDir, 'no-mcp.yaml'), ` name: no-mcp description: Piece without MCP servers -max_iterations: 5 +max_movements: 5 initial_movement: implement movements: @@ -568,7 +568,7 @@ movements: writeFileSync(join(piecesDir, 'multi-mcp.yaml'), ` name: multi-mcp description: Piece with multiple MCP servers -max_iterations: 5 +max_movements: 5 initial_movement: test movements: @@ -625,7 +625,7 @@ describe('Piece Loader IT: structural-reform piece', () => { expect(config).not.toBeNull(); expect(config!.name).toBe('structural-reform'); expect(config!.movements.length).toBe(7); - expect(config!.maxIterations).toBe(50); + expect(config!.maxMovements).toBe(50); expect(config!.initialMovement).toBe('review'); }); diff --git a/src/__tests__/it-pipeline-modes.test.ts b/src/__tests__/it-pipeline-modes.test.ts index c2712625..0916befd 100644 --- a/src/__tests__/it-pipeline-modes.test.ts +++ b/src/__tests__/it-pipeline-modes.test.ts @@ -171,7 +171,7 @@ function createTestPieceDir(): { dir: string; piecePath: string } { const pieceYaml = ` name: it-pipeline description: Pipeline test piece -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: diff --git a/src/__tests__/it-pipeline.test.ts b/src/__tests__/it-pipeline.test.ts index ddac9a44..ad027238 100644 --- a/src/__tests__/it-pipeline.test.ts +++ b/src/__tests__/it-pipeline.test.ts @@ -152,7 +152,7 @@ function createTestPieceDir(): { dir: string; piecePath: string } { const pieceYaml = ` name: it-simple description: Integration test piece -max_iterations: 10 +max_movements: 10 initial_movement: plan movements: diff --git a/src/__tests__/it-sigint-interrupt.test.ts b/src/__tests__/it-sigint-interrupt.test.ts index a8652b10..576600a8 100644 --- a/src/__tests__/it-sigint-interrupt.test.ts +++ b/src/__tests__/it-sigint-interrupt.test.ts @@ -196,7 +196,7 @@ describe('executePiece: SIGINT handler integration', () => { function makeConfig(): PieceConfig { return { name: 'test-sigint', - maxIterations: 10, + maxMovements: 10, initialMovement: 'step1', movements: [ { diff --git a/src/__tests__/it-three-phase-execution.test.ts b/src/__tests__/it-three-phase-execution.test.ts index c87380b3..a7e250a7 100644 --- a/src/__tests__/it-three-phase-execution.test.ts +++ b/src/__tests__/it-three-phase-execution.test.ts @@ -133,7 +133,7 @@ describe('Three-Phase Execution IT: phase1 only (no report, no tag rules)', () = const config: PieceConfig = { name: 'it-phase1-only', description: 'Test', - maxIterations: 5, + maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ @@ -185,7 +185,7 @@ describe('Three-Phase Execution IT: phase1 + phase2 (report defined)', () => { const config: PieceConfig = { name: 'it-phase1-2', description: 'Test', - maxIterations: 5, + maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ @@ -215,7 +215,7 @@ describe('Three-Phase Execution IT: phase1 + phase2 (report defined)', () => { const config: PieceConfig = { name: 'it-phase1-2-multi', description: 'Test', - maxIterations: 5, + maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ @@ -266,7 +266,7 @@ describe('Three-Phase Execution IT: phase1 + phase3 (tag rules defined)', () => const config: PieceConfig = { name: 'it-phase1-3', description: 'Test', - maxIterations: 5, + maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ @@ -317,7 +317,7 @@ describe('Three-Phase Execution IT: all three phases', () => { const config: PieceConfig = { name: 'it-all-phases', description: 'Test', - maxIterations: 5, + maxMovements: 5, initialMovement: 'step', movements: [ makeMovement('step', agentPath, [ @@ -377,7 +377,7 @@ describe('Three-Phase Execution IT: phase3 tag → rule match', () => { const config: PieceConfig = { name: 'it-phase3-tag', description: 'Test', - maxIterations: 5, + maxMovements: 5, initialMovement: 'step1', movements: [ makeMovement('step1', agentPath, [ diff --git a/src/__tests__/knowledge.test.ts b/src/__tests__/knowledge.test.ts index 45266aa5..baa8839f 100644 --- a/src/__tests__/knowledge.test.ts +++ b/src/__tests__/knowledge.test.ts @@ -330,7 +330,7 @@ function createMinimalContext(overrides: Partial = {}): Inst return { task: 'Test task', iteration: 1, - maxIterations: 10, + maxMovements: 10, movementIteration: 1, cwd: '/tmp/test', projectCwd: '/tmp/test', diff --git a/src/__tests__/models.test.ts b/src/__tests__/models.test.ts index db67628f..20139f33 100644 --- a/src/__tests__/models.test.ts +++ b/src/__tests__/models.test.ts @@ -81,7 +81,7 @@ describe('PieceConfigRawSchema', () => { expect(result.name).toBe('test-piece'); expect(result.movements).toHaveLength(1); expect(result.movements![0]?.allowed_tools).toEqual(['Read', 'Grep']); - expect(result.max_iterations).toBe(10); + expect(result.max_movements).toBe(10); }); it('should parse movement with permission_mode', () => { diff --git a/src/__tests__/parallel-and-loader.test.ts b/src/__tests__/parallel-and-loader.test.ts index 961d4280..706d2ae5 100644 --- a/src/__tests__/parallel-and-loader.test.ts +++ b/src/__tests__/parallel-and-loader.test.ts @@ -145,7 +145,7 @@ describe('PieceConfigRawSchema with parallel movements', () => { }, ], initial_movement: 'plan', - max_iterations: 10, + max_movements: 10, }; const result = PieceConfigRawSchema.safeParse(raw); diff --git a/src/__tests__/parallel-logger.test.ts b/src/__tests__/parallel-logger.test.ts index 5a4113c7..8f87bd8d 100644 --- a/src/__tests__/parallel-logger.test.ts +++ b/src/__tests__/parallel-logger.test.ts @@ -74,7 +74,7 @@ describe('ParallelLogger', () => { writeFn, progressInfo: { iteration: 4, - maxIterations: 30, + maxMovements: 30, }, taskLabel: 'override-persona-provider', taskColorIndex: 0, @@ -529,7 +529,7 @@ describe('ParallelLogger', () => { writeFn, progressInfo: { iteration: 3, - maxIterations: 10, + maxMovements: 10, }, }); @@ -545,7 +545,7 @@ describe('ParallelLogger', () => { writeFn, progressInfo: { iteration: 5, - maxIterations: 20, + maxMovements: 20, }, }); @@ -576,7 +576,7 @@ describe('ParallelLogger', () => { writeFn, progressInfo: { iteration: 2, - maxIterations: 5, + maxMovements: 5, }, }); const handler = logger.createStreamHandler('step-a', 0); diff --git a/src/__tests__/piece-categories.test.ts b/src/__tests__/piece-categories.test.ts index c4ad8046..b025782c 100644 --- a/src/__tests__/piece-categories.test.ts +++ b/src/__tests__/piece-categories.test.ts @@ -24,7 +24,7 @@ import { const SAMPLE_PIECE = `name: test-piece description: Test piece initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 diff --git a/src/__tests__/piece-category-config.test.ts b/src/__tests__/piece-category-config.test.ts index e89edc4e..1a164fff 100644 --- a/src/__tests__/piece-category-config.test.ts +++ b/src/__tests__/piece-category-config.test.ts @@ -65,7 +65,7 @@ function createPieceMap(entries: { name: string; source: 'builtin' | 'user' | 'p name: entry.name, movements: [], initialMovement: 'start', - maxIterations: 1, + maxMovements: 1, }, }); } diff --git a/src/__tests__/piece-selection.test.ts b/src/__tests__/piece-selection.test.ts index ae250dfc..a05088a0 100644 --- a/src/__tests__/piece-selection.test.ts +++ b/src/__tests__/piece-selection.test.ts @@ -78,7 +78,7 @@ function createPieceMap(entries: { name: string; source: 'user' | 'builtin' }[]) name: e.name, movements: [], initialMovement: 'start', - maxIterations: 1, + maxMovements: 1, }, }); } diff --git a/src/__tests__/pieceExecution-debug-prompts.test.ts b/src/__tests__/pieceExecution-debug-prompts.test.ts index 8a84df1b..75aa4e5c 100644 --- a/src/__tests__/pieceExecution-debug-prompts.test.ts +++ b/src/__tests__/pieceExecution-debug-prompts.test.ts @@ -170,7 +170,7 @@ describe('executePiece debug prompts logging', () => { function makeConfig(): PieceConfig { return { name: 'test-piece', - maxIterations: 5, + maxMovements: 5, initialMovement: 'implement', movements: [ { diff --git a/src/__tests__/pieceLoader.test.ts b/src/__tests__/pieceLoader.test.ts index 3b50cf5a..71ca03f6 100644 --- a/src/__tests__/pieceLoader.test.ts +++ b/src/__tests__/pieceLoader.test.ts @@ -16,7 +16,7 @@ import { const SAMPLE_PIECE = `name: test-piece description: Test piece initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -173,7 +173,7 @@ describe('loadAllPieces with project-local', () => { const overridePiece = `name: project-override description: Project override initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 diff --git a/src/__tests__/pieceResolver.test.ts b/src/__tests__/pieceResolver.test.ts index a6b1c4af..ca6583e8 100644 --- a/src/__tests__/pieceResolver.test.ts +++ b/src/__tests__/pieceResolver.test.ts @@ -23,7 +23,7 @@ describe('getPieceDescription', () => { const pieceYaml = `name: test-piece description: Test piece for workflow initial_movement: plan -max_iterations: 3 +max_movements: 3 movements: - name: plan @@ -56,7 +56,7 @@ movements: const pieceYaml = `name: coding description: Full coding workflow initial_movement: plan -max_iterations: 10 +max_movements: 10 movements: - name: plan @@ -98,7 +98,7 @@ movements: it('should handle movements without descriptions', () => { const pieceYaml = `name: minimal initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -132,7 +132,7 @@ movements: it('should handle parallel movements without descriptions', () => { const pieceYaml = `name: test-parallel initial_movement: parent -max_iterations: 1 +max_movements: 1 movements: - name: parent @@ -174,7 +174,7 @@ describe('getPieceDescription with movementPreviews', () => { const pieceYaml = `name: preview-test description: Test piece initial_movement: plan -max_iterations: 5 +max_movements: 5 movements: - name: plan @@ -237,7 +237,7 @@ movements: it('should return empty previews when previewCount is 0', () => { const pieceYaml = `name: test initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -256,7 +256,7 @@ movements: it('should return empty previews when previewCount is not specified', () => { const pieceYaml = `name: test initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -275,7 +275,7 @@ movements: it('should stop at COMPLETE movement', () => { const pieceYaml = `name: test-complete initial_movement: step1 -max_iterations: 3 +max_movements: 3 movements: - name: step1 @@ -301,7 +301,7 @@ movements: it('should stop at ABORT movement', () => { const pieceYaml = `name: test-abort initial_movement: step1 -max_iterations: 3 +max_movements: 3 movements: - name: step1 @@ -331,7 +331,7 @@ movements: const pieceYaml = `name: test-persona-file initial_movement: plan -max_iterations: 1 +max_movements: 1 personas: planner: ./planner.md @@ -355,7 +355,7 @@ movements: it('should limit previews to maxCount', () => { const pieceYaml = `name: test-limit initial_movement: step1 -max_iterations: 5 +max_movements: 5 movements: - name: step1 @@ -388,7 +388,7 @@ movements: it('should handle movements without rules (stop after first)', () => { const pieceYaml = `name: test-no-rules initial_movement: step1 -max_iterations: 3 +max_movements: 3 movements: - name: step1 @@ -411,7 +411,7 @@ movements: it('should return empty previews when initial movement not found in list', () => { const pieceYaml = `name: test-missing-initial initial_movement: nonexistent -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -430,7 +430,7 @@ movements: it('should handle self-referencing rule (prevent infinite loop)', () => { const pieceYaml = `name: test-self-ref initial_movement: step1 -max_iterations: 5 +max_movements: 5 movements: - name: step1 @@ -453,7 +453,7 @@ movements: it('should handle multi-node cycle A→B→A (prevent duplicate previews)', () => { const pieceYaml = `name: test-cycle initial_movement: stepA -max_iterations: 10 +max_movements: 10 movements: - name: stepA @@ -489,7 +489,7 @@ movements: it('should use inline persona content when no personaPath', () => { const pieceYaml = `name: test-inline initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -515,7 +515,7 @@ movements: const pieceYaml = `name: test-unreadable-persona initial_movement: plan -max_iterations: 1 +max_movements: 1 personas: planner: ./unreadable-persona.md @@ -545,7 +545,7 @@ movements: it('should include personaDisplayName in previews', () => { const pieceYaml = `name: test-display initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -578,7 +578,7 @@ describe('getPieceDescription interactiveMode field', () => { it('should return interactiveMode when piece defines interactive_mode', () => { const pieceYaml = `name: test-mode initial_movement: step1 -max_iterations: 1 +max_movements: 1 interactive_mode: quiet movements: @@ -598,7 +598,7 @@ movements: it('should return undefined interactiveMode when piece omits interactive_mode', () => { const pieceYaml = `name: test-no-mode initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -618,7 +618,7 @@ movements: for (const mode of ['assistant', 'persona', 'quiet', 'passthrough'] as const) { const pieceYaml = `name: test-${mode} initial_movement: step1 -max_iterations: 1 +max_movements: 1 interactive_mode: ${mode} movements: @@ -651,7 +651,7 @@ describe('getPieceDescription firstMovement field', () => { it('should return firstMovement with inline persona content', () => { const pieceYaml = `name: test-first initial_movement: plan -max_iterations: 1 +max_movements: 1 movements: - name: plan @@ -681,7 +681,7 @@ movements: const pieceYaml = `name: test-persona-file initial_movement: plan -max_iterations: 1 +max_movements: 1 personas: planner: ./planner-persona.md @@ -705,7 +705,7 @@ movements: it('should return undefined firstMovement when initialMovement not found', () => { const pieceYaml = `name: test-missing initial_movement: nonexistent -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -724,7 +724,7 @@ movements: it('should return empty allowedTools array when movement has no tools', () => { const pieceYaml = `name: test-no-tools initial_movement: step1 -max_iterations: 1 +max_movements: 1 movements: - name: step1 @@ -749,7 +749,7 @@ movements: const pieceYaml = `name: test-fallback initial_movement: step1 -max_iterations: 1 +max_movements: 1 personas: myagent: ./unreadable.md diff --git a/src/__tests__/policy-persona.test.ts b/src/__tests__/policy-persona.test.ts index 33a62365..7fd2cf2e 100644 --- a/src/__tests__/policy-persona.test.ts +++ b/src/__tests__/policy-persona.test.ts @@ -27,7 +27,7 @@ function makeContext(overrides: Partial = {}): InstructionCo return { task: 'Test task', iteration: 1, - maxIterations: 10, + maxMovements: 10, movementIteration: 1, cwd: '/tmp/test', projectCwd: '/tmp/test', diff --git a/src/__tests__/review-only-piece.test.ts b/src/__tests__/review-only-piece.test.ts index 4ca0b305..74e891b2 100644 --- a/src/__tests__/review-only-piece.test.ts +++ b/src/__tests__/review-only-piece.test.ts @@ -36,8 +36,8 @@ describe('review-only piece (EN)', () => { expect(raw.initial_movement).toBe('plan'); }); - it('should have max_iterations of 10', () => { - expect(raw.max_iterations).toBe(10); + it('should have max_movements of 10', () => { + expect(raw.max_movements).toBe(10); }); it('should have 4 movements: plan, reviewers, supervise, pr-comment', () => { diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index b8dca60a..57dcd557 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -311,7 +311,7 @@ describe('runAllTasks concurrency', () => { name: 'default', movements: [{ name: 'implement', personaDisplayName: 'coder' }], initialMovement: 'implement', - maxIterations: 10, + maxMovements: 10, }; beforeEach(() => { diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index b11fff32..2aad3566 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -131,7 +131,7 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { name: 'default', movements: [], initialMovement: 'start', - maxIterations: 1, + maxMovements: 1, }, }], ])); diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index 7496defc..cd3093d1 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -188,7 +188,7 @@ describe('NDJSON log', () => { const abort: NdjsonPieceAbort = { type: 'piece_abort', iterations: 1, - reason: 'Max iterations reached', + reason: 'Max movements reached', endTime: '2025-01-01T00:00:03.000Z', }; appendNdjsonLine(filepath, abort); diff --git a/src/__tests__/state-manager.test.ts b/src/__tests__/state-manager.test.ts index 3da87a98..15580d59 100644 --- a/src/__tests__/state-manager.test.ts +++ b/src/__tests__/state-manager.test.ts @@ -22,7 +22,7 @@ function makeConfig(overrides: Partial = {}): PieceConfig { name: 'test-piece', movements: [], initialMovement: 'start', - maxIterations: 10, + maxMovements: 10, ...overrides, }; } diff --git a/src/__tests__/switchPiece.test.ts b/src/__tests__/switchPiece.test.ts index 5a865242..44ab51a1 100644 --- a/src/__tests__/switchPiece.test.ts +++ b/src/__tests__/switchPiece.test.ts @@ -57,7 +57,7 @@ describe('switchPiece', () => { name: 'default', movements: [], initialMovement: 'start', - maxIterations: 1, + maxMovements: 1, }, }], ])); diff --git a/src/__tests__/task-prefix-writer.test.ts b/src/__tests__/task-prefix-writer.test.ts index 8cc4fdb2..cf038656 100644 --- a/src/__tests__/task-prefix-writer.test.ts +++ b/src/__tests__/task-prefix-writer.test.ts @@ -188,7 +188,7 @@ describe('TaskPrefixWriter', () => { writer.setMovementContext({ movementName: 'implement', iteration: 4, - maxIterations: 30, + maxMovements: 30, movementIteration: 2, }); writer.writeLine('content'); diff --git a/src/__tests__/taskRetryActions.test.ts b/src/__tests__/taskRetryActions.test.ts index 5035dde6..981261d9 100644 --- a/src/__tests__/taskRetryActions.test.ts +++ b/src/__tests__/taskRetryActions.test.ts @@ -51,7 +51,7 @@ const defaultPieceConfig: PieceConfig = { name: 'default', description: 'Default piece', initialMovement: 'plan', - maxIterations: 30, + maxMovements: 30, movements: [ { name: 'plan', persona: 'planner', instruction: '' }, { name: 'implement', persona: 'coder', instruction: '' }, diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index bd5636db..5ba4bcea 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -210,7 +210,7 @@ export interface PieceConfig { reportFormats?: Record; movements: PieceMovement[]; initialMovement: string; - maxIterations: number; + maxMovements: number; /** Loop detection settings */ loopDetection?: LoopDetectionConfig; /** Loop monitors for detecting cyclic patterns between movements */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 1d4ce4e9..6b1d18ca 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -281,7 +281,7 @@ export const PieceConfigRawSchema = z.object({ report_formats: z.record(z.string(), z.string()).optional(), movements: z.array(PieceMovementRawSchema).min(1), initial_movement: z.string().optional(), - max_iterations: z.number().int().positive().optional().default(10), + max_movements: z.number().int().positive().optional().default(10), loop_monitors: z.array(LoopMonitorSchema).optional(), answer_agent: z.string().optional(), /** Default interactive mode for this piece (overrides user default) */ diff --git a/src/core/models/session.ts b/src/core/models/session.ts index 5bf2401a..d503e5f2 100644 --- a/src/core/models/session.ts +++ b/src/core/models/session.ts @@ -12,7 +12,7 @@ export interface SessionState { task: string; projectDir: string; iteration: number; - maxIterations: number; + maxMovements: number; coderStatus: Status; architectStatus: Status; supervisorStatus: Status; @@ -32,7 +32,7 @@ export function createSessionState( task, projectDir, iteration: 0, - maxIterations: 10, + maxMovements: 10, coderStatus: 'pending', architectStatus: 'pending', supervisorStatus: 'pending', diff --git a/src/core/piece/constants.ts b/src/core/piece/constants.ts index 4874e25c..ee87d471 100644 --- a/src/core/piece/constants.ts +++ b/src/core/piece/constants.ts @@ -19,5 +19,5 @@ export const ERROR_MESSAGES = { `Loop detected: movement "${movementName}" ran ${count} times consecutively without progress.`, UNKNOWN_MOVEMENT: (movementName: string) => `Unknown movement: ${movementName}`, MOVEMENT_EXECUTION_FAILED: (message: string) => `Movement execution failed: ${message}`, - MAX_ITERATIONS_REACHED: 'Max iterations reached', + MAX_MOVEMENTS_REACHED: 'Max movements reached', }; diff --git a/src/core/piece/engine/MovementExecutor.ts b/src/core/piece/engine/MovementExecutor.ts index d794b1a8..702560e6 100644 --- a/src/core/piece/engine/MovementExecutor.ts +++ b/src/core/piece/engine/MovementExecutor.ts @@ -131,7 +131,7 @@ export class MovementExecutor { movementIteration: number, state: PieceState, task: string, - maxIterations: number, + maxMovements: number, ): string { this.ensurePreviousResponseSnapshot(state, step.name, movementIteration); const policySnapshot = this.writeFacetSnapshot( @@ -150,7 +150,7 @@ export class MovementExecutor { return new InstructionBuilder(step, { task, iteration: state.iteration, - maxIterations, + maxMovements, movementIteration, cwd: this.deps.getCwd(), projectCwd: this.deps.getProjectCwd(), @@ -182,14 +182,14 @@ export class MovementExecutor { step: PieceMovement, state: PieceState, task: string, - maxIterations: number, + maxMovements: number, updatePersonaSession: (persona: string, sessionId: string | undefined) => void, prebuiltInstruction?: string, ): Promise<{ response: AgentResponse; instruction: string }> { const movementIteration = prebuiltInstruction ? state.movementIterations.get(step.name) ?? 1 : incrementMovementIteration(state, step.name); - const instruction = prebuiltInstruction ?? this.buildInstruction(step, movementIteration, state, task, maxIterations); + const instruction = prebuiltInstruction ?? this.buildInstruction(step, movementIteration, state, task, maxMovements); const sessionKey = buildSessionKey(step); log.debug('Running movement', { movement: step.name, diff --git a/src/core/piece/engine/ParallelRunner.ts b/src/core/piece/engine/ParallelRunner.ts index 62e3c75d..a72a04eb 100644 --- a/src/core/piece/engine/ParallelRunner.ts +++ b/src/core/piece/engine/ParallelRunner.ts @@ -54,7 +54,7 @@ export class ParallelRunner { step: PieceMovement, state: PieceState, task: string, - maxIterations: number, + maxMovements: number, updatePersonaSession: (persona: string, sessionId: string | undefined) => void, ): Promise<{ response: AgentResponse; instruction: string }> { if (!step.parallel) { @@ -70,7 +70,7 @@ export class ParallelRunner { // Create parallel logger for prefixed output (only when streaming is enabled) const parallelLogger = this.deps.engineOptions.onStream - ? new ParallelLogger(this.buildParallelLoggerOptions(step.name, movementIteration, subMovements.map((s) => s.name), state.iteration, maxIterations)) + ? new ParallelLogger(this.buildParallelLoggerOptions(step.name, movementIteration, subMovements.map((s) => s.name), state.iteration, maxMovements)) : undefined; const ruleCtx = { @@ -85,7 +85,7 @@ export class ParallelRunner { const settled = await Promise.allSettled( subMovements.map(async (subMovement, index) => { const subIteration = incrementMovementIteration(state, subMovement.name); - const subInstruction = this.deps.movementExecutor.buildInstruction(subMovement, subIteration, state, task, maxIterations); + const subInstruction = this.deps.movementExecutor.buildInstruction(subMovement, subIteration, state, task, maxMovements); // Session key uses buildSessionKey (persona:provider) — same as normal movements. // This ensures sessions are shared across movements with the same persona+provider, @@ -207,14 +207,14 @@ export class ParallelRunner { movementIteration: number, subMovementNames: string[], iteration: number, - maxIterations: number, + maxMovements: number, ): ParallelLoggerOptions { const options: ParallelLoggerOptions = { subMovementNames, parentOnStream: this.deps.engineOptions.onStream, progressInfo: { iteration, - maxIterations, + maxMovements, }, }; diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index b47831ac..cfde2397 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -167,7 +167,7 @@ export class PieceEngine extends EventEmitter { piece: config.name, movements: config.movements.map(s => s.name), initialMovement: config.initialMovement, - maxIterations: config.maxIterations, + maxMovements: config.maxMovements, }); } @@ -331,7 +331,7 @@ export class PieceEngine extends EventEmitter { if (step.parallel && step.parallel.length > 0) { result = await this.parallelRunner.runParallelMovement( - step, this.state, this.task, this.config.maxIterations, updateSession, + step, this.state, this.task, this.config.maxMovements, updateSession, ); } else if (step.arpeggio) { result = await this.arpeggioRunner.runArpeggioMovement( @@ -339,7 +339,7 @@ export class PieceEngine extends EventEmitter { ); } else { result = await this.movementExecutor.runNormalMovement( - step, this.state, this.task, this.config.maxIterations, updateSession, prebuiltInstruction, + step, this.state, this.task, this.config.maxMovements, updateSession, prebuiltInstruction, ); } @@ -364,7 +364,7 @@ export class PieceEngine extends EventEmitter { /** Build instruction (public, used by pieceExecution.ts for logging) */ buildInstruction(step: PieceMovement, movementIteration: number): string { return this.movementExecutor.buildInstruction( - step, movementIteration, this.state, this.task, this.config.maxIterations, + step, movementIteration, this.state, this.task, this.config.maxMovements, ); } @@ -451,7 +451,7 @@ export class PieceEngine extends EventEmitter { this.state.iteration++; const movementIteration = incrementMovementIteration(this.state, judgeMovement.name); const prebuiltInstruction = this.movementExecutor.buildInstruction( - judgeMovement, movementIteration, this.state, this.task, this.config.maxIterations, + judgeMovement, movementIteration, this.state, this.task, this.config.maxMovements, ); this.emit('movement:start', judgeMovement, this.state.iteration, prebuiltInstruction); @@ -460,7 +460,7 @@ export class PieceEngine extends EventEmitter { judgeMovement, this.state, this.task, - this.config.maxIterations, + this.config.maxMovements, this.updatePersonaSession.bind(this), prebuiltInstruction, ); @@ -491,27 +491,27 @@ export class PieceEngine extends EventEmitter { break; } - if (this.state.iteration >= this.config.maxIterations) { - this.emit('iteration:limit', this.state.iteration, this.config.maxIterations); + if (this.state.iteration >= this.config.maxMovements) { + this.emit('iteration:limit', this.state.iteration, this.config.maxMovements); if (this.options.onIterationLimit) { const additionalIterations = await this.options.onIterationLimit({ currentIteration: this.state.iteration, - maxIterations: this.config.maxIterations, + maxMovements: this.config.maxMovements, currentMovement: this.state.currentMovement, }); if (additionalIterations !== null && additionalIterations > 0) { this.config = { ...this.config, - maxIterations: this.config.maxIterations + additionalIterations, + maxMovements: this.config.maxMovements + additionalIterations, }; continue; } } this.state.status = 'aborted'; - this.emit('piece:abort', this.state, ERROR_MESSAGES.MAX_ITERATIONS_REACHED); + this.emit('piece:abort', this.state, ERROR_MESSAGES.MAX_MOVEMENTS_REACHED); break; } @@ -537,7 +537,7 @@ export class PieceEngine extends EventEmitter { if (!isDelegated) { const movementIteration = incrementMovementIteration(this.state, movement.name); prebuiltInstruction = this.movementExecutor.buildInstruction( - movement, movementIteration, this.state, this.task, this.config.maxIterations, + movement, movementIteration, this.state, this.task, this.config.maxMovements, ); } this.emit('movement:start', movement, this.state.iteration, prebuiltInstruction ?? ''); diff --git a/src/core/piece/engine/parallel-logger.ts b/src/core/piece/engine/parallel-logger.ts index 9994ef5e..2566a2af 100644 --- a/src/core/piece/engine/parallel-logger.ts +++ b/src/core/piece/engine/parallel-logger.ts @@ -17,8 +17,8 @@ const RESET = '\x1b[0m'; export interface ParallelProgressInfo { /** Current iteration (1-indexed) */ iteration: number; - /** Maximum iterations allowed */ - maxIterations: number; + /** Maximum movements allowed */ + maxMovements: number; } export interface ParallelLoggerOptions { @@ -83,8 +83,8 @@ export class ParallelLogger { buildPrefix(name: string, index: number): string { if (this.taskLabel && this.parentMovementName && this.progressInfo && this.movementIteration != null && this.taskColorIndex != null) { const taskColor = COLORS[this.taskColorIndex % COLORS.length]; - const { iteration, maxIterations } = this.progressInfo; - return `${taskColor}[${this.taskLabel}]${RESET}[${this.parentMovementName}][${name}](${iteration}/${maxIterations})(${this.movementIteration}) `; + const { iteration, maxMovements } = this.progressInfo; + return `${taskColor}[${this.taskLabel}]${RESET}[${this.parentMovementName}][${name}](${iteration}/${maxMovements})(${this.movementIteration}) `; } const color = COLORS[index % COLORS.length]; @@ -92,9 +92,9 @@ export class ParallelLogger { let progressPart = ''; if (this.progressInfo) { - const { iteration, maxIterations } = this.progressInfo; + const { iteration, maxMovements } = this.progressInfo; // index is 0-indexed, display as 1-indexed for step number - progressPart = `(${iteration}/${maxIterations}) step ${index + 1}/${this.totalSubMovements} `; + progressPart = `(${iteration}/${maxMovements}) step ${index + 1}/${this.totalSubMovements} `; } return `${color}[${name}]${RESET}${padding} ${progressPart}`; diff --git a/src/core/piece/instruction/InstructionBuilder.ts b/src/core/piece/instruction/InstructionBuilder.ts index 30b0fb5a..316ebd0a 100644 --- a/src/core/piece/instruction/InstructionBuilder.ts +++ b/src/core/piece/instruction/InstructionBuilder.ts @@ -202,7 +202,7 @@ export class InstructionBuilder { pieceDescription, hasPieceDescription, pieceStructure, - iteration: `${this.context.iteration}/${this.context.maxIterations}`, + iteration: `${this.context.iteration}/${this.context.maxMovements}`, movementIteration: String(this.context.movementIteration), movement: this.step.name, hasReport, diff --git a/src/core/piece/instruction/ReportInstructionBuilder.ts b/src/core/piece/instruction/ReportInstructionBuilder.ts index 2c21aa92..6ed90d4b 100644 --- a/src/core/piece/instruction/ReportInstructionBuilder.ts +++ b/src/core/piece/instruction/ReportInstructionBuilder.ts @@ -59,7 +59,7 @@ export class ReportInstructionBuilder { const instrContext: InstructionContext = { task: '', iteration: 0, - maxIterations: 0, + maxMovements: 0, movementIteration: this.context.movementIteration, cwd: this.context.cwd, projectCwd: this.context.cwd, diff --git a/src/core/piece/instruction/escape.ts b/src/core/piece/instruction/escape.ts index 4a2b7df6..9b4fcdd6 100644 --- a/src/core/piece/instruction/escape.ts +++ b/src/core/piece/instruction/escape.ts @@ -30,9 +30,9 @@ export function replaceTemplatePlaceholders( // Replace {task} result = result.replace(/\{task\}/g, escapeTemplateChars(context.task)); - // Replace {iteration}, {max_iterations}, and {movement_iteration} + // Replace {iteration}, {max_movements}, and {movement_iteration} result = result.replace(/\{iteration\}/g, String(context.iteration)); - result = result.replace(/\{max_iterations\}/g, String(context.maxIterations)); + result = result.replace(/\{max_movements\}/g, String(context.maxMovements)); result = result.replace(/\{movement_iteration\}/g, String(context.movementIteration)); // Replace {previous_response} diff --git a/src/core/piece/instruction/instruction-context.ts b/src/core/piece/instruction/instruction-context.ts index 739d91b6..ee8bf645 100644 --- a/src/core/piece/instruction/instruction-context.ts +++ b/src/core/piece/instruction/instruction-context.ts @@ -14,8 +14,8 @@ export interface InstructionContext { task: string; /** Current iteration number (piece-wide turn count) */ iteration: number; - /** Maximum iterations allowed */ - maxIterations: number; + /** Maximum movements allowed */ + maxMovements: number; /** Current movement's iteration number (how many times this movement has been executed) */ movementIteration: number; /** Working directory (agent work dir, may be a clone) */ diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index f24edd26..b4482eb3 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -117,7 +117,7 @@ export interface PieceEvents { 'phase:complete': (step: PieceMovement, phase: 1 | 2 | 3, phaseName: PhaseName, content: string, status: string, error?: string) => void; 'piece:complete': (state: PieceState) => void; 'piece:abort': (state: PieceState, reason: string) => void; - 'iteration:limit': (iteration: number, maxIterations: number) => void; + 'iteration:limit': (iteration: number, maxMovements: number) => void; 'movement:loop_detected': (step: PieceMovement, consecutiveCount: number) => void; 'movement:cycle_detected': (monitor: LoopMonitorConfig, cycleCount: number) => void; } @@ -136,8 +136,8 @@ export interface UserInputRequest { export interface IterationLimitRequest { /** Current iteration count */ currentIteration: number; - /** Current max iterations */ - maxIterations: number; + /** Current max movements */ + maxMovements: number; /** Current movement name */ currentMovement: string; } diff --git a/src/features/prompt/preview.ts b/src/features/prompt/preview.ts index 42f64ef6..27d5bc89 100644 --- a/src/features/prompt/preview.ts +++ b/src/features/prompt/preview.ts @@ -48,7 +48,7 @@ export async function previewPrompts(cwd: string, pieceIdentifier?: string): Pro const context: InstructionContext = { task: '', iteration: 1, - maxIterations: config.maxIterations, + maxMovements: config.maxMovements, movementIteration: 1, cwd, projectCwd: cwd, diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index f87e0206..e660f95a 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -332,7 +332,7 @@ export async function executePiece( out.warn( getLabel('piece.iterationLimit.maxReached', undefined, { currentIteration: String(request.currentIteration), - maxIterations: String(request.maxIterations), + maxMovements: String(request.maxMovements), }) ); out.info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement })); @@ -362,7 +362,7 @@ export async function executePiece( const additionalIterations = Number.parseInt(input, 10); if (Number.isInteger(additionalIterations) && additionalIterations > 0) { - pieceConfig.maxIterations = request.maxIterations + additionalIterations; + pieceConfig.maxMovements = request.maxMovements + additionalIterations; return additionalIterations; } @@ -475,10 +475,10 @@ export async function executePiece( prefixWriter?.setMovementContext({ movementName: step.name, iteration, - maxIterations: pieceConfig.maxIterations, + maxMovements: pieceConfig.maxMovements, movementIteration, }); - out.info(`[${iteration}/${pieceConfig.maxIterations}] ${step.name} (${step.personaDisplayName})`); + out.info(`[${iteration}/${pieceConfig.maxMovements}] ${step.name} (${step.personaDisplayName})`); // Log prompt content for debugging if (instruction) { @@ -496,7 +496,7 @@ export async function executePiece( const agentLabel = step.personaDisplayName; displayRef.current = new StreamDisplay(agentLabel, quiet, { iteration, - maxIterations: pieceConfig.maxIterations, + maxMovements: pieceConfig.maxMovements, movementIndex: movementIndex >= 0 ? movementIndex : 0, totalMovements, }); diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 800c8708..bd5f6df5 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -314,7 +314,7 @@ export function normalizePieceConfig( reportFormats: resolvedReportFormats, movements, initialMovement, - maxIterations: parsed.max_iterations, + maxMovements: parsed.max_movements, loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir, sections, context), answerAgent: parsed.answer_agent, interactiveMode: parsed.interactive_mode, diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index ffbd4756..c2624a19 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -45,7 +45,7 @@ interactive: # ===== Piece Execution UI ===== piece: iterationLimit: - maxReached: "Reached max iterations ({currentIteration}/{maxIterations})" + maxReached: "Reached max iterations ({currentIteration}/{maxMovements})" currentMovement: "Current movement: {currentMovement}" continueQuestion: "Continue?" continueLabel: "Continue (enter additional iterations)" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 0c508906..e2ddbf69 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -45,7 +45,7 @@ interactive: # ===== Piece Execution UI ===== piece: iterationLimit: - maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxIterations})" + maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxMovements})" currentMovement: "現在のムーブメント: {currentMovement}" continueQuestion: "続行しますか?" continueLabel: "続行する(追加イテレーション数を入力)" diff --git a/src/shared/ui/StreamDisplay.ts b/src/shared/ui/StreamDisplay.ts index 581a79fc..8be57656 100644 --- a/src/shared/ui/StreamDisplay.ts +++ b/src/shared/ui/StreamDisplay.ts @@ -18,8 +18,8 @@ import { stripAnsi } from '../utils/text.js'; export interface ProgressInfo { /** Current iteration (1-indexed) */ iteration: number; - /** Maximum iterations allowed */ - maxIterations: number; + /** Maximum movements allowed */ + maxMovements: number; /** Current movement index within piece (0-indexed) */ movementIndex: number; /** Total number of movements in piece */ @@ -52,16 +52,16 @@ export class StreamDisplay { /** * Build progress prefix string for display. - * Format: `(iteration/maxIterations) step movementIndex/totalMovements` + * Format: `(iteration/maxMovements) step movementIndex/totalMovements` * Example: `(3/10) step 2/4` */ private buildProgressPrefix(): string { if (!this.progressInfo) { return ''; } - const { iteration, maxIterations, movementIndex, totalMovements } = this.progressInfo; + const { iteration, maxMovements, movementIndex, totalMovements } = this.progressInfo; // movementIndex is 0-indexed, display as 1-indexed - return `(${iteration}/${maxIterations}) step ${movementIndex + 1}/${totalMovements}`; + return `(${iteration}/${maxMovements}) step ${movementIndex + 1}/${totalMovements}`; } showInit(model: string): void { diff --git a/src/shared/ui/TaskPrefixWriter.ts b/src/shared/ui/TaskPrefixWriter.ts index 188ea88f..7cf508b9 100644 --- a/src/shared/ui/TaskPrefixWriter.ts +++ b/src/shared/ui/TaskPrefixWriter.ts @@ -30,7 +30,7 @@ export interface TaskPrefixWriterOptions { export interface MovementPrefixContext { movementName: string; iteration: number; - maxIterations: number; + maxMovements: number; movementIteration: number; } @@ -63,8 +63,8 @@ export class TaskPrefixWriter { return `${this.taskPrefix} `; } - const { movementName, iteration, maxIterations, movementIteration } = this.movementContext; - return `${this.taskPrefix}[${movementName}](${iteration}/${maxIterations})(${movementIteration}) `; + const { movementName, iteration, maxMovements, movementIteration } = this.movementContext; + return `${this.taskPrefix}[${movementName}](${iteration}/${maxMovements})(${movementIteration}) `; } /** From 621b8bd507b71d2ad8b46bb35d41f251f59418c9 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:44:03 +0900 Subject: [PATCH 21/45] takt: github-issue-180-ai (#219) --- .../cli-routing-issue-resolve.test.ts | 51 +++- src/__tests__/interactive.test.ts | 36 +++ src/__tests__/session-reader.test.ts | 261 ++++++++++++++++++ src/__tests__/sessionSelector.test.ts | 156 +++++++++++ src/app/cli/routing.ts | 14 +- src/features/interactive/index.ts | 1 + src/features/interactive/interactive.ts | 4 +- src/features/interactive/sessionSelector.ts | 103 +++++++ src/infra/claude/index.ts | 1 + src/infra/claude/session-reader.ts | 123 +++++++++ src/shared/i18n/labels_en.yaml | 6 + src/shared/i18n/labels_ja.yaml | 6 + 12 files changed, 758 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/session-reader.test.ts create mode 100644 src/__tests__/sessionSelector.test.ts create mode 100644 src/features/interactive/sessionSelector.ts create mode 100644 src/infra/claude/session-reader.ts diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index ca3dda0f..5e7fb931 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -47,6 +47,7 @@ vi.mock('../features/pipeline/index.js', () => ({ vi.mock('../features/interactive/index.js', () => ({ interactiveMode: vi.fn(), selectInteractiveMode: vi.fn(() => 'assistant'), + selectRecentSession: vi.fn(() => null), passthroughMode: vi.fn(), quietMode: vi.fn(), personaMode: vi.fn(), @@ -85,7 +86,8 @@ vi.mock('../app/cli/helpers.js', () => ({ import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; import { selectAndExecuteTask, determinePiece, createIssueAndSaveTask } from '../features/tasks/index.js'; -import { interactiveMode } from '../features/interactive/index.js'; +import { interactiveMode, selectRecentSession } from '../features/interactive/index.js'; +import { loadGlobalConfig } from '../infra/config/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; import { executeDefaultAction } from '../app/cli/routing.js'; import type { GitHubIssue } from '../infra/github/types.js'; @@ -98,6 +100,8 @@ const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask); const mockDeterminePiece = vi.mocked(determinePiece); const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask); const mockInteractiveMode = vi.mocked(interactiveMode); +const mockSelectRecentSession = vi.mocked(selectRecentSession); +const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); const mockIsDirectTask = vi.mocked(isDirectTask); function createMockIssue(number: number): GitHubIssue { @@ -144,6 +148,7 @@ describe('Issue resolution in routing', () => { '/test/cwd', '## GitHub Issue #131: Issue #131', expect.anything(), + undefined, ); // Then: selectAndExecuteTask should receive issues in options @@ -196,6 +201,7 @@ describe('Issue resolution in routing', () => { '/test/cwd', '## GitHub Issue #131: Issue #131', expect.anything(), + undefined, ); // Then: selectAndExecuteTask should receive issues @@ -220,6 +226,7 @@ describe('Issue resolution in routing', () => { '/test/cwd', 'refactor the code', expect.anything(), + undefined, ); // Then: no issue fetching should occur @@ -239,6 +246,7 @@ describe('Issue resolution in routing', () => { '/test/cwd', undefined, expect.anything(), + undefined, ); // Then: no issue fetching should occur @@ -291,4 +299,45 @@ describe('Issue resolution in routing', () => { expect(mockSelectAndExecuteTask).not.toHaveBeenCalled(); }); }); + + describe('session selection with provider=claude', () => { + it('should pass selected session ID to interactiveMode when provider is claude', async () => { + // Given + mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); + mockSelectRecentSession.mockResolvedValue('session-xyz'); + + // When + await executeDefaultAction(); + + // Then: selectRecentSession should be called + expect(mockSelectRecentSession).toHaveBeenCalledWith('/test/cwd', 'en'); + + // Then: interactiveMode should receive the session ID as 4th argument + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + undefined, + expect.anything(), + 'session-xyz', + ); + }); + + it('should not call selectRecentSession when provider is not claude', async () => { + // Given + mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'openai' }); + + // When + await executeDefaultAction(); + + // Then: selectRecentSession should NOT be called + expect(mockSelectRecentSession).not.toHaveBeenCalled(); + + // Then: interactiveMode should be called with undefined session ID + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + undefined, + expect.anything(), + undefined, + ); + }); + }); }); diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index d5310424..1419be9b 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -369,6 +369,42 @@ describe('interactiveMode', () => { expect(result.task).toBe('Fix login page with clarified scope.'); }); + it('should pass sessionId to provider when sessionId parameter is given', async () => { + // Given + setupRawStdin(toRawInputs(['hello', '/cancel'])); + setupMockProvider(['AI response']); + + // When + await interactiveMode('/project', undefined, undefined, 'test-session-id'); + + // Then: provider call should include the overridden sessionId + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + sessionId: 'test-session-id', + }), + ); + }); + + it('should use saved sessionId from initializeSession when no sessionId parameter is given', async () => { + // Given + setupRawStdin(toRawInputs(['hello', '/cancel'])); + setupMockProvider(['AI response']); + + // When: no sessionId parameter + await interactiveMode('/project'); + + // Then: provider call should include sessionId from initializeSession (undefined in mock) + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + expect(mockProvider._call).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + sessionId: undefined, + }), + ); + }); + describe('/play command', () => { it('should return action=execute with task on /play command', async () => { // Given diff --git a/src/__tests__/session-reader.test.ts b/src/__tests__/session-reader.test.ts new file mode 100644 index 00000000..b5baed0d --- /dev/null +++ b/src/__tests__/session-reader.test.ts @@ -0,0 +1,261 @@ +/** + * Tests for Claude Code session reader + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Mock getClaudeProjectSessionsDir to point to our temp directory +let mockSessionsDir: string; + +vi.mock('../infra/config/project/sessionStore.js', () => ({ + getClaudeProjectSessionsDir: vi.fn(() => mockSessionsDir), +})); + +import { loadSessionIndex, extractLastAssistantResponse } from '../infra/claude/session-reader.js'; + +describe('loadSessionIndex', () => { + beforeEach(() => { + mockSessionsDir = mkdtempSync(join(tmpdir(), 'session-reader-test-')); + }); + + it('returns empty array when sessions-index.json does not exist', () => { + const result = loadSessionIndex('/nonexistent'); + expect(result).toEqual([]); + }); + + it('reads and parses sessions-index.json correctly', () => { + const indexData = { + version: 1, + entries: [ + { + sessionId: 'aaa', + firstPrompt: 'First session', + modified: '2026-01-28T10:00:00.000Z', + messageCount: 5, + gitBranch: 'main', + isSidechain: false, + fullPath: '/path/to/aaa.jsonl', + }, + { + sessionId: 'bbb', + firstPrompt: 'Second session', + modified: '2026-01-29T10:00:00.000Z', + messageCount: 10, + gitBranch: '', + isSidechain: false, + fullPath: '/path/to/bbb.jsonl', + }, + ], + }; + + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), JSON.stringify(indexData)); + + const result = loadSessionIndex('/any'); + expect(result).toHaveLength(2); + // Sorted by modified descending: bbb (Jan 29) first, then aaa (Jan 28) + expect(result[0]!.sessionId).toBe('bbb'); + expect(result[1]!.sessionId).toBe('aaa'); + }); + + it('filters out sidechain sessions', () => { + const indexData = { + version: 1, + entries: [ + { + sessionId: 'main-session', + firstPrompt: 'User conversation', + modified: '2026-01-28T10:00:00.000Z', + messageCount: 5, + gitBranch: '', + isSidechain: false, + fullPath: '/path/to/main.jsonl', + }, + { + sessionId: 'sidechain-session', + firstPrompt: 'Sub-agent work', + modified: '2026-01-29T10:00:00.000Z', + messageCount: 20, + gitBranch: '', + isSidechain: true, + fullPath: '/path/to/sidechain.jsonl', + }, + ], + }; + + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), JSON.stringify(indexData)); + + const result = loadSessionIndex('/any'); + expect(result).toHaveLength(1); + expect(result[0]!.sessionId).toBe('main-session'); + }); + + it('returns empty array when entries is missing', () => { + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), JSON.stringify({ version: 1 })); + + const result = loadSessionIndex('/any'); + expect(result).toEqual([]); + }); + + it('returns empty array when sessions-index.json contains corrupted JSON', () => { + writeFileSync(join(mockSessionsDir, 'sessions-index.json'), '{corrupted json content'); + + const result = loadSessionIndex('/any'); + expect(result).toEqual([]); + }); +}); + +describe('extractLastAssistantResponse', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'session-reader-extract-')); + }); + + it('returns null when file does not exist', () => { + const result = extractLastAssistantResponse('/nonexistent/file.jsonl', 200); + expect(result).toBeNull(); + }); + + it('extracts text from last assistant message', () => { + const lines = [ + JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'First response' }] }, + }), + JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'Follow up' }] }, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Last response here' }] }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Last response here'); + }); + + it('skips assistant messages with only tool_use content', () => { + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Text response' }] }, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'tool_use', id: 'tool1', name: 'Read', input: {} }] }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Text response'); + }); + + it('returns null when no assistant messages have text', () => { + const lines = [ + JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'tool_use', id: 'tool1', name: 'Read', input: {} }] }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBeNull(); + }); + + it('truncates long responses', () => { + const longText = 'A'.repeat(300); + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: longText }] }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toHaveLength(201); // 200 chars + '…' + expect(result!.endsWith('…')).toBe(true); + }); + + it('concatenates multiple text blocks in a single message', () => { + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'Part one' }, + { type: 'tool_use', id: 'tool1', name: 'Read', input: {} }, + { type: 'text', text: 'Part two' }, + ], + }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Part one\nPart two'); + }); + + it('handles malformed JSON lines gracefully', () => { + const lines = [ + 'not valid json', + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Valid response' }] }, + }), + '{also broken', + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Valid response'); + }); + + it('handles progress and other non-assistant record types', () => { + const lines = [ + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Response' }] }, + }), + JSON.stringify({ + type: 'progress', + data: { type: 'hook_progress' }, + }), + ]; + + const filePath = join(tempDir, 'session.jsonl'); + writeFileSync(filePath, lines.join('\n')); + + const result = extractLastAssistantResponse(filePath, 200); + expect(result).toBe('Response'); + }); +}); diff --git a/src/__tests__/sessionSelector.test.ts b/src/__tests__/sessionSelector.test.ts new file mode 100644 index 00000000..9bb7941d --- /dev/null +++ b/src/__tests__/sessionSelector.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for session selector + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SessionIndexEntry } from '../infra/claude/session-reader.js'; + +const mockLoadSessionIndex = vi.fn<(dir: string) => SessionIndexEntry[]>(); +const mockExtractLastAssistantResponse = vi.fn<(path: string, maxLen: number) => string | null>(); + +vi.mock('../infra/claude/session-reader.js', () => ({ + loadSessionIndex: (...args: [string]) => mockLoadSessionIndex(...args), + extractLastAssistantResponse: (...args: [string, number]) => mockExtractLastAssistantResponse(...args), +})); + +const mockSelectOption = vi.fn<(prompt: string, options: unknown[]) => Promise>(); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: (...args: [string, unknown[]]) => mockSelectOption(...args), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: (key: string, _lang: string, params?: Record) => { + if (key === 'interactive.sessionSelector.newSession') return 'New session'; + if (key === 'interactive.sessionSelector.newSessionDescription') return 'Start a new conversation'; + if (key === 'interactive.sessionSelector.messages') return `${params?.count} messages`; + if (key === 'interactive.sessionSelector.lastResponse') return `Last: ${params?.response}`; + if (key === 'interactive.sessionSelector.prompt') return 'Select a session'; + return key; + }, +})); + +import { selectRecentSession } from '../features/interactive/sessionSelector.js'; + +describe('selectRecentSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return null when no sessions exist', async () => { + mockLoadSessionIndex.mockReturnValue([]); + + const result = await selectRecentSession('/project', 'en'); + + expect(result).toBeNull(); + expect(mockSelectOption).not.toHaveBeenCalled(); + }); + + it('should return null when user selects __new__', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('session-1', 'Hello world', '2026-01-28T10:00:00.000Z'), + ]); + mockExtractLastAssistantResponse.mockReturnValue(null); + mockSelectOption.mockResolvedValue('__new__'); + + const result = await selectRecentSession('/project', 'en'); + + expect(result).toBeNull(); + }); + + it('should return null when user cancels selection', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('session-1', 'Hello world', '2026-01-28T10:00:00.000Z'), + ]); + mockExtractLastAssistantResponse.mockReturnValue(null); + mockSelectOption.mockResolvedValue(null); + + const result = await selectRecentSession('/project', 'en'); + + expect(result).toBeNull(); + }); + + it('should return sessionId when user selects a session', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('session-abc', 'Fix the bug', '2026-01-28T10:00:00.000Z'), + ]); + mockExtractLastAssistantResponse.mockReturnValue(null); + mockSelectOption.mockResolvedValue('session-abc'); + + const result = await selectRecentSession('/project', 'en'); + + expect(result).toBe('session-abc'); + }); + + it('should pass correct options to selectOption with new session first', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('s1', 'First prompt', '2026-01-28T10:00:00.000Z', 5), + ]); + mockExtractLastAssistantResponse.mockReturnValue('Some response'); + mockSelectOption.mockResolvedValue('s1'); + + await selectRecentSession('/project', 'en'); + + expect(mockSelectOption).toHaveBeenCalledWith( + 'Select a session', + expect.arrayContaining([ + expect.objectContaining({ value: '__new__', label: 'New session' }), + expect.objectContaining({ value: 's1' }), + ]), + ); + + const options = mockSelectOption.mock.calls[0]![1] as Array<{ value: string }>; + expect(options[0]!.value).toBe('__new__'); + expect(options[1]!.value).toBe('s1'); + }); + + it('should limit display to MAX_DISPLAY_SESSIONS (10)', async () => { + const sessions = Array.from({ length: 15 }, (_, i) => + createSession(`s${i}`, `Prompt ${i}`, `2026-01-${String(i + 10).padStart(2, '0')}T10:00:00.000Z`), + ); + mockLoadSessionIndex.mockReturnValue(sessions); + mockExtractLastAssistantResponse.mockReturnValue(null); + mockSelectOption.mockResolvedValue(null); + + await selectRecentSession('/project', 'en'); + + const options = mockSelectOption.mock.calls[0]![1] as Array<{ value: string }>; + // 1 new session + 10 display sessions = 11 total + expect(options).toHaveLength(11); + }); + + it('should include last response details when available', async () => { + mockLoadSessionIndex.mockReturnValue([ + createSession('s1', 'Hello', '2026-01-28T10:00:00.000Z', 3, '/path/to/s1.jsonl'), + ]); + mockExtractLastAssistantResponse.mockReturnValue('AI response text'); + mockSelectOption.mockResolvedValue('s1'); + + await selectRecentSession('/project', 'en'); + + expect(mockExtractLastAssistantResponse).toHaveBeenCalledWith('/path/to/s1.jsonl', 200); + + const options = mockSelectOption.mock.calls[0]![1] as Array<{ value: string; details?: string[] }>; + const sessionOption = options[1]!; + expect(sessionOption.details).toBeDefined(); + expect(sessionOption.details![0]).toContain('AI response text'); + }); +}); + +function createSession( + sessionId: string, + firstPrompt: string, + modified: string, + messageCount = 5, + fullPath = `/path/to/${sessionId}.jsonl`, +): SessionIndexEntry { + return { + sessionId, + firstPrompt, + modified, + messageCount, + gitBranch: 'main', + isSidechain: false, + fullPath, + }; +} diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 43394416..b4854776 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -14,6 +14,7 @@ import { executePipeline } from '../../features/pipeline/index.js'; import { interactiveMode, selectInteractiveMode, + selectRecentSession, passthroughMode, quietMode, personaMode, @@ -162,9 +163,18 @@ export async function executeDefaultAction(task?: string): Promise { let result: InteractiveModeResult; switch (selectedMode) { - case 'assistant': - result = await interactiveMode(resolvedCwd, initialInput, pieceContext); + case 'assistant': { + let selectedSessionId: string | undefined; + const provider = globalConfig.provider; + if (provider === 'claude') { + const sessionId = await selectRecentSession(resolvedCwd, lang); + if (sessionId) { + selectedSessionId = sessionId; + } + } + result = await interactiveMode(resolvedCwd, initialInput, pieceContext, selectedSessionId); break; + } case 'passthrough': result = await passthroughMode(lang, initialInput); diff --git a/src/features/interactive/index.ts b/src/features/interactive/index.ts index 66b5e9d1..56bda3f3 100644 --- a/src/features/interactive/index.ts +++ b/src/features/interactive/index.ts @@ -15,6 +15,7 @@ export { } from './interactive.js'; export { selectInteractiveMode } from './modeSelection.js'; +export { selectRecentSession } from './sessionSelector.js'; export { passthroughMode } from './passthroughMode.js'; export { quietMode } from './quietMode.js'; export { personaMode } from './personaMode.js'; diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 0e677377..36b9104b 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -221,8 +221,10 @@ export async function interactiveMode( cwd: string, initialInput?: string, pieceContext?: PieceContext, + sessionId?: string, ): Promise { - const ctx = initializeSession(cwd, 'interactive'); + const baseCtx = initializeSession(cwd, 'interactive'); + const ctx = sessionId ? { ...baseCtx, sessionId } : baseCtx; displayAndClearSessionState(cwd, ctx.lang); diff --git a/src/features/interactive/sessionSelector.ts b/src/features/interactive/sessionSelector.ts new file mode 100644 index 00000000..c7fd6116 --- /dev/null +++ b/src/features/interactive/sessionSelector.ts @@ -0,0 +1,103 @@ +/** + * Session selector for interactive mode + * + * Presents recent Claude Code sessions for the user to choose from, + * allowing them to resume a previous conversation as the assistant. + */ + +import { loadSessionIndex, extractLastAssistantResponse } from '../../infra/claude/session-reader.js'; +import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js'; +import { getLabel } from '../../shared/i18n/index.js'; + +/** Maximum number of sessions to display */ +const MAX_DISPLAY_SESSIONS = 10; + +/** Maximum length for last response preview */ +const MAX_RESPONSE_PREVIEW_LENGTH = 200; + +/** + * Format a modified date for display. + */ +function formatModifiedDate(modified: string, lang: 'en' | 'ja'): string { + const date = new Date(modified); + return date.toLocaleString(lang === 'ja' ? 'ja-JP' : 'en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** + * Truncate a single-line string for use as a label. + */ +function truncateForLabel(text: string, maxLength: number): string { + const singleLine = text.replace(/\n/g, ' ').trim(); + if (singleLine.length <= maxLength) { + return singleLine; + } + return singleLine.slice(0, maxLength) + '…'; +} + +/** + * Prompt user to select from recent Claude Code sessions. + * + * @param cwd - Current working directory (project directory) + * @param lang - Display language + * @returns Selected session ID, or null for new session / no sessions + */ +export async function selectRecentSession( + cwd: string, + lang: 'en' | 'ja', +): Promise { + const sessions = loadSessionIndex(cwd); + + if (sessions.length === 0) { + return null; + } + + const displaySessions = sessions.slice(0, MAX_DISPLAY_SESSIONS); + + const options: SelectOptionItem[] = [ + { + label: getLabel('interactive.sessionSelector.newSession', lang), + value: '__new__', + description: getLabel('interactive.sessionSelector.newSessionDescription', lang), + }, + ]; + + for (const session of displaySessions) { + const label = truncateForLabel(session.firstPrompt, 60); + const dateStr = formatModifiedDate(session.modified, lang); + const messagesStr = getLabel('interactive.sessionSelector.messages', lang, { + count: String(session.messageCount), + }); + const description = `${dateStr} | ${messagesStr}`; + + const details: string[] = []; + const lastResponse = extractLastAssistantResponse(session.fullPath, MAX_RESPONSE_PREVIEW_LENGTH); + if (lastResponse) { + const previewLine = lastResponse.replace(/\n/g, ' ').trim(); + const preview = getLabel('interactive.sessionSelector.lastResponse', lang, { + response: previewLine, + }); + details.push(preview); + } + + options.push({ + label, + value: session.sessionId, + description, + details: details.length > 0 ? details : undefined, + }); + } + + const prompt = getLabel('interactive.sessionSelector.prompt', lang); + const selected = await selectOption(prompt, options); + + if (selected === null || selected === '__new__') { + return null; + } + + return selected; +} diff --git a/src/infra/claude/index.ts b/src/infra/claude/index.ts index 8e8060db..5e2cb5c2 100644 --- a/src/infra/claude/index.ts +++ b/src/infra/claude/index.ts @@ -70,3 +70,4 @@ export { detectRuleIndex, isRegexSafe, } from './client.js'; + diff --git a/src/infra/claude/session-reader.ts b/src/infra/claude/session-reader.ts new file mode 100644 index 00000000..82e6b1ea --- /dev/null +++ b/src/infra/claude/session-reader.ts @@ -0,0 +1,123 @@ +/** + * Claude Code session reader + * + * Reads Claude Code's sessions-index.json and individual .jsonl session files + * to extract session metadata and last assistant responses. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getClaudeProjectSessionsDir } from '../config/project/sessionStore.js'; + +/** Entry in Claude Code's sessions-index.json */ +export interface SessionIndexEntry { + sessionId: string; + firstPrompt: string; + modified: string; + messageCount: number; + gitBranch: string; + isSidechain: boolean; + fullPath: string; +} + +/** Shape of sessions-index.json */ +interface SessionsIndex { + version: number; + entries: SessionIndexEntry[]; +} + +/** + * Load the session index for a project directory. + * + * Reads ~/.claude/projects/{encoded-path}/sessions-index.json, + * filters out sidechain sessions, and sorts by modified descending. + */ +export function loadSessionIndex(projectDir: string): SessionIndexEntry[] { + const sessionsDir = getClaudeProjectSessionsDir(projectDir); + const indexPath = join(sessionsDir, 'sessions-index.json'); + + if (!existsSync(indexPath)) { + return []; + } + + const content = readFileSync(indexPath, 'utf-8'); + + let index: SessionsIndex; + try { + index = JSON.parse(content) as SessionsIndex; + } catch { + return []; + } + + if (!index.entries || !Array.isArray(index.entries)) { + return []; + } + + return index.entries + .filter((entry) => !entry.isSidechain) + .sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()); +} + +/** Content block with text type from Claude API */ +interface TextContentBlock { + type: 'text'; + text: string; +} + +/** Message structure in JSONL records */ +interface AssistantMessage { + content: Array; +} + +/** JSONL record for assistant messages */ +interface SessionRecord { + type: string; + message?: AssistantMessage; +} + +/** + * Extract the last assistant text response from a session JSONL file. + * + * Reads the file and scans from the end to find the last `type: "assistant"` + * record with a text content block. Returns the truncated text. + */ +export function extractLastAssistantResponse(sessionFilePath: string, maxLength: number): string | null { + if (!existsSync(sessionFilePath)) { + return null; + } + + const content = readFileSync(sessionFilePath, 'utf-8'); + const lines = content.split('\n').filter((line) => line.trim()); + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line) continue; + + let record: SessionRecord; + try { + record = JSON.parse(line) as SessionRecord; + } catch { + continue; + } + + if (record.type !== 'assistant' || !record.message?.content) { + continue; + } + + const textBlocks = record.message.content.filter( + (block): block is TextContentBlock => block.type === 'text', + ); + + if (textBlocks.length === 0) { + continue; + } + + const fullText = textBlocks.map((b) => b.text).join('\n'); + if (fullText.length <= maxLength) { + return fullText; + } + return fullText.slice(0, maxLength) + '…'; + } + + return null; +} diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index c2624a19..f85f8bcb 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -35,6 +35,12 @@ interactive: quietDescription: "Generate instructions without asking questions" passthrough: "Passthrough" passthroughDescription: "Pass your input directly as task text" + sessionSelector: + prompt: "Resume from a recent session?" + newSession: "New session" + newSessionDescription: "Start a fresh conversation" + lastResponse: "Last: {response}" + messages: "{count} messages" previousTask: success: "✅ Previous task completed successfully" error: "❌ Previous task failed: {error}" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index e2ddbf69..1931d873 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -35,6 +35,12 @@ interactive: quietDescription: "質問なしでベストエフォートの指示書を生成" passthrough: "パススルー" passthroughDescription: "入力をそのままタスクとして渡す" + sessionSelector: + prompt: "直近のセッションを引き継ぎますか?" + newSession: "新しいセッション" + newSessionDescription: "新しい会話を始める" + lastResponse: "最後: {response}" + messages: "{count}メッセージ" previousTask: success: "✅ 前回のタスクは正常に完了しました" error: "❌ 前回のタスクはエラーで終了しました: {error}" From 11045d1c5750fd08c6d1ca56812495d759584ad9 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:44:41 +0900 Subject: [PATCH 22/45] takt: github-issue-163-report-phase-blocked (#218) --- src/__tests__/report-phase-blocked.test.ts | 224 +++++++++++++++++++++ src/core/piece/engine/MovementExecutor.ts | 9 +- src/core/piece/index.ts | 2 +- src/core/piece/phase-runner.ts | 12 +- 4 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/report-phase-blocked.test.ts diff --git a/src/__tests__/report-phase-blocked.test.ts b/src/__tests__/report-phase-blocked.test.ts new file mode 100644 index 00000000..3afad149 --- /dev/null +++ b/src/__tests__/report-phase-blocked.test.ts @@ -0,0 +1,224 @@ +/** + * PieceEngine integration tests: Report phase (Phase 2) blocked handling. + * + * Covers: + * - Report phase blocked propagates to PieceEngine's handleBlocked flow + * - User input triggers full movement retry (Phase 1 → 2 → 3) + * - Null user input aborts the piece + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, rmSync } from 'node:fs'; + +// --- Mock setup (must be before imports that use these modules) --- + +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../core/piece/evaluation/index.js', () => ({ + detectMatchedRule: vi.fn(), +})); + +vi.mock('../core/piece/phase-runner.js', () => ({ + needsStatusJudgmentPhase: vi.fn().mockReturnValue(false), + runReportPhase: vi.fn().mockResolvedValue(undefined), + runStatusJudgmentPhase: vi.fn().mockResolvedValue(''), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), +})); + +// --- Imports (after mocks) --- + +import { PieceEngine } from '../core/piece/index.js'; +import { runReportPhase } from '../core/piece/index.js'; +import { + makeResponse, + makeMovement, + buildDefaultPieceConfig, + mockRunAgentSequence, + mockDetectMatchedRuleSequence, + createTestTmpDir, + applyDefaultMocks, +} from './engine-test-helpers.js'; +import type { PieceConfig, OutputContractItem } from '../core/models/index.js'; + +/** + * Build a piece config where a movement has outputContracts (triggering report phase). + * plan → implement (with report) → supervise + */ +function buildConfigWithReport(): PieceConfig { + const reportContract: OutputContractItem = { + name: '02-coder-scope.md', + label: 'Scope', + description: 'Scope report', + }; + + return buildDefaultPieceConfig({ + movements: [ + makeMovement('plan', { + rules: [ + { condition: 'Requirements are clear', next: 'implement' }, + { condition: 'Requirements unclear', next: 'ABORT' }, + ], + }), + makeMovement('implement', { + outputContracts: [reportContract], + rules: [ + { condition: 'Implementation complete', next: 'supervise' }, + { condition: 'Cannot proceed', next: 'plan' }, + ], + }), + makeMovement('supervise', { + rules: [ + { condition: 'All checks passed', next: 'COMPLETE' }, + { condition: 'Requirements unmet', next: 'plan' }, + ], + }), + ], + }); +} + +describe('PieceEngine Integration: Report Phase Blocked Handling', () => { + let tmpDir: string; + + beforeEach(() => { + vi.resetAllMocks(); + applyDefaultMocks(); + tmpDir = createTestTmpDir(); + }); + + afterEach(() => { + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should abort when report phase is blocked and no onUserInput callback', async () => { + const config = buildConfigWithReport(); + const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + + // Phase 1 succeeds for plan, then implement + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan done' }), + makeResponse({ persona: 'implement', content: 'Impl done' }), + ]); + + // plan → implement, then implement's report phase blocks + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + ]); + + // Report phase returns blocked (only implement has outputContracts, so only one call) + const blockedResponse = makeResponse({ persona: 'implement', status: 'blocked', content: 'Need clarification for report' }); + vi.mocked(runReportPhase).mockResolvedValueOnce({ blocked: true, response: blockedResponse }); + + const blockedFn = vi.fn(); + const abortFn = vi.fn(); + engine.on('movement:blocked', blockedFn); + engine.on('piece:abort', abortFn); + + const state = await engine.run(); + + expect(state.status).toBe('aborted'); + expect(blockedFn).toHaveBeenCalledOnce(); + expect(abortFn).toHaveBeenCalledOnce(); + }); + + it('should abort when report phase is blocked and onUserInput returns null', async () => { + const config = buildConfigWithReport(); + const onUserInput = vi.fn().mockResolvedValue(null); + const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, onUserInput }); + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan done' }), + makeResponse({ persona: 'implement', content: 'Impl done' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + ]); + + const blockedResponse = makeResponse({ persona: 'implement', status: 'blocked', content: 'Need info for report' }); + vi.mocked(runReportPhase).mockResolvedValueOnce({ blocked: true, response: blockedResponse }); + + const state = await engine.run(); + + expect(state.status).toBe('aborted'); + expect(onUserInput).toHaveBeenCalledOnce(); + }); + + it('should retry full movement when report phase is blocked and user provides input', async () => { + const config = buildConfigWithReport(); + const onUserInput = vi.fn().mockResolvedValueOnce('User provided report clarification'); + const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, onUserInput }); + + mockRunAgentSequence([ + // First: plan succeeds + makeResponse({ persona: 'plan', content: 'Plan done' }), + // Second: implement Phase 1 succeeds, but Phase 2 will block + makeResponse({ persona: 'implement', content: 'Impl done' }), + // Third: implement retried after user input (Phase 1 re-executes) + makeResponse({ persona: 'implement', content: 'Impl done with clarification' }), + // Fourth: supervise + makeResponse({ persona: 'supervise', content: 'All passed' }), + ]); + + mockDetectMatchedRuleSequence([ + // plan → implement + { index: 0, method: 'phase1_tag' }, + // implement (blocked, no rule eval happens) + // implement retry → supervise + { index: 0, method: 'phase1_tag' }, + // supervise → COMPLETE + { index: 0, method: 'phase1_tag' }, + ]); + + // Report phase: only implement has outputContracts; blocks first, succeeds on retry + const blockedResponse = makeResponse({ persona: 'implement', status: 'blocked', content: 'Need report clarification' }); + vi.mocked(runReportPhase).mockResolvedValueOnce({ blocked: true, response: blockedResponse }); // implement (first attempt) + vi.mocked(runReportPhase).mockResolvedValueOnce(undefined); // implement (retry, succeeds) + + const userInputFn = vi.fn(); + engine.on('movement:user_input', userInputFn); + + const state = await engine.run(); + + expect(state.status).toBe('completed'); + expect(onUserInput).toHaveBeenCalledOnce(); + expect(userInputFn).toHaveBeenCalledOnce(); + expect(state.userInputs).toContain('User provided report clarification'); + }); + + it('should propagate blocked content from report phase to engine response', async () => { + const config = buildConfigWithReport(); + const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan done' }), + makeResponse({ persona: 'implement', content: 'Original impl content' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + ]); + + const blockedContent = 'Blocked: need specific file path for report'; + const blockedResponse = makeResponse({ persona: 'implement', status: 'blocked', content: blockedContent }); + vi.mocked(runReportPhase).mockResolvedValueOnce({ blocked: true, response: blockedResponse }); + + const blockedFn = vi.fn(); + engine.on('movement:blocked', blockedFn); + + const state = await engine.run(); + + expect(state.status).toBe('aborted'); + expect(blockedFn).toHaveBeenCalledWith( + expect.objectContaining({ name: 'implement' }), + expect.objectContaining({ status: 'blocked', content: blockedContent }), + ); + }); +}); diff --git a/src/core/piece/engine/MovementExecutor.ts b/src/core/piece/engine/MovementExecutor.ts index 702560e6..9f0d9942 100644 --- a/src/core/piece/engine/MovementExecutor.ts +++ b/src/core/piece/engine/MovementExecutor.ts @@ -209,8 +209,15 @@ export class MovementExecutor { const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(state, response.content, updatePersonaSession, this.deps.onPhaseStart, this.deps.onPhaseComplete); // Phase 2: report output (resume same session, Write only) + // When report phase returns blocked, propagate to PieceEngine's handleBlocked flow if (step.outputContracts && step.outputContracts.length > 0) { - await runReportPhase(step, movementIteration, phaseCtx); + const reportResult = await runReportPhase(step, movementIteration, phaseCtx); + if (reportResult?.blocked) { + response = { ...response, status: 'blocked', content: reportResult.response.content }; + state.movementOutputs.set(step.name, response); + state.lastOutput = response; + return { response, instruction }; + } } // Phase 3: status judgment (resume session, no tools, output status tag) diff --git a/src/core/piece/index.ts b/src/core/piece/index.ts index a2991e2e..776c8104 100644 --- a/src/core/piece/index.ts +++ b/src/core/piece/index.ts @@ -64,4 +64,4 @@ export { RuleEvaluator, type RuleMatch, type RuleEvaluatorContext, detectMatched export { AggregateEvaluator } from './evaluation/AggregateEvaluator.js'; // Phase runner -export { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from './phase-runner.js'; +export { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase, type ReportPhaseBlockedResult } from './phase-runner.js'; diff --git a/src/core/piece/phase-runner.ts b/src/core/piece/phase-runner.ts index ac784b59..51b7a93a 100644 --- a/src/core/piece/phase-runner.ts +++ b/src/core/piece/phase-runner.ts @@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, parse, resolve, sep } from 'node:path'; -import type { PieceMovement, Language } from '../models/types.js'; +import type { PieceMovement, Language, AgentResponse } from '../models/types.js'; import type { PhaseName } from './types.js'; import { runAgent, type RunAgentOptions } from '../../agents/runner.js'; import { ReportInstructionBuilder } from './instruction/ReportInstructionBuilder.js'; @@ -18,6 +18,9 @@ import { buildSessionKey } from './session-key.js'; const log = createLogger('phase-runner'); +/** Result when Phase 2 encounters a blocked status */ +export type ReportPhaseBlockedResult = { blocked: true; response: AgentResponse }; + export interface PhaseRunnerContext { /** Working directory (agent work dir, may be a clone) */ cwd: string; @@ -107,7 +110,7 @@ export async function runReportPhase( step: PieceMovement, movementIteration: number, ctx: PhaseRunnerContext, -): Promise { +): Promise { const sessionKey = buildSessionKey(step); let currentSessionId = ctx.getSessionId(sessionKey); if (!currentSessionId) { @@ -153,6 +156,11 @@ export async function runReportPhase( throw error; } + if (reportResponse.status === 'blocked') { + ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status); + return { blocked: true, response: reportResponse }; + } + if (reportResponse.status !== 'done') { const errorMsg = reportResponse.error || reportResponse.content || 'Unknown error'; ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status, errorMsg); From dbc296e97ad91b953357b4007137b3d7cdc12588 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:52:52 +0900 Subject: [PATCH 23/45] =?UTF-8?q?Issue=20=E3=80=80=E4=BD=9C=E6=88=90?= =?UTF-8?q?=E6=99=82=E3=81=AB=E3=82=BF=E3=82=B9=E3=82=AF=E3=82=92=E7=A9=8D?= =?UTF-8?q?=E3=82=80=E3=81=8B=E3=82=92=E7=A2=BA=E8=AA=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli-routing-issue-resolve.test.ts | 23 ++++++++++++++++++- src/app/cli/routing.ts | 5 +++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index 5e7fb931..74c3de9b 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -14,6 +14,10 @@ vi.mock('../shared/ui/index.js', () => ({ withProgress: vi.fn(async (_start, _done, operation) => operation()), })); +vi.mock('../shared/prompt/index.js', () => ({ + confirm: vi.fn(() => true), +})); + vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), createLogger: () => ({ @@ -88,6 +92,7 @@ import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '.. import { selectAndExecuteTask, determinePiece, createIssueAndSaveTask } from '../features/tasks/index.js'; import { interactiveMode, selectRecentSession } from '../features/interactive/index.js'; import { loadGlobalConfig } from '../infra/config/index.js'; +import { confirm } from '../shared/prompt/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; import { executeDefaultAction } from '../app/cli/routing.js'; import type { GitHubIssue } from '../infra/github/types.js'; @@ -102,6 +107,7 @@ const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask); const mockInteractiveMode = vi.mocked(interactiveMode); const mockSelectRecentSession = vi.mocked(selectRecentSession); const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); +const mockConfirm = vi.mocked(confirm); const mockIsDirectTask = vi.mocked(isDirectTask); function createMockIssue(number: number): GitHubIssue { @@ -123,6 +129,7 @@ beforeEach(() => { // Default setup mockDeterminePiece.mockResolvedValue('default'); mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' }); + mockConfirm.mockResolvedValue(true); mockIsDirectTask.mockReturnValue(false); mockParseIssueNumbers.mockReturnValue([]); }); @@ -273,9 +280,10 @@ describe('Issue resolution in routing', () => { }); describe('create_issue action', () => { - it('should delegate to createIssueAndSaveTask with cwd, task, and pieceId', async () => { + it('should delegate to createIssueAndSaveTask with cwd, task, and pieceId when confirmed', async () => { // Given mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); + mockConfirm.mockResolvedValue(true); // When await executeDefaultAction(); @@ -288,9 +296,22 @@ describe('Issue resolution in routing', () => { ); }); + it('should skip createIssueAndSaveTask when not confirmed', async () => { + // Given + mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); + mockConfirm.mockResolvedValue(false); + + // When + await executeDefaultAction(); + + // Then: task should not be added when user declines + expect(mockCreateIssueAndSaveTask).not.toHaveBeenCalled(); + }); + it('should not call selectAndExecuteTask when create_issue action is chosen', async () => { // Given mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); + mockConfirm.mockResolvedValue(true); // When await executeDefaultAction(); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index b4854776..7073aebc 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -6,6 +6,7 @@ */ import { info, error, withProgress } from '../../shared/ui/index.js'; +import { confirm } from '../../shared/prompt/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { getLabel } from '../../shared/i18n/index.js'; import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js'; @@ -204,7 +205,9 @@ export async function executeDefaultAction(task?: string): Promise { break; case 'create_issue': - await createIssueAndSaveTask(resolvedCwd, result.task, pieceId); + if (await confirm('Add this issue to tasks?', true)) { + await createIssueAndSaveTask(resolvedCwd, result.task, pieceId); + } break; case 'save_task': From b80f6d0aa0a0136700791aba429f4c1cef4e773b Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 06:35:50 +0900 Subject: [PATCH 24/45] takt: opencode (#222) --- package-lock.json | 7 + package.json | 1 + src/__tests__/apiKeyAuth.test.ts | 64 ++- src/__tests__/globalConfig-defaults.test.ts | 60 +++ src/__tests__/opencode-config.test.ts | 70 ++++ src/__tests__/opencode-provider.test.ts | 67 ++++ src/__tests__/opencode-stream-handler.test.ts | 364 ++++++++++++++++++ src/__tests__/opencode-types.test.ts | 30 ++ src/agents/types.ts | 2 +- src/app/cli/program.ts | 2 +- src/core/models/global-config.ts | 10 +- src/core/models/piece-types.ts | 2 +- src/core/models/schemas.ts | 14 +- src/core/piece/types.ts | 2 +- src/infra/config/global/globalConfig.ts | 26 +- src/infra/config/global/index.ts | 1 + src/infra/config/global/initialization.ts | 7 +- src/infra/config/types.ts | 2 +- src/infra/opencode/OpenCodeStreamHandler.ts | 222 +++++++++++ src/infra/opencode/client.ts | 331 ++++++++++++++++ src/infra/opencode/index.ts | 7 + src/infra/opencode/types.ts | 34 ++ src/infra/providers/index.ts | 4 +- src/infra/providers/opencode.ts | 47 +++ src/infra/providers/types.ts | 4 +- 25 files changed, 1356 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/opencode-config.test.ts create mode 100644 src/__tests__/opencode-provider.test.ts create mode 100644 src/__tests__/opencode-stream-handler.test.ts create mode 100644 src/__tests__/opencode-types.test.ts create mode 100644 src/infra/opencode/OpenCodeStreamHandler.ts create mode 100644 src/infra/opencode/client.ts create mode 100644 src/infra/opencode/index.ts create mode 100644 src/infra/opencode/types.ts create mode 100644 src/infra/providers/opencode.ts diff --git a/package-lock.json b/package-lock.json index c63fda5c..fa308cde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.37", "@openai/codex-sdk": "^0.98.0", + "@opencode-ai/sdk": "^1.1.53", "chalk": "^5.3.0", "commander": "^12.1.0", "update-notifier": "^7.3.1", @@ -936,6 +937,12 @@ "node": ">=18" } }, + "node_modules/@opencode-ai/sdk": { + "version": "1.1.53", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.53.tgz", + "integrity": "sha512-RUIVnPOP1CyyU32FrOOYuE7Ge51lOBuhaFp2NSX98ncApT7ffoNetmwzqrhOiJQgZB1KrbCHLYOCK6AZfacxag==", + "license": "MIT" + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", diff --git a/package.json b/package.json index a9b37634..f205080b 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.37", "@openai/codex-sdk": "^0.98.0", + "@opencode-ai/sdk": "^1.1.53", "chalk": "^5.3.0", "commander": "^12.1.0", "update-notifier": "^7.3.1", diff --git a/src/__tests__/apiKeyAuth.test.ts b/src/__tests__/apiKeyAuth.test.ts index dc418e2b..f21f5db9 100644 --- a/src/__tests__/apiKeyAuth.test.ts +++ b/src/__tests__/apiKeyAuth.test.ts @@ -32,7 +32,7 @@ vi.mock('../infra/config/paths.js', async (importOriginal) => { }); // Import after mocking -const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js'); +const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, resolveOpencodeApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js'); describe('GlobalConfigSchema API key fields', () => { it('should accept config without API keys', () => { @@ -280,3 +280,65 @@ describe('resolveOpenaiApiKey', () => { expect(key).toBeUndefined(); }); }); + +describe('resolveOpencodeApiKey', () => { + const originalEnv = process.env['TAKT_OPENCODE_API_KEY']; + + beforeEach(() => { + invalidateGlobalConfigCache(); + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['TAKT_OPENCODE_API_KEY'] = originalEnv; + } else { + delete process.env['TAKT_OPENCODE_API_KEY']; + } + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return env var when set', () => { + process.env['TAKT_OPENCODE_API_KEY'] = 'sk-opencode-from-env'; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + 'opencode_api_key: sk-opencode-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpencodeApiKey(); + expect(key).toBe('sk-opencode-from-env'); + }); + + it('should fall back to config when env var is not set', () => { + delete process.env['TAKT_OPENCODE_API_KEY']; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + 'opencode_api_key: sk-opencode-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpencodeApiKey(); + expect(key).toBe('sk-opencode-from-yaml'); + }); + + it('should return undefined when neither env var nor config is set', () => { + delete process.env['TAKT_OPENCODE_API_KEY']; + const yaml = [ + 'language: en', + 'default_piece: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpencodeApiKey(); + expect(key).toBeUndefined(); + }); +}); diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index ec4ec512..511d54e3 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -518,5 +518,65 @@ describe('loadGlobalConfig', () => { expect(() => loadGlobalConfig()).not.toThrow(); }); + + it('should throw when provider is opencode but model is a Claude alias (opus)', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'provider: opencode\nmodel: opus\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).toThrow(/model 'opus' is a Claude model alias but provider is 'opencode'/); + }); + + it('should throw when provider is opencode but model is sonnet', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'provider: opencode\nmodel: sonnet\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).toThrow(/model 'sonnet' is a Claude model alias but provider is 'opencode'/); + }); + + it('should throw when provider is opencode but model is haiku', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'provider: opencode\nmodel: haiku\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).toThrow(/model 'haiku' is a Claude model alias but provider is 'opencode'/); + }); + + it('should not throw when provider is opencode with a compatible model', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'provider: opencode\nmodel: gpt-4o\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).not.toThrow(); + }); + + it('should not throw when provider is opencode without a model', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'provider: opencode\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).not.toThrow(); + }); }); }); diff --git a/src/__tests__/opencode-config.test.ts b/src/__tests__/opencode-config.test.ts new file mode 100644 index 00000000..6e181f64 --- /dev/null +++ b/src/__tests__/opencode-config.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for OpenCode integration in schemas and global config + */ + +import { describe, it, expect } from 'vitest'; +import { + GlobalConfigSchema, + ProjectConfigSchema, + CustomAgentConfigSchema, + PieceMovementRawSchema, + ParallelSubMovementRawSchema, +} from '../core/models/index.js'; + +describe('Schemas accept opencode provider', () => { + it('should accept opencode in GlobalConfigSchema provider field', () => { + const result = GlobalConfigSchema.parse({ provider: 'opencode' }); + expect(result.provider).toBe('opencode'); + }); + + it('should accept opencode in GlobalConfigSchema persona_providers field', () => { + const result = GlobalConfigSchema.parse({ + persona_providers: { coder: 'opencode' }, + }); + expect(result.persona_providers).toEqual({ coder: 'opencode' }); + }); + + it('should accept opencode_api_key in GlobalConfigSchema', () => { + const result = GlobalConfigSchema.parse({ + opencode_api_key: 'test-key-123', + }); + expect(result.opencode_api_key).toBe('test-key-123'); + }); + + it('should accept opencode in ProjectConfigSchema', () => { + const result = ProjectConfigSchema.parse({ provider: 'opencode' }); + expect(result.provider).toBe('opencode'); + }); + + it('should accept opencode in CustomAgentConfigSchema', () => { + const result = CustomAgentConfigSchema.parse({ + name: 'test', + prompt: 'You are a test agent', + provider: 'opencode', + }); + expect(result.provider).toBe('opencode'); + }); + + it('should accept opencode in PieceMovementRawSchema', () => { + const result = PieceMovementRawSchema.parse({ + name: 'test-movement', + provider: 'opencode', + }); + expect(result.provider).toBe('opencode'); + }); + + it('should accept opencode in ParallelSubMovementRawSchema', () => { + const result = ParallelSubMovementRawSchema.parse({ + name: 'sub-1', + provider: 'opencode', + }); + expect(result.provider).toBe('opencode'); + }); + + it('should still accept existing providers (claude, codex, mock)', () => { + for (const provider of ['claude', 'codex', 'mock']) { + const result = GlobalConfigSchema.parse({ provider }); + expect(result.provider).toBe(provider); + } + }); +}); diff --git a/src/__tests__/opencode-provider.test.ts b/src/__tests__/opencode-provider.test.ts new file mode 100644 index 00000000..d7cbe745 --- /dev/null +++ b/src/__tests__/opencode-provider.test.ts @@ -0,0 +1,67 @@ +/** + * Tests for OpenCode provider implementation + */ + +import { describe, it, expect } from 'vitest'; +import { OpenCodeProvider } from '../infra/providers/opencode.js'; +import { ProviderRegistry } from '../infra/providers/index.js'; + +describe('OpenCodeProvider', () => { + it('should throw when claudeAgent is specified', () => { + const provider = new OpenCodeProvider(); + + expect(() => provider.setup({ + name: 'test', + claudeAgent: 'some-agent', + })).toThrow('Claude Code agent calls are not supported by the OpenCode provider'); + }); + + it('should throw when claudeSkill is specified', () => { + const provider = new OpenCodeProvider(); + + expect(() => provider.setup({ + name: 'test', + claudeSkill: 'some-skill', + })).toThrow('Claude Code skill calls are not supported by the OpenCode provider'); + }); + + it('should return a ProviderAgent when setup with name only', () => { + const provider = new OpenCodeProvider(); + const agent = provider.setup({ name: 'test' }); + + expect(agent).toBeDefined(); + expect(typeof agent.call).toBe('function'); + }); + + it('should return a ProviderAgent when setup with systemPrompt', () => { + const provider = new OpenCodeProvider(); + const agent = provider.setup({ + name: 'test', + systemPrompt: 'You are a helpful assistant.', + }); + + expect(agent).toBeDefined(); + expect(typeof agent.call).toBe('function'); + }); +}); + +describe('ProviderRegistry with OpenCode', () => { + it('should return OpenCode provider from registry', () => { + ProviderRegistry.resetInstance(); + const registry = ProviderRegistry.getInstance(); + const provider = registry.get('opencode'); + + expect(provider).toBeDefined(); + expect(provider).toBeInstanceOf(OpenCodeProvider); + }); + + it('should setup an agent through the registry', () => { + ProviderRegistry.resetInstance(); + const registry = ProviderRegistry.getInstance(); + const provider = registry.get('opencode'); + const agent = provider.setup({ name: 'test' }); + + expect(agent).toBeDefined(); + expect(typeof agent.call).toBe('function'); + }); +}); diff --git a/src/__tests__/opencode-stream-handler.test.ts b/src/__tests__/opencode-stream-handler.test.ts new file mode 100644 index 00000000..b4dbe4db --- /dev/null +++ b/src/__tests__/opencode-stream-handler.test.ts @@ -0,0 +1,364 @@ +/** + * Tests for OpenCode stream event handling + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + createStreamTrackingState, + emitInit, + emitText, + emitThinking, + emitToolUse, + emitToolResult, + emitResult, + handlePartUpdated, + type OpenCodeTextPart, + type OpenCodeReasoningPart, + type OpenCodeToolPart, +} from '../infra/opencode/OpenCodeStreamHandler.js'; +import type { StreamCallback } from '../core/piece/types.js'; + +describe('createStreamTrackingState', () => { + it('should create fresh state with empty collections', () => { + const state = createStreamTrackingState(); + + expect(state.textOffsets.size).toBe(0); + expect(state.thinkingOffsets.size).toBe(0); + expect(state.startedTools.size).toBe(0); + }); +}); + +describe('emitInit', () => { + it('should emit init event with model and sessionId', () => { + const onStream = vi.fn(); + + emitInit(onStream, 'gpt-4', 'session-123'); + + expect(onStream).toHaveBeenCalledOnce(); + expect(onStream).toHaveBeenCalledWith({ + type: 'init', + data: { model: 'gpt-4', sessionId: 'session-123' }, + }); + }); + + it('should use default model name when model is undefined', () => { + const onStream = vi.fn(); + + emitInit(onStream, undefined, 'session-abc'); + + expect(onStream).toHaveBeenCalledWith({ + type: 'init', + data: { model: 'opencode', sessionId: 'session-abc' }, + }); + }); + + it('should not emit when onStream is undefined', () => { + emitInit(undefined, 'gpt-4', 'session-123'); + }); +}); + +describe('emitText', () => { + it('should emit text event', () => { + const onStream = vi.fn(); + + emitText(onStream, 'Hello world'); + + expect(onStream).toHaveBeenCalledWith({ + type: 'text', + data: { text: 'Hello world' }, + }); + }); + + it('should not emit when text is empty', () => { + const onStream = vi.fn(); + + emitText(onStream, ''); + + expect(onStream).not.toHaveBeenCalled(); + }); + + it('should not emit when onStream is undefined', () => { + emitText(undefined, 'Hello'); + }); +}); + +describe('emitThinking', () => { + it('should emit thinking event', () => { + const onStream = vi.fn(); + + emitThinking(onStream, 'Reasoning...'); + + expect(onStream).toHaveBeenCalledWith({ + type: 'thinking', + data: { thinking: 'Reasoning...' }, + }); + }); + + it('should not emit when thinking is empty', () => { + const onStream = vi.fn(); + + emitThinking(onStream, ''); + + expect(onStream).not.toHaveBeenCalled(); + }); +}); + +describe('emitToolUse', () => { + it('should emit tool_use event', () => { + const onStream = vi.fn(); + + emitToolUse(onStream, 'Bash', { command: 'ls' }, 'tool-1'); + + expect(onStream).toHaveBeenCalledWith({ + type: 'tool_use', + data: { tool: 'Bash', input: { command: 'ls' }, id: 'tool-1' }, + }); + }); +}); + +describe('emitToolResult', () => { + it('should emit tool_result event for success', () => { + const onStream = vi.fn(); + + emitToolResult(onStream, 'file.txt', false); + + expect(onStream).toHaveBeenCalledWith({ + type: 'tool_result', + data: { content: 'file.txt', isError: false }, + }); + }); + + it('should emit tool_result event for error', () => { + const onStream = vi.fn(); + + emitToolResult(onStream, 'command not found', true); + + expect(onStream).toHaveBeenCalledWith({ + type: 'tool_result', + data: { content: 'command not found', isError: true }, + }); + }); +}); + +describe('emitResult', () => { + it('should emit result event for success', () => { + const onStream = vi.fn(); + + emitResult(onStream, true, 'Completed', 'session-1'); + + expect(onStream).toHaveBeenCalledWith({ + type: 'result', + data: { + result: 'Completed', + sessionId: 'session-1', + success: true, + error: undefined, + }, + }); + }); + + it('should emit result event for failure', () => { + const onStream = vi.fn(); + + emitResult(onStream, false, 'Network error', 'session-1'); + + expect(onStream).toHaveBeenCalledWith({ + type: 'result', + data: { + result: 'Network error', + sessionId: 'session-1', + success: false, + error: 'Network error', + }, + }); + }); +}); + +describe('handlePartUpdated', () => { + it('should handle text part with delta', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello world' }; + + handlePartUpdated(part, 'Hello', onStream, state); + + expect(onStream).toHaveBeenCalledWith({ + type: 'text', + data: { text: 'Hello' }, + }); + }); + + it('should handle text part without delta using offset tracking', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + const part1: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello' }; + handlePartUpdated(part1, undefined, onStream, state); + + expect(onStream).toHaveBeenCalledWith({ + type: 'text', + data: { text: 'Hello' }, + }); + + onStream.mockClear(); + + const part2: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello world' }; + handlePartUpdated(part2, undefined, onStream, state); + + expect(onStream).toHaveBeenCalledWith({ + type: 'text', + data: { text: ' world' }, + }); + }); + + it('should not emit duplicate text when offset has not changed', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello' }; + handlePartUpdated(part, undefined, onStream, state); + onStream.mockClear(); + + handlePartUpdated(part, undefined, onStream, state); + + expect(onStream).not.toHaveBeenCalled(); + }); + + it('should handle reasoning part with delta', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeReasoningPart = { id: 'r1', type: 'reasoning', text: 'Thinking...' }; + + handlePartUpdated(part, 'Thinking', onStream, state); + + expect(onStream).toHaveBeenCalledWith({ + type: 'thinking', + data: { thinking: 'Thinking' }, + }); + }); + + it('should handle reasoning part without delta using offset tracking', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeReasoningPart = { id: 'r1', type: 'reasoning', text: 'Step 1' }; + handlePartUpdated(part, undefined, onStream, state); + + expect(onStream).toHaveBeenCalledWith({ + type: 'thinking', + data: { thinking: 'Step 1' }, + }); + }); + + it('should handle tool part in running state', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeToolPart = { + id: 't1', + type: 'tool', + callID: 'call-1', + tool: 'Bash', + state: { status: 'running', input: { command: 'ls' } }, + }; + + handlePartUpdated(part, undefined, onStream, state); + + expect(onStream).toHaveBeenCalledWith({ + type: 'tool_use', + data: { tool: 'Bash', input: { command: 'ls' }, id: 'call-1' }, + }); + expect(state.startedTools.has('call-1')).toBe(true); + }); + + it('should handle tool part in completed state', () => { + const onStream: StreamCallback = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeToolPart = { + id: 't1', + type: 'tool', + callID: 'call-1', + tool: 'Bash', + state: { + status: 'completed', + input: { command: 'ls' }, + output: 'file.txt', + title: 'List files', + }, + }; + + handlePartUpdated(part, undefined, onStream, state); + + expect(onStream).toHaveBeenCalledTimes(2); + expect(onStream).toHaveBeenNthCalledWith(1, { + type: 'tool_use', + data: { tool: 'Bash', input: { command: 'ls' }, id: 'call-1' }, + }); + expect(onStream).toHaveBeenNthCalledWith(2, { + type: 'tool_result', + data: { content: 'file.txt', isError: false }, + }); + }); + + it('should handle tool part in error state', () => { + const onStream: StreamCallback = vi.fn(); + const state = createStreamTrackingState(); + + const part: OpenCodeToolPart = { + id: 't1', + type: 'tool', + callID: 'call-1', + tool: 'Bash', + state: { + status: 'error', + input: { command: 'rm -rf /' }, + error: 'Permission denied', + }, + }; + + handlePartUpdated(part, undefined, onStream, state); + + expect(onStream).toHaveBeenCalledTimes(2); + expect(onStream).toHaveBeenNthCalledWith(2, { + type: 'tool_result', + data: { content: 'Permission denied', isError: true }, + }); + }); + + it('should not emit duplicate tool_use for already-started tool', () => { + const onStream: StreamCallback = vi.fn(); + const state = createStreamTrackingState(); + state.startedTools.add('call-1'); + + const part: OpenCodeToolPart = { + id: 't1', + type: 'tool', + callID: 'call-1', + tool: 'Bash', + state: { status: 'running', input: { command: 'ls' } }, + }; + + handlePartUpdated(part, undefined, onStream, state); + + expect(onStream).not.toHaveBeenCalled(); + }); + + it('should ignore unknown part types', () => { + const onStream = vi.fn(); + const state = createStreamTrackingState(); + + handlePartUpdated({ id: 'x1', type: 'unknown' }, undefined, onStream, state); + + expect(onStream).not.toHaveBeenCalled(); + }); + + it('should not emit when onStream is undefined', () => { + const state = createStreamTrackingState(); + + const part: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello' }; + handlePartUpdated(part, 'Hello', undefined, state); + }); +}); diff --git a/src/__tests__/opencode-types.test.ts b/src/__tests__/opencode-types.test.ts new file mode 100644 index 00000000..6251b8d0 --- /dev/null +++ b/src/__tests__/opencode-types.test.ts @@ -0,0 +1,30 @@ +/** + * Tests for OpenCode type definitions and permission mapping + */ + +import { describe, it, expect } from 'vitest'; +import { mapToOpenCodePermissionReply } from '../infra/opencode/types.js'; +import type { PermissionMode } from '../core/models/index.js'; + +describe('mapToOpenCodePermissionReply', () => { + it('should map readonly to reject', () => { + expect(mapToOpenCodePermissionReply('readonly')).toBe('reject'); + }); + + it('should map edit to once', () => { + expect(mapToOpenCodePermissionReply('edit')).toBe('once'); + }); + + it('should map full to always', () => { + expect(mapToOpenCodePermissionReply('full')).toBe('always'); + }); + + it('should handle all PermissionMode values', () => { + const modes: PermissionMode[] = ['readonly', 'edit', 'full']; + const expectedReplies = ['reject', 'once', 'always']; + + modes.forEach((mode, index) => { + expect(mapToOpenCodePermissionReply(mode)).toBe(expectedReplies[index]); + }); + }); +}); diff --git a/src/agents/types.ts b/src/agents/types.ts index cdfd2a68..d27882a7 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -13,7 +13,7 @@ export interface RunAgentOptions { abortSignal?: AbortSignal; sessionId?: string; model?: string; - provider?: 'claude' | 'codex' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'mock'; /** Resolved path to persona prompt file */ personaPath?: string; /** Allowed tools for this agent run */ diff --git a/src/app/cli/program.ts b/src/app/cli/program.ts index 75b88ae7..f6057327 100644 --- a/src/app/cli/program.ts +++ b/src/app/cli/program.ts @@ -46,7 +46,7 @@ program .option('-b, --branch ', 'Branch name (auto-generated if omitted)') .option('--auto-pr', 'Create PR after successful execution') .option('--repo ', 'Repository (defaults to current)') - .option('--provider ', 'Override agent provider (claude|codex|mock)') + .option('--provider ', 'Override agent provider (claude|codex|opencode|mock)') .option('--model ', 'Override agent model') .option('-t, --task ', 'Task content (as alternative to GitHub issue)') .option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation') diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 4f7e1680..4974cd51 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -10,7 +10,7 @@ export interface CustomAgentConfig { allowedTools?: string[]; claudeAgent?: string; claudeSkill?: string; - provider?: 'claude' | 'codex' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'mock'; model?: string; } @@ -52,7 +52,7 @@ export interface GlobalConfig { language: Language; defaultPiece: string; logLevel: 'debug' | 'info' | 'warn' | 'error'; - provider?: 'claude' | 'codex' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'mock'; model?: string; debug?: DebugConfig; /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ @@ -67,6 +67,8 @@ export interface GlobalConfig { anthropicApiKey?: string; /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ openaiApiKey?: string; + /** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */ + opencodeApiKey?: string; /** Pipeline execution settings */ pipeline?: PipelineConfig; /** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */ @@ -76,7 +78,7 @@ export interface GlobalConfig { /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ pieceCategoriesFile?: string; /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ - personaProviders?: Record; + personaProviders?: Record; /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branchNameStrategy?: 'romaji' | 'ai'; /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ @@ -97,5 +99,5 @@ export interface GlobalConfig { export interface ProjectConfig { piece?: string; agents?: CustomAgentConfig[]; - provider?: 'claude' | 'codex' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'mock'; } diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index 5ba4bcea..7c029cc5 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -97,7 +97,7 @@ export interface PieceMovement { /** Resolved absolute path to persona prompt file (set by loader) */ personaPath?: string; /** Provider override for this movement */ - provider?: 'claude' | 'codex' | 'mock'; + provider?: 'claude' | 'codex' | 'opencode' | 'mock'; /** Model override for this movement */ model?: string; /** Permission mode for tool execution in this movement */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 6b1d18ca..64a60d7a 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -183,7 +183,7 @@ export const ParallelSubMovementRawSchema = z.object({ knowledge: z.union([z.string(), z.array(z.string())]).optional(), allowed_tools: z.array(z.string()).optional(), mcp_servers: McpServersSchema, - provider: z.enum(['claude', 'codex', 'mock']).optional(), + provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), model: z.string().optional(), permission_mode: PermissionModeSchema.optional(), edit: z.boolean().optional(), @@ -213,7 +213,7 @@ export const PieceMovementRawSchema = z.object({ knowledge: z.union([z.string(), z.array(z.string())]).optional(), allowed_tools: z.array(z.string()).optional(), mcp_servers: McpServersSchema, - provider: z.enum(['claude', 'codex', 'mock']).optional(), + provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), model: z.string().optional(), /** Permission mode for tool execution in this movement */ permission_mode: PermissionModeSchema.optional(), @@ -296,7 +296,7 @@ export const CustomAgentConfigSchema = z.object({ allowed_tools: z.array(z.string()).optional(), claude_agent: z.string().optional(), claude_skill: z.string().optional(), - provider: z.enum(['claude', 'codex', 'mock']).optional(), + provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), model: z.string().optional(), }).refine( (data) => data.prompt_file || data.prompt || data.claude_agent || data.claude_skill, @@ -338,7 +338,7 @@ export const GlobalConfigSchema = z.object({ language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), default_piece: z.string().optional().default('default'), log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'), - provider: z.enum(['claude', 'codex', 'mock']).optional().default('claude'), + provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'), model: z.string().optional(), debug: DebugConfigSchema.optional(), /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ @@ -353,6 +353,8 @@ export const GlobalConfigSchema = z.object({ anthropic_api_key: z.string().optional(), /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ openai_api_key: z.string().optional(), + /** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */ + opencode_api_key: z.string().optional(), /** Pipeline execution settings */ pipeline: PipelineConfigSchema.optional(), /** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */ @@ -362,7 +364,7 @@ export const GlobalConfigSchema = z.object({ /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ piece_categories_file: z.string().optional(), /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ - persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'mock'])).optional(), + persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(), /** 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) */ @@ -389,5 +391,5 @@ export const GlobalConfigSchema = z.object({ export const ProjectConfigSchema = z.object({ piece: z.string().optional(), agents: z.array(CustomAgentConfigSchema).optional(), - provider: z.enum(['claude', 'codex', 'mock']).optional(), + provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(), }); diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index b4482eb3..73b940d5 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -8,7 +8,7 @@ import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js'; -export type ProviderType = 'claude' | 'codex' | 'mock'; +export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; export interface StreamInitEventData { model: string; diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 169853b8..1298b9ad 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -19,10 +19,10 @@ const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']); function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void { if (!provider || !model) return; - if (provider === 'codex' && CLAUDE_MODEL_ALIASES.has(model)) { + if ((provider === 'codex' || provider === 'opencode') && CLAUDE_MODEL_ALIASES.has(model)) { throw new Error( `Configuration error: model '${model}' is a Claude model alias but provider is '${provider}'. ` + - `Either change the provider to 'claude' or specify a Codex-compatible model.` + `Either change the provider to 'claude' or specify a ${provider}-compatible model.` ); } } @@ -98,6 +98,7 @@ export class GlobalConfigManager { enableBuiltinPieces: parsed.enable_builtin_pieces, anthropicApiKey: parsed.anthropic_api_key, openaiApiKey: parsed.openai_api_key, + opencodeApiKey: parsed.opencode_api_key, pipeline: parsed.pipeline ? { defaultBranchPrefix: parsed.pipeline.default_branch_prefix, commitMessageTemplate: parsed.pipeline.commit_message_template, @@ -162,6 +163,9 @@ export class GlobalConfigManager { if (config.openaiApiKey) { raw.openai_api_key = config.openaiApiKey; } + if (config.opencodeApiKey) { + raw.opencode_api_key = config.opencodeApiKey; + } if (config.pipeline) { const pipelineRaw: Record = {}; if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix; @@ -272,7 +276,7 @@ export function setLanguage(language: Language): void { saveGlobalConfig(config); } -export function setProvider(provider: 'claude' | 'codex'): void { +export function setProvider(provider: 'claude' | 'codex' | 'opencode'): void { const config = loadGlobalConfig(); config.provider = provider; saveGlobalConfig(config); @@ -310,6 +314,22 @@ export function resolveOpenaiApiKey(): string | undefined { } } +/** + * Resolve the OpenCode API key. + * Priority: TAKT_OPENCODE_API_KEY env var > config.yaml > undefined + */ +export function resolveOpencodeApiKey(): string | undefined { + const envKey = process.env['TAKT_OPENCODE_API_KEY']; + if (envKey) return envKey; + + try { + const config = loadGlobalConfig(); + return config.opencodeApiKey; + } catch { + return undefined; + } +} + /** Load project-level debug configuration (from .takt/config.yaml) */ export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined { const configPath = getProjectConfigPath(projectDir); diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index 30b0b937..7f442b09 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -14,6 +14,7 @@ export { setProvider, resolveAnthropicApiKey, resolveOpenaiApiKey, + resolveOpencodeApiKey, loadProjectDebugConfig, getEffectiveDebugConfig, } from './globalConfig.js'; diff --git a/src/infra/config/global/initialization.ts b/src/infra/config/global/initialization.ts index 1caf1c58..ec5656a9 100644 --- a/src/infra/config/global/initialization.ts +++ b/src/infra/config/global/initialization.ts @@ -56,14 +56,15 @@ export async function promptLanguageSelection(): Promise { * Prompt user to select provider for resources. * Exits process if cancelled (initial setup is required). */ -export async function promptProviderSelection(): Promise<'claude' | 'codex'> { - const options: { label: string; value: 'claude' | 'codex' }[] = [ +export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode'> { + const options: { label: string; value: 'claude' | 'codex' | 'opencode' }[] = [ { label: 'Claude Code', value: 'claude' }, { label: 'Codex', value: 'codex' }, + { label: 'OpenCode', value: 'opencode' }, ]; const result = await selectOptionWithDefault( - 'Select provider (Claude Code or Codex) / プロバイダーを選択してください:', + 'Select provider / プロバイダーを選択してください:', options, 'claude' ); diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index 4d227f98..f29d5374 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -17,7 +17,7 @@ export interface ProjectLocalConfig { /** Current piece name */ piece?: string; /** Provider selection for agent runtime */ - provider?: 'claude' | 'codex'; + provider?: 'claude' | 'codex' | 'opencode'; /** Permission mode setting */ permissionMode?: PermissionMode; /** Verbose output mode */ diff --git a/src/infra/opencode/OpenCodeStreamHandler.ts b/src/infra/opencode/OpenCodeStreamHandler.ts new file mode 100644 index 00000000..f6cb7b4b --- /dev/null +++ b/src/infra/opencode/OpenCodeStreamHandler.ts @@ -0,0 +1,222 @@ +/** + * OpenCode stream event handling. + * + * Converts OpenCode SDK SSE events into the unified StreamCallback format + * used throughout the takt codebase. + */ + +import type { StreamCallback } from '../claude/index.js'; + +/** Subset of OpenCode Part types relevant for stream handling */ +export interface OpenCodeTextPart { + id: string; + type: 'text'; + text: string; +} + +export interface OpenCodeReasoningPart { + id: string; + type: 'reasoning'; + text: string; +} + +export interface OpenCodeToolPart { + id: string; + type: 'tool'; + callID: string; + tool: string; + state: OpenCodeToolState; +} + +export type OpenCodeToolState = + | { status: 'pending'; input: Record } + | { status: 'running'; input: Record; title?: string } + | { status: 'completed'; input: Record; output: string; title: string } + | { status: 'error'; input: Record; error: string }; + +export type OpenCodePart = OpenCodeTextPart | OpenCodeReasoningPart | OpenCodeToolPart | { id: string; type: string }; + +/** OpenCode SSE event types relevant for stream handling */ +export interface OpenCodeMessagePartUpdatedEvent { + type: 'message.part.updated'; + properties: { part: OpenCodePart; delta?: string }; +} + +export interface OpenCodeSessionIdleEvent { + type: 'session.idle'; + properties: { sessionID: string }; +} + +export interface OpenCodeSessionErrorEvent { + type: 'session.error'; + properties: { + sessionID?: string; + error?: { name: string; data: { message: string } }; + }; +} + +export interface OpenCodePermissionAskedEvent { + type: 'permission.asked'; + properties: { + id: string; + sessionID: string; + permission: string; + patterns: string[]; + metadata: Record; + always: string[]; + }; +} + +export type OpenCodeStreamEvent = + | OpenCodeMessagePartUpdatedEvent + | OpenCodeSessionIdleEvent + | OpenCodeSessionErrorEvent + | OpenCodePermissionAskedEvent + | { type: string; properties: Record }; + +/** Tracking state for stream offsets during a single OpenCode session */ +export interface StreamTrackingState { + textOffsets: Map; + thinkingOffsets: Map; + startedTools: Set; +} + +export function createStreamTrackingState(): StreamTrackingState { + return { + textOffsets: new Map(), + thinkingOffsets: new Map(), + startedTools: new Set(), + }; +} + +// ---- Stream emission helpers ---- + +export function emitInit( + onStream: StreamCallback | undefined, + model: string | undefined, + sessionId: string, +): void { + if (!onStream) return; + onStream({ + type: 'init', + data: { + model: model || 'opencode', + sessionId, + }, + }); +} + +export function emitText(onStream: StreamCallback | undefined, text: string): void { + if (!onStream || !text) return; + onStream({ type: 'text', data: { text } }); +} + +export function emitThinking(onStream: StreamCallback | undefined, thinking: string): void { + if (!onStream || !thinking) return; + onStream({ type: 'thinking', data: { thinking } }); +} + +export function emitToolUse( + onStream: StreamCallback | undefined, + tool: string, + input: Record, + id: string, +): void { + if (!onStream) return; + onStream({ type: 'tool_use', data: { tool, input, id } }); +} + +export function emitToolResult( + onStream: StreamCallback | undefined, + content: string, + isError: boolean, +): void { + if (!onStream) return; + onStream({ type: 'tool_result', data: { content, isError } }); +} + +export function emitResult( + onStream: StreamCallback | undefined, + success: boolean, + result: string, + sessionId: string, +): void { + if (!onStream) return; + onStream({ + type: 'result', + data: { + result, + sessionId, + success, + error: success ? undefined : result || undefined, + }, + }); +} + +/** Process a message.part.updated event and emit appropriate stream events */ +export function handlePartUpdated( + part: OpenCodePart, + delta: string | undefined, + onStream: StreamCallback | undefined, + state: StreamTrackingState, +): void { + if (!onStream) return; + + switch (part.type) { + case 'text': { + const textPart = part as OpenCodeTextPart; + if (delta) { + emitText(onStream, delta); + } else { + const prev = state.textOffsets.get(textPart.id) ?? 0; + if (textPart.text.length > prev) { + emitText(onStream, textPart.text.slice(prev)); + state.textOffsets.set(textPart.id, textPart.text.length); + } + } + break; + } + case 'reasoning': { + const reasoningPart = part as OpenCodeReasoningPart; + if (delta) { + emitThinking(onStream, delta); + } else { + const prev = state.thinkingOffsets.get(reasoningPart.id) ?? 0; + if (reasoningPart.text.length > prev) { + emitThinking(onStream, reasoningPart.text.slice(prev)); + state.thinkingOffsets.set(reasoningPart.id, reasoningPart.text.length); + } + } + break; + } + case 'tool': { + const toolPart = part as OpenCodeToolPart; + handleToolPartUpdated(toolPart, onStream, state); + break; + } + default: + break; + } +} + +function handleToolPartUpdated( + toolPart: OpenCodeToolPart, + onStream: StreamCallback, + state: StreamTrackingState, +): void { + const toolId = toolPart.callID || toolPart.id; + + if (!state.startedTools.has(toolId)) { + emitToolUse(onStream, toolPart.tool, toolPart.state.input, toolId); + state.startedTools.add(toolId); + } + + switch (toolPart.state.status) { + case 'completed': + emitToolResult(onStream, toolPart.state.output, false); + break; + case 'error': + emitToolResult(onStream, toolPart.state.error, true); + break; + } +} diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts new file mode 100644 index 00000000..42f29187 --- /dev/null +++ b/src/infra/opencode/client.ts @@ -0,0 +1,331 @@ +/** + * OpenCode SDK integration for agent interactions + * + * Uses @opencode-ai/sdk/v2 for native TypeScript integration. + * Follows the same patterns as the Codex client. + */ + +import { createOpencode } from '@opencode-ai/sdk/v2'; +import type { AgentResponse } from '../../core/models/index.js'; +import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; +import { mapToOpenCodePermissionReply, type OpenCodeCallOptions } from './types.js'; +import { + type OpenCodeStreamEvent, + type OpenCodePart, + type OpenCodeTextPart, + createStreamTrackingState, + emitInit, + emitResult, + handlePartUpdated, +} from './OpenCodeStreamHandler.js'; + +export type { OpenCodeCallOptions } from './types.js'; + +const log = createLogger('opencode-sdk'); +const OPENCODE_STREAM_IDLE_TIMEOUT_MS = 10 * 60 * 1000; +const OPENCODE_STREAM_ABORTED_MESSAGE = 'OpenCode execution aborted'; +const OPENCODE_RETRY_MAX_ATTEMPTS = 3; +const OPENCODE_RETRY_BASE_DELAY_MS = 250; +const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ + 'stream disconnected before completion', + 'transport error', + 'network error', + 'error decoding response body', + 'econnreset', + 'etimedout', + 'eai_again', + 'fetch failed', +]; + +/** + * Client for OpenCode SDK agent interactions. + * + * Handles session management, streaming event conversion, + * permission auto-reply, and response processing. + */ +export class OpenCodeClient { + private isRetriableError(message: string, aborted: boolean, abortCause?: 'timeout' | 'external'): boolean { + if (aborted || abortCause) { + return false; + } + + const lower = message.toLowerCase(); + return OPENCODE_RETRYABLE_ERROR_PATTERNS.some((pattern) => lower.includes(pattern)); + } + + private async waitForRetryDelay(attempt: number, signal?: AbortSignal): Promise { + const delayMs = OPENCODE_RETRY_BASE_DELAY_MS * (2 ** Math.max(0, attempt - 1)); + await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + if (signal) { + signal.removeEventListener('abort', onAbort); + } + resolve(); + }, delayMs); + + const onAbort = (): void => { + clearTimeout(timeoutId); + if (signal) { + signal.removeEventListener('abort', onAbort); + } + reject(new Error(OPENCODE_STREAM_ABORTED_MESSAGE)); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + }); + } + + /** Call OpenCode with an agent prompt */ + async call( + agentType: string, + prompt: string, + options: OpenCodeCallOptions, + ): Promise { + const fullPrompt = options.systemPrompt + ? `${options.systemPrompt}\n\n${prompt}` + : prompt; + + for (let attempt = 1; attempt <= OPENCODE_RETRY_MAX_ATTEMPTS; attempt++) { + let idleTimeoutId: ReturnType | undefined; + const streamAbortController = new AbortController(); + const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; + let abortCause: 'timeout' | 'external' | undefined; + let serverClose: (() => void) | undefined; + + const resetIdleTimeout = (): void => { + if (idleTimeoutId !== undefined) { + clearTimeout(idleTimeoutId); + } + idleTimeoutId = setTimeout(() => { + abortCause = 'timeout'; + streamAbortController.abort(); + }, OPENCODE_STREAM_IDLE_TIMEOUT_MS); + }; + + const onExternalAbort = (): void => { + abortCause = 'external'; + streamAbortController.abort(); + }; + + if (options.abortSignal) { + if (options.abortSignal.aborted) { + streamAbortController.abort(); + } else { + options.abortSignal.addEventListener('abort', onExternalAbort, { once: true }); + } + } + + try { + log.debug('Starting OpenCode session', { + agentType, + model: options.model, + hasSystemPrompt: !!options.systemPrompt, + attempt, + }); + + const { client, server } = await createOpencode({ + signal: streamAbortController.signal, + ...(options.opencodeApiKey + ? { config: { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } } + : {}), + }); + serverClose = server.close; + + const sessionResult = options.sessionId + ? { data: { id: options.sessionId } } + : await client.session.create({ directory: options.cwd }); + + const sessionId = sessionResult.data?.id; + if (!sessionId) { + throw new Error('Failed to create OpenCode session'); + } + + const { stream } = await client.event.subscribe({ directory: options.cwd }); + resetIdleTimeout(); + + await client.session.promptAsync({ + sessionID: sessionId, + directory: options.cwd, + ...(options.model ? { model: { providerID: 'opencode', modelID: options.model } } : {}), + parts: [{ type: 'text' as const, text: fullPrompt }], + }); + + emitInit(options.onStream, options.model, sessionId); + + let content = ''; + let success = true; + let failureMessage = ''; + const state = createStreamTrackingState(); + const textContentParts = new Map(); + + for await (const event of stream) { + if (streamAbortController.signal.aborted) break; + resetIdleTimeout(); + + const sseEvent = event as OpenCodeStreamEvent; + + if (sseEvent.type === 'message.part.updated') { + const props = sseEvent.properties as { part: OpenCodePart; delta?: string }; + const part = props.part; + const delta = props.delta; + + if (part.type === 'text') { + const textPart = part as OpenCodeTextPart; + textContentParts.set(textPart.id, textPart.text); + } + + handlePartUpdated(part, delta, options.onStream, state); + continue; + } + + if (sseEvent.type === 'permission.asked') { + const permProps = sseEvent.properties as { + id: string; + sessionID: string; + }; + if (permProps.sessionID === sessionId) { + const reply = options.permissionMode + ? mapToOpenCodePermissionReply(options.permissionMode) + : 'once'; + await client.permission.reply({ + requestID: permProps.id, + directory: options.cwd, + reply, + }); + } + continue; + } + + if (sseEvent.type === 'session.idle') { + const idleProps = sseEvent.properties as { sessionID: string }; + if (idleProps.sessionID === sessionId) { + break; + } + continue; + } + + if (sseEvent.type === 'session.error') { + const errorProps = sseEvent.properties as { + sessionID?: string; + error?: { name: string; data: { message: string } }; + }; + if (!errorProps.sessionID || errorProps.sessionID === sessionId) { + success = false; + failureMessage = errorProps.error?.data?.message ?? 'OpenCode session error'; + break; + } + continue; + } + } + + content = [...textContentParts.values()].join('\n'); + + if (!success) { + const message = failureMessage || 'OpenCode execution failed'; + const retriable = this.isRetriableError(message, streamAbortController.signal.aborted, abortCause); + if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) { + log.info('Retrying OpenCode call after transient failure', { agentType, attempt, message }); + await this.waitForRetryDelay(attempt, options.abortSignal); + continue; + } + + emitResult(options.onStream, false, message, sessionId); + return { + persona: agentType, + status: 'error', + content: message, + timestamp: new Date(), + sessionId, + }; + } + + const trimmed = content.trim(); + emitResult(options.onStream, true, trimmed, sessionId); + + return { + persona: agentType, + status: 'done', + content: trimmed, + timestamp: new Date(), + sessionId, + }; + } catch (error) { + const message = getErrorMessage(error); + const errorMessage = streamAbortController.signal.aborted + ? abortCause === 'timeout' + ? timeoutMessage + : OPENCODE_STREAM_ABORTED_MESSAGE + : message; + + const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause); + if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) { + log.info('Retrying OpenCode call after transient exception', { agentType, attempt, errorMessage }); + await this.waitForRetryDelay(attempt, options.abortSignal); + continue; + } + + if (options.sessionId) { + emitResult(options.onStream, false, errorMessage, options.sessionId); + } + + return { + persona: agentType, + status: 'error', + content: errorMessage, + timestamp: new Date(), + sessionId: options.sessionId, + }; + } finally { + if (idleTimeoutId !== undefined) { + clearTimeout(idleTimeoutId); + } + if (options.abortSignal) { + options.abortSignal.removeEventListener('abort', onExternalAbort); + } + if (serverClose) { + serverClose(); + } + } + } + + throw new Error('Unreachable: OpenCode retry loop exhausted without returning'); + } + + /** Call OpenCode with a custom agent configuration (system prompt + prompt) */ + async callCustom( + agentName: string, + prompt: string, + systemPrompt: string, + options: OpenCodeCallOptions, + ): Promise { + return this.call(agentName, prompt, { + ...options, + systemPrompt, + }); + } +} + +const defaultClient = new OpenCodeClient(); + +export async function callOpenCode( + agentType: string, + prompt: string, + options: OpenCodeCallOptions, +): Promise { + return defaultClient.call(agentType, prompt, options); +} + +export async function callOpenCodeCustom( + agentName: string, + prompt: string, + systemPrompt: string, + options: OpenCodeCallOptions, +): Promise { + return defaultClient.callCustom(agentName, prompt, systemPrompt, options); +} diff --git a/src/infra/opencode/index.ts b/src/infra/opencode/index.ts new file mode 100644 index 00000000..1d36e84f --- /dev/null +++ b/src/infra/opencode/index.ts @@ -0,0 +1,7 @@ +/** + * OpenCode integration exports + */ + +export { OpenCodeClient, callOpenCode, callOpenCodeCustom } from './client.js'; +export { mapToOpenCodePermissionReply } from './types.js'; +export type { OpenCodeCallOptions, OpenCodePermissionReply } from './types.js'; diff --git a/src/infra/opencode/types.ts b/src/infra/opencode/types.ts new file mode 100644 index 00000000..c9ca8282 --- /dev/null +++ b/src/infra/opencode/types.ts @@ -0,0 +1,34 @@ +/** + * Type definitions for OpenCode SDK integration + */ + +import type { StreamCallback } from '../claude/index.js'; +import type { PermissionMode } from '../../core/models/index.js'; + +/** OpenCode permission reply values */ +export type OpenCodePermissionReply = 'once' | 'always' | 'reject'; + +/** Map TAKT PermissionMode to OpenCode permission reply */ +export function mapToOpenCodePermissionReply(mode: PermissionMode): OpenCodePermissionReply { + const mapping: Record = { + readonly: 'reject', + edit: 'once', + full: 'always', + }; + return mapping[mode]; +} + +/** Options for calling OpenCode */ +export interface OpenCodeCallOptions { + cwd: string; + abortSignal?: AbortSignal; + sessionId?: string; + model?: string; + systemPrompt?: string; + /** Permission mode for automatic permission handling */ + permissionMode?: PermissionMode; + /** Enable streaming mode with callback (best-effort) */ + onStream?: StreamCallback; + /** OpenCode API key */ + opencodeApiKey?: string; +} diff --git a/src/infra/providers/index.ts b/src/infra/providers/index.ts index 576744b8..88659c88 100644 --- a/src/infra/providers/index.ts +++ b/src/infra/providers/index.ts @@ -1,12 +1,13 @@ /** * Provider abstraction layer * - * Provides a unified interface for different agent providers (Claude, Codex, Mock). + * Provides a unified interface for different agent providers (Claude, Codex, OpenCode, Mock). * This enables adding new providers without modifying the runner logic. */ import { ClaudeProvider } from './claude.js'; import { CodexProvider } from './codex.js'; +import { OpenCodeProvider } from './opencode.js'; import { MockProvider } from './mock.js'; import type { Provider, ProviderType } from './types.js'; @@ -24,6 +25,7 @@ export class ProviderRegistry { this.providers = { claude: new ClaudeProvider(), codex: new CodexProvider(), + opencode: new OpenCodeProvider(), mock: new MockProvider(), }; } diff --git a/src/infra/providers/opencode.ts b/src/infra/providers/opencode.ts new file mode 100644 index 00000000..5f83a11d --- /dev/null +++ b/src/infra/providers/opencode.ts @@ -0,0 +1,47 @@ +/** + * OpenCode provider implementation + */ + +import { callOpenCode, callOpenCodeCustom, type OpenCodeCallOptions } from '../opencode/index.js'; +import { resolveOpencodeApiKey } from '../config/index.js'; +import type { AgentResponse } from '../../core/models/index.js'; +import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; + +function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions { + return { + cwd: options.cwd, + abortSignal: options.abortSignal, + sessionId: options.sessionId, + model: options.model, + permissionMode: options.permissionMode, + onStream: options.onStream, + opencodeApiKey: options.opencodeApiKey ?? resolveOpencodeApiKey(), + }; +} + +/** OpenCode provider — delegates to OpenCode SDK */ +export class OpenCodeProvider implements Provider { + setup(config: AgentSetup): ProviderAgent { + if (config.claudeAgent) { + throw new Error('Claude Code agent calls are not supported by the OpenCode provider'); + } + if (config.claudeSkill) { + throw new Error('Claude Code skill calls are not supported by the OpenCode provider'); + } + + const { name, systemPrompt } = config; + if (systemPrompt) { + return { + call: async (prompt: string, options: ProviderCallOptions): Promise => { + return callOpenCodeCustom(name, prompt, systemPrompt, toOpenCodeOptions(options)); + }, + }; + } + + return { + call: async (prompt: string, options: ProviderCallOptions): Promise => { + return callOpenCode(name, prompt, toOpenCodeOptions(options)); + }, + }; + } +} diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index df688d99..d2bc48d1 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -38,6 +38,8 @@ export interface ProviderCallOptions { anthropicApiKey?: string; /** OpenAI API key for Codex provider */ openaiApiKey?: string; + /** OpenCode API key for OpenCode provider */ + opencodeApiKey?: string; } /** A configured agent ready to be called */ @@ -51,4 +53,4 @@ export interface Provider { } /** Provider type */ -export type ProviderType = 'claude' | 'codex' | 'mock'; +export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; From 6bf495f4176a6a88f67d9333ccdf4219e6be04e0 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 06:36:40 +0900 Subject: [PATCH 25/45] takt: github-issue-192-e2e-test (#221) --- docs/testing/e2e.md | 24 ++++++ e2e/specs/cli-config.e2e.ts | 102 ++++++++++++++++++++++++++ e2e/specs/cli-export-cc.e2e.ts | 88 ++++++++++++++++++++++ e2e/specs/cli-reset-categories.e2e.ts | 61 +++++++++++++++ vitest.config.e2e.mock.ts | 3 + 5 files changed, 278 insertions(+) create mode 100644 e2e/specs/cli-config.e2e.ts create mode 100644 e2e/specs/cli-export-cc.e2e.ts create mode 100644 e2e/specs/cli-reset-categories.e2e.ts diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index 1f91ed76..1df1e8ca 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -118,3 +118,27 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - `takt list --non-interactive --action diff --branch ` で差分統計が出力されることを確認する。 - `takt list --non-interactive --action try --branch ` で変更がステージされることを確認する。 - `takt list --non-interactive --action merge --branch ` でブランチがマージされ削除されることを確認する。 +- Config permission mode(`e2e/specs/cli-config.e2e.ts`) + - 目的: `takt config` でパーミッションモードの切り替えと永続化を確認。 + - LLM: 呼び出さない(LLM不使用の操作のみ) + - 手順(ユーザー行動/コマンド): + - `takt config default` を実行し、`Switched to: default` が出力されることを確認する。 + - `takt config sacrifice-my-pc` を実行し、`Switched to: sacrifice-my-pc` が出力されることを確認する。 + - `takt config sacrifice-my-pc` 実行後、`.takt/config.yaml` に `permissionMode: sacrifice-my-pc` が保存されていることを確認する。 + - `takt config invalid-mode` を実行し、`Invalid mode` が出力されることを確認する。 +- Reset categories(`e2e/specs/cli-reset-categories.e2e.ts`) + - 目的: `takt reset categories` でカテゴリオーバーレイのリセットを確認。 + - LLM: 呼び出さない(LLM不使用の操作のみ) + - 手順(ユーザー行動/コマンド): + - `takt reset categories` を実行する。 + - 出力に `reset` を含むことを確認する。 + - `$TAKT_CONFIG_DIR/preferences/piece-categories.yaml` が存在し `piece_categories: {}` を含むことを確認する。 +- Export Claude Code Skill(`e2e/specs/cli-export-cc.e2e.ts`) + - 目的: `takt export-cc` でClaude Code Skillのデプロイを確認。 + - LLM: 呼び出さない(LLM不使用の操作のみ) + - 手順(ユーザー行動/コマンド): + - `HOME` を一時ディレクトリに設定する。 + - `takt export-cc` を実行する。 + - 出力に `ファイルをデプロイしました` を含むことを確認する。 + - `$HOME/.claude/skills/takt/SKILL.md` が存在することを確認する。 + - `$HOME/.claude/skills/takt/pieces/` および `$HOME/.claude/skills/takt/personas/` ディレクトリが存在し、それぞれ少なくとも1ファイルを含むことを確認する。 diff --git a/e2e/specs/cli-config.e2e.ts b/e2e/specs/cli-config.e2e.ts new file mode 100644 index 00000000..e51cfc4b --- /dev/null +++ b/e2e/specs/cli-config.e2e.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-config-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Config command (takt config)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should switch to default mode with explicit argument', () => { + // Given: a local repo with isolated env + + // When: running takt config default + const result = runTakt({ + args: ['config', 'default'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: exits successfully and outputs switched message + expect(result.exitCode).toBe(0); + const output = result.stdout; + expect(output).toMatch(/Switched to: default/); + }); + + it('should switch to sacrifice-my-pc mode with explicit argument', () => { + // Given: a local repo with isolated env + + // When: running takt config sacrifice-my-pc + const result = runTakt({ + args: ['config', 'sacrifice-my-pc'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: exits successfully and outputs switched message + expect(result.exitCode).toBe(0); + const output = result.stdout; + expect(output).toMatch(/Switched to: sacrifice-my-pc/); + }); + + it('should persist permission mode to project config', () => { + // Given: a local repo with isolated env + + // When: running takt config sacrifice-my-pc + runTakt({ + args: ['config', 'sacrifice-my-pc'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: .takt/config.yaml contains permissionMode: sacrifice-my-pc + const configPath = join(repo.path, '.takt', 'config.yaml'); + const content = readFileSync(configPath, 'utf-8'); + expect(content).toMatch(/permissionMode:\s*sacrifice-my-pc/); + }); + + it('should report error for invalid mode name', () => { + // Given: a local repo with isolated env + + // When: running takt config with an invalid mode + const result = runTakt({ + args: ['config', 'invalid-mode'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains invalid mode message + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/Invalid mode/); + }); +}); diff --git a/e2e/specs/cli-export-cc.e2e.ts b/e2e/specs/cli-export-cc.e2e.ts new file mode 100644 index 00000000..b1d771c8 --- /dev/null +++ b/e2e/specs/cli-export-cc.e2e.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, existsSync, readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-export-cc-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Export-cc command (takt export-cc)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + let fakeHome: string; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + fakeHome = mkdtempSync(join(tmpdir(), 'takt-e2e-export-cc-home-')); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + try { rmSync(fakeHome, { recursive: true, force: true }); } catch { /* best-effort */ } + }); + + it('should deploy skill files to isolated home directory', () => { + // Given: a local repo with isolated env and HOME redirected to fakeHome + const env: NodeJS.ProcessEnv = { ...isolatedEnv.env, HOME: fakeHome }; + + // When: running takt export-cc + const result = runTakt({ + args: ['export-cc'], + cwd: repo.path, + env, + }); + + // Then: exits successfully and outputs deploy message + expect(result.exitCode).toBe(0); + const output = result.stdout; + expect(output).toMatch(/ファイルをデプロイしました/); + + // Then: SKILL.md exists in the skill directory + const skillMdPath = join(fakeHome, '.claude', 'skills', 'takt', 'SKILL.md'); + expect(existsSync(skillMdPath)).toBe(true); + }); + + it('should deploy resource directories', () => { + // Given: a local repo with isolated env and HOME redirected to fakeHome + const env: NodeJS.ProcessEnv = { ...isolatedEnv.env, HOME: fakeHome }; + + // When: running takt export-cc + runTakt({ + args: ['export-cc'], + cwd: repo.path, + env, + }); + + // Then: pieces/ and personas/ directories exist with at least one file each + const skillDir = join(fakeHome, '.claude', 'skills', 'takt'); + + const piecesDir = join(skillDir, 'pieces'); + expect(existsSync(piecesDir)).toBe(true); + const pieceFiles = readdirSync(piecesDir); + expect(pieceFiles.length).toBeGreaterThan(0); + + const personasDir = join(skillDir, 'personas'); + expect(existsSync(personasDir)).toBe(true); + const personaFiles = readdirSync(personasDir); + expect(personaFiles.length).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/cli-reset-categories.e2e.ts b/e2e/specs/cli-reset-categories.e2e.ts new file mode 100644 index 00000000..f53131e5 --- /dev/null +++ b/e2e/specs/cli-reset-categories.e2e.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-reset-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Reset categories command (takt reset categories)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should reset categories and create overlay file', () => { + // Given: a local repo with isolated env + + // When: running takt reset categories + const result = runTakt({ + args: ['reset', 'categories'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: exits successfully and outputs reset message + expect(result.exitCode).toBe(0); + const output = result.stdout; + expect(output).toMatch(/reset/i); + + // Then: piece-categories.yaml exists with initial content + const categoriesPath = join(isolatedEnv.taktDir, 'preferences', 'piece-categories.yaml'); + expect(existsSync(categoriesPath)).toBe(true); + const content = readFileSync(categoriesPath, 'utf-8'); + expect(content).toContain('piece_categories: {}'); + }); +}); diff --git a/vitest.config.e2e.mock.ts b/vitest.config.e2e.mock.ts index 48171808..12bc9fc1 100644 --- a/vitest.config.e2e.mock.ts +++ b/vitest.config.e2e.mock.ts @@ -20,6 +20,9 @@ export default defineConfig({ 'e2e/specs/cli-switch.e2e.ts', 'e2e/specs/cli-help.e2e.ts', 'e2e/specs/cli-clear.e2e.ts', + 'e2e/specs/cli-config.e2e.ts', + 'e2e/specs/cli-reset-categories.e2e.ts', + 'e2e/specs/cli-export-cc.e2e.ts', 'e2e/specs/quiet-mode.e2e.ts', 'e2e/specs/task-content-file.e2e.ts', ], From 36e77ae0fac7843b8c6630e1b93455c81868be92 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 06:37:06 +0900 Subject: [PATCH 26/45] takt: issue (#220) --- src/__tests__/parallel-logger.test.ts | 57 ++++++++++++++++++++++++ src/core/piece/engine/parallel-logger.ts | 20 +++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/__tests__/parallel-logger.test.ts b/src/__tests__/parallel-logger.test.ts index 8f87bd8d..67681b73 100644 --- a/src/__tests__/parallel-logger.test.ts +++ b/src/__tests__/parallel-logger.test.ts @@ -393,6 +393,63 @@ describe('ParallelLogger', () => { expect(doneIndex0).toBe(doneIndex1); }); + it('should prepend task prefix to all summary lines in rich mode', () => { + const logger = new ParallelLogger({ + subMovementNames: ['arch-review', 'security-review'], + writeFn, + progressInfo: { iteration: 5, maxMovements: 30 }, + taskLabel: 'override-persona-provider', + taskColorIndex: 0, + parentMovementName: 'reviewers', + movementIteration: 1, + }); + + logger.printSummary('reviewers', [ + { name: 'arch-review', condition: 'approved' }, + { name: 'security-review', condition: 'needs_fix' }, + ]); + + // Every output line should have the task prefix + for (const line of output) { + expect(line).toContain('[over]'); + expect(line).toContain('[reviewers]'); + expect(line).toContain('(5/30)(1)'); + } + + // Verify task color (cyan for index 0) + expect(output[0]).toContain('\x1b[36m'); + + // Verify summary content is still present + const fullOutput = output.join(''); + expect(fullOutput).toContain('reviewers results'); + expect(fullOutput).toContain('arch-review:'); + expect(fullOutput).toContain('approved'); + expect(fullOutput).toContain('security-review:'); + expect(fullOutput).toContain('needs_fix'); + }); + + it('should not prepend task prefix to summary lines in non-rich mode', () => { + const logger = new ParallelLogger({ + subMovementNames: ['arch-review'], + writeFn, + }); + + logger.printSummary('reviewers', [ + { name: 'arch-review', condition: 'approved' }, + ]); + + // No task prefix should appear + for (const line of output) { + expect(line).not.toContain('[over]'); + } + + // Summary content is present + const fullOutput = output.join(''); + expect(fullOutput).toContain('reviewers results'); + expect(fullOutput).toContain('arch-review:'); + expect(fullOutput).toContain('approved'); + }); + it('should flush remaining buffers before printing summary', () => { const logger = new ParallelLogger({ subMovementNames: ['step-a'], diff --git a/src/core/piece/engine/parallel-logger.ts b/src/core/piece/engine/parallel-logger.ts index 2566a2af..d44a6fff 100644 --- a/src/core/piece/engine/parallel-logger.ts +++ b/src/core/piece/engine/parallel-logger.ts @@ -189,6 +189,19 @@ export class ParallelLogger { } } + /** + * Build the prefix string for summary lines (no sub-movement name). + * Returns empty string in non-rich mode (no task-level prefix needed). + */ + private buildSummaryPrefix(): string { + if (this.taskLabel && this.parentMovementName && this.progressInfo && this.movementIteration != null && this.taskColorIndex != null) { + const taskColor = COLORS[this.taskColorIndex % COLORS.length]; + const { iteration, maxMovements } = this.progressInfo; + return `${taskColor}[${this.taskLabel}]${RESET}[${this.parentMovementName}](${iteration}/${maxMovements})(${this.movementIteration}) `; + } + return ''; + } + /** * Flush remaining line buffers for all sub-movements. * Call after all sub-movements complete to output any trailing partial lines. @@ -243,10 +256,11 @@ export class ParallelLogger { const headerLine = `${'─'.repeat(sideWidth)}${headerText}${'─'.repeat(sideWidth)}`; const footerLine = '─'.repeat(headerLine.length); - this.writeFn(`${headerLine}\n`); + const summaryPrefix = this.buildSummaryPrefix(); + this.writeFn(`${summaryPrefix}${headerLine}\n`); for (const line of resultLines) { - this.writeFn(`${line}\n`); + this.writeFn(`${summaryPrefix}${line}\n`); } - this.writeFn(`${footerLine}\n`); + this.writeFn(`${summaryPrefix}${footerLine}\n`); } } From 166d6d9b5c4c51535c0a94d17e6123e721f4d89d Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 07:04:46 +0900 Subject: [PATCH 27/45] =?UTF-8?q?=E3=83=9D=E3=83=BC=E3=83=88=E7=AB=B6?= =?UTF-8?q?=E5=90=88=E5=9B=9E=E9=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/engine-error.test.ts | 1 + src/infra/opencode/client.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/__tests__/engine-error.test.ts b/src/__tests__/engine-error.test.ts index d018242b..bcc9ca2b 100644 --- a/src/__tests__/engine-error.test.ts +++ b/src/__tests__/engine-error.test.ts @@ -109,6 +109,7 @@ describe('PieceEngine Integration: Error Handling', () => { const reason = abortFn.mock.calls[0]![1] as string; expect(reason).toContain('API connection failed'); }); + }); // ===================================================== diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index 42f29187..1a5dd25b 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -6,6 +6,7 @@ */ import { createOpencode } from '@opencode-ai/sdk/v2'; +import { createServer } from 'node:net'; import type { AgentResponse } from '../../core/models/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { mapToOpenCodePermissionReply, type OpenCodeCallOptions } from './types.js'; @@ -35,8 +36,32 @@ const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ 'etimedout', 'eai_again', 'fetch failed', + 'failed to start server on port', ]; +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') { + server.close(() => reject(new Error('Failed to allocate free TCP port'))); + return; + } + const port = addr.port; + server.close((err) => { + if (err) { + reject(err); + return; + } + resolve(port); + }); + }); + }); +} + /** * Client for OpenCode SDK agent interactions. * @@ -129,7 +154,9 @@ export class OpenCodeClient { attempt, }); + const port = await getFreePort(); const { client, server } = await createOpencode({ + port, signal: streamAbortController.signal, ...(options.opencodeApiKey ? { config: { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } } From 4bc759c8939b7db8002a308a48c39c2fad50018f Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 07:57:04 +0900 Subject: [PATCH 28/45] =?UTF-8?q?opencode=20=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/globalConfig-defaults.test.ts | 18 ++++++++++++--- src/__tests__/opencode-stream-handler.test.ts | 17 +++----------- src/__tests__/provider-model.test.ts | 23 +++++++++++++++++++ src/infra/config/global/globalConfig.ts | 15 +++++++++++- src/infra/opencode/OpenCodeStreamHandler.ts | 4 ++-- src/infra/opencode/client.ts | 16 +++++++++---- src/infra/opencode/types.ts | 2 +- src/infra/providers/opencode.ts | 4 ++++ src/shared/utils/providerModel.ts | 21 +++++++++++++++++ 9 files changed, 95 insertions(+), 25 deletions(-) create mode 100644 src/__tests__/provider-model.test.ts create mode 100644 src/shared/utils/providerModel.ts diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 511d54e3..47537324 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -560,14 +560,14 @@ describe('loadGlobalConfig', () => { mkdirSync(taktDir, { recursive: true }); writeFileSync( getGlobalConfigPath(), - 'provider: opencode\nmodel: gpt-4o\n', + 'provider: opencode\nmodel: opencode/big-pickle\n', 'utf-8', ); expect(() => loadGlobalConfig()).not.toThrow(); }); - it('should not throw when provider is opencode without a model', () => { + it('should throw when provider is opencode without a model', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); writeFileSync( @@ -576,7 +576,19 @@ describe('loadGlobalConfig', () => { 'utf-8', ); - expect(() => loadGlobalConfig()).not.toThrow(); + expect(() => loadGlobalConfig()).toThrow(/provider 'opencode' requires model in 'provider\/model' format/i); + }); + + it('should throw when provider is opencode and model is not provider/model format', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'provider: opencode\nmodel: big-pickle\n', + 'utf-8', + ); + + expect(() => loadGlobalConfig()).toThrow(/must be in 'provider\/model' format/i); }); }); }); diff --git a/src/__tests__/opencode-stream-handler.test.ts b/src/__tests__/opencode-stream-handler.test.ts index b4dbe4db..2456f05a 100644 --- a/src/__tests__/opencode-stream-handler.test.ts +++ b/src/__tests__/opencode-stream-handler.test.ts @@ -32,28 +32,17 @@ describe('emitInit', () => { it('should emit init event with model and sessionId', () => { const onStream = vi.fn(); - emitInit(onStream, 'gpt-4', 'session-123'); + emitInit(onStream, 'opencode/big-pickle', 'session-123'); expect(onStream).toHaveBeenCalledOnce(); expect(onStream).toHaveBeenCalledWith({ type: 'init', - data: { model: 'gpt-4', sessionId: 'session-123' }, - }); - }); - - it('should use default model name when model is undefined', () => { - const onStream = vi.fn(); - - emitInit(onStream, undefined, 'session-abc'); - - expect(onStream).toHaveBeenCalledWith({ - type: 'init', - data: { model: 'opencode', sessionId: 'session-abc' }, + data: { model: 'opencode/big-pickle', sessionId: 'session-123' }, }); }); it('should not emit when onStream is undefined', () => { - emitInit(undefined, 'gpt-4', 'session-123'); + emitInit(undefined, 'opencode/big-pickle', 'session-123'); }); }); diff --git a/src/__tests__/provider-model.test.ts b/src/__tests__/provider-model.test.ts new file mode 100644 index 00000000..74b29be1 --- /dev/null +++ b/src/__tests__/provider-model.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { parseProviderModel } from '../shared/utils/providerModel.js'; + +describe('parseProviderModel', () => { + it('should parse provider/model format', () => { + expect(parseProviderModel('opencode/big-pickle', 'model')).toEqual({ + providerID: 'opencode', + modelID: 'big-pickle', + }); + }); + + it('should reject empty string', () => { + expect(() => parseProviderModel('', 'model')).toThrow(/must not be empty/i); + }); + + it('should reject missing slash', () => { + expect(() => parseProviderModel('big-pickle', 'model')).toThrow(/provider\/model/i); + }); + + it('should reject multiple slashes', () => { + expect(() => parseProviderModel('a/b/c', 'model')).toThrow(/provider\/model/i); + }); +}); diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 1298b9ad..e7a7ea63 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -11,13 +11,22 @@ import { GlobalConfigSchema } from '../../../core/models/index.js'; import type { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js'; import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js'; import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; +import { parseProviderModel } from '../../../shared/utils/providerModel.js'; /** Claude-specific model aliases that are not valid for other providers */ const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']); /** Validate that provider and model are compatible */ function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void { - if (!provider || !model) return; + if (!provider) return; + + if (provider === 'opencode' && !model) { + throw new Error( + "Configuration error: provider 'opencode' requires model in 'provider/model' format (e.g. 'opencode/big-pickle')." + ); + } + + if (!model) return; if ((provider === 'codex' || provider === 'opencode') && CLAUDE_MODEL_ALIASES.has(model)) { throw new Error( @@ -25,6 +34,10 @@ function validateProviderModelCompatibility(provider: string | undefined, model: `Either change the provider to 'claude' or specify a ${provider}-compatible model.` ); } + + if (provider === 'opencode') { + parseProviderModel(model, "Configuration error: model"); + } } /** Create default global configuration (fresh instance each call) */ diff --git a/src/infra/opencode/OpenCodeStreamHandler.ts b/src/infra/opencode/OpenCodeStreamHandler.ts index f6cb7b4b..dfd70d6a 100644 --- a/src/infra/opencode/OpenCodeStreamHandler.ts +++ b/src/infra/opencode/OpenCodeStreamHandler.ts @@ -93,14 +93,14 @@ export function createStreamTrackingState(): StreamTrackingState { export function emitInit( onStream: StreamCallback | undefined, - model: string | undefined, + model: string, sessionId: string, ): void { if (!onStream) return; onStream({ type: 'init', data: { - model: model || 'opencode', + model, sessionId, }, }); diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index 1a5dd25b..d87c06d8 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -9,6 +9,7 @@ import { createOpencode } from '@opencode-ai/sdk/v2'; import { createServer } from 'node:net'; import type { AgentResponse } from '../../core/models/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; +import { parseProviderModel } from '../../shared/utils/providerModel.js'; import { mapToOpenCodePermissionReply, type OpenCodeCallOptions } from './types.js'; import { type OpenCodeStreamEvent, @@ -154,13 +155,20 @@ export class OpenCodeClient { attempt, }); + const parsedModel = parseProviderModel(options.model, 'OpenCode model'); + const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`; const port = await getFreePort(); + const config = { + model: fullModel, + small_model: fullModel, + ...(options.opencodeApiKey + ? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } + : {}), + }; const { client, server } = await createOpencode({ port, signal: streamAbortController.signal, - ...(options.opencodeApiKey - ? { config: { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } } - : {}), + config, }); serverClose = server.close; @@ -179,7 +187,7 @@ export class OpenCodeClient { await client.session.promptAsync({ sessionID: sessionId, directory: options.cwd, - ...(options.model ? { model: { providerID: 'opencode', modelID: options.model } } : {}), + model: parsedModel, parts: [{ type: 'text' as const, text: fullPrompt }], }); diff --git a/src/infra/opencode/types.ts b/src/infra/opencode/types.ts index c9ca8282..ae3976f8 100644 --- a/src/infra/opencode/types.ts +++ b/src/infra/opencode/types.ts @@ -23,7 +23,7 @@ export interface OpenCodeCallOptions { cwd: string; abortSignal?: AbortSignal; sessionId?: string; - model?: string; + model: string; systemPrompt?: string; /** Permission mode for automatic permission handling */ permissionMode?: PermissionMode; diff --git a/src/infra/providers/opencode.ts b/src/infra/providers/opencode.ts index 5f83a11d..aa026807 100644 --- a/src/infra/providers/opencode.ts +++ b/src/infra/providers/opencode.ts @@ -8,6 +8,10 @@ import type { AgentResponse } from '../../core/models/index.js'; import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js'; function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions { + if (!options.model) { + throw new Error("OpenCode provider requires model in 'provider/model' format (e.g. 'opencode/big-pickle')."); + } + return { cwd: options.cwd, abortSignal: options.abortSignal, diff --git a/src/shared/utils/providerModel.ts b/src/shared/utils/providerModel.ts new file mode 100644 index 00000000..a0947658 --- /dev/null +++ b/src/shared/utils/providerModel.ts @@ -0,0 +1,21 @@ +/** + * Parse provider/model identifier. + * + * Expected format: "/" with both segments non-empty. + */ +export function parseProviderModel(value: string, fieldName: string): { providerID: string; modelID: string } { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${fieldName} must not be empty`); + } + + const slashIndex = trimmed.indexOf('/'); + if (slashIndex <= 0 || slashIndex === trimmed.length - 1 || trimmed.indexOf('/', slashIndex + 1) !== -1) { + throw new Error(`${fieldName} must be in 'provider/model' format: received '${value}'`); + } + + return { + providerID: trimmed.slice(0, slashIndex), + modelID: trimmed.slice(slashIndex + 1), + }; +} From addd7023cd8d957af74dc6717c2b6988f5fbc939 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:11:05 +0900 Subject: [PATCH 29/45] =?UTF-8?q?pass=5Fprevious=5Fresponse=E3=82=92?= =?UTF-8?q?=E5=BE=A9=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- builtins/en/pieces/expert-cqrs.yaml | 4 ---- builtins/en/pieces/expert.yaml | 4 ---- builtins/en/pieces/frontend.yaml | 4 ---- builtins/ja/pieces/expert-cqrs.yaml | 4 ---- builtins/ja/pieces/expert.yaml | 4 ---- builtins/ja/pieces/frontend.yaml | 4 ---- 6 files changed, 24 deletions(-) diff --git a/builtins/en/pieces/expert-cqrs.yaml b/builtins/en/pieces/expert-cqrs.yaml index 8cc7e621..041f5d96 100644 --- a/builtins/en/pieces/expert-cqrs.yaml +++ b/builtins/en/pieces/expert-cqrs.yaml @@ -26,7 +26,6 @@ movements: - name: implement edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -87,7 +86,6 @@ movements: - name: ai_fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -218,7 +216,6 @@ movements: - name: fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -267,7 +264,6 @@ movements: - name: fix_supervisor edit: true persona: coder - pass_previous_response: false policy: - coding - testing diff --git a/builtins/en/pieces/expert.yaml b/builtins/en/pieces/expert.yaml index 3ec13b86..65a006ee 100644 --- a/builtins/en/pieces/expert.yaml +++ b/builtins/en/pieces/expert.yaml @@ -26,7 +26,6 @@ movements: - name: implement edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -86,7 +85,6 @@ movements: - name: ai_fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -216,7 +214,6 @@ movements: - name: fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -264,7 +261,6 @@ movements: - name: fix_supervisor edit: true persona: coder - pass_previous_response: false policy: - coding - testing diff --git a/builtins/en/pieces/frontend.yaml b/builtins/en/pieces/frontend.yaml index d862c548..31e3eb68 100644 --- a/builtins/en/pieces/frontend.yaml +++ b/builtins/en/pieces/frontend.yaml @@ -26,7 +26,6 @@ movements: - name: implement edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -85,7 +84,6 @@ movements: - name: ai_fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -214,7 +212,6 @@ movements: - name: fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -261,7 +258,6 @@ movements: - name: fix_supervisor edit: true persona: coder - pass_previous_response: false policy: - coding - testing diff --git a/builtins/ja/pieces/expert-cqrs.yaml b/builtins/ja/pieces/expert-cqrs.yaml index 277fd97b..8c2a2b67 100644 --- a/builtins/ja/pieces/expert-cqrs.yaml +++ b/builtins/ja/pieces/expert-cqrs.yaml @@ -26,7 +26,6 @@ movements: - name: implement edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -87,7 +86,6 @@ movements: - name: ai_fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -218,7 +216,6 @@ movements: - name: fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -267,7 +264,6 @@ movements: - name: fix_supervisor edit: true persona: coder - pass_previous_response: false policy: - coding - testing diff --git a/builtins/ja/pieces/expert.yaml b/builtins/ja/pieces/expert.yaml index c5195ea7..9f36c606 100644 --- a/builtins/ja/pieces/expert.yaml +++ b/builtins/ja/pieces/expert.yaml @@ -26,7 +26,6 @@ movements: - name: implement edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -86,7 +85,6 @@ movements: - name: ai_fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -216,7 +214,6 @@ movements: - name: fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -264,7 +261,6 @@ movements: - name: fix_supervisor edit: true persona: coder - pass_previous_response: false policy: - coding - testing diff --git a/builtins/ja/pieces/frontend.yaml b/builtins/ja/pieces/frontend.yaml index 0d46ff1f..6bcbcb29 100644 --- a/builtins/ja/pieces/frontend.yaml +++ b/builtins/ja/pieces/frontend.yaml @@ -26,7 +26,6 @@ movements: - name: implement edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -85,7 +84,6 @@ movements: - name: ai_fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -214,7 +212,6 @@ movements: - name: fix edit: true persona: coder - pass_previous_response: false policy: - coding - testing @@ -261,7 +258,6 @@ movements: - name: fix_supervisor edit: true persona: coder - pass_previous_response: false policy: - coding - testing From 475da03d60e4ec47bdbc87621a53c202d3e22125 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:41:38 +0900 Subject: [PATCH 30/45] takt: task-1770764964345 (#225) --- .../pieceExecution-session-loading.test.ts | 221 ++++++++++++++++++ src/features/tasks/execute/pieceExecution.ts | 15 +- 2 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/pieceExecution-session-loading.test.ts diff --git a/src/__tests__/pieceExecution-session-loading.test.ts b/src/__tests__/pieceExecution-session-loading.test.ts new file mode 100644 index 00000000..e09d0d87 --- /dev/null +++ b/src/__tests__/pieceExecution-session-loading.test.ts @@ -0,0 +1,221 @@ +/** + * Tests: session loading behavior in executePiece(). + * + * Normal runs pass empty sessions to PieceEngine; + * retry runs (startMovement / retryNote) load persisted sessions. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { PieceConfig } from '../core/models/index.js'; + +const { MockPieceEngine, mockLoadPersonaSessions, mockLoadWorktreeSessions } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { EventEmitter: EE } = require('node:events') as typeof import('node:events'); + + const mockLoadPersonaSessions = vi.fn().mockReturnValue({ coder: 'saved-session-id' }); + const mockLoadWorktreeSessions = vi.fn().mockReturnValue({ coder: 'worktree-session-id' }); + + class MockPieceEngine extends EE { + static lastInstance: MockPieceEngine; + readonly receivedOptions: Record; + + constructor(config: PieceConfig, _cwd: string, _task: string, options: Record) { + super(); + this.receivedOptions = options; + MockPieceEngine.lastInstance = this; + } + + abort(): void {} + + async run(): Promise<{ status: string; iteration: number }> { + this.emit('piece:complete', { status: 'completed', iteration: 1 }); + return { status: 'completed', iteration: 1 }; + } + } + + return { MockPieceEngine, mockLoadPersonaSessions, mockLoadWorktreeSessions }; +}); + +vi.mock('../core/piece/index.js', () => ({ + PieceEngine: MockPieceEngine, +})); + +vi.mock('../infra/claude/index.js', () => ({ + detectRuleIndex: vi.fn(), + interruptAllQueries: vi.fn(), +})); + +vi.mock('../agents/ai-judge.js', () => ({ + callAiJudge: vi.fn(), +})); + +vi.mock('../infra/config/index.js', () => ({ + loadPersonaSessions: mockLoadPersonaSessions, + updatePersonaSession: vi.fn(), + loadWorktreeSessions: mockLoadWorktreeSessions, + updateWorktreeSession: vi.fn(), + loadGlobalConfig: vi.fn().mockReturnValue({ provider: 'claude' }), + saveSessionState: vi.fn(), + ensureDir: vi.fn(), + writeFileAtomic: vi.fn(), +})); + +vi.mock('../shared/context.js', () => ({ + isQuietMode: vi.fn().mockReturnValue(true), +})); + +vi.mock('../shared/ui/index.js', () => ({ + header: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + status: vi.fn(), + blankLine: vi.fn(), + StreamDisplay: vi.fn().mockImplementation(() => ({ + createHandler: vi.fn().mockReturnValue(vi.fn()), + flush: vi.fn(), + })), +})); + +vi.mock('../infra/fs/index.js', () => ({ + generateSessionId: vi.fn().mockReturnValue('test-session-id'), + createSessionLog: vi.fn().mockReturnValue({ + startTime: new Date().toISOString(), + iterations: 0, + }), + finalizeSessionLog: vi.fn().mockImplementation((log, status) => ({ + ...log, + status, + endTime: new Date().toISOString(), + })), + initNdjsonLog: vi.fn().mockReturnValue('/tmp/test-log.jsonl'), + appendNdjsonLine: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', () => ({ + createLogger: vi.fn().mockReturnValue({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + notifySuccess: vi.fn(), + notifyError: vi.fn(), + preventSleep: vi.fn(), + isDebugEnabled: vi.fn().mockReturnValue(false), + writePromptLog: vi.fn(), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), + isValidReportDirName: vi.fn().mockReturnValue(true), + playWarningSound: vi.fn(), +})); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: vi.fn(), + promptInput: vi.fn(), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn().mockImplementation((key: string) => key), +})); + +vi.mock('../shared/exitCodes.js', () => ({ + EXIT_SIGINT: 130, +})); + +import { executePiece } from '../features/tasks/execute/pieceExecution.js'; + +function makeConfig(): PieceConfig { + return { + name: 'test-piece', + maxMovements: 5, + initialMovement: 'implement', + movements: [ + { + name: 'implement', + persona: '../agents/coder.md', + personaDisplayName: 'coder', + instructionTemplate: 'Implement task', + passPreviousResponse: true, + rules: [{ condition: 'done', next: 'COMPLETE' }], + }, + ], + }; +} + +describe('executePiece session loading', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadPersonaSessions.mockReturnValue({ coder: 'saved-session-id' }); + mockLoadWorktreeSessions.mockReturnValue({ coder: 'worktree-session-id' }); + }); + + it('should pass empty initialSessions on normal run', async () => { + // Given: normal execution (no startMovement, no retryNote) + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + }); + + // Then: PieceEngine receives empty sessions + expect(mockLoadPersonaSessions).not.toHaveBeenCalled(); + expect(mockLoadWorktreeSessions).not.toHaveBeenCalled(); + expect(MockPieceEngine.lastInstance.receivedOptions.initialSessions).toEqual({}); + }); + + it('should load persisted sessions when startMovement is set (retry)', async () => { + // Given: retry execution with startMovement + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + startMovement: 'implement', + }); + + // Then: loadPersonaSessions is called to load saved sessions + expect(mockLoadPersonaSessions).toHaveBeenCalledWith('/tmp/project', 'claude'); + }); + + it('should load persisted sessions when retryNote is set (retry)', async () => { + // Given: retry execution with retryNote + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + retryNote: 'Fix the failing test', + }); + + // Then: loadPersonaSessions is called to load saved sessions + expect(mockLoadPersonaSessions).toHaveBeenCalledWith('/tmp/project', 'claude'); + }); + + it('should load worktree sessions on retry when cwd differs from projectCwd', async () => { + // Given: retry execution in a worktree (cwd !== projectCwd) + await executePiece(makeConfig(), 'task', '/tmp/worktree', { + projectCwd: '/tmp/project', + startMovement: 'implement', + }); + + // Then: loadWorktreeSessions is called instead of loadPersonaSessions + expect(mockLoadWorktreeSessions).toHaveBeenCalledWith('/tmp/project', '/tmp/worktree', 'claude'); + expect(mockLoadPersonaSessions).not.toHaveBeenCalled(); + }); + + it('should not load sessions for worktree normal run', async () => { + // Given: normal execution in a worktree (no retry) + await executePiece(makeConfig(), 'task', '/tmp/worktree', { + projectCwd: '/tmp/project', + }); + + // Then: neither session loader is called + expect(mockLoadPersonaSessions).not.toHaveBeenCalled(); + expect(mockLoadWorktreeSessions).not.toHaveBeenCalled(); + }); + + it('should load sessions when both startMovement and retryNote are set', async () => { + // Given: retry with both flags + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + startMovement: 'implement', + retryNote: 'Fix issue', + }); + + // Then: sessions are loaded + expect(mockLoadPersonaSessions).toHaveBeenCalledWith('/tmp/project', 'claude'); + }); +}); diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index e660f95a..ba3d19c4 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -218,8 +218,9 @@ export async function executePiece( : undefined; const out = createOutputFns(prefixWriter); - // Always continue from previous sessions (use /clear to reset) - log.debug('Continuing session (use /clear to reset)'); + // Retry reuses saved sessions; normal runs start fresh + const isRetry = Boolean(options.startMovement || options.retryNote); + log.debug('Session mode', { isRetry, isWorktree: cwd !== projectCwd }); out.header(`${headerPrefix} ${pieceConfig.name}`); @@ -292,7 +293,7 @@ export async function executePiece( displayRef.current.createHandler()(event); }; - // Load saved agent sessions for continuity (from project root or clone-specific storage) + // Load saved agent sessions only on retry; normal runs start with empty sessions const isWorktree = cwd !== projectCwd; const globalConfig = loadGlobalConfig(); const shouldNotify = globalConfig.notificationSound !== false; @@ -306,9 +307,11 @@ export async function executePiece( if (globalConfig.preventSleep) { preventSleep(); } - const savedSessions = isWorktree - ? loadWorktreeSessions(projectCwd, cwd, currentProvider) - : loadPersonaSessions(projectCwd, currentProvider); + const savedSessions = isRetry + ? (isWorktree + ? loadWorktreeSessions(projectCwd, cwd, currentProvider) + : loadPersonaSessions(projectCwd, currentProvider)) + : {}; // Session update handler - persist session IDs when they change // Clone sessions are stored separately per clone path From 1e4182b0ebe3fce52ca98b2e482988011afde10d Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:47:46 +0900 Subject: [PATCH 31/45] =?UTF-8?q?opencode=20=E3=81=A7=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=83=B3=E3=83=97=E3=83=88=E3=81=8Cecho=E3=81=95=E3=82=8C?= =?UTF-8?q?=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/infra/opencode/client.ts | 44 +++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index d87c06d8..fa2690e4 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -17,6 +17,7 @@ import { type OpenCodeTextPart, createStreamTrackingState, emitInit, + emitText, emitResult, handlePartUpdated, } from './OpenCodeStreamHandler.js'; @@ -40,6 +41,31 @@ const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ 'failed to start server on port', ]; +function getCommonPrefixLength(a: string, b: string): number { + const max = Math.min(a.length, b.length); + let i = 0; + while (i < max && a[i] === b[i]) { + i += 1; + } + return i; +} + +function stripPromptEcho( + chunk: string, + echoState: { remainingPrompt: string }, +): string { + if (!chunk) return ''; + if (!echoState.remainingPrompt) return chunk; + + const consumeLength = getCommonPrefixLength(chunk, echoState.remainingPrompt); + if (consumeLength > 0) { + echoState.remainingPrompt = echoState.remainingPrompt.slice(consumeLength); + return chunk.slice(consumeLength); + } + + return chunk; +} + async function getFreePort(): Promise { return new Promise((resolve, reject) => { const server = createServer(); @@ -197,6 +223,8 @@ export class OpenCodeClient { let success = true; let failureMessage = ''; const state = createStreamTrackingState(); + const echoState = { remainingPrompt: fullPrompt }; + const textOffsets = new Map(); const textContentParts = new Map(); for await (const event of stream) { @@ -212,7 +240,21 @@ export class OpenCodeClient { if (part.type === 'text') { const textPart = part as OpenCodeTextPart; - textContentParts.set(textPart.id, textPart.text); + const prev = textOffsets.get(textPart.id) ?? 0; + const rawDelta = delta + ?? (textPart.text.length > prev ? textPart.text.slice(prev) : ''); + + textOffsets.set(textPart.id, textPart.text.length); + + if (rawDelta) { + const visibleDelta = stripPromptEcho(rawDelta, echoState); + if (visibleDelta) { + emitText(options.onStream, visibleDelta); + const previous = textContentParts.get(textPart.id) ?? ''; + textContentParts.set(textPart.id, `${previous}${visibleDelta}`); + } + } + continue; } handlePartUpdated(part, delta, options.onStream, state); From c42799739eb933e8f2320d28dd5f00d2d031cf09 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:48:05 +0900 Subject: [PATCH 32/45] =?UTF-8?q?opencode=20=E3=81=8C=E3=83=8F=E3=83=B3?= =?UTF-8?q?=E3=82=B0=E3=81=99=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli-routing-issue-resolve.test.ts | 28 +- src/__tests__/opencode-client-cleanup.test.ts | 248 ++++++++++++++++++ src/app/cli/index.ts | 7 + src/app/cli/routing.ts | 13 +- src/features/tasks/add/index.ts | 8 +- src/infra/opencode/OpenCodeStreamHandler.ts | 22 ++ src/infra/opencode/client.ts | 94 ++++++- src/shared/prompt/confirm.ts | 12 + 8 files changed, 406 insertions(+), 26 deletions(-) create mode 100644 src/__tests__/opencode-client-cleanup.test.ts diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index 74c3de9b..522b7e91 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -41,7 +41,7 @@ vi.mock('../features/tasks/index.js', () => ({ selectAndExecuteTask: vi.fn(), determinePiece: vi.fn(), saveTaskFromInteractive: vi.fn(), - createIssueAndSaveTask: vi.fn(), + createIssueFromTask: vi.fn(), })); vi.mock('../features/pipeline/index.js', () => ({ @@ -89,7 +89,7 @@ vi.mock('../app/cli/helpers.js', () => ({ })); import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; -import { selectAndExecuteTask, determinePiece, createIssueAndSaveTask } from '../features/tasks/index.js'; +import { selectAndExecuteTask, determinePiece, createIssueFromTask, saveTaskFromInteractive } from '../features/tasks/index.js'; import { interactiveMode, selectRecentSession } from '../features/interactive/index.js'; import { loadGlobalConfig } from '../infra/config/index.js'; import { confirm } from '../shared/prompt/index.js'; @@ -103,7 +103,8 @@ const mockFormatIssueAsTask = vi.mocked(formatIssueAsTask); const mockParseIssueNumbers = vi.mocked(parseIssueNumbers); const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask); const mockDeterminePiece = vi.mocked(determinePiece); -const mockCreateIssueAndSaveTask = vi.mocked(createIssueAndSaveTask); +const mockCreateIssueFromTask = vi.mocked(createIssueFromTask); +const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive); const mockInteractiveMode = vi.mocked(interactiveMode); const mockSelectRecentSession = vi.mocked(selectRecentSession); const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); @@ -280,38 +281,41 @@ describe('Issue resolution in routing', () => { }); describe('create_issue action', () => { - it('should delegate to createIssueAndSaveTask with cwd, task, and pieceId when confirmed', async () => { + it('should create issue first, then delegate final confirmation to saveTaskFromInteractive', async () => { // Given mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); - mockConfirm.mockResolvedValue(true); + mockCreateIssueFromTask.mockReturnValue(226); // When await executeDefaultAction(); - // Then: createIssueAndSaveTask should be called with correct args - expect(mockCreateIssueAndSaveTask).toHaveBeenCalledWith( + // Then: issue is created first + expect(mockCreateIssueFromTask).toHaveBeenCalledWith('New feature request'); + // Then: saveTaskFromInteractive receives final confirmation message + expect(mockSaveTaskFromInteractive).toHaveBeenCalledWith( '/test/cwd', 'New feature request', 'default', + { issue: 226, confirmAtEndMessage: 'Add this issue to tasks?' }, ); }); - it('should skip createIssueAndSaveTask when not confirmed', async () => { + it('should skip confirmation and task save when issue creation fails', async () => { // Given mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); - mockConfirm.mockResolvedValue(false); + mockCreateIssueFromTask.mockReturnValue(undefined); // When await executeDefaultAction(); - // Then: task should not be added when user declines - expect(mockCreateIssueAndSaveTask).not.toHaveBeenCalled(); + // Then + expect(mockCreateIssueFromTask).toHaveBeenCalledWith('New feature request'); + expect(mockSaveTaskFromInteractive).not.toHaveBeenCalled(); }); it('should not call selectAndExecuteTask when create_issue action is chosen', async () => { // Given mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature request' }); - mockConfirm.mockResolvedValue(true); // When await executeDefaultAction(); diff --git a/src/__tests__/opencode-client-cleanup.test.ts b/src/__tests__/opencode-client-cleanup.test.ts new file mode 100644 index 00000000..58c0e545 --- /dev/null +++ b/src/__tests__/opencode-client-cleanup.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +class MockEventStream implements AsyncGenerator { + private index = 0; + private readonly events: unknown[]; + readonly returnSpy = vi.fn(async () => ({ done: true as const, value: undefined })); + + constructor(events: unknown[]) { + this.events = events; + } + + [Symbol.asyncIterator](): AsyncGenerator { + return this; + } + + async next(): Promise> { + if (this.index >= this.events.length) { + return { done: true, value: undefined }; + } + const value = this.events[this.index]; + this.index += 1; + return { done: false, value }; + } + + async return(): Promise> { + return this.returnSpy(); + } + + async throw(e?: unknown): Promise> { + throw e; + } +} + +class HangingAfterEventsStream implements AsyncGenerator { + private index = 0; + private closed = false; + private pendingResolve: ((value: IteratorResult) => void) | undefined; + readonly returnSpy = vi.fn(async () => { + this.closed = true; + this.pendingResolve?.({ done: true, value: undefined }); + return { done: true as const, value: undefined }; + }); + + constructor(private readonly events: unknown[]) {} + + [Symbol.asyncIterator](): AsyncGenerator { + return this; + } + + async next(): Promise> { + if (this.closed) { + return { done: true, value: undefined }; + } + if (this.index < this.events.length) { + const value = this.events[this.index]; + this.index += 1; + return { done: false, value }; + } + return new Promise>((resolve) => { + this.pendingResolve = resolve; + }); + } + + async return(): Promise> { + return this.returnSpy(); + } + + async throw(e?: unknown): Promise> { + throw e; + } +} + +const { createOpencodeMock } = vi.hoisted(() => ({ + createOpencodeMock: vi.fn(), +})); + +vi.mock('node:net', () => ({ + createServer: () => { + const handlers = new Map void>(); + return { + unref: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(event, handler); + }), + listen: vi.fn((_port: number, _host: string, cb: () => void) => { + cb(); + }), + address: vi.fn(() => ({ port: 62000 })), + close: vi.fn((cb?: (err?: Error) => void) => cb?.()), + }; + }, +})); + +vi.mock('@opencode-ai/sdk/v2', () => ({ + createOpencode: createOpencodeMock, +})); + +describe('OpenCodeClient stream cleanup', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should close SSE stream when session.idle is received', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'session.idle', + properties: { sessionID: 'session-1' }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-1' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + + const subscribe = vi.fn().mockResolvedValue({ stream }); + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('interactive', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + }); + + expect(result.status).toBe('done'); + expect(stream.returnSpy).toHaveBeenCalled(); + expect(disposeInstance).toHaveBeenCalledWith( + { directory: '/tmp' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(subscribe).toHaveBeenCalledWith( + { directory: '/tmp' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('should close SSE stream when session.error is received', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'session.error', + properties: { + sessionID: 'session-2', + error: { name: 'Error', data: { message: 'boom' } }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-2' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + + const subscribe = vi.fn().mockResolvedValue({ stream }); + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('interactive', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + }); + + expect(result.status).toBe('error'); + expect(result.content).toContain('boom'); + expect(stream.returnSpy).toHaveBeenCalled(); + expect(disposeInstance).toHaveBeenCalledWith( + { directory: '/tmp' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(subscribe).toHaveBeenCalledWith( + { directory: '/tmp' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('should complete without hanging when assistant message is completed', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new HangingAfterEventsStream([ + { + type: 'message.part.updated', + properties: { + part: { id: 'p-1', type: 'text', text: 'done' }, + delta: 'done', + }, + }, + { + type: 'message.updated', + properties: { + info: { + sessionID: 'session-3', + role: 'assistant', + time: { created: Date.now(), completed: Date.now() + 1 }, + }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-3' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + + const subscribe = vi.fn().mockResolvedValue({ stream }); + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await Promise.race([ + client.call('interactive', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), 500)), + ]); + + expect(result.status).toBe('done'); + expect(result.content).toBe('done'); + expect(disposeInstance).toHaveBeenCalledWith( + { directory: '/tmp' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(subscribe).toHaveBeenCalledWith( + { directory: '/tmp' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); +}); diff --git a/src/app/cli/index.ts b/src/app/cli/index.ts index 05653b59..c93b140d 100644 --- a/src/app/cli/index.ts +++ b/src/app/cli/index.ts @@ -35,6 +35,13 @@ import { executeDefaultAction } from './routing.js'; // Normal parsing for all other cases (including '#' prefixed inputs) await program.parseAsync(); + + // Some providers/SDKs may leave active handles even after command completion. + // Keep only watch mode as a long-running command; all others should exit explicitly. + const rootArg = process.argv.slice(2)[0]; + if (rootArg !== 'watch') { + process.exit(0); + } })().catch((err) => { console.error(err); process.exit(1); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 7073aebc..aae09e70 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -6,11 +6,10 @@ */ import { info, error, withProgress } from '../../shared/ui/index.js'; -import { confirm } from '../../shared/prompt/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { getLabel } from '../../shared/i18n/index.js'; import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js'; -import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueAndSaveTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; +import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; import { executePipeline } from '../../features/pipeline/index.js'; import { interactiveMode, @@ -205,8 +204,14 @@ export async function executeDefaultAction(task?: string): Promise { break; case 'create_issue': - if (await confirm('Add this issue to tasks?', true)) { - await createIssueAndSaveTask(resolvedCwd, result.task, pieceId); + { + const issueNumber = createIssueFromTask(result.task); + if (issueNumber !== undefined) { + await saveTaskFromInteractive(resolvedCwd, result.task, pieceId, { + issue: issueNumber, + confirmAtEndMessage: 'Add this issue to tasks?', + }); + } } break; diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 643c3e5c..cd48f402 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -149,9 +149,15 @@ export async function saveTaskFromInteractive( cwd: string, task: string, piece?: string, - options?: { issue?: number }, + options?: { issue?: number; confirmAtEndMessage?: string }, ): Promise { const settings = await promptWorktreeSettings(); + if (options?.confirmAtEndMessage) { + const approved = await confirm(options.confirmAtEndMessage, true); + if (!approved) { + return; + } + } const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings }); displayTaskCreationResult(created, settings, piece); } diff --git a/src/infra/opencode/OpenCodeStreamHandler.ts b/src/infra/opencode/OpenCodeStreamHandler.ts index dfd70d6a..421bb6fe 100644 --- a/src/infra/opencode/OpenCodeStreamHandler.ts +++ b/src/infra/opencode/OpenCodeStreamHandler.ts @@ -47,6 +47,14 @@ export interface OpenCodeSessionIdleEvent { properties: { sessionID: string }; } +export interface OpenCodeSessionStatusEvent { + type: 'session.status'; + properties: { + sessionID: string; + status: { type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number }; + }; +} + export interface OpenCodeSessionErrorEvent { type: 'session.error'; properties: { @@ -55,6 +63,18 @@ export interface OpenCodeSessionErrorEvent { }; } +export interface OpenCodeMessageUpdatedEvent { + type: 'message.updated'; + properties: { + info: { + sessionID: string; + role: 'assistant' | 'user'; + time?: { created?: number; completed?: number }; + error?: unknown; + }; + }; +} + export interface OpenCodePermissionAskedEvent { type: 'permission.asked'; properties: { @@ -69,6 +89,8 @@ export interface OpenCodePermissionAskedEvent { export type OpenCodeStreamEvent = | OpenCodeMessagePartUpdatedEvent + | OpenCodeMessageUpdatedEvent + | OpenCodeSessionStatusEvent | OpenCodeSessionIdleEvent | OpenCodeSessionErrorEvent | OpenCodePermissionAskedEvent diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index fa2690e4..2161c4d3 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -41,6 +41,23 @@ const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ 'failed to start server on port', ]; +function extractOpenCodeErrorMessage(error: unknown): string | undefined { + if (!error || typeof error !== 'object') { + return undefined; + } + const value = error as { message?: unknown; data?: { message?: unknown }; name?: unknown }; + if (typeof value.message === 'string' && value.message.length > 0) { + return value.message; + } + if (typeof value.data?.message === 'string' && value.data.message.length > 0) { + return value.data.message; + } + if (typeof value.name === 'string' && value.name.length > 0) { + return value.name; + } + return undefined; +} + function getCommonPrefixLength(a: string, b: string): number { const max = Math.min(a.length, b.length); let i = 0; @@ -149,6 +166,7 @@ export class OpenCodeClient { const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; let abortCause: 'timeout' | 'external' | undefined; let serverClose: (() => void) | undefined; + let opencodeApiClient: Awaited>['client'] | undefined; const resetIdleTimeout = (): void => { if (idleTimeoutId !== undefined) { @@ -196,6 +214,7 @@ export class OpenCodeClient { signal: streamAbortController.signal, config, }); + opencodeApiClient = client; serverClose = server.close; const sessionResult = options.sessionId @@ -206,16 +225,21 @@ export class OpenCodeClient { if (!sessionId) { throw new Error('Failed to create OpenCode session'); } - - const { stream } = await client.event.subscribe({ directory: options.cwd }); + const { stream } = await client.event.subscribe( + { directory: options.cwd }, + { signal: streamAbortController.signal }, + ); resetIdleTimeout(); - await client.session.promptAsync({ - sessionID: sessionId, - directory: options.cwd, - model: parsedModel, - parts: [{ type: 'text' as const, text: fullPrompt }], - }); + await client.session.promptAsync( + { + sessionID: sessionId, + directory: options.cwd, + model: parsedModel, + parts: [{ type: 'text' as const, text: fullPrompt }], + }, + { signal: streamAbortController.signal }, + ); emitInit(options.onStream, options.model, sessionId); @@ -232,7 +256,6 @@ export class OpenCodeClient { resetIdleTimeout(); const sseEvent = event as OpenCodeStreamEvent; - if (sseEvent.type === 'message.part.updated') { const props = sseEvent.properties as { part: OpenCodePart; delta?: string }; const part = props.part; @@ -279,6 +302,40 @@ export class OpenCodeClient { continue; } + if (sseEvent.type === 'message.updated') { + const messageProps = sseEvent.properties as { + info?: { + sessionID?: string; + role?: 'assistant' | 'user'; + time?: { completed?: number }; + error?: unknown; + }; + }; + const info = messageProps.info; + const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; + const isCompleted = typeof info?.time?.completed === 'number'; + if (isCurrentAssistantMessage && isCompleted) { + const streamError = extractOpenCodeErrorMessage(info.error); + if (streamError) { + success = false; + failureMessage = streamError; + } + break; + } + continue; + } + + if (sseEvent.type === 'session.status') { + const statusProps = sseEvent.properties as { + sessionID?: string; + status?: { type?: string }; + }; + if (statusProps.sessionID === sessionId && statusProps.status?.type === 'idle') { + break; + } + continue; + } + if (sseEvent.type === 'session.idle') { const idleProps = sseEvent.properties as { sessionID: string }; if (idleProps.sessionID === sessionId) { @@ -365,9 +422,28 @@ export class OpenCodeClient { if (options.abortSignal) { options.abortSignal.removeEventListener('abort', onExternalAbort); } + if (opencodeApiClient) { + const disposeAbortController = new AbortController(); + const disposeTimeoutId = setTimeout(() => { + disposeAbortController.abort(); + }, 3000); + try { + await opencodeApiClient.instance.dispose( + { directory: options.cwd }, + { signal: disposeAbortController.signal }, + ); + } catch { + // Ignore dispose errors during cleanup. + } finally { + clearTimeout(disposeTimeoutId); + } + } if (serverClose) { serverClose(); } + if (!streamAbortController.signal.aborted) { + streamAbortController.abort(); + } } } diff --git a/src/shared/prompt/confirm.ts b/src/shared/prompt/confirm.ts index bb823343..ac3bc4f7 100644 --- a/src/shared/prompt/confirm.ts +++ b/src/shared/prompt/confirm.ts @@ -9,6 +9,16 @@ import * as readline from 'node:readline'; import chalk from 'chalk'; import { resolveTtyPolicy, assertTtyIfForced } from './tty.js'; +function pauseStdinSafely(): void { + try { + if (process.stdin.readable && !process.stdin.destroyed) { + process.stdin.pause(); + } + } catch { + // Ignore stdin state errors during prompt cleanup. + } +} + /** * Prompt user for simple text input * @returns User input or null if cancelled @@ -27,6 +37,7 @@ export async function promptInput(message: string): Promise { return new Promise((resolve) => { rl.question(chalk.green(message + ': '), (answer) => { rl.close(); + pauseStdinSafely(); const trimmed = answer.trim(); if (!trimmed) { @@ -98,6 +109,7 @@ export async function confirm(message: string, defaultYes = true): Promise { rl.question(chalk.green(`${message} ${hint}: `), (answer) => { rl.close(); + pauseStdinSafely(); const trimmed = answer.trim().toLowerCase(); From 77cd485c2216182e2f678d88890f8c7b500ae46a Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:03:30 +0900 Subject: [PATCH 33/45] =?UTF-8?q?worktree=E3=81=AB=E3=82=BF=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E6=8C=87=E7=A4=BA=E6=9B=B8=E3=82=92=E3=82=B3=E3=83=94?= =?UTF-8?q?=E3=83=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/taskExecution.test.ts | 56 ++++++++++++++++++++- src/app/cli/index.ts | 2 - src/features/tasks/execute/resolveTask.ts | 40 +++++++++++++++ src/features/tasks/execute/taskExecution.ts | 16 +++++- src/shared/prompt/confirm.ts | 4 +- 5 files changed, 109 insertions(+), 9 deletions(-) diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 60d49d0b..382e00ca 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -2,6 +2,9 @@ * Tests for resolveTaskExecution */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock dependencies before importing the module under test @@ -522,7 +525,13 @@ describe('resolveTaskExecution', () => { expect(mockCreateSharedClone).not.toHaveBeenCalled(); }); - it('should return reportDirName from taskDir basename', async () => { + it('should stage task_dir spec into run context and return reportDirName', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-taskdir-normal-')); + const projectDir = path.join(tmpRoot, 'project'); + fs.mkdirSync(path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng'), { recursive: true }); + const sourceOrder = path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng', 'order.md'); + fs.writeFileSync(sourceOrder, '# normal task spec\n', 'utf-8'); + const task: TaskInfo = { name: 'task-with-dir', content: 'Task content', @@ -533,9 +542,15 @@ describe('resolveTaskExecution', () => { }, }; - const result = await resolveTaskExecution(task, '/project', 'default'); + const result = await resolveTaskExecution(task, projectDir, 'default'); expect(result.reportDirName).toBe('20260201-015714-foptng'); + expect(result.execCwd).toBe(projectDir); + const stagedOrder = path.join(projectDir, '.takt', 'runs', '20260201-015714-foptng', 'context', 'task', 'order.md'); + expect(fs.existsSync(stagedOrder)).toBe(true); + expect(fs.readFileSync(stagedOrder, 'utf-8')).toContain('normal task spec'); + expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260201-015714-foptng/context/task/order.md`.'); + expect(result.taskPrompt).not.toContain(projectDir); }); it('should throw when taskDir format is invalid', async () => { @@ -569,4 +584,41 @@ describe('resolveTaskExecution', () => { 'Invalid task_dir format: .takt/tasks/..', ); }); + + it('should stage task_dir spec into worktree run context and return run-scoped task prompt', async () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-taskdir-')); + const projectDir = path.join(tmpRoot, 'project'); + const cloneDir = path.join(tmpRoot, 'clone'); + fs.mkdirSync(path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng'), { recursive: true }); + fs.mkdirSync(cloneDir, { recursive: true }); + const sourceOrder = path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng', 'order.md'); + fs.writeFileSync(sourceOrder, '# webhook task\n', 'utf-8'); + + const task: TaskInfo = { + name: 'task-with-taskdir-worktree', + content: 'Task content', + taskDir: '.takt/tasks/20260201-015714-foptng', + filePath: '/tasks/task.yaml', + data: { + task: 'Task content', + worktree: true, + }, + }; + + mockSummarizeTaskName.mockResolvedValue('webhook-task'); + mockCreateSharedClone.mockReturnValue({ + path: cloneDir, + branch: 'takt/webhook-task', + }); + + const result = await resolveTaskExecution(task, projectDir, 'default'); + + const stagedOrder = path.join(cloneDir, '.takt', 'runs', '20260201-015714-foptng', 'context', 'task', 'order.md'); + expect(fs.existsSync(stagedOrder)).toBe(true); + expect(fs.readFileSync(stagedOrder, 'utf-8')).toContain('webhook task'); + + expect(result.taskPrompt).toContain('Implement using only the files in `.takt/runs/20260201-015714-foptng/context/task`.'); + expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260201-015714-foptng/context/task/order.md`.'); + expect(result.taskPrompt).not.toContain(projectDir); + }); }); diff --git a/src/app/cli/index.ts b/src/app/cli/index.ts index c93b140d..76394a99 100644 --- a/src/app/cli/index.ts +++ b/src/app/cli/index.ts @@ -36,8 +36,6 @@ import { executeDefaultAction } from './routing.js'; // Normal parsing for all other cases (including '#' prefixed inputs) await program.parseAsync(); - // Some providers/SDKs may leave active handles even after command completion. - // Keep only watch mode as a long-running command; all others should exit explicitly. const rootArg = process.argv.slice(2)[0]; if (rootArg !== 'watch') { process.exit(0); diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index a63fd252..e860e65f 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -2,6 +2,8 @@ * Resolve execution directory and piece from task data. */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { loadGlobalConfig } from '../../../infra/config/index.js'; import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; import { info, withProgress } from '../../../shared/ui/index.js'; @@ -11,6 +13,7 @@ export interface ResolvedTaskExecution { execCwd: string; execPiece: string; isWorktree: boolean; + taskPrompt?: string; reportDirName?: string; branch?: string; baseBranch?: string; @@ -20,6 +23,36 @@ export interface ResolvedTaskExecution { issueNumber?: number; } +function buildRunTaskDirInstruction(reportDirName: string): string { + const runTaskDir = `.takt/runs/${reportDirName}/context/task`; + const orderFile = `${runTaskDir}/order.md`; + return [ + `Implement using only the files in \`${runTaskDir}\`.`, + `Primary spec: \`${orderFile}\`.`, + 'Use report files in Report Directory as primary execution history.', + 'Do not rely on previous response or conversation summary.', + ].join('\n'); +} + +function stageTaskSpecForExecution( + projectCwd: string, + execCwd: string, + taskDir: string, + reportDirName: string, +): string { + const sourceOrderPath = path.join(projectCwd, taskDir, 'order.md'); + if (!fs.existsSync(sourceOrderPath)) { + throw new Error(`Task spec file is missing: ${sourceOrderPath}`); + } + + const targetTaskDir = path.join(execCwd, '.takt', 'runs', reportDirName, 'context', 'task'); + const targetOrderPath = path.join(targetTaskDir, 'order.md'); + fs.mkdirSync(targetTaskDir, { recursive: true }); + fs.copyFileSync(sourceOrderPath, targetOrderPath); + + return buildRunTaskDirInstruction(reportDirName); +} + function throwIfAborted(signal?: AbortSignal): void { if (signal?.aborted) { throw new Error('Task execution aborted'); @@ -47,6 +80,7 @@ export async function resolveTaskExecution( let execCwd = defaultCwd; let isWorktree = false; let reportDirName: string | undefined; + let taskPrompt: string | undefined; let branch: string | undefined; let baseBranch: string | undefined; if (task.taskDir) { @@ -81,6 +115,11 @@ export async function resolveTaskExecution( execCwd = result.path; branch = result.branch; isWorktree = true; + + } + + if (task.taskDir && reportDirName) { + taskPrompt = stageTaskSpecForExecution(defaultCwd, execCwd, task.taskDir, reportDirName); } const execPiece = data.piece || defaultPiece; @@ -99,6 +138,7 @@ export async function resolveTaskExecution( execCwd, execPiece, isWorktree, + ...(taskPrompt ? { taskPrompt } : {}), ...(reportDirName ? { reportDirName } : {}), ...(branch ? { branch } : {}), ...(baseBranch ? { baseBranch } : {}), diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 5c17e3a7..bdc50cb9 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -130,11 +130,23 @@ export async function executeAndCompleteTask( } try { - const { execCwd, execPiece, isWorktree, reportDirName, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal); + const { + execCwd, + execPiece, + isWorktree, + taskPrompt, + reportDirName, + branch, + baseBranch, + startMovement, + retryNote, + autoPr, + issueNumber, + } = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal); // cwd is always the project root; pass it as projectCwd so reports/sessions go there const taskRunResult = await executeTaskWithResult({ - task: task.content, + task: taskPrompt ?? task.content, cwd: execCwd, pieceIdentifier: execPiece, projectCwd: cwd, diff --git a/src/shared/prompt/confirm.ts b/src/shared/prompt/confirm.ts index ac3bc4f7..663cde82 100644 --- a/src/shared/prompt/confirm.ts +++ b/src/shared/prompt/confirm.ts @@ -14,9 +14,7 @@ function pauseStdinSafely(): void { if (process.stdin.readable && !process.stdin.destroyed) { process.stdin.pause(); } - } catch { - // Ignore stdin state errors during prompt cleanup. - } + } catch {} } /** From fc1dfcc3c0b5382d095c701a2f03784cb5ba5b5f Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:08:23 +0900 Subject: [PATCH 34/45] =?UTF-8?q?opencode=20=E3=81=AE=20question=20?= =?UTF-8?q?=E3=82=92=E6=8A=91=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/interactive.test.ts | 28 +++++ src/__tests__/opencode-client-cleanup.test.ts | 111 ++++++++++++++++++ src/features/interactive/conversationLoop.ts | 20 ++++ src/infra/opencode/OpenCodeStreamHandler.ts | 18 +++ src/infra/opencode/client.ts | 88 ++++++++++++++ src/infra/opencode/types.ts | 2 + src/infra/providers/opencode.ts | 1 + 7 files changed, 268 insertions(+) diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index 1419be9b..76e29e5f 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -387,6 +387,34 @@ describe('interactiveMode', () => { ); }); + it('should abort in-flight provider call on SIGINT during initial input', async () => { + mockGetProvider.mockReturnValue({ + setup: () => ({ + call: vi.fn((_prompt: string, options: { abortSignal?: AbortSignal }) => { + return new Promise((resolve) => { + options.abortSignal?.addEventListener('abort', () => { + resolve({ + persona: 'interactive', + status: 'error', + content: 'aborted', + timestamp: new Date(), + }); + }, { once: true }); + }); + }), + }), + } as unknown as ReturnType); + + const promise = interactiveMode('/project', 'trigger'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const listeners = process.rawListeners('SIGINT') as Array<() => void>; + listeners[listeners.length - 1]?.(); + + const result = await promise; + expect(result.action).toBe('cancel'); + }); + it('should use saved sessionId from initializeSession when no sessionId parameter is given', async () => { // Given setupRawStdin(toRawInputs(['hello', '/cancel'])); diff --git a/src/__tests__/opencode-client-cleanup.test.ts b/src/__tests__/opencode-client-cleanup.test.ts index 58c0e545..30816824 100644 --- a/src/__tests__/opencode-client-cleanup.test.ts +++ b/src/__tests__/opencode-client-cleanup.test.ts @@ -245,4 +245,115 @@ describe('OpenCodeClient stream cleanup', () => { expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); + + it('should fail fast when question.asked is received without handler', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'question.asked', + properties: { + id: 'q-1', + sessionID: 'session-4', + questions: [ + { + question: 'Select one', + header: 'Question', + options: [{ label: 'A', description: 'A desc' }], + }, + ], + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-4' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const questionReject = vi.fn().mockResolvedValue({ data: true }); + + const subscribe = vi.fn().mockResolvedValue({ stream }); + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + question: { reject: questionReject, reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('interactive', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + }); + + expect(result.status).toBe('error'); + expect(result.content).toContain('no question handler'); + expect(questionReject).toHaveBeenCalledWith({ + requestID: 'q-1', + directory: '/tmp', + }); + }); + + it('should answer question.asked when handler is configured', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'question.asked', + properties: { + id: 'q-2', + sessionID: 'session-5', + questions: [ + { + question: 'Select one', + header: 'Question', + options: [{ label: 'A', description: 'A desc' }], + }, + ], + }, + }, + { + type: 'message.updated', + properties: { + info: { + sessionID: 'session-5', + role: 'assistant', + time: { created: Date.now(), completed: Date.now() + 1 }, + }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-5' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const questionReply = vi.fn().mockResolvedValue({ data: true }); + + const subscribe = vi.fn().mockResolvedValue({ stream }); + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + question: { reject: vi.fn(), reply: questionReply }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('interactive', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + onAskUserQuestion: async () => ({ Question: 'A' }), + }); + + expect(result.status).toBe('done'); + expect(questionReply).toHaveBeenCalledWith({ + requestID: 'q-2', + directory: '/tmp', + answers: [['A']], + }); + }); }); diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index 1819ceb4..156862fc 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -22,6 +22,7 @@ import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; import { readMultilineInput } from './lineEditor.js'; +import { EXIT_SIGINT } from '../../shared/exitCodes.js'; import { type PieceContext, type InteractiveModeResult, @@ -97,6 +98,21 @@ export async function callAIWithRetry( ctx: SessionContext, ): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> { const display = new StreamDisplay('assistant', isQuietMode()); + const abortController = new AbortController(); + let sigintCount = 0; + const onSigInt = (): void => { + sigintCount += 1; + if (sigintCount === 1) { + blankLine(); + info(getLabel('piece.sigintGraceful', ctx.lang)); + abortController.abort(); + return; + } + blankLine(); + error(getLabel('piece.sigintForce', ctx.lang)); + process.exit(EXIT_SIGINT); + }; + process.on('SIGINT', onSigInt); let { sessionId } = ctx; try { @@ -106,6 +122,7 @@ export async function callAIWithRetry( model: ctx.model, sessionId, allowedTools, + abortSignal: abortController.signal, onStream: display.createHandler(), }); display.flush(); @@ -121,6 +138,7 @@ export async function callAIWithRetry( model: ctx.model, sessionId: undefined, allowedTools, + abortSignal: abortController.signal, onStream: retryDisplay.createHandler(), }); retryDisplay.flush(); @@ -148,6 +166,8 @@ export async function callAIWithRetry( error(msg); blankLine(); return { result: null, sessionId }; + } finally { + process.removeListener('SIGINT', onSigInt); } } diff --git a/src/infra/opencode/OpenCodeStreamHandler.ts b/src/infra/opencode/OpenCodeStreamHandler.ts index 421bb6fe..f7c0e826 100644 --- a/src/infra/opencode/OpenCodeStreamHandler.ts +++ b/src/infra/opencode/OpenCodeStreamHandler.ts @@ -87,6 +87,23 @@ export interface OpenCodePermissionAskedEvent { }; } +export interface OpenCodeQuestionAskedEvent { + type: 'question.asked'; + properties: { + id: string; + sessionID: string; + questions: Array<{ + question: string; + header: string; + options: Array<{ + label: string; + description: string; + }>; + multiple?: boolean; + }>; + }; +} + export type OpenCodeStreamEvent = | OpenCodeMessagePartUpdatedEvent | OpenCodeMessageUpdatedEvent @@ -94,6 +111,7 @@ export type OpenCodeStreamEvent = | OpenCodeSessionIdleEvent | OpenCodeSessionErrorEvent | OpenCodePermissionAskedEvent + | OpenCodeQuestionAskedEvent | { type: string; properties: Record }; /** Tracking state for stream offsets during a single OpenCode session */ diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index 2161c4d3..3d266fdd 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -83,6 +83,60 @@ function stripPromptEcho( return chunk; } +type OpenCodeQuestionOption = { + label: string; + description: string; +}; + +type OpenCodeQuestionInfo = { + question: string; + header: string; + options: OpenCodeQuestionOption[]; + multiple?: boolean; +}; + +type OpenCodeQuestionAskedProperties = { + id: string; + sessionID: string; + questions: OpenCodeQuestionInfo[]; +}; + +function toQuestionInput(props: OpenCodeQuestionAskedProperties): { + questions: Array<{ + question: string; + header?: string; + options?: Array<{ + label: string; + description?: string; + }>; + multiSelect?: boolean; + }>; +} { + return { + questions: props.questions.map((item) => ({ + question: item.question, + header: item.header, + options: item.options.map((opt) => ({ + label: opt.label, + description: opt.description, + })), + multiSelect: item.multiple, + })), + }; +} + +function toQuestionAnswers( + props: OpenCodeQuestionAskedProperties, + answers: Record, +): Array> { + return props.questions.map((item) => { + const key = item.header || item.question; + const value = answers[key]; + if (!value) return []; + return [value]; + }); +} + async function getFreePort(): Promise { return new Promise((resolve, reject) => { const server = createServer(); @@ -205,6 +259,7 @@ export class OpenCodeClient { const config = { model: fullModel, small_model: fullModel, + permission: { question: 'deny' as const }, ...(options.opencodeApiKey ? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } : {}), @@ -302,6 +357,39 @@ export class OpenCodeClient { continue; } + if (sseEvent.type === 'question.asked') { + const questionProps = sseEvent.properties as OpenCodeQuestionAskedProperties; + if (questionProps.sessionID === sessionId) { + if (!options.onAskUserQuestion) { + await client.question.reject({ + requestID: questionProps.id, + directory: options.cwd, + }); + success = false; + failureMessage = 'OpenCode asked a question, but no question handler is configured'; + break; + } + + try { + const answers = await options.onAskUserQuestion(toQuestionInput(questionProps)); + await client.question.reply({ + requestID: questionProps.id, + directory: options.cwd, + answers: toQuestionAnswers(questionProps, answers), + }); + } catch { + await client.question.reject({ + requestID: questionProps.id, + directory: options.cwd, + }); + success = false; + failureMessage = 'OpenCode question handling failed'; + break; + } + } + continue; + } + if (sseEvent.type === 'message.updated') { const messageProps = sseEvent.properties as { info?: { diff --git a/src/infra/opencode/types.ts b/src/infra/opencode/types.ts index ae3976f8..d25816d7 100644 --- a/src/infra/opencode/types.ts +++ b/src/infra/opencode/types.ts @@ -3,6 +3,7 @@ */ import type { StreamCallback } from '../claude/index.js'; +import type { AskUserQuestionHandler } from '../../core/piece/types.js'; import type { PermissionMode } from '../../core/models/index.js'; /** OpenCode permission reply values */ @@ -29,6 +30,7 @@ export interface OpenCodeCallOptions { permissionMode?: PermissionMode; /** Enable streaming mode with callback (best-effort) */ onStream?: StreamCallback; + onAskUserQuestion?: AskUserQuestionHandler; /** OpenCode API key */ opencodeApiKey?: string; } diff --git a/src/infra/providers/opencode.ts b/src/infra/providers/opencode.ts index aa026807..d5df0aa4 100644 --- a/src/infra/providers/opencode.ts +++ b/src/infra/providers/opencode.ts @@ -19,6 +19,7 @@ function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions { model: options.model, permissionMode: options.permissionMode, onStream: options.onStream, + onAskUserQuestion: options.onAskUserQuestion, opencodeApiKey: options.opencodeApiKey ?? resolveOpencodeApiKey(), }; } From 69bd77ab620a2c2dfbca663e83bfe3d5af97202f Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:38:03 +0900 Subject: [PATCH 35/45] =?UTF-8?q?Provider=20=E3=81=8A=E3=82=88=E3=81=B3?= =?UTF-8?q?=E3=83=A2=E3=83=87=E3=83=AB=E5=90=8D=E3=82=92=E5=87=BA=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/it-sigint-interrupt.test.ts | 31 ++++++++++++++++++- .../pieceExecution-session-loading.test.ts | 30 ++++++++++++++++++ src/core/piece/engine/OptionsBuilder.ts | 11 +++++-- src/core/piece/provider-resolution.ts | 24 ++++++++++++++ src/features/tasks/execute/pieceExecution.ts | 23 ++++++++++++-- 5 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 src/core/piece/provider-resolution.ts diff --git a/src/__tests__/it-sigint-interrupt.test.ts b/src/__tests__/it-sigint-interrupt.test.ts index 576600a8..28abafec 100644 --- a/src/__tests__/it-sigint-interrupt.test.ts +++ b/src/__tests__/it-sigint-interrupt.test.ts @@ -27,14 +27,18 @@ const { mockInterruptAllQueries, MockPieceEngine } = vi.hoisted(() => { class MockPieceEngine extends EE { private abortRequested = false; private runResolve: ((value: { status: string; iteration: number }) => void) | null = null; + static lastOptions: { abortSignal?: AbortSignal } | null = null; constructor( _config: unknown, _cwd: string, _task: string, - _options: unknown, + options: unknown, ) { super(); + if (options && typeof options === 'object') { + MockPieceEngine.lastOptions = options as { abortSignal?: AbortSignal }; + } } abort(): void { @@ -170,6 +174,7 @@ describe('executePiece: SIGINT handler integration', () => { beforeEach(() => { vi.clearAllMocks(); + MockPieceEngine.lastOptions = null; tmpDir = join(tmpdir(), `takt-sigint-it-${randomUUID()}`); mkdirSync(tmpDir, { recursive: true }); mkdirSync(join(tmpDir, '.takt', 'reports'), { recursive: true }); @@ -243,6 +248,30 @@ describe('executePiece: SIGINT handler integration', () => { expect(result.success).toBe(false); }); + it('should abort provider signal on first SIGINT', async () => { + const config = makeConfig(); + + const resultPromise = executePiece(config, 'test task', tmpDir, { + projectCwd: tmpDir, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const signal = MockPieceEngine.lastOptions?.abortSignal; + expect(signal).toBeDefined(); + expect(signal!.aborted).toBe(false); + + const allListeners = process.rawListeners('SIGINT') as ((...args: unknown[]) => void)[]; + const newListener = allListeners.find((l) => !savedSigintListeners.includes(l)); + expect(newListener).toBeDefined(); + newListener!(); + + expect(signal!.aborted).toBe(true); + + const result = await resultPromise; + expect(result.success).toBe(false); + }); + it('should register EPIPE handler before calling interruptAllQueries', async () => { const config = makeConfig(); diff --git a/src/__tests__/pieceExecution-session-loading.test.ts b/src/__tests__/pieceExecution-session-loading.test.ts index e09d0d87..92ff51e5 100644 --- a/src/__tests__/pieceExecution-session-loading.test.ts +++ b/src/__tests__/pieceExecution-session-loading.test.ts @@ -18,9 +18,11 @@ const { MockPieceEngine, mockLoadPersonaSessions, mockLoadWorktreeSessions } = v class MockPieceEngine extends EE { static lastInstance: MockPieceEngine; readonly receivedOptions: Record; + private readonly config: PieceConfig; constructor(config: PieceConfig, _cwd: string, _task: string, options: Record) { super(); + this.config = config; this.receivedOptions = options; MockPieceEngine.lastInstance = this; } @@ -28,6 +30,10 @@ const { MockPieceEngine, mockLoadPersonaSessions, mockLoadWorktreeSessions } = v abort(): void {} async run(): Promise<{ status: string; iteration: number }> { + const firstStep = this.config.movements[0]; + if (firstStep) { + this.emit('movement:start', firstStep, 1, firstStep.instructionTemplate); + } this.emit('piece:complete', { status: 'completed', iteration: 1 }); return { status: 'completed', iteration: 1 }; } @@ -124,6 +130,7 @@ vi.mock('../shared/exitCodes.js', () => ({ })); import { executePiece } from '../features/tasks/execute/pieceExecution.js'; +import { info } from '../shared/ui/index.js'; function makeConfig(): PieceConfig { return { @@ -218,4 +225,27 @@ describe('executePiece session loading', () => { // Then: sessions are loaded expect(mockLoadPersonaSessions).toHaveBeenCalledWith('/tmp/project', 'claude'); }); + + it('should log provider and model per movement with global defaults', async () => { + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + }); + + const mockInfo = vi.mocked(info); + expect(mockInfo).toHaveBeenCalledWith('Provider: claude'); + expect(mockInfo).toHaveBeenCalledWith('Model: (default)'); + }); + + it('should log provider and model per movement with overrides', async () => { + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + provider: 'codex', + model: 'gpt-5', + personaProviders: { coder: 'opencode' }, + }); + + const mockInfo = vi.mocked(info); + expect(mockInfo).toHaveBeenCalledWith('Provider: opencode'); + expect(mockInfo).toHaveBeenCalledWith('Model: gpt-5'); + }); }); diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index bec67a40..8fe68c31 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -11,6 +11,7 @@ import type { RunAgentOptions } from '../../../agents/runner.js'; import type { PhaseRunnerContext } from '../phase-runner.js'; import type { PieceEngineOptions, PhaseName } from '../types.js'; import { buildSessionKey } from '../session-key.js'; +import { resolveMovementProviderModel } from '../provider-resolution.js'; export class OptionsBuilder { constructor( @@ -30,13 +31,19 @@ export class OptionsBuilder { const movements = this.getPieceMovements(); const currentIndex = movements.findIndex((m) => m.name === step.name); const currentPosition = currentIndex >= 0 ? `${currentIndex + 1}/${movements.length}` : '?/?'; + const resolved = resolveMovementProviderModel({ + step, + provider: this.engineOptions.provider, + model: this.engineOptions.model, + personaProviders: this.engineOptions.personaProviders, + }); return { cwd: this.getCwd(), abortSignal: this.engineOptions.abortSignal, personaPath: step.personaPath, - provider: step.provider ?? this.engineOptions.personaProviders?.[step.personaDisplayName] ?? this.engineOptions.provider, - model: step.model ?? this.engineOptions.model, + provider: resolved.provider, + model: resolved.model, permissionMode: step.permissionMode, language: this.getLanguage(), onStream: this.engineOptions.onStream, diff --git a/src/core/piece/provider-resolution.ts b/src/core/piece/provider-resolution.ts new file mode 100644 index 00000000..6561c800 --- /dev/null +++ b/src/core/piece/provider-resolution.ts @@ -0,0 +1,24 @@ +import type { PieceMovement } from '../models/types.js'; + +export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; + +export interface MovementProviderModelInput { + step: Pick; + provider?: ProviderType; + model?: string; + personaProviders?: Record; +} + +export interface MovementProviderModelOutput { + provider?: ProviderType; + model?: string; +} + +export function resolveMovementProviderModel(input: MovementProviderModelInput): MovementProviderModelOutput { + return { + provider: input.step.provider + ?? input.personaProviders?.[input.step.personaDisplayName] + ?? input.provider, + model: input.step.model ?? input.model, + }; +} diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index ba3d19c4..4b58272c 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -63,6 +63,7 @@ import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { getLabel } from '../../../shared/i18n/index.js'; import { installSigIntHandler } from './sigintHandler.js'; import { buildRunPaths } from '../../../core/piece/run/run-paths.js'; +import { resolveMovementProviderModel } from '../../../core/piece/provider-resolution.js'; import { writeFileAtomic, ensureDir } from '../../../infra/config/index.js'; const log = createLogger('piece'); @@ -396,10 +397,11 @@ export async function executePiece( let onAbortSignal: (() => void) | undefined; let sigintCleanup: (() => void) | undefined; let onEpipe: ((err: NodeJS.ErrnoException) => void) | undefined; + const runAbortController = new AbortController(); try { engine = new PieceEngine(pieceConfig, cwd, task, { - abortSignal: options.abortSignal, + abortSignal: runAbortController.signal, onStream: streamHandler, onUserInput, initialSessions: savedSessions, @@ -482,6 +484,16 @@ export async function executePiece( movementIteration, }); out.info(`[${iteration}/${pieceConfig.maxMovements}] ${step.name} (${step.personaDisplayName})`); + const resolved = resolveMovementProviderModel({ + step, + provider: options.provider, + model: options.model, + personaProviders: options.personaProviders, + }); + const movementProvider = resolved.provider ?? currentProvider; + const movementModel = resolved.model ?? globalConfig.model ?? '(default)'; + out.info(`Provider: ${movementProvider}`); + out.info(`Model: ${movementModel}`); // Log prompt content for debugging if (instruction) { @@ -686,6 +698,9 @@ export async function executePiece( if (!engine || !onEpipe) { throw new Error('Abort handler invoked before PieceEngine initialization'); } + if (!runAbortController.signal.aborted) { + runAbortController.abort(); + } process.on('uncaughtException', onEpipe); interruptAllQueries(); engine.abort(); @@ -695,7 +710,11 @@ export async function executePiece( const useExternalAbort = Boolean(options.abortSignal); if (useExternalAbort) { onAbortSignal = abortEngine; - options.abortSignal!.addEventListener('abort', onAbortSignal, { once: true }); + if (options.abortSignal!.aborted) { + abortEngine(); + } else { + options.abortSignal!.addEventListener('abort', onAbortSignal, { once: true }); + } } else { const handler = installSigIntHandler(abortEngine); sigintCleanup = handler.cleanup; From 15fc6875e26b94deb4f588cd716ab9be20b3195c Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:03:00 +0900 Subject: [PATCH 36/45] fix: lint errors in merge/resolveTask/confirm --- src/core/piece/arpeggio/merge.ts | 2 +- src/features/tasks/execute/resolveTask.ts | 2 +- src/shared/prompt/confirm.ts | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/piece/arpeggio/merge.ts b/src/core/piece/arpeggio/merge.ts index 6636b6e6..7ea25d34 100644 --- a/src/core/piece/arpeggio/merge.ts +++ b/src/core/piece/arpeggio/merge.ts @@ -7,7 +7,7 @@ */ import { writeFileSync } from 'node:fs'; -import type { ArpeggioMergeMovementConfig, BatchResult, MergeFn } from './types.js'; +import type { ArpeggioMergeMovementConfig, MergeFn } from './types.js'; /** Create a concat merge function with the given separator */ function createConcatMerge(separator: string): MergeFn { diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index e860e65f..43d1e11d 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -6,7 +6,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { loadGlobalConfig } from '../../../infra/config/index.js'; import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; -import { info, withProgress } from '../../../shared/ui/index.js'; +import { withProgress } from '../../../shared/ui/index.js'; import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js'; export interface ResolvedTaskExecution { diff --git a/src/shared/prompt/confirm.ts b/src/shared/prompt/confirm.ts index 663cde82..8f516682 100644 --- a/src/shared/prompt/confirm.ts +++ b/src/shared/prompt/confirm.ts @@ -14,7 +14,9 @@ function pauseStdinSafely(): void { if (process.stdin.readable && !process.stdin.destroyed) { process.stdin.pause(); } - } catch {} + } catch { + return; + } } /** From ccca0949ae1e7fa38c92e24a861c20e94b07f5c1 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:31:38 +0900 Subject: [PATCH 37/45] fix: opencode permission and tool wiring for edit execution --- src/__tests__/opencode-client-cleanup.test.ts | 207 +++++++++++++++++- src/__tests__/opencode-types.test.ts | 56 ++++- src/infra/opencode/client.ts | 96 ++++++-- src/infra/opencode/types.ts | 134 ++++++++++++ src/infra/providers/opencode.ts | 1 + 5 files changed, 465 insertions(+), 29 deletions(-) diff --git a/src/__tests__/opencode-client-cleanup.test.ts b/src/__tests__/opencode-client-cleanup.test.ts index 30816824..673398e1 100644 --- a/src/__tests__/opencode-client-cleanup.test.ts +++ b/src/__tests__/opencode-client-cleanup.test.ts @@ -290,10 +290,13 @@ describe('OpenCodeClient stream cleanup', () => { expect(result.status).toBe('error'); expect(result.content).toContain('no question handler'); - expect(questionReject).toHaveBeenCalledWith({ - requestID: 'q-1', - directory: '/tmp', - }); + expect(questionReject).toHaveBeenCalledWith( + { + requestID: 'q-1', + directory: '/tmp', + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); }); it('should answer question.asked when handler is configured', async () => { @@ -350,10 +353,200 @@ describe('OpenCodeClient stream cleanup', () => { }); expect(result.status).toBe('done'); - expect(questionReply).toHaveBeenCalledWith({ - requestID: 'q-2', + expect(questionReply).toHaveBeenCalledWith( + { + requestID: 'q-2', + directory: '/tmp', + answers: [['A']], + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('should pass mapped tools to promptAsync when allowedTools is set', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'message.updated', + properties: { + info: { + sessionID: 'session-tools', + role: 'assistant', + time: { created: Date.now(), completed: Date.now() + 1 }, + }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-tools' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const subscribe = vi.fn().mockResolvedValue({ stream }); + + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + const result = await client.call('coder', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + allowedTools: ['Read', 'Edit', 'Bash', 'WebSearch', 'WebFetch', 'mcp__github__search'], + }); + + expect(result.status).toBe('done'); + expect(promptAsync).toHaveBeenCalledWith( + expect.objectContaining({ + tools: { + read: true, + edit: true, + bash: true, + websearch: true, + webfetch: true, + mcp__github__search: true, + }, + }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('should configure allow permissions for edit mode', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'message.updated', + properties: { + info: { + sessionID: 'session-perm', + role: 'assistant', + time: { created: Date.now(), completed: Date.now() + 1 }, + }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-perm' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const subscribe = vi.fn().mockResolvedValue({ stream }); + + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + await client.call('coder', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + permissionMode: 'edit', + }); + + const createCallArgs = createOpencodeMock.mock.calls[0]?.[0] as { config?: Record }; + const permission = createCallArgs.config?.permission as Record; + expect(permission.read).toBe('allow'); + expect(permission.edit).toBe('allow'); + expect(permission.write).toBe('allow'); + expect(permission.bash).toBe('allow'); + expect(permission.question).toBe('deny'); + }); + + it('should pass permission ruleset to session.create', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'message.updated', + properties: { + info: { + sessionID: 'session-ruleset', + role: 'assistant', + time: { created: Date.now(), completed: Date.now() + 1 }, + }, + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-ruleset' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const subscribe = vi.fn().mockResolvedValue({ stream }); + + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: vi.fn() }, + }, + server: { close: vi.fn() }, + }); + + const client = new OpenCodeClient(); + await client.call('coder', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + permissionMode: 'edit', + }); + + expect(sessionCreate).toHaveBeenCalledWith(expect.objectContaining({ directory: '/tmp', - answers: [['A']], + permission: expect.arrayContaining([ + expect.objectContaining({ permission: 'edit', action: 'allow' }), + expect.objectContaining({ permission: 'question', action: 'deny' }), + ]), + })); + }); + + it('should fail fast when permission reply times out', async () => { + const { OpenCodeClient } = await import('../infra/opencode/client.js'); + const stream = new MockEventStream([ + { + type: 'permission.asked', + properties: { + id: 'perm-1', + sessionID: 'session-perm-timeout', + }, + }, + ]); + + const promptAsync = vi.fn().mockResolvedValue(undefined); + const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-perm-timeout' } }); + const disposeInstance = vi.fn().mockResolvedValue({ data: {} }); + const subscribe = vi.fn().mockResolvedValue({ stream }); + const permissionReply = vi.fn().mockImplementation(() => new Promise(() => {})); + + createOpencodeMock.mockResolvedValue({ + client: { + instance: { dispose: disposeInstance }, + session: { create: sessionCreate, promptAsync }, + event: { subscribe }, + permission: { reply: permissionReply }, + }, + server: { close: vi.fn() }, }); + + const client = new OpenCodeClient(); + const result = await Promise.race([ + client.call('coder', 'hello', { + cwd: '/tmp', + model: 'opencode/big-pickle', + permissionMode: 'edit', + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), 8000)), + ]); + + expect(result.status).toBe('error'); + expect(result.content).toContain('permission reply timed out'); }); }); diff --git a/src/__tests__/opencode-types.test.ts b/src/__tests__/opencode-types.test.ts index 6251b8d0..e8ad9b3f 100644 --- a/src/__tests__/opencode-types.test.ts +++ b/src/__tests__/opencode-types.test.ts @@ -3,7 +3,12 @@ */ import { describe, it, expect } from 'vitest'; -import { mapToOpenCodePermissionReply } from '../infra/opencode/types.js'; +import { + buildOpenCodePermissionConfig, + buildOpenCodePermissionRuleset, + mapToOpenCodePermissionReply, + mapToOpenCodeTools, +} from '../infra/opencode/types.js'; import type { PermissionMode } from '../core/models/index.js'; describe('mapToOpenCodePermissionReply', () => { @@ -28,3 +33,52 @@ describe('mapToOpenCodePermissionReply', () => { }); }); }); + +describe('mapToOpenCodeTools', () => { + it('should map built-in tool names to OpenCode tool IDs', () => { + expect(mapToOpenCodeTools(['Read', 'Edit', 'Bash', 'WebSearch', 'WebFetch'])).toEqual({ + read: true, + edit: true, + bash: true, + websearch: true, + webfetch: true, + }); + }); + + it('should keep unknown tool names as-is', () => { + expect(mapToOpenCodeTools(['mcp__github__search', 'custom_tool'])).toEqual({ + mcp__github__search: true, + custom_tool: true, + }); + }); + + it('should return undefined when tools are not provided', () => { + expect(mapToOpenCodeTools(undefined)).toBeUndefined(); + expect(mapToOpenCodeTools([])).toBeUndefined(); + }); +}); + +describe('OpenCode permissions', () => { + it('should build allow config for full mode', () => { + expect(buildOpenCodePermissionConfig('full')).toBe('allow'); + }); + + it('should build deny config for readonly mode', () => { + expect(buildOpenCodePermissionConfig('readonly')).toBe('deny'); + }); + + it('should build ruleset for edit mode', () => { + const ruleset = buildOpenCodePermissionRuleset('edit'); + expect(ruleset.length).toBeGreaterThan(0); + expect(ruleset.find((rule) => rule.permission === 'edit')).toEqual({ + permission: 'edit', + pattern: '**', + action: 'allow', + }); + expect(ruleset.find((rule) => rule.permission === 'question')).toEqual({ + permission: 'question', + pattern: '**', + action: 'deny', + }); + }); +}); diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index 3d266fdd..cb6271b0 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -10,7 +10,13 @@ import { createServer } from 'node:net'; import type { AgentResponse } from '../../core/models/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { parseProviderModel } from '../../shared/utils/providerModel.js'; -import { mapToOpenCodePermissionReply, type OpenCodeCallOptions } from './types.js'; +import { + buildOpenCodePermissionConfig, + buildOpenCodePermissionRuleset, + mapToOpenCodePermissionReply, + mapToOpenCodeTools, + type OpenCodeCallOptions, +} from './types.js'; import { type OpenCodeStreamEvent, type OpenCodePart, @@ -29,6 +35,7 @@ const OPENCODE_STREAM_IDLE_TIMEOUT_MS = 10 * 60 * 1000; const OPENCODE_STREAM_ABORTED_MESSAGE = 'OpenCode execution aborted'; const OPENCODE_RETRY_MAX_ATTEMPTS = 3; const OPENCODE_RETRY_BASE_DELAY_MS = 250; +const OPENCODE_INTERACTION_TIMEOUT_MS = 5000; const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ 'stream disconnected before completion', 'transport error', @@ -41,6 +48,31 @@ const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ 'failed to start server on port', ]; +async function withTimeout( + operation: (signal: AbortSignal) => Promise, + timeoutMs: number, + timeoutErrorMessage: string, +): Promise { + const controller = new AbortController(); + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + controller.abort(); + reject(new Error(timeoutErrorMessage)); + }, timeoutMs); + }); + try { + return await Promise.race([ + operation(controller.signal), + timeoutPromise, + ]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } +} + function extractOpenCodeErrorMessage(error: unknown): string | undefined { if (!error || typeof error !== 'object') { return undefined; @@ -256,10 +288,11 @@ export class OpenCodeClient { const parsedModel = parseProviderModel(options.model, 'OpenCode model'); const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`; const port = await getFreePort(); + const permission = buildOpenCodePermissionConfig(options.permissionMode); const config = { model: fullModel, small_model: fullModel, - permission: { question: 'deny' as const }, + permission, ...(options.opencodeApiKey ? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } : {}), @@ -274,7 +307,10 @@ export class OpenCodeClient { const sessionResult = options.sessionId ? { data: { id: options.sessionId } } - : await client.session.create({ directory: options.cwd }); + : await client.session.create({ + directory: options.cwd, + permission: buildOpenCodePermissionRuleset(options.permissionMode), + }); const sessionId = sessionResult.data?.id; if (!sessionId) { @@ -286,11 +322,13 @@ export class OpenCodeClient { ); resetIdleTimeout(); + const tools = mapToOpenCodeTools(options.allowedTools); await client.session.promptAsync( { sessionID: sessionId, directory: options.cwd, model: parsedModel, + ...(tools ? { tools } : {}), parts: [{ type: 'text' as const, text: fullPrompt }], }, { signal: streamAbortController.signal }, @@ -348,11 +386,15 @@ export class OpenCodeClient { const reply = options.permissionMode ? mapToOpenCodePermissionReply(options.permissionMode) : 'once'; - await client.permission.reply({ - requestID: permProps.id, - directory: options.cwd, - reply, - }); + await withTimeout( + (signal) => client.permission.reply({ + requestID: permProps.id, + directory: options.cwd, + reply, + }, { signal }), + OPENCODE_INTERACTION_TIMEOUT_MS, + 'OpenCode permission reply timed out', + ); } continue; } @@ -361,10 +403,14 @@ export class OpenCodeClient { const questionProps = sseEvent.properties as OpenCodeQuestionAskedProperties; if (questionProps.sessionID === sessionId) { if (!options.onAskUserQuestion) { - await client.question.reject({ - requestID: questionProps.id, - directory: options.cwd, - }); + await withTimeout( + (signal) => client.question.reject({ + requestID: questionProps.id, + directory: options.cwd, + }, { signal }), + OPENCODE_INTERACTION_TIMEOUT_MS, + 'OpenCode question reject timed out', + ); success = false; failureMessage = 'OpenCode asked a question, but no question handler is configured'; break; @@ -372,16 +418,24 @@ export class OpenCodeClient { try { const answers = await options.onAskUserQuestion(toQuestionInput(questionProps)); - await client.question.reply({ - requestID: questionProps.id, - directory: options.cwd, - answers: toQuestionAnswers(questionProps, answers), - }); + await withTimeout( + (signal) => client.question.reply({ + requestID: questionProps.id, + directory: options.cwd, + answers: toQuestionAnswers(questionProps, answers), + }, { signal }), + OPENCODE_INTERACTION_TIMEOUT_MS, + 'OpenCode question reply timed out', + ); } catch { - await client.question.reject({ - requestID: questionProps.id, - directory: options.cwd, - }); + await withTimeout( + (signal) => client.question.reject({ + requestID: questionProps.id, + directory: options.cwd, + }, { signal }), + OPENCODE_INTERACTION_TIMEOUT_MS, + 'OpenCode question reject timed out', + ); success = false; failureMessage = 'OpenCode question handling failed'; break; diff --git a/src/infra/opencode/types.ts b/src/infra/opencode/types.ts index d25816d7..490561d7 100644 --- a/src/infra/opencode/types.ts +++ b/src/infra/opencode/types.ts @@ -8,6 +8,7 @@ import type { PermissionMode } from '../../core/models/index.js'; /** OpenCode permission reply values */ export type OpenCodePermissionReply = 'once' | 'always' | 'reject'; +export type OpenCodePermissionAction = 'ask' | 'allow' | 'deny'; /** Map TAKT PermissionMode to OpenCode permission reply */ export function mapToOpenCodePermissionReply(mode: PermissionMode): OpenCodePermissionReply { @@ -19,6 +20,138 @@ export function mapToOpenCodePermissionReply(mode: PermissionMode): OpenCodePerm return mapping[mode]; } +const OPEN_CODE_PERMISSION_KEYS = [ + 'read', + 'glob', + 'grep', + 'edit', + 'write', + 'bash', + 'task', + 'websearch', + 'webfetch', + 'question', +] as const; + +export type OpenCodePermissionKey = typeof OPEN_CODE_PERMISSION_KEYS[number]; + +export type OpenCodePermissionMap = Record; + +function buildPermissionMap(mode?: PermissionMode): OpenCodePermissionMap { + const allDeny: OpenCodePermissionMap = { + read: 'deny', + glob: 'deny', + grep: 'deny', + edit: 'deny', + write: 'deny', + bash: 'deny', + task: 'deny', + websearch: 'deny', + webfetch: 'deny', + question: 'deny', + }; + + if (mode === 'readonly') return allDeny; + + if (mode === 'full') { + return { + ...allDeny, + read: 'allow', + glob: 'allow', + grep: 'allow', + edit: 'allow', + write: 'allow', + bash: 'allow', + task: 'allow', + websearch: 'allow', + webfetch: 'allow', + question: 'allow', + }; + } + + if (mode === 'edit') { + return { + ...allDeny, + read: 'allow', + glob: 'allow', + grep: 'allow', + edit: 'allow', + write: 'allow', + bash: 'allow', + task: 'allow', + websearch: 'allow', + webfetch: 'allow', + question: 'deny', + }; + } + + return { + ...allDeny, + read: 'ask', + glob: 'ask', + grep: 'ask', + edit: 'ask', + write: 'ask', + bash: 'ask', + task: 'ask', + websearch: 'ask', + webfetch: 'ask', + question: 'deny', + }; +} + +export function buildOpenCodePermissionConfig(mode?: PermissionMode): OpenCodePermissionAction | Record { + if (mode === 'readonly') return 'deny'; + if (mode === 'full') return 'allow'; + return buildPermissionMap(mode); +} + +export function buildOpenCodePermissionRuleset(mode?: PermissionMode): Array<{ permission: string; pattern: string; action: OpenCodePermissionAction }> { + const permissionMap = buildPermissionMap(mode); + return OPEN_CODE_PERMISSION_KEYS.map((permission) => ({ + permission, + pattern: '**', + action: permissionMap[permission], + })); +} + +const BUILTIN_TOOL_MAP: Record = { + Read: 'read', + Glob: 'glob', + Grep: 'grep', + Edit: 'edit', + Write: 'write', + Bash: 'bash', + WebSearch: 'websearch', + WebFetch: 'webfetch', +}; + +export function mapToOpenCodeTools(allowedTools?: string[]): Record | undefined { + if (!allowedTools || allowedTools.length === 0) { + return undefined; + } + + const mapped = new Set(); + for (const tool of allowedTools) { + const normalized = tool.trim(); + if (!normalized) { + continue; + } + const mappedTool = BUILTIN_TOOL_MAP[normalized] ?? normalized; + mapped.add(mappedTool); + } + + if (mapped.size === 0) { + return undefined; + } + + const tools: Record = {}; + for (const tool of mapped) { + tools[tool] = true; + } + return tools; +} + /** Options for calling OpenCode */ export interface OpenCodeCallOptions { cwd: string; @@ -26,6 +159,7 @@ export interface OpenCodeCallOptions { sessionId?: string; model: string; systemPrompt?: string; + allowedTools?: string[]; /** Permission mode for automatic permission handling */ permissionMode?: PermissionMode; /** Enable streaming mode with callback (best-effort) */ diff --git a/src/infra/providers/opencode.ts b/src/infra/providers/opencode.ts index d5df0aa4..19e97989 100644 --- a/src/infra/providers/opencode.ts +++ b/src/infra/providers/opencode.ts @@ -17,6 +17,7 @@ function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions { abortSignal: options.abortSignal, sessionId: options.sessionId, model: options.model, + allowedTools: options.allowedTools, permissionMode: options.permissionMode, onStream: options.onStream, onAskUserQuestion: options.onAskUserQuestion, From 2a678f3a755d17fb445a375018607208e3a6c8d4 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:54:18 +0900 Subject: [PATCH 38/45] =?UTF-8?q?opencode=E3=81=AE=E7=B5=82=E4=BA=86?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E3=81=8C=E8=AA=A4=E3=81=A3=E3=81=A6=E3=81=84?= =?UTF-8?q?=E3=81=9F=E3=81=AE=E3=81=A7=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/opencode-client-cleanup.test.ts | 56 +++++-------------- src/infra/opencode/client.ts | 44 ++++++++++++++- 2 files changed, 55 insertions(+), 45 deletions(-) diff --git a/src/__tests__/opencode-client-cleanup.test.ts b/src/__tests__/opencode-client-cleanup.test.ts index 673398e1..5dc07053 100644 --- a/src/__tests__/opencode-client-cleanup.test.ts +++ b/src/__tests__/opencode-client-cleanup.test.ts @@ -31,45 +31,6 @@ class MockEventStream implements AsyncGenerator { } } -class HangingAfterEventsStream implements AsyncGenerator { - private index = 0; - private closed = false; - private pendingResolve: ((value: IteratorResult) => void) | undefined; - readonly returnSpy = vi.fn(async () => { - this.closed = true; - this.pendingResolve?.({ done: true, value: undefined }); - return { done: true as const, value: undefined }; - }); - - constructor(private readonly events: unknown[]) {} - - [Symbol.asyncIterator](): AsyncGenerator { - return this; - } - - async next(): Promise> { - if (this.closed) { - return { done: true, value: undefined }; - } - if (this.index < this.events.length) { - const value = this.events[this.index]; - this.index += 1; - return { done: false, value }; - } - return new Promise>((resolve) => { - this.pendingResolve = resolve; - }); - } - - async return(): Promise> { - return this.returnSpy(); - } - - async throw(e?: unknown): Promise> { - throw e; - } -} - const { createOpencodeMock } = vi.hoisted(() => ({ createOpencodeMock: vi.fn(), })); @@ -188,9 +149,9 @@ describe('OpenCodeClient stream cleanup', () => { ); }); - it('should complete without hanging when assistant message is completed', async () => { + it('should continue after assistant message completed and finish on session.idle', async () => { const { OpenCodeClient } = await import('../infra/opencode/client.js'); - const stream = new HangingAfterEventsStream([ + const stream = new MockEventStream([ { type: 'message.part.updated', properties: { @@ -208,6 +169,17 @@ describe('OpenCodeClient stream cleanup', () => { }, }, }, + { + type: 'message.part.updated', + properties: { + part: { id: 'p-1', type: 'text', text: 'done more' }, + delta: ' more', + }, + }, + { + type: 'session.idle', + properties: { sessionID: 'session-3' }, + }, ]); const promptAsync = vi.fn().mockResolvedValue(undefined); @@ -235,7 +207,7 @@ describe('OpenCodeClient stream cleanup', () => { ]); expect(result.status).toBe('done'); - expect(result.content).toBe('done'); + expect(result.content).toBe('done more'); expect(disposeInstance).toHaveBeenCalledWith( { directory: '/tmp' }, expect.objectContaining({ signal: expect.any(AbortSignal) }), diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index cb6271b0..5db61521 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -455,13 +455,51 @@ export class OpenCodeClient { }; const info = messageProps.info; const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; - const isCompleted = typeof info?.time?.completed === 'number'; - if (isCurrentAssistantMessage && isCompleted) { - const streamError = extractOpenCodeErrorMessage(info.error); + if (isCurrentAssistantMessage) { + const streamError = extractOpenCodeErrorMessage(info?.error); if (streamError) { success = false; failureMessage = streamError; + break; } + } + continue; + } + + if (sseEvent.type === 'message.completed') { + const completedProps = sseEvent.properties as { + info?: { + sessionID?: string; + role?: 'assistant' | 'user'; + error?: unknown; + }; + }; + const info = completedProps.info; + const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; + if (isCurrentAssistantMessage) { + const streamError = extractOpenCodeErrorMessage(info?.error); + if (streamError) { + success = false; + failureMessage = streamError; + break; + } + } + continue; + } + + if (sseEvent.type === 'message.failed') { + const failedProps = sseEvent.properties as { + info?: { + sessionID?: string; + role?: 'assistant' | 'user'; + error?: unknown; + }; + }; + const info = failedProps.info; + const isCurrentAssistantMessage = info?.sessionID === sessionId && info.role === 'assistant'; + if (isCurrentAssistantMessage) { + success = false; + failureMessage = extractOpenCodeErrorMessage(info?.error) ?? 'OpenCode message failed'; break; } continue; From ee7f7365db504ed8a2f8baddcf539e52bd584ea7 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:01:50 +0900 Subject: [PATCH 39/45] add e2e for opencode --- docs/testing/e2e.md | 5 ++++- e2e/helpers/isolated-env.ts | 13 +++++++++++-- package.json | 2 ++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index 1df1e8ca..cc996b3f 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -5,7 +5,8 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 ## 前提条件 - `gh` CLI が利用可能で、対象GitHubアカウントでログイン済みであること。 - `takt-testing` リポジトリが対象アカウントに存在すること(E2Eがクローンして使用)。 -- 必要に応じて `TAKT_E2E_PROVIDER` を設定すること(例: `claude` / `codex`)。 +- 必要に応じて `TAKT_E2E_PROVIDER` を設定すること(例: `claude` / `codex` / `opencode`)。 +- `TAKT_E2E_PROVIDER=opencode` の場合は `TAKT_E2E_MODEL` が必須(例: `opencode/big-pickle`)。 - 実行時間が長いテストがあるため、タイムアウトに注意すること。 - E2Eは `e2e/helpers/test-repo.ts` が `gh` でリポジトリをクローンし、テンポラリディレクトリで実行する。 - 対話UIを避けるため、E2E環境では `TAKT_NO_TTY=1` を設定してTTYを無効化する。 @@ -26,9 +27,11 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - `npm run test:e2e:provider`: `claude` と `codex` の両方で実行。 - `npm run test:e2e:provider:claude`: `TAKT_E2E_PROVIDER=claude` で実行。 - `npm run test:e2e:provider:codex`: `TAKT_E2E_PROVIDER=codex` で実行。 +- `npm run test:e2e:provider:opencode`: `TAKT_E2E_PROVIDER=opencode` で実行(`TAKT_E2E_MODEL` 必須)。 - `npm run test:e2e:all`: `mock` + `provider` を通しで実行。 - `npm run test:e2e:claude`: `test:e2e:provider:claude` の別名。 - `npm run test:e2e:codex`: `test:e2e:provider:codex` の別名。 +- `npm run test:e2e:opencode`: `test:e2e:provider:opencode` の別名。 - `npx vitest run e2e/specs/add-and-run.e2e.ts`: 単体実行の例。 ## シナリオ一覧 diff --git a/e2e/helpers/isolated-env.ts b/e2e/helpers/isolated-env.ts index 2aea4a70..8f1e24d3 100644 --- a/e2e/helpers/isolated-env.ts +++ b/e2e/helpers/isolated-env.ts @@ -95,8 +95,17 @@ export function createIsolatedEnv(): IsolatedEnv { // Create TAKT config directory and config.yaml mkdirSync(taktDir, { recursive: true }); const baseConfig = readE2EFixtureConfig(); - const config = process.env.TAKT_E2E_PROVIDER - ? { ...baseConfig, provider: process.env.TAKT_E2E_PROVIDER } + const provider = process.env.TAKT_E2E_PROVIDER; + const model = process.env.TAKT_E2E_MODEL; + if (provider === 'opencode' && !model) { + throw new Error('TAKT_E2E_PROVIDER=opencode requires TAKT_E2E_MODEL (e.g. opencode/big-pickle)'); + } + const config = provider + ? { + ...baseConfig, + provider, + ...(provider === 'opencode' && model ? { model } : {}), + } : baseConfig; writeConfigFile(taktDir, config); diff --git a/package.json b/package.json index f205080b..0b737653 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,10 @@ "test:e2e:provider": "npm run test:e2e:provider:claude && npm run test:e2e:provider:codex", "test:e2e:provider:claude": "TAKT_E2E_PROVIDER=claude vitest run --config vitest.config.e2e.provider.ts --reporter=verbose", "test:e2e:provider:codex": "TAKT_E2E_PROVIDER=codex vitest run --config vitest.config.e2e.provider.ts --reporter=verbose", + "test:e2e:provider:opencode": "TAKT_E2E_PROVIDER=opencode vitest run --config vitest.config.e2e.provider.ts --reporter=verbose", "test:e2e:claude": "npm run test:e2e:provider:claude", "test:e2e:codex": "npm run test:e2e:provider:codex", + "test:e2e:opencode": "npm run test:e2e:provider:opencode", "lint": "eslint src/", "prepublishOnly": "npm run lint && npm run build && npm run test" }, From 3ffae2ffc2b0614f034ed4b27786d194ba559ddf Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:18:41 +0900 Subject: [PATCH 40/45] add test --- .../cli-routing-issue-resolve.test.ts | 22 +++++++++++++++++++ src/app/cli/routing.ts | 13 ++++++++--- src/shared/i18n/labels_en.yaml | 1 + src/shared/i18n/labels_ja.yaml | 1 + 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index 522b7e91..1ba690b6 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -329,6 +329,7 @@ describe('Issue resolution in routing', () => { it('should pass selected session ID to interactiveMode when provider is claude', async () => { // Given mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); + mockConfirm.mockResolvedValue(true); mockSelectRecentSession.mockResolvedValue('session-xyz'); // When @@ -344,6 +345,27 @@ describe('Issue resolution in routing', () => { expect.anything(), 'session-xyz', ); + + expect(mockConfirm).toHaveBeenCalledWith('Choose a previous session?', false); + }); + + it('should not call selectRecentSession when user selects no in confirmation', async () => { + // Given + mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); + mockConfirm.mockResolvedValue(false); + + // When + await executeDefaultAction(); + + // Then + expect(mockConfirm).toHaveBeenCalledWith('Choose a previous session?', false); + expect(mockSelectRecentSession).not.toHaveBeenCalled(); + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + undefined, + expect.anything(), + undefined, + ); }); it('should not call selectRecentSession when provider is not claude', async () => { diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index aae09e70..140b16e8 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -6,6 +6,7 @@ */ import { info, error, withProgress } from '../../shared/ui/index.js'; +import { confirm } from '../../shared/prompt/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { getLabel } from '../../shared/i18n/index.js'; import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js'; @@ -167,9 +168,15 @@ export async function executeDefaultAction(task?: string): Promise { let selectedSessionId: string | undefined; const provider = globalConfig.provider; if (provider === 'claude') { - const sessionId = await selectRecentSession(resolvedCwd, lang); - if (sessionId) { - selectedSessionId = sessionId; + const shouldSelectSession = await confirm( + getLabel('interactive.sessionSelector.confirm', lang), + false, + ); + if (shouldSelectSession) { + const sessionId = await selectRecentSession(resolvedCwd, lang); + if (sessionId) { + selectedSessionId = sessionId; + } } } result = await interactiveMode(resolvedCwd, initialInput, pieceContext, selectedSessionId); diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index f85f8bcb..5826eb6c 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -36,6 +36,7 @@ interactive: passthrough: "Passthrough" passthroughDescription: "Pass your input directly as task text" sessionSelector: + confirm: "Choose a previous session?" prompt: "Resume from a recent session?" newSession: "New session" newSessionDescription: "Start a fresh conversation" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 1931d873..8239e7d1 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -36,6 +36,7 @@ interactive: passthrough: "パススルー" passthroughDescription: "入力をそのままタスクとして渡す" sessionSelector: + confirm: "前回セッションを選択しますか?" prompt: "直近のセッションを引き継ぎますか?" newSession: "新しいセッション" newSessionDescription: "新しい会話を始める" From a3555ebeb4fc17e5e9863af207d855f327c71ae8 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:01:52 +0900 Subject: [PATCH 41/45] takt: github-issue-236-feat-claude-codex-opencode (#239) --- src/__tests__/globalConfig-defaults.test.ts | 37 ++++ src/__tests__/models.test.ts | 5 + src/__tests__/providerEventLogger.test.ts | 188 +++++++++++++++++++ src/core/models/global-config.ts | 7 + src/core/models/index.ts | 1 + src/core/models/schemas.ts | 5 + src/core/models/types.ts | 1 + src/features/tasks/execute/pieceExecution.ts | 16 +- src/infra/config/global/globalConfig.ts | 8 + src/shared/utils/index.ts | 1 + src/shared/utils/providerEventLogger.ts | 137 ++++++++++++++ 11 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/providerEventLogger.test.ts create mode 100644 src/shared/utils/providerEventLogger.ts diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 47537324..d8a5cccc 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -314,6 +314,43 @@ describe('loadGlobalConfig', () => { }); }); + it('should load observability.provider_events config from config.yaml', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + [ + 'language: en', + 'observability:', + ' provider_events: false', + ].join('\n'), + 'utf-8', + ); + + const config = loadGlobalConfig(); + expect(config.observability).toEqual({ + providerEvents: false, + }); + }); + + it('should save and reload observability.provider_events config', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.observability = { + providerEvents: false, + }; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.observability).toEqual({ + providerEvents: false, + }); + }); + it('should save and reload notification_sound_events config', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); diff --git a/src/__tests__/models.test.ts b/src/__tests__/models.test.ts index 20139f33..0709d022 100644 --- a/src/__tests__/models.test.ts +++ b/src/__tests__/models.test.ts @@ -410,15 +410,20 @@ describe('GlobalConfigSchema', () => { expect(result.default_piece).toBe('default'); expect(result.log_level).toBe('info'); expect(result.provider).toBe('claude'); + expect(result.observability).toBeUndefined(); }); it('should accept valid config', () => { const config = { default_piece: 'custom', log_level: 'debug' as const, + observability: { + provider_events: false, + }, }; const result = GlobalConfigSchema.parse(config); expect(result.log_level).toBe('debug'); + expect(result.observability?.provider_events).toBe(false); }); }); diff --git a/src/__tests__/providerEventLogger.test.ts b/src/__tests__/providerEventLogger.test.ts new file mode 100644 index 00000000..08d361fc --- /dev/null +++ b/src/__tests__/providerEventLogger.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + createProviderEventLogger, + isProviderEventsEnabled, +} from '../shared/utils/providerEventLogger.js'; +import type { ProviderType } from '../core/piece/index.js'; + +describe('providerEventLogger', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `takt-provider-events-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should enable provider events by default', () => { + expect(isProviderEventsEnabled()).toBe(true); + expect(isProviderEventsEnabled({})).toBe(true); + expect(isProviderEventsEnabled({ observability: {} })).toBe(true); + expect(isProviderEventsEnabled({ observability: { providerEvents: true } })).toBe(true); + }); + + it('should disable provider events only when explicitly false', () => { + expect(isProviderEventsEnabled({ observability: { providerEvents: false } })).toBe(false); + }); + + it('should write normalized JSONL records when enabled', () => { + const logger = createProviderEventLogger({ + logsDir: tempDir, + sessionId: 'session-1', + runId: 'run-1', + provider: 'opencode', + movement: 'implement', + enabled: true, + }); + + const original = vi.fn(); + const wrapped = logger.wrapCallback(original); + + wrapped({ + type: 'tool_use', + data: { + tool: 'Read', + id: 'call-123', + messageId: 'msg-123', + requestId: 'req-123', + sessionID: 'session-abc', + }, + }); + + expect(original).toHaveBeenCalledTimes(1); + expect(existsSync(logger.filepath)).toBe(true); + + const lines = readFileSync(logger.filepath, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(1); + + const parsed = JSON.parse(lines[0]!) as { + provider: ProviderType; + event_type: string; + run_id: string; + movement: string; + session_id?: string; + call_id?: string; + message_id?: string; + request_id?: string; + data: Record; + }; + + expect(parsed.provider).toBe('opencode'); + expect(parsed.event_type).toBe('tool_use'); + expect(parsed.run_id).toBe('run-1'); + expect(parsed.movement).toBe('implement'); + expect(parsed.session_id).toBe('session-abc'); + expect(parsed.call_id).toBe('call-123'); + expect(parsed.message_id).toBe('msg-123'); + expect(parsed.request_id).toBe('req-123'); + expect(parsed.data['tool']).toBe('Read'); + }); + + it('should update movement and provider for subsequent events', () => { + const logger = createProviderEventLogger({ + logsDir: tempDir, + sessionId: 'session-2', + runId: 'run-2', + provider: 'claude', + movement: 'plan', + enabled: true, + }); + + const wrapped = logger.wrapCallback(); + + wrapped({ type: 'init', data: { model: 'sonnet', sessionId: 's-1' } }); + logger.setMovement('implement'); + logger.setProvider('codex'); + wrapped({ type: 'result', data: { result: 'ok', sessionId: 's-1', success: true } }); + + const lines = readFileSync(logger.filepath, 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(2); + + const first = JSON.parse(lines[0]!) as { provider: ProviderType; movement: string }; + const second = JSON.parse(lines[1]!) as { provider: ProviderType; movement: string }; + + expect(first.provider).toBe('claude'); + expect(first.movement).toBe('plan'); + expect(second.provider).toBe('codex'); + expect(second.movement).toBe('implement'); + }); + + it('should not write records when disabled', () => { + const logger = createProviderEventLogger({ + logsDir: tempDir, + sessionId: 'session-3', + runId: 'run-3', + provider: 'claude', + movement: 'plan', + enabled: false, + }); + + const original = vi.fn(); + const wrapped = logger.wrapCallback(original); + wrapped({ type: 'text', data: { text: 'hello' } }); + + expect(original).toHaveBeenCalledTimes(1); + expect(existsSync(logger.filepath)).toBe(false); + }); + + it('should truncate long text fields', () => { + const logger = createProviderEventLogger({ + logsDir: tempDir, + sessionId: 'session-4', + runId: 'run-4', + provider: 'claude', + movement: 'plan', + enabled: true, + }); + + const wrapped = logger.wrapCallback(); + const longText = 'a'.repeat(11_000); + wrapped({ type: 'text', data: { text: longText } }); + + const line = readFileSync(logger.filepath, 'utf-8').trim(); + const parsed = JSON.parse(line) as { data: { text: string } }; + + expect(parsed.data.text.length).toBeLessThan(longText.length); + expect(parsed.data.text).toContain('...[truncated]'); + }); + + it('should write init event records with typed data objects', () => { + const logger = createProviderEventLogger({ + logsDir: tempDir, + sessionId: 'session-5', + runId: 'run-5', + provider: 'codex', + movement: 'implement', + enabled: true, + }); + + const wrapped = logger.wrapCallback(); + wrapped({ + type: 'init', + data: { + model: 'gpt-5-codex', + sessionId: 'thread-1', + }, + }); + + const line = readFileSync(logger.filepath, 'utf-8').trim(); + const parsed = JSON.parse(line) as { + provider: ProviderType; + event_type: string; + session_id?: string; + data: { model: string; sessionId: string }; + }; + + expect(parsed.provider).toBe('codex'); + expect(parsed.event_type).toBe('init'); + expect(parsed.session_id).toBe('thread-1'); + expect(parsed.data.model).toBe('gpt-5-codex'); + expect(parsed.data.sessionId).toBe('thread-1'); + }); +}); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 4974cd51..89223a20 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -20,6 +20,12 @@ export interface DebugConfig { logFile?: string; } +/** Observability configuration for runtime event logs */ +export interface ObservabilityConfig { + /** Enable provider stream event logging (default: true when undefined) */ + providerEvents?: boolean; +} + /** Language setting for takt */ export type Language = 'en' | 'ja'; @@ -55,6 +61,7 @@ export interface GlobalConfig { provider?: 'claude' | 'codex' | 'opencode' | 'mock'; model?: string; debug?: DebugConfig; + observability?: ObservabilityConfig; /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ worktreeDir?: string; /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ diff --git a/src/core/models/index.ts b/src/core/models/index.ts index bf9b5ef6..8221700c 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -22,6 +22,7 @@ export type { PieceState, CustomAgentConfig, DebugConfig, + ObservabilityConfig, Language, PipelineConfig, GlobalConfig, diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 64a60d7a..6ab743d3 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -309,6 +309,10 @@ export const DebugConfigSchema = z.object({ log_file: z.string().optional(), }); +export const ObservabilityConfigSchema = z.object({ + provider_events: z.boolean().optional(), +}); + /** Language setting schema */ export const LanguageSchema = z.enum(['en', 'ja']); @@ -341,6 +345,7 @@ export const GlobalConfigSchema = z.object({ provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'), model: z.string().optional(), debug: DebugConfigSchema.optional(), + observability: ObservabilityConfigSchema.optional(), /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ worktree_dir: z.string().optional(), /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ diff --git a/src/core/models/types.ts b/src/core/models/types.ts index a5787525..42e49e9c 100644 --- a/src/core/models/types.ts +++ b/src/core/models/types.ts @@ -45,6 +45,7 @@ export type { export type { CustomAgentConfig, DebugConfig, + ObservabilityConfig, Language, PipelineConfig, GlobalConfig, diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 4b58272c..01bd8056 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -59,6 +59,10 @@ import { isValidReportDirName, } from '../../../shared/utils/index.js'; import type { PromptLogRecord } from '../../../shared/utils/index.js'; +import { + createProviderEventLogger, + isProviderEventsEnabled, +} from '../../../shared/utils/providerEventLogger.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { getLabel } from '../../../shared/i18n/index.js'; import { installSigIntHandler } from './sigintHandler.js'; @@ -303,6 +307,14 @@ export async function executePiece( const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false; const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false; const currentProvider = globalConfig.provider ?? 'claude'; + const providerEventLogger = createProviderEventLogger({ + logsDir: runPaths.logsAbs, + sessionId: pieceSessionId, + runId: runSlug, + provider: currentProvider, + movement: options.startMovement ?? pieceConfig.initialMovement, + enabled: isProviderEventsEnabled(globalConfig), + }); // Prevent macOS idle sleep if configured if (globalConfig.preventSleep) { @@ -402,7 +414,7 @@ export async function executePiece( try { engine = new PieceEngine(pieceConfig, cwd, task, { abortSignal: runAbortController.signal, - onStream: streamHandler, + onStream: providerEventLogger.wrapCallback(streamHandler), onUserInput, initialSessions: savedSessions, onSessionUpdate: sessionUpdateHandler, @@ -492,6 +504,8 @@ export async function executePiece( }); const movementProvider = resolved.provider ?? currentProvider; const movementModel = resolved.model ?? globalConfig.model ?? '(default)'; + providerEventLogger.setMovement(step.name); + providerEventLogger.setProvider(movementProvider); out.info(`Provider: ${movementProvider}`); out.info(`Model: ${movementModel}`); diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index e7a7ea63..763f138a 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -105,6 +105,9 @@ export class GlobalConfigManager { enabled: parsed.debug.enabled, logFile: parsed.debug.log_file, } : undefined, + observability: parsed.observability ? { + providerEvents: parsed.observability.provider_events, + } : undefined, worktreeDir: parsed.worktree_dir, autoPr: parsed.auto_pr, disabledBuiltins: parsed.disabled_builtins, @@ -158,6 +161,11 @@ export class GlobalConfigManager { log_file: config.debug.logFile, }; } + if (config.observability && config.observability.providerEvents !== undefined) { + raw.observability = { + provider_events: config.observability.providerEvents, + }; + } if (config.worktreeDir) { raw.worktree_dir = config.worktreeDir; } diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 24859d54..b23b7740 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -5,6 +5,7 @@ export * from './debug.js'; export * from './error.js'; export * from './notification.js'; +export * from './providerEventLogger.js'; export * from './reportDir.js'; export * from './sleep.js'; export * from './slug.js'; diff --git a/src/shared/utils/providerEventLogger.ts b/src/shared/utils/providerEventLogger.ts new file mode 100644 index 00000000..70b36308 --- /dev/null +++ b/src/shared/utils/providerEventLogger.ts @@ -0,0 +1,137 @@ +import { appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ProviderType, StreamCallback, StreamEvent } from '../../core/piece/index.js'; + +export interface ProviderEventLoggerConfig { + logsDir: string; + sessionId: string; + runId: string; + provider: ProviderType; + movement: string; + enabled: boolean; +} + +export interface ProviderEventLogger { + readonly filepath: string; + setMovement(movement: string): void; + setProvider(provider: ProviderType): void; + wrapCallback(original?: StreamCallback): StreamCallback; +} + +interface ProviderEventLogRecord { + timestamp: string; + provider: ProviderType; + event_type: string; + run_id: string; + movement: string; + session_id?: string; + message_id?: string; + call_id?: string; + request_id?: string; + data: Record; +} + +const MAX_TEXT_LENGTH = 10_000; +const HEAD_LENGTH = 5_000; +const TAIL_LENGTH = 2_000; +const TRUNCATED_MARKER = '...[truncated]'; + +function truncateString(value: string): string { + if (value.length <= MAX_TEXT_LENGTH) { + return value; + } + return value.slice(0, HEAD_LENGTH) + TRUNCATED_MARKER + value.slice(-TAIL_LENGTH); +} + +function sanitizeData(data: Record): Record { + return Object.fromEntries( + Object.entries(data).map(([key, value]) => { + if (typeof value === 'string') { + return [key, truncateString(value)]; + } + return [key, value]; + }) + ); +} + +function pickString(source: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = source[key]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + return undefined; +} + +function buildLogRecord( + event: StreamEvent, + provider: ProviderType, + movement: string, + runId: string, +): ProviderEventLogRecord { + const data = sanitizeData(event.data as unknown as Record); + const sessionId = pickString(data, ['session_id', 'sessionId', 'sessionID', 'thread_id', 'threadId']); + const messageId = pickString(data, ['message_id', 'messageId', 'item_id', 'itemId']); + const callId = pickString(data, ['call_id', 'callId', 'id']); + const requestId = pickString(data, ['request_id', 'requestId']); + + return { + timestamp: new Date().toISOString(), + provider, + event_type: event.type, + run_id: runId, + movement, + ...(sessionId ? { session_id: sessionId } : {}), + ...(messageId ? { message_id: messageId } : {}), + ...(callId ? { call_id: callId } : {}), + ...(requestId ? { request_id: requestId } : {}), + data, + }; +} + +export function createProviderEventLogger(config: ProviderEventLoggerConfig): ProviderEventLogger { + const filepath = join(config.logsDir, `${config.sessionId}-provider-events.jsonl`); + let movement = config.movement; + let provider = config.provider; + + const write = (event: StreamEvent): void => { + try { + const record = buildLogRecord(event, provider, movement, config.runId); + appendFileSync(filepath, JSON.stringify(record) + '\n', 'utf-8'); + } catch { + // Silently fail - observability logging should not interrupt main flow. + } + }; + + return { + filepath, + setMovement(nextMovement: string): void { + movement = nextMovement; + }, + setProvider(nextProvider: ProviderType): void { + provider = nextProvider; + }, + wrapCallback(original?: StreamCallback): StreamCallback { + if (!config.enabled && original) { + return original; + } + if (!config.enabled) { + return () => {}; + } + + return (event: StreamEvent): void => { + write(event); + original?.(event); + }; + }, + }; +} + +export function isProviderEventsEnabled(config?: { + observability?: { + providerEvents?: boolean; + }; +}): boolean { + return config?.observability?.providerEvents !== false; +} From 4fb058aa6a3e35eccf06f6dce0f5d72e910c804e Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:02:03 +0900 Subject: [PATCH 42/45] takt: slackweb (#234) --- src/__tests__/runAllTasks-concurrency.test.ts | 101 +++++++++++++ src/__tests__/slackWebhook.test.ts | 135 ++++++++++++++++++ src/features/tasks/execute/taskExecution.ts | 12 +- src/shared/utils/index.ts | 1 + src/shared/utils/slackWebhook.ts | 43 ++++++ 5 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/slackWebhook.test.ts create mode 100644 src/shared/utils/slackWebhook.ts diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 57dcd557..9bab686c 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -28,6 +28,8 @@ const { mockRecoverInterruptedRunningTasks, mockNotifySuccess, mockNotifyError, + mockSendSlackNotification, + mockGetSlackWebhookUrl, } = vi.hoisted(() => ({ mockClaimNextTasks: vi.fn(), mockCompleteTask: vi.fn(), @@ -35,6 +37,8 @@ const { mockRecoverInterruptedRunningTasks: vi.fn(), mockNotifySuccess: vi.fn(), mockNotifyError: vi.fn(), + mockSendSlackNotification: vi.fn(), + mockGetSlackWebhookUrl: vi.fn(), })); vi.mock('../infra/task/index.js', async (importOriginal) => ({ @@ -88,6 +92,8 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ getErrorMessage: vi.fn((e) => e.message), notifySuccess: mockNotifySuccess, notifyError: mockNotifyError, + sendSlackNotification: mockSendSlackNotification, + getSlackWebhookUrl: mockGetSlackWebhookUrl, })); vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ @@ -655,4 +661,99 @@ describe('runAllTasks concurrency', () => { expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort'); }); }); + + describe('Slack webhook notification', () => { + const webhookUrl = 'https://hooks.slack.com/services/T00/B00/xxx'; + const fakePieceConfig = { + name: 'default', + movements: [{ name: 'implement', personaDisplayName: 'coder' }], + initialMovement: 'implement', + maxMovements: 10, + }; + + beforeEach(() => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + defaultPiece: 'default', + logLevel: 'info', + concurrency: 1, + taskPollIntervalMs: 500, + }); + mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never); + }); + + it('should send Slack notification on success when webhook URL is set', async () => { + // Given + mockGetSlackWebhookUrl.mockReturnValue(webhookUrl); + const task1 = createTask('task-1'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + // When + await runAllTasks('/project'); + + // Then + expect(mockSendSlackNotification).toHaveBeenCalledOnce(); + expect(mockSendSlackNotification).toHaveBeenCalledWith( + webhookUrl, + 'TAKT Run complete: 1 tasks succeeded', + ); + }); + + it('should send Slack notification on failure when webhook URL is set', async () => { + // Given + mockGetSlackWebhookUrl.mockReturnValue(webhookUrl); + const task1 = createTask('task-1'); + mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' }); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + // When + await runAllTasks('/project'); + + // Then + expect(mockSendSlackNotification).toHaveBeenCalledOnce(); + expect(mockSendSlackNotification).toHaveBeenCalledWith( + webhookUrl, + 'TAKT Run finished with errors: 1 failed out of 1 tasks', + ); + }); + + it('should send Slack notification on exception when webhook URL is set', async () => { + // Given + mockGetSlackWebhookUrl.mockReturnValue(webhookUrl); + const task1 = createTask('task-1'); + const poolError = new Error('worker pool crashed'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockImplementationOnce(() => { + throw poolError; + }); + + // When / Then + await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed'); + expect(mockSendSlackNotification).toHaveBeenCalledOnce(); + expect(mockSendSlackNotification).toHaveBeenCalledWith( + webhookUrl, + 'TAKT Run error: worker pool crashed', + ); + }); + + it('should not send Slack notification when webhook URL is not set', async () => { + // Given + mockGetSlackWebhookUrl.mockReturnValue(undefined); + const task1 = createTask('task-1'); + mockClaimNextTasks + .mockReturnValueOnce([task1]) + .mockReturnValueOnce([]); + + // When + await runAllTasks('/project'); + + // Then + expect(mockSendSlackNotification).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/__tests__/slackWebhook.test.ts b/src/__tests__/slackWebhook.test.ts new file mode 100644 index 00000000..5946eb39 --- /dev/null +++ b/src/__tests__/slackWebhook.test.ts @@ -0,0 +1,135 @@ +/** + * Unit tests for Slack Incoming Webhook notification + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { sendSlackNotification, getSlackWebhookUrl } from '../shared/utils/slackWebhook.js'; + +describe('sendSlackNotification', () => { + const webhookUrl = 'https://hooks.slack.com/services/T00/B00/xxx'; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should send POST request with correct payload', async () => { + // Given + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', mockFetch); + + // When + await sendSlackNotification(webhookUrl, 'Hello from TAKT'); + + // Then + expect(mockFetch).toHaveBeenCalledOnce(); + expect(mockFetch).toHaveBeenCalledWith( + webhookUrl, + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'Hello from TAKT' }), + }), + ); + }); + + it('should include AbortSignal for timeout', async () => { + // Given + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', mockFetch); + + // When + await sendSlackNotification(webhookUrl, 'test'); + + // Then + const callArgs = mockFetch.mock.calls[0]![1] as RequestInit; + expect(callArgs.signal).toBeInstanceOf(AbortSignal); + }); + + it('should write to stderr on non-ok response', async () => { + // Given + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + }); + vi.stubGlobal('fetch', mockFetch); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + // When + await sendSlackNotification(webhookUrl, 'test'); + + // Then: no exception thrown, error written to stderr + expect(stderrSpy).toHaveBeenCalledWith( + 'Slack webhook failed: HTTP 403 Forbidden\n', + ); + }); + + it('should write to stderr on fetch error without throwing', async () => { + // Given + const mockFetch = vi.fn().mockRejectedValue(new Error('network timeout')); + vi.stubGlobal('fetch', mockFetch); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + // When + await sendSlackNotification(webhookUrl, 'test'); + + // Then: no exception thrown, error written to stderr + expect(stderrSpy).toHaveBeenCalledWith( + 'Slack webhook error: network timeout\n', + ); + }); + + it('should handle non-Error thrown values', async () => { + // Given + const mockFetch = vi.fn().mockRejectedValue('string error'); + vi.stubGlobal('fetch', mockFetch); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + // When + await sendSlackNotification(webhookUrl, 'test'); + + // Then + expect(stderrSpy).toHaveBeenCalledWith( + 'Slack webhook error: string error\n', + ); + }); +}); + +describe('getSlackWebhookUrl', () => { + const envKey = 'TAKT_NOTIFY_WEBHOOK'; + let originalValue: string | undefined; + + beforeEach(() => { + originalValue = process.env[envKey]; + }); + + afterEach(() => { + if (originalValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = originalValue; + } + }); + + it('should return the webhook URL when environment variable is set', () => { + // Given + process.env[envKey] = 'https://hooks.slack.com/services/T00/B00/xxx'; + + // When + const url = getSlackWebhookUrl(); + + // Then + expect(url).toBe('https://hooks.slack.com/services/T00/B00/xxx'); + }); + + it('should return undefined when environment variable is not set', () => { + // Given + delete process.env[envKey]; + + // When + const url = getSlackWebhookUrl(); + + // Then + expect(url).toBeUndefined(); + }); +}); diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index bdc50cb9..14c4a0de 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -12,7 +12,7 @@ import { status, blankLine, } from '../../../shared/ui/index.js'; -import { createLogger, getErrorMessage, notifyError, notifySuccess } from '../../../shared/utils/index.js'; +import { createLogger, getErrorMessage, getSlackWebhookUrl, notifyError, notifySuccess, sendSlackNotification } 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'; @@ -259,6 +259,7 @@ export async function runAllTasks( const shouldNotifyRunAbort = globalConfig.notificationSound !== false && globalConfig.notificationSoundEvents?.runAbort !== false; const concurrency = globalConfig.concurrency; + const slackWebhookUrl = getSlackWebhookUrl(); const recovered = taskRunner.recoverInterruptedRunningTasks(); if (recovered > 0) { info(`Recovered ${recovered} interrupted running task(s) to pending.`); @@ -290,16 +291,25 @@ export async function runAllTasks( if (shouldNotifyRunAbort) { notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: String(result.fail) })); } + if (slackWebhookUrl) { + await sendSlackNotification(slackWebhookUrl, `TAKT Run finished with errors: ${String(result.fail)} failed out of ${String(totalCount)} tasks`); + } return; } if (shouldNotifyRunComplete) { notifySuccess('TAKT', getLabel('run.notifyComplete', undefined, { total: String(totalCount) })); } + if (slackWebhookUrl) { + await sendSlackNotification(slackWebhookUrl, `TAKT Run complete: ${String(totalCount)} tasks succeeded`); + } } catch (e) { if (shouldNotifyRunAbort) { notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: getErrorMessage(e) })); } + if (slackWebhookUrl) { + await sendSlackNotification(slackWebhookUrl, `TAKT Run error: ${getErrorMessage(e)}`); + } throw e; } } diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index b23b7740..340d55ca 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -7,6 +7,7 @@ export * from './error.js'; export * from './notification.js'; export * from './providerEventLogger.js'; export * from './reportDir.js'; +export * from './slackWebhook.js'; export * from './sleep.js'; export * from './slug.js'; export * from './taskPaths.js'; diff --git a/src/shared/utils/slackWebhook.ts b/src/shared/utils/slackWebhook.ts new file mode 100644 index 00000000..6d208f92 --- /dev/null +++ b/src/shared/utils/slackWebhook.ts @@ -0,0 +1,43 @@ +/** + * Slack Incoming Webhook notification + * + * Sends a text message to a Slack channel via Incoming Webhook. + * Activated only when TAKT_NOTIFY_WEBHOOK environment variable is set. + */ + +const WEBHOOK_ENV_KEY = 'TAKT_NOTIFY_WEBHOOK'; +const TIMEOUT_MS = 10_000; + +/** + * Send a notification message to Slack via Incoming Webhook. + * + * Never throws: errors are written to stderr so the caller's flow is not disrupted. + */ +export async function sendSlackNotification(webhookUrl: string, message: string): Promise { + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: message }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + if (!response.ok) { + process.stderr.write( + `Slack webhook failed: HTTP ${String(response.status)} ${response.statusText}\n`, + ); + } + } catch (err: unknown) { + const detail = err instanceof Error ? err.message : String(err); + process.stderr.write(`Slack webhook error: ${detail}\n`); + } +} + +/** + * Read the Slack webhook URL from the environment. + * + * @returns The webhook URL, or undefined if the environment variable is not set. + */ +export function getSlackWebhookUrl(): string | undefined { + return process.env[WEBHOOK_ENV_KEY]; +} From 9f1c7e6aff97ed94479ee75fd42f9c242d44b7eb Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:26:12 +0900 Subject: [PATCH 43/45] takt: github-issue-238-fix-opencode (#240) --- src/__tests__/opencode-client-cleanup.test.ts | 17 ++++++++-- src/__tests__/opencode-stream-handler.test.ts | 33 +++++++++++++++++++ src/infra/opencode/OpenCodeStreamHandler.ts | 24 ++++++++++++++ src/infra/opencode/client.ts | 4 +-- 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/__tests__/opencode-client-cleanup.test.ts b/src/__tests__/opencode-client-cleanup.test.ts index 5dc07053..af74d2cf 100644 --- a/src/__tests__/opencode-client-cleanup.test.ts +++ b/src/__tests__/opencode-client-cleanup.test.ts @@ -218,7 +218,7 @@ describe('OpenCodeClient stream cleanup', () => { ); }); - it('should fail fast when question.asked is received without handler', async () => { + it('should reject question.asked without handler and continue processing', async () => { const { OpenCodeClient } = await import('../infra/opencode/client.js'); const stream = new MockEventStream([ { @@ -235,6 +235,17 @@ describe('OpenCodeClient stream cleanup', () => { ], }, }, + { + type: 'message.part.updated', + properties: { + part: { id: 'p-q1', type: 'text', text: 'continued response' }, + delta: 'continued response', + }, + }, + { + type: 'session.idle', + properties: { sessionID: 'session-4' }, + }, ]); const promptAsync = vi.fn().mockResolvedValue(undefined); @@ -260,8 +271,8 @@ describe('OpenCodeClient stream cleanup', () => { model: 'opencode/big-pickle', }); - expect(result.status).toBe('error'); - expect(result.content).toContain('no question handler'); + expect(result.status).toBe('done'); + expect(result.content).toBe('continued response'); expect(questionReject).toHaveBeenCalledWith( { requestID: 'q-1', diff --git a/src/__tests__/opencode-stream-handler.test.ts b/src/__tests__/opencode-stream-handler.test.ts index 2456f05a..108fc100 100644 --- a/src/__tests__/opencode-stream-handler.test.ts +++ b/src/__tests__/opencode-stream-handler.test.ts @@ -12,6 +12,7 @@ import { emitToolResult, emitResult, handlePartUpdated, + type OpenCodeStreamEvent, type OpenCodeTextPart, type OpenCodeReasoningPart, type OpenCodeToolPart, @@ -351,3 +352,35 @@ describe('handlePartUpdated', () => { handlePartUpdated(part, 'Hello', undefined, state); }); }); + +describe('OpenCodeStreamEvent typing', () => { + it('should accept message.completed event shape', () => { + const event: OpenCodeStreamEvent = { + type: 'message.completed', + properties: { + info: { + sessionID: 'session-1', + role: 'assistant', + error: undefined, + }, + }, + }; + + expect(event.type).toBe('message.completed'); + }); + + it('should accept message.failed event shape', () => { + const event: OpenCodeStreamEvent = { + type: 'message.failed', + properties: { + info: { + sessionID: 'session-2', + role: 'assistant', + error: { message: 'failed' }, + }, + }, + }; + + expect(event.type).toBe('message.failed'); + }); +}); diff --git a/src/infra/opencode/OpenCodeStreamHandler.ts b/src/infra/opencode/OpenCodeStreamHandler.ts index f7c0e826..6b312edf 100644 --- a/src/infra/opencode/OpenCodeStreamHandler.ts +++ b/src/infra/opencode/OpenCodeStreamHandler.ts @@ -75,6 +75,28 @@ export interface OpenCodeMessageUpdatedEvent { }; } +export interface OpenCodeMessageCompletedEvent { + type: 'message.completed'; + properties: { + info: { + sessionID: string; + role: 'assistant' | 'user'; + error?: unknown; + }; + }; +} + +export interface OpenCodeMessageFailedEvent { + type: 'message.failed'; + properties: { + info: { + sessionID: string; + role: 'assistant' | 'user'; + error?: unknown; + }; + }; +} + export interface OpenCodePermissionAskedEvent { type: 'permission.asked'; properties: { @@ -107,6 +129,8 @@ export interface OpenCodeQuestionAskedEvent { export type OpenCodeStreamEvent = | OpenCodeMessagePartUpdatedEvent | OpenCodeMessageUpdatedEvent + | OpenCodeMessageCompletedEvent + | OpenCodeMessageFailedEvent | OpenCodeSessionStatusEvent | OpenCodeSessionIdleEvent | OpenCodeSessionErrorEvent diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index 5db61521..d8537979 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -411,9 +411,7 @@ export class OpenCodeClient { OPENCODE_INTERACTION_TIMEOUT_MS, 'OpenCode question reject timed out', ); - success = false; - failureMessage = 'OpenCode asked a question, but no question handler is configured'; - break; + continue; } try { From 21537a3214ce9158a7c92aa1557a74b56e229452 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:44:26 +0900 Subject: [PATCH 44/45] Release v0.12.0 --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ README.md | 31 ++++++++++++++++++------------- docs/README.ja.md | 31 ++++++++++++++++++------------- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 78 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7289ea7a..26fbb64e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ 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.12.0] - 2026-02-11 + +### Added + +- **OpenCode プロバイダー**: 第3のプロバイダーとして OpenCode をネイティブサポート — `@opencode-ai/sdk/v2` による SDK 統合、権限マッピング(readonly/edit/full → reject/once/always)、SSE ストリーム処理、リトライ機構(最大3回)、10分タイムアウトによるハング検出 (#236, #238) +- **Arpeggio ムーブメント**: データ駆動バッチ処理の新ムーブメントタイプ — CSV データソースからバッチ分割、テンプレート展開(`{line:N}`, `{col:N:name}`, `{batch_index}`)、並行 LLM 呼び出し(Semaphore 制御)、concat/custom マージ戦略をサポート (#200) +- **`frontend` ビルトインピース**: フロントエンド開発特化のピースを新規追加 — React/Next.js 向けの knowledge 注入、coding/testing ポリシー適用、並列アーキテクチャレビュー対応 +- **Slack Webhook 通知**: ピース実行完了時に Slack へ自動通知 — `TAKT_NOTIFY_WEBHOOK` 環境変数で設定、10秒タイムアウト、失敗時も他処理をブロックしない (#234) +- **セッション選択 UI**: インタラクティブモード開始時に Claude Code の過去セッションから再開可能なセッションを選択可能に — 最新10セッションの一覧表示、初期入力・最終応答プレビュー付き (#180) +- **プロバイダーイベントログ**: Claude/Codex/OpenCode の実行中イベントを NDJSON 形式でファイル出力 — `.takt/logs/{sessionId}-provider-events.jsonl` に記録、長大テキストの自動圧縮 (#236) +- **プロバイダー・モデル名の出力表示**: 各ムーブメント実行時に使用中のプロバイダーとモデル名をコンソールに表示 + +### Changed + +- **`takt add` の刷新**: Issue 選択時にタスクへの自動追加、インタラクティブモードの廃止、Issue 作成時のタスク積み込み確認 (#193, #194) +- **`max_iteration` → `max_movement` 統一**: イテレーション上限の用語を統一し、無限実行指定として `ostinato` を追加 (#212) +- **`previous_response` 注入仕様の改善**: 長さ制御と Source Path 常時注入を実装 (#207) +- **タスク管理の改善**: `.takt/tasks/` を長文タスク仕様の置き場所として再定義、`completeTask()` で completed レコードを `tasks.yaml` から削除 (#201, #204) +- **レビュー出力の改善**: レビュー出力を最新化し、過去レポートは履歴ログへ分離 (#209) +- **ビルトインピース簡素化**: 全ビルトインピースのトップレベル宣言をさらに整理 + +### Fixed + +- **Report Phase blocked 時の動作修正**: Report Phase(Phase 2)で blocked 状態の際に新規セッションでリトライするよう修正 (#163) +- **OpenCode のハング・終了判定の修正**: プロンプトのエコー抑制、question の抑制、ハング問題の修正、終了判定の誤りを修正 (#238) +- **OpenCode の権限・ツール設定の修正**: edit 実行時の権限とツール配線を修正 +- **Worktree へのタスク指示書コピー**: Worktree 実行時にタスク指示書が正しくコピーされるよう修正 +- lint エラーの修正(merge/resolveTask/confirm) + +### Internal + +- OpenCode プロバイダーの包括的なテスト追加(client-cleanup, config, provider, stream-handler, types) +- Arpeggio の包括的なテスト追加(csv, data-source-factory, merge, schema, template, engine-arpeggio) +- E2E テストの大幅な拡充: cli-catalog, cli-clear, cli-config, cli-export-cc, cli-help, cli-prompt, cli-reset-categories, cli-switch, error-handling, piece-error-handling, provider-error, quiet-mode, run-multiple-tasks, task-content-file (#192, #198) +- `providerEventLogger.ts`, `providerModel.ts`, `slackWebhook.ts`, `session-reader.ts`, `sessionSelector.ts`, `provider-resolution.ts`, `run-paths.ts` の新規追加 +- `ArpeggioRunner.ts` の新規追加(データ駆動バッチ処理エンジン) +- AI Judge をプロバイダーシステム経由に変更(Codex/OpenCode 対応) +- テスト追加・拡充: report-phase-blocked, phase-runner-report-history, judgment-fallback, pieceExecution-session-loading, globalConfig-defaults, session-reader, sessionSelector, slackWebhook, providerEventLogger, provider-model, interactive, run-paths, engine-test-helpers + ## [0.11.1] - 2026-02-10 ### Fixed diff --git a/README.md b/README.md index d461c4e5..60a4501a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **T**ask **A**gent **K**oordination **T**ool - Define how AI agents coordinate, where humans intervene, and what gets recorded — in YAML -TAKT runs multiple AI agents (Claude Code, Codex) through YAML-defined workflows. Each step — who runs, what they see, what's allowed, what happens on failure — is declared in a piece file, not left to the agent. +TAKT runs multiple AI agents (Claude Code, Codex, OpenCode) through YAML-defined workflows. Each step — who runs, what they see, what's allowed, what happens on failure — is declared in a piece file, not left to the agent. TAKT is built with TAKT itself (dogfooding). @@ -49,14 +49,14 @@ Personas, policies, and knowledge are managed as independent files and freely co Choose one: -- **Use provider CLIs**: [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or [Codex](https://github.com/openai/codex) installed -- **Use direct API**: **Anthropic API Key** or **OpenAI API Key** (no CLI required) +- **Use provider CLIs**: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), or [OpenCode](https://opencode.ai) installed +- **Use direct API**: **Anthropic API Key**, **OpenAI API Key**, or **OpenCode API Key** (no CLI required) Additionally required: - [GitHub CLI](https://cli.github.com/) (`gh`) — Only needed for `takt #N` (GitHub Issue execution) -**Pricing Note**: When using API Keys, TAKT directly calls the Claude API (Anthropic) or OpenAI API. The pricing structure is the same as using Claude Code or Codex. Be mindful of costs, especially when running automated tasks in CI/CD environments, as API usage can accumulate. +**Pricing Note**: When using API Keys, TAKT directly calls the Claude API (Anthropic), OpenAI API, or OpenCode API. The pricing structure is the same as using the respective CLI tools. Be mindful of costs, especially when running automated tasks in CI/CD environments, as API usage can accumulate. ## Installation @@ -322,7 +322,7 @@ takt reset categories | `--repo ` | Specify repository (for PR creation) | | `--create-worktree ` | Skip worktree confirmation prompt | | `-q, --quiet` | Minimal output mode: suppress AI output (for CI) | -| `--provider ` | Override agent provider (claude\|codex\|mock) | +| `--provider ` | Override agent provider (claude\|codex\|opencode\|mock) | | `--model ` | Override agent model | ## Pieces @@ -473,6 +473,7 @@ TAKT includes multiple builtin pieces: | `structural-reform` | Full project review and structural reform: iterative codebase restructuring with staged file splits. | | `unit-test` | Unit test focused piece: test analysis → test implementation → review → fix. | | `e2e-test` | E2E test focused piece: E2E analysis → E2E implementation → review → fix (Vitest-based E2E flow). | +| `frontend` | Frontend-specialized development piece with React/Next.js focused reviews and knowledge injection. | **Per-persona provider overrides:** Use `persona_providers` in config to route specific personas to different providers (e.g., coder on Codex, reviewers on Claude) without duplicating pieces. @@ -560,7 +561,7 @@ Configure default provider and model in `~/.takt/config.yaml`: language: en default_piece: default log_level: info -provider: claude # Default provider: claude or codex +provider: claude # Default provider: claude, codex, or opencode model: sonnet # Default model (optional) branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow) prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate) @@ -582,9 +583,10 @@ interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, # ai-antipattern-reviewer: claude # Keep reviewers on Claude # API Key configuration (optional) -# Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY +# Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY anthropic_api_key: sk-ant-... # For Claude (Anthropic) # openai_api_key: sk-... # For Codex (OpenAI) +# opencode_api_key: ... # For OpenCode # Builtin piece filtering (optional) # builtin_pieces_enabled: true # Set false to disable all builtins @@ -608,17 +610,17 @@ anthropic_api_key: sk-ant-... # For Claude (Anthropic) 1. **Set via environment variables**: ```bash export TAKT_ANTHROPIC_API_KEY=sk-ant-... # For Claude - # or export TAKT_OPENAI_API_KEY=sk-... # For Codex + export TAKT_OPENCODE_API_KEY=... # For OpenCode ``` 2. **Set in config file**: - Write `anthropic_api_key` or `openai_api_key` in `~/.takt/config.yaml` as shown above + Write `anthropic_api_key`, `openai_api_key`, or `opencode_api_key` in `~/.takt/config.yaml` as shown above Priority: Environment variables > `config.yaml` settings **Notes:** -- If you set an API Key, installing Claude Code or Codex is not necessary. TAKT directly calls the Anthropic API or OpenAI API. +- If you set an API Key, installing Claude Code, Codex, or OpenCode is not necessary. TAKT directly calls the respective API. - **Security**: If you write API Keys in `config.yaml`, be careful not to commit this file to Git. Consider using environment variables or adding `~/.takt/config.yaml` to `.gitignore`. **Pipeline Template Variables:** @@ -634,7 +636,7 @@ Priority: Environment variables > `config.yaml` settings 1. Piece movement `model` (highest priority) 2. Custom agent `model` 3. Global config `model` -4. Provider default (Claude: sonnet, Codex: codex) +4. Provider default (Claude: sonnet, Codex: codex, OpenCode: provider default) ## Detailed Guides @@ -796,7 +798,7 @@ Special `next` values: `COMPLETE` (success), `ABORT` (failure) | `edit` | - | Whether movement can edit project files (`true`/`false`) | | `pass_previous_response` | `true` | Pass previous movement output to `{previous_response}` | | `allowed_tools` | - | List of tools agent can use (Read, Glob, Grep, Edit, Write, Bash, etc.) | -| `provider` | - | Override provider for this movement (`claude` or `codex`) | +| `provider` | - | Override provider for this movement (`claude`, `codex`, or `opencode`) | | `model` | - | Override model for this movement | | `permission_mode` | - | Permission mode: `readonly`, `edit`, `full` (provider-independent) | | `output_contracts` | - | Output contract definitions for report files | @@ -874,7 +876,7 @@ npm install -g takt takt --pipeline --task "Fix bug" --auto-pr --repo owner/repo ``` -For authentication, set `TAKT_ANTHROPIC_API_KEY` or `TAKT_OPENAI_API_KEY` environment variables (TAKT-specific prefix). +For authentication, set `TAKT_ANTHROPIC_API_KEY`, `TAKT_OPENAI_API_KEY`, or `TAKT_OPENCODE_API_KEY` environment variables (TAKT-specific prefix). ```bash # For Claude (Anthropic) @@ -882,6 +884,9 @@ export TAKT_ANTHROPIC_API_KEY=sk-ant-... # For Codex (OpenAI) export TAKT_OPENAI_API_KEY=sk-... + +# For OpenCode +export TAKT_OPENCODE_API_KEY=... ``` ## Documentation diff --git a/docs/README.ja.md b/docs/README.ja.md index 258a37a3..9ab545cb 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -2,7 +2,7 @@ **T**ask **A**gent **K**oordination **T**ool - AIエージェントの協調手順・人の介入ポイント・記録をYAMLで定義する -TAKTは複数のAIエージェント(Claude Code、Codex)をYAMLで定義されたワークフローに従って実行します。各ステップで誰が実行し、何を見て、何を許可し、失敗時にどうするかはピースファイルに宣言され、エージェント任せにしません。 +TAKTは複数のAIエージェント(Claude Code、Codex、OpenCode)をYAMLで定義されたワークフローに従って実行します。各ステップで誰が実行し、何を見て、何を許可し、失敗時にどうするかはピースファイルに宣言され、エージェント任せにしません。 TAKTはTAKT自身で開発されています(ドッグフーディング)。 @@ -45,14 +45,14 @@ TAKTはエージェントの実行を**制御**し、プロンプトの構成要 次のいずれかを選択してください。 -- **プロバイダーCLIを使用**: [Claude Code](https://docs.anthropic.com/en/docs/claude-code) または [Codex](https://github.com/openai/codex) をインストール -- **API直接利用**: **Anthropic API Key** または **OpenAI API Key**(CLI不要) +- **プロバイダーCLIを使用**: [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Codex](https://github.com/openai/codex)、または [OpenCode](https://opencode.ai) をインストール +- **API直接利用**: **Anthropic API Key**、**OpenAI API Key**、または **OpenCode API Key**(CLI不要) 追加で必要なもの: - [GitHub CLI](https://cli.github.com/) (`gh`) — `takt #N`(GitHub Issue実行)を使う場合のみ必要 -**料金について**: API Key を使用する場合、TAKT は Claude API(Anthropic)または OpenAI API を直接呼び出します。料金体系は Claude Code や Codex を使った場合と同じです。特に CI/CD で自動実行する場合、API 使用量が増えるため、コストに注意してください。 +**料金について**: API Key を使用する場合、TAKT は Claude API(Anthropic)、OpenAI API、または OpenCode API を直接呼び出します。料金体系は各 CLI ツールを使った場合と同じです。特に CI/CD で自動実行する場合、API 使用量が増えるため、コストに注意してください。 ## インストール @@ -322,7 +322,7 @@ takt reset categories | `--repo ` | リポジトリ指定(PR作成時) | | `--create-worktree ` | worktree確認プロンプトをスキップ | | `-q, --quiet` | 最小限の出力モード: AIの出力を抑制(CI向け) | -| `--provider ` | エージェントプロバイダーを上書き(claude\|codex\|mock) | +| `--provider ` | エージェントプロバイダーを上書き(claude\|codex\|opencode\|mock) | | `--model ` | エージェントモデルを上書き | ## ピース @@ -473,6 +473,7 @@ TAKTには複数のビルトインピースが同梱されています: | `structural-reform` | プロジェクト全体の構造改革: 段階的なファイル分割を伴う反復的なコードベース再構成。 | | `unit-test` | ユニットテスト重視ピース: テスト分析 → テスト実装 → レビュー → 修正。 | | `e2e-test` | E2Eテスト重視ピース: E2E分析 → E2E実装 → レビュー → 修正(VitestベースのE2Eフロー)。 | +| `frontend` | フロントエンド特化開発ピース: React/Next.js 向けのレビューとナレッジ注入。 | **ペルソナ別プロバイダー設定:** 設定ファイルの `persona_providers` で、特定のペルソナを異なるプロバイダーにルーティングできます(例: coder は Codex、レビュアーは Claude)。ピースを複製する必要はありません。 @@ -560,7 +561,7 @@ Claude Code はエイリアス(`opus`、`sonnet`、`haiku`、`opusplan`、`def language: ja default_piece: default log_level: info -provider: claude # デフォルトプロバイダー: claude または codex +provider: claude # デフォルトプロバイダー: claude、codex、または opencode model: sonnet # デフォルトモデル(オプション) branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速) prevent_sleep: false # macOS の実行中スリープ防止(caffeinate) @@ -582,9 +583,10 @@ interactive_preview_movements: 3 # 対話モードでのムーブメントプ # ai-antipattern-reviewer: claude # レビュアーは Claude のまま # API Key 設定(オプション) -# 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY で上書き可能 +# 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY で上書き可能 anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合 # openai_api_key: sk-... # Codex (OpenAI) を使う場合 +# opencode_api_key: ... # OpenCode を使う場合 # ビルトインピースのフィルタリング(オプション) # builtin_pieces_enabled: true # false でビルトイン全体を無効化 @@ -608,17 +610,17 @@ anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合 1. **環境変数で設定**: ```bash export TAKT_ANTHROPIC_API_KEY=sk-ant-... # Claude の場合 - # または export TAKT_OPENAI_API_KEY=sk-... # Codex の場合 + export TAKT_OPENCODE_API_KEY=... # OpenCode の場合 ``` 2. **設定ファイルで設定**: - 上記の `~/.takt/config.yaml` に `anthropic_api_key` または `openai_api_key` を記述 + 上記の `~/.takt/config.yaml` に `anthropic_api_key`、`openai_api_key`、または `opencode_api_key` を記述 優先順位: 環境変数 > `config.yaml` の設定 **注意事項:** -- API Key を設定した場合、Claude Code や Codex のインストールは不要です。TAKT が直接 Anthropic API または OpenAI API を呼び出します。 +- API Key を設定した場合、Claude Code、Codex、OpenCode のインストールは不要です。TAKT が直接各 API を呼び出します。 - **セキュリティ**: `config.yaml` に API Key を記述した場合、このファイルを Git にコミットしないよう注意してください。環境変数での設定を使うか、`.gitignore` に `~/.takt/config.yaml` を追加することを検討してください。 **パイプラインテンプレート変数:** @@ -634,7 +636,7 @@ anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合 1. ピースのムーブメントの `model`(最優先) 2. カスタムエージェントの `model` 3. グローバル設定の `model` -4. プロバイダーデフォルト(Claude: sonnet、Codex: codex) +4. プロバイダーデフォルト(Claude: sonnet、Codex: codex、OpenCode: プロバイダーデフォルト) ## 詳細ガイド @@ -796,7 +798,7 @@ rules: | `edit` | - | ムーブメントがプロジェクトファイルを編集できるか(`true`/`false`) | | `pass_previous_response` | `true` | 前のムーブメントの出力を`{previous_response}`に渡す | | `allowed_tools` | - | エージェントが使用できるツール一覧(Read, Glob, Grep, Edit, Write, Bash等) | -| `provider` | - | このムーブメントのプロバイダーを上書き(`claude`または`codex`) | +| `provider` | - | このムーブメントのプロバイダーを上書き(`claude`、`codex`、または`opencode`) | | `model` | - | このムーブメントのモデルを上書き | | `permission_mode` | - | パーミッションモード: `readonly`、`edit`、`full`(プロバイダー非依存) | | `output_contracts` | - | レポートファイルの出力契約定義 | @@ -874,7 +876,7 @@ npm install -g takt takt --pipeline --task "バグ修正" --auto-pr --repo owner/repo ``` -認証には `TAKT_ANTHROPIC_API_KEY` または `TAKT_OPENAI_API_KEY` 環境変数を設定してください(TAKT 独自のプレフィックス付き)。 +認証には `TAKT_ANTHROPIC_API_KEY`、`TAKT_OPENAI_API_KEY`、または `TAKT_OPENCODE_API_KEY` 環境変数を設定してください(TAKT 独自のプレフィックス付き)。 ```bash # Claude (Anthropic) を使う場合 @@ -882,6 +884,9 @@ export TAKT_ANTHROPIC_API_KEY=sk-ant-... # Codex (OpenAI) を使う場合 export TAKT_OPENAI_API_KEY=sk-... + +# OpenCode を使う場合 +export TAKT_OPENCODE_API_KEY=... ``` ## ドキュメント diff --git a/package-lock.json b/package-lock.json index fa308cde..8d8d0c96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "takt", - "version": "0.11.1", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.37", diff --git a/package.json b/package.json index 0b737653..7b36cdeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "takt", - "version": "0.11.1", + "version": "0.12.0", "description": "TAKT: Task Agent Koordination Tool - AI Agent Piece Orchestration", "main": "dist/index.js", "types": "dist/index.d.ts", From 1705a33a88b4a5b98e9ca442ffe72d26d18677b6 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:32:11 +0900 Subject: [PATCH 45/45] provider event log default false --- e2e/specs/add-and-run.e2e.ts | 4 ++-- e2e/specs/watch.e2e.ts | 2 +- src/__tests__/providerEventLogger.test.ts | 11 +++++++---- src/core/models/global-config.ts | 2 +- src/shared/utils/providerEventLogger.ts | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/e2e/specs/add-and-run.e2e.ts b/e2e/specs/add-and-run.e2e.ts index 3b7c47bf..ac91c0ec 100644 --- a/e2e/specs/add-and-run.e2e.ts +++ b/e2e/specs/add-and-run.e2e.ts @@ -74,10 +74,10 @@ describe('E2E: Add task and run (takt add → takt run)', () => { const readme = readFileSync(readmePath, 'utf-8'); expect(readme).toContain('E2E test passed'); - // Verify task status became completed + // Verify completed task was removed from tasks.yaml const tasksRaw = readFileSync(tasksFile, 'utf-8'); const parsed = parseYaml(tasksRaw) as { tasks?: Array<{ name?: string; status?: string }> }; const executed = parsed.tasks?.find((task) => task.name === 'e2e-test-task'); - expect(executed?.status).toBe('completed'); + expect(executed).toBeUndefined(); }, 240_000); }); diff --git a/e2e/specs/watch.e2e.ts b/e2e/specs/watch.e2e.ts index 29e0c180..f16318c2 100644 --- a/e2e/specs/watch.e2e.ts +++ b/e2e/specs/watch.e2e.ts @@ -96,6 +96,6 @@ 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?.status).toBe('completed'); + expect(watchTask).toBeUndefined(); }, 240_000); }); diff --git a/src/__tests__/providerEventLogger.test.ts b/src/__tests__/providerEventLogger.test.ts index 08d361fc..d2ac74ac 100644 --- a/src/__tests__/providerEventLogger.test.ts +++ b/src/__tests__/providerEventLogger.test.ts @@ -20,10 +20,13 @@ describe('providerEventLogger', () => { rmSync(tempDir, { recursive: true, force: true }); }); - it('should enable provider events by default', () => { - expect(isProviderEventsEnabled()).toBe(true); - expect(isProviderEventsEnabled({})).toBe(true); - expect(isProviderEventsEnabled({ observability: {} })).toBe(true); + it('should disable provider events by default', () => { + expect(isProviderEventsEnabled()).toBe(false); + expect(isProviderEventsEnabled({})).toBe(false); + expect(isProviderEventsEnabled({ observability: {} })).toBe(false); + }); + + it('should enable provider events only when explicitly true', () => { expect(isProviderEventsEnabled({ observability: { providerEvents: true } })).toBe(true); }); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 89223a20..7ab9db56 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -22,7 +22,7 @@ export interface DebugConfig { /** Observability configuration for runtime event logs */ export interface ObservabilityConfig { - /** Enable provider stream event logging (default: true when undefined) */ + /** Enable provider stream event logging (default: false when undefined) */ providerEvents?: boolean; } diff --git a/src/shared/utils/providerEventLogger.ts b/src/shared/utils/providerEventLogger.ts index 70b36308..0789e90e 100644 --- a/src/shared/utils/providerEventLogger.ts +++ b/src/shared/utils/providerEventLogger.ts @@ -133,5 +133,5 @@ export function isProviderEventsEnabled(config?: { providerEvents?: boolean; }; }): boolean { - return config?.observability?.providerEvents !== false; + return config?.observability?.providerEvents === true; }