diff --git a/package.json b/package.json index 6b9418b..7176db2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "takt-cli": "./dist/app/cli/index.js" }, "scripts": { - "build": "tsc && mkdir -p dist/shared/prompts && cp src/shared/prompts/prompts_en.yaml src/shared/prompts/prompts_ja.yaml dist/shared/prompts/", + "build": "tsc && mkdir -p dist/shared/prompts dist/shared/i18n && cp src/shared/prompts/prompts_en.yaml src/shared/prompts/prompts_ja.yaml dist/shared/prompts/ && cp src/shared/i18n/labels_en.yaml src/shared/i18n/labels_ja.yaml dist/shared/i18n/", "watch": "tsc --watch", "test": "vitest run", "test:watch": "vitest", diff --git a/src/__tests__/i18n.test.ts b/src/__tests__/i18n.test.ts new file mode 100644 index 0000000..f317efc --- /dev/null +++ b/src/__tests__/i18n.test.ts @@ -0,0 +1,140 @@ +/** + * Tests for UI label loader utility (src/shared/i18n/index.ts) + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { getLabel, getLabelObject, _resetLabelCache } from '../shared/i18n/index.js'; + +beforeEach(() => { + _resetLabelCache(); +}); + +describe('getLabel', () => { + it('returns a label by key (defaults to en)', () => { + const result = getLabel('interactive.ui.intro'); + expect(result).toContain('Interactive mode'); + }); + + it('returns an English label when lang is "en"', () => { + const result = getLabel('interactive.ui.intro', 'en'); + expect(result).toContain('Interactive mode'); + }); + + it('returns a Japanese label when lang is "ja"', () => { + const result = getLabel('interactive.ui.intro', 'ja'); + expect(result).toContain('対話モード'); + }); + + it('throws for a non-existent key', () => { + expect(() => getLabel('nonexistent.key')).toThrow('Label key not found: nonexistent.key'); + }); + + it('throws for a non-existent key with language', () => { + expect(() => getLabel('nonexistent.key', 'en')).toThrow('Label key not found: nonexistent.key (lang: en)'); + }); + + describe('template variable substitution', () => { + it('replaces {variableName} placeholders with provided values', () => { + const result = getLabel('workflow.iterationLimit.maxReached', undefined, { + currentIteration: '5', + maxIterations: '10', + }); + expect(result).toContain('(5/10)'); + }); + + it('replaces single variable', () => { + const result = getLabel('workflow.notifyComplete', undefined, { + iteration: '3', + }); + expect(result).toContain('3 iterations'); + }); + + it('leaves unmatched placeholders as-is', () => { + const result = getLabel('workflow.notifyAbort', undefined, {}); + expect(result).toContain('{reason}'); + }); + }); +}); + +describe('getLabelObject', () => { + it('returns interactive UI text object', () => { + const result = getLabelObject<{ intro: string }>('interactive.ui', 'en'); + expect(result.intro).toContain('Interactive mode'); + }); + + it('returns Japanese interactive UI text object', () => { + const result = getLabelObject<{ intro: string }>('interactive.ui', 'ja'); + expect(result.intro).toContain('対話モード'); + }); + + it('throws for a non-existent key', () => { + expect(() => getLabelObject('nonexistent.key')).toThrow('Label key not found: nonexistent.key'); + }); +}); + +describe('caching', () => { + it('returns the same data on repeated calls', () => { + const first = getLabel('interactive.ui.intro'); + const second = getLabel('interactive.ui.intro'); + expect(first).toBe(second); + }); + + it('reloads after cache reset', () => { + const first = getLabel('interactive.ui.intro'); + _resetLabelCache(); + const second = getLabel('interactive.ui.intro'); + expect(first).toBe(second); + }); +}); + +describe('label integrity', () => { + it('contains all expected interactive UI keys in en', () => { + const ui = getLabelObject>('interactive.ui', 'en'); + expect(ui).toHaveProperty('intro'); + expect(ui).toHaveProperty('resume'); + expect(ui).toHaveProperty('noConversation'); + expect(ui).toHaveProperty('summarizeFailed'); + expect(ui).toHaveProperty('continuePrompt'); + expect(ui).toHaveProperty('proposed'); + expect(ui).toHaveProperty('confirm'); + expect(ui).toHaveProperty('cancelled'); + }); + + it('contains all expected workflow keys in en', () => { + expect(() => getLabel('workflow.iterationLimit.maxReached')).not.toThrow(); + expect(() => getLabel('workflow.iterationLimit.currentStep')).not.toThrow(); + expect(() => getLabel('workflow.iterationLimit.continueQuestion')).not.toThrow(); + expect(() => getLabel('workflow.iterationLimit.continueLabel')).not.toThrow(); + expect(() => getLabel('workflow.iterationLimit.continueDescription')).not.toThrow(); + expect(() => getLabel('workflow.iterationLimit.stopLabel')).not.toThrow(); + expect(() => getLabel('workflow.iterationLimit.inputPrompt')).not.toThrow(); + expect(() => getLabel('workflow.iterationLimit.invalidInput')).not.toThrow(); + expect(() => getLabel('workflow.iterationLimit.userInputPrompt')).not.toThrow(); + expect(() => getLabel('workflow.notifyComplete')).not.toThrow(); + expect(() => getLabel('workflow.notifyAbort')).not.toThrow(); + expect(() => getLabel('workflow.sigintGraceful')).not.toThrow(); + expect(() => getLabel('workflow.sigintForce')).not.toThrow(); + }); + + it('en and ja have the same key structure', () => { + const stringKeys = [ + 'interactive.ui.intro', + 'interactive.ui.cancelled', + 'workflow.iterationLimit.maxReached', + 'workflow.notifyComplete', + 'workflow.sigintGraceful', + ]; + for (const key of stringKeys) { + expect(() => getLabel(key, 'en')).not.toThrow(); + expect(() => getLabel(key, 'ja')).not.toThrow(); + } + + const objectKeys = [ + 'interactive.ui', + ]; + for (const key of objectKeys) { + expect(() => getLabelObject(key, 'en')).not.toThrow(); + expect(() => getLabelObject(key, 'ja')).not.toThrow(); + } + }); +}); diff --git a/src/__tests__/prompts.test.ts b/src/__tests__/prompts.test.ts index 4e6b752..875f652 100644 --- a/src/__tests__/prompts.test.ts +++ b/src/__tests__/prompts.test.ts @@ -51,11 +51,12 @@ describe('getPrompt', () => { }); it('replaces multiple different variables', () => { - const result = getPrompt('workflow.iterationLimit.maxReached', undefined, { - currentIteration: '5', - maxIterations: '10', + const result = getPrompt('claude.judgePrompt', undefined, { + agentOutput: 'test output', + conditionList: '| 1 | Success |', }); - expect(result).toContain('(5/10)'); + expect(result).toContain('test output'); + expect(result).toContain('| 1 | Success |'); }); }); }); @@ -71,11 +72,6 @@ describe('getPromptObject', () => { expect(result.heading).toBe('## 実行コンテキスト'); }); - it('returns interactive UI text object', () => { - const result = getPromptObject<{ intro: string }>('interactive.ui', 'en'); - expect(result.intro).toContain('Interactive mode'); - }); - it('throws for a non-existent key', () => { expect(() => getPromptObject('nonexistent.key')).toThrow('Prompt key not found: nonexistent.key'); }); @@ -103,7 +99,6 @@ describe('YAML content integrity', () => { expect(() => getPrompt('interactive.workflowInfo', 'en')).not.toThrow(); expect(() => getPrompt('interactive.conversationLabel', 'en')).not.toThrow(); expect(() => getPrompt('interactive.noTranscript', 'en')).not.toThrow(); - expect(() => getPromptObject('interactive.ui', 'en')).not.toThrow(); expect(() => getPrompt('summarize.slugGenerator')).not.toThrow(); expect(() => getPrompt('claude.agentDefault')).not.toThrow(); expect(() => getPrompt('claude.judgePrompt')).not.toThrow(); @@ -114,7 +109,6 @@ describe('YAML content integrity', () => { expect(() => getPromptObject('instruction.reportSections', 'en')).not.toThrow(); expect(() => getPrompt('instruction.statusJudgment.header', 'en')).not.toThrow(); expect(() => getPromptObject('instruction.statusRules', 'en')).not.toThrow(); - expect(() => getPrompt('workflow.iterationLimit.maxReached')).not.toThrow(); }); it('contains all expected top-level keys in ja', () => { @@ -123,7 +117,6 @@ describe('YAML content integrity', () => { expect(() => getPrompt('interactive.workflowInfo', 'ja')).not.toThrow(); expect(() => getPrompt('interactive.conversationLabel', 'ja')).not.toThrow(); expect(() => getPrompt('interactive.noTranscript', 'ja')).not.toThrow(); - expect(() => getPromptObject('interactive.ui', 'ja')).not.toThrow(); expect(() => getPrompt('summarize.slugGenerator', 'ja')).not.toThrow(); expect(() => getPrompt('claude.agentDefault', 'ja')).not.toThrow(); expect(() => getPrompt('claude.judgePrompt', 'ja')).not.toThrow(); @@ -134,7 +127,6 @@ describe('YAML content integrity', () => { expect(() => getPromptObject('instruction.reportSections', 'ja')).not.toThrow(); expect(() => getPrompt('instruction.statusJudgment.header', 'ja')).not.toThrow(); expect(() => getPromptObject('instruction.statusRules', 'ja')).not.toThrow(); - expect(() => getPrompt('workflow.iterationLimit.maxReached', 'ja')).not.toThrow(); }); it('instruction.metadata has all required fields', () => { @@ -169,14 +161,12 @@ describe('YAML content integrity', () => { 'interactive.systemPrompt', 'summarize.slugGenerator', 'claude.agentDefault', - 'workflow.iterationLimit.maxReached', ]; for (const key of stringKeys) { expect(() => getPrompt(key, 'en')).not.toThrow(); expect(() => getPrompt(key, 'ja')).not.toThrow(); } const objectKeys = [ - 'interactive.ui', 'instruction.metadata', 'instruction.sections', ]; diff --git a/src/core/workflow/instruction/ReportInstructionBuilder.ts b/src/core/workflow/instruction/ReportInstructionBuilder.ts index ad8fdc2..aac2319 100644 --- a/src/core/workflow/instruction/ReportInstructionBuilder.ts +++ b/src/core/workflow/instruction/ReportInstructionBuilder.ts @@ -45,6 +45,8 @@ export interface ReportInstructionContext { stepIteration: number; /** Language */ language?: Language; + /** Target report file name (when generating a single report) */ + targetFile?: string; } /** @@ -85,21 +87,30 @@ export class ReportInstructionBuilder { execLines.push(''); sections.push(execLines.join('\n')); - // 2. Workflow Context (report info only) - const workflowLines = [ - s.workflowContext, - renderReportContext(this.step.report, this.context.reportDir, language), - ]; + // 2. Workflow Context (single file info when targetFile is specified) + const workflowLines = [s.workflowContext]; + if (this.context.targetFile) { + const sectionStr = getPromptObject<{ reportDirectory: string; reportFile: string }>('instruction.sections', language); + workflowLines.push(`- ${sectionStr.reportDirectory}: ${this.context.reportDir}/`); + workflowLines.push(`- ${sectionStr.reportFile}: ${this.context.reportDir}/${this.context.targetFile}`); + } else { + workflowLines.push(renderReportContext(this.step.report, this.context.reportDir, language)); + } sections.push(workflowLines.join('\n')); - // 3. Instructions + report output instruction + format - const instrParts: string[] = [ - s.instructions, - r.instructionBody, - r.reportJsonFormat, - ]; - instrParts.push(r.reportPlainAllowed); - instrParts.push(r.reportOnlyOutput); + // 3. Instructions (simplified when targetFile is specified) + const instrParts: string[] = [s.instructions]; + + if (this.context.targetFile) { + instrParts.push(r.instructionBody); + instrParts.push(`**このフェーズではツールは使えません。レポート内容をテキストとして直接回答してください。**`); + instrParts.push(`**レポート本文のみを回答してください。** ファイル名やJSON形式は不要です。`); + } else { + instrParts.push(r.instructionBody); + instrParts.push(r.reportJsonFormat); + instrParts.push(r.reportPlainAllowed); + instrParts.push(r.reportOnlyOutput); + } // Report output instruction (auto-generated or explicit order) const reportContext: InstructionContext = { @@ -118,7 +129,7 @@ export class ReportInstructionBuilder { const processedOrder = replaceTemplatePlaceholders(this.step.report.order.trimEnd(), this.step, reportContext); instrParts.push(''); instrParts.push(processedOrder); - } else { + } else if (!this.context.targetFile) { const reportInstruction = renderReportOutputInstruction(this.step, reportContext, language); if (reportInstruction) { instrParts.push(''); diff --git a/src/core/workflow/phase-runner.ts b/src/core/workflow/phase-runner.ts index 21063fd..be6ed3a 100644 --- a/src/core/workflow/phase-runner.ts +++ b/src/core/workflow/phase-runner.ts @@ -47,33 +47,6 @@ export function needsStatusJudgmentPhase(step: WorkflowStep): boolean { return hasTagBasedRules(step); } -function extractJsonPayload(content: string): string | null { - const trimmed = content.trim(); - if (trimmed.startsWith('{') && trimmed.endsWith('}')) { - return trimmed; - } - const match = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); - return match ? match[1]!.trim() : null; -} - -function parseReportJson(content: string): Record | null { - const payload = extractJsonPayload(content); - if (!payload) return null; - try { - const parsed = JSON.parse(payload); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - const obj = parsed as Record; - for (const value of Object.values(obj)) { - if (typeof value !== 'string') return null; - } - return obj as Record; - } - return null; - } catch { - return null; - } -} - function getReportFiles(report: WorkflowStep['report']): string[] { if (!report) return []; if (typeof report === 'string') return [report]; @@ -81,35 +54,6 @@ function getReportFiles(report: WorkflowStep['report']): string[] { return report.map((rc) => rc.path); } -function resolveReportOutputs( - report: WorkflowStep['report'], - reportDir: string, - content: string, -): Map { - if (!report) return new Map(); - - const files = getReportFiles(report); - const json = parseReportJson(content); - if (!json) { - const raw = content; - if (!raw || raw.trim().length === 0) { - throw new Error('Report output is empty.'); - } - return new Map(files.map((file) => [file, raw])); - } - - const outputs = new Map(); - for (const file of files) { - const absolutePath = resolve(reportDir, file); - const value = json[file] ?? json[absolutePath]; - if (typeof value !== 'string') { - throw new Error(`Report output missing content for file: ${file}`); - } - outputs.set(file, value); - } - return outputs; -} - function writeReportFile(reportDir: string, fileName: string, content: string): void { const baseDir = resolve(reportDir); const targetPath = resolve(reportDir, fileName); @@ -128,7 +72,8 @@ function writeReportFile(reportDir: string, fileName: string, content: string): /** * Phase 2: Report output. * Resumes the agent session with no tools to request report content. - * The engine writes the report files to the Report Directory. + * Each report file is generated individually in a loop. + * Plain text responses are written directly to files (no JSON parsing). */ export async function runReportPhase( step: WorkflowStep, @@ -136,53 +81,73 @@ export async function runReportPhase( ctx: PhaseRunnerContext, ): Promise { const sessionKey = step.agent ?? step.name; - const sessionId = ctx.getSessionId(sessionKey); - if (!sessionId) { + let currentSessionId = ctx.getSessionId(sessionKey); + if (!currentSessionId) { throw new Error(`Report phase requires a session to resume, but no sessionId found for agent "${sessionKey}" in step "${step.name}"`); } - log.debug('Running report phase', { step: step.name, sessionId }); + log.debug('Running report phase', { step: step.name, sessionId: currentSessionId }); - const reportInstruction = new ReportInstructionBuilder(step, { - cwd: ctx.cwd, - reportDir: ctx.reportDir, - stepIteration, - language: ctx.language, - }).build(); - - ctx.onPhaseStart?.(step, 2, 'report', reportInstruction); - - const reportOptions = ctx.buildResumeOptions(step, sessionId, { - allowedTools: [], - maxTurns: 3, - }); - - let reportResponse; - try { - reportResponse = await runAgent(step.agent, reportInstruction, reportOptions); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - ctx.onPhaseComplete?.(step, 2, 'report', '', 'error', errorMsg); - throw error; + const reportFiles = getReportFiles(step.report); + if (reportFiles.length === 0) { + log.debug('No report files configured, skipping report phase'); + return; } - // Check for errors in report phase - if (reportResponse.status !== 'done') { - const errorMsg = reportResponse.error || reportResponse.content || 'Unknown error'; - ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status, errorMsg); - throw new Error(`Report phase failed: ${errorMsg}`); - } + for (const fileName of reportFiles) { + if (!fileName) { + throw new Error(`Invalid report file name: ${fileName}`); + } + + log.debug('Generating report file', { step: step.name, fileName }); + + const reportInstruction = new ReportInstructionBuilder(step, { + cwd: ctx.cwd, + reportDir: ctx.reportDir, + stepIteration, + language: ctx.language, + targetFile: fileName, + }).build(); + + ctx.onPhaseStart?.(step, 2, 'report', reportInstruction); + + const reportOptions = ctx.buildResumeOptions(step, currentSessionId, { + allowedTools: [], + maxTurns: 3, + }); + + let reportResponse; + try { + reportResponse = await runAgent(step.agent, reportInstruction, reportOptions); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + ctx.onPhaseComplete?.(step, 2, 'report', '', 'error', errorMsg); + throw error; + } + + if (reportResponse.status !== 'done') { + const errorMsg = reportResponse.error || reportResponse.content || 'Unknown error'; + ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status, errorMsg); + throw new Error(`Report phase failed for ${fileName}: ${errorMsg}`); + } + + const content = reportResponse.content.trim(); + if (content.length === 0) { + throw new Error(`Report output is empty for file: ${fileName}`); + } - const outputs = resolveReportOutputs(step.report, ctx.reportDir, reportResponse.content); - for (const [fileName, content] of outputs.entries()) { writeReportFile(ctx.reportDir, fileName, content); + + if (reportResponse.sessionId) { + currentSessionId = reportResponse.sessionId; + ctx.updateAgentSession(sessionKey, currentSessionId); + } + + ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status); + log.debug('Report file generated', { step: step.name, fileName }); } - // Update session (phase 2 may update it) - ctx.updateAgentSession(sessionKey, reportResponse.sessionId); - - ctx.onPhaseComplete?.(step, 2, 'report', reportResponse.content, reportResponse.status); - log.debug('Report phase complete', { step: step.name, status: reportResponse.status }); + log.debug('Report phase complete', { step: step.name, filesGenerated: reportFiles.length }); } /** diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 33c630f..109f222 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -19,7 +19,8 @@ import { getProvider, type ProviderType } from '../../infra/providers/index.js'; import { selectOption } from '../../shared/prompt/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; -import { getPrompt, getPromptObject } from '../../shared/prompts/index.js'; +import { getPrompt } from '../../shared/prompts/index.js'; +import { getLabelObject } from '../../shared/i18n/index.js'; const log = createLogger('interactive'); /** Shape of interactive UI text */ @@ -58,7 +59,7 @@ function getInteractivePrompts(lang: 'en' | 'ja', workflowContext?: WorkflowCont summaryPrompt, conversationLabel: getPrompt('interactive.conversationLabel', lang), noTranscript: getPrompt('interactive.noTranscript', lang), - ui: getPromptObject('interactive.ui', lang), + ui: getLabelObject('interactive.ui', lang), }; } diff --git a/src/features/tasks/execute/workflowExecution.ts b/src/features/tasks/execute/workflowExecution.ts index a9deeea..07993ac 100644 --- a/src/features/tasks/execute/workflowExecution.ts +++ b/src/features/tasks/execute/workflowExecution.ts @@ -47,7 +47,7 @@ import { import { createLogger, notifySuccess, notifyError } from '../../../shared/utils/index.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; -import { getPrompt } from '../../../shared/prompts/index.js'; +import { getLabel } from '../../../shared/i18n/index.js'; const log = createLogger('workflow'); @@ -154,20 +154,20 @@ export async function executeWorkflow( blankLine(); warn( - getPrompt('workflow.iterationLimit.maxReached', undefined, { + getLabel('workflow.iterationLimit.maxReached', undefined, { currentIteration: String(request.currentIteration), maxIterations: String(request.maxIterations), }) ); - info(getPrompt('workflow.iterationLimit.currentStep', undefined, { currentStep: request.currentStep })); + info(getLabel('workflow.iterationLimit.currentStep', undefined, { currentStep: request.currentStep })); - const action = await selectOption(getPrompt('workflow.iterationLimit.continueQuestion'), [ + const action = await selectOption(getLabel('workflow.iterationLimit.continueQuestion'), [ { - label: getPrompt('workflow.iterationLimit.continueLabel'), + label: getLabel('workflow.iterationLimit.continueLabel'), value: 'continue', - description: getPrompt('workflow.iterationLimit.continueDescription'), + description: getLabel('workflow.iterationLimit.continueDescription'), }, - { label: getPrompt('workflow.iterationLimit.stopLabel'), value: 'stop' }, + { label: getLabel('workflow.iterationLimit.stopLabel'), value: 'stop' }, ]); if (action !== 'continue') { @@ -175,7 +175,7 @@ export async function executeWorkflow( } while (true) { - const input = await promptInput(getPrompt('workflow.iterationLimit.inputPrompt')); + const input = await promptInput(getLabel('workflow.iterationLimit.inputPrompt')); if (!input) { return null; } @@ -186,7 +186,7 @@ export async function executeWorkflow( return additionalIterations; } - warn(getPrompt('workflow.iterationLimit.invalidInput')); + warn(getLabel('workflow.iterationLimit.invalidInput')); } }; @@ -198,7 +198,7 @@ export async function executeWorkflow( } blankLine(); info(request.prompt.trim()); - const input = await promptInput(getPrompt('workflow.iterationLimit.userInputPrompt')); + const input = await promptInput(getLabel('workflow.iterationLimit.userInputPrompt')); return input && input.trim() ? input.trim() : null; } : undefined; @@ -355,7 +355,7 @@ export async function executeWorkflow( success(`Workflow completed (${state.iteration} iterations${elapsedDisplay})`); info(`Session log: ${ndjsonLogPath}`); - notifySuccess('TAKT', getPrompt('workflow.notifyComplete', undefined, { iteration: String(state.iteration) })); + notifySuccess('TAKT', getLabel('workflow.notifyComplete', undefined, { iteration: String(state.iteration) })); }); engine.on('workflow:abort', (state, reason) => { @@ -385,7 +385,7 @@ export async function executeWorkflow( error(`Workflow aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`); info(`Session log: ${ndjsonLogPath}`); - notifyError('TAKT', getPrompt('workflow.notifyAbort', undefined, { reason })); + notifyError('TAKT', getLabel('workflow.notifyAbort', undefined, { reason })); }); // SIGINT handler: 1st Ctrl+C = graceful abort, 2nd = force exit @@ -394,11 +394,11 @@ export async function executeWorkflow( sigintCount++; if (sigintCount === 1) { blankLine(); - warn(getPrompt('workflow.sigintGraceful')); + warn(getLabel('workflow.sigintGraceful')); engine.abort(); } else { blankLine(); - error(getPrompt('workflow.sigintForce')); + error(getLabel('workflow.sigintForce')); process.exit(EXIT_SIGINT); } }; diff --git a/src/shared/i18n/index.ts b/src/shared/i18n/index.ts new file mode 100644 index 0000000..3333f85 --- /dev/null +++ b/src/shared/i18n/index.ts @@ -0,0 +1,112 @@ +/** + * UI label loader utility + * + * Loads user-facing display strings from language-specific YAML files + * (labels_en.yaml / labels_ja.yaml) and provides + * key-based access with template variable substitution. + * + * This module handles UI labels only — AI prompts live in ../prompts/. + */ + +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse as parseYaml } from 'yaml'; +import type { Language } from '../../core/models/types.js'; +import { DEFAULT_LANGUAGE } from '../constants.js'; + +/** Cached YAML data per language */ +const labelCache = new Map>(); + +function loadLabels(lang: Language): Record { + const cached = labelCache.get(lang); + if (cached) return cached; + const __dirname = dirname(fileURLToPath(import.meta.url)); + const yamlPath = join(__dirname, `labels_${lang}.yaml`); + const content = readFileSync(yamlPath, 'utf-8'); + const data = parseYaml(content) as Record; + labelCache.set(lang, data); + return data; +} + +/** + * Resolve a dot-separated key path to a value in a nested object. + * Returns undefined if the path does not exist. + */ +function resolveKey(obj: Record, keyPath: string): unknown { + const parts = keyPath.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current === null || current === undefined || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[part]; + } + return current; +} + +/** + * Replace {key} placeholders in a template string with values from vars. + * Unmatched placeholders are left as-is. + */ +function applyVars(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => { + if (key in vars) { + const value: string = vars[key] as string; + return value; + } + return match; + }); +} + +/** + * Get a UI label string from the language-specific YAML by dot-separated key. + * + * When `lang` is provided, loads the corresponding language file. + * When `lang` is omitted, uses DEFAULT_LANGUAGE. + * + * Template variables in `{name}` format are replaced when `vars` is given. + */ +export function getLabel( + key: string, + lang?: Language, + vars?: Record, +): string { + const effectiveLang = lang ?? DEFAULT_LANGUAGE; + const data = loadLabels(effectiveLang); + + const value = resolveKey(data, key); + if (typeof value !== 'string') { + throw new Error(`Label key not found: ${key}${lang ? ` (lang: ${lang})` : ''}`); + } + + if (vars) { + return applyVars(value, vars); + } + return value; +} + +/** + * Get a nested object from the language-specific YAML by dot-separated key. + * + * When `lang` is provided, loads the corresponding language file. + * When `lang` is omitted, uses DEFAULT_LANGUAGE. + * + * Useful for structured label groups (e.g. UI text objects). + */ +export function getLabelObject(key: string, lang?: Language): T { + const effectiveLang = lang ?? DEFAULT_LANGUAGE; + const data = loadLabels(effectiveLang); + + const value = resolveKey(data, key); + if (value === undefined || value === null) { + throw new Error(`Label key not found: ${key}${lang ? ` (lang: ${lang})` : ''}`); + } + + return value as T; +} + +/** Reset cached data (for testing) */ +export function _resetLabelCache(): void { + labelCache.clear(); +} diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml new file mode 100644 index 0000000..5ca9f34 --- /dev/null +++ b/src/shared/i18n/labels_en.yaml @@ -0,0 +1,35 @@ +# ============================================================================= +# TAKT UI Labels — English +# ============================================================================= +# User-facing display strings (not AI prompts). +# Template variables use {variableName} syntax. +# ============================================================================= + +# ===== Interactive Mode UI ===== +interactive: + ui: + intro: "Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)" + resume: "Resuming previous session" + noConversation: "No conversation yet. Please describe your task first." + summarizeFailed: "Failed to summarize conversation. Please try again." + continuePrompt: "Okay, continue describing your task." + proposed: "Proposed task instruction:" + confirm: "Use this task instruction?" + cancelled: "Cancelled" + +# ===== Workflow Execution UI ===== +workflow: + iterationLimit: + maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxIterations})" + currentStep: "現在のステップ: {currentStep}" + continueQuestion: "続行しますか?" + continueLabel: "続行する(追加イテレーション数を入力)" + continueDescription: "入力した回数だけ上限を増やします" + stopLabel: "終了する" + inputPrompt: "追加するイテレーション数を入力してください(1以上)" + invalidInput: "1以上の整数を入力してください。" + userInputPrompt: "追加の指示を入力してください(空で中止)" + notifyComplete: "ワークフロー完了 ({iteration} iterations)" + notifyAbort: "中断: {reason}" + sigintGraceful: "Ctrl+C: ワークフローを中断しています..." + sigintForce: "Ctrl+C: 強制終了します" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml new file mode 100644 index 0000000..453c531 --- /dev/null +++ b/src/shared/i18n/labels_ja.yaml @@ -0,0 +1,35 @@ +# ============================================================================= +# TAKT UI Labels — 日本語 +# ============================================================================= +# ユーザー向け表示文字列(AIプロンプトではない)。 +# テンプレート変数は {variableName} 形式を使用します。 +# ============================================================================= + +# ===== Interactive Mode UI ===== +interactive: + ui: + intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /cancel(終了)" + resume: "前回のセッションを再開します" + noConversation: "まだ会話がありません。まずタスク内容を入力してください。" + summarizeFailed: "会話の要約に失敗しました。再度お試しください。" + continuePrompt: "続けてタスク内容を入力してください。" + proposed: "提案されたタスク指示:" + confirm: "このタスク指示で進めますか?" + cancelled: "キャンセルしました" + +# ===== Workflow Execution UI ===== +workflow: + iterationLimit: + maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxIterations})" + currentStep: "現在のステップ: {currentStep}" + continueQuestion: "続行しますか?" + continueLabel: "続行する(追加イテレーション数を入力)" + continueDescription: "入力した回数だけ上限を増やします" + stopLabel: "終了する" + inputPrompt: "追加するイテレーション数を入力してください(1以上)" + invalidInput: "1以上の整数を入力してください。" + userInputPrompt: "追加の指示を入力してください(空で中止)" + notifyComplete: "ワークフロー完了 ({iteration} iterations)" + notifyAbort: "中断: {reason}" + sigintGraceful: "Ctrl+C: ワークフローを中断しています..." + sigintForce: "Ctrl+C: 強制終了します" diff --git a/src/shared/prompts/prompts_en.yaml b/src/shared/prompts/prompts_en.yaml index a94cfa3..6866ade 100644 --- a/src/shared/prompts/prompts_en.yaml +++ b/src/shared/prompts/prompts_en.yaml @@ -1,10 +1,11 @@ # ============================================================================= # TAKT Prompt Definitions — English # ============================================================================= +# AI-facing prompt definitions only. UI labels live in ../i18n/. # Template variables use {variableName} syntax. # ============================================================================= -# ===== Interactive Mode ===== +# ===== Interactive Mode — AI prompts for conversation and summarization ===== interactive: systemPrompt: | You are a task planning assistant. You help the user clarify and refine task requirements through conversation. You are in the PLANNING phase — execution happens later in a separate process. @@ -69,17 +70,7 @@ interactive: noTranscript: "(No local transcript. Summarize the current session context.)" - ui: - intro: "Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)" - resume: "Resuming previous session" - noConversation: "No conversation yet. Please describe your task first." - summarizeFailed: "Failed to summarize conversation. Please try again." - continuePrompt: "Okay, continue describing your task." - proposed: "Proposed task instruction:" - confirm: "Use this task instruction?" - cancelled: "Cancelled" - -# ===== Summarize ===== +# ===== Summarize — slug generation for task descriptions ===== summarize: slugGenerator: | You are a slug generator. Given a task description, output ONLY a slug. @@ -96,7 +87,7 @@ summarize: worktreeを作るときブランチ名をAIで生成 → ai-branch-naming レビュー画面に元の指示を表示する → show-original-instruction -# ===== Claude Client ===== +# ===== Claude Client — agent and judge prompts ===== claude: agentDefault: "You are the {agentName} agent. Follow the standard {agentName} workflow." judgePrompt: | @@ -119,7 +110,7 @@ claude: Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition. Do not output anything else. -# ===== Instruction Builders ===== +# ===== Instruction Builders — prompt construction for workflow steps ===== instruction: metadata: heading: "## Execution Context" @@ -179,20 +170,3 @@ instruction: outputInstruction: "Output the tag corresponding to your decision:" appendixHeading: "### Appendix Template" appendixInstruction: "When outputting `[{tag}]`, append the following:" - -# ===== Workflow Execution ===== -workflow: - iterationLimit: - maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxIterations})" - currentStep: "現在のステップ: {currentStep}" - continueQuestion: "続行しますか?" - continueLabel: "続行する(追加イテレーション数を入力)" - continueDescription: "入力した回数だけ上限を増やします" - stopLabel: "終了する" - inputPrompt: "追加するイテレーション数を入力してください(1以上)" - invalidInput: "1以上の整数を入力してください。" - userInputPrompt: "追加の指示を入力してください(空で中止)" - notifyComplete: "ワークフロー完了 ({iteration} iterations)" - notifyAbort: "中断: {reason}" - sigintGraceful: "Ctrl+C: ワークフローを中断しています..." - sigintForce: "Ctrl+C: 強制終了します" diff --git a/src/shared/prompts/prompts_ja.yaml b/src/shared/prompts/prompts_ja.yaml index 3223fc5..889e1f2 100644 --- a/src/shared/prompts/prompts_ja.yaml +++ b/src/shared/prompts/prompts_ja.yaml @@ -1,10 +1,11 @@ # ============================================================================= # TAKT Prompt Definitions — 日本語 # ============================================================================= +# AI向けプロンプト定義のみ。UIラベルは ../i18n/ を参照。 # テンプレート変数は {variableName} 形式を使用します。 # ============================================================================= -# ===== Interactive Mode ===== +# ===== Interactive Mode — 対話モードのAIプロンプト ===== interactive: systemPrompt: | あなたはTAKT(AIエージェントワークフローオーケストレーションツール)の対話モードを担当しています。 @@ -82,17 +83,7 @@ interactive: noTranscript: "(ローカル履歴なし。現在のセッション文脈を要約してください。)" - ui: - intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /cancel(終了)" - resume: "前回のセッションを再開します" - noConversation: "まだ会話がありません。まずタスク内容を入力してください。" - summarizeFailed: "会話の要約に失敗しました。再度お試しください。" - continuePrompt: "続けてタスク内容を入力してください。" - proposed: "提案されたタスク指示:" - confirm: "このタスク指示で進めますか?" - cancelled: "キャンセルしました" - -# ===== Summarize ===== +# ===== Summarize — スラグ生成 ===== summarize: slugGenerator: | You are a slug generator. Given a task description, output ONLY a slug. @@ -109,7 +100,7 @@ summarize: worktreeを作るときブランチ名をAIで生成 → ai-branch-naming レビュー画面に元の指示を表示する → show-original-instruction -# ===== Claude Client ===== +# ===== Claude Client — エージェント・ジャッジプロンプト ===== claude: agentDefault: "You are the {agentName} agent. Follow the standard {agentName} workflow." judgePrompt: | @@ -132,7 +123,7 @@ claude: Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition. Do not output anything else. -# ===== Instruction Builders ===== +# ===== Instruction Builders — ワークフローステップのプロンプト構成 ===== instruction: metadata: heading: "## 実行コンテキスト" @@ -192,20 +183,3 @@ instruction: outputInstruction: "判定に対応するタグを出力してください:" appendixHeading: "### 追加出力テンプレート" appendixInstruction: "`[{tag}]` を出力する場合、以下を追記してください:" - -# ===== Workflow Execution ===== -workflow: - iterationLimit: - maxReached: "最大イテレーションに到達しました ({currentIteration}/{maxIterations})" - currentStep: "現在のステップ: {currentStep}" - continueQuestion: "続行しますか?" - continueLabel: "続行する(追加イテレーション数を入力)" - continueDescription: "入力した回数だけ上限を増やします" - stopLabel: "終了する" - inputPrompt: "追加するイテレーション数を入力してください(1以上)" - invalidInput: "1以上の整数を入力してください。" - userInputPrompt: "追加の指示を入力してください(空で中止)" - notifyComplete: "ワークフロー完了 ({iteration} iterations)" - notifyAbort: "中断: {reason}" - sigintGraceful: "Ctrl+C: ワークフローを中断しています..." - sigintForce: "Ctrl+C: 強制終了します" diff --git a/tools/debug-log-viewer.html b/tools/debug-log-viewer.html new file mode 100644 index 0000000..e346ef7 --- /dev/null +++ b/tools/debug-log-viewer.html @@ -0,0 +1,849 @@ + + + + + + TAKT Debug Log Viewer + + + +
+

TAKT Debug Log Viewer

+ +
+
+ ここにデバッグログファイルをドラッグ&ドロップ
+ またはクリックしてファイルを選択 +
+ +
+ +
+ + +
+ + + +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ + +
+
+ +
+
+ + + + diff --git a/tools/jsonl-viewer.html b/tools/jsonl-viewer.html index 9590373..21db6dd 100644 --- a/tools/jsonl-viewer.html +++ b/tools/jsonl-viewer.html @@ -51,6 +51,87 @@ color: #858585; } + .quick-actions { + display: flex; + gap: 10px; + margin-bottom: 20px; + justify-content: center; + } + + .action-btn { + padding: 10px 20px; + background: #007acc; + color: #ffffff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; + } + + .action-btn:hover { + background: #005a9e; + } + + .action-btn:disabled { + background: #3e3e42; + color: #858585; + cursor: not-allowed; + } + + .action-btn.secondary { + background: #252526; + border: 1px solid #3e3e42; + } + + .action-btn.secondary:hover:not(:disabled) { + background: #2d2d30; + border-color: #007acc; + } + + .navigation { + display: none; + background: #252526; + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; + align-items: center; + gap: 10px; + } + + .navigation.active { + display: flex; + } + + .nav-btn { + padding: 8px 16px; + background: #007acc; + color: #ffffff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; + } + + .nav-btn:hover:not(:disabled) { + background: #005a9e; + } + + .nav-btn:disabled { + background: #3e3e42; + color: #858585; + cursor: not-allowed; + } + + .current-file { + flex: 1; + text-align: center; + font-size: 14px; + color: #d4d4d4; + padding: 0 10px; + } + .file-info { background: #252526; padding: 15px; @@ -236,6 +317,19 @@ +
+ + +
+ + +
@@ -246,10 +340,86 @@ - + \ No newline at end of file