From 60f7c0851dd6c6d9426fd9e8407159db379b80ec Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:17:40 +0900 Subject: [PATCH] =?UTF-8?q?worktree=E3=82=92=E5=88=A9=E7=94=A8=E3=81=97?= =?UTF-8?q?=E3=81=9F=E9=9A=9B=E3=81=AB=E5=88=A5=E3=81=AE=E5=A0=B4=E6=89=80?= =?UTF-8?q?=E3=81=A7=E4=BD=9C=E6=A5=AD=E3=81=97=E3=81=A6=E3=81=97=E3=81=BE?= =?UTF-8?q?=E3=81=86=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/__tests__/instructionBuilder.test.ts | 148 +++++++++++++++++++++-- src/workflow/index.ts | 8 +- src/workflow/instruction-builder.ts | 48 ++++++++ 3 files changed, 193 insertions(+), 11 deletions(-) diff --git a/src/__tests__/instructionBuilder.test.ts b/src/__tests__/instructionBuilder.test.ts index 686ff41..98d8205 100644 --- a/src/__tests__/instructionBuilder.test.ts +++ b/src/__tests__/instructionBuilder.test.ts @@ -3,7 +3,12 @@ */ import { describe, it, expect } from 'vitest'; -import { buildInstruction, type InstructionContext } from '../workflow/instruction-builder.js'; +import { + buildInstruction, + buildExecutionMetadata, + renderExecutionMetadata, + type InstructionContext, +} from '../workflow/instruction-builder.js'; import type { WorkflowStep } from '../models/types.js'; function createMinimalStep(template: string): WorkflowStep { @@ -30,6 +35,60 @@ function createMinimalContext(overrides: Partial = {}): Inst } describe('instruction-builder', () => { + describe('execution context metadata', () => { + it('should always include Working Directory', () => { + const step = createMinimalStep('Do some work'); + const context = createMinimalContext({ cwd: '/project' }); + + const result = buildInstruction(step, context); + + expect(result).toContain('## Execution Context'); + expect(result).toContain('Working Directory: /project'); + expect(result).toContain('Do some work'); + }); + + it('should include Project Root and Mode when cwd !== projectCwd', () => { + const step = createMinimalStep('Do some work'); + const context = createMinimalContext({ + cwd: '/worktree-path', + projectCwd: '/project-path', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('## Execution Context'); + expect(result).toContain('Working Directory: /worktree-path'); + expect(result).toContain('Project Root: /project-path'); + expect(result).toContain('Mode: worktree'); + expect(result).toContain('Do some work'); + }); + + it('should NOT include Project Root or Mode when cwd === projectCwd', () => { + const step = createMinimalStep('Do some work'); + const context = createMinimalContext({ + cwd: '/project', + projectCwd: '/project', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('Working Directory: /project'); + expect(result).not.toContain('Project Root'); + expect(result).not.toContain('Mode:'); + }); + + it('should NOT include Project Root or Mode when projectCwd is not set', () => { + const step = createMinimalStep('Do some work'); + const context = createMinimalContext({ cwd: '/project' }); + + const result = buildInstruction(step, context); + + expect(result).toContain('Working Directory: /project'); + expect(result).not.toContain('Project Root'); + expect(result).not.toContain('Mode:'); + }); + }); + describe('report_dir replacement', () => { it('should replace .takt/reports/{report_dir} with full absolute path', () => { const step = createMinimalStep( @@ -42,7 +101,7 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - expect(result).toBe( + expect(result).toContain( '- Report Directory: /project/.takt/reports/20260128-test-report/' ); }); @@ -59,11 +118,11 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - expect(result).toBe( + expect(result).toContain( '- Report: /project/.takt/reports/20260128-worktree-report/00-plan.md' ); - // Should NOT contain the worktree path - expect(result).not.toContain('/project/.takt/worktrees/'); + expect(result).toContain('Working Directory: /project/.takt/worktrees/my-task'); + expect(result).toContain('Project Root: /project'); }); it('should replace multiple .takt/reports/{report_dir} occurrences', () => { @@ -92,7 +151,7 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - expect(result).toBe('Report dir name: 20260128-standalone'); + expect(result).toContain('Report dir name: 20260128-standalone'); }); it('should fall back to cwd when projectCwd is not provided', () => { @@ -103,16 +162,85 @@ describe('instruction-builder', () => { cwd: '/fallback-project', reportDir: '20260128-fallback', }); - // projectCwd intentionally omitted const result = buildInstruction(step, context); - expect(result).toBe( + expect(result).toContain( '- Dir: /fallback-project/.takt/reports/20260128-fallback/' ); }); }); + describe('buildExecutionMetadata', () => { + it('should set workingDirectory and omit projectRoot in normal mode', () => { + const context = createMinimalContext({ cwd: '/project' }); + const metadata = buildExecutionMetadata(context); + + expect(metadata.workingDirectory).toBe('/project'); + expect(metadata.projectRoot).toBeUndefined(); + }); + + it('should set projectRoot in worktree mode', () => { + const context = createMinimalContext({ + cwd: '/worktree-path', + projectCwd: '/project-path', + }); + const metadata = buildExecutionMetadata(context); + + expect(metadata.workingDirectory).toBe('/worktree-path'); + expect(metadata.projectRoot).toBe('/project-path'); + }); + + it('should omit projectRoot when projectCwd is not set', () => { + const context = createMinimalContext({ cwd: '/project' }); + // projectCwd is undefined by default + const metadata = buildExecutionMetadata(context); + + expect(metadata.workingDirectory).toBe('/project'); + expect(metadata.projectRoot).toBeUndefined(); + }); + + it('should omit projectRoot when cwd equals projectCwd', () => { + const context = createMinimalContext({ + cwd: '/same-path', + projectCwd: '/same-path', + }); + const metadata = buildExecutionMetadata(context); + + expect(metadata.workingDirectory).toBe('/same-path'); + expect(metadata.projectRoot).toBeUndefined(); + }); + }); + + describe('renderExecutionMetadata', () => { + it('should render normal mode without Project Root or Mode', () => { + const rendered = renderExecutionMetadata({ workingDirectory: '/project' }); + + expect(rendered).toContain('## Execution Context'); + expect(rendered).toContain('- Working Directory: /project'); + expect(rendered).not.toContain('Project Root'); + expect(rendered).not.toContain('Mode:'); + }); + + it('should render worktree mode with Project Root and Mode', () => { + const rendered = renderExecutionMetadata({ + workingDirectory: '/worktree', + projectRoot: '/project', + }); + + expect(rendered).toContain('## Execution Context'); + expect(rendered).toContain('- Working Directory: /worktree'); + expect(rendered).toContain('- Project Root: /project'); + expect(rendered).toContain('- Mode: worktree'); + }); + + it('should end with a trailing empty line', () => { + const rendered = renderExecutionMetadata({ workingDirectory: '/project' }); + + expect(rendered).toMatch(/\n$/); + }); + }); + describe('basic placeholder replacement', () => { it('should replace {task} placeholder', () => { const step = createMinimalStep('Execute: {task}'); @@ -129,7 +257,7 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - expect(result).toBe('Step 3/20'); + expect(result).toContain('Step 3/20'); }); it('should replace {step_iteration}', () => { @@ -138,7 +266,7 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - expect(result).toBe('Run #2'); + expect(result).toContain('Run #2'); }); }); }); diff --git a/src/workflow/index.ts b/src/workflow/index.ts index f4b654b..92fc6ab 100644 --- a/src/workflow/index.ts +++ b/src/workflow/index.ts @@ -37,7 +37,13 @@ export { } from './state-manager.js'; // Instruction building -export { buildInstruction, type InstructionContext } from './instruction-builder.js'; +export { + buildInstruction, + buildExecutionMetadata, + renderExecutionMetadata, + type InstructionContext, + type ExecutionMetadata, +} from './instruction-builder.js'; // Blocked handling export { handleBlocked, type BlockedHandlerResult } from './blocked-handler.js'; diff --git a/src/workflow/instruction-builder.ts b/src/workflow/instruction-builder.ts index 2de0b77..1a22dcd 100644 --- a/src/workflow/instruction-builder.ts +++ b/src/workflow/instruction-builder.ts @@ -33,6 +33,50 @@ export interface InstructionContext { reportDir?: string; } +/** Execution environment metadata prepended to agent instructions */ +export interface ExecutionMetadata { + /** The agent's working directory (may be a worktree) */ + readonly workingDirectory: string; + /** Project root where .takt/ lives. Present only in worktree mode. */ + readonly projectRoot?: string; +} + +/** + * Build execution metadata from instruction context. + * + * Pure function: InstructionContext → ExecutionMetadata. + * Sets `projectRoot` only when cwd differs from projectCwd (worktree mode). + */ +export function buildExecutionMetadata(context: InstructionContext): ExecutionMetadata { + const projectRoot = context.projectCwd ?? context.cwd; + const isWorktree = context.cwd !== projectRoot; + + return { + workingDirectory: context.cwd, + ...(isWorktree ? { projectRoot } : {}), + }; +} + +/** + * Render execution metadata as a markdown string. + * + * Pure function: ExecutionMetadata → string. + * Always includes `## Execution Context` + `Working Directory`. + * Adds `Project Root` and `Mode` only in worktree mode (when projectRoot is present). + */ +export function renderExecutionMetadata(metadata: ExecutionMetadata): string { + const lines = [ + '## Execution Context', + `- Working Directory: ${metadata.workingDirectory}`, + ]; + if (metadata.projectRoot !== undefined) { + lines.push(`- Project Root: ${metadata.projectRoot}`); + lines.push('- Mode: worktree (source edits in Working Directory, reports in Project Root)'); + } + lines.push(''); + return lines.join('\n'); +} + /** * Escape special characters in dynamic content to prevent template injection. */ @@ -101,6 +145,10 @@ export function buildInstruction( instruction = instruction.replace(/\{report_dir\}/g, context.reportDir); } + // Prepend execution context metadata. + const metadata = buildExecutionMetadata(context); + instruction = `${renderExecutionMetadata(metadata)}\n${instruction}`; + // Append status_rules_prompt if present if (step.statusRulesPrompt) { instruction = `${instruction}\n\n${step.statusRulesPrompt}`;