github-issue-135-beesunofuaset (#145)

* planner と architect-planner を統合し、knowledge で設計知識を補完する構成に変更

plan → architect → implement の3ステップを plan → implement の2ステップに統合。
planner ペルソナに構造設計・モジュール設計の知識を追加し、plan ステップに
knowledge: architecture を付与することで architect ステップを不要にした。
prompt-log-viewer ツールを追加。

* takt: github-issue-135-beesunofuaset
This commit is contained in:
nrs 2026-02-08 17:54:45 +09:00 committed by GitHub
parent 85271075a2
commit 3167f038a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 983 additions and 132 deletions

View File

@ -66,7 +66,7 @@ describe('E2E: Eject builtin pieces (takt eject)', () => {
expect(result.stdout).toContain('Available builtin pieces'); 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({ const result = runTakt({
args: ['eject', 'default'], args: ['eject', 'default'],
cwd: repo.path, cwd: repo.path,
@ -79,14 +79,12 @@ describe('E2E: Eject builtin pieces (takt eject)', () => {
const piecePath = join(repo.path, '.takt', 'pieces', 'default.yaml'); const piecePath = join(repo.path, '.takt', 'pieces', 'default.yaml');
expect(existsSync(piecePath)).toBe(true); 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'); const personasDir = join(repo.path, '.takt', 'personas');
expect(existsSync(personasDir)).toBe(true); expect(existsSync(personasDir)).toBe(false);
expect(existsSync(join(personasDir, 'coder.md'))).toBe(true);
expect(existsSync(join(personasDir, 'planner.md'))).toBe(true);
}); });
it('should preserve relative persona paths in ejected piece (no rewriting)', () => { it('should preserve content of builtin piece YAML as-is', () => {
runTakt({ runTakt({
args: ['eject', 'default'], args: ['eject', 'default'],
cwd: repo.path, cwd: repo.path,
@ -96,13 +94,13 @@ describe('E2E: Eject builtin pieces (takt eject)', () => {
const piecePath = join(repo.path, '.takt', 'pieces', 'default.yaml'); const piecePath = join(repo.path, '.takt', 'pieces', 'default.yaml');
const content = readFileSync(piecePath, 'utf-8'); const content = readFileSync(piecePath, 'utf-8');
// Relative paths should be preserved as ../personas/ // Content should be an exact copy of builtin — paths preserved as-is
expect(content).toContain('../personas/'); expect(content).toContain('name: default');
// Should NOT contain rewritten absolute paths // Should NOT contain rewritten absolute paths
expect(content).not.toContain('~/.takt/personas/'); 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({ const result = runTakt({
args: ['eject', 'default', '--global'], args: ['eject', 'default', '--global'],
cwd: repo.path, cwd: repo.path,
@ -115,10 +113,9 @@ describe('E2E: Eject builtin pieces (takt eject)', () => {
const piecePath = join(isolatedEnv.taktDir, 'pieces', 'default.yaml'); const piecePath = join(isolatedEnv.taktDir, 'pieces', 'default.yaml');
expect(existsSync(piecePath)).toBe(true); 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'); const personasDir = join(isolatedEnv.taktDir, 'personas');
expect(existsSync(personasDir)).toBe(true); expect(existsSync(personasDir)).toBe(false);
expect(existsSync(join(personasDir, 'coder.md'))).toBe(true);
// Should NOT be in project dir // Should NOT be in project dir
const projectPiecePath = join(repo.path, '.takt', 'pieces', 'default.yaml'); 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'); 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({ const result = runTakt({
args: ['eject', 'magi'], args: ['eject', 'magi'],
cwd: repo.path, cwd: repo.path,
@ -164,14 +161,80 @@ describe('E2E: Eject builtin pieces (takt eject)', () => {
expect(result.exitCode).toBe(0); 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'); const personasDir = join(repo.path, '.takt', 'personas');
expect(existsSync(join(personasDir, 'melchior.md'))).toBe(true); expect(existsSync(personasDir)).toBe(false);
expect(existsSync(join(personasDir, 'balthasar.md'))).toBe(true);
expect(existsSync(join(personasDir, 'casper.md'))).toBe(true);
}); });
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({ runTakt({
args: ['eject', 'magi', '--global'], args: ['eject', 'magi', '--global'],
cwd: repo.path, cwd: repo.path,
@ -181,7 +244,7 @@ describe('E2E: Eject builtin pieces (takt eject)', () => {
const piecePath = join(isolatedEnv.taktDir, 'pieces', 'magi.yaml'); const piecePath = join(isolatedEnv.taktDir, 'pieces', 'magi.yaml');
const content = readFileSync(piecePath, 'utf-8'); const content = readFileSync(piecePath, 'utf-8');
expect(content).toContain('../personas/'); expect(content).toContain('name: magi');
expect(content).not.toContain('~/.takt/personas/'); expect(content).not.toContain('~/.takt/personas/');
}); });
}); });

View File

@ -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<typeof createTestDirs>;
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'));
});
});

View File

@ -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');
});
});

View File

@ -7,7 +7,7 @@
import { clearPersonaSessions, getCurrentPiece } from '../../infra/config/index.js'; import { clearPersonaSessions, getCurrentPiece } from '../../infra/config/index.js';
import { success } from '../../shared/ui/index.js'; import { success } from '../../shared/ui/index.js';
import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/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 { previewPrompts } from '../../features/prompt/index.js';
import { showCatalog } from '../../features/catalog/index.js'; import { showCatalog } from '../../features/catalog/index.js';
import { program, resolvedCwd } from './program.js'; import { program, resolvedCwd } from './program.js';
@ -76,11 +76,24 @@ program
program program
.command('eject') .command('eject')
.description('Copy builtin piece/agents for customization (default: project .takt/)') .description('Copy builtin piece or facet for customization (default: project .takt/)')
.argument('[name]', 'Specific builtin to eject') .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/') .option('--global', 'Eject to ~/.takt/ instead of project .takt/')
.action(async (name: string | undefined, opts: { global?: boolean }) => { .action(async (typeOrName: string | undefined, facetName: string | undefined, opts: { global?: boolean }) => {
await ejectBuiltin(name, { global: opts.global, projectDir: resolvedCwd }); 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 program

View File

@ -1,8 +1,9 @@
/** /**
* /eject command implementation * /eject command implementation
* *
* Copies a builtin piece (and its personas/policies/instructions) for user customization. * Copies a builtin piece YAML for user customization.
* Directory structure is mirrored so relative paths work as-is. * Also supports ejecting individual facets (persona, policy, etc.)
* to override builtins via layer resolution.
* *
* Default target: project-local (.takt/) * Default target: project-local (.takt/)
* With --global: user global (~/.takt/) * With --global: user global (~/.takt/)
@ -10,35 +11,54 @@
import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import type { FacetType } from '../../infra/config/paths.js';
import { import {
getGlobalPiecesDir, getGlobalPiecesDir,
getGlobalPersonasDir,
getProjectPiecesDir, getProjectPiecesDir,
getProjectPersonasDir,
getBuiltinPiecesDir, getBuiltinPiecesDir,
getProjectFacetDir,
getGlobalFacetDir,
getBuiltinFacetDir,
getLanguage, getLanguage,
} from '../../infra/config/index.js'; } from '../../infra/config/index.js';
import { getLanguageResourcesDir } from '../../infra/resources/index.js';
import { header, success, info, warn, error, blankLine } from '../../shared/ui/index.js'; import { header, success, info, warn, error, blankLine } from '../../shared/ui/index.js';
export interface EjectOptions { export interface EjectOptions {
global?: boolean; global?: boolean;
projectDir?: string; projectDir: string;
}
/** Singular CLI facet type names mapped to directory (plural) FacetType */
const FACET_TYPE_MAP: Record<string, FacetType> = {
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. * Eject a builtin piece YAML to project or global space for customization.
* Copies the piece YAML and related agent .md files, preserving * Only copies the piece YAML facets are resolved via layer system.
* the directory structure so relative paths continue to work.
*/ */
export async function ejectBuiltin(name?: string, options: EjectOptions = {}): Promise<void> { export async function ejectBuiltin(name: string | undefined, options: EjectOptions): Promise<void> {
header('Eject Builtin'); header('Eject Builtin');
const lang = getLanguage(); const lang = getLanguage();
const builtinPiecesDir = getBuiltinPiecesDir(lang); const builtinPiecesDir = getBuiltinPiecesDir(lang);
if (!name) { if (!name) {
// List available builtins
listAvailableBuiltins(builtinPiecesDir, options.global); listAvailableBuiltins(builtinPiecesDir, options.global);
return; return;
} }
@ -50,16 +70,12 @@ export async function ejectBuiltin(name?: string, options: EjectOptions = {}): P
return; return;
} }
const projectDir = options.projectDir || process.cwd(); const targetPiecesDir = options.global ? getGlobalPiecesDir() : getProjectPiecesDir(options.projectDir);
const targetPiecesDir = options.global ? getGlobalPiecesDir() : getProjectPiecesDir(projectDir);
const targetBaseDir = options.global ? dirname(getGlobalPersonasDir()) : dirname(getProjectPersonasDir(projectDir));
const builtinBaseDir = getLanguageResourcesDir(lang);
const targetLabel = options.global ? 'global (~/.takt/)' : 'project (.takt/)'; const targetLabel = options.global ? 'global (~/.takt/)' : 'project (.takt/)';
info(`Ejecting to ${targetLabel}`); info(`Ejecting piece YAML to ${targetLabel}`);
blankLine(); blankLine();
// Copy piece YAML as-is (no path rewriting — directory structure mirrors builtin)
const pieceDest = join(targetPiecesDir, `${name}.yaml`); const pieceDest = join(targetPiecesDir, `${name}.yaml`);
if (existsSync(pieceDest)) { if (existsSync(pieceDest)) {
warn(`User piece already exists: ${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'); writeFileSync(pieceDest, content, 'utf-8');
success(`Ejected piece: ${pieceDest}`); success(`Ejected piece: ${pieceDest}`);
} }
}
// Copy related resource files (personas, policies, instructions, output-contracts) /**
const resourceRefs = extractResourceRelativePaths(builtinPath); * Eject an individual facet from builtin to upper layer for customization.
let copiedCount = 0; * 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<void> {
header('Eject Facet');
for (const ref of resourceRefs) { const lang = getLanguage();
const srcPath = join(builtinBaseDir, ref.type, ref.path); const builtinDir = getBuiltinFacetDir(lang, facetType);
const destPath = join(targetBaseDir, ref.type, ref.path); const srcPath = join(builtinDir, `${name}.md`);
if (!existsSync(srcPath)) continue; if (!existsSync(srcPath)) {
error(`Builtin ${facetType}/${name}.md not found`);
info(`Available ${facetType}:`);
listAvailableFacets(builtinDir);
return;
}
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)) { if (existsSync(destPath)) {
info(` Already exists: ${destPath}`); warn(`Already exists: ${destPath}`);
continue; warn('Skipping copy (existing file takes priority).');
return;
} }
mkdirSync(dirname(destPath), { recursive: true }); mkdirSync(dirname(destPath), { recursive: true });
writeFileSync(destPath, readFileSync(srcPath)); const content = readFileSync(srcPath, 'utf-8');
info(` ${destPath}`); writeFileSync(destPath, content, 'utf-8');
copiedCount++; success(`Ejected: ${destPath}`);
}
if (copiedCount > 0) {
success(`${copiedCount} resource file(s) ejected.`);
}
} }
/** List available builtin pieces for ejection */ /** List available builtin pieces for ejection */
@ -118,48 +152,23 @@ function listAvailableBuiltins(builtinPiecesDir: string, isGlobal?: boolean): vo
blankLine(); blankLine();
const globalFlag = isGlobal ? ' --global' : ''; const globalFlag = isGlobal ? ' --global' : '';
info(`Usage: takt eject {name}${globalFlag}`); info(`Usage: takt eject {name}${globalFlag}`);
info(` Eject individual facet: takt eject {type} {name}${globalFlag}`);
info(` Types: ${VALID_FACET_TYPES.join(', ')}`);
if (!isGlobal) { if (!isGlobal) {
info(' Add --global to eject to ~/.takt/ instead of .takt/'); info(' Add --global to eject to ~/.takt/ instead of .takt/');
} }
} }
/** Resource reference extracted from piece YAML */ /** List available facet files in a builtin directory */
interface ResourceRef { function listAvailableFacets(builtinDir: string): void {
/** Resource type directory (personas, policies, instructions, output-contracts) */ if (!existsSync(builtinDir)) {
type: string; info(' (none)');
/** Relative path within the resource type directory */ return;
path: string; }
}
for (const entry of readdirSync(builtinDir).sort()) {
/** Known resource type directories that can be referenced from piece YAML */ if (!entry.endsWith('.md')) continue;
const RESOURCE_TYPES = ['personas', 'policies', 'knowledge', 'instructions', 'output-contracts']; if (!statSync(join(builtinDir, entry)).isFile()) continue;
info(` ${entry.replace(/\.md$/, '')}`);
/** }
* 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<string>();
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 });
}
}
}
return refs;
} }

View File

@ -4,6 +4,6 @@
export { switchPiece } from './switchPiece.js'; export { switchPiece } from './switchPiece.js';
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './switchConfig.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 { resetCategoriesToDefault } from './resetCategories.js';
export { deploySkill } from './deploySkill.js'; export { deploySkill } from './deploySkill.js';

View File

@ -11,8 +11,10 @@ import { parse as parseYaml } from 'yaml';
import type { z } from 'zod'; import type { z } from 'zod';
import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js'; import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js';
import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractLabelPath, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge } 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 { import {
type PieceSections, type PieceSections,
type FacetResolutionContext,
resolveResourceContent, resolveResourceContent,
resolveRefToContent, resolveRefToContent,
resolveRefList, resolveRefList,
@ -44,6 +46,7 @@ function normalizeOutputContracts(
raw: { report?: Array<Record<string, string> | { name: string; order?: string; format?: string }> } | undefined, raw: { report?: Array<Record<string, string> | { name: string; order?: string; format?: string }> } | undefined,
pieceDir: string, pieceDir: string,
resolvedReportFormats?: Record<string, string>, resolvedReportFormats?: Record<string, string>,
context?: FacetResolutionContext,
): OutputContractEntry[] | undefined { ): OutputContractEntry[] | undefined {
if (raw?.report == null || raw.report.length === 0) return undefined; if (raw?.report == null || raw.report.length === 0) return undefined;
@ -54,8 +57,8 @@ function normalizeOutputContracts(
// Item format: {name, order?, format?} // Item format: {name, order?, format?}
const item: OutputContractItem = { const item: OutputContractItem = {
name: entry.name, name: entry.name,
order: entry.order ? resolveRefToContent(entry.order, resolvedReportFormats, pieceDir) : undefined, order: entry.order ? resolveRefToContent(entry.order, resolvedReportFormats, pieceDir, 'output-contracts', context) : undefined,
format: entry.format ? resolveRefToContent(entry.format, resolvedReportFormats, pieceDir) : undefined, format: entry.format ? resolveRefToContent(entry.format, resolvedReportFormats, pieceDir, 'output-contracts', context) : undefined,
}; };
result.push(item); result.push(item);
} else { } else {
@ -153,23 +156,24 @@ function normalizeStepFromRaw(
step: RawStep, step: RawStep,
pieceDir: string, pieceDir: string,
sections: PieceSections, sections: PieceSections,
context?: FacetResolutionContext,
): PieceMovement { ): PieceMovement {
const rules: PieceRule[] | undefined = step.rules?.map(normalizeRule); const rules: PieceRule[] | undefined = step.rules?.map(normalizeRule);
const rawPersona = (step as Record<string, unknown>).persona as string | undefined; const rawPersona = (step as Record<string, unknown>).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<string, unknown>).persona_name as string const displayName: string | undefined = (step as Record<string, unknown>).persona_name as string
|| undefined; || undefined;
const policyRef = (step as Record<string, unknown>).policy as string | string[] | undefined; const policyRef = (step as Record<string, unknown>).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<string, unknown>).knowledge as string | string[] | undefined; const knowledgeRef = (step as Record<string, unknown>).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 const expandedInstruction = step.instruction
? resolveRefToContent(step.instruction, sections.resolvedInstructions, pieceDir) ? resolveRefToContent(step.instruction, sections.resolvedInstructions, pieceDir, 'instructions', context)
: undefined; : undefined;
const result: PieceMovement = { const result: PieceMovement = {
@ -187,7 +191,7 @@ function normalizeStepFromRaw(
edit: step.edit, edit: step.edit,
instructionTemplate: resolveResourceContent(step.instruction_template, pieceDir) || expandedInstruction || '{task}', instructionTemplate: resolveResourceContent(step.instruction_template, pieceDir) || expandedInstruction || '{task}',
rules, rules,
outputContracts: normalizeOutputContracts(step.output_contracts, pieceDir, sections.resolvedReportFormats), outputContracts: normalizeOutputContracts(step.output_contracts, pieceDir, sections.resolvedReportFormats, context),
qualityGates: step.quality_gates, qualityGates: step.quality_gates,
passPreviousResponse: step.pass_previous_response ?? true, passPreviousResponse: step.pass_previous_response ?? true,
policyContents, policyContents,
@ -195,7 +199,7 @@ function normalizeStepFromRaw(
}; };
if (step.parallel && step.parallel.length > 0) { 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; return result;
@ -206,8 +210,9 @@ function normalizeLoopMonitorJudge(
raw: { persona?: string; instruction_template?: string; rules: Array<{ condition: string; next: string }> }, raw: { persona?: string; instruction_template?: string; rules: Array<{ condition: string; next: string }> },
pieceDir: string, pieceDir: string,
sections: PieceSections, sections: PieceSections,
context?: FacetResolutionContext,
): LoopMonitorJudge { ): LoopMonitorJudge {
const { personaSpec, personaPath } = resolvePersona(raw.persona, sections, pieceDir); const { personaSpec, personaPath } = resolvePersona(raw.persona, sections, pieceDir, context);
return { return {
persona: personaSpec, 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, raw: Array<{ cycle: string[]; threshold: number; judge: { persona?: string; instruction_template?: string; rules: Array<{ condition: string; next: string }> } }> | undefined,
pieceDir: string, pieceDir: string,
sections: PieceSections, sections: PieceSections,
context?: FacetResolutionContext,
): LoopMonitorConfig[] | undefined { ): LoopMonitorConfig[] | undefined {
if (!raw || raw.length === 0) return undefined; if (!raw || raw.length === 0) return undefined;
return raw.map((monitor) => ({ return raw.map((monitor) => ({
cycle: monitor.cycle, cycle: monitor.cycle,
threshold: monitor.threshold, threshold: monitor.threshold,
judge: normalizeLoopMonitorJudge(monitor.judge, pieceDir, sections), judge: normalizeLoopMonitorJudge(monitor.judge, pieceDir, sections, context),
})); }));
} }
/** Convert raw YAML piece config to internal format. */ /** 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 parsed = PieceConfigRawSchema.parse(raw);
const resolvedPolicies = resolveSectionMap(parsed.policies, pieceDir); 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) => const movements: PieceMovement[] = parsed.movements.map((step) =>
normalizeStepFromRaw(step, pieceDir, sections), normalizeStepFromRaw(step, pieceDir, sections, context),
); );
// Schema guarantees movements.min(1) // Schema guarantees movements.min(1)
@ -268,7 +278,7 @@ export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfi
movements, movements,
initialMovement, initialMovement,
maxIterations: parsed.max_iterations, maxIterations: parsed.max_iterations,
loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir, sections), loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir, sections, context),
answerAgent: parsed.answer_agent, answerAgent: parsed.answer_agent,
}; };
} }
@ -276,13 +286,20 @@ export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfi
/** /**
* Load a piece from a YAML file. * Load a piece from a YAML file.
* @param filePath Path to the piece 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)) { if (!existsSync(filePath)) {
throw new Error(`Piece file not found: ${filePath}`); throw new Error(`Piece file not found: ${filePath}`);
} }
const content = readFileSync(filePath, 'utf-8'); const content = readFileSync(filePath, 'utf-8');
const raw = parseYaml(content); const raw = parseYaml(content);
const pieceDir = dirname(filePath); const pieceDir = dirname(filePath);
return normalizePieceConfig(raw, pieceDir);
let context: FacetResolutionContext | undefined;
if (projectDir) {
context = { projectDir, lang: getLanguage() };
}
return normalizePieceConfig(raw, pieceDir, context);
} }

View File

@ -35,7 +35,7 @@ export function listBuiltinPieceNames(options?: { includeDisabled?: boolean }):
} }
/** Get builtin piece by name */ /** 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; if (!getBuiltinPiecesEnabled()) return null;
const lang = getLanguage(); const lang = getLanguage();
const disabled = getDisabledBuiltins(); const disabled = getDisabledBuiltins();
@ -44,7 +44,7 @@ export function getBuiltinPiece(name: string): PieceConfig | null {
const builtinDir = getBuiltinPiecesDir(lang); const builtinDir = getBuiltinPiecesDir(lang);
const yamlPath = join(builtinDir, `${name}.yaml`); const yamlPath = join(builtinDir, `${name}.yaml`);
if (existsSync(yamlPath)) { if (existsSync(yamlPath)) {
return loadPieceFromFile(yamlPath); return loadPieceFromFile(yamlPath, projectCwd);
} }
return null; return null;
} }
@ -69,12 +69,13 @@ function resolvePath(pathInput: string, basePath: string): string {
function loadPieceFromPath( function loadPieceFromPath(
filePath: string, filePath: string,
basePath: string, basePath: string,
projectCwd?: string,
): PieceConfig | null { ): PieceConfig | null {
const resolvedPath = resolvePath(filePath, basePath); const resolvedPath = resolvePath(filePath, basePath);
if (!existsSync(resolvedPath)) { if (!existsSync(resolvedPath)) {
return null; return null;
} }
return loadPieceFromFile(resolvedPath); return loadPieceFromFile(resolvedPath, projectCwd);
} }
/** /**
@ -106,16 +107,16 @@ export function loadPiece(
const projectPiecesDir = join(getProjectConfigDir(projectCwd), 'pieces'); const projectPiecesDir = join(getProjectConfigDir(projectCwd), 'pieces');
const projectMatch = resolvePieceFile(projectPiecesDir, name); const projectMatch = resolvePieceFile(projectPiecesDir, name);
if (projectMatch) { if (projectMatch) {
return loadPieceFromFile(projectMatch); return loadPieceFromFile(projectMatch, projectCwd);
} }
const globalPiecesDir = getGlobalPiecesDir(); const globalPiecesDir = getGlobalPiecesDir();
const globalMatch = resolvePieceFile(globalPiecesDir, name); const globalMatch = resolvePieceFile(globalPiecesDir, name);
if (globalMatch) { 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, projectCwd: string,
): PieceConfig | null { ): PieceConfig | null {
if (isPiecePath(identifier)) { if (isPiecePath(identifier)) {
return loadPieceFromPath(identifier, projectCwd); return loadPieceFromPath(identifier, projectCwd, projectCwd);
} }
return loadPiece(identifier, projectCwd); return loadPiece(identifier, projectCwd);
} }
@ -271,7 +272,7 @@ export function loadAllPiecesWithSources(cwd: string): Map<string, PieceWithSour
for (const { dir, source, disabled } of getPieceDirs(cwd)) { for (const { dir, source, disabled } of getPieceDirs(cwd)) {
for (const entry of iteratePieceDir(dir, source, disabled)) { for (const entry of iteratePieceDir(dir, source, disabled)) {
try { try {
pieces.set(entry.name, { config: loadPieceFromFile(entry.path), source: entry.source }); pieces.set(entry.name, { config: loadPieceFromFile(entry.path, cwd), source: entry.source });
} catch (err) { } catch (err) {
log.debug('Skipping invalid piece file', { path: entry.path, error: getErrorMessage(err) }); log.debug('Skipping invalid piece file', { path: entry.path, error: getErrorMessage(err) });
} }

View File

@ -2,12 +2,22 @@
* Resource resolution helpers for piece YAML parsing. * Resource resolution helpers for piece YAML parsing.
* *
* Resolves file paths, content references, and persona specs * Resolves file paths, content references, and persona specs
* from piece-level section maps. * from piece-level section maps. Supports 3-layer facet resolution
* (project user builtin).
*/ */
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join, basename } from 'node:path'; import { join, basename } from 'node:path';
import type { Language } from '../../../core/models/index.js';
import type { FacetType } from '../paths.js';
import { getProjectFacetDir, getGlobalFacetDir, getBuiltinFacetDir } from '../paths.js';
/** Context for 3-layer facet resolution. */
export interface FacetResolutionContext {
projectDir: string;
lang: Language;
}
/** Pre-resolved section maps passed to movement normalization. */ /** Pre-resolved section maps passed to movement normalization. */
export interface PieceSections { export interface PieceSections {
@ -23,6 +33,68 @@ export interface PieceSections {
resolvedReportFormats?: Record<string, string>; resolvedReportFormats?: Record<string, string>;
} }
/**
* 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. */ /** Resolve a resource spec to an absolute file path. */
export function resolveResourcePath(spec: string, pieceDir: string): string { export function resolveResourcePath(spec: string, pieceDir: string): string {
if (spec.startsWith('./')) return join(pieceDir, spec.slice(2)); 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. * 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( export function resolveRefToContent(
ref: string, ref: string,
resolvedMap: Record<string, string> | undefined, resolvedMap: Record<string, string> | undefined,
pieceDir: string, pieceDir: string,
facetType?: FacetType,
context?: FacetResolutionContext,
): string | undefined { ): string | undefined {
const mapped = resolvedMap?.[ref]; const mapped = resolvedMap?.[ref];
if (mapped) return mapped; if (mapped) return mapped;
if (isResourcePath(ref)) {
return resolveResourceContent(ref, pieceDir);
}
if (facetType && context) {
return resolveFacetByName(ref, facetType, context);
}
return resolveResourceContent(ref, pieceDir); return resolveResourceContent(ref, pieceDir);
} }
@ -64,12 +149,14 @@ export function resolveRefList(
refs: string | string[] | undefined, refs: string | string[] | undefined,
resolvedMap: Record<string, string> | undefined, resolvedMap: Record<string, string> | undefined,
pieceDir: string, pieceDir: string,
facetType?: FacetType,
context?: FacetResolutionContext,
): string[] | undefined { ): string[] | undefined {
if (refs == null) return undefined; if (refs == null) return undefined;
const list = Array.isArray(refs) ? refs : [refs]; const list = Array.isArray(refs) ? refs : [refs];
const contents: string[] = []; const contents: string[] = [];
for (const ref of list) { for (const ref of list) {
const content = resolveRefToContent(ref, resolvedMap, pieceDir); const content = resolveRefToContent(ref, resolvedMap, pieceDir, facetType, context);
if (content) contents.push(content); if (content) contents.push(content);
} }
return contents.length > 0 ? contents : undefined; return contents.length > 0 ? contents : undefined;
@ -99,11 +186,35 @@ export function resolvePersona(
rawPersona: string | undefined, rawPersona: string | undefined,
sections: PieceSections, sections: PieceSections,
pieceDir: string, pieceDir: string,
context?: FacetResolutionContext,
): { personaSpec?: string; personaPath?: string } { ): { personaSpec?: string; personaPath?: string } {
if (!rawPersona) return {}; 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; const personaPath = existsSync(resolved) ? resolved : undefined;
return { personaSpec, personaPath }; 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: rawPersona, personaPath };
} }

View File

@ -11,6 +11,9 @@ import { existsSync, mkdirSync } from 'node:fs';
import type { Language } from '../../core/models/index.js'; import type { Language } from '../../core/models/index.js';
import { getLanguageResourcesDir } from '../resources/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) */ /** Get takt global config directory (~/.takt or TAKT_CONFIG_DIR) */
export function getGlobalConfigDir(): string { export function getGlobalConfigDir(): string {
return process.env.TAKT_CONFIG_DIR || join(homedir(), '.takt'); return process.env.TAKT_CONFIG_DIR || join(homedir(), '.takt');
@ -56,11 +59,6 @@ export function getProjectPiecesDir(projectDir: string): string {
return join(getProjectConfigDir(projectDir), 'pieces'); 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 */ /** Get project config file path */
export function getProjectConfigPath(projectDir: string): string { export function getProjectConfigPath(projectDir: string): string {
return join(getProjectConfigDir(projectDir), 'config.yaml'); 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) */ /** Validate path is safe (no directory traversal) */
export function isPathSafe(basePath: string, targetPath: string): boolean { export function isPathSafe(basePath: string, targetPath: string): boolean {
const resolvedBase = resolve(basePath); const resolvedBase = resolve(basePath);