diff --git a/packages/persona-registry/src/index.test.ts b/packages/persona-registry/src/index.test.ts index 2f72fee8..1ee649fb 100644 --- a/packages/persona-registry/src/index.test.ts +++ b/packages/persona-registry/src/index.test.ts @@ -5,7 +5,14 @@ import { join } from 'node:path'; import test from 'node:test'; import type { PersonaSpec } from '@agentworkforce/persona-kit'; -import { PersonaResolutionError, resolvePersonaReference, __mergeOverrideForTests } from './index.js'; +import { + PersonaResolutionError, + buildPersonaSourceDirectories, + findRepoRoot, + loadLocalPersonas, + resolvePersonaReference, + __mergeOverrideForTests +} from './index.js'; test('a later mount layer can re-enable inherited mount patterns', () => { const base: PersonaSpec = { @@ -105,3 +112,78 @@ test('unknown names fail with a typed resolution error', () => { rmSync(cwd, { recursive: true, force: true }); } }); + +test("a repo's personas load from a subdirectory of it", () => { + const root = mkdtempSync(join(tmpdir(), 'persona-registry-repo-')); + const repo = join(root, 'repo'); + const nested = join(repo, 'packages', 'deep'); + mkdirSync(join(repo, '.git'), { recursive: true }); + mkdirSync(nested, { recursive: true }); + const personas = join(repo, '.agentworkforce', 'workforce', 'personas'); + mkdirSync(personas, { recursive: true }); + writeFileSync( + join(personas, 'scout.json'), + JSON.stringify({ + id: 'repo-scout', + extends: 'persona-maker', + description: 'Defined at the repo root' + }) + ); + + try { + // Commands are typically run from a package directory, not the repo root. + const fromNested = loadLocalPersonas({ cwd: nested, personaDirs: [] }); + assert.equal(fromNested.byId.get('repo-scout')?.description, 'Defined at the repo root'); + assert.equal(fromNested.sources.get('repo-scout'), 'repo'); + + // The repo root itself still reports the persona as its own cwd layer, + // with no duplicate repo entry. + const fromRoot = loadLocalPersonas({ cwd: repo, personaDirs: [] }); + assert.equal(fromRoot.sources.get('repo-scout'), 'cwd'); + const rootDirs = buildPersonaSourceDirectories({ cwd: repo, personaDirs: [] }).directories; + assert.equal(rootDirs.some((d) => String(d.source).startsWith('repo')), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('the cwd layer still outranks the repo root', () => { + const root = mkdtempSync(join(tmpdir(), 'persona-registry-rank-')); + const repo = join(root, 'repo'); + const nested = join(repo, 'packages', 'deep'); + mkdirSync(join(repo, '.git'), { recursive: true }); + const repoPersonas = join(repo, '.agentworkforce', 'workforce', 'personas'); + const nestedPersonas = join(nested, '.agentworkforce', 'workforce', 'personas'); + mkdirSync(repoPersonas, { recursive: true }); + mkdirSync(nestedPersonas, { recursive: true }); + writeFileSync( + join(repoPersonas, 'scout.json'), + JSON.stringify({ id: 'scout', extends: 'persona-maker', description: 'repo root' }) + ); + writeFileSync( + join(nestedPersonas, 'scout.json'), + JSON.stringify({ id: 'scout', extends: 'persona-maker', description: 'package dir' }) + ); + + try { + const loaded = loadLocalPersonas({ cwd: nested, personaDirs: [] }); + assert.equal(loaded.byId.get('scout')?.description, 'package dir'); + assert.equal(loaded.sources.get('scout'), 'cwd'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('the repo walk stops rather than escaping to the home directory', () => { + const root = mkdtempSync(join(tmpdir(), 'persona-registry-norepo-')); + const loose = join(root, 'not', 'a', 'repo'); + mkdirSync(loose, { recursive: true }); + + try { + assert.equal(findRepoRoot(loose), undefined); + const dirs = buildPersonaSourceDirectories({ cwd: loose, personaDirs: [] }).directories; + assert.equal(dirs.some((d) => String(d.source).startsWith('repo')), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/persona-registry/src/local-personas.ts b/packages/persona-registry/src/local-personas.ts index d15274ca..3a80625f 100644 --- a/packages/persona-registry/src/local-personas.ts +++ b/packages/persona-registry/src/local-personas.ts @@ -103,6 +103,11 @@ export type PersonaSource = string; * - `cwd:agents` → same — `/.agentworkforce/workforce/agents//persona.json`, * agents that keep their persona next to their handler. * Also a precise pointer, so also kept as-is. + * - `repo` → `repo` — `/.agentworkforce/workforce/personas/`, + * the repository's own personas, visible from any + * subdirectory of it. Present only when cwd is below the + * root and the directory exists. `repo:agents` is its + * nested counterpart. * - `dir:N` → `dir:N` — extra configurable persona dirs (passed * through unchanged so position is still legible). * @@ -212,6 +217,27 @@ export function defaultCwdAgentDir(cwd: string): string { return join(cwd, '.agentworkforce', 'workforce', 'agents'); } +/** + * The repository root at or above `cwd`, or `undefined` outside a repository. + * + * A repo's personas live at its root, but commands are typically run from a + * package or source subdirectory. Without this the cascade looks only at the + * exact cwd, so the same repo yields different personas depending on which + * directory you happen to be standing in. The walk stops at the home directory + * — `~/.agentworkforce/workforce/personas/` is already the `user` layer. + */ +export function findRepoRoot(cwd: string): string | undefined { + const home = resolvePath(homedir()); + let dir = resolvePath(cwd); + while (true) { + if (existsSync(join(dir, '.git'))) return dir; + if (dir === home) return undefined; + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + /** Persona filename read from each subdirectory of a nested source dir. */ export const NESTED_PERSONA_FILENAME = 'persona.json'; @@ -360,6 +386,30 @@ function sourceForPersonaDir( return dir === userPersonaDir ? 'user' : `dir:${idx + 1}`; } +/** + * Persona directories contributed by the repository root when the command runs + * from a subdirectory. Ranked directly below the cwd layers: the directory you + * are standing in stays the most specific, and the repo answers for everywhere + * else inside it. + */ +function repoSourceDirectories(cwd: string): PersonaSourceDirectory[] { + const root = findRepoRoot(cwd); + if (!root || resolvePath(root) === resolvePath(cwd)) return []; + // Unlike the cwd layers — which are always listed because they are where + // `create` writes — a repo layer is only worth naming when it exists. Most + // repositories keep no personas, and listing a path nobody created is noise. + const dirs: PersonaSourceDirectory[] = []; + const personas = defaultCwdPersonaDir(root); + if (existsSync(personas)) { + dirs.push({ source: 'repo', dir: personas, configurable: false }); + } + const agents = defaultCwdAgentDir(root); + if (existsSync(agents)) { + dirs.push({ source: 'repo:agents', dir: agents, configurable: false, nested: true }); + } + return dirs; +} + export function buildPersonaSourceDirectories( options: LoadOptions = {} ): { directories: PersonaSourceDirectory[]; config: PersonaSourceConfig } { @@ -379,6 +429,7 @@ export function buildPersonaSourceDirectories( configurable: false, nested: true }, + ...repoSourceDirectories(cwd), ...config.personaDirs.map((dir, idx) => ({ source: sourceForPersonaDir(dir, idx, config.userPersonaDir), dir,