レポート出力をフェーズ2に分離し、本体実行からWriteを除外

ステップ実行を2フェーズに分離:
- フェーズ1(本体): allowed_toolsからWriteを除外、レポート情報を注入しない
- フェーズ2(レポート出力): 同一セッションresume、Writeのみ付与、ステータス検出なし

buildInstruction()からレポート関連コードを削除し、
buildReportInstruction()を新設してレポート出力の責務を完全分離。
This commit is contained in:
nrslib 2026-01-30 15:26:56 +09:00
parent 70651f8dd8
commit 9c597a9b0d
6 changed files with 419 additions and 268 deletions

View File

@ -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> = {}): 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', () => {

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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<void> {
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

View File

@ -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');
}