takt: update-report-path-variable

This commit is contained in:
nrslib 2026-01-30 02:13:13 +09:00
parent f7181fc00c
commit 79227dffd1
7 changed files with 278 additions and 22 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "takt", "name": "takt",
"version": "0.2.5", "version": "0.2.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "takt", "name": "takt",
"version": "0.2.5", "version": "0.2.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.19", "@anthropic-ai/claude-agent-sdk": "^0.2.19",

View File

@ -374,8 +374,8 @@ describe('instruction-builder', () => {
const result = buildInstruction(step, context); 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 File: .takt/reports/20260129-test/00-plan.md'); expect(result).toContain('- Report File: 20260129-test/00-plan.md');
expect(result).not.toContain('Report Files:'); expect(result).not.toContain('Report Files:');
}); });
@ -393,13 +393,29 @@ describe('instruction-builder', () => {
const result = buildInstruction(step, context); 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('- Report Files:');
expect(result).toContain(' - Scope: .takt/reports/20260129-test/01-scope.md'); expect(result).toContain(' - Scope: 20260129-test/01-scope.md');
expect(result).toContain(' - Decisions: .takt/reports/20260129-test/02-decisions.md'); expect(result).toContain(' - Decisions: 20260129-test/02-decisions.md');
expect(result).not.toContain('Report File:'); 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', () => { it('should NOT include report info when reportDir is undefined', () => {
const step = createMinimalStep('Do work'); const step = createMinimalStep('Do work');
step.report = '00-plan.md'; step.report = '00-plan.md';
@ -437,6 +453,164 @@ describe('instruction-builder', () => {
expect(result).toContain('- Step Iteration: 3このステップの実行回数'); 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', () => { describe('auto-injected User Request and Additional User Inputs sections', () => {

View File

@ -8,7 +8,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, dirname, basename } from 'node:path'; import { join, dirname, basename } from 'node:path';
import { parse as parseYaml } from 'yaml'; import { parse as parseYaml } from 'yaml';
import { WorkflowConfigRawSchema } from '../models/schemas.js'; 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'; import { getGlobalWorkflowsDir } from './paths.js';
/** Get builtin workflow by name */ /** Get builtin workflow by name */
@ -54,6 +54,13 @@ function extractAgentDisplayName(agentPath: string): string {
return filename; 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. * Normalize the raw report field from YAML into internal format.
* *
@ -62,16 +69,23 @@ function extractAgentDisplayName(agentPath: string): string {
* report: ReportConfig[] (multiple files) * report: ReportConfig[] (multiple files)
* - Scope: 01-scope.md * - Scope: 01-scope.md
* - Decisions: 02-decisions.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"}, ...] * Array items are parsed as single-key objects: [{Scope: "01-scope.md"}, ...]
*/ */
function normalizeReport( function normalizeReport(
raw: string | Record<string, string>[] | undefined, raw: string | Record<string, string>[] | { name: string; order?: string; format?: string } | undefined,
): string | ReportConfig[] | undefined { ): string | ReportConfig[] | ReportObjectConfig | undefined {
if (raw == null) return undefined; if (raw == null) return undefined;
if (typeof raw === 'string') return raw; 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"}, ...] // Convert [{Scope: "01-scope.md"}, ...] to [{label: "Scope", path: "01-scope.md"}, ...]
return raw.flatMap((entry) => return (raw as Record<string, string>[]).flatMap((entry) =>
Object.entries(entry).map(([label, path]) => ({ label, path })), Object.entries(entry).map(([label, path]) => ({ label, path })),
); );
} }

View File

@ -3,6 +3,7 @@ export type {
AgentType, AgentType,
Status, Status,
ReportConfig, ReportConfig,
ReportObjectConfig,
AgentResponse, AgentResponse,
SessionState, SessionState,
WorkflowStep, WorkflowStep,

View File

@ -27,20 +27,48 @@ export const StatusSchema = z.enum([
/** Permission mode schema for tool execution */ /** Permission mode schema for tool execution */
export const PermissionModeSchema = z.enum(['default', 'acceptEdits', 'bypassPermissions']); 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. * Report field schema.
* *
* YAML formats: * YAML formats:
* report: 00-plan.md # single file * report: 00-plan.md # single file (string)
* report: # multiple files (label: path map entries) * report: # multiple files (label: path map entries)
* - Scope: 01-scope.md * - Scope: 01-scope.md
* - Decisions: 02-decisions.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"}, ...] * Array items are parsed as single-key objects: [{Scope: "01-scope.md"}, ...]
*/ */
export const ReportFieldSchema = z.union([ export const ReportFieldSchema = z.union([
z.string().min(1), z.string().min(1),
z.array(z.record(z.string(), z.string())).min(1), z.array(z.record(z.string(), z.string())).min(1),
ReportObjectSchema,
]); ]);
/** Rule-based transition schema (new unified format) */ /** Rule-based transition schema (new unified format) */

View File

@ -50,7 +50,7 @@ export interface WorkflowRule {
appendix?: string; appendix?: string;
} }
/** Report file configuration for a workflow step */ /** Report file configuration for a workflow step (label: path pair) */
export interface ReportConfig { export interface ReportConfig {
/** Display label (e.g., "Scope", "Decisions") */ /** Display label (e.g., "Scope", "Decisions") */
label: string; label: string;
@ -58,6 +58,16 @@ export interface ReportConfig {
path: string; 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 */ /** Permission mode for tool execution */
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions'; export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
@ -81,8 +91,8 @@ export interface WorkflowStep {
instructionTemplate: string; instructionTemplate: string;
/** Rules for step routing */ /** Rules for step routing */
rules?: WorkflowRule[]; rules?: WorkflowRule[];
/** Report file configuration. Single string for one file, array for multiple. */ /** Report file configuration. Single string, array of label:path, or object with order/format. */
report?: string | ReportConfig[]; report?: string | ReportConfig[] | ReportObjectConfig;
passPreviousResponse: boolean; passPreviousResponse: boolean;
} }

View File

@ -8,7 +8,7 @@
* 3. Appending auto-generated status rules from workflow rules * 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'; import { getGitDiff } from '../agents/runner.js';
/** /**
@ -216,6 +216,13 @@ function escapeTemplateChars(str: string): string {
return str.replace(/\{/g, '').replace(/\}/g, ''); 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 */ /** Localized strings for auto-injected sections */
const SECTION_STRINGS = { const SECTION_STRINGS = {
en: { en: {
@ -268,16 +275,19 @@ function renderWorkflowContext(
// Report info (only if step has report config AND reportDir is available) // Report info (only if step has report config AND reportDir is available)
if (step.report && context.reportDir) { if (step.report && context.reportDir) {
lines.push(`- ${s.reportDirectory}: .takt/reports/${context.reportDir}/`); lines.push(`- ${s.reportDirectory}: ${context.reportDir}/`);
if (typeof step.report === 'string') { if (typeof step.report === 'string') {
// Single file // Single file (string form)
lines.push(`- ${s.reportFile}: .takt/reports/${context.reportDir}/${step.report}`); 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 { } else {
// Multiple files // Multiple files (ReportConfig[] form)
lines.push(`- ${s.reportFiles}:`); lines.push(`- ${s.reportFiles}:`);
for (const file of step.report as ReportConfig[]) { 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); 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; return result;
} }
@ -403,7 +420,13 @@ export function buildInstruction(
sections.push(`${s.additionalUserInputs}\n${escapeTemplateChars(userInputsStr)}`); 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( const processedTemplate = replaceTemplatePlaceholders(
step.instructionTemplate, step.instructionTemplate,
step, step,
@ -411,6 +434,12 @@ export function buildInstruction(
); );
sections.push(`${s.instructions}\n${processedTemplate}`); 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) // 7. Status rules (auto-generated from rules)
if (step.rules && step.rules.length > 0) { if (step.rules && step.rules.length > 0) {
const statusHeader = renderStatusRulesHeader(language); const statusHeader = renderStatusRulesHeader(language);