From 709e81fe165f9d72bc7aa578cb0ab7f8e28a5a1d Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sun, 22 Feb 2026 12:14:58 +0900 Subject: [PATCH] Release v0.22.0 --- CHANGELOG.md | 18 +- README.md | 6 +- docs/CHANGELOG.ja.md | 4 + src-diff.txt | 1913 ------------------------------------------ 4 files changed, 18 insertions(+), 1923 deletions(-) delete mode 100644 src-diff.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index cbffe48..4185331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,30 +10,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- **Ensemble package system** (`takt ensemble add/remove/list`): Import and manage external TAKT packages from GitHub — `takt ensemble add github:{owner}/{repo}@{ref}` downloads packages to `~/.takt/ensemble/` with atomic installation, version compatibility checks, lock files, and package content summary before confirmation -- **@scope references in piece YAML**: Facet references now support `@{owner}/{repo}/{facet-name}` syntax to reference facets from installed ensemble packages (e.g., `persona: @nrslib/takt-fullstack/expert-coder`) -- **4-layer facet resolution**: Upgraded from 3-layer (project → user → builtin) to 4-layer (package-local → project → user → builtin) — ensemble package pieces automatically resolve their own facets first -- **Ensemble category in piece selection**: Installed ensemble packages automatically appear as subcategories under an "ensemble" category in the piece selection UI +- **Repertoire package system** (`takt repertoire add/remove/list`): Import and manage external TAKT packages from GitHub — `takt repertoire add github:{owner}/{repo}@{ref}` downloads packages to `~/.takt/repertoire/` with atomic installation, version compatibility checks, lock files, and package content summary before confirmation +- **@scope references in piece YAML**: Facet references now support `@{owner}/{repo}/{facet-name}` syntax to reference facets from installed repertoire packages (e.g., `persona: @nrslib/takt-fullstack/expert-coder`) +- **4-layer facet resolution**: Upgraded from 3-layer (project → user → builtin) to 4-layer (package-local → project → user → builtin) — repertoire package pieces automatically resolve their own facets first +- **Repertoire category in piece selection**: Installed repertoire packages automatically appear as subcategories under a "repertoire" category in the piece selection UI - **Build gate in implement/fix instructions**: `implement` and `fix` builtin instructions now require build (type check) verification before test execution +- **Repertoire package documentation**: Added comprehensive docs for the repertoire package system ([en](./docs/repertoire.md), [ja](./docs/repertoire.ja.md)) + ### Changed +- **BREAKING: "ensemble" renamed to "repertoire"**: All CLI commands, directories, config keys, and APIs renamed — `takt ensemble` → `takt repertoire`, `~/.takt/ensemble/` → `~/.takt/repertoire/`. Migration: rename your `~/.takt/ensemble/` directory to `~/.takt/repertoire/` - **BREAKING: Facets directory restructured**: Facet directories moved under a `facets/` subdirectory at all levels — `builtins/{lang}/{facetType}/` → `builtins/{lang}/facets/{facetType}/`, `~/.takt/{facetType}/` → `~/.takt/facets/{facetType}/`, `.takt/{facetType}/` → `.takt/facets/{facetType}/`. Migration: move your custom facet files into the new `facets/` subdirectory - Contract string hardcoding prevention rule added to coding policy and architecture review instruction ### Fixed -- Override piece validation now includes ensemble scope via the resolver +- Override piece validation now includes repertoire scope via the resolver - `takt export-cc` now reads facets from the new `builtins/{lang}/facets/` directory structure -- `confirm()` prompt now supports piped stdin (e.g., `echo "y" | takt ensemble add ...`) +- `confirm()` prompt now supports piped stdin (e.g., `echo "y" | takt repertoire add ...`) - Suppressed `poll_tick` debug log flooding during iteration input wait - Piece resolver `stat()` calls now catch errors gracefully instead of crashing on inaccessible entries ### Internal -- Comprehensive ensemble test suite: atomic-update, ensemble-paths, file-filter, github-ref-resolver, github-spec, list, lock-file, pack-summary, package-facet-resolution, remove-reference-check, remove, takt-ensemble-config, tar-parser, takt-ensemble-schema +- Comprehensive repertoire test suite: atomic-update, repertoire-paths, file-filter, github-ref-resolver, github-spec, list, lock-file, pack-summary, package-facet-resolution, remove-reference-check, remove, takt-repertoire-config, tar-parser, takt-repertoire-schema - Added `src/faceted-prompting/scope.ts` for @scope reference parsing, validation, and resolution - Added scope-ref tests for the faceted-prompting module - Added `inputWait.ts` for shared input-wait state to suppress worker pool log noise +- Added piece-selection-branches and repertoire e2e tests ## [0.21.0] - 2026-02-20 diff --git a/README.md b/README.md index 17cbd5e..b1c8bcc 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ See the [Builtin Catalog](./docs/builtin-catalog.md) for all pieces and personas | `takt #N` | Execute GitHub Issue as task | | `takt switch` | Switch active piece | | `takt eject` | Copy builtin pieces/personas for customization | -| `takt ensemble add` | Install an ensemble package from GitHub | +| `takt repertoire add` | Install a repertoire package from GitHub | See the [CLI Reference](./docs/cli-reference.md) for all commands and options. @@ -214,7 +214,7 @@ See the [CI/CD Guide](./docs/ci-cd.md) for full setup instructions. ├── config.yaml # Provider, model, language, etc. ├── pieces/ # User piece definitions ├── facets/ # User facets (personas, policies, knowledge, etc.) -└── ensemble/ # Installed ensemble packages +└── repertoire/ # Installed repertoire packages .takt/ # Project-level ├── config.yaml # Project config @@ -250,7 +250,7 @@ await engine.run(); | [Agent Guide](./docs/agents.md) | Custom agent configuration | | [Builtin Catalog](./docs/builtin-catalog.md) | All builtin pieces and personas | | [Faceted Prompting](./docs/faceted-prompting.md) | Prompt design methodology | -| [Ensemble Packages](./docs/ensemble.md) | Installing and sharing packages | +| [Repertoire Packages](./docs/repertoire.md) | Installing and sharing packages | | [Task Management](./docs/task-management.md) | Task queuing, execution, isolation | | [CI/CD Integration](./docs/ci-cd.md) | GitHub Actions and pipeline mode | | [Changelog](./CHANGELOG.md) ([日本語](./docs/CHANGELOG.ja.md)) | Version history | diff --git a/docs/CHANGELOG.ja.md b/docs/CHANGELOG.ja.md index 5c7d9b4..397ef4f 100644 --- a/docs/CHANGELOG.ja.md +++ b/docs/CHANGELOG.ja.md @@ -15,8 +15,11 @@ - **4層ファセット解決**: 3層(project → user → builtin)から4層(package-local → project → user → builtin)に拡張 — repertoire パッケージのピースは自パッケージ内のファセットを最優先で解決 - **ピース選択に repertoire カテゴリ追加**: インストール済みの repertoire パッケージがピース選択 UI の「repertoire」カテゴリにサブカテゴリとして自動表示 - **implement/fix インストラクションにビルドゲート追加**: `implement` と `fix` のビルトインインストラクションでテスト実行前にビルド(型チェック)の実行を必須化 +- **Repertoire パッケージドキュメント追加**: repertoire パッケージシステムの包括的なドキュメントを追加([en](./repertoire.md), [ja](./repertoire.ja.md)) + ### Changed +- **BREAKING: "ensemble" を "repertoire" にリネーム**: 全 CLI コマンド、ディレクトリ、設定キー、API を変更 — `takt ensemble` → `takt repertoire`、`~/.takt/ensemble/` → `~/.takt/repertoire/`。マイグレーション: `~/.takt/ensemble/` ディレクトリを `~/.takt/repertoire/` にリネームしてください - **BREAKING: ファセットディレクトリ構造の変更**: 全レイヤーでファセットディレクトリが `facets/` サブディレクトリ配下に移動 — `builtins/{lang}/{facetType}/` → `builtins/{lang}/facets/{facetType}/`、`~/.takt/{facetType}/` → `~/.takt/facets/{facetType}/`、`.takt/{facetType}/` → `.takt/facets/{facetType}/`。マイグレーション: カスタムファセットファイルを新しい `facets/` サブディレクトリに移動してください - 契約文字列のハードコード散在防止ルールをコーディングポリシーとアーキテクチャレビューインストラクションに追加 @@ -34,6 +37,7 @@ - `src/faceted-prompting/scope.ts` を追加(@scope 参照のパース・バリデーション・解決) - faceted-prompting モジュールの scope-ref テストを追加 - `inputWait.ts` を追加(ワーカープールのログノイズ抑制のための入力待ち状態共有) +- piece-selection-branches および repertoire の e2e テストを追加 ## [0.21.0] - 2026-02-20 diff --git a/src-diff.txt b/src-diff.txt deleted file mode 100644 index 09fd167..0000000 --- a/src-diff.txt +++ /dev/null @@ -1,1913 +0,0 @@ -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)).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('', '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('', '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, -+ categorized: Set, -+): PieceCategoryNode[] { -+ const packagePieces = new Map(); -+ 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, -@@ -381,10 +417,11 @@ export function buildCategorizedPieces( - - const categorized = new Set(); - 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 { -+ 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 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) */