diff --git a/README.md b/README.md index 1dbdf8e..c6496ec 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,38 @@ launcher metadata. AgentWorkforce records Use `--no-launch-metadata` or `AGENTWORKFORCE_LAUNCH_METADATA=0` to skip metadata writing and session-log refresh for that launch. +### Persona sources + +Three places a persona can live, surfaced as `SOURCE` in `agentworkforce list`, +`sources list`, and the interactive picker: + +- **`built-in`** — bundled with `@agentworkforce/cli`. Reserved for personas + that are directly about Agent Workforce itself, e.g. `persona-maker` and + `persona-improver`. Every user has these without installing anything. This + is intentionally a small set. +- **`personal`** — `~/.agentworkforce/workforce/personas/`. For things one + user wants on every repo on their machine, but doesn't want to commit. +- **`cwd`** — `/.agentworkforce/workforce/personas/`. Personas codified + in the working tree so the whole team gets them on checkout. Two flavors + end up here: + - **library personas you've copied in** via `agentworkforce install ` — + generalized personas that can be extended to multiple codebases, similar + to the shadcn copy-and-own model. Example: the Relay team's + [`@agentrelay/personas`](https://github.com/AgentWorkforce/relay/tree/main/packages/personas) + pack. + - **repo-specific overrides** — hand-authored or `extends`-based personas + that encode rules unique to this codebase. Often extend a library persona + with project-specific auth, conventions, or skills. See + [AgentWorkforce/relay#839](https://github.com/AgentWorkforce/relay/pull/839) + for a worked example. + +Both flavors physically share the `cwd` directory; the distinction is +conceptual — "did this persona come from a published pack, or did we write +it for this codebase?" + +Cascade order is `cwd` → configured persona dirs → `personal` → `built-in`; +higher layers may override or `extends` lower ones field-by-field. + ### Persona pack installs Install a persona pack into the current project: diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d685a7e..3f93ca0 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -67,6 +67,7 @@ import { import { buildPersonaSourceDirectories, defaultCwdPersonaDir, + formatPersonaSourceLabel, loadLocalPersonas, loadPersonaSourceConfig, normalizePersonaDir, @@ -81,7 +82,9 @@ const USAGE = `Usage: agentworkforce [args...] Run with no arguments inside a TTY to open an interactive persona picker — the top 3 most recently used personas are shown first, and typing fuzzy- -searches across persona names and descriptions. +searches across persona names and descriptions. Each row's SOURCE column +is one of: built-in (bundled), cwd (./.agentworkforce/workforce/personas), +personal (~/.agentworkforce/workforce/personas), or dir:N (configured). Commands: create [flags] Opens persona-maker@best for creating a new @@ -1694,15 +1697,21 @@ function formatSourcesTable( dir: 'DIR' }; const cols = ['cascade', 'config', 'source', 'exists', 'dir'] as const; + // Display label only — the underlying `source` literal flows through to + // --json so tooling that pins on `'cwd'` / `'user'` / `'library'` is fine. + const display: readonly SourceDirRow[] = rows.map((r) => ({ + ...r, + source: formatPersonaSourceLabel(r.source) + })); const widths = Object.fromEntries( - cols.map((c) => [c, Math.max(headers[c].length, ...rows.map((r) => r[c].length))]) + cols.map((c) => [c, Math.max(headers[c].length, ...display.map((r) => r[c].length))]) ) as Record<(typeof cols)[number], number>; const line = (row: SourceDirRow) => cols.map((c) => row[c].padEnd(widths[c])).join(' ').trimEnd(); return [ `Config: ${configPath}`, `Default create target: ${defaultCreateTarget ?? '(auto)'}`, - [line(headers), ...rows.map(line)].join('\n'), + [line(headers), ...display.map(line)].join('\n'), '' ].join('\n'); } @@ -2032,7 +2041,9 @@ function formatPersonaTable( }; const rendered: RenderRow[] = rows.map((r) => ({ persona: r.persona, - source: r.source, + // Show the user-facing label (`built-in` / `repo` / `personal` / `dir:N`). + // The internal cascade key is still in `--json` output for tooling. + source: formatPersonaSourceLabel(r.source), harness: r.harness, model: r.model, rating: r.rating, @@ -3535,13 +3546,17 @@ function applyPatchInPlace(root: Record, patch: ImproverPatch): export function buildTuiCandidates(): TuiCandidate[] { const byId = new Map(); for (const spec of listBuiltInPersonas()) { - byId.set(spec.id, { id: spec.id, description: spec.description, source: 'library' }); + byId.set(spec.id, { + id: spec.id, + description: spec.description, + source: formatPersonaSourceLabel('library') + }); } for (const [id, spec] of local.byId.entries()) { byId.set(id, { id, description: spec.description, - source: local.sources.get(id) ?? 'library' + source: formatPersonaSourceLabel(local.sources.get(id) ?? 'library') }); } return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)); diff --git a/packages/cli/src/local-personas.test.ts b/packages/cli/src/local-personas.test.ts index 55b79ab..3883447 100644 --- a/packages/cli/src/local-personas.test.ts +++ b/packages/cli/src/local-personas.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import { __mergeOverrideForTests, + formatPersonaSourceLabel, loadLocalPersonas, loadPersonaSourceConfig, type LocalPersonaOverride @@ -1030,3 +1031,13 @@ test('override leaves channel alone: inherited claudeMdContent flows through', ( assert.equal(merged.claudeMdContent, '# keep me\n'); assert.equal(merged.claudeMd, undefined); }); + +test('formatPersonaSourceLabel maps internal cascade keys to display labels', () => { + assert.equal(formatPersonaSourceLabel('library'), 'built-in'); + assert.equal(formatPersonaSourceLabel('user'), 'personal'); + // cwd passes through — it's already a precise pointer to a real dir. + assert.equal(formatPersonaSourceLabel('cwd'), 'cwd'); + // dir:N passes through unchanged so cascade position stays legible. + assert.equal(formatPersonaSourceLabel('dir:1'), 'dir:1'); + assert.equal(formatPersonaSourceLabel('dir:42'), 'dir:42'); +}); diff --git a/packages/cli/src/local-personas.ts b/packages/cli/src/local-personas.ts index 593e571..286bb63 100644 --- a/packages/cli/src/local-personas.ts +++ b/packages/cli/src/local-personas.ts @@ -88,6 +88,31 @@ export interface LocalPersonaOverride { export type PersonaSource = string; +/** + * Map an internal {@link PersonaSource} cascade label to the human-readable + * vocabulary surfaced in `agentworkforce list`, `sources list`, and the + * interactive picker: + * + * - `library` → `built-in` — bundled with `@agentworkforce/cli`, + * available to every user without an install step. + * - `user` → `personal` — `~/.agentworkforce/workforce/personas/`, + * i.e. personas a single user keeps across all repos. + * - `cwd` → `cwd` — `/.agentworkforce/workforce/personas/`, + * the working-tree dir; both installed library packs and + * hand-authored team overrides live here. Kept as-is + * because it's a precise pointer to a real directory. + * - `dir:N` → `dir:N` — extra configurable persona dirs (passed + * through unchanged so position is still legible). + * + * Internal strings are left alone so `--save-in-directory ` and the + * JSON outputs of `list` / `sources list` keep their existing values. + */ +export function formatPersonaSourceLabel(source: PersonaSource): string { + if (source === 'library') return 'built-in'; + if (source === 'user') return 'personal'; + return source; +} + interface SourceLayer { key: string; source: PersonaSource;