From 79227dffd10489c8740d130add1000000c189362 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:13:13 +0900 Subject: [PATCH] takt: update-report-path-variable --- package-lock.json | 4 +- src/__tests__/instructionBuilder.test.ts | 184 ++++++++++++++++++++++- src/config/workflowLoader.ts | 22 ++- src/models/index.ts | 1 + src/models/schemas.ts | 30 +++- src/models/types.ts | 16 +- src/workflow/instruction-builder.ts | 43 +++++- 7 files changed, 278 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0f2da26..0cfd65d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "takt", - "version": "0.2.5", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.2.5", + "version": "0.2.3", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.19", diff --git a/src/__tests__/instructionBuilder.test.ts b/src/__tests__/instructionBuilder.test.ts index 21c0db4..94363db 100644 --- a/src/__tests__/instructionBuilder.test.ts +++ b/src/__tests__/instructionBuilder.test.ts @@ -374,8 +374,8 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - expect(result).toContain('- Report Directory: .takt/reports/20260129-test/'); - expect(result).toContain('- Report File: .takt/reports/20260129-test/00-plan.md'); + expect(result).toContain('- Report Directory: 20260129-test/'); + expect(result).toContain('- Report File: 20260129-test/00-plan.md'); expect(result).not.toContain('Report Files:'); }); @@ -393,13 +393,29 @@ describe('instruction-builder', () => { const result = buildInstruction(step, context); - expect(result).toContain('- Report Directory: .takt/reports/20260129-test/'); + expect(result).toContain('- Report Directory: 20260129-test/'); expect(result).toContain('- Report Files:'); - expect(result).toContain(' - Scope: .takt/reports/20260129-test/01-scope.md'); - expect(result).toContain(' - Decisions: .takt/reports/20260129-test/02-decisions.md'); + expect(result).toContain(' - Scope: 20260129-test/01-scope.md'); + expect(result).toContain(' - Decisions: 20260129-test/02-decisions.md'); expect(result).not.toContain('Report File:'); }); + it('should include report file when report is ReportObjectConfig', () => { + const step = createMinimalStep('Do work'); + step.name = 'plan'; + step.report = { name: '00-plan.md' }; + const context = createMinimalContext({ + reportDir: '20260129-test', + language: 'en', + }); + + 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'; @@ -437,6 +453,164 @@ 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 not inject order/format when report is a simple string', () => { + const step = createMinimalStep('Do work'); + step.report = '00-plan.md'; + const context = createMinimalContext({ + reportDir: '20260129-test', + language: 'en', + }); + + const result = buildInstruction(step, context); + + // Should contain instructions normally + expect(result).toContain('## Instructions'); + expect(result).toContain('Do work'); + // The instruction should appear right after Additional User Inputs, not after any order section + const additionalIdx = result.indexOf('## Additional User Inputs'); + const instructionsIdx = result.indexOf('## Instructions'); + expect(additionalIdx).toBeGreaterThan(-1); + expect(instructionsIdx).toBeGreaterThan(additionalIdx); + }); + + it('should not inject order/format when report is ReportConfig[]', () => { + const step = createMinimalStep('Do work'); + step.report = [ + { label: 'Scope', path: '01-scope.md' }, + ]; + const context = createMinimalContext({ + reportDir: '20260129-test', + language: 'en', + }); + + const result = buildInstruction(step, context); + + // Just verify normal behavior without extra sections + expect(result).toContain('## Instructions'); + expect(result).toContain('Do work'); + }); + + 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'); + }); }); describe('auto-injected User Request and Additional User Inputs sections', () => { diff --git a/src/config/workflowLoader.ts b/src/config/workflowLoader.ts index d0e7656..b0a1956 100644 --- a/src/config/workflowLoader.ts +++ b/src/config/workflowLoader.ts @@ -8,7 +8,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; import { join, dirname, basename } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { WorkflowConfigRawSchema } from '../models/schemas.js'; -import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig } from '../models/types.js'; +import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../models/types.js'; import { getGlobalWorkflowsDir } from './paths.js'; /** Get builtin workflow by name */ @@ -54,6 +54,13 @@ function extractAgentDisplayName(agentPath: string): string { return filename; } +/** + * Check if a raw report value is the object form (has 'name' property). + */ +function isReportObject(raw: unknown): raw is { name: string; order?: string; format?: string } { + return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw; +} + /** * Normalize the raw report field from YAML into internal format. * @@ -62,16 +69,23 @@ function extractAgentDisplayName(agentPath: string): string { * report: → ReportConfig[] (multiple files) * - Scope: 01-scope.md * - Decisions: 02-decisions.md + * report: → ReportObjectConfig (object form) + * name: 00-plan.md + * order: ... + * format: ... * * Array items are parsed as single-key objects: [{Scope: "01-scope.md"}, ...] */ function normalizeReport( - raw: string | Record[] | undefined, -): string | ReportConfig[] | undefined { + raw: string | Record[] | { name: string; order?: string; format?: string } | undefined, +): string | ReportConfig[] | ReportObjectConfig | undefined { if (raw == null) return undefined; if (typeof raw === 'string') return raw; + if (isReportObject(raw)) { + return { name: raw.name, order: raw.order, format: raw.format }; + } // Convert [{Scope: "01-scope.md"}, ...] to [{label: "Scope", path: "01-scope.md"}, ...] - return raw.flatMap((entry) => + return (raw as Record[]).flatMap((entry) => Object.entries(entry).map(([label, path]) => ({ label, path })), ); } diff --git a/src/models/index.ts b/src/models/index.ts index 9c97d95..5b3e865 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -3,6 +3,7 @@ export type { AgentType, Status, ReportConfig, + ReportObjectConfig, AgentResponse, SessionState, WorkflowStep, diff --git a/src/models/schemas.ts b/src/models/schemas.ts index c63328a..7da615e 100644 --- a/src/models/schemas.ts +++ b/src/models/schemas.ts @@ -27,20 +27,48 @@ export const StatusSchema = z.enum([ /** Permission mode schema for tool execution */ export const PermissionModeSchema = z.enum(['default', 'acceptEdits', 'bypassPermissions']); +/** + * Report object schema (new structured format). + * + * YAML format: + * report: + * name: 00-plan.md + * order: | + * **レポート出力:** {report:00-plan.md} に出力してください。 + * format: | + * **レポートフォーマット:** + * ```markdown + * ... + * ``` + */ +export const ReportObjectSchema = z.object({ + /** Report file name */ + name: z.string().min(1), + /** Instruction prepended before instruction_template (e.g., output destination) */ + order: z.string().optional(), + /** Instruction appended after instruction_template (e.g., output format) */ + format: z.string().optional(), +}); + /** * Report field schema. * * YAML formats: - * report: 00-plan.md # single file + * report: 00-plan.md # single file (string) * report: # multiple files (label: path map entries) * - Scope: 01-scope.md * - Decisions: 02-decisions.md + * report: # object form (name + order + format) + * name: 00-plan.md + * order: ... + * format: ... * * Array items are parsed as single-key objects: [{Scope: "01-scope.md"}, ...] */ export const ReportFieldSchema = z.union([ z.string().min(1), z.array(z.record(z.string(), z.string())).min(1), + ReportObjectSchema, ]); /** Rule-based transition schema (new unified format) */ diff --git a/src/models/types.ts b/src/models/types.ts index 7c1c8b9..0390d1d 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -50,7 +50,7 @@ export interface WorkflowRule { appendix?: string; } -/** Report file configuration for a workflow step */ +/** Report file configuration for a workflow step (label: path pair) */ export interface ReportConfig { /** Display label (e.g., "Scope", "Decisions") */ label: string; @@ -58,6 +58,16 @@ export interface ReportConfig { path: string; } +/** Report object configuration with order/format instructions */ +export interface ReportObjectConfig { + /** Report file name (e.g., "00-plan.md") */ + name: string; + /** Instruction prepended before instruction_template (e.g., output destination) */ + order?: string; + /** Instruction appended after instruction_template (e.g., output format) */ + format?: string; +} + /** Permission mode for tool execution */ export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions'; @@ -81,8 +91,8 @@ export interface WorkflowStep { instructionTemplate: string; /** Rules for step routing */ rules?: WorkflowRule[]; - /** Report file configuration. Single string for one file, array for multiple. */ - report?: string | ReportConfig[]; + /** Report file configuration. Single string, array of label:path, or object with order/format. */ + report?: string | ReportConfig[] | ReportObjectConfig; passPreviousResponse: boolean; } diff --git a/src/workflow/instruction-builder.ts b/src/workflow/instruction-builder.ts index 676a083..80e66d3 100644 --- a/src/workflow/instruction-builder.ts +++ b/src/workflow/instruction-builder.ts @@ -8,7 +8,7 @@ * 3. Appending auto-generated status rules from workflow rules */ -import type { WorkflowStep, WorkflowRule, AgentResponse, Language, ReportConfig } from '../models/types.js'; +import type { WorkflowStep, WorkflowRule, AgentResponse, Language, ReportConfig, ReportObjectConfig } from '../models/types.js'; import { getGitDiff } from '../agents/runner.js'; /** @@ -216,6 +216,13 @@ function escapeTemplateChars(str: string): string { return str.replace(/\{/g, '{').replace(/\}/g, '}'); } +/** + * Check if a report config is the object form (ReportObjectConfig). + */ +function isReportObjectConfig(report: string | ReportConfig[] | ReportObjectConfig): report is ReportObjectConfig { + return typeof report === 'object' && !Array.isArray(report) && 'name' in report; +} + /** Localized strings for auto-injected sections */ const SECTION_STRINGS = { en: { @@ -268,16 +275,19 @@ function renderWorkflowContext( // Report info (only if step has report config AND reportDir is available) if (step.report && context.reportDir) { - lines.push(`- ${s.reportDirectory}: .takt/reports/${context.reportDir}/`); + lines.push(`- ${s.reportDirectory}: ${context.reportDir}/`); if (typeof step.report === 'string') { - // Single file - lines.push(`- ${s.reportFile}: .takt/reports/${context.reportDir}/${step.report}`); + // 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 + // Multiple files (ReportConfig[] form) lines.push(`- ${s.reportFiles}:`); for (const file of step.report as ReportConfig[]) { - lines.push(` - ${file.label}: .takt/reports/${context.reportDir}/${file.path}`); + lines.push(` - ${file.label}: ${context.reportDir}/${file.path}`); } } } @@ -341,6 +351,13 @@ function replaceTemplatePlaceholders( result = result.replace(/\{report_dir\}/g, context.reportDir); } + // Replace {report:filename} with reportDir/filename + if (context.reportDir) { + result = result.replace(/\{report:([^}]+)\}/g, (_match, filename: string) => { + return `${context.reportDir}/${filename}`; + }); + } + return result; } @@ -403,7 +420,13 @@ export function buildInstruction( sections.push(`${s.additionalUserInputs}\n${escapeTemplateChars(userInputsStr)}`); } - // 6. Instructions header + instruction_template content + // 6a. Report order (prepended before instruction_template, from ReportObjectConfig) + if (step.report && isReportObjectConfig(step.report) && step.report.order) { + const processedOrder = replaceTemplatePlaceholders(step.report.order.trimEnd(), step, context); + sections.push(processedOrder); + } + + // 6b. Instructions header + instruction_template content const processedTemplate = replaceTemplatePlaceholders( step.instructionTemplate, step, @@ -411,6 +434,12 @@ 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) if (step.rules && step.rules.length > 0) { const statusHeader = renderStatusRulesHeader(language);