From e7d5dbfb33dd70f1a82ab1caf7845d90a236c65c Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:28:51 +0900 Subject: [PATCH] =?UTF-8?q?knowledge=20=E3=82=B7=E3=82=B9=E3=83=86?= =?UTF-8?q?=E3=83=A0=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/global/en/knowledge/.gitkeep | 0 resources/global/ja/knowledge/.gitkeep | 0 src/__tests__/knowledge.test.ts | 447 ++++++++++++++++++ src/core/models/piece-types.ts | 4 + src/core/models/schemas.ts | 6 + src/core/piece/engine/MovementExecutor.ts | 1 + .../piece/instruction/InstructionBuilder.ts | 7 + .../piece/instruction/instruction-context.ts | 2 + src/features/config/ejectBuiltin.ts | 2 +- src/infra/config/loaders/pieceParser.ts | 9 + .../prompts/en/perform_phase1_message.md | 9 +- .../prompts/ja/perform_phase1_message.md | 9 +- 12 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 resources/global/en/knowledge/.gitkeep create mode 100644 resources/global/ja/knowledge/.gitkeep create mode 100644 src/__tests__/knowledge.test.ts diff --git a/resources/global/en/knowledge/.gitkeep b/resources/global/en/knowledge/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/global/ja/knowledge/.gitkeep b/resources/global/ja/knowledge/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/__tests__/knowledge.test.ts b/src/__tests__/knowledge.test.ts new file mode 100644 index 0000000..05ed729 --- /dev/null +++ b/src/__tests__/knowledge.test.ts @@ -0,0 +1,447 @@ +/** + * Tests for knowledge category feature + * + * Covers: + * - Schema validation for knowledge field at piece and movement level + * - Piece parser resolution of knowledge references + * - InstructionBuilder knowledge content injection + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + PieceConfigRawSchema, + PieceMovementRawSchema, + ParallelSubMovementRawSchema, +} from '../core/models/index.js'; +import { normalizePieceConfig } from '../infra/config/loaders/pieceParser.js'; +import { InstructionBuilder } from '../core/piece/instruction/InstructionBuilder.js'; +import type { InstructionContext } from '../core/piece/instruction/instruction-context.js'; +import type { PieceMovement } from '../core/models/types.js'; + +describe('PieceConfigRawSchema knowledge field', () => { + it('should accept knowledge map at piece level', () => { + const raw = { + name: 'test-piece', + knowledge: { + frontend: 'frontend.md', + backend: 'backend.md', + }, + movements: [ + { name: 'step1', persona: 'coder.md', instruction: '{task}' }, + ], + }; + + const result = PieceConfigRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.knowledge).toEqual({ + frontend: 'frontend.md', + backend: 'backend.md', + }); + } + }); + + it('should accept piece without knowledge field', () => { + const raw = { + name: 'test-piece', + movements: [ + { name: 'step1', persona: 'coder.md', instruction: '{task}' }, + ], + }; + + const result = PieceConfigRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.knowledge).toBeUndefined(); + } + }); +}); + +describe('PieceMovementRawSchema knowledge field', () => { + it('should accept knowledge as a string reference', () => { + const raw = { + name: 'implement', + persona: 'coder.md', + knowledge: 'frontend', + instruction: '{task}', + }; + + const result = PieceMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.knowledge).toBe('frontend'); + } + }); + + it('should accept knowledge as array of string references', () => { + const raw = { + name: 'implement', + persona: 'coder.md', + knowledge: ['frontend', 'backend'], + instruction: '{task}', + }; + + const result = PieceMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.knowledge).toEqual(['frontend', 'backend']); + } + }); + + it('should accept movement without knowledge field', () => { + const raw = { + name: 'implement', + persona: 'coder.md', + instruction: '{task}', + }; + + const result = PieceMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.knowledge).toBeUndefined(); + } + }); + + it('should accept both stance and knowledge fields', () => { + const raw = { + name: 'implement', + persona: 'coder.md', + stance: 'coding', + knowledge: 'frontend', + instruction: '{task}', + }; + + const result = PieceMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.stance).toBe('coding'); + expect(result.data.knowledge).toBe('frontend'); + } + }); +}); + +describe('ParallelSubMovementRawSchema knowledge field', () => { + it('should accept knowledge on parallel sub-movements', () => { + const raw = { + name: 'sub-step', + persona: 'reviewer.md', + knowledge: 'security', + instruction_template: 'Review security', + }; + + const result = ParallelSubMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.knowledge).toBe('security'); + } + }); + + it('should accept knowledge array on parallel sub-movements', () => { + const raw = { + name: 'sub-step', + persona: 'reviewer.md', + knowledge: ['security', 'performance'], + instruction_template: 'Review', + }; + + const result = ParallelSubMovementRawSchema.safeParse(raw); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.knowledge).toEqual(['security', 'performance']); + } + }); +}); + +describe('normalizePieceConfig knowledge resolution', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-knowledge-test-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should resolve knowledge from piece-level map to movement', () => { + const frontendKnowledge = '# Frontend Knowledge\n\nUse React for components.'; + writeFileSync(join(tempDir, 'frontend.md'), frontendKnowledge); + + const raw = { + name: 'test-piece', + knowledge: { + frontend: 'frontend.md', + }, + movements: [ + { + name: 'implement', + persona: 'coder.md', + knowledge: 'frontend', + instruction: '{task}', + }, + ], + }; + + const piece = normalizePieceConfig(raw, tempDir); + + expect(piece.knowledge).toBeDefined(); + expect(piece.knowledge!['frontend']).toBe(frontendKnowledge); + expect(piece.movements[0].knowledgeContents).toEqual([frontendKnowledge]); + }); + + it('should resolve multiple knowledge references', () => { + const frontendKnowledge = '# Frontend\nReact patterns.'; + const backendKnowledge = '# Backend\nAPI design.'; + writeFileSync(join(tempDir, 'frontend.md'), frontendKnowledge); + writeFileSync(join(tempDir, 'backend.md'), backendKnowledge); + + const raw = { + name: 'test-piece', + knowledge: { + frontend: 'frontend.md', + backend: 'backend.md', + }, + movements: [ + { + name: 'implement', + persona: 'coder.md', + knowledge: ['frontend', 'backend'], + instruction: '{task}', + }, + ], + }; + + const piece = normalizePieceConfig(raw, tempDir); + + expect(piece.movements[0].knowledgeContents).toHaveLength(2); + expect(piece.movements[0].knowledgeContents).toContain(frontendKnowledge); + expect(piece.movements[0].knowledgeContents).toContain(backendKnowledge); + }); + + it('should resolve knowledge on parallel sub-movements', () => { + const securityKnowledge = '# Security\nOWASP guidelines.'; + writeFileSync(join(tempDir, 'security.md'), securityKnowledge); + + const raw = { + name: 'test-piece', + knowledge: { + security: 'security.md', + }, + movements: [ + { + name: 'review', + parallel: [ + { + name: 'sec-review', + persona: 'reviewer.md', + knowledge: 'security', + instruction_template: 'Review security', + }, + ], + rules: [{ condition: 'approved', next: 'COMPLETE' }], + }, + ], + }; + + const piece = normalizePieceConfig(raw, tempDir); + + expect(piece.movements[0].parallel).toHaveLength(1); + expect(piece.movements[0].parallel![0].knowledgeContents).toEqual([securityKnowledge]); + }); + + it('should handle inline knowledge content', () => { + const raw = { + name: 'test-piece', + knowledge: { + inline: 'This is inline knowledge content.', + }, + movements: [ + { + name: 'implement', + persona: 'coder.md', + knowledge: 'inline', + instruction: '{task}', + }, + ], + }; + + const piece = normalizePieceConfig(raw, tempDir); + + expect(piece.knowledge!['inline']).toBe('This is inline knowledge content.'); + expect(piece.movements[0].knowledgeContents).toEqual(['This is inline knowledge content.']); + }); + + it('should handle direct file path reference without piece-level map', () => { + const directKnowledge = '# Direct Knowledge\nLoaded directly.'; + writeFileSync(join(tempDir, 'direct.md'), directKnowledge); + + const raw = { + name: 'test-piece', + movements: [ + { + name: 'implement', + persona: 'coder.md', + knowledge: 'direct.md', + instruction: '{task}', + }, + ], + }; + + const piece = normalizePieceConfig(raw, tempDir); + + expect(piece.movements[0].knowledgeContents).toEqual([directKnowledge]); + }); + + it('should treat non-file reference as inline content when knowledge reference not found in map', () => { + const raw = { + name: 'test-piece', + movements: [ + { + name: 'implement', + persona: 'coder.md', + knowledge: 'nonexistent', + instruction: '{task}', + }, + ], + }; + + const piece = normalizePieceConfig(raw, tempDir); + + // Non-.md references that are not in the knowledge map are treated as inline content + expect(piece.movements[0].knowledgeContents).toEqual(['nonexistent']); + }); +}); + +// --- Test helpers for InstructionBuilder --- + +function createMinimalStep(instructionTemplate: string): PieceMovement { + return { + name: 'test-step', + personaDisplayName: 'coder', + instructionTemplate, + passPreviousResponse: false, + }; +} + +function createMinimalContext(overrides: Partial = {}): InstructionContext { + return { + task: 'Test task', + iteration: 1, + maxIterations: 10, + movementIteration: 1, + cwd: '/tmp/test', + projectCwd: '/tmp/test', + userInputs: [], + language: 'ja', + ...overrides, + }; +} + +// --- InstructionBuilder knowledge injection tests --- + +describe('InstructionBuilder knowledge injection', () => { + it('should inject knowledge section when knowledgeContents present in step', () => { + const step = createMinimalStep('{task}'); + step.knowledgeContents = ['# Frontend Knowledge\n\nUse React.']; + const ctx = createMinimalContext(); + const builder = new InstructionBuilder(step, ctx); + const result = builder.build(); + + expect(result).toContain('## Knowledge'); + expect(result).toContain('Frontend Knowledge'); + expect(result).toContain('Use React.'); + }); + + it('should not inject knowledge section when no knowledgeContents', () => { + const step = createMinimalStep('{task}'); + const ctx = createMinimalContext(); + const builder = new InstructionBuilder(step, ctx); + const result = builder.build(); + + expect(result).not.toContain('## Knowledge'); + }); + + it('should prefer context knowledgeContents over step knowledgeContents', () => { + const step = createMinimalStep('{task}'); + step.knowledgeContents = ['Step knowledge.']; + const ctx = createMinimalContext({ + knowledgeContents: ['Context knowledge.'], + }); + const builder = new InstructionBuilder(step, ctx); + const result = builder.build(); + + expect(result).toContain('Context knowledge.'); + expect(result).not.toContain('Step knowledge.'); + }); + + it('should join multiple knowledge contents with separator', () => { + const step = createMinimalStep('{task}'); + step.knowledgeContents = ['Knowledge A content.', 'Knowledge B content.']; + const ctx = createMinimalContext(); + const builder = new InstructionBuilder(step, ctx); + const result = builder.build(); + + expect(result).toContain('Knowledge A content.'); + expect(result).toContain('Knowledge B content.'); + expect(result).toContain('---'); + }); + + it('should inject knowledge section in English', () => { + const step = createMinimalStep('{task}'); + step.knowledgeContents = ['# API Guidelines\n\nUse REST conventions.']; + const ctx = createMinimalContext({ language: 'en' }); + const builder = new InstructionBuilder(step, ctx); + const result = builder.build(); + + expect(result).toContain('## Knowledge'); + expect(result).toContain('API Guidelines'); + }); +}); + +describe('knowledge and stance coexistence', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-knowledge-stance-test-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should resolve both stance and knowledge for same movement', () => { + const stanceContent = '# Coding Stance\nWrite clean code.'; + const knowledgeContent = '# Frontend Knowledge\nUse TypeScript.'; + writeFileSync(join(tempDir, 'coding.md'), stanceContent); + writeFileSync(join(tempDir, 'frontend.md'), knowledgeContent); + + const raw = { + name: 'test-piece', + stances: { + coding: 'coding.md', + }, + knowledge: { + frontend: 'frontend.md', + }, + movements: [ + { + name: 'implement', + persona: 'coder.md', + stance: 'coding', + knowledge: 'frontend', + instruction: '{task}', + }, + ], + }; + + const piece = normalizePieceConfig(raw, tempDir); + + expect(piece.stances!['coding']).toBe(stanceContent); + expect(piece.knowledge!['frontend']).toBe(knowledgeContent); + expect(piece.movements[0].stanceContents).toEqual([stanceContent]); + expect(piece.movements[0].knowledgeContents).toEqual([knowledgeContent]); + }); +}); diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index c84f619..631557c 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -83,6 +83,8 @@ export interface PieceMovement { parallel?: PieceMovement[]; /** Resolved stance content strings (from piece-level stances map, resolved at parse time) */ stanceContents?: string[]; + /** Resolved knowledge content strings (from piece-level knowledge map, resolved at parse time) */ + knowledgeContents?: string[]; } /** Loop detection configuration */ @@ -131,6 +133,8 @@ export interface PieceConfig { personas?: Record; /** Resolved stance definitions — map of name to file content (resolved at parse time) */ stances?: Record; + /** Resolved knowledge definitions — map of name to file content (resolved at parse time) */ + knowledge?: Record; /** Resolved instruction definitions — map of name to file content (resolved at parse time) */ instructions?: Record; /** Resolved report format definitions — map of name to file content (resolved at parse time) */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 629e240..50296a2 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -122,6 +122,8 @@ export const ParallelSubMovementRawSchema = z.object({ persona_name: z.string().optional(), /** Stance reference(s) — key name(s) from piece-level stances map */ stance: z.union([z.string(), z.array(z.string())]).optional(), + /** Knowledge reference(s) — key name(s) from piece-level knowledge map */ + knowledge: z.union([z.string(), z.array(z.string())]).optional(), allowed_tools: z.array(z.string()).optional(), provider: z.enum(['claude', 'codex', 'mock']).optional(), model: z.string().optional(), @@ -146,6 +148,8 @@ export const PieceMovementRawSchema = z.object({ persona_name: z.string().optional(), /** Stance reference(s) — key name(s) from piece-level stances map */ stance: z.union([z.string(), z.array(z.string())]).optional(), + /** Knowledge reference(s) — key name(s) from piece-level knowledge map */ + knowledge: z.union([z.string(), z.array(z.string())]).optional(), allowed_tools: z.array(z.string()).optional(), provider: z.enum(['claude', 'codex', 'mock']).optional(), model: z.string().optional(), @@ -200,6 +204,8 @@ export const PieceConfigRawSchema = z.object({ personas: z.record(z.string(), z.string()).optional(), /** Piece-level stance definitions — map of name to .md file path or inline content */ stances: z.record(z.string(), z.string()).optional(), + /** Piece-level knowledge definitions — map of name to .md file path or inline content */ + knowledge: z.record(z.string(), z.string()).optional(), /** Piece-level instruction definitions — map of name to .md file path or inline content */ instructions: z.record(z.string(), z.string()).optional(), /** Piece-level report format definitions — map of name to .md file path or inline content */ diff --git a/src/core/piece/engine/MovementExecutor.ts b/src/core/piece/engine/MovementExecutor.ts index 364558d..0b4cdaa 100644 --- a/src/core/piece/engine/MovementExecutor.ts +++ b/src/core/piece/engine/MovementExecutor.ts @@ -78,6 +78,7 @@ export class MovementExecutor { pieceDescription: this.deps.getPieceDescription(), retryNote: this.deps.getRetryNote(), stanceContents: step.stanceContents, + knowledgeContents: step.knowledgeContents, }).build(); } diff --git a/src/core/piece/instruction/InstructionBuilder.ts b/src/core/piece/instruction/InstructionBuilder.ts index 96f30c8..9b2d1a5 100644 --- a/src/core/piece/instruction/InstructionBuilder.ts +++ b/src/core/piece/instruction/InstructionBuilder.ts @@ -104,6 +104,11 @@ export class InstructionBuilder { const stanceContent = hasStance ? stanceContents!.join('\n\n---\n\n') : ''; const stanceReminder = ''; // Reminder text is in the template itself + // Knowledge injection (domain-specific knowledge, no reminder needed) + const knowledgeContents = this.context.knowledgeContents ?? this.step.knowledgeContents; + const hasKnowledge = !!(knowledgeContents && knowledgeContents.length > 0); + const knowledgeContent = hasKnowledge ? knowledgeContents!.join('\n\n---\n\n') : ''; + return loadTemplate('perform_phase1_message', language, { workingDirectory: this.context.cwd, editRule, @@ -128,6 +133,8 @@ export class InstructionBuilder { hasStance, stanceContent, stanceReminder, + hasKnowledge, + knowledgeContent, instructions, }); } diff --git a/src/core/piece/instruction/instruction-context.ts b/src/core/piece/instruction/instruction-context.ts index 2113f91..42903df 100644 --- a/src/core/piece/instruction/instruction-context.ts +++ b/src/core/piece/instruction/instruction-context.ts @@ -44,6 +44,8 @@ export interface InstructionContext { retryNote?: string; /** Resolved stance content strings for injection into instruction */ stanceContents?: string[]; + /** Resolved knowledge content strings for injection into instruction */ + knowledgeContents?: string[]; } /** diff --git a/src/features/config/ejectBuiltin.ts b/src/features/config/ejectBuiltin.ts index 274e85a..41cc7b2 100644 --- a/src/features/config/ejectBuiltin.ts +++ b/src/features/config/ejectBuiltin.ts @@ -132,7 +132,7 @@ interface ResourceRef { } /** Known resource type directories that can be referenced from piece YAML */ -const RESOURCE_TYPES = ['personas', 'stances', 'instructions', 'report-formats']; +const RESOURCE_TYPES = ['personas', 'stances', 'knowledge', 'instructions', 'report-formats']; /** * Extract resource relative paths from a builtin piece YAML. diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 335dbba..6133ce8 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -106,6 +106,8 @@ interface PieceSections { personas?: Record; /** Stance name → resolved content */ resolvedStances?: Record; + /** Knowledge name → resolved content */ + resolvedKnowledge?: Record; /** Instruction name → resolved content */ resolvedInstructions?: Record; /** Report format name → resolved content */ @@ -232,6 +234,9 @@ function normalizeStepFromRaw( const stanceRef = (step as Record).stance as string | string[] | undefined; const stanceContents = resolveRefList(stanceRef, sections.resolvedStances, pieceDir); + const knowledgeRef = (step as Record).knowledge as string | string[] | undefined; + const knowledgeContents = resolveRefList(knowledgeRef, sections.resolvedKnowledge, pieceDir); + const expandedInstruction = step.instruction ? resolveRefToContent(step.instruction, sections.resolvedInstructions, pieceDir) : undefined; @@ -253,6 +258,7 @@ function normalizeStepFromRaw( report: normalizeReport(step.report, pieceDir, sections.resolvedReportFormats), passPreviousResponse: step.pass_previous_response ?? true, stanceContents, + knowledgeContents, }; if (step.parallel && step.parallel.length > 0) { @@ -299,12 +305,14 @@ export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfi const parsed = PieceConfigRawSchema.parse(raw); const resolvedStances = resolveSectionMap(parsed.stances, pieceDir); + const resolvedKnowledge = resolveSectionMap(parsed.knowledge, pieceDir); const resolvedInstructions = resolveSectionMap(parsed.instructions, pieceDir); const resolvedReportFormats = resolveSectionMap(parsed.report_formats, pieceDir); const sections: PieceSections = { personas: parsed.personas, resolvedStances, + resolvedKnowledge, resolvedInstructions, resolvedReportFormats, }; @@ -321,6 +329,7 @@ export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfi description: parsed.description, personas: parsed.personas, stances: resolvedStances, + knowledge: resolvedKnowledge, instructions: resolvedInstructions, reportFormats: resolvedReportFormats, movements, diff --git a/src/shared/prompts/en/perform_phase1_message.md b/src/shared/prompts/en/perform_phase1_message.md index 03fc1c3..d40f20e 100644 --- a/src/shared/prompts/en/perform_phase1_message.md +++ b/src/shared/prompts/en/perform_phase1_message.md @@ -5,7 +5,7 @@ pieceStructure, iteration, movementIteration, movement, hasReport, reportInfo, phaseNote, hasTaskSection, userRequest, hasPreviousResponse, previousResponse, hasUserInputs, userInputs, hasRetryNote, retryNote, hasStance, stanceContent, - stanceReminder, instructions + stanceReminder, hasKnowledge, knowledgeContent, instructions builder: InstructionBuilder --> ## Execution Context @@ -25,6 +25,13 @@ The following stances are behavioral standards applied to this movement. You MUS {{stanceContent}} {{/if}} +{{#if hasKnowledge}} + +## Knowledge +The following knowledge is domain-specific information for this movement. Use it as reference. + +{{knowledgeContent}} +{{/if}} ## Piece Context {{#if pieceName}}- Piece: {{pieceName}} diff --git a/src/shared/prompts/ja/perform_phase1_message.md b/src/shared/prompts/ja/perform_phase1_message.md index 23c6a2d..f7cc4ea 100644 --- a/src/shared/prompts/ja/perform_phase1_message.md +++ b/src/shared/prompts/ja/perform_phase1_message.md @@ -5,7 +5,7 @@ pieceStructure, iteration, movementIteration, movement, hasReport, reportInfo, phaseNote, hasTaskSection, userRequest, hasPreviousResponse, previousResponse, hasUserInputs, userInputs, hasRetryNote, retryNote, hasStance, stanceContent, - stanceReminder, instructions + stanceReminder, hasKnowledge, knowledgeContent, instructions builder: InstructionBuilder --> ## 実行コンテキスト @@ -24,6 +24,13 @@ {{stanceContent}} {{/if}} +{{#if hasKnowledge}} + +## Knowledge +以下のナレッジはこのムーブメントに適用されるドメイン固有の知識です。参考にしてください。 + +{{knowledgeContent}} +{{/if}} ## Piece Context {{#if pieceName}}- ピース: {{pieceName}}