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:
parent
85271075a2
commit
3167f038a4
@ -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/');
|
||||
});
|
||||
});
|
||||
|
||||
128
src/__tests__/eject-facet.test.ts
Normal file
128
src/__tests__/eject-facet.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
496
src/__tests__/facet-resolution.test.ts
Normal file
496
src/__tests__/facet-resolution.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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<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.
|
||||
* 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<void> {
|
||||
export async function ejectBuiltin(name: string | undefined, options: EjectOptions): Promise<void> {
|
||||
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<void> {
|
||||
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(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)) {
|
||||
info(` Already exists: ${destPath}`);
|
||||
continue;
|
||||
warn(`Already exists: ${destPath}`);
|
||||
warn('Skipping copy (existing file takes priority).');
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(dirname(destPath), { recursive: true });
|
||||
writeFileSync(destPath, readFileSync(srcPath));
|
||||
info(` ${destPath}`);
|
||||
copiedCount++;
|
||||
}
|
||||
|
||||
if (copiedCount > 0) {
|
||||
success(`${copiedCount} resource file(s) ejected.`);
|
||||
}
|
||||
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;
|
||||
/** List available facet files in a builtin directory */
|
||||
function listAvailableFacets(builtinDir: string): void {
|
||||
if (!existsSync(builtinDir)) {
|
||||
info(' (none)');
|
||||
return;
|
||||
}
|
||||
|
||||
/** 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<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 });
|
||||
for (const entry of readdirSync(builtinDir).sort()) {
|
||||
if (!entry.endsWith('.md')) continue;
|
||||
if (!statSync(join(builtinDir, entry)).isFile()) continue;
|
||||
info(` ${entry.replace(/\.md$/, '')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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<Record<string, string> | { name: string; order?: string; format?: string }> } | undefined,
|
||||
pieceDir: string,
|
||||
resolvedReportFormats?: Record<string, string>,
|
||||
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<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
|
||||
|| 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 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);
|
||||
}
|
||||
|
||||
@ -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<string, PieceWithSour
|
||||
for (const { dir, source, disabled } of getPieceDirs(cwd)) {
|
||||
for (const entry of iteratePieceDir(dir, source, disabled)) {
|
||||
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) {
|
||||
log.debug('Skipping invalid piece file', { path: entry.path, error: getErrorMessage(err) });
|
||||
}
|
||||
|
||||
@ -2,12 +2,22 @@
|
||||
* Resource resolution helpers for piece YAML parsing.
|
||||
*
|
||||
* 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 { homedir } from 'node:os';
|
||||
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. */
|
||||
export interface PieceSections {
|
||||
@ -23,6 +33,68 @@ export interface PieceSections {
|
||||
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. */
|
||||
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<string, string> | 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<string, string> | 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, 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 };
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user