diff --git a/src/__tests__/instructionBuilder.test.ts b/src/__tests__/instructionBuilder.test.ts index c7a5ecc..d168624 100644 --- a/src/__tests__/instructionBuilder.test.ts +++ b/src/__tests__/instructionBuilder.test.ts @@ -5,12 +5,14 @@ import { describe, it, expect } from 'vitest'; import { buildInstruction, + buildReportInstruction, buildExecutionMetadata, renderExecutionMetadata, renderStatusRulesHeader, generateStatusRulesFromRules, isReportObjectConfig, type InstructionContext, + type ReportInstructionContext, } from '../workflow/instruction-builder.js'; import type { WorkflowStep, WorkflowRule } from '../models/types.js'; @@ -444,7 +446,7 @@ describe('instruction-builder', () => { expect(result).toContain('- Step: implement'); }); - it('should include single report file when report is a string', () => { + it('should NOT include report info even when step has report (phase separation)', () => { const step = createMinimalStep('Do work'); step.name = 'plan'; step.report = '00-plan.md'; @@ -455,14 +457,13 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - expect(result).toContain('- Report Directory: 20260129-test/'); - expect(result).toContain('- Report File: 20260129-test/00-plan.md'); - expect(result).not.toContain('Report Files:'); + expect(result).toContain('## Workflow Context'); + expect(result).not.toContain('Report Directory'); + expect(result).not.toContain('Report File'); }); - it('should include multiple report files when report is ReportConfig[]', () => { + it('should NOT include report info for ReportConfig[] (phase separation)', () => { const step = createMinimalStep('Do work'); - step.name = 'implement'; step.report = [ { label: 'Scope', path: '01-scope.md' }, { label: 'Decisions', path: '02-decisions.md' }, @@ -474,16 +475,12 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - expect(result).toContain('- Report Directory: 20260129-test/'); - expect(result).toContain('- Report Files:'); - expect(result).toContain(' - Scope: 20260129-test/01-scope.md'); - expect(result).toContain(' - Decisions: 20260129-test/02-decisions.md'); - expect(result).not.toContain('Report File:'); + expect(result).not.toContain('Report Directory'); + expect(result).not.toContain('Report Files'); }); - it('should include report file when report is ReportObjectConfig', () => { + it('should NOT include report info for ReportObjectConfig (phase separation)', () => { const step = createMinimalStep('Do work'); - step.name = 'plan'; step.report = { name: '00-plan.md' }; const context = createMinimalContext({ reportDir: '20260129-test', @@ -492,33 +489,6 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - expect(result).toContain('- Report Directory: 20260129-test/'); - expect(result).toContain('- Report File: 20260129-test/00-plan.md'); - expect(result).not.toContain('Report Files:'); - }); - - it('should NOT include report info when reportDir is undefined', () => { - const step = createMinimalStep('Do work'); - step.report = '00-plan.md'; - const context = createMinimalContext({ language: 'en' }); - - const result = buildInstruction(step, context); - - expect(result).toContain('## Workflow Context'); - expect(result).not.toContain('Report Directory'); - expect(result).not.toContain('Report File'); - }); - - it('should NOT include report info when step has no report', () => { - const step = createMinimalStep('Do work'); - const context = createMinimalContext({ - reportDir: '20260129-test', - language: 'en', - }); - - const result = buildInstruction(step, context); - - expect(result).toContain('## Workflow Context'); expect(result).not.toContain('Report Directory'); expect(result).not.toContain('Report File'); }); @@ -534,99 +504,10 @@ describe('instruction-builder', () => { expect(result).toContain('- Step Iteration: 3(このステップの実行回数)'); }); - - it('should NOT include .takt/reports/ prefix in report paths', () => { - const step = createMinimalStep('Do work'); - step.report = '00-plan.md'; - const context = createMinimalContext({ - reportDir: '20260129-test', - language: 'en', - }); - - const result = buildInstruction(step, context); - - expect(result).not.toContain('.takt/reports/'); - }); }); - describe('ReportObjectConfig order/format injection', () => { - it('should inject order before instruction_template', () => { - const step = createMinimalStep('Do work'); - step.report = { - name: '00-plan.md', - order: '**Output:** Write to {report:00-plan.md}', - }; - const context = createMinimalContext({ - reportDir: '20260129-test', - language: 'en', - }); - - const result = buildInstruction(step, context); - - const orderIdx = result.indexOf('**Output:** Write to 20260129-test/00-plan.md'); - const instructionsIdx = result.indexOf('## Instructions'); - expect(orderIdx).toBeGreaterThan(-1); - expect(instructionsIdx).toBeGreaterThan(orderIdx); - }); - - it('should inject format after instruction_template', () => { - const step = createMinimalStep('Do work'); - step.report = { - name: '00-plan.md', - format: '**Format:**\n```markdown\n# Plan\n```', - }; - const context = createMinimalContext({ - reportDir: '20260129-test', - language: 'en', - }); - - const result = buildInstruction(step, context); - - const instructionsIdx = result.indexOf('## Instructions'); - const formatIdx = result.indexOf('**Format:**'); - expect(formatIdx).toBeGreaterThan(instructionsIdx); - }); - - it('should inject both order before and format after instruction_template', () => { - const step = createMinimalStep('Do work'); - step.report = { - name: '00-plan.md', - order: '**Output:** Write to {report:00-plan.md}', - format: '**Format:**\n```markdown\n# Plan\n```', - }; - const context = createMinimalContext({ - reportDir: '20260129-test', - language: 'en', - }); - - const result = buildInstruction(step, context); - - const orderIdx = result.indexOf('**Output:** Write to 20260129-test/00-plan.md'); - const instructionsIdx = result.indexOf('## Instructions'); - const formatIdx = result.indexOf('**Format:**'); - expect(orderIdx).toBeGreaterThan(-1); - expect(instructionsIdx).toBeGreaterThan(orderIdx); - expect(formatIdx).toBeGreaterThan(instructionsIdx); - }); - - it('should replace {report:filename} in order text', () => { - const step = createMinimalStep('Do work'); - step.report = { - name: '00-plan.md', - order: 'Output to {report:00-plan.md} file.', - }; - const context = createMinimalContext({ - reportDir: '20260129-test', - language: 'en', - }); - - const result = buildInstruction(step, context); - - expect(result).toContain('Output to 20260129-test/00-plan.md file.'); - expect(result).not.toContain('{report:00-plan.md}'); - }); - - it('should auto-inject report output instruction when report is a simple string', () => { + describe('buildInstruction report-free (phase separation)', () => { + it('should NOT include report output instruction in buildInstruction', () => { const step = createMinimalStep('Do work'); step.report = '00-plan.md'; const context = createMinimalContext({ @@ -636,20 +517,14 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - // Auto-generated report output instruction should be injected before ## Instructions - expect(result).toContain('**Report output:** Output to the `Report File` specified above.'); - expect(result).toContain('- If file does not exist: Create new file'); - const reportIdx = result.indexOf('**Report output:**'); - const instructionsIdx = result.indexOf('## Instructions'); - expect(reportIdx).toBeGreaterThan(-1); - expect(instructionsIdx).toBeGreaterThan(reportIdx); + expect(result).not.toContain('**Report output:**'); + expect(result).not.toContain('Report File'); + expect(result).not.toContain('Report Directory'); }); - it('should auto-inject report output instruction when report is ReportConfig[]', () => { + it('should NOT include report format in buildInstruction', () => { const step = createMinimalStep('Do work'); - step.report = [ - { label: 'Scope', path: '01-scope.md' }, - ]; + step.report = { name: '00-plan.md', format: '**Format:**\n# Plan' }; const context = createMinimalContext({ reportDir: '20260129-test', language: 'en', @@ -657,84 +532,10 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - // Auto-generated multi-file report output instruction - expect(result).toContain('**Report output:** Output to the `Report Files` specified above.'); - expect(result).toContain('- If file does not exist: Create new file'); + expect(result).not.toContain('**Format:**'); }); - it('should replace {report:filename} in instruction_template too', () => { - const step = createMinimalStep('Write to {report:00-plan.md}'); - const context = createMinimalContext({ - reportDir: '20260129-test', - language: 'en', - }); - - const result = buildInstruction(step, context); - - expect(result).toContain('Write to 20260129-test/00-plan.md'); - expect(result).not.toContain('{report:00-plan.md}'); - }); - - it('should replace {step_iteration} in order/format text', () => { - const step = createMinimalStep('Do work'); - step.report = { - name: '00-plan.md', - order: 'Append ## Iteration {step_iteration} section', - }; - const context = createMinimalContext({ - reportDir: '20260129-test', - stepIteration: 3, - language: 'en', - }); - - const result = buildInstruction(step, context); - - expect(result).toContain('Append ## Iteration 3 section'); - }); - - it('should auto-inject Japanese report output instruction for ja language', () => { - const step = createMinimalStep('作業する'); - step.report = { name: '00-plan.md' }; - const context = createMinimalContext({ - reportDir: '20260129-test', - language: 'ja', - }); - - const result = buildInstruction(step, context); - - expect(result).toContain('**レポート出力:** `Report File` に出力してください。'); - expect(result).toContain('- ファイルが存在しない場合: 新規作成'); - expect(result).toContain('- ファイルが存在する場合: `## Iteration 1` セクションを追記'); - }); - - it('should auto-inject Japanese multi-file report output instruction', () => { - const step = createMinimalStep('作業する'); - step.report = [{ label: 'Scope', path: '01-scope.md' }]; - const context = createMinimalContext({ - reportDir: '20260129-test', - language: 'ja', - }); - - const result = buildInstruction(step, context); - - expect(result).toContain('**レポート出力:** Report Files に出力してください。'); - }); - - it('should replace {step_iteration} in auto-generated report output instruction', () => { - const step = createMinimalStep('Do work'); - step.report = '00-plan.md'; - const context = createMinimalContext({ - reportDir: '20260129-test', - stepIteration: 5, - language: 'en', - }); - - const result = buildInstruction(step, context); - - expect(result).toContain('Append with `## Iteration 5` section'); - }); - - it('should prefer explicit order over auto-generated report instruction', () => { + it('should NOT include report order in buildInstruction', () => { const step = createMinimalStep('Do work'); step.report = { name: '00-plan.md', @@ -747,13 +548,11 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - expect(result).toContain('Custom order instruction'); - expect(result).not.toContain('**Report output:**'); + expect(result).not.toContain('Custom order instruction'); }); - it('should auto-inject report output for ReportObjectConfig without order', () => { - const step = createMinimalStep('Do work'); - step.report = { name: '00-plan.md', format: '# Plan' }; + it('should still replace {report:filename} in instruction_template', () => { + const step = createMinimalStep('Write to {report:00-plan.md}'); const context = createMinimalContext({ reportDir: '20260129-test', language: 'en', @@ -761,20 +560,195 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - expect(result).toContain('**Report output:** Output to the `Report File` specified above.'); + expect(result).toContain('Write to 20260129-test/00-plan.md'); + expect(result).not.toContain('{report:00-plan.md}'); }); + }); - it('should NOT inject report output when no reportDir', () => { + describe('buildReportInstruction (phase 2)', () => { + function createReportContext(overrides: Partial = {}): ReportInstructionContext { + return { + cwd: '/project', + reportDir: '20260129-test', + stepIteration: 1, + language: 'en', + ...overrides, + }; + } + + it('should include execution context with working directory', () => { const step = createMinimalStep('Do work'); step.report = '00-plan.md'; - const context = createMinimalContext({ - language: 'en', - }); + const ctx = createReportContext({ cwd: '/my/project' }); - const result = buildInstruction(step, context); + const result = buildReportInstruction(step, ctx); + expect(result).toContain('Working Directory: /my/project'); + }); + + it('should include no-source-edit rule in execution rules', () => { + const step = createMinimalStep('Do work'); + step.report = '00-plan.md'; + const ctx = createReportContext(); + + const result = buildReportInstruction(step, ctx); + + expect(result).toContain('Do NOT modify project source files'); + }); + + it('should include no-commit and no-cd rules', () => { + const step = createMinimalStep('Do work'); + step.report = '00-plan.md'; + const ctx = createReportContext(); + + const result = buildReportInstruction(step, ctx); + + expect(result).toContain('Do NOT run git commit'); + expect(result).toContain('Do NOT use `cd`'); + }); + + it('should include report directory and file for string report', () => { + const step = createMinimalStep('Do work'); + step.report = '00-plan.md'; + const ctx = createReportContext({ reportDir: '20260130-test' }); + + const result = buildReportInstruction(step, ctx); + + expect(result).toContain('- Report Directory: 20260130-test/'); + expect(result).toContain('- Report File: 20260130-test/00-plan.md'); + }); + + it('should include report files for ReportConfig[] report', () => { + const step = createMinimalStep('Do work'); + step.report = [ + { label: 'Scope', path: '01-scope.md' }, + { label: 'Decisions', path: '02-decisions.md' }, + ]; + const ctx = createReportContext(); + + const result = buildReportInstruction(step, ctx); + + expect(result).toContain('- Report Directory: 20260129-test/'); + expect(result).toContain('- Report Files:'); + expect(result).toContain(' - Scope: 20260129-test/01-scope.md'); + expect(result).toContain(' - Decisions: 20260129-test/02-decisions.md'); + }); + + it('should include report file for ReportObjectConfig report', () => { + const step = createMinimalStep('Do work'); + step.report = { name: '00-plan.md' }; + const ctx = createReportContext(); + + const result = buildReportInstruction(step, ctx); + + expect(result).toContain('- Report File: 20260129-test/00-plan.md'); + }); + + it('should include auto-generated report output instruction', () => { + const step = createMinimalStep('Do work'); + step.report = '00-plan.md'; + const ctx = createReportContext(); + + const result = buildReportInstruction(step, ctx); + + 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'); + }); + + it('should include explicit order instead of auto-generated', () => { + const step = createMinimalStep('Do work'); + step.report = { + name: '00-plan.md', + order: 'Output to {report:00-plan.md} file.', + }; + const ctx = createReportContext(); + + const result = buildReportInstruction(step, ctx); + + expect(result).toContain('Output to 20260129-test/00-plan.md file.'); expect(result).not.toContain('**Report output:**'); }); + + it('should include format from ReportObjectConfig', () => { + const step = createMinimalStep('Do work'); + step.report = { + name: '00-plan.md', + format: '**Format:**\n```markdown\n# Plan\n```', + }; + const ctx = createReportContext(); + + const result = buildReportInstruction(step, ctx); + + expect(result).toContain('**Format:**'); + expect(result).toContain('# Plan'); + }); + + it('should replace {step_iteration} in report output instruction', () => { + const step = createMinimalStep('Do work'); + step.report = '00-plan.md'; + const ctx = createReportContext({ stepIteration: 5 }); + + const result = buildReportInstruction(step, ctx); + + expect(result).toContain('Append with `## Iteration 5` section'); + }); + + it('should include instruction body text', () => { + const step = createMinimalStep('Do work'); + step.report = '00-plan.md'; + const ctx = createReportContext(); + + const result = buildReportInstruction(step, ctx); + + expect(result).toContain('## Instructions'); + expect(result).toContain('Output the results of your previous work as a report'); + }); + + it('should NOT include user request, previous response, or status rules', () => { + const step = createMinimalStep('Do work'); + step.report = '00-plan.md'; + step.rules = [ + { condition: 'Done', next: 'COMPLETE' }, + ]; + const ctx = createReportContext(); + + const result = buildReportInstruction(step, ctx); + + expect(result).not.toContain('User Request'); + expect(result).not.toContain('Previous Response'); + expect(result).not.toContain('Additional User Inputs'); + expect(result).not.toContain('Status Output Rules'); + }); + + it('should render Japanese report instruction', () => { + const step = createMinimalStep('作業する'); + step.report = { name: '00-plan.md' }; + const ctx = createReportContext({ language: 'ja' }); + + const result = buildReportInstruction(step, ctx); + + expect(result).toContain('前のステップの作業結果をレポートとして出力してください'); + expect(result).toContain('プロジェクトのソースファイルを変更しないでください'); + expect(result).toContain('**レポート出力:** `Report File` に出力してください。'); + }); + + it('should throw error when step has no report config', () => { + const step = createMinimalStep('Do work'); + const ctx = createReportContext(); + + expect(() => buildReportInstruction(step, ctx)).toThrow('no report config'); + }); + + it('should include multi-file report output instruction for ReportConfig[]', () => { + const step = createMinimalStep('Do work'); + step.report = [{ label: 'Scope', path: '01-scope.md' }]; + const ctx = createReportContext(); + + const result = buildReportInstruction(step, ctx); + + expect(result).toContain('**Report output:** Output to the `Report Files` specified above.'); + }); }); describe('auto-injected User Request and Additional User Inputs sections', () => { diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 62c4c40..531898d 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -31,6 +31,8 @@ export interface RunAgentOptions { agentPath?: string; /** Allowed tools for this agent run */ allowedTools?: string[]; + /** Maximum number of agentic turns */ + maxTurns?: number; /** Permission mode for tool execution (from workflow step) */ permissionMode?: PermissionMode; onStream?: StreamCallback; @@ -82,6 +84,7 @@ export async function runCustomAgent( cwd: options.cwd, sessionId: options.sessionId, allowedTools, + maxTurns: options.maxTurns, model: resolveModel(options.cwd, options, agentConfig), permissionMode: options.permissionMode, onStream: options.onStream, @@ -98,6 +101,7 @@ export async function runCustomAgent( cwd: options.cwd, sessionId: options.sessionId, allowedTools, + maxTurns: options.maxTurns, model: resolveModel(options.cwd, options, agentConfig), permissionMode: options.permissionMode, onStream: options.onStream, @@ -118,6 +122,7 @@ export async function runCustomAgent( cwd: options.cwd, sessionId: options.sessionId, allowedTools, + maxTurns: options.maxTurns, model: resolveModel(options.cwd, options, agentConfig), permissionMode: options.permissionMode, onStream: options.onStream, @@ -195,6 +200,7 @@ export async function runAgent( cwd: options.cwd, sessionId: options.sessionId, allowedTools: options.allowedTools, + maxTurns: options.maxTurns, model: resolveModel(options.cwd, options), systemPrompt, permissionMode: options.permissionMode, diff --git a/src/providers/claude.ts b/src/providers/claude.ts index e60ef74..9c8aec3 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -14,6 +14,7 @@ export class ClaudeProvider implements Provider { sessionId: options.sessionId, allowedTools: options.allowedTools, model: options.model, + maxTurns: options.maxTurns, systemPrompt: options.systemPrompt, permissionMode: options.permissionMode, onStream: options.onStream, @@ -31,6 +32,7 @@ export class ClaudeProvider implements Provider { sessionId: options.sessionId, allowedTools: options.allowedTools, model: options.model, + maxTurns: options.maxTurns, permissionMode: options.permissionMode, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, diff --git a/src/providers/index.ts b/src/providers/index.ts index c8d9c7f..5b013d0 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -18,6 +18,8 @@ export interface ProviderCallOptions { model?: string; systemPrompt?: string; allowedTools?: string[]; + /** Maximum number of agentic turns */ + maxTurns?: number; /** Permission mode for tool execution (from workflow step) */ permissionMode?: PermissionMode; onStream?: StreamCallback; diff --git a/src/workflow/engine.ts b/src/workflow/engine.ts index ae11961..34a5c43 100644 --- a/src/workflow/engine.ts +++ b/src/workflow/engine.ts @@ -16,7 +16,7 @@ import { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from './constants.js'; import type { WorkflowEngineOptions } from './types.js'; import { determineNextStepByRules } from './transitions.js'; import { detectRuleIndex, callAiJudge } from '../claude/client.js'; -import { buildInstruction as buildInstructionFromTemplate, isReportObjectConfig } from './instruction-builder.js'; +import { buildInstruction as buildInstructionFromTemplate, buildReportInstruction as buildReportInstructionFromTemplate, isReportObjectConfig } from './instruction-builder.js'; import { LoopDetector } from './loop-detector.js'; import { handleBlocked } from './blocked-handler.js'; import { @@ -206,11 +206,16 @@ export class WorkflowEngine extends EventEmitter { /** Build RunAgentOptions from a step's configuration */ private buildAgentOptions(step: WorkflowStep): RunAgentOptions { + // Phase 1: exclude Write from allowedTools when step has report config + const allowedTools = step.report + ? step.allowedTools?.filter((t) => t !== 'Write') + : step.allowedTools; + return { cwd: this.cwd, sessionId: this.state.agentSessions.get(step.agent), agentPath: step.agentPath, - allowedTools: step.allowedTools, + allowedTools, provider: step.provider, model: step.model, permissionMode: step.permissionMode, @@ -266,11 +271,17 @@ export class WorkflowEngine extends EventEmitter { sessionId: this.state.agentSessions.get(step.agent) ?? 'new', }); + // Phase 1: main execution (Write excluded if step has report) const agentOptions = this.buildAgentOptions(step); let response = await runAgent(step.agent, instruction, agentOptions); - this.updateAgentSession(step.agent, response.sessionId); + // Phase 2: report output (resume same session, Write only) + if (step.report) { + await this.runReportPhase(step, stepIteration); + } + + // Status detection uses phase 1 response const matchedRuleIndex = await this.detectMatchedRule(step, response.content); if (matchedRuleIndex != null) { response = { ...response, matchedRuleIndex }; @@ -281,6 +292,50 @@ export class WorkflowEngine extends EventEmitter { return { response, instruction }; } + /** + * Phase 2: Report output. + * Resumes the agent session with Write-only tools to output reports. + * The response is discarded — only sessionId is updated. + */ + private async runReportPhase(step: WorkflowStep, stepIteration: number): Promise { + const sessionId = this.state.agentSessions.get(step.agent); + if (!sessionId) { + log.debug('Skipping report phase: no sessionId to resume', { step: step.name }); + return; + } + + log.debug('Running report phase', { step: step.name, sessionId }); + + const reportInstruction = buildReportInstructionFromTemplate(step, { + cwd: this.cwd, + reportDir: this.reportDir, + stepIteration, + language: this.language, + }); + + const reportOptions: RunAgentOptions = { + cwd: this.cwd, + sessionId, + agentPath: step.agentPath, + allowedTools: ['Write'], + maxTurns: 3, + provider: step.provider, + model: step.model, + permissionMode: step.permissionMode, + onStream: this.options.onStream, + onPermissionRequest: this.options.onPermissionRequest, + onAskUserQuestion: this.options.onAskUserQuestion, + bypassPermissions: this.options.bypassPermissions, + }; + + const reportResponse = await runAgent(step.agent, reportInstruction, reportOptions); + + // Update session (phase 2 may update it) + this.updateAgentSession(step.agent, reportResponse.sessionId); + + log.debug('Report phase complete', { step: step.name, status: reportResponse.status }); + } + /** * Run a parallel step: execute all sub-steps concurrently, then aggregate results. * The aggregated output becomes the parent step's response for rules evaluation. @@ -300,11 +355,16 @@ export class WorkflowEngine extends EventEmitter { const subIteration = incrementStepIteration(this.state, subStep.name); const subInstruction = this.buildInstruction(subStep, subIteration); + // Phase 1: main execution (Write excluded if sub-step has report) const agentOptions = this.buildAgentOptions(subStep); const subResponse = await runAgent(subStep.agent, subInstruction, agentOptions); - this.updateAgentSession(subStep.agent, subResponse.sessionId); + // Phase 2: report output for sub-step + if (subStep.report) { + await this.runReportPhase(subStep, subIteration); + } + // Detect sub-step rule matches (tag detection + ai() fallback) const matchedRuleIndex = await this.detectMatchedRule(subStep, subResponse.content); const finalResponse = matchedRuleIndex != null diff --git a/src/workflow/instruction-builder.ts b/src/workflow/instruction-builder.ts index 71abf8d..1dabef0 100644 --- a/src/workflow/instruction-builder.ts +++ b/src/workflow/instruction-builder.ts @@ -323,22 +323,31 @@ function renderWorkflowContext( `- ${s.step}: ${step.name}`, ]; - // Report info (only if step has report config AND reportDir is available) - if (step.report && context.reportDir) { - lines.push(`- ${s.reportDirectory}: ${context.reportDir}/`); + return lines.join('\n'); +} - if (typeof step.report === 'string') { - // Single file (string form) - lines.push(`- ${s.reportFile}: ${context.reportDir}/${step.report}`); - } else if (isReportObjectConfig(step.report)) { - // Object form (name + order + format) - lines.push(`- ${s.reportFile}: ${context.reportDir}/${step.report.name}`); - } else { - // Multiple files (ReportConfig[] form) - lines.push(`- ${s.reportFiles}:`); - for (const file of step.report as ReportConfig[]) { - lines.push(` - ${file.label}: ${context.reportDir}/${file.path}`); - } +/** + * Render report info for the Workflow Context section. + * Used only by buildReportInstruction() (phase 2). + */ +function renderReportContext( + report: string | ReportConfig[] | ReportObjectConfig, + reportDir: string, + language: Language, +): string { + const s = SECTION_STRINGS[language]; + const lines: string[] = [ + `- ${s.reportDirectory}: ${reportDir}/`, + ]; + + if (typeof report === 'string') { + lines.push(`- ${s.reportFile}: ${reportDir}/${report}`); + } else if (isReportObjectConfig(report)) { + lines.push(`- ${s.reportFile}: ${reportDir}/${report.name}`); + } else { + lines.push(`- ${s.reportFiles}:`); + for (const file of report) { + lines.push(` - ${file.label}: ${reportDir}/${file.path}`); } } @@ -466,20 +475,7 @@ export function buildInstruction( sections.push(`${s.additionalUserInputs}\n${escapeTemplateChars(userInputsStr)}`); } - // 6a. Report output instruction (auto-generated from step.report) - // If ReportObjectConfig has an explicit `order:`, use that (backward compat). - // Otherwise, auto-generate from the report declaration. - if (step.report && isReportObjectConfig(step.report) && step.report.order) { - const processedOrder = replaceTemplatePlaceholders(step.report.order.trimEnd(), step, context); - sections.push(processedOrder); - } else { - const reportInstruction = renderReportOutputInstruction(step, context, language); - if (reportInstruction) { - sections.push(reportInstruction); - } - } - - // 6b. Instructions header + instruction_template content + // 6. Instructions header + instruction_template content const processedTemplate = replaceTemplatePlaceholders( step.instructionTemplate, step, @@ -487,12 +483,6 @@ export function buildInstruction( ); sections.push(`${s.instructions}\n${processedTemplate}`); - // 6c. Report format (appended after instruction_template, from ReportObjectConfig) - if (step.report && isReportObjectConfig(step.report) && step.report.format) { - const processedFormat = replaceTemplatePlaceholders(step.report.format.trimEnd(), step, context); - sections.push(processedFormat); - } - // 7. Status rules (auto-generated from rules) // Skip when ALL rules are ai() conditions — agent doesn't need to output status tags if (step.rules && step.rules.length > 0) { @@ -506,3 +496,120 @@ export function buildInstruction( return sections.join('\n\n'); } + +/** Localized strings for report phase execution rules */ +const REPORT_PHASE_STRINGS = { + en: { + noSourceEdit: '**Do NOT modify project source files.** Only output report files.', + instructionBody: 'Output the results of your previous work as a report.', + }, + ja: { + noSourceEdit: '**プロジェクトのソースファイルを変更しないでください。** レポートファイルのみ出力してください。', + instructionBody: '前のステップの作業結果をレポートとして出力してください。', + }, +} as const; + +/** + * Context for building report phase instruction. + */ +export interface ReportInstructionContext { + /** Working directory */ + cwd: string; + /** Report directory path */ + reportDir: string; + /** Step iteration (for {step_iteration} replacement) */ + stepIteration: number; + /** Language */ + language?: Language; +} + +/** + * Build instruction for phase 2 (report output). + * + * Separate from buildInstruction() — only includes: + * - Execution Context (cwd + rules) + * - Workflow Context (report info only) + * - Report output instruction + format + * + * Does NOT include: User Request, Previous Response, User Inputs, + * Status rules, instruction_template. + */ +export function buildReportInstruction( + step: WorkflowStep, + context: ReportInstructionContext, +): string { + if (!step.report) { + throw new Error(`buildReportInstruction called for step "${step.name}" which has no report config`); + } + + const language = context.language ?? 'en'; + const s = SECTION_STRINGS[language]; + const r = REPORT_PHASE_STRINGS[language]; + const m = METADATA_STRINGS[language]; + const sections: string[] = []; + + // 1. Execution Context + const execLines = [ + m.heading, + `- ${m.workingDirectory}: ${context.cwd}`, + '', + m.rulesHeading, + `- ${m.noCommit}`, + `- ${m.noCd}`, + `- ${r.noSourceEdit}`, + ]; + if (m.note) { + execLines.push(''); + execLines.push(m.note); + } + execLines.push(''); + sections.push(execLines.join('\n')); + + // 2. Workflow Context (report info only) + const workflowLines = [ + s.workflowContext, + renderReportContext(step.report, context.reportDir, language), + ]; + sections.push(workflowLines.join('\n')); + + // 3. Instructions + report output instruction + format + const instrParts: string[] = [ + `${s.instructions}`, + r.instructionBody, + ]; + + // Report output instruction (auto-generated or explicit order) + const reportContext: InstructionContext = { + task: '', + iteration: 0, + maxIterations: 0, + stepIteration: context.stepIteration, + cwd: context.cwd, + userInputs: [], + reportDir: context.reportDir, + language, + }; + + if (isReportObjectConfig(step.report) && step.report.order) { + const processedOrder = replaceTemplatePlaceholders(step.report.order.trimEnd(), step, reportContext); + instrParts.push(''); + instrParts.push(processedOrder); + } else { + const reportInstruction = renderReportOutputInstruction(step, reportContext, language); + if (reportInstruction) { + instrParts.push(''); + instrParts.push(reportInstruction); + } + } + + // Report format + if (isReportObjectConfig(step.report) && step.report.format) { + const processedFormat = replaceTemplatePlaceholders(step.report.format.trimEnd(), step, reportContext); + instrParts.push(''); + instrParts.push(processedFormat); + } + + sections.push(instrParts.join('\n')); + + return sections.join('\n\n'); +}