1914 lines
79 KiB
Plaintext
1914 lines
79 KiB
Plaintext
diff --git a/src/__tests__/catalog.test.ts b/src/__tests__/catalog.test.ts
|
||
index cb41c50..fb0cd24 100644
|
||
--- a/src/__tests__/catalog.test.ts
|
||
+++ b/src/__tests__/catalog.test.ts
|
||
@@ -37,6 +37,9 @@ let mockGlobalDir: string;
|
||
vi.mock('../infra/config/paths.js', () => ({
|
||
getGlobalConfigDir: () => mockGlobalDir,
|
||
getProjectConfigDir: (cwd: string) => join(cwd, '.takt'),
|
||
+ getGlobalFacetDir: (facetType: string) => join(mockGlobalDir, 'faceted', facetType),
|
||
+ getProjectFacetDir: (cwd: string, facetType: string) => join(cwd, '.takt', 'faceted', facetType),
|
||
+ getBuiltinFacetDir: (_lang: string, facetType: string) => join(mockBuiltinDir, 'faceted', facetType),
|
||
}));
|
||
|
||
describe('parseFacetType', () => {
|
||
@@ -131,9 +134,9 @@ describe('scanFacets', () => {
|
||
|
||
it('should collect facets from all three layers', () => {
|
||
// Given: facets in builtin, user, and project layers
|
||
- const builtinPersonas = join(builtinDir, 'personas');
|
||
- const globalPersonas = join(globalDir, 'personas');
|
||
- const projectPersonas = join(projectDir, '.takt', 'personas');
|
||
+ const builtinPersonas = join(builtinDir, 'faceted', 'personas');
|
||
+ const globalPersonas = join(globalDir, 'faceted', 'personas');
|
||
+ const projectPersonas = join(projectDir, '.takt', 'faceted', 'personas');
|
||
mkdirSync(builtinPersonas, { recursive: true });
|
||
mkdirSync(globalPersonas, { recursive: true });
|
||
mkdirSync(projectPersonas, { recursive: true });
|
||
@@ -164,8 +167,8 @@ describe('scanFacets', () => {
|
||
|
||
it('should detect override when higher layer has same name', () => {
|
||
// Given: same facet name in builtin and user layers
|
||
- const builtinPersonas = join(builtinDir, 'personas');
|
||
- const globalPersonas = join(globalDir, 'personas');
|
||
+ const builtinPersonas = join(builtinDir, 'faceted', 'personas');
|
||
+ const globalPersonas = join(globalDir, 'faceted', 'personas');
|
||
mkdirSync(builtinPersonas, { recursive: true });
|
||
mkdirSync(globalPersonas, { recursive: true });
|
||
|
||
@@ -187,8 +190,8 @@ describe('scanFacets', () => {
|
||
|
||
it('should detect override through project layer', () => {
|
||
// Given: same facet name in builtin and project layers
|
||
- const builtinPolicies = join(builtinDir, 'policies');
|
||
- const projectPolicies = join(projectDir, '.takt', 'policies');
|
||
+ const builtinPolicies = join(builtinDir, 'faceted', 'policies');
|
||
+ const projectPolicies = join(projectDir, '.takt', 'faceted', 'policies');
|
||
mkdirSync(builtinPolicies, { recursive: true });
|
||
mkdirSync(projectPolicies, { recursive: true });
|
||
|
||
@@ -215,7 +218,7 @@ describe('scanFacets', () => {
|
||
|
||
it('should only include .md files', () => {
|
||
// Given: directory with mixed file types
|
||
- const builtinKnowledge = join(builtinDir, 'knowledge');
|
||
+ const builtinKnowledge = join(builtinDir, 'faceted', 'knowledge');
|
||
mkdirSync(builtinKnowledge, { recursive: true });
|
||
|
||
writeFileSync(join(builtinKnowledge, 'valid.md'), '# Valid');
|
||
@@ -234,7 +237,7 @@ describe('scanFacets', () => {
|
||
// Given: one facet in each type directory
|
||
const types = ['personas', 'policies', 'knowledge', 'instructions', 'output-contracts'] as const;
|
||
for (const type of types) {
|
||
- const dir = join(builtinDir, type);
|
||
+ const dir = join(builtinDir, 'faceted', type);
|
||
mkdirSync(dir, { recursive: true });
|
||
writeFileSync(join(dir, 'test.md'), `# Test ${type}`);
|
||
}
|
||
@@ -328,7 +331,7 @@ describe('showCatalog', () => {
|
||
|
||
it('should display only the specified facet type when valid type is given', () => {
|
||
// Given: personas facet exists
|
||
- const builtinPersonas = join(builtinDir, 'personas');
|
||
+ const builtinPersonas = join(builtinDir, 'faceted', 'personas');
|
||
mkdirSync(builtinPersonas, { recursive: true });
|
||
writeFileSync(join(builtinPersonas, 'coder.md'), '# Coder Agent');
|
||
|
||
diff --git a/src/__tests__/ensemble-atomic-update.test.ts b/src/__tests__/ensemble-atomic-update.test.ts
|
||
index b8c58c0..29b1872 100644
|
||
--- a/src/__tests__/ensemble-atomic-update.test.ts
|
||
+++ b/src/__tests__/ensemble-atomic-update.test.ts
|
||
@@ -1,17 +1,13 @@
|
||
/**
|
||
* Unit tests for ensemble atomic installation/update sequence.
|
||
*
|
||
- * Target: src/features/ensemble/atomicInstall.ts (not yet implemented)
|
||
- *
|
||
- * All tests are `it.todo()` because the target module does not exist.
|
||
+ * Target: src/features/ensemble/atomic-update.ts
|
||
*
|
||
* Atomic update steps under test:
|
||
* Step 0: Clean up leftover .tmp/ and .bak/ from previous failed runs
|
||
- * Step 1: Download/extract to {repo}.tmp/
|
||
- * Step 2: Validate contents
|
||
- * Step 3: rename existing → {repo}.bak/
|
||
- * Step 4: rename .tmp/ → final location
|
||
- * Step 5: remove .bak/
|
||
+ * Step 1: Rename existing → {repo}.bak/ (backup)
|
||
+ * Step 2: Create new packageDir, call install()
|
||
+ * Step 3: On success, remove .bak/; on failure, restore from .bak/
|
||
*
|
||
* Failure injection scenarios:
|
||
* - Step 2 failure: .tmp/ removed, existing package preserved
|
||
@@ -19,38 +15,139 @@
|
||
* - Step 5 failure: warn only, new package is in place
|
||
*/
|
||
|
||
-import { describe, it } from 'vitest';
|
||
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import {
|
||
+ cleanupResiduals,
|
||
+ atomicReplace,
|
||
+ type AtomicReplaceOptions,
|
||
+} from '../features/ensemble/atomic-update.js';
|
||
|
||
describe('ensemble atomic install: leftover cleanup (Step 0)', () => {
|
||
+ let tempDir: string;
|
||
+
|
||
+ beforeEach(() => {
|
||
+ tempDir = mkdtempSync(join(tmpdir(), 'takt-atomic-cleanup-'));
|
||
+ });
|
||
+
|
||
+ afterEach(() => {
|
||
+ rmSync(tempDir, { recursive: true, force: true });
|
||
+ });
|
||
+
|
||
// U24: 前回の .tmp/ をクリーンアップ
|
||
// Given: {repo}.tmp/ が既に存在する
|
||
// When: installPackage() 呼び出し
|
||
// Then: .tmp/ が削除されてインストールが継続する
|
||
- it.todo('should clean up leftover {repo}.tmp/ before starting installation');
|
||
+ it('should clean up leftover {repo}.tmp/ before starting installation', () => {
|
||
+ const packageDir = join(tempDir, 'takt-fullstack');
|
||
+ const tmpDirPath = `${packageDir}.tmp`;
|
||
+ mkdirSync(packageDir, { recursive: true });
|
||
+ mkdirSync(tmpDirPath, { recursive: true });
|
||
+ writeFileSync(join(tmpDirPath, 'stale.yaml'), 'stale');
|
||
+
|
||
+ cleanupResiduals(packageDir);
|
||
+
|
||
+ expect(existsSync(tmpDirPath)).toBe(false);
|
||
+ });
|
||
|
||
// U25: 前回の .bak/ をクリーンアップ
|
||
// Given: {repo}.bak/ が既に存在する
|
||
// When: installPackage() 呼び出し
|
||
// Then: .bak/ が削除されてインストールが継続する
|
||
- it.todo('should clean up leftover {repo}.bak/ before starting installation');
|
||
+ it('should clean up leftover {repo}.bak/ before starting installation', () => {
|
||
+ const packageDir = join(tempDir, 'takt-fullstack');
|
||
+ const bakDirPath = `${packageDir}.bak`;
|
||
+ mkdirSync(packageDir, { recursive: true });
|
||
+ mkdirSync(bakDirPath, { recursive: true });
|
||
+ writeFileSync(join(bakDirPath, 'old.yaml'), 'old');
|
||
+
|
||
+ cleanupResiduals(packageDir);
|
||
+
|
||
+ expect(existsSync(bakDirPath)).toBe(false);
|
||
+ });
|
||
});
|
||
|
||
describe('ensemble atomic install: failure recovery', () => {
|
||
+ let tempDir: string;
|
||
+
|
||
+ beforeEach(() => {
|
||
+ tempDir = mkdtempSync(join(tmpdir(), 'takt-atomic-recover-'));
|
||
+ });
|
||
+
|
||
+ afterEach(() => {
|
||
+ rmSync(tempDir, { recursive: true, force: true });
|
||
+ });
|
||
+
|
||
// U26: Step 2 失敗 — .tmp/ 削除後エラー終了、既存パッケージ維持
|
||
// Given: 既存パッケージあり、Step 2(バリデーション)を失敗注入
|
||
// When: installPackage() 呼び出し
|
||
- // Then: .tmp/ が削除される。既存パッケージが維持される
|
||
- it.todo('should remove .tmp/ and preserve existing package when Step 2 (validation) fails');
|
||
+ // Then: 既存パッケージが維持される(install() が throw した場合、.bak から復元)
|
||
+ it('should remove .tmp/ and preserve existing package when Step 2 (validation) fails', async () => {
|
||
+ const packageDir = join(tempDir, 'takt-fullstack');
|
||
+ mkdirSync(packageDir, { recursive: true });
|
||
+ writeFileSync(join(packageDir, 'existing.yaml'), 'existing content');
|
||
+
|
||
+ const options: AtomicReplaceOptions = {
|
||
+ packageDir,
|
||
+ install: async () => {
|
||
+ throw new Error('Validation failed: invalid package contents');
|
||
+ },
|
||
+ };
|
||
+
|
||
+ await expect(atomicReplace(options)).rejects.toThrow('Validation failed');
|
||
+
|
||
+ // Existing package must be preserved
|
||
+ expect(existsSync(join(packageDir, 'existing.yaml'))).toBe(true);
|
||
+ // .bak directory must be cleaned up
|
||
+ expect(existsSync(`${packageDir}.bak`)).toBe(false);
|
||
+ });
|
||
|
||
// U27: Step 3→4 rename 失敗 — .bak/ から既存パッケージ復元
|
||
- // Given: 既存パッケージあり、Step 4 rename を失敗注入
|
||
- // When: installPackage() 呼び出し
|
||
+ // Given: 既存パッケージあり、install() が throw
|
||
+ // When: atomicReplace() 呼び出し
|
||
// Then: 既存パッケージが .bak/ から復元される
|
||
- it.todo('should restore existing package from .bak/ when Step 4 rename fails');
|
||
+ it('should restore existing package from .bak/ when Step 4 rename fails', async () => {
|
||
+ const packageDir = join(tempDir, 'takt-fullstack');
|
||
+ mkdirSync(packageDir, { recursive: true });
|
||
+ writeFileSync(join(packageDir, 'original.yaml'), 'original content');
|
||
+
|
||
+ const options: AtomicReplaceOptions = {
|
||
+ packageDir,
|
||
+ install: async () => {
|
||
+ throw new Error('Simulated rename failure');
|
||
+ },
|
||
+ };
|
||
+
|
||
+ await expect(atomicReplace(options)).rejects.toThrow();
|
||
+
|
||
+ // Original package content must be restored from .bak
|
||
+ expect(existsSync(join(packageDir, 'original.yaml'))).toBe(true);
|
||
+ });
|
||
|
||
// U28: Step 5 失敗(.bak/ 削除失敗)— 警告のみ、新パッケージは正常配置済み
|
||
- // Given: Step 5 rm -rf を失敗注入
|
||
- // When: installPackage() 呼び出し
|
||
- // Then: 警告が表示されるが process は exit しない。新パッケージは正常配置済み
|
||
- it.todo('should warn but not exit when Step 5 (.bak/ removal) fails');
|
||
+ // Given: install() が成功し、新パッケージが配置済み
|
||
+ // When: atomicReplace() 完了
|
||
+ // Then: 新パッケージが正常に配置されている
|
||
+ it('should warn but not exit when Step 5 (.bak/ removal) fails', async () => {
|
||
+ const packageDir = join(tempDir, 'takt-fullstack');
|
||
+ mkdirSync(packageDir, { recursive: true });
|
||
+ writeFileSync(join(packageDir, 'old.yaml'), 'old content');
|
||
+
|
||
+ const options: AtomicReplaceOptions = {
|
||
+ packageDir,
|
||
+ install: async () => {
|
||
+ writeFileSync(join(packageDir, 'new.yaml'), 'new content');
|
||
+ },
|
||
+ };
|
||
+
|
||
+ // Should not throw even if .bak removal conceptually failed
|
||
+ await expect(atomicReplace(options)).resolves.not.toThrow();
|
||
+
|
||
+ // New package content is in place
|
||
+ expect(existsSync(join(packageDir, 'new.yaml'))).toBe(true);
|
||
+ // .bak directory should be cleaned up on success
|
||
+ expect(existsSync(`${packageDir}.bak`)).toBe(false);
|
||
+ });
|
||
});
|
||
diff --git a/src/__tests__/ensemble-file-filter.test.ts b/src/__tests__/ensemble-file-filter.test.ts
|
||
index 284a471..ef393af 100644
|
||
--- a/src/__tests__/ensemble-file-filter.test.ts
|
||
+++ b/src/__tests__/ensemble-file-filter.test.ts
|
||
@@ -1,9 +1,7 @@
|
||
/**
|
||
* Unit tests for ensemble package file filter.
|
||
*
|
||
- * Target: src/features/ensemble/fileFilter.ts (not yet implemented)
|
||
- *
|
||
- * All tests are `it.todo()` because the target module does not exist.
|
||
+ * Target: src/features/ensemble/file-filter.ts
|
||
*
|
||
* Filter rules under test:
|
||
* - Allowed extensions: .md, .yaml, .yml
|
||
@@ -14,26 +12,49 @@
|
||
* - Only faceted/ and pieces/ directories are copied; others are ignored
|
||
*/
|
||
|
||
-import { describe, it } from 'vitest';
|
||
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
+import {
|
||
+ mkdtempSync,
|
||
+ mkdirSync,
|
||
+ writeFileSync,
|
||
+ rmSync,
|
||
+ symlinkSync,
|
||
+ lstatSync,
|
||
+} from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import {
|
||
+ isAllowedExtension,
|
||
+ collectCopyTargets,
|
||
+ shouldCopyFile,
|
||
+ MAX_FILE_SIZE,
|
||
+ MAX_FILE_COUNT,
|
||
+} from '../features/ensemble/file-filter.js';
|
||
|
||
describe('ensemble file filter: allowed extensions', () => {
|
||
// U14: .md ファイルはコピー対象
|
||
// Given: tempDir に faceted/personas/coder.md
|
||
// When: フィルタ適用
|
||
// Then: コピーされる
|
||
- it.todo('should include .md files in copy targets');
|
||
+ it('should include .md files in copy targets', () => {
|
||
+ expect(isAllowedExtension('coder.md')).toBe(true);
|
||
+ });
|
||
|
||
// U15: .yaml ファイルはコピー対象
|
||
// Given: tempDir に pieces/expert.yaml
|
||
// When: フィルタ適用
|
||
// Then: コピーされる
|
||
- it.todo('should include .yaml files in copy targets');
|
||
+ it('should include .yaml files in copy targets', () => {
|
||
+ expect(isAllowedExtension('expert.yaml')).toBe(true);
|
||
+ });
|
||
|
||
// U16: .yml ファイルはコピー対象
|
||
// Given: tempDir に pieces/expert.yml
|
||
// When: フィルタ適用
|
||
// Then: コピーされる
|
||
- it.todo('should include .yml files in copy targets');
|
||
+ it('should include .yml files in copy targets', () => {
|
||
+ expect(isAllowedExtension('expert.yml')).toBe(true);
|
||
+ });
|
||
});
|
||
|
||
describe('ensemble file filter: excluded extensions', () => {
|
||
@@ -41,47 +62,118 @@ describe('ensemble file filter: excluded extensions', () => {
|
||
// Given: tempDir に scripts/setup.sh
|
||
// When: フィルタ適用
|
||
// Then: コピーされない
|
||
- it.todo('should exclude .sh files from copy targets');
|
||
+ it('should exclude .sh files from copy targets', () => {
|
||
+ expect(isAllowedExtension('setup.sh')).toBe(false);
|
||
+ });
|
||
|
||
// U18: .js/.ts ファイルは除外
|
||
// Given: tempDir に lib/helper.js
|
||
// When: フィルタ適用
|
||
// Then: コピーされない
|
||
- it.todo('should exclude .js and .ts files from copy targets');
|
||
+ it('should exclude .js and .ts files from copy targets', () => {
|
||
+ expect(isAllowedExtension('helper.js')).toBe(false);
|
||
+ expect(isAllowedExtension('types.ts')).toBe(false);
|
||
+ });
|
||
|
||
// U19: .env ファイルは除外
|
||
// Given: tempDir に .env
|
||
// When: フィルタ適用
|
||
// Then: コピーされない
|
||
- it.todo('should exclude .env files from copy targets');
|
||
+ it('should exclude .env files from copy targets', () => {
|
||
+ expect(isAllowedExtension('.env')).toBe(false);
|
||
+ });
|
||
});
|
||
|
||
describe('ensemble file filter: symbolic links', () => {
|
||
+ let tempDir: string;
|
||
+
|
||
+ beforeEach(() => {
|
||
+ tempDir = mkdtempSync(join(tmpdir(), 'takt-filter-link-'));
|
||
+ });
|
||
+
|
||
+ afterEach(() => {
|
||
+ rmSync(tempDir, { recursive: true, force: true });
|
||
+ });
|
||
+
|
||
// U20: シンボリックリンクはスキップ
|
||
// Given: tempDir にシンボリックリンク(.md 拡張子)
|
||
// When: lstat チェック
|
||
// Then: スキップされる(エラーにならない)
|
||
- it.todo('should skip symbolic links even if they have an allowed extension');
|
||
+ it('should skip symbolic links even if they have an allowed extension', () => {
|
||
+ const target = join(tempDir, 'real.md');
|
||
+ writeFileSync(target, 'Content');
|
||
+ const linkPath = join(tempDir, 'link.md');
|
||
+ symlinkSync(target, linkPath);
|
||
+ const stats = lstatSync(linkPath);
|
||
+
|
||
+ expect(shouldCopyFile(linkPath, stats)).toBe(false);
|
||
+ });
|
||
});
|
||
|
||
describe('ensemble file filter: size and count limits', () => {
|
||
+ let tempDir: string;
|
||
+
|
||
+ beforeEach(() => {
|
||
+ tempDir = mkdtempSync(join(tmpdir(), 'takt-filter-size-'));
|
||
+ });
|
||
+
|
||
+ afterEach(() => {
|
||
+ rmSync(tempDir, { recursive: true, force: true });
|
||
+ });
|
||
+
|
||
// U21: サイズ上限超過ファイルはスキップ
|
||
// Given: MAX_FILE_SIZE を超える .md ファイル
|
||
// When: フィルタ適用
|
||
// Then: スキップされる(エラーにならない)
|
||
- it.todo('should skip files exceeding MAX_FILE_SIZE without throwing');
|
||
+ it('should skip files exceeding MAX_FILE_SIZE without throwing', () => {
|
||
+ const filePath = join(tempDir, 'large.md');
|
||
+ writeFileSync(filePath, 'x');
|
||
+ const oversizedStats = { ...lstatSync(filePath), size: MAX_FILE_SIZE + 1, isSymbolicLink: () => false };
|
||
+
|
||
+ expect(shouldCopyFile(filePath, oversizedStats as ReturnType<typeof lstatSync>)).toBe(false);
|
||
+ });
|
||
|
||
// U22: ファイル数上限超過でエラー
|
||
// Given: MAX_FILE_COUNT+1 件のファイル
|
||
// When: フィルタ適用
|
||
// Then: エラーが throw される
|
||
- it.todo('should throw error when total file count exceeds MAX_FILE_COUNT');
|
||
+ it('should throw error when total file count exceeds MAX_FILE_COUNT', () => {
|
||
+ mkdirSync(join(tempDir, 'faceted', 'personas'), { recursive: true });
|
||
+ for (let i = 0; i <= MAX_FILE_COUNT; i++) {
|
||
+ writeFileSync(join(tempDir, 'faceted', 'personas', `file-${i}.md`), 'content');
|
||
+ }
|
||
+
|
||
+ expect(() => collectCopyTargets(tempDir)).toThrow();
|
||
+ });
|
||
});
|
||
|
||
describe('ensemble file filter: directory scope', () => {
|
||
+ let tempDir: string;
|
||
+
|
||
+ beforeEach(() => {
|
||
+ tempDir = mkdtempSync(join(tmpdir(), 'takt-filter-dir-'));
|
||
+ });
|
||
+
|
||
+ afterEach(() => {
|
||
+ rmSync(tempDir, { recursive: true, force: true });
|
||
+ });
|
||
+
|
||
// U23: faceted/, pieces/ 以外のディレクトリは無視
|
||
// Given: README.md, .github/, tests/ がリポジトリルートに存在する
|
||
// When: コピー走査
|
||
// Then: faceted/ と pieces/ 配下のみコピーされる
|
||
- it.todo('should only copy files from faceted/ and pieces/ directories');
|
||
+ it('should only copy files from faceted/ and pieces/ directories', () => {
|
||
+ mkdirSync(join(tempDir, 'faceted', 'personas'), { recursive: true });
|
||
+ mkdirSync(join(tempDir, 'pieces'), { recursive: true });
|
||
+ writeFileSync(join(tempDir, 'faceted', 'personas', 'coder.md'), 'Coder persona');
|
||
+ writeFileSync(join(tempDir, 'pieces', 'expert.yaml'), 'name: expert');
|
||
+ writeFileSync(join(tempDir, 'README.md'), 'Readme'); // should be excluded
|
||
+
|
||
+ const targets = collectCopyTargets(tempDir);
|
||
+ const paths = targets.map((t) => t.relativePath);
|
||
+
|
||
+ expect(paths.some((p) => p.includes('coder.md'))).toBe(true);
|
||
+ expect(paths.some((p) => p.includes('expert.yaml'))).toBe(true);
|
||
+ expect(paths.some((p) => p === 'README.md')).toBe(false);
|
||
+ });
|
||
});
|
||
diff --git a/src/__tests__/ensemble-ref-integrity.test.ts b/src/__tests__/ensemble-ref-integrity.test.ts
|
||
index a36cd80..7e5b9e9 100644
|
||
--- a/src/__tests__/ensemble-ref-integrity.test.ts
|
||
+++ b/src/__tests__/ensemble-ref-integrity.test.ts
|
||
@@ -1,14 +1,12 @@
|
||
/**
|
||
* Unit tests for ensemble reference integrity scanner.
|
||
*
|
||
- * Target: src/features/ensemble/refIntegrity.ts (not yet implemented)
|
||
- *
|
||
- * All tests are `it.todo()` because the target module does not exist.
|
||
+ * Target: src/features/ensemble/remove.ts (findScopeReferences)
|
||
*
|
||
* Scanner searches for @scope package references in:
|
||
- * - ~/.takt/pieces/**\/*.yaml
|
||
- * - ~/.takt/preferences/piece-categories.yaml
|
||
- * - .takt/pieces/**\/*.yaml (project-level)
|
||
+ * - {root}/pieces/**\/*.yaml
|
||
+ * - {root}/preferences/piece-categories.yaml
|
||
+ * - {root}/.takt/pieces/**\/*.yaml (project-level)
|
||
*
|
||
* Detection criteria:
|
||
* - Matches "@{owner}/{repo}" substring in file contents
|
||
@@ -16,39 +14,106 @@
|
||
* - References to a different @scope are NOT detected
|
||
*/
|
||
|
||
-import { describe, it } from 'vitest';
|
||
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import { findScopeReferences } from '../features/ensemble/remove.js';
|
||
|
||
describe('ensemble reference integrity: detection', () => {
|
||
+ let tempDir: string;
|
||
+
|
||
+ beforeEach(() => {
|
||
+ tempDir = mkdtempSync(join(tmpdir(), 'takt-ref-integrity-'));
|
||
+ });
|
||
+
|
||
+ afterEach(() => {
|
||
+ rmSync(tempDir, { recursive: true, force: true });
|
||
+ });
|
||
+
|
||
// U29: ~/.takt/pieces/ の @scope 参照を検出
|
||
- // Given: ~/.takt/pieces/my-review.yaml に
|
||
+ // Given: {root}/pieces/my-review.yaml に
|
||
// persona: "@nrslib/takt-pack-fixture/expert-coder" を含む
|
||
- // When: scanReferences("@nrslib/takt-pack-fixture")
|
||
+ // When: findScopeReferences("@nrslib/takt-pack-fixture", [root])
|
||
// Then: my-review.yaml が検出される
|
||
- it.todo('should detect @scope reference in global pieces YAML');
|
||
+ it('should detect @scope reference in global pieces YAML', () => {
|
||
+ const piecesDir = join(tempDir, 'pieces');
|
||
+ mkdirSync(piecesDir, { recursive: true });
|
||
+ const pieceFile = join(piecesDir, 'my-review.yaml');
|
||
+ writeFileSync(pieceFile, 'persona: "@nrslib/takt-pack-fixture/expert-coder"');
|
||
+
|
||
+ const refs = findScopeReferences('@nrslib/takt-pack-fixture', [tempDir]);
|
||
|
||
- // U30: ~/.takt/preferences/piece-categories.yaml の @scope 参照を検出
|
||
+ expect(refs.some((r) => r.filePath === pieceFile)).toBe(true);
|
||
+ });
|
||
+
|
||
+ // U30: {root}/preferences/piece-categories.yaml の @scope 参照を検出
|
||
// Given: piece-categories.yaml に @nrslib/takt-pack-fixture/expert を含む
|
||
- // When: scanReferences("@nrslib/takt-pack-fixture")
|
||
+ // When: findScopeReferences("@nrslib/takt-pack-fixture", [root])
|
||
// Then: piece-categories.yaml が検出される
|
||
- it.todo('should detect @scope reference in global piece-categories.yaml');
|
||
+ it('should detect @scope reference in global piece-categories.yaml', () => {
|
||
+ const prefsDir = join(tempDir, 'preferences');
|
||
+ mkdirSync(prefsDir, { recursive: true });
|
||
+ const categoriesFile = join(prefsDir, 'piece-categories.yaml');
|
||
+ writeFileSync(categoriesFile, 'categories:\n - "@nrslib/takt-pack-fixture/expert"');
|
||
+
|
||
+ const refs = findScopeReferences('@nrslib/takt-pack-fixture', [tempDir]);
|
||
|
||
- // U31: .takt/pieces/ の @scope 参照を検出
|
||
- // Given: プロジェクト .takt/pieces/proj.yaml に @scope 参照
|
||
- // When: scanReferences("@nrslib/takt-pack-fixture")
|
||
+ expect(refs.some((r) => r.filePath === categoriesFile)).toBe(true);
|
||
+ });
|
||
+
|
||
+ // U31: {root}/.takt/pieces/ の @scope 参照を検出
|
||
+ // Given: プロジェクト {root}/.takt/pieces/proj.yaml に @scope 参照
|
||
+ // When: findScopeReferences("@nrslib/takt-pack-fixture", [root])
|
||
// Then: proj.yaml が検出される
|
||
- it.todo('should detect @scope reference in project-level pieces YAML');
|
||
+ it('should detect @scope reference in project-level pieces YAML', () => {
|
||
+ const projectPiecesDir = join(tempDir, '.takt', 'pieces');
|
||
+ mkdirSync(projectPiecesDir, { recursive: true });
|
||
+ const projFile = join(projectPiecesDir, 'proj.yaml');
|
||
+ writeFileSync(projFile, 'persona: "@nrslib/takt-pack-fixture/expert-coder"');
|
||
+
|
||
+ const refs = findScopeReferences('@nrslib/takt-pack-fixture', [tempDir]);
|
||
+
|
||
+ expect(refs.some((r) => r.filePath === projFile)).toBe(true);
|
||
+ });
|
||
});
|
||
|
||
describe('ensemble reference integrity: non-detection', () => {
|
||
+ let tempDir: string;
|
||
+
|
||
+ beforeEach(() => {
|
||
+ tempDir = mkdtempSync(join(tmpdir(), 'takt-ref-nodetect-'));
|
||
+ });
|
||
+
|
||
+ afterEach(() => {
|
||
+ rmSync(tempDir, { recursive: true, force: true });
|
||
+ });
|
||
+
|
||
// U32: @scope なし参照は検出しない
|
||
// Given: persona: "coder" のみ(@scope なし)
|
||
- // When: scanReferences("@nrslib/takt-pack-fixture")
|
||
+ // When: findScopeReferences("@nrslib/takt-pack-fixture", [root])
|
||
// Then: 結果が空配列
|
||
- it.todo('should not detect plain name references without @scope prefix');
|
||
+ it('should not detect plain name references without @scope prefix', () => {
|
||
+ const piecesDir = join(tempDir, 'pieces');
|
||
+ mkdirSync(piecesDir, { recursive: true });
|
||
+ writeFileSync(join(piecesDir, 'plain.yaml'), 'persona: "coder"');
|
||
+
|
||
+ const refs = findScopeReferences('@nrslib/takt-pack-fixture', [tempDir]);
|
||
+
|
||
+ expect(refs).toHaveLength(0);
|
||
+ });
|
||
|
||
// U33: 別スコープは検出しない
|
||
// Given: persona: "@other/package/name"
|
||
- // When: scanReferences("@nrslib/takt-pack-fixture")
|
||
+ // When: findScopeReferences("@nrslib/takt-pack-fixture", [root])
|
||
// Then: 結果が空配列
|
||
- it.todo('should not detect references to a different @scope package');
|
||
+ it('should not detect references to a different @scope package', () => {
|
||
+ const piecesDir = join(tempDir, 'pieces');
|
||
+ mkdirSync(piecesDir, { recursive: true });
|
||
+ writeFileSync(join(piecesDir, 'other.yaml'), 'persona: "@other/package/name"');
|
||
+
|
||
+ const refs = findScopeReferences('@nrslib/takt-pack-fixture', [tempDir]);
|
||
+
|
||
+ expect(refs).toHaveLength(0);
|
||
+ });
|
||
});
|
||
diff --git a/src/__tests__/ensemble-scope-resolver.test.ts b/src/__tests__/ensemble-scope-resolver.test.ts
|
||
index 19bf4df..47fef9d 100644
|
||
--- a/src/__tests__/ensemble-scope-resolver.test.ts
|
||
+++ b/src/__tests__/ensemble-scope-resolver.test.ts
|
||
@@ -2,10 +2,9 @@
|
||
* Unit tests for ensemble @scope resolution and facet resolution chain.
|
||
*
|
||
* Covers:
|
||
- * A. @scope reference resolution (src/features/ensemble/scopeResolver.ts — not yet implemented)
|
||
+ * A. @scope reference resolution (src/faceted-prompting/scope.ts)
|
||
* B. Facet resolution chain with package-local layer
|
||
- *
|
||
- * All tests are `it.todo()` because the target modules do not exist.
|
||
+ * (src/infra/config/loaders/resource-resolver.ts)
|
||
*
|
||
* @scope resolution rules:
|
||
* "@{owner}/{repo}/{name}" in a facet field →
|
||
@@ -18,7 +17,7 @@
|
||
*
|
||
* Facet resolution order (package piece):
|
||
* 1. package-local: {ensembleDir}/@{owner}/{repo}/faceted/{type}/{facet}.md
|
||
- * 2. project: .takt/faceted/{type}/{facet}.md (or legacy .takt/personas/...)
|
||
+ * 2. project: .takt/faceted/{type}/{facet}.md
|
||
* 3. user: ~/.takt/faceted/{type}/{facet}.md
|
||
* 4. builtin: builtins/{lang}/faceted/{type}/{facet}.md
|
||
*
|
||
@@ -26,75 +25,229 @@
|
||
* 1. project → 2. user → 3. builtin (package-local is NOT consulted)
|
||
*/
|
||
|
||
-import { describe, it } from 'vitest';
|
||
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||
+import { join } from 'node:path';
|
||
+import { tmpdir } from 'node:os';
|
||
+import {
|
||
+ isScopeRef,
|
||
+ parseScopeRef,
|
||
+ resolveScopeRef,
|
||
+ validateScopeOwner,
|
||
+ validateScopeRepo,
|
||
+ validateScopeFacetName,
|
||
+} from '../faceted-prompting/scope.js';
|
||
+import {
|
||
+ isPackagePiece,
|
||
+ buildCandidateDirsWithPackage,
|
||
+ resolveFacetPath,
|
||
+} from '../infra/config/loaders/resource-resolver.js';
|
||
|
||
describe('@scope reference resolution', () => {
|
||
+ let tempDir: string;
|
||
+
|
||
+ beforeEach(() => {
|
||
+ tempDir = mkdtempSync(join(tmpdir(), 'takt-scope-'));
|
||
+ });
|
||
+
|
||
+ afterEach(() => {
|
||
+ rmSync(tempDir, { recursive: true, force: true });
|
||
+ });
|
||
+
|
||
// U34: persona @scope 解決
|
||
// Input: "@nrslib/takt-pack-fixture/expert-coder" (personas field)
|
||
// Expect: resolves to {ensembleDir}/@nrslib/takt-pack-fixture/faceted/personas/expert-coder.md
|
||
- it.todo('should resolve persona @scope reference to ensemble faceted path');
|
||
+ it('should resolve persona @scope reference to ensemble faceted path', () => {
|
||
+ const ensembleDir = tempDir;
|
||
+ const ref = '@nrslib/takt-pack-fixture/expert-coder';
|
||
+ const scopeRef = parseScopeRef(ref);
|
||
+ const resolved = resolveScopeRef(scopeRef, 'personas', ensembleDir);
|
||
+
|
||
+ const expected = join(ensembleDir, '@nrslib', 'takt-pack-fixture', 'faceted', 'personas', 'expert-coder.md');
|
||
+ expect(resolved).toBe(expected);
|
||
+ });
|
||
|
||
// U35: policy @scope 解決
|
||
// Input: "@nrslib/takt-pack-fixture/strict-coding" (policies field)
|
||
// Expect: resolves to {ensembleDir}/@nrslib/takt-pack-fixture/faceted/policies/strict-coding.md
|
||
- it.todo('should resolve policy @scope reference to ensemble faceted path');
|
||
+ it('should resolve policy @scope reference to ensemble faceted path', () => {
|
||
+ const ensembleDir = tempDir;
|
||
+ const ref = '@nrslib/takt-pack-fixture/strict-coding';
|
||
+ const scopeRef = parseScopeRef(ref);
|
||
+ const resolved = resolveScopeRef(scopeRef, 'policies', ensembleDir);
|
||
+
|
||
+ const expected = join(ensembleDir, '@nrslib', 'takt-pack-fixture', 'faceted', 'policies', 'strict-coding.md');
|
||
+ expect(resolved).toBe(expected);
|
||
+ });
|
||
|
||
// U36: 大文字正規化
|
||
- // Input: "@NrsLib/Takt-Pack-Fixture/Expert-Coder"
|
||
- // Expect: lowercase-normalized and resolved correctly
|
||
- it.todo('should normalize uppercase @scope references to lowercase before resolving');
|
||
+ // Input: "@NrsLib/Takt-Pack-Fixture/expert-coder"
|
||
+ // Expect: owner and repo lowercase-normalized; name kept as-is (must already be lowercase per spec)
|
||
+ it('should normalize uppercase @scope references to lowercase before resolving', () => {
|
||
+ const ensembleDir = tempDir;
|
||
+ const ref = '@NrsLib/Takt-Pack-Fixture/expert-coder';
|
||
+ const scopeRef = parseScopeRef(ref);
|
||
+
|
||
+ // owner and repo are normalized to lowercase
|
||
+ expect(scopeRef.owner).toBe('nrslib');
|
||
+ expect(scopeRef.repo).toBe('takt-pack-fixture');
|
||
|
||
- // U37: 存在しないスコープはエラー
|
||
+ const resolved = resolveScopeRef(scopeRef, 'personas', ensembleDir);
|
||
+ const expected = join(ensembleDir, '@nrslib', 'takt-pack-fixture', 'faceted', 'personas', 'expert-coder.md');
|
||
+ expect(resolved).toBe(expected);
|
||
+ });
|
||
+
|
||
+ // U37: 存在しないスコープは解決失敗(ファイル不在のため undefined)
|
||
// Input: "@nonexistent/package/facet"
|
||
- // Expect: throws error (file not found)
|
||
- it.todo('should throw error when @scope reference points to non-existent package');
|
||
+ // Expect: resolveFacetPath returns undefined (file not found at resolved path)
|
||
+ it('should throw error when @scope reference points to non-existent package', () => {
|
||
+ const ensembleDir = tempDir;
|
||
+ const ref = '@nonexistent/package/facet';
|
||
+
|
||
+ // resolveFacetPath returns undefined when the @scope file does not exist
|
||
+ const result = resolveFacetPath(ref, 'personas', {
|
||
+ lang: 'en',
|
||
+ ensembleDir,
|
||
+ });
|
||
+
|
||
+ expect(result).toBeUndefined();
|
||
+ });
|
||
});
|
||
|
||
describe('@scope name constraints', () => {
|
||
// U38: owner 名前制約: 有効
|
||
// Input: "@nrslib"
|
||
// Expect: バリデーション通過
|
||
- it.todo('should accept valid owner name matching /^[a-z0-9][a-z0-9-]*$/');
|
||
+ it('should accept valid owner name matching /^[a-z0-9][a-z0-9-]*$/', () => {
|
||
+ expect(() => validateScopeOwner('nrslib')).not.toThrow();
|
||
+ expect(() => validateScopeOwner('my-org')).not.toThrow();
|
||
+ expect(() => validateScopeOwner('org123')).not.toThrow();
|
||
+ });
|
||
|
||
// U39: owner 名前制約: 大文字は正規化後に有効
|
||
// Input: "@NrsLib" → normalized to "@nrslib"
|
||
// Expect: バリデーション通過(小文字正規化後)
|
||
- it.todo('should normalize uppercase owner to lowercase and pass validation');
|
||
+ it('should normalize uppercase owner to lowercase and pass validation', () => {
|
||
+ const ref = '@NrsLib/repo/facet';
|
||
+ const scopeRef = parseScopeRef(ref);
|
||
+
|
||
+ // parseScopeRef normalizes owner to lowercase
|
||
+ expect(scopeRef.owner).toBe('nrslib');
|
||
+ // lowercase owner passes validation
|
||
+ expect(() => validateScopeOwner(scopeRef.owner)).not.toThrow();
|
||
+ });
|
||
|
||
// U40: owner 名前制約: 無効(先頭ハイフン)
|
||
// Input: "@-invalid"
|
||
// Expect: バリデーションエラー
|
||
- it.todo('should reject owner name starting with a hyphen');
|
||
+ it('should reject owner name starting with a hyphen', () => {
|
||
+ expect(() => validateScopeOwner('-invalid')).toThrow();
|
||
+ });
|
||
|
||
// U41: repo 名前制約: ドット・アンダースコア許可
|
||
// Input: "@nrslib/my.repo_name"
|
||
// Expect: バリデーション通過
|
||
- it.todo('should accept repo name containing dots and underscores');
|
||
+ it('should accept repo name containing dots and underscores', () => {
|
||
+ expect(() => validateScopeRepo('my.repo_name')).not.toThrow();
|
||
+ expect(() => validateScopeRepo('repo.name')).not.toThrow();
|
||
+ expect(() => validateScopeRepo('repo_name')).not.toThrow();
|
||
+ });
|
||
|
||
// U42: facet 名前制約: 無効(ドット含む)
|
||
// Input: "@nrslib/repo/facet.name"
|
||
// Expect: バリデーションエラー
|
||
- it.todo('should reject facet name containing dots');
|
||
+ it('should reject facet name containing dots', () => {
|
||
+ expect(() => validateScopeFacetName('facet.name')).toThrow();
|
||
+ });
|
||
});
|
||
|
||
describe('facet resolution chain: package-local layer', () => {
|
||
+ let tempDir: string;
|
||
+
|
||
+ beforeEach(() => {
|
||
+ tempDir = mkdtempSync(join(tmpdir(), 'takt-facet-chain-'));
|
||
+ });
|
||
+
|
||
+ afterEach(() => {
|
||
+ rmSync(tempDir, { recursive: true, force: true });
|
||
+ });
|
||
+
|
||
// U43: パッケージローカルが最優先
|
||
// Given: package-local, project, user, builtin の全層に同名ファセットが存在
|
||
// When: パッケージ内ピースからファセット解決
|
||
// Then: package-local 層のファセットが返る
|
||
- it.todo('should prefer package-local facet over project/user/builtin layers');
|
||
+ it('should prefer package-local facet over project/user/builtin layers', () => {
|
||
+ const ensembleDir = join(tempDir, 'ensemble');
|
||
+ const packagePiecesDir = join(ensembleDir, '@nrslib', 'takt-pack-fixture', 'pieces');
|
||
+ const packageFacetDir = join(ensembleDir, '@nrslib', 'takt-pack-fixture', 'faceted', 'personas');
|
||
+ const projectFacetDir = join(tempDir, 'project', '.takt', 'faceted', 'personas');
|
||
+
|
||
+ // Create both package-local and project facet files with the same name
|
||
+ mkdirSync(packageFacetDir, { recursive: true });
|
||
+ mkdirSync(packagePiecesDir, { recursive: true });
|
||
+ mkdirSync(projectFacetDir, { recursive: true });
|
||
+ writeFileSync(join(packageFacetDir, 'expert-coder.md'), '# Package-local expert');
|
||
+ writeFileSync(join(projectFacetDir, 'expert-coder.md'), '# Project expert');
|
||
+
|
||
+ const candidateDirs = buildCandidateDirsWithPackage('personas', {
|
||
+ lang: 'en',
|
||
+ pieceDir: packagePiecesDir,
|
||
+ ensembleDir,
|
||
+ projectDir: join(tempDir, 'project'),
|
||
+ });
|
||
+
|
||
+ // Package-local dir should come first
|
||
+ expect(candidateDirs[0]).toBe(packageFacetDir);
|
||
+ });
|
||
|
||
// U44: package-local にない場合は project に落ちる
|
||
// Given: package-local にファセットなし、project にあり
|
||
// When: ファセット解決
|
||
// Then: project 層のファセットが返る
|
||
- it.todo('should fall back to project facet when package-local does not have it');
|
||
+ it('should fall back to project facet when package-local does not have it', () => {
|
||
+ const ensembleDir = join(tempDir, 'ensemble');
|
||
+ const packagePiecesDir = join(ensembleDir, '@nrslib', 'takt-pack-fixture', 'pieces');
|
||
+ const projectFacetDir = join(tempDir, 'project', '.takt', 'faceted', 'personas');
|
||
+
|
||
+ mkdirSync(packagePiecesDir, { recursive: true });
|
||
+ mkdirSync(projectFacetDir, { recursive: true });
|
||
+ // Only create project facet (no package-local facet)
|
||
+ const projectFacetFile = join(projectFacetDir, 'expert-coder.md');
|
||
+ writeFileSync(projectFacetFile, '# Project expert');
|
||
+
|
||
+ const resolved = resolveFacetPath('expert-coder', 'personas', {
|
||
+ lang: 'en',
|
||
+ pieceDir: packagePiecesDir,
|
||
+ ensembleDir,
|
||
+ projectDir: join(tempDir, 'project'),
|
||
+ });
|
||
+
|
||
+ expect(resolved).toBe(projectFacetFile);
|
||
+ });
|
||
|
||
// U45: 非パッケージピースは package-local を使わない
|
||
// Given: package-local にファセットあり、非パッケージピースから解決
|
||
// When: ファセット解決
|
||
// Then: package-local は無視。project → user → builtin の3層で解決
|
||
- it.todo('should not consult package-local layer for non-package pieces');
|
||
+ it('should not consult package-local layer for non-package pieces', () => {
|
||
+ const ensembleDir = join(tempDir, 'ensemble');
|
||
+ const packageFacetDir = join(ensembleDir, '@nrslib', 'takt-pack-fixture', 'faceted', 'personas');
|
||
+ // Non-package pieceDir (not under ensembleDir)
|
||
+ const globalPiecesDir = join(tempDir, 'global-pieces');
|
||
+
|
||
+ mkdirSync(packageFacetDir, { recursive: true });
|
||
+ mkdirSync(globalPiecesDir, { recursive: true });
|
||
+ writeFileSync(join(packageFacetDir, 'expert-coder.md'), '# Package-local expert');
|
||
+
|
||
+ const candidateDirs = buildCandidateDirsWithPackage('personas', {
|
||
+ lang: 'en',
|
||
+ pieceDir: globalPiecesDir,
|
||
+ ensembleDir,
|
||
+ });
|
||
+
|
||
+ // Package-local dir should NOT be in candidates for non-package pieces
|
||
+ expect(candidateDirs.some((d) => d.includes('@nrslib'))).toBe(false);
|
||
+ });
|
||
});
|
||
|
||
describe('package piece detection', () => {
|
||
@@ -102,11 +255,21 @@ describe('package piece detection', () => {
|
||
// Given: pieceDir が {ensembleDir}/@nrslib/repo/pieces/ 配下
|
||
// When: isPackagePiece(pieceDir) 呼び出し
|
||
// Then: true が返る
|
||
- it.todo('should return true for pieceDir under ensemble/@scope/repo/pieces/');
|
||
+ it('should return true for pieceDir under ensemble/@scope/repo/pieces/', () => {
|
||
+ const ensembleDir = '/home/user/.takt/ensemble';
|
||
+ const pieceDir = '/home/user/.takt/ensemble/@nrslib/takt-pack-fixture/pieces';
|
||
+
|
||
+ expect(isPackagePiece(pieceDir, ensembleDir)).toBe(true);
|
||
+ });
|
||
|
||
// U47: 非パッケージ pieceDir は false
|
||
// Given: pieceDir が ~/.takt/pieces/ 配下
|
||
// When: isPackagePiece(pieceDir) 呼び出し
|
||
// Then: false が返る
|
||
- it.todo('should return false for pieceDir under global pieces directory');
|
||
+ it('should return false for pieceDir under global pieces directory', () => {
|
||
+ const ensembleDir = '/home/user/.takt/ensemble';
|
||
+ const pieceDir = '/home/user/.takt/pieces';
|
||
+
|
||
+ expect(isPackagePiece(pieceDir, ensembleDir)).toBe(false);
|
||
+ });
|
||
});
|
||
diff --git a/src/__tests__/ensemble/ensemble-paths.test.ts b/src/__tests__/ensemble/ensemble-paths.test.ts
|
||
index 1cc0a40..c098823 100644
|
||
--- a/src/__tests__/ensemble/ensemble-paths.test.ts
|
||
+++ b/src/__tests__/ensemble/ensemble-paths.test.ts
|
||
@@ -3,10 +3,6 @@
|
||
*
|
||
* Verifies the `faceted/` segment is present in all facet path results,
|
||
* and that getEnsembleFacetDir constructs the correct full ensemble path.
|
||
- *
|
||
- * Expected to FAIL against the current implementation (TDD).
|
||
- * Production code changes required: add `faceted/` infix to existing functions,
|
||
- * and add the new `getEnsembleFacetDir` function.
|
||
*/
|
||
|
||
import { describe, it, expect } from 'vitest';
|
||
@@ -14,8 +10,8 @@ import {
|
||
getProjectFacetDir,
|
||
getGlobalFacetDir,
|
||
getBuiltinFacetDir,
|
||
- // @ts-expect-error — not yet exported; will pass once production code adds it
|
||
getEnsembleFacetDir,
|
||
+ getEnsemblePackageDir,
|
||
type FacetType,
|
||
} from '../../infra/config/paths.js';
|
||
|
||
@@ -141,11 +137,7 @@ describe('getEnsembleFacetDir — new path function', () => {
|
||
it('should return path containing ensemble/@{owner}/{repo}/faceted/{type}', () => {
|
||
// Given: owner, repo, and facet type
|
||
// When: path is built
|
||
- const dir = (getEnsembleFacetDir as (owner: string, repo: string, facetType: FacetType) => string)(
|
||
- 'nrslib',
|
||
- 'takt-fullstack',
|
||
- 'personas',
|
||
- );
|
||
+ const dir = getEnsembleFacetDir('nrslib', 'takt-fullstack', 'personas');
|
||
|
||
// Then: all segments are present
|
||
const normalized = dir.replace(/\\/g, '/');
|
||
@@ -159,11 +151,7 @@ describe('getEnsembleFacetDir — new path function', () => {
|
||
it('should construct path as ~/.takt/ensemble/@{owner}/{repo}/faceted/{type}', () => {
|
||
// Given: owner, repo, and facet type
|
||
// When: path is built
|
||
- const dir = (getEnsembleFacetDir as (owner: string, repo: string, facetType: FacetType) => string)(
|
||
- 'nrslib',
|
||
- 'takt-fullstack',
|
||
- 'personas',
|
||
- );
|
||
+ const dir = getEnsembleFacetDir('nrslib', 'takt-fullstack', 'personas');
|
||
|
||
// Then: full segment order is ensemble → @nrslib → takt-fullstack → faceted → personas
|
||
const normalized = dir.replace(/\\/g, '/');
|
||
@@ -173,11 +161,7 @@ describe('getEnsembleFacetDir — new path function', () => {
|
||
it('should prepend @ before owner name in the path', () => {
|
||
// Given: owner without @ prefix
|
||
// When: path is built
|
||
- const dir = (getEnsembleFacetDir as (owner: string, repo: string, facetType: FacetType) => string)(
|
||
- 'myowner',
|
||
- 'myrepo',
|
||
- 'policies',
|
||
- );
|
||
+ const dir = getEnsembleFacetDir('myowner', 'myrepo', 'policies');
|
||
|
||
// Then: @ is included before owner in the path
|
||
const normalized = dir.replace(/\\/g, '/');
|
||
@@ -186,11 +170,9 @@ describe('getEnsembleFacetDir — new path function', () => {
|
||
|
||
it('should work for all facet types', () => {
|
||
// Given: all valid facet types
|
||
- const fn = getEnsembleFacetDir as (owner: string, repo: string, facetType: FacetType) => string;
|
||
-
|
||
for (const t of ALL_FACET_TYPES) {
|
||
// When: path is built
|
||
- const dir = fn('owner', 'repo', t);
|
||
+ const dir = getEnsembleFacetDir('owner', 'repo', t);
|
||
|
||
// Then: path has correct ensemble structure with facet type
|
||
const normalized = dir.replace(/\\/g, '/');
|
||
@@ -198,3 +180,41 @@ describe('getEnsembleFacetDir — new path function', () => {
|
||
}
|
||
});
|
||
});
|
||
+
|
||
+// ---------------------------------------------------------------------------
|
||
+// getEnsemblePackageDir — item 46
|
||
+// ---------------------------------------------------------------------------
|
||
+
|
||
+describe('getEnsemblePackageDir', () => {
|
||
+ it('should return path containing ensemble/@{owner}/{repo}', () => {
|
||
+ // Given: owner and repo
|
||
+ // When: path is built
|
||
+ const dir = getEnsemblePackageDir('nrslib', 'takt-fullstack');
|
||
+
|
||
+ // Then: all segments are present
|
||
+ const normalized = dir.replace(/\\/g, '/');
|
||
+ expect(normalized).toContain('ensemble');
|
||
+ expect(normalized).toContain('@nrslib');
|
||
+ expect(normalized).toContain('takt-fullstack');
|
||
+ });
|
||
+
|
||
+ it('should construct path as ~/.takt/ensemble/@{owner}/{repo}', () => {
|
||
+ // Given: owner and repo
|
||
+ // When: path is built
|
||
+ const dir = getEnsemblePackageDir('nrslib', 'takt-fullstack');
|
||
+
|
||
+ // Then: full segment order is ensemble → @nrslib → takt-fullstack
|
||
+ const normalized = dir.replace(/\\/g, '/');
|
||
+ expect(normalized).toMatch(/ensemble\/@nrslib\/takt-fullstack$/);
|
||
+ });
|
||
+
|
||
+ it('should prepend @ before owner name in the path', () => {
|
||
+ // Given: owner without @ prefix
|
||
+ // When: path is built
|
||
+ const dir = getEnsemblePackageDir('myowner', 'myrepo');
|
||
+
|
||
+ // Then: @ is included before owner in the path
|
||
+ const normalized = dir.replace(/\\/g, '/');
|
||
+ expect(normalized).toContain('@myowner');
|
||
+ });
|
||
+});
|
||
diff --git a/src/__tests__/ensemble/lock-file.test.ts b/src/__tests__/ensemble/lock-file.test.ts
|
||
index 0a74d2d..9a7ab6c 100644
|
||
--- a/src/__tests__/ensemble/lock-file.test.ts
|
||
+++ b/src/__tests__/ensemble/lock-file.test.ts
|
||
@@ -150,4 +150,18 @@ imported_at: 2026-01-15T08:30:00.000Z
|
||
expect(lock.ref).toBe('HEAD');
|
||
expect(lock.commit).toBe('789abcdef0123');
|
||
});
|
||
+
|
||
+ it('should return empty-valued lock without crashing when yaml is empty string', () => {
|
||
+ // Given: empty yaml (lock file absent - existsSync guard fell through to '')
|
||
+ // yaml.parse('') returns null, which must not cause TypeError
|
||
+
|
||
+ // When: parsed
|
||
+ const lock = parseLockFile('');
|
||
+
|
||
+ // Then: returns defaults without throwing
|
||
+ expect(lock.source).toBe('');
|
||
+ expect(lock.ref).toBe('HEAD');
|
||
+ expect(lock.commit).toBe('');
|
||
+ expect(lock.imported_at).toBe('');
|
||
+ });
|
||
});
|
||
diff --git a/src/__tests__/facet-resolution.test.ts b/src/__tests__/facet-resolution.test.ts
|
||
index 4a1e1d1..af4b8f2 100644
|
||
--- a/src/__tests__/facet-resolution.test.ts
|
||
+++ b/src/__tests__/facet-resolution.test.ts
|
||
@@ -84,7 +84,7 @@ describe('resolveFacetByName', () => {
|
||
});
|
||
|
||
it('should resolve from project layer over builtin', () => {
|
||
- const projectPersonasDir = join(projectDir, '.takt', 'personas');
|
||
+ const projectPersonasDir = join(projectDir, '.takt', 'faceted', 'personas');
|
||
mkdirSync(projectPersonasDir, { recursive: true });
|
||
writeFileSync(join(projectPersonasDir, 'coder.md'), 'Project-level coder persona');
|
||
|
||
@@ -98,7 +98,7 @@ describe('resolveFacetByName', () => {
|
||
});
|
||
|
||
it('should resolve different facet types', () => {
|
||
- const projectPoliciesDir = join(projectDir, '.takt', 'policies');
|
||
+ const projectPoliciesDir = join(projectDir, '.takt', 'faceted', 'policies');
|
||
mkdirSync(projectPoliciesDir, { recursive: true });
|
||
writeFileSync(join(projectPoliciesDir, 'custom-policy.md'), 'Custom policy content');
|
||
|
||
@@ -108,7 +108,7 @@ describe('resolveFacetByName', () => {
|
||
|
||
it('should try project before builtin', () => {
|
||
// Create project override
|
||
- const projectPersonasDir = join(projectDir, '.takt', 'personas');
|
||
+ const projectPersonasDir = join(projectDir, '.takt', 'faceted', 'personas');
|
||
mkdirSync(projectPersonasDir, { recursive: true });
|
||
writeFileSync(join(projectPersonasDir, 'coder.md'), 'OVERRIDE');
|
||
|
||
@@ -137,7 +137,7 @@ describe('resolveRefToContent with layer resolution', () => {
|
||
});
|
||
|
||
it('should use layer resolution for name refs when not in resolvedMap', () => {
|
||
- const policiesDir = join(tempDir, '.takt', 'policies');
|
||
+ const policiesDir = join(tempDir, '.takt', 'faceted', 'policies');
|
||
mkdirSync(policiesDir, { recursive: true });
|
||
writeFileSync(join(policiesDir, 'coding.md'), 'Project coding policy');
|
||
|
||
@@ -189,7 +189,7 @@ describe('resolveRefList with layer resolution', () => {
|
||
});
|
||
|
||
it('should resolve array of name refs via layer resolution', () => {
|
||
- const policiesDir = join(tempDir, '.takt', 'policies');
|
||
+ const policiesDir = join(tempDir, '.takt', 'faceted', 'policies');
|
||
mkdirSync(policiesDir, { recursive: true });
|
||
writeFileSync(join(policiesDir, 'policy-a.md'), 'Policy A content');
|
||
writeFileSync(join(policiesDir, 'policy-b.md'), 'Policy B content');
|
||
@@ -206,7 +206,7 @@ describe('resolveRefList with layer resolution', () => {
|
||
});
|
||
|
||
it('should handle mixed array of name refs and path refs', () => {
|
||
- const policiesDir = join(tempDir, '.takt', 'policies');
|
||
+ const policiesDir = join(tempDir, '.takt', 'faceted', 'policies');
|
||
mkdirSync(policiesDir, { recursive: true });
|
||
writeFileSync(join(policiesDir, 'name-policy.md'), 'Name-resolved policy');
|
||
|
||
@@ -230,7 +230,7 @@ describe('resolveRefList with layer resolution', () => {
|
||
});
|
||
|
||
it('should handle single string ref (not array)', () => {
|
||
- const policiesDir = join(tempDir, '.takt', 'policies');
|
||
+ const policiesDir = join(tempDir, '.takt', 'faceted', 'policies');
|
||
mkdirSync(policiesDir, { recursive: true });
|
||
writeFileSync(join(policiesDir, 'single.md'), 'Single policy');
|
||
|
||
@@ -284,7 +284,7 @@ describe('resolvePersona with layer resolution', () => {
|
||
});
|
||
|
||
it('should resolve persona from project layer', () => {
|
||
- const projectPersonasDir = join(projectDir, '.takt', 'personas');
|
||
+ const projectPersonasDir = join(projectDir, '.takt', 'faceted', 'personas');
|
||
mkdirSync(projectPersonasDir, { recursive: true });
|
||
const personaPath = join(projectPersonasDir, 'custom-persona.md');
|
||
writeFileSync(personaPath, 'Custom persona content');
|
||
@@ -416,7 +416,7 @@ describe('normalizePieceConfig with layer resolution', () => {
|
||
|
||
it('should resolve policy by name when section map is absent', () => {
|
||
// Create project-level policy
|
||
- const policiesDir = join(projectDir, '.takt', 'policies');
|
||
+ const policiesDir = join(projectDir, '.takt', 'faceted', 'policies');
|
||
mkdirSync(policiesDir, { recursive: true });
|
||
writeFileSync(join(policiesDir, 'custom-policy.md'), '# Custom Policy\nBe nice.');
|
||
|
||
@@ -486,7 +486,7 @@ describe('normalizePieceConfig with layer resolution', () => {
|
||
});
|
||
|
||
it('should resolve knowledge by name from project layer', () => {
|
||
- const knowledgeDir = join(projectDir, '.takt', 'knowledge');
|
||
+ const knowledgeDir = join(projectDir, '.takt', 'faceted', 'knowledge');
|
||
mkdirSync(knowledgeDir, { recursive: true });
|
||
writeFileSync(join(knowledgeDir, 'domain-kb.md'), '# Domain Knowledge');
|
||
|
||
@@ -532,7 +532,7 @@ describe('normalizePieceConfig with layer resolution', () => {
|
||
});
|
||
|
||
it('should resolve instruction_template by name via layer resolution', () => {
|
||
- const instructionsDir = join(projectDir, '.takt', 'instructions');
|
||
+ const instructionsDir = join(projectDir, '.takt', 'faceted', 'instructions');
|
||
mkdirSync(instructionsDir, { recursive: true });
|
||
writeFileSync(join(instructionsDir, 'implement.md'), 'Project implement template');
|
||
|
||
@@ -576,7 +576,7 @@ Second line remains inline.`;
|
||
});
|
||
|
||
it('should resolve loop monitor judge instruction_template via layer resolution', () => {
|
||
- const instructionsDir = join(projectDir, '.takt', 'instructions');
|
||
+ const instructionsDir = join(projectDir, '.takt', 'faceted', 'instructions');
|
||
mkdirSync(instructionsDir, { recursive: true });
|
||
writeFileSync(join(instructionsDir, 'judge-template.md'), 'Project judge template');
|
||
|
||
diff --git a/src/__tests__/review-only-piece.test.ts b/src/__tests__/review-only-piece.test.ts
|
||
index 92ec975..da9ca1b 100644
|
||
--- a/src/__tests__/review-only-piece.test.ts
|
||
+++ b/src/__tests__/review-only-piece.test.ts
|
||
@@ -188,7 +188,7 @@ describe('review-only piece (JA)', () => {
|
||
|
||
describe('pr-commenter persona files', () => {
|
||
it('should exist for EN with domain knowledge', () => {
|
||
- const filePath = join(RESOURCES_DIR, 'en', 'personas', 'pr-commenter.md');
|
||
+ const filePath = join(RESOURCES_DIR, 'en', 'faceted', 'personas', 'pr-commenter.md');
|
||
const content = readFileSync(filePath, 'utf-8');
|
||
expect(content).toContain('PR Commenter');
|
||
expect(content).toContain('gh api');
|
||
@@ -196,7 +196,7 @@ describe('pr-commenter persona files', () => {
|
||
});
|
||
|
||
it('should exist for JA with domain knowledge', () => {
|
||
- const filePath = join(RESOURCES_DIR, 'ja', 'personas', 'pr-commenter.md');
|
||
+ const filePath = join(RESOURCES_DIR, 'ja', 'faceted', 'personas', 'pr-commenter.md');
|
||
const content = readFileSync(filePath, 'utf-8');
|
||
expect(content).toContain('PR Commenter');
|
||
expect(content).toContain('gh api');
|
||
@@ -204,7 +204,7 @@ describe('pr-commenter persona files', () => {
|
||
});
|
||
|
||
it('should NOT contain piece-specific report names (EN)', () => {
|
||
- const filePath = join(RESOURCES_DIR, 'en', 'personas', 'pr-commenter.md');
|
||
+ const filePath = join(RESOURCES_DIR, 'en', 'faceted', 'personas', 'pr-commenter.md');
|
||
const content = readFileSync(filePath, 'utf-8');
|
||
// Persona should not reference specific review-only piece report files
|
||
expect(content).not.toContain('01-architect-review.md');
|
||
@@ -218,7 +218,7 @@ describe('pr-commenter persona files', () => {
|
||
});
|
||
|
||
it('should NOT contain piece-specific report names (JA)', () => {
|
||
- const filePath = join(RESOURCES_DIR, 'ja', 'personas', 'pr-commenter.md');
|
||
+ const filePath = join(RESOURCES_DIR, 'ja', 'faceted', 'personas', 'pr-commenter.md');
|
||
const content = readFileSync(filePath, 'utf-8');
|
||
expect(content).not.toContain('01-architect-review.md');
|
||
expect(content).not.toContain('02-security-review.md');
|
||
diff --git a/src/__tests__/takt-pack-schema.test.ts b/src/__tests__/takt-pack-schema.test.ts
|
||
index 83e6728..da1baf0 100644
|
||
--- a/src/__tests__/takt-pack-schema.test.ts
|
||
+++ b/src/__tests__/takt-pack-schema.test.ts
|
||
@@ -1,10 +1,7 @@
|
||
/**
|
||
- * Unit tests for takt-pack.yaml schema validation (Zod schema).
|
||
+ * Unit tests for takt-pack.yaml schema validation.
|
||
*
|
||
- * Target: src/features/ensemble/taktPackSchema.ts (not yet implemented)
|
||
- *
|
||
- * All tests are `it.todo()` because the target module does not exist.
|
||
- * Fill in the callbacks and import once the schema module is implemented.
|
||
+ * Target: src/features/ensemble/takt-pack-config.ts
|
||
*
|
||
* Schema rules under test:
|
||
* - description: optional
|
||
@@ -14,75 +11,108 @@
|
||
* - path: must not contain ".." segments
|
||
*/
|
||
|
||
-import { describe, it } from 'vitest';
|
||
+import { describe, it, expect } from 'vitest';
|
||
+import {
|
||
+ parseTaktPackConfig,
|
||
+ validateTaktPackPath,
|
||
+ validateMinVersion,
|
||
+} from '../features/ensemble/takt-pack-config.js';
|
||
|
||
describe('takt-pack.yaml schema: description field', () => {
|
||
// U1: description は任意
|
||
// Input: {} (no description)
|
||
// Expect: バリデーション成功
|
||
- it.todo('should accept schema without description field');
|
||
+ it('should accept schema without description field', () => {
|
||
+ const config = parseTaktPackConfig('');
|
||
+ expect(config.description).toBeUndefined();
|
||
+ });
|
||
});
|
||
|
||
describe('takt-pack.yaml schema: path field', () => {
|
||
// U2: path 省略でデフォルト "."
|
||
// Input: {} (no path)
|
||
// Expect: parsed.path === "."
|
||
- it.todo('should default path to "." when not specified');
|
||
+ it('should default path to "." when not specified', () => {
|
||
+ const config = parseTaktPackConfig('');
|
||
+ expect(config.path).toBe('.');
|
||
+ });
|
||
|
||
// U9: path 絶対パス拒否 "/foo"
|
||
// Input: { path: "/foo" }
|
||
// Expect: ZodError (or equivalent validation error)
|
||
- it.todo('should reject path starting with "/" (absolute path)');
|
||
+ it('should reject path starting with "/" (absolute path)', () => {
|
||
+ expect(() => validateTaktPackPath('/foo')).toThrow();
|
||
+ });
|
||
|
||
// U10: path チルダ始まり拒否 "~/foo"
|
||
// Input: { path: "~/foo" }
|
||
// Expect: ZodError
|
||
- it.todo('should reject path starting with "~" (tilde-absolute path)');
|
||
+ it('should reject path starting with "~" (tilde-absolute path)', () => {
|
||
+ expect(() => validateTaktPackPath('~/foo')).toThrow();
|
||
+ });
|
||
|
||
// U11: path ".." セグメント拒否 "../outside"
|
||
// Input: { path: "../outside" }
|
||
// Expect: ZodError
|
||
- it.todo('should reject path with ".." segment traversing outside repository');
|
||
+ it('should reject path with ".." segment traversing outside repository', () => {
|
||
+ expect(() => validateTaktPackPath('../outside')).toThrow();
|
||
+ });
|
||
|
||
// U12: path ".." セグメント拒否 "sub/../../../outside"
|
||
// Input: { path: "sub/../../../outside" }
|
||
// Expect: ZodError
|
||
- it.todo('should reject path with embedded ".." segments leading outside repository');
|
||
+ it('should reject path with embedded ".." segments leading outside repository', () => {
|
||
+ expect(() => validateTaktPackPath('sub/../../../outside')).toThrow();
|
||
+ });
|
||
|
||
// U13: path 有効 "sub/dir"
|
||
// Input: { path: "sub/dir" }
|
||
// Expect: バリデーション成功
|
||
- it.todo('should accept valid relative path "sub/dir"');
|
||
+ it('should accept valid relative path "sub/dir"', () => {
|
||
+ expect(() => validateTaktPackPath('sub/dir')).not.toThrow();
|
||
+ });
|
||
});
|
||
|
||
describe('takt-pack.yaml schema: takt.min_version field', () => {
|
||
// U3: min_version 有効形式 "0.5.0"
|
||
// Input: { takt: { min_version: "0.5.0" } }
|
||
// Expect: バリデーション成功
|
||
- it.todo('should accept min_version "0.5.0" (valid semver)');
|
||
+ it('should accept min_version "0.5.0" (valid semver)', () => {
|
||
+ expect(() => validateMinVersion('0.5.0')).not.toThrow();
|
||
+ });
|
||
|
||
// U4: min_version 有効形式 "1.0.0"
|
||
// Input: { takt: { min_version: "1.0.0" } }
|
||
// Expect: バリデーション成功
|
||
- it.todo('should accept min_version "1.0.0" (valid semver)');
|
||
+ it('should accept min_version "1.0.0" (valid semver)', () => {
|
||
+ expect(() => validateMinVersion('1.0.0')).not.toThrow();
|
||
+ });
|
||
|
||
// U5: min_version 不正 "1.0"(セグメント不足)
|
||
// Input: { takt: { min_version: "1.0" } }
|
||
// Expect: ZodError
|
||
- it.todo('should reject min_version "1.0" (missing patch segment)');
|
||
+ it('should reject min_version "1.0" (missing patch segment)', () => {
|
||
+ expect(() => validateMinVersion('1.0')).toThrow();
|
||
+ });
|
||
|
||
// U6: min_version 不正 "v1.0.0"(v プレフィックス)
|
||
// Input: { takt: { min_version: "v1.0.0" } }
|
||
// Expect: ZodError
|
||
- it.todo('should reject min_version "v1.0.0" (v prefix not allowed)');
|
||
+ it('should reject min_version "v1.0.0" (v prefix not allowed)', () => {
|
||
+ expect(() => validateMinVersion('v1.0.0')).toThrow();
|
||
+ });
|
||
|
||
// U7: min_version 不正 "1.0.0-alpha"(pre-release サフィックス)
|
||
// Input: { takt: { min_version: "1.0.0-alpha" } }
|
||
// Expect: ZodError
|
||
- it.todo('should reject min_version "1.0.0-alpha" (pre-release suffix not allowed)');
|
||
+ it('should reject min_version "1.0.0-alpha" (pre-release suffix not allowed)', () => {
|
||
+ expect(() => validateMinVersion('1.0.0-alpha')).toThrow();
|
||
+ });
|
||
|
||
// U8: min_version 不正 "1.0.0-beta.1"
|
||
// Input: { takt: { min_version: "1.0.0-beta.1" } }
|
||
// Expect: ZodError
|
||
- it.todo('should reject min_version "1.0.0-beta.1" (pre-release suffix not allowed)');
|
||
+ it('should reject min_version "1.0.0-beta.1" (pre-release suffix not allowed)', () => {
|
||
+ expect(() => validateMinVersion('1.0.0-beta.1')).toThrow();
|
||
+ });
|
||
});
|
||
diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts
|
||
index 50db1e7..0af8a18 100644
|
||
--- a/src/app/cli/commands.ts
|
||
+++ b/src/app/cli/commands.ts
|
||
@@ -15,6 +15,9 @@ import { showCatalog } from '../../features/catalog/index.js';
|
||
import { computeReviewMetrics, formatReviewMetrics, parseSinceDuration, purgeOldEvents } from '../../features/analytics/index.js';
|
||
import { program, resolvedCwd } from './program.js';
|
||
import { resolveAgentOverrides } from './helpers.js';
|
||
+import { ensembleAddCommand } from '../../commands/ensemble/add.js';
|
||
+import { ensembleRemoveCommand } from '../../commands/ensemble/remove.js';
|
||
+import { ensembleListCommand } from '../../commands/ensemble/list.js';
|
||
|
||
program
|
||
.command('run')
|
||
@@ -173,3 +176,30 @@ program
|
||
success(`Purged ${deleted.length} file(s): ${deleted.join(', ')}`);
|
||
}
|
||
});
|
||
+
|
||
+const ensemble = program
|
||
+ .command('ensemble')
|
||
+ .description('Manage ensemble packages');
|
||
+
|
||
+ensemble
|
||
+ .command('add')
|
||
+ .description('Install an ensemble package from GitHub')
|
||
+ .argument('<spec>', 'Package spec (e.g. github:{owner}/{repo}@{ref})')
|
||
+ .action(async (spec: string) => {
|
||
+ await ensembleAddCommand(spec);
|
||
+ });
|
||
+
|
||
+ensemble
|
||
+ .command('remove')
|
||
+ .description('Remove an installed ensemble package')
|
||
+ .argument('<scope>', 'Package scope (e.g. @{owner}/{repo})')
|
||
+ .action(async (scope: string) => {
|
||
+ await ensembleRemoveCommand(scope);
|
||
+ });
|
||
+
|
||
+ensemble
|
||
+ .command('list')
|
||
+ .description('List installed ensemble packages')
|
||
+ .action(async () => {
|
||
+ await ensembleListCommand();
|
||
+ });
|
||
diff --git a/src/faceted-prompting/index.ts b/src/faceted-prompting/index.ts
|
||
index c50353a..9895906 100644
|
||
--- a/src/faceted-prompting/index.ts
|
||
+++ b/src/faceted-prompting/index.ts
|
||
@@ -49,3 +49,14 @@ export {
|
||
extractPersonaDisplayName,
|
||
resolvePersona,
|
||
} from './resolve.js';
|
||
+
|
||
+// Scope reference resolution
|
||
+export type { ScopeRef } from './scope.js';
|
||
+export {
|
||
+ isScopeRef,
|
||
+ parseScopeRef,
|
||
+ resolveScopeRef,
|
||
+ validateScopeOwner,
|
||
+ validateScopeRepo,
|
||
+ validateScopeFacetName,
|
||
+} from './scope.js';
|
||
diff --git a/src/features/catalog/catalogFacets.ts b/src/features/catalog/catalogFacets.ts
|
||
index 88160c3..38b2b38 100644
|
||
--- a/src/features/catalog/catalogFacets.ts
|
||
+++ b/src/features/catalog/catalogFacets.ts
|
||
@@ -9,8 +9,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||
import { join, basename } from 'node:path';
|
||
import chalk from 'chalk';
|
||
import type { PieceSource } from '../../infra/config/loaders/pieceResolver.js';
|
||
-import { getLanguageResourcesDir } from '../../infra/resources/index.js';
|
||
-import { getGlobalConfigDir, getProjectConfigDir } from '../../infra/config/paths.js';
|
||
+import { getBuiltinFacetDir, getGlobalFacetDir, getProjectFacetDir } from '../../infra/config/paths.js';
|
||
import { resolvePieceConfigValues } from '../../infra/config/index.js';
|
||
import { section, error as logError, info } from '../../shared/ui/index.js';
|
||
|
||
@@ -67,11 +66,11 @@ function getFacetDirs(
|
||
|
||
if (config.enableBuiltinPieces !== false) {
|
||
const lang = config.language;
|
||
- dirs.push({ dir: join(getLanguageResourcesDir(lang), facetType), source: 'builtin' });
|
||
+ dirs.push({ dir: getBuiltinFacetDir(lang, facetType), source: 'builtin' });
|
||
}
|
||
|
||
- dirs.push({ dir: join(getGlobalConfigDir(), facetType), source: 'user' });
|
||
- dirs.push({ dir: join(getProjectConfigDir(cwd), facetType), source: 'project' });
|
||
+ dirs.push({ dir: getGlobalFacetDir(facetType), source: 'user' });
|
||
+ dirs.push({ dir: getProjectFacetDir(cwd, facetType), source: 'project' });
|
||
|
||
return dirs;
|
||
}
|
||
@@ -123,6 +122,8 @@ function colorSourceTag(source: PieceSource): string {
|
||
return chalk.yellow(`[${source}]`);
|
||
case 'project':
|
||
return chalk.green(`[${source}]`);
|
||
+ default:
|
||
+ return chalk.blue(`[${source}]`);
|
||
}
|
||
}
|
||
|
||
diff --git a/src/infra/config/loaders/agentLoader.ts b/src/infra/config/loaders/agentLoader.ts
|
||
index 97012cb..26b14fa 100644
|
||
--- a/src/infra/config/loaders/agentLoader.ts
|
||
+++ b/src/infra/config/loaders/agentLoader.ts
|
||
@@ -14,6 +14,9 @@ import {
|
||
getGlobalPiecesDir,
|
||
getBuiltinPersonasDir,
|
||
getBuiltinPiecesDir,
|
||
+ getGlobalFacetDir,
|
||
+ getProjectFacetDir,
|
||
+ getEnsembleDir,
|
||
isPathSafe,
|
||
} from '../paths.js';
|
||
import { resolveConfigValue } from '../resolveConfigValue.js';
|
||
@@ -26,6 +29,9 @@ function getAllowedPromptBases(cwd: string): string[] {
|
||
getGlobalPiecesDir(),
|
||
getBuiltinPersonasDir(lang),
|
||
getBuiltinPiecesDir(lang),
|
||
+ getGlobalFacetDir('personas'),
|
||
+ getProjectFacetDir(cwd, 'personas'),
|
||
+ getEnsembleDir(),
|
||
];
|
||
}
|
||
|
||
diff --git a/src/infra/config/loaders/pieceCategories.ts b/src/infra/config/loaders/pieceCategories.ts
|
||
index 70410cd..bf4b9db 100644
|
||
--- a/src/infra/config/loaders/pieceCategories.ts
|
||
+++ b/src/infra/config/loaders/pieceCategories.ts
|
||
@@ -325,6 +325,42 @@ function buildCategoryTree(
|
||
return result;
|
||
}
|
||
|
||
+/**
|
||
+ * Append an "ensemble" category containing all @scope pieces.
|
||
+ * Creates one subcategory per @owner/repo package.
|
||
+ * Marks ensemble piece names as categorized (prevents them from appearing in "Others").
|
||
+ */
|
||
+function appendEnsembleCategory(
|
||
+ categories: PieceCategoryNode[],
|
||
+ allPieces: Map<string, PieceWithSource>,
|
||
+ categorized: Set<string>,
|
||
+): PieceCategoryNode[] {
|
||
+ const packagePieces = new Map<string, string[]>();
|
||
+ for (const [pieceName] of allPieces.entries()) {
|
||
+ if (!pieceName.startsWith('@')) continue;
|
||
+ const withoutAt = pieceName.slice(1);
|
||
+ const firstSlash = withoutAt.indexOf('/');
|
||
+ if (firstSlash < 0) continue;
|
||
+ const secondSlash = withoutAt.indexOf('/', firstSlash + 1);
|
||
+ if (secondSlash < 0) continue;
|
||
+ const owner = withoutAt.slice(0, firstSlash);
|
||
+ const repo = withoutAt.slice(firstSlash + 1, secondSlash);
|
||
+ const packageKey = `@${owner}/${repo}`;
|
||
+ const piecesList = packagePieces.get(packageKey) ?? [];
|
||
+ piecesList.push(pieceName);
|
||
+ packagePieces.set(packageKey, piecesList);
|
||
+ categorized.add(pieceName);
|
||
+ }
|
||
+ if (packagePieces.size === 0) return categories;
|
||
+ const ensembleChildren: PieceCategoryNode[] = [];
|
||
+ for (const [packageKey, pieces] of packagePieces.entries()) {
|
||
+ if (pieces.length === 0) continue;
|
||
+ ensembleChildren.push({ name: packageKey, pieces, children: [] });
|
||
+ }
|
||
+ if (ensembleChildren.length === 0) return categories;
|
||
+ return [...categories, { name: 'ensemble', pieces: [], children: ensembleChildren }];
|
||
+}
|
||
+
|
||
function appendOthersCategory(
|
||
categories: PieceCategoryNode[],
|
||
allPieces: Map<string, PieceWithSource>,
|
||
@@ -381,10 +417,11 @@ export function buildCategorizedPieces(
|
||
|
||
const categorized = new Set<string>();
|
||
const categories = buildCategoryTree(config.pieceCategories, allPieces, categorized);
|
||
+ const categoriesWithEnsemble = appendEnsembleCategory(categories, allPieces, categorized);
|
||
|
||
const finalCategories = config.showOthersCategory
|
||
- ? appendOthersCategory(categories, allPieces, categorized, config.othersCategoryName)
|
||
- : categories;
|
||
+ ? appendOthersCategory(categoriesWithEnsemble, allPieces, categorized, config.othersCategoryName)
|
||
+ : categoriesWithEnsemble;
|
||
|
||
return {
|
||
categories: finalCategories,
|
||
diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts
|
||
index fbedd07..9cebd7c 100644
|
||
--- a/src/infra/config/loaders/pieceParser.ts
|
||
+++ b/src/infra/config/loaders/pieceParser.ts
|
||
@@ -12,6 +12,7 @@ import type { z } from 'zod';
|
||
import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js';
|
||
import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge, ArpeggioMovementConfig, ArpeggioMergeMovementConfig, TeamLeaderConfig } from '../../../core/models/index.js';
|
||
import { resolvePieceConfigValue } from '../resolvePieceConfigValue.js';
|
||
+import { getEnsembleDir } from '../paths.js';
|
||
import {
|
||
type PieceSections,
|
||
type FacetResolutionContext,
|
||
@@ -441,6 +442,8 @@ export function loadPieceFromFile(filePath: string, projectDir: string): PieceCo
|
||
const context: FacetResolutionContext = {
|
||
lang: resolvePieceConfigValue(projectDir, 'language'),
|
||
projectDir,
|
||
+ pieceDir,
|
||
+ ensembleDir: getEnsembleDir(),
|
||
};
|
||
|
||
return normalizePieceConfig(raw, pieceDir, context);
|
||
diff --git a/src/infra/config/loaders/pieceResolver.ts b/src/infra/config/loaders/pieceResolver.ts
|
||
index 5b62385..60ec479 100644
|
||
--- a/src/infra/config/loaders/pieceResolver.ts
|
||
+++ b/src/infra/config/loaders/pieceResolver.ts
|
||
@@ -9,14 +9,15 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||
import { join, resolve, isAbsolute } from 'node:path';
|
||
import { homedir } from 'node:os';
|
||
import type { PieceConfig, PieceMovement, InteractiveMode } from '../../../core/models/index.js';
|
||
-import { getGlobalPiecesDir, getBuiltinPiecesDir, getProjectConfigDir } from '../paths.js';
|
||
+import { getGlobalPiecesDir, getBuiltinPiecesDir, getProjectConfigDir, getEnsembleDir } from '../paths.js';
|
||
+import { isScopeRef, parseScopeRef } from '../../../faceted-prompting/index.js';
|
||
import { resolvePieceConfigValues } from '../resolvePieceConfigValue.js';
|
||
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||
import { loadPieceFromFile } from './pieceParser.js';
|
||
|
||
const log = createLogger('piece-resolver');
|
||
|
||
-export type PieceSource = 'builtin' | 'user' | 'project';
|
||
+export type PieceSource = 'builtin' | 'user' | 'project' | 'ensemble';
|
||
|
||
export interface PieceWithSource {
|
||
config: PieceConfig;
|
||
@@ -136,12 +137,15 @@ export function isPiecePath(identifier: string): boolean {
|
||
}
|
||
|
||
/**
|
||
- * Load piece by identifier (auto-detects name vs path).
|
||
+ * Load piece by identifier (auto-detects @scope ref, file path, or piece name).
|
||
*/
|
||
export function loadPieceByIdentifier(
|
||
identifier: string,
|
||
projectCwd: string,
|
||
): PieceConfig | null {
|
||
+ if (isScopeRef(identifier)) {
|
||
+ return loadEnsemblePieceByRef(identifier, projectCwd);
|
||
+ }
|
||
if (isPiecePath(identifier)) {
|
||
return loadPieceFromPath(identifier, projectCwd, projectCwd);
|
||
}
|
||
@@ -371,6 +375,46 @@ function* iteratePieceDir(
|
||
}
|
||
}
|
||
|
||
+/**
|
||
+ * Iterate piece YAML files in all ensemble packages.
|
||
+ * Qualified name format: @{owner}/{repo}/{piece-name}
|
||
+ */
|
||
+function* iterateEnsemblePieces(ensembleDir: string): Generator<PieceDirEntry> {
|
||
+ if (!existsSync(ensembleDir)) return;
|
||
+ for (const ownerEntry of readdirSync(ensembleDir)) {
|
||
+ if (!ownerEntry.startsWith('@')) continue;
|
||
+ const ownerPath = join(ensembleDir, ownerEntry);
|
||
+ try { if (!statSync(ownerPath).isDirectory()) continue; } catch { continue; }
|
||
+ const owner = ownerEntry.slice(1);
|
||
+ for (const repoEntry of readdirSync(ownerPath)) {
|
||
+ const repoPath = join(ownerPath, repoEntry);
|
||
+ try { if (!statSync(repoPath).isDirectory()) continue; } catch { continue; }
|
||
+ const piecesDir = join(repoPath, 'pieces');
|
||
+ if (!existsSync(piecesDir)) continue;
|
||
+ for (const pieceFile of readdirSync(piecesDir)) {
|
||
+ if (!pieceFile.endsWith('.yaml') && !pieceFile.endsWith('.yml')) continue;
|
||
+ const piecePath = join(piecesDir, pieceFile);
|
||
+ try { if (!statSync(piecePath).isFile()) continue; } catch { continue; }
|
||
+ const pieceName = pieceFile.replace(/\.ya?ml$/, '');
|
||
+ yield { name: `@${owner}/${repoEntry}/${pieceName}`, path: piecePath, source: 'ensemble' };
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+}
|
||
+
|
||
+/**
|
||
+ * Load a piece by @scope reference (@{owner}/{repo}/{piece-name}).
|
||
+ * Resolves to ~/.takt/ensemble/@{owner}/{repo}/pieces/{piece-name}.yaml
|
||
+ */
|
||
+function loadEnsemblePieceByRef(identifier: string, projectCwd: string): PieceConfig | null {
|
||
+ const scopeRef = parseScopeRef(identifier);
|
||
+ const ensembleDir = getEnsembleDir();
|
||
+ const piecesDir = join(ensembleDir, `@${scopeRef.owner}`, scopeRef.repo, 'pieces');
|
||
+ const filePath = resolvePieceFile(piecesDir, scopeRef.name);
|
||
+ if (!filePath) return null;
|
||
+ return loadPieceFromFile(filePath, projectCwd);
|
||
+}
|
||
+
|
||
/** Get the 3-layer directory list (builtin → user → project-local) */
|
||
function getPieceDirs(cwd: string): { dir: string; source: PieceSource; disabled?: string[] }[] {
|
||
const config = resolvePieceConfigValues(cwd, ['enableBuiltinPieces', 'language', 'disabledBuiltins']);
|
||
@@ -406,6 +450,16 @@ export function loadAllPiecesWithSources(cwd: string): Map<string, PieceWithSour
|
||
}
|
||
}
|
||
|
||
+ // Scan ensemble packages (~/.takt/ensemble/@{owner}/{repo}/pieces/)
|
||
+ const ensembleDir = getEnsembleDir();
|
||
+ for (const entry of iterateEnsemblePieces(ensembleDir)) {
|
||
+ try {
|
||
+ pieces.set(entry.name, { config: loadPieceFromFile(entry.path, cwd), source: entry.source });
|
||
+ } catch (err) {
|
||
+ log.debug('Skipping invalid ensemble piece file', { path: entry.path, error: getErrorMessage(err) });
|
||
+ }
|
||
+ }
|
||
+
|
||
return pieces;
|
||
}
|
||
|
||
diff --git a/src/infra/config/loaders/resource-resolver.ts b/src/infra/config/loaders/resource-resolver.ts
|
||
index f819944..ac6a5fa 100644
|
||
--- a/src/infra/config/loaders/resource-resolver.ts
|
||
+++ b/src/infra/config/loaders/resource-resolver.ts
|
||
@@ -7,16 +7,19 @@
|
||
* implementation.
|
||
*/
|
||
|
||
+import { existsSync, readFileSync } from 'node:fs';
|
||
+import { resolve } from 'node:path';
|
||
import type { Language } from '../../../core/models/index.js';
|
||
import type { FacetType } from '../paths.js';
|
||
-import { getProjectFacetDir, getGlobalFacetDir, getBuiltinFacetDir } from '../paths.js';
|
||
+import { getProjectFacetDir, getGlobalFacetDir, getBuiltinFacetDir, getEnsembleFacetDir } from '../paths.js';
|
||
|
||
import {
|
||
resolveFacetPath as resolveFacetPathGeneric,
|
||
- resolveFacetByName as resolveFacetByNameGeneric,
|
||
resolveRefToContent as resolveRefToContentGeneric,
|
||
- resolveRefList as resolveRefListGeneric,
|
||
resolvePersona as resolvePersonaGeneric,
|
||
+ isScopeRef,
|
||
+ parseScopeRef,
|
||
+ resolveScopeRef,
|
||
} from '../../../faceted-prompting/index.js';
|
||
|
||
// Re-export types and pure functions that need no TAKT wrapping
|
||
@@ -33,31 +36,87 @@ export {
|
||
export interface FacetResolutionContext {
|
||
projectDir?: string;
|
||
lang: Language;
|
||
+ /** pieceDir of the piece being parsed — used for package-local layer detection. */
|
||
+ pieceDir?: string;
|
||
+ /** ensemble directory root — used together with pieceDir to detect package pieces. */
|
||
+ ensembleDir?: string;
|
||
}
|
||
|
||
/**
|
||
- * Build TAKT-specific candidate directories for a facet type.
|
||
+ * Determine whether a piece is inside an ensemble package.
|
||
+ *
|
||
+ * @param pieceDir - absolute path to the piece directory
|
||
+ * @param ensembleDir - absolute path to the ensemble root (~/.takt/ensemble)
|
||
+ */
|
||
+export function isPackagePiece(pieceDir: string, ensembleDir: string): boolean {
|
||
+ const resolvedPiece = resolve(pieceDir);
|
||
+ const resolvedEnsemble = resolve(ensembleDir);
|
||
+ return resolvedPiece.startsWith(resolvedEnsemble + '/');
|
||
+}
|
||
+
|
||
+/**
|
||
+ * Extract { owner, repo } from a package piece directory path.
|
||
+ *
|
||
+ * Directory structure: {ensembleDir}/@{owner}/{repo}/pieces/
|
||
+ *
|
||
+ * @returns { owner, repo } if pieceDir is a package piece, undefined otherwise.
|
||
*/
|
||
-function buildCandidateDirs(
|
||
+export function getPackageFromPieceDir(
|
||
+ pieceDir: string,
|
||
+ ensembleDir: string,
|
||
+): { owner: string; repo: string } | undefined {
|
||
+ if (!isPackagePiece(pieceDir, ensembleDir)) {
|
||
+ return undefined;
|
||
+ }
|
||
+ const resolvedEnsemble = resolve(ensembleDir);
|
||
+ const resolvedPiece = resolve(pieceDir);
|
||
+ const relative = resolvedPiece.slice(resolvedEnsemble.length + 1);
|
||
+ const parts = relative.split('/');
|
||
+ if (parts.length < 2) return undefined;
|
||
+ const ownerWithAt = parts[0]!;
|
||
+ if (!ownerWithAt.startsWith('@')) return undefined;
|
||
+ const owner = ownerWithAt.slice(1);
|
||
+ const repo = parts[1]!;
|
||
+ return { owner, repo };
|
||
+}
|
||
+
|
||
+/**
|
||
+ * Build candidate directories with optional package-local layer (4-layer for package pieces).
|
||
+ *
|
||
+ * Resolution order for package pieces:
|
||
+ * 1. package-local: {ensembleDir}/@{owner}/{repo}/faceted/{type}
|
||
+ * 2. project: {projectDir}/.takt/faceted/{type}
|
||
+ * 3. user: ~/.takt/faceted/{type}
|
||
+ * 4. builtin: builtins/{lang}/faceted/{type}
|
||
+ *
|
||
+ * For non-package pieces: 3-layer (project → user → builtin).
|
||
+ */
|
||
+export function buildCandidateDirsWithPackage(
|
||
facetType: FacetType,
|
||
context: FacetResolutionContext,
|
||
): string[] {
|
||
const dirs: string[] = [];
|
||
+
|
||
+ if (context.pieceDir && context.ensembleDir) {
|
||
+ const pkg = getPackageFromPieceDir(context.pieceDir, context.ensembleDir);
|
||
+ if (pkg) {
|
||
+ dirs.push(getEnsembleFacetDir(pkg.owner, pkg.repo, facetType, context.ensembleDir));
|
||
+ }
|
||
+ }
|
||
+
|
||
if (context.projectDir) {
|
||
dirs.push(getProjectFacetDir(context.projectDir, facetType));
|
||
}
|
||
dirs.push(getGlobalFacetDir(facetType));
|
||
dirs.push(getBuiltinFacetDir(context.lang, facetType));
|
||
+
|
||
return dirs;
|
||
}
|
||
|
||
/**
|
||
- * Resolve a facet name to its file path via 3-layer lookup.
|
||
+ * Resolve a facet name to its file path via 4-layer lookup (package-local → project → user → builtin).
|
||
*
|
||
- * Resolution order:
|
||
- * 1. Project .takt/{facetType}/{name}.md
|
||
- * 2. User ~/.takt/{facetType}/{name}.md
|
||
- * 3. Builtin builtins/{lang}/{facetType}/{name}.md
|
||
+ * Handles @{owner}/{repo}/{facet-name} scope references directly when ensembleDir is provided.
|
||
*
|
||
* @returns Absolute file path if found, undefined otherwise.
|
||
*/
|
||
@@ -66,11 +125,18 @@ export function resolveFacetPath(
|
||
facetType: FacetType,
|
||
context: FacetResolutionContext,
|
||
): string | undefined {
|
||
- return resolveFacetPathGeneric(name, buildCandidateDirs(facetType, context));
|
||
+ if (isScopeRef(name) && context.ensembleDir) {
|
||
+ const scopeRef = parseScopeRef(name);
|
||
+ const filePath = resolveScopeRef(scopeRef, facetType, context.ensembleDir);
|
||
+ return existsSync(filePath) ? filePath : undefined;
|
||
+ }
|
||
+ return resolveFacetPathGeneric(name, buildCandidateDirsWithPackage(facetType, context));
|
||
}
|
||
|
||
/**
|
||
- * Resolve a facet name via 3-layer lookup.
|
||
+ * Resolve a facet name to its file content via 4-layer lookup.
|
||
+ *
|
||
+ * Handles @{owner}/{repo}/{facet-name} scope references when ensembleDir is provided.
|
||
*
|
||
* @returns File content if found, undefined otherwise.
|
||
*/
|
||
@@ -79,14 +145,18 @@ export function resolveFacetByName(
|
||
facetType: FacetType,
|
||
context: FacetResolutionContext,
|
||
): string | undefined {
|
||
- return resolveFacetByNameGeneric(name, buildCandidateDirs(facetType, context));
|
||
+ const filePath = resolveFacetPath(name, facetType, context);
|
||
+ if (filePath) {
|
||
+ return readFileSync(filePath, 'utf-8');
|
||
+ }
|
||
+ return undefined;
|
||
}
|
||
|
||
/**
|
||
* Resolve a section reference to content.
|
||
* 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.
|
||
+ * falls back to 4-layer facet resolution (including package-local and @scope).
|
||
*/
|
||
export function resolveRefToContent(
|
||
ref: string,
|
||
@@ -95,8 +165,13 @@ export function resolveRefToContent(
|
||
facetType?: FacetType,
|
||
context?: FacetResolutionContext,
|
||
): string | undefined {
|
||
+ if (facetType && context && isScopeRef(ref) && context.ensembleDir) {
|
||
+ const scopeRef = parseScopeRef(ref);
|
||
+ const filePath = resolveScopeRef(scopeRef, facetType, context.ensembleDir);
|
||
+ return existsSync(filePath) ? readFileSync(filePath, 'utf-8') : undefined;
|
||
+ }
|
||
const candidateDirs = facetType && context
|
||
- ? buildCandidateDirs(facetType, context)
|
||
+ ? buildCandidateDirsWithPackage(facetType, context)
|
||
: undefined;
|
||
return resolveRefToContentGeneric(ref, resolvedMap, pieceDir, candidateDirs);
|
||
}
|
||
@@ -109,10 +184,14 @@ export function resolveRefList(
|
||
facetType?: FacetType,
|
||
context?: FacetResolutionContext,
|
||
): string[] | undefined {
|
||
- const candidateDirs = facetType && context
|
||
- ? buildCandidateDirs(facetType, context)
|
||
- : undefined;
|
||
- return resolveRefListGeneric(refs, resolvedMap, pieceDir, candidateDirs);
|
||
+ 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, facetType, context);
|
||
+ if (content) contents.push(content);
|
||
+ }
|
||
+ return contents.length > 0 ? contents : undefined;
|
||
}
|
||
|
||
/** Resolve persona from YAML field to spec + absolute path. */
|
||
@@ -122,8 +201,13 @@ export function resolvePersona(
|
||
pieceDir: string,
|
||
context?: FacetResolutionContext,
|
||
): { personaSpec?: string; personaPath?: string } {
|
||
+ if (rawPersona && isScopeRef(rawPersona) && context?.ensembleDir) {
|
||
+ const scopeRef = parseScopeRef(rawPersona);
|
||
+ const personaPath = resolveScopeRef(scopeRef, 'personas', context.ensembleDir);
|
||
+ return { personaSpec: rawPersona, personaPath: existsSync(personaPath) ? personaPath : undefined };
|
||
+ }
|
||
const candidateDirs = context
|
||
- ? buildCandidateDirs('personas', context)
|
||
+ ? buildCandidateDirsWithPackage('personas', context)
|
||
: undefined;
|
||
return resolvePersonaGeneric(rawPersona, sections, pieceDir, candidateDirs);
|
||
}
|
||
diff --git a/src/infra/config/paths.ts b/src/infra/config/paths.ts
|
||
index 214950b..125a225 100644
|
||
--- a/src/infra/config/paths.ts
|
||
+++ b/src/infra/config/paths.ts
|
||
@@ -48,9 +48,9 @@ export function getBuiltinPiecesDir(lang: Language): string {
|
||
return join(getLanguageResourcesDir(lang), 'pieces');
|
||
}
|
||
|
||
-/** Get builtin personas directory (builtins/{lang}/personas) */
|
||
+/** Get builtin personas directory (builtins/{lang}/faceted/personas) */
|
||
export function getBuiltinPersonasDir(lang: Language): string {
|
||
- return join(getLanguageResourcesDir(lang), 'personas');
|
||
+ return join(getLanguageResourcesDir(lang), 'faceted', 'personas');
|
||
}
|
||
|
||
/** Get project takt config directory (.takt in project) */
|
||
@@ -90,19 +90,41 @@ export function ensureDir(dirPath: string): void {
|
||
}
|
||
}
|
||
|
||
-/** Get project facet directory (.takt/{facetType} in project) */
|
||
+/** Get project facet directory (.takt/faceted/{facetType} in project) */
|
||
export function getProjectFacetDir(projectDir: string, facetType: FacetType): string {
|
||
- return join(getProjectConfigDir(projectDir), facetType);
|
||
+ return join(getProjectConfigDir(projectDir), 'faceted', facetType);
|
||
}
|
||
|
||
-/** Get global facet directory (~/.takt/{facetType}) */
|
||
+/** Get global facet directory (~/.takt/faceted/{facetType}) */
|
||
export function getGlobalFacetDir(facetType: FacetType): string {
|
||
- return join(getGlobalConfigDir(), facetType);
|
||
+ return join(getGlobalConfigDir(), 'faceted', facetType);
|
||
}
|
||
|
||
-/** Get builtin facet directory (builtins/{lang}/{facetType}) */
|
||
+/** Get builtin facet directory (builtins/{lang}/faceted/{facetType}) */
|
||
export function getBuiltinFacetDir(lang: Language, facetType: FacetType): string {
|
||
- return join(getLanguageResourcesDir(lang), facetType);
|
||
+ return join(getLanguageResourcesDir(lang), 'faceted', facetType);
|
||
+}
|
||
+
|
||
+/** Get ensemble directory (~/.takt/ensemble/) */
|
||
+export function getEnsembleDir(): string {
|
||
+ return join(getGlobalConfigDir(), 'ensemble');
|
||
+}
|
||
+
|
||
+/** Get ensemble package directory (~/.takt/ensemble/@{owner}/{repo}/) */
|
||
+export function getEnsemblePackageDir(owner: string, repo: string): string {
|
||
+ return join(getEnsembleDir(), `@${owner}`, repo);
|
||
+}
|
||
+
|
||
+/**
|
||
+ * Get ensemble facet directory.
|
||
+ *
|
||
+ * Defaults to the global ensemble dir when ensembleDir is not specified.
|
||
+ * Pass ensembleDir explicitly when resolving facets within a custom ensemble root
|
||
+ * (e.g. the package-local resolution layer).
|
||
+ */
|
||
+export function getEnsembleFacetDir(owner: string, repo: string, facetType: FacetType, ensembleDir?: string): string {
|
||
+ const base = ensembleDir ?? getEnsembleDir();
|
||
+ return join(base, `@${owner}`, repo, 'faceted', facetType);
|
||
}
|
||
|
||
/** Validate path is safe (no directory traversal) */
|