From 3167f038a42a9aa39fddea037f7a9db7aa203129 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:54:45 +0900 Subject: [PATCH] github-issue-135-beesunofuaset (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * planner と architect-planner を統合し、knowledge で設計知識を補完する構成に変更 plan → architect → implement の3ステップを plan → implement の2ステップに統合。 planner ペルソナに構造設計・モジュール設計の知識を追加し、plan ステップに knowledge: architecture を付与することで architect ステップを不要にした。 prompt-log-viewer ツールを追加。 * takt: github-issue-135-beesunofuaset --- e2e/specs/eject.e2e.ts | 101 +++- src/__tests__/eject-facet.test.ts | 128 +++++ src/__tests__/facet-resolution.test.ts | 496 ++++++++++++++++++ src/app/cli/commands.ts | 23 +- src/features/config/ejectBuiltin.ts | 155 +++--- src/features/config/index.ts | 2 +- src/infra/config/loaders/pieceParser.ts | 47 +- src/infra/config/loaders/pieceResolver.ts | 17 +- src/infra/config/loaders/resource-resolver.ts | 123 ++++- src/infra/config/paths.ts | 23 +- 10 files changed, 983 insertions(+), 132 deletions(-) create mode 100644 src/__tests__/eject-facet.test.ts create mode 100644 src/__tests__/facet-resolution.test.ts diff --git a/e2e/specs/eject.e2e.ts b/e2e/specs/eject.e2e.ts index 975ec9f..6ced7f3 100644 --- a/e2e/specs/eject.e2e.ts +++ b/e2e/specs/eject.e2e.ts @@ -66,7 +66,7 @@ describe('E2E: Eject builtin pieces (takt eject)', () => { expect(result.stdout).toContain('Available builtin pieces'); }); - it('should eject piece to project .takt/ by default', () => { + it('should eject piece YAML only to project .takt/ by default', () => { const result = runTakt({ args: ['eject', 'default'], cwd: repo.path, @@ -79,14 +79,12 @@ describe('E2E: Eject builtin pieces (takt eject)', () => { const piecePath = join(repo.path, '.takt', 'pieces', 'default.yaml'); expect(existsSync(piecePath)).toBe(true); - // Personas should be in project .takt/personas/ + // Personas should NOT be copied (resolved via layer system) const personasDir = join(repo.path, '.takt', 'personas'); - expect(existsSync(personasDir)).toBe(true); - expect(existsSync(join(personasDir, 'coder.md'))).toBe(true); - expect(existsSync(join(personasDir, 'planner.md'))).toBe(true); + expect(existsSync(personasDir)).toBe(false); }); - it('should preserve relative persona paths in ejected piece (no rewriting)', () => { + it('should preserve content of builtin piece YAML as-is', () => { runTakt({ args: ['eject', 'default'], cwd: repo.path, @@ -96,13 +94,13 @@ describe('E2E: Eject builtin pieces (takt eject)', () => { const piecePath = join(repo.path, '.takt', 'pieces', 'default.yaml'); const content = readFileSync(piecePath, 'utf-8'); - // Relative paths should be preserved as ../personas/ - expect(content).toContain('../personas/'); + // Content should be an exact copy of builtin — paths preserved as-is + expect(content).toContain('name: default'); // Should NOT contain rewritten absolute paths expect(content).not.toContain('~/.takt/personas/'); }); - it('should eject piece to global ~/.takt/ with --global flag', () => { + it('should eject piece YAML only to global ~/.takt/ with --global flag', () => { const result = runTakt({ args: ['eject', 'default', '--global'], cwd: repo.path, @@ -115,10 +113,9 @@ describe('E2E: Eject builtin pieces (takt eject)', () => { const piecePath = join(isolatedEnv.taktDir, 'pieces', 'default.yaml'); expect(existsSync(piecePath)).toBe(true); - // Personas should be in global personas dir + // Personas should NOT be copied (resolved via layer system) const personasDir = join(isolatedEnv.taktDir, 'personas'); - expect(existsSync(personasDir)).toBe(true); - expect(existsSync(join(personasDir, 'coder.md'))).toBe(true); + expect(existsSync(personasDir)).toBe(false); // Should NOT be in project dir const projectPiecePath = join(repo.path, '.takt', 'pieces', 'default.yaml'); @@ -155,7 +152,7 @@ describe('E2E: Eject builtin pieces (takt eject)', () => { expect(result.stdout).toContain('not found'); }); - it('should correctly eject personas for pieces with unique personas', () => { + it('should eject piece YAML only for pieces with unique personas', () => { const result = runTakt({ args: ['eject', 'magi'], cwd: repo.path, @@ -164,14 +161,80 @@ describe('E2E: Eject builtin pieces (takt eject)', () => { expect(result.exitCode).toBe(0); - // MAGI piece should have its personas ejected + // Piece YAML should be copied + const piecePath = join(repo.path, '.takt', 'pieces', 'magi.yaml'); + expect(existsSync(piecePath)).toBe(true); + + // Personas should NOT be copied (resolved via layer system) const personasDir = join(repo.path, '.takt', 'personas'); - expect(existsSync(join(personasDir, 'melchior.md'))).toBe(true); - expect(existsSync(join(personasDir, 'balthasar.md'))).toBe(true); - expect(existsSync(join(personasDir, 'casper.md'))).toBe(true); + expect(existsSync(personasDir)).toBe(false); }); - it('should preserve relative paths for global eject too', () => { + it('should eject individual facet to project .takt/', () => { + const result = runTakt({ + args: ['eject', 'persona', 'coder'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + + // Persona should be copied to project .takt/personas/ + const personaPath = join(repo.path, '.takt', 'personas', 'coder.md'); + expect(existsSync(personaPath)).toBe(true); + const content = readFileSync(personaPath, 'utf-8'); + expect(content.length).toBeGreaterThan(0); + }); + + it('should eject individual facet to global ~/.takt/ with --global', () => { + const result = runTakt({ + args: ['eject', 'persona', 'coder', '--global'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + + // Persona should be copied to global dir + const personaPath = join(isolatedEnv.taktDir, 'personas', 'coder.md'); + expect(existsSync(personaPath)).toBe(true); + + // Should NOT be in project dir + const projectPersonaPath = join(repo.path, '.takt', 'personas', 'coder.md'); + expect(existsSync(projectPersonaPath)).toBe(false); + }); + + it('should skip eject facet when already exists', () => { + // First eject + runTakt({ + args: ['eject', 'persona', 'coder'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Second eject — should skip + const result = runTakt({ + args: ['eject', 'persona', 'coder'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Already exists'); + }); + + it('should report error for non-existent facet', () => { + const result = runTakt({ + args: ['eject', 'persona', 'nonexistent-xyz'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('not found'); + }); + + it('should preserve content of builtin piece YAML for global eject', () => { runTakt({ args: ['eject', 'magi', '--global'], cwd: repo.path, @@ -181,7 +244,7 @@ describe('E2E: Eject builtin pieces (takt eject)', () => { const piecePath = join(isolatedEnv.taktDir, 'pieces', 'magi.yaml'); const content = readFileSync(piecePath, 'utf-8'); - expect(content).toContain('../personas/'); + expect(content).toContain('name: magi'); expect(content).not.toContain('~/.takt/personas/'); }); }); diff --git a/src/__tests__/eject-facet.test.ts b/src/__tests__/eject-facet.test.ts new file mode 100644 index 0000000..6276698 --- /dev/null +++ b/src/__tests__/eject-facet.test.ts @@ -0,0 +1,128 @@ +/** + * Tests for ejectFacet function. + * + * Covers: + * - Normal copy from builtin to project layer + * - Normal copy from builtin to global layer (--global) + * - Skip when facet already exists at destination + * - Error and listing when facet not found in builtins + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// vi.hoisted runs before vi.mock hoisting — safe for shared state +const mocks = vi.hoisted(() => { + let builtinDir = ''; + let projectFacetDir = ''; + let globalFacetDir = ''; + + return { + get builtinDir() { return builtinDir; }, + set builtinDir(v: string) { builtinDir = v; }, + get projectFacetDir() { return projectFacetDir; }, + set projectFacetDir(v: string) { projectFacetDir = v; }, + get globalFacetDir() { return globalFacetDir; }, + set globalFacetDir(v: string) { globalFacetDir = v; }, + ui: { + header: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + blankLine: vi.fn(), + }, + }; +}); + +vi.mock('../infra/config/index.js', () => ({ + getLanguage: () => 'en' as const, + getBuiltinFacetDir: () => mocks.builtinDir, + getProjectFacetDir: () => mocks.projectFacetDir, + getGlobalFacetDir: () => mocks.globalFacetDir, + getGlobalPiecesDir: vi.fn(), + getProjectPiecesDir: vi.fn(), + getBuiltinPiecesDir: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => mocks.ui); + +import { ejectFacet } from '../features/config/ejectBuiltin.js'; + +function createTestDirs() { + const baseDir = mkdtempSync(join(tmpdir(), 'takt-eject-facet-test-')); + const builtinDir = join(baseDir, 'builtins', 'personas'); + const projectDir = join(baseDir, 'project'); + const globalDir = join(baseDir, 'global'); + + mkdirSync(builtinDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(globalDir, { recursive: true }); + + writeFileSync(join(builtinDir, 'coder.md'), '# Coder Persona\nYou are a coder.'); + writeFileSync(join(builtinDir, 'planner.md'), '# Planner Persona\nYou are a planner.'); + + return { + baseDir, + builtinDir, + projectDir, + globalDir, + cleanup: () => rmSync(baseDir, { recursive: true, force: true }), + }; +} + +describe('ejectFacet', () => { + let dirs: ReturnType; + + beforeEach(() => { + dirs = createTestDirs(); + mocks.builtinDir = dirs.builtinDir; + mocks.projectFacetDir = join(dirs.projectDir, '.takt', 'personas'); + mocks.globalFacetDir = join(dirs.globalDir, 'personas'); + + Object.values(mocks.ui).forEach((fn) => fn.mockClear()); + }); + + afterEach(() => { + dirs.cleanup(); + }); + + it('should copy builtin facet to project .takt/{type}/', async () => { + await ejectFacet('personas', 'coder', { projectDir: dirs.projectDir }); + + const destPath = join(dirs.projectDir, '.takt', 'personas', 'coder.md'); + expect(existsSync(destPath)).toBe(true); + expect(readFileSync(destPath, 'utf-8')).toBe('# Coder Persona\nYou are a coder.'); + expect(mocks.ui.success).toHaveBeenCalled(); + }); + + it('should copy builtin facet to global ~/.takt/{type}/ with --global', async () => { + await ejectFacet('personas', 'coder', { global: true, projectDir: dirs.projectDir }); + + const destPath = join(dirs.globalDir, 'personas', 'coder.md'); + expect(existsSync(destPath)).toBe(true); + expect(readFileSync(destPath, 'utf-8')).toBe('# Coder Persona\nYou are a coder.'); + expect(mocks.ui.success).toHaveBeenCalled(); + }); + + it('should skip if facet already exists at destination', async () => { + const destDir = join(dirs.projectDir, '.takt', 'personas'); + mkdirSync(destDir, { recursive: true }); + writeFileSync(join(destDir, 'coder.md'), 'Custom coder content'); + + await ejectFacet('personas', 'coder', { projectDir: dirs.projectDir }); + + // File should NOT be overwritten + expect(readFileSync(join(destDir, 'coder.md'), 'utf-8')).toBe('Custom coder content'); + expect(mocks.ui.warn).toHaveBeenCalledWith(expect.stringContaining('Already exists')); + }); + + it('should show error and list available facets when not found', async () => { + await ejectFacet('personas', 'nonexistent', { projectDir: dirs.projectDir }); + + expect(mocks.ui.error).toHaveBeenCalledWith(expect.stringContaining('not found')); + expect(mocks.ui.info).toHaveBeenCalledWith(expect.stringContaining('Available')); + }); +}); diff --git a/src/__tests__/facet-resolution.test.ts b/src/__tests__/facet-resolution.test.ts new file mode 100644 index 0000000..c863338 --- /dev/null +++ b/src/__tests__/facet-resolution.test.ts @@ -0,0 +1,496 @@ +/** + * Tests for name-based facet resolution (layer system). + * + * Covers: + * - isResourcePath() helper + * - resolveFacetByName() 3-layer resolution (project → user → builtin) + * - resolveRefToContent() with facetType and context + * - resolvePersona() with context (name-based resolution) + * - parseFacetType() CLI mapping + * - Facet directory path helpers + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + isResourcePath, + resolveFacetByName, + resolveRefToContent, + resolveRefList, + resolvePersona, + type FacetResolutionContext, + type PieceSections, +} from '../infra/config/loaders/resource-resolver.js'; +import { + getProjectFacetDir, + getGlobalFacetDir, + getBuiltinFacetDir, + type FacetType, +} from '../infra/config/paths.js'; +import { parseFacetType, VALID_FACET_TYPES } from '../features/config/ejectBuiltin.js'; +import { normalizePieceConfig } from '../infra/config/loaders/pieceParser.js'; + +describe('isResourcePath', () => { + it('should return true for relative paths starting with ./', () => { + expect(isResourcePath('./personas/coder.md')).toBe(true); + }); + + it('should return true for relative paths starting with ../', () => { + expect(isResourcePath('../personas/coder.md')).toBe(true); + }); + + it('should return true for absolute paths', () => { + expect(isResourcePath('/home/user/coder.md')).toBe(true); + }); + + it('should return true for home directory paths', () => { + expect(isResourcePath('~/coder.md')).toBe(true); + }); + + it('should return true for paths ending with .md', () => { + expect(isResourcePath('coder.md')).toBe(true); + }); + + it('should return false for plain names', () => { + expect(isResourcePath('coder')).toBe(false); + expect(isResourcePath('architecture-reviewer')).toBe(false); + expect(isResourcePath('coding')).toBe(false); + }); +}); + +describe('resolveFacetByName', () => { + let tempDir: string; + let projectDir: string; + let context: FacetResolutionContext; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-facet-test-')); + projectDir = join(tempDir, 'project'); + mkdirSync(projectDir, { recursive: true }); + context = { projectDir, lang: 'ja' }; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should resolve from builtin when no project/user override exists', () => { + // Builtin personas exist in the real builtins directory + const content = resolveFacetByName('coder', 'personas', context); + expect(content).toBeDefined(); + expect(content).toContain(''); // Just verify it returns something + }); + + it('should resolve from project layer over builtin', () => { + const projectPersonasDir = join(projectDir, '.takt', 'personas'); + mkdirSync(projectPersonasDir, { recursive: true }); + writeFileSync(join(projectPersonasDir, 'coder.md'), 'Project-level coder persona'); + + const content = resolveFacetByName('coder', 'personas', context); + expect(content).toBe('Project-level coder persona'); + }); + + it('should return undefined when facet not found in any layer', () => { + const content = resolveFacetByName('nonexistent-facet-xyz', 'personas', context); + expect(content).toBeUndefined(); + }); + + it('should resolve different facet types', () => { + const projectPoliciesDir = join(projectDir, '.takt', 'policies'); + mkdirSync(projectPoliciesDir, { recursive: true }); + writeFileSync(join(projectPoliciesDir, 'custom-policy.md'), 'Custom policy content'); + + const content = resolveFacetByName('custom-policy', 'policies', context); + expect(content).toBe('Custom policy content'); + }); + + it('should try project before builtin', () => { + // Create project override + const projectPersonasDir = join(projectDir, '.takt', 'personas'); + mkdirSync(projectPersonasDir, { recursive: true }); + writeFileSync(join(projectPersonasDir, 'coder.md'), 'OVERRIDE'); + + const content = resolveFacetByName('coder', 'personas', context); + expect(content).toBe('OVERRIDE'); + }); +}); + +describe('resolveRefToContent with layer resolution', () => { + let tempDir: string; + let context: FacetResolutionContext; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-ref-test-')); + context = { projectDir: tempDir, lang: 'ja' }; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should prefer resolvedMap over layer resolution', () => { + const resolvedMap = { 'coding': 'Map content for coding' }; + const content = resolveRefToContent('coding', resolvedMap, tempDir, 'policies', context); + expect(content).toBe('Map content for coding'); + }); + + it('should use layer resolution for name refs when not in resolvedMap', () => { + const policiesDir = join(tempDir, '.takt', 'policies'); + mkdirSync(policiesDir, { recursive: true }); + writeFileSync(join(policiesDir, 'coding.md'), 'Project coding policy'); + + const content = resolveRefToContent('coding', undefined, tempDir, 'policies', context); + expect(content).toBe('Project coding policy'); + }); + + it('should use path resolution for path-like refs', () => { + const policyFile = join(tempDir, 'my-policy.md'); + writeFileSync(policyFile, 'Inline policy'); + + const content = resolveRefToContent('./my-policy.md', undefined, tempDir); + expect(content).toBe('Inline policy'); + }); + + it('should fall back to path resolution when no context', () => { + const content = resolveRefToContent('some-name', undefined, tempDir); + // No context, no file — returns the spec as-is (inline content behavior) + expect(content).toBe('some-name'); + }); +}); + +describe('resolveRefList with layer resolution', () => { + let tempDir: string; + let context: FacetResolutionContext; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-reflist-test-')); + context = { projectDir: tempDir, lang: 'ja' }; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should resolve array of name refs via layer resolution', () => { + const policiesDir = join(tempDir, '.takt', 'policies'); + mkdirSync(policiesDir, { recursive: true }); + writeFileSync(join(policiesDir, 'policy-a.md'), 'Policy A content'); + writeFileSync(join(policiesDir, 'policy-b.md'), 'Policy B content'); + + const result = resolveRefList( + ['policy-a', 'policy-b'], + undefined, + tempDir, + 'policies', + context, + ); + + expect(result).toEqual(['Policy A content', 'Policy B content']); + }); + + it('should handle mixed array of name refs and path refs', () => { + const policiesDir = join(tempDir, '.takt', 'policies'); + mkdirSync(policiesDir, { recursive: true }); + writeFileSync(join(policiesDir, 'name-policy.md'), 'Name-resolved policy'); + + const pathFile = join(tempDir, 'local-policy.md'); + writeFileSync(pathFile, 'Path-resolved policy'); + + const result = resolveRefList( + ['name-policy', './local-policy.md'], + undefined, + tempDir, + 'policies', + context, + ); + + expect(result).toEqual(['Name-resolved policy', 'Path-resolved policy']); + }); + + it('should return undefined for undefined input', () => { + const result = resolveRefList(undefined, undefined, tempDir, 'policies', context); + expect(result).toBeUndefined(); + }); + + it('should handle single string ref (not array)', () => { + const policiesDir = join(tempDir, '.takt', 'policies'); + mkdirSync(policiesDir, { recursive: true }); + writeFileSync(join(policiesDir, 'single.md'), 'Single policy'); + + const result = resolveRefList( + 'single', + undefined, + tempDir, + 'policies', + context, + ); + + expect(result).toEqual(['Single policy']); + }); + + it('should prefer resolvedMap over layer resolution', () => { + const resolvedMap = { coding: 'Map content for coding' }; + const result = resolveRefList( + ['coding'], + resolvedMap, + tempDir, + 'policies', + context, + ); + + expect(result).toEqual(['Map content for coding']); + }); +}); + +describe('resolvePersona with layer resolution', () => { + let tempDir: string; + let projectDir: string; + let context: FacetResolutionContext; + const emptySections: PieceSections = {}; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-persona-test-')); + projectDir = join(tempDir, 'project'); + mkdirSync(projectDir, { recursive: true }); + context = { projectDir, lang: 'ja' }; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should resolve persona by name from builtin', () => { + const result = resolvePersona('coder', emptySections, tempDir, context); + expect(result.personaSpec).toBe('coder'); + expect(result.personaPath).toBeDefined(); + expect(result.personaPath).toContain('coder.md'); + }); + + it('should resolve persona from project layer', () => { + const projectPersonasDir = join(projectDir, '.takt', 'personas'); + mkdirSync(projectPersonasDir, { recursive: true }); + const personaPath = join(projectPersonasDir, 'custom-persona.md'); + writeFileSync(personaPath, 'Custom persona content'); + + const result = resolvePersona('custom-persona', emptySections, tempDir, context); + expect(result.personaSpec).toBe('custom-persona'); + expect(result.personaPath).toBe(personaPath); + }); + + it('should prefer section map over layer resolution', () => { + const personaFile = join(tempDir, 'explicit.md'); + writeFileSync(personaFile, 'Explicit persona'); + + const sections: PieceSections = { + personas: { 'my-persona': './explicit.md' }, + }; + + const result = resolvePersona('my-persona', sections, tempDir, context); + expect(result.personaSpec).toBe('./explicit.md'); + expect(result.personaPath).toBe(personaFile); + }); + + it('should handle path-like persona specs directly', () => { + const personaFile = join(tempDir, 'personas', 'coder.md'); + mkdirSync(join(tempDir, 'personas'), { recursive: true }); + writeFileSync(personaFile, 'Path persona'); + + const result = resolvePersona('../personas/coder.md', emptySections, tempDir); + // Path-like spec should be resolved as resource path, not name + expect(result.personaSpec).toBe('../personas/coder.md'); + }); + + it('should return empty for undefined persona', () => { + const result = resolvePersona(undefined, emptySections, tempDir, context); + expect(result).toEqual({}); + }); +}); + +describe('facet directory path helpers', () => { + it('getProjectFacetDir should return .takt/{type}/ path', () => { + const dir = getProjectFacetDir('/my/project', 'personas'); + expect(dir).toContain('.takt'); + expect(dir).toContain('personas'); + }); + + it('getGlobalFacetDir should return path with facet type', () => { + const dir = getGlobalFacetDir('policies'); + expect(dir).toContain('policies'); + }); + + it('getBuiltinFacetDir should return path with lang and facet type', () => { + const dir = getBuiltinFacetDir('ja', 'knowledge'); + expect(dir).toContain('ja'); + expect(dir).toContain('knowledge'); + }); + + it('should work with all facet types', () => { + const types: FacetType[] = ['personas', 'policies', 'knowledge', 'instructions', 'output-contracts']; + for (const t of types) { + expect(getProjectFacetDir('/proj', t)).toContain(t); + expect(getGlobalFacetDir(t)).toContain(t); + expect(getBuiltinFacetDir('en', t)).toContain(t); + } + }); +}); + +describe('parseFacetType', () => { + it('should map singular to plural facet types', () => { + expect(parseFacetType('persona')).toBe('personas'); + expect(parseFacetType('policy')).toBe('policies'); + expect(parseFacetType('knowledge')).toBe('knowledge'); + expect(parseFacetType('instruction')).toBe('instructions'); + expect(parseFacetType('output-contract')).toBe('output-contracts'); + }); + + it('should return undefined for invalid facet types', () => { + expect(parseFacetType('invalid')).toBeUndefined(); + expect(parseFacetType('personas')).toBeUndefined(); + expect(parseFacetType('')).toBeUndefined(); + }); + + it('VALID_FACET_TYPES should contain all singular forms', () => { + expect(VALID_FACET_TYPES).toContain('persona'); + expect(VALID_FACET_TYPES).toContain('policy'); + expect(VALID_FACET_TYPES).toContain('knowledge'); + expect(VALID_FACET_TYPES).toContain('instruction'); + expect(VALID_FACET_TYPES).toContain('output-contract'); + expect(VALID_FACET_TYPES).toHaveLength(5); + }); +}); + +describe('normalizePieceConfig with layer resolution', () => { + let tempDir: string; + let pieceDir: string; + let projectDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-normalize-test-')); + pieceDir = join(tempDir, 'pieces'); + projectDir = join(tempDir, 'project'); + mkdirSync(pieceDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should resolve persona by name when section map is absent and context provided', () => { + const raw = { + name: 'test-piece', + movements: [ + { + name: 'step1', + persona: 'coder', + instruction: '{task}', + }, + ], + }; + + const context: FacetResolutionContext = { projectDir, lang: 'ja' }; + const config = normalizePieceConfig(raw, pieceDir, context); + + expect(config.movements[0]!.persona).toBe('coder'); + // With context, it should find the builtin coder persona + expect(config.movements[0]!.personaPath).toBeDefined(); + expect(config.movements[0]!.personaPath).toContain('coder.md'); + }); + + it('should resolve policy by name when section map is absent', () => { + // Create project-level policy + const policiesDir = join(projectDir, '.takt', 'policies'); + mkdirSync(policiesDir, { recursive: true }); + writeFileSync(join(policiesDir, 'custom-policy.md'), '# Custom Policy\nBe nice.'); + + const raw = { + name: 'test-piece', + movements: [ + { + name: 'step1', + persona: 'coder', + policy: 'custom-policy', + instruction: '{task}', + }, + ], + }; + + const context: FacetResolutionContext = { projectDir, lang: 'ja' }; + const config = normalizePieceConfig(raw, pieceDir, context); + + expect(config.movements[0]!.policyContents).toBeDefined(); + expect(config.movements[0]!.policyContents![0]).toBe('# Custom Policy\nBe nice.'); + }); + + it('should prefer section map over layer resolution', () => { + // Create section map entry + const personaFile = join(pieceDir, 'my-coder.md'); + writeFileSync(personaFile, 'Section map coder'); + + const raw = { + name: 'test-piece', + personas: { + coder: './my-coder.md', + }, + movements: [ + { + name: 'step1', + persona: 'coder', + instruction: '{task}', + }, + ], + }; + + const context: FacetResolutionContext = { projectDir, lang: 'ja' }; + const config = normalizePieceConfig(raw, pieceDir, context); + + // Section map should be used, not layer resolution + expect(config.movements[0]!.persona).toBe('./my-coder.md'); + expect(config.movements[0]!.personaPath).toBe(personaFile); + }); + + it('should work without context (backward compatibility)', () => { + const raw = { + name: 'test-piece', + movements: [ + { + name: 'step1', + persona: 'coder', + instruction: '{task}', + }, + ], + }; + + // No context — backward compatibility mode + const config = normalizePieceConfig(raw, pieceDir); + + // Without context, name 'coder' resolves as relative path from pieceDir + expect(config.movements[0]!.persona).toBe('coder'); + }); + + it('should resolve knowledge by name from project layer', () => { + const knowledgeDir = join(projectDir, '.takt', 'knowledge'); + mkdirSync(knowledgeDir, { recursive: true }); + writeFileSync(join(knowledgeDir, 'domain-kb.md'), '# Domain Knowledge'); + + const raw = { + name: 'test-piece', + movements: [ + { + name: 'step1', + persona: 'coder', + knowledge: 'domain-kb', + instruction: '{task}', + }, + ], + }; + + const context: FacetResolutionContext = { projectDir, lang: 'ja' }; + const config = normalizePieceConfig(raw, pieceDir, context); + + expect(config.movements[0]!.knowledgeContents).toBeDefined(); + expect(config.movements[0]!.knowledgeContents![0]).toBe('# Domain Knowledge'); + }); +}); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 7566ffb..0b6ad5d 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -7,7 +7,7 @@ import { clearPersonaSessions, getCurrentPiece } from '../../infra/config/index.js'; import { success } from '../../shared/ui/index.js'; import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js'; -import { switchPiece, switchConfig, ejectBuiltin, resetCategoriesToDefault, deploySkill } from '../../features/config/index.js'; +import { switchPiece, switchConfig, ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, deploySkill } from '../../features/config/index.js'; import { previewPrompts } from '../../features/prompt/index.js'; import { showCatalog } from '../../features/catalog/index.js'; import { program, resolvedCwd } from './program.js'; @@ -76,11 +76,24 @@ program program .command('eject') - .description('Copy builtin piece/agents for customization (default: project .takt/)') - .argument('[name]', 'Specific builtin to eject') + .description('Copy builtin piece or facet for customization (default: project .takt/)') + .argument('[typeOrName]', `Piece name, or facet type (${VALID_FACET_TYPES.join(', ')})`) + .argument('[facetName]', 'Facet name (when first arg is a facet type)') .option('--global', 'Eject to ~/.takt/ instead of project .takt/') - .action(async (name: string | undefined, opts: { global?: boolean }) => { - await ejectBuiltin(name, { global: opts.global, projectDir: resolvedCwd }); + .action(async (typeOrName: string | undefined, facetName: string | undefined, opts: { global?: boolean }) => { + const ejectOptions = { global: opts.global, projectDir: resolvedCwd }; + + if (typeOrName && facetName) { + const facetType = parseFacetType(typeOrName); + if (!facetType) { + console.error(`Invalid facet type: ${typeOrName}. Valid types: ${VALID_FACET_TYPES.join(', ')}`); + process.exitCode = 1; + return; + } + await ejectFacet(facetType, facetName, ejectOptions); + } else { + await ejectBuiltin(typeOrName, ejectOptions); + } }); program diff --git a/src/features/config/ejectBuiltin.ts b/src/features/config/ejectBuiltin.ts index cb28c98..88c92fa 100644 --- a/src/features/config/ejectBuiltin.ts +++ b/src/features/config/ejectBuiltin.ts @@ -1,8 +1,9 @@ /** * /eject command implementation * - * Copies a builtin piece (and its personas/policies/instructions) for user customization. - * Directory structure is mirrored so relative paths work as-is. + * Copies a builtin piece YAML for user customization. + * Also supports ejecting individual facets (persona, policy, etc.) + * to override builtins via layer resolution. * * Default target: project-local (.takt/) * With --global: user global (~/.takt/) @@ -10,35 +11,54 @@ import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; +import type { FacetType } from '../../infra/config/paths.js'; import { getGlobalPiecesDir, - getGlobalPersonasDir, getProjectPiecesDir, - getProjectPersonasDir, getBuiltinPiecesDir, + getProjectFacetDir, + getGlobalFacetDir, + getBuiltinFacetDir, getLanguage, } from '../../infra/config/index.js'; -import { getLanguageResourcesDir } from '../../infra/resources/index.js'; import { header, success, info, warn, error, blankLine } from '../../shared/ui/index.js'; export interface EjectOptions { global?: boolean; - projectDir?: string; + projectDir: string; +} + +/** Singular CLI facet type names mapped to directory (plural) FacetType */ +const FACET_TYPE_MAP: Record = { + persona: 'personas', + policy: 'policies', + knowledge: 'knowledge', + instruction: 'instructions', + 'output-contract': 'output-contracts', +}; + +/** Valid singular facet type names for CLI */ +export const VALID_FACET_TYPES = Object.keys(FACET_TYPE_MAP); + +/** + * Parse singular CLI facet type to plural directory FacetType. + * Returns undefined if the input is not a valid facet type. + */ +export function parseFacetType(singular: string): FacetType | undefined { + return FACET_TYPE_MAP[singular]; } /** - * Eject a builtin piece to project or global space for customization. - * Copies the piece YAML and related agent .md files, preserving - * the directory structure so relative paths continue to work. + * Eject a builtin piece YAML to project or global space for customization. + * Only copies the piece YAML — facets are resolved via layer system. */ -export async function ejectBuiltin(name?: string, options: EjectOptions = {}): Promise { +export async function ejectBuiltin(name: string | undefined, options: EjectOptions): Promise { header('Eject Builtin'); const lang = getLanguage(); const builtinPiecesDir = getBuiltinPiecesDir(lang); if (!name) { - // List available builtins listAvailableBuiltins(builtinPiecesDir, options.global); return; } @@ -50,16 +70,12 @@ export async function ejectBuiltin(name?: string, options: EjectOptions = {}): P return; } - const projectDir = options.projectDir || process.cwd(); - const targetPiecesDir = options.global ? getGlobalPiecesDir() : getProjectPiecesDir(projectDir); - const targetBaseDir = options.global ? dirname(getGlobalPersonasDir()) : dirname(getProjectPersonasDir(projectDir)); - const builtinBaseDir = getLanguageResourcesDir(lang); + const targetPiecesDir = options.global ? getGlobalPiecesDir() : getProjectPiecesDir(options.projectDir); const targetLabel = options.global ? 'global (~/.takt/)' : 'project (.takt/)'; - info(`Ejecting to ${targetLabel}`); + info(`Ejecting piece YAML to ${targetLabel}`); blankLine(); - // Copy piece YAML as-is (no path rewriting — directory structure mirrors builtin) const pieceDest = join(targetPiecesDir, `${name}.yaml`); if (existsSync(pieceDest)) { warn(`User piece already exists: ${pieceDest}`); @@ -70,31 +86,49 @@ export async function ejectBuiltin(name?: string, options: EjectOptions = {}): P writeFileSync(pieceDest, content, 'utf-8'); success(`Ejected piece: ${pieceDest}`); } +} - // Copy related resource files (personas, policies, instructions, output-contracts) - const resourceRefs = extractResourceRelativePaths(builtinPath); - let copiedCount = 0; +/** + * Eject an individual facet from builtin to upper layer for customization. + * Copies the builtin facet .md file to project (.takt/{type}/) or global (~/.takt/{type}/). + */ +export async function ejectFacet( + facetType: FacetType, + name: string, + options: EjectOptions, +): Promise { + header('Eject Facet'); - for (const ref of resourceRefs) { - const srcPath = join(builtinBaseDir, ref.type, ref.path); - const destPath = join(targetBaseDir, ref.type, ref.path); + const lang = getLanguage(); + const builtinDir = getBuiltinFacetDir(lang, facetType); + const srcPath = join(builtinDir, `${name}.md`); - if (!existsSync(srcPath)) continue; - - if (existsSync(destPath)) { - info(` Already exists: ${destPath}`); - continue; - } - - mkdirSync(dirname(destPath), { recursive: true }); - writeFileSync(destPath, readFileSync(srcPath)); - info(` ${destPath}`); - copiedCount++; + if (!existsSync(srcPath)) { + error(`Builtin ${facetType}/${name}.md not found`); + info(`Available ${facetType}:`); + listAvailableFacets(builtinDir); + return; } - if (copiedCount > 0) { - success(`${copiedCount} resource file(s) ejected.`); + const targetDir = options.global + ? getGlobalFacetDir(facetType) + : getProjectFacetDir(options.projectDir, facetType); + const targetLabel = options.global ? 'global (~/.takt/)' : 'project (.takt/)'; + const destPath = join(targetDir, `${name}.md`); + + info(`Ejecting ${facetType}/${name} to ${targetLabel}`); + blankLine(); + + if (existsSync(destPath)) { + warn(`Already exists: ${destPath}`); + warn('Skipping copy (existing file takes priority).'); + return; } + + mkdirSync(dirname(destPath), { recursive: true }); + const content = readFileSync(srcPath, 'utf-8'); + writeFileSync(destPath, content, 'utf-8'); + success(`Ejected: ${destPath}`); } /** List available builtin pieces for ejection */ @@ -118,48 +152,23 @@ function listAvailableBuiltins(builtinPiecesDir: string, isGlobal?: boolean): vo blankLine(); const globalFlag = isGlobal ? ' --global' : ''; info(`Usage: takt eject {name}${globalFlag}`); + info(` Eject individual facet: takt eject {type} {name}${globalFlag}`); + info(` Types: ${VALID_FACET_TYPES.join(', ')}`); if (!isGlobal) { info(' Add --global to eject to ~/.takt/ instead of .takt/'); } } -/** Resource reference extracted from piece YAML */ -interface ResourceRef { - /** Resource type directory (personas, policies, instructions, output-contracts) */ - type: string; - /** Relative path within the resource type directory */ - path: string; -} - -/** Known resource type directories that can be referenced from piece YAML */ -const RESOURCE_TYPES = ['personas', 'policies', 'knowledge', 'instructions', 'output-contracts']; - -/** - * Extract resource relative paths from a builtin piece YAML. - * Matches `../{type}/{path}` patterns for all known resource types. - */ -function extractResourceRelativePaths(piecePath: string): ResourceRef[] { - const content = readFileSync(piecePath, 'utf-8'); - const seen = new Set(); - const refs: ResourceRef[] = []; - const typePattern = RESOURCE_TYPES.join('|'); - const regex = new RegExp(`\\.\\.\\/(?:${typePattern})\\/(.+)`, 'g'); - - let match: RegExpExecArray | null; - while ((match = regex.exec(content)) !== null) { - // Re-parse to extract type and path separately - const fullMatch = match[0]; - const typeMatch = fullMatch.match(/\.\.\/([^/]+)\/(.+)/); - if (typeMatch?.[1] && typeMatch[2]) { - const type = typeMatch[1]; - const path = typeMatch[2].trim(); - const key = `${type}/${path}`; - if (!seen.has(key)) { - seen.add(key); - refs.push({ type, path }); - } - } +/** List available facet files in a builtin directory */ +function listAvailableFacets(builtinDir: string): void { + if (!existsSync(builtinDir)) { + info(' (none)'); + return; } - return refs; + for (const entry of readdirSync(builtinDir).sort()) { + if (!entry.endsWith('.md')) continue; + if (!statSync(join(builtinDir, entry)).isFile()) continue; + info(` ${entry.replace(/\.md$/, '')}`); + } } diff --git a/src/features/config/index.ts b/src/features/config/index.ts index 39b2ccc..2847c03 100644 --- a/src/features/config/index.ts +++ b/src/features/config/index.ts @@ -4,6 +4,6 @@ export { switchPiece } from './switchPiece.js'; export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './switchConfig.js'; -export { ejectBuiltin } from './ejectBuiltin.js'; +export { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES } from './ejectBuiltin.js'; export { resetCategoriesToDefault } from './resetCategories.js'; export { deploySkill } from './deploySkill.js'; diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index a1e124d..7359f82 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -11,8 +11,10 @@ import { parse as parseYaml } from 'yaml'; import type { z } from 'zod'; import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js'; import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractLabelPath, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge } from '../../../core/models/index.js'; +import { getLanguage } from '../global/globalConfig.js'; import { type PieceSections, + type FacetResolutionContext, resolveResourceContent, resolveRefToContent, resolveRefList, @@ -44,6 +46,7 @@ function normalizeOutputContracts( raw: { report?: Array | { name: string; order?: string; format?: string }> } | undefined, pieceDir: string, resolvedReportFormats?: Record, + context?: FacetResolutionContext, ): OutputContractEntry[] | undefined { if (raw?.report == null || raw.report.length === 0) return undefined; @@ -54,8 +57,8 @@ function normalizeOutputContracts( // Item format: {name, order?, format?} const item: OutputContractItem = { name: entry.name, - order: entry.order ? resolveRefToContent(entry.order, resolvedReportFormats, pieceDir) : undefined, - format: entry.format ? resolveRefToContent(entry.format, resolvedReportFormats, pieceDir) : undefined, + order: entry.order ? resolveRefToContent(entry.order, resolvedReportFormats, pieceDir, 'output-contracts', context) : undefined, + format: entry.format ? resolveRefToContent(entry.format, resolvedReportFormats, pieceDir, 'output-contracts', context) : undefined, }; result.push(item); } else { @@ -153,23 +156,24 @@ function normalizeStepFromRaw( step: RawStep, pieceDir: string, sections: PieceSections, + context?: FacetResolutionContext, ): PieceMovement { const rules: PieceRule[] | undefined = step.rules?.map(normalizeRule); const rawPersona = (step as Record).persona as string | undefined; - const { personaSpec, personaPath } = resolvePersona(rawPersona, sections, pieceDir); + const { personaSpec, personaPath } = resolvePersona(rawPersona, sections, pieceDir, context); const displayName: string | undefined = (step as Record).persona_name as string || undefined; const policyRef = (step as Record).policy as string | string[] | undefined; - const policyContents = resolveRefList(policyRef, sections.resolvedPolicies, pieceDir); + const policyContents = resolveRefList(policyRef, sections.resolvedPolicies, pieceDir, 'policies', context); const knowledgeRef = (step as Record).knowledge as string | string[] | undefined; - const knowledgeContents = resolveRefList(knowledgeRef, sections.resolvedKnowledge, pieceDir); + const knowledgeContents = resolveRefList(knowledgeRef, sections.resolvedKnowledge, pieceDir, 'knowledge', context); const expandedInstruction = step.instruction - ? resolveRefToContent(step.instruction, sections.resolvedInstructions, pieceDir) + ? resolveRefToContent(step.instruction, sections.resolvedInstructions, pieceDir, 'instructions', context) : undefined; const result: PieceMovement = { @@ -187,7 +191,7 @@ function normalizeStepFromRaw( edit: step.edit, instructionTemplate: resolveResourceContent(step.instruction_template, pieceDir) || expandedInstruction || '{task}', rules, - outputContracts: normalizeOutputContracts(step.output_contracts, pieceDir, sections.resolvedReportFormats), + outputContracts: normalizeOutputContracts(step.output_contracts, pieceDir, sections.resolvedReportFormats, context), qualityGates: step.quality_gates, passPreviousResponse: step.pass_previous_response ?? true, policyContents, @@ -195,7 +199,7 @@ function normalizeStepFromRaw( }; if (step.parallel && step.parallel.length > 0) { - result.parallel = step.parallel.map((sub: RawStep) => normalizeStepFromRaw(sub, pieceDir, sections)); + result.parallel = step.parallel.map((sub: RawStep) => normalizeStepFromRaw(sub, pieceDir, sections, context)); } return result; @@ -206,8 +210,9 @@ function normalizeLoopMonitorJudge( raw: { persona?: string; instruction_template?: string; rules: Array<{ condition: string; next: string }> }, pieceDir: string, sections: PieceSections, + context?: FacetResolutionContext, ): LoopMonitorJudge { - const { personaSpec, personaPath } = resolvePersona(raw.persona, sections, pieceDir); + const { personaSpec, personaPath } = resolvePersona(raw.persona, sections, pieceDir, context); return { persona: personaSpec, @@ -224,17 +229,22 @@ function normalizeLoopMonitors( raw: Array<{ cycle: string[]; threshold: number; judge: { persona?: string; instruction_template?: string; rules: Array<{ condition: string; next: string }> } }> | undefined, pieceDir: string, sections: PieceSections, + context?: FacetResolutionContext, ): LoopMonitorConfig[] | undefined { if (!raw || raw.length === 0) return undefined; return raw.map((monitor) => ({ cycle: monitor.cycle, threshold: monitor.threshold, - judge: normalizeLoopMonitorJudge(monitor.judge, pieceDir, sections), + judge: normalizeLoopMonitorJudge(monitor.judge, pieceDir, sections, context), })); } /** Convert raw YAML piece config to internal format. */ -export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfig { +export function normalizePieceConfig( + raw: unknown, + pieceDir: string, + context?: FacetResolutionContext, +): PieceConfig { const parsed = PieceConfigRawSchema.parse(raw); const resolvedPolicies = resolveSectionMap(parsed.policies, pieceDir); @@ -251,7 +261,7 @@ export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfi }; const movements: PieceMovement[] = parsed.movements.map((step) => - normalizeStepFromRaw(step, pieceDir, sections), + normalizeStepFromRaw(step, pieceDir, sections, context), ); // Schema guarantees movements.min(1) @@ -268,7 +278,7 @@ export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfi movements, initialMovement, maxIterations: parsed.max_iterations, - loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir, sections), + loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir, sections, context), answerAgent: parsed.answer_agent, }; } @@ -276,13 +286,20 @@ export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfi /** * Load a piece from a YAML file. * @param filePath Path to the piece YAML file + * @param projectDir Optional project directory for 3-layer facet resolution */ -export function loadPieceFromFile(filePath: string): PieceConfig { +export function loadPieceFromFile(filePath: string, projectDir?: string): PieceConfig { if (!existsSync(filePath)) { throw new Error(`Piece file not found: ${filePath}`); } const content = readFileSync(filePath, 'utf-8'); const raw = parseYaml(content); const pieceDir = dirname(filePath); - return normalizePieceConfig(raw, pieceDir); + + let context: FacetResolutionContext | undefined; + if (projectDir) { + context = { projectDir, lang: getLanguage() }; + } + + return normalizePieceConfig(raw, pieceDir, context); } diff --git a/src/infra/config/loaders/pieceResolver.ts b/src/infra/config/loaders/pieceResolver.ts index a089069..32c3296 100644 --- a/src/infra/config/loaders/pieceResolver.ts +++ b/src/infra/config/loaders/pieceResolver.ts @@ -35,7 +35,7 @@ export function listBuiltinPieceNames(options?: { includeDisabled?: boolean }): } /** Get builtin piece by name */ -export function getBuiltinPiece(name: string): PieceConfig | null { +export function getBuiltinPiece(name: string, projectCwd?: string): PieceConfig | null { if (!getBuiltinPiecesEnabled()) return null; const lang = getLanguage(); const disabled = getDisabledBuiltins(); @@ -44,7 +44,7 @@ export function getBuiltinPiece(name: string): PieceConfig | null { const builtinDir = getBuiltinPiecesDir(lang); const yamlPath = join(builtinDir, `${name}.yaml`); if (existsSync(yamlPath)) { - return loadPieceFromFile(yamlPath); + return loadPieceFromFile(yamlPath, projectCwd); } return null; } @@ -69,12 +69,13 @@ function resolvePath(pathInput: string, basePath: string): string { function loadPieceFromPath( filePath: string, basePath: string, + projectCwd?: string, ): PieceConfig | null { const resolvedPath = resolvePath(filePath, basePath); if (!existsSync(resolvedPath)) { return null; } - return loadPieceFromFile(resolvedPath); + return loadPieceFromFile(resolvedPath, projectCwd); } /** @@ -106,16 +107,16 @@ export function loadPiece( const projectPiecesDir = join(getProjectConfigDir(projectCwd), 'pieces'); const projectMatch = resolvePieceFile(projectPiecesDir, name); if (projectMatch) { - return loadPieceFromFile(projectMatch); + return loadPieceFromFile(projectMatch, projectCwd); } const globalPiecesDir = getGlobalPiecesDir(); const globalMatch = resolvePieceFile(globalPiecesDir, name); if (globalMatch) { - return loadPieceFromFile(globalMatch); + return loadPieceFromFile(globalMatch, projectCwd); } - return getBuiltinPiece(name); + return getBuiltinPiece(name, projectCwd); } /** @@ -140,7 +141,7 @@ export function loadPieceByIdentifier( projectCwd: string, ): PieceConfig | null { if (isPiecePath(identifier)) { - return loadPieceFromPath(identifier, projectCwd); + return loadPieceFromPath(identifier, projectCwd, projectCwd); } return loadPiece(identifier, projectCwd); } @@ -271,7 +272,7 @@ export function loadAllPiecesWithSources(cwd: string): Map; } +/** + * Check if a spec looks like a resource path (vs. a facet name). + * Paths start with './', '../', '/', '~' or end with '.md'. + */ +export function isResourcePath(spec: string): boolean { + return ( + spec.startsWith('./') || + spec.startsWith('../') || + spec.startsWith('/') || + spec.startsWith('~') || + spec.endsWith('.md') + ); +} + +/** + * Resolve a facet name to its file path via 3-layer lookup. + * + * Resolution order: + * 1. Project .takt/{facetType}/{name}.md + * 2. User ~/.takt/{facetType}/{name}.md + * 3. Builtin builtins/{lang}/{facetType}/{name}.md + * + * @returns Absolute file path if found, undefined otherwise. + */ +export function resolveFacetPath( + name: string, + facetType: FacetType, + context: FacetResolutionContext, +): string | undefined { + const candidateDirs = [ + getProjectFacetDir(context.projectDir, facetType), + getGlobalFacetDir(facetType), + getBuiltinFacetDir(context.lang, facetType), + ]; + + for (const dir of candidateDirs) { + const filePath = join(dir, `${name}.md`); + if (existsSync(filePath)) { + return filePath; + } + } + + return undefined; +} + +/** + * Resolve a facet name via 3-layer lookup. + * + * @returns File content if found, undefined otherwise. + */ +export function resolveFacetByName( + name: string, + facetType: FacetType, + context: FacetResolutionContext, +): string | undefined { + const filePath = resolveFacetPath(name, facetType, context); + if (filePath) { + return readFileSync(filePath, 'utf-8'); + } + return undefined; +} + /** Resolve a resource spec to an absolute file path. */ export function resolveResourcePath(spec: string, pieceDir: string): string { if (spec.startsWith('./')) return join(pieceDir, spec.slice(2)); @@ -47,15 +119,28 @@ export function resolveResourceContent(spec: string | undefined, pieceDir: strin /** * Resolve a section reference to content. - * Looks up ref in resolvedMap first, then falls back to resolveResourceContent. + * Looks up ref in resolvedMap first, then falls back to path resolution. + * If a FacetResolutionContext is provided and ref is a name (not a path), + * falls back to 3-layer facet resolution. */ export function resolveRefToContent( ref: string, resolvedMap: Record | undefined, pieceDir: string, + facetType?: FacetType, + context?: FacetResolutionContext, ): string | undefined { const mapped = resolvedMap?.[ref]; if (mapped) return mapped; + + if (isResourcePath(ref)) { + return resolveResourceContent(ref, pieceDir); + } + + if (facetType && context) { + return resolveFacetByName(ref, facetType, context); + } + return resolveResourceContent(ref, pieceDir); } @@ -64,12 +149,14 @@ export function resolveRefList( refs: string | string[] | undefined, resolvedMap: Record | undefined, pieceDir: string, + facetType?: FacetType, + context?: FacetResolutionContext, ): string[] | undefined { if (refs == null) return undefined; const list = Array.isArray(refs) ? refs : [refs]; const contents: string[] = []; for (const ref of list) { - const content = resolveRefToContent(ref, resolvedMap, pieceDir); + const content = resolveRefToContent(ref, resolvedMap, pieceDir, facetType, context); if (content) contents.push(content); } return contents.length > 0 ? contents : undefined; @@ -99,11 +186,35 @@ export function resolvePersona( rawPersona: string | undefined, sections: PieceSections, pieceDir: string, + context?: FacetResolutionContext, ): { personaSpec?: string; personaPath?: string } { if (!rawPersona) return {}; - const personaSpec = sections.personas?.[rawPersona] ?? rawPersona; - const resolved = resolveResourcePath(personaSpec, pieceDir); + // If section map has explicit mapping, use it (path-based) + const sectionMapping = sections.personas?.[rawPersona]; + if (sectionMapping) { + const resolved = resolveResourcePath(sectionMapping, pieceDir); + const personaPath = existsSync(resolved) ? resolved : undefined; + return { personaSpec: sectionMapping, personaPath }; + } + + // If rawPersona is a path, resolve it directly + if (isResourcePath(rawPersona)) { + const resolved = resolveResourcePath(rawPersona, pieceDir); + const personaPath = existsSync(resolved) ? resolved : undefined; + return { personaSpec: rawPersona, personaPath }; + } + + // Name-based: try 3-layer resolution to find the persona file + if (context) { + const filePath = resolveFacetPath(rawPersona, 'personas', context); + if (filePath) { + return { personaSpec: rawPersona, personaPath: filePath }; + } + } + + // Fallback: try as relative path from pieceDir (backward compat) + const resolved = resolveResourcePath(rawPersona, pieceDir); const personaPath = existsSync(resolved) ? resolved : undefined; - return { personaSpec, personaPath }; + return { personaSpec: rawPersona, personaPath }; } diff --git a/src/infra/config/paths.ts b/src/infra/config/paths.ts index dba4f9f..c806d25 100644 --- a/src/infra/config/paths.ts +++ b/src/infra/config/paths.ts @@ -11,6 +11,9 @@ import { existsSync, mkdirSync } from 'node:fs'; import type { Language } from '../../core/models/index.js'; import { getLanguageResourcesDir } from '../resources/index.js'; +/** Facet types used in layer resolution */ +export type FacetType = 'personas' | 'policies' | 'knowledge' | 'instructions' | 'output-contracts'; + /** Get takt global config directory (~/.takt or TAKT_CONFIG_DIR) */ export function getGlobalConfigDir(): string { return process.env.TAKT_CONFIG_DIR || join(homedir(), '.takt'); @@ -56,11 +59,6 @@ export function getProjectPiecesDir(projectDir: string): string { return join(getProjectConfigDir(projectDir), 'pieces'); } -/** Get project personas directory (.takt/personas in project) */ -export function getProjectPersonasDir(projectDir: string): string { - return join(getProjectConfigDir(projectDir), 'personas'); -} - /** Get project config file path */ export function getProjectConfigPath(projectDir: string): string { return join(getProjectConfigDir(projectDir), 'config.yaml'); @@ -88,6 +86,21 @@ export function ensureDir(dirPath: string): void { } } +/** Get project facet directory (.takt/{facetType} in project) */ +export function getProjectFacetDir(projectDir: string, facetType: FacetType): string { + return join(getProjectConfigDir(projectDir), facetType); +} + +/** Get global facet directory (~/.takt/{facetType}) */ +export function getGlobalFacetDir(facetType: FacetType): string { + return join(getGlobalConfigDir(), facetType); +} + +/** Get builtin facet directory (builtins/{lang}/{facetType}) */ +export function getBuiltinFacetDir(lang: Language, facetType: FacetType): string { + return join(getLanguageResourcesDir(lang), facetType); +} + /** Validate path is safe (no directory traversal) */ export function isPathSafe(basePath: string, targetPath: string): boolean { const resolvedBase = resolve(basePath);