Skip to content
16 changes: 14 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,9 @@ agentworkforce sources remove <dir|config-position>

Two fixed project sources always lead the cascade:
`<cwd>/.agentworkforce/workforce/personas/*.json`, then
`<cwd>/.agentworkforce/workforce/agents/<name>/persona.json`.
`<cwd>/.agentworkforce/workforce/agents/<name>/persona.json`. A third fixed
source, `~/.agentworkforce/workforce/agents/<name>/persona.json`, carries
personal agents and follows the personal personas dir wherever it ranks.

After that, the CLI reads an ordered list of configurable persona directories
from `~/.agentworkforce/workforce/config.json`. If no config exists, the list
Expand Down Expand Up @@ -477,13 +479,23 @@ wins):
2. `<cwd>/.agentworkforce/workforce/agents/<name>/persona.json` — **cwd:agents**
3. Configurable persona source dirs, in order. Default:
`~/.agentworkforce/workforce/personas/*.json` — **user**
4. Internal built-in system personas in `/personas/` — **library**
4. `~/.agentworkforce/workforce/agents/<name>/persona.json` — **user:agents**,
the personal mirror of `cwd:agents`. It rides directly behind the personal
personas dir, so moving that dir in the cascade moves both. The tables
printed by `list` and `sources list` show it as **personal:agents**,
following `user` → `personal`; `user:agents` is the value `--json` emits.
5. Internal built-in system personas in `/personas/` — **library**
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

Local files are **partial overlays**: only the fields you set replace the
inherited value. Everything else cascades through from below.

### Agents that ship their own handler

The same directory layout works in a repo (`cwd:agents`) and in your personal
config (`user:agents`), so an agent you want everywhere lives in
`~/.agentworkforce/workforce/agents/<name>/` and a repo's own agents live in its
working tree. A repo agent outranks a personal one of the same id.

An agent with code of its own keeps persona, handler, tests, and README in one
directory:

Expand Down
280 changes: 279 additions & 1 deletion packages/cli/src/local-personas.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

Expand Down Expand Up @@ -1034,3 +1034,281 @@ test('a missing agents/ directory is not an error', () => {
assert.deepEqual(loaded.warnings, []);
});
});

// --- handler agents ---------------------------------------------------------
// An agent driven by its `onEvent` entry has no interactive launch to
// configure, so harness/model/systemPrompt are optional. Requiring them kept
// exactly these agents out of the cascade the agents/ dir was added for.

test('a handler persona loads without interactive fields', () => {
withAgentLayer(({ cwd, homeDir, agentsDir }) => {
const agentDir = join(agentsDir, 'digest');
mkdirSync(agentDir, { recursive: true });
writeJson(join(agentDir, 'persona.json'), {
id: 'digest',
intent: 'documentation',
description: 'Weekly digest handler.',
cloud: true,
onEvent: './agent.ts',
harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
});
const loaded = loadLocalPersonas({ cwd, homeDir });
assert.deepEqual(loaded.warnings, []);
const spec = loaded.byId.get('digest');
assert.ok(spec);
assert.equal(spec.onEvent, './agent.ts');
assert.equal(spec.cloud, true);
assert.equal(spec.harness, undefined);
assert.equal(spec.model, undefined);
assert.equal(spec.systemPrompt, undefined);
});
});

test('a standalone persona with no handler still requires harness', () => {
withAgentLayer(({ cwd, homeDir, agentsDir }) => {
const agentDir = join(agentsDir, 'interactive');
mkdirSync(agentDir, { recursive: true });
writeJson(join(agentDir, 'persona.json'), {
id: 'interactive',
intent: 'documentation',
description: 'No handler, so an operator launches it.',
harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
});
const loaded = loadLocalPersonas({ cwd, homeDir });
assert.equal(loaded.byId.has('interactive'), false);
assert.match(loaded.warnings[0] ?? '', /harness is required for standalone personas/);
});
});

test('an overlay tweaking env keeps the handler entry it inherits', () => {
withAgentLayer(({ cwd, homeDir, pwdDir, agentsDir }) => {
const agentDir = join(agentsDir, 'digest');
mkdirSync(agentDir, { recursive: true });
writeJson(join(agentDir, 'persona.json'), {
id: 'digest',
intent: 'documentation',
description: 'Weekly digest handler.',
cloud: true,
onEvent: './agent.ts',
harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
});
writeJson(join(pwdDir, 'digest.json'), { id: 'digest', env: { TONE: 'terse' } });

const loaded = loadLocalPersonas({ cwd, homeDir });
assert.deepEqual(loaded.warnings, []);
const spec = loaded.byId.get('digest');
assert.equal(spec?.onEvent, './agent.ts');
assert.equal(spec?.cloud, true);
assert.equal(spec?.env?.TONE, 'terse');
});
});

test('onEvent may not escape the agent directory', () => {
withAgentLayer(({ cwd, homeDir, agentsDir }) => {
const agentDir = join(agentsDir, 'digest');
mkdirSync(agentDir, { recursive: true });
writeJson(join(agentDir, 'persona.json'), {
id: 'digest',
intent: 'documentation',
description: 'Escapes its directory.',
onEvent: '../../../elsewhere/agent.ts',
harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
});
const loaded = loadLocalPersonas({ cwd, homeDir });
assert.equal(loaded.byId.has('digest'), false);
assert.match(loaded.warnings[0] ?? '', /onEvent must not contain "\.\." segments/);
});
});

test('a padded onEvent is normalized before it is stored', () => {
withAgentLayer(({ cwd, homeDir, agentsDir }) => {
const agentDir = join(agentsDir, 'digest');
mkdirSync(agentDir, { recursive: true });
writeJson(join(agentDir, 'persona.json'), {
id: 'digest',
intent: 'documentation',
description: 'Padded but legal handler path.',
onEvent: ' ./agent.ts',
harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
});
const loaded = loadLocalPersonas({ cwd, homeDir });
assert.deepEqual(loaded.warnings, []);
// Stored untrimmed this resolves against a directory named " .".
assert.equal(loaded.byId.get('digest')?.onEvent, './agent.ts');
});
});

test('onEvent must point at a handler source file', () => {
withAgentLayer(({ cwd, homeDir, agentsDir }) => {
const agentDir = join(agentsDir, 'digest');
mkdirSync(agentDir, { recursive: true });
writeJson(join(agentDir, 'persona.json'), {
id: 'digest',
intent: 'documentation',
description: 'Points at prose, not a handler.',
onEvent: 'README.md',
harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
});
const loaded = loadLocalPersonas({ cwd, homeDir });
// Without the extension rule this would read as a handler and skip the
// interactive fields it never declared.
assert.equal(loaded.byId.has('digest'), false);
assert.match(loaded.warnings[0] ?? '', /must point at a \.ts/);
});
});

test('a padded onEvent cannot smuggle a .. segment past the guard', () => {
withAgentLayer(({ cwd, homeDir, agentsDir }) => {
const agentDir = join(agentsDir, 'digest');
mkdirSync(agentDir, { recursive: true });
writeJson(join(agentDir, 'persona.json'), {
id: 'digest',
intent: 'documentation',
description: 'Escapes once trimmed.',
onEvent: ' ../outside/agent.ts ',
harnessSettings: { reasoning: 'medium', timeoutSeconds: 600 }
});
const loaded = loadLocalPersonas({ cwd, homeDir });
assert.equal(loaded.byId.has('digest'), false);
// Trimmed before validation, so the traversal guard sees the real path.
assert.match(loaded.warnings[0] ?? '', /onEvent must not contain "\.\." segments/);
});
});

test('a persona.json older than its authoring source warns but still loads', () => {
withAgentLayer(({ cwd, homeDir, agentsDir }) => {
const agentDir = join(agentsDir, 'proposal-agent');
mkdirSync(agentDir, { recursive: true });
writeJson(join(agentDir, 'persona.json'), {
id: 'proposal-agent',
extends: 'persona-maker',
env: { COMPILED: 'stale' }
});
writeFileSync(join(agentDir, 'persona.ts'), 'export default {}\n');
// Backdate the artifact rather than sleeping — same relation, no wall clock.
const past = new Date(Date.now() - 60_000);
utimesSync(join(agentDir, 'persona.json'), past, past);

const loaded = loadLocalPersonas({ cwd, homeDir });
assert.equal(loaded.warnings.length, 1);
assert.match(loaded.warnings[0] ?? '', /persona\.json is older than persona\.ts/);
assert.match(loaded.warnings[0] ?? '', /agentworkforce persona compile/);
// Still served: a forgotten compile must not read as a missing persona.
assert.equal(loaded.byId.get('proposal-agent')?.env?.COMPILED, 'stale');
});
});

test('a persona.json newer than its authoring source is silent', () => {
withAgentLayer(({ cwd, homeDir, agentsDir }) => {
const agentDir = join(agentsDir, 'proposal-agent');
mkdirSync(agentDir, { recursive: true });
writeFileSync(join(agentDir, 'persona.ts'), 'export default {}\n');
writeJson(join(agentDir, 'persona.json'), {
id: 'proposal-agent',
extends: 'persona-maker'
});
const past = new Date(Date.now() - 60_000);
utimesSync(join(agentDir, 'persona.ts'), past, past);

const loaded = loadLocalPersonas({ cwd, homeDir });
assert.deepEqual(loaded.warnings, []);
assert.ok(loaded.byId.has('proposal-agent'));
});
});

test('staleness is measured against the newest authoring file, not the first', () => {
withAgentLayer(({ cwd, homeDir, agentsDir }) => {
const agentDir = join(agentsDir, 'proposal-agent');
mkdirSync(agentDir, { recursive: true });
// An abandoned persona.ts predates the artifact; the live persona.js is
// newer. Measuring against the .ts alone would call this fresh.
writeFileSync(join(agentDir, 'persona.ts'), 'export default {}\n');
writeJson(join(agentDir, 'persona.json'), { id: 'proposal-agent', extends: 'persona-maker' });
writeFileSync(join(agentDir, 'persona.js'), 'export default {}\n');

const old = new Date(Date.now() - 120_000);
utimesSync(join(agentDir, 'persona.ts'), old, old);
const mid = new Date(Date.now() - 60_000);
utimesSync(join(agentDir, 'persona.json'), mid, mid);

const loaded = loadLocalPersonas({ cwd, homeDir });
assert.equal(loaded.warnings.length, 1);
assert.match(loaded.warnings[0] ?? '', /persona\.json is older than persona\.js/);
});
});

// --- user:agents layer ------------------------------------------------------
// The personal mirror of cwd:agents — an agent-with-handler a user keeps
// across every repo, in the `agents/` sibling of their personal personas dir.

test('the personal agents dir loads as user:agents', () => {
withLayers(({ cwd, home, homeDir }) => {
const userAgents = join(home, '.agentworkforce', 'workforce', 'agents');
mkdirSync(join(userAgents, 'note-taker'), { recursive: true });
writeJson(join(userAgents, 'note-taker', 'persona.json'), {
id: 'note-taker',
extends: 'persona-maker',
env: { SCOPE: 'personal' }
});
const loaded = loadLocalPersonas({ cwd, homeDir });
assert.deepEqual(loaded.warnings, []);
assert.equal(loaded.sources.get('note-taker'), 'user:agents');
assert.equal(loaded.byId.get('note-taker')?.env?.SCOPE, 'personal');
});
});

test('a personal agent resolves its skills against its own directory', () => {
withLayers(({ cwd, home, homeDir }) => {
const workforceHome = join(home, '.agentworkforce', 'workforce');
mkdirSync(join(workforceHome, 'skills'), { recursive: true });
writeFileSync(join(workforceHome, 'skills', 'voice.md'), '# voice\n');
const agentDir = join(workforceHome, 'agents', 'note-taker');
mkdirSync(agentDir, { recursive: true });
writeJson(join(agentDir, 'persona.json'), {
id: 'note-taker',
extends: 'persona-maker',
skills: [{ id: 'local/voice', source: '../../skills/voice.md', description: 'voice' }]
});
const loaded = loadLocalPersonas({ cwd, homeDir });
assert.deepEqual(loaded.warnings, []);
assert.equal(
loaded.byId.get('note-taker')?.skills[0]?.source,
join(workforceHome, 'skills', 'voice.md')
);
});
});

test('a repo agent outranks a personal agent of the same id', () => {
withAgentLayer(({ cwd, home, homeDir, agentsDir }) => {
const userAgents = join(home, '.agentworkforce', 'workforce', 'agents');
mkdirSync(join(userAgents, 'note-taker'), { recursive: true });
writeJson(join(userAgents, 'note-taker', 'persona.json'), {
id: 'note-taker',
extends: 'persona-maker',
env: { SCOPE: 'personal', KEPT: 'yes' }
});
mkdirSync(join(agentsDir, 'note-taker'), { recursive: true });
writeJson(join(agentsDir, 'note-taker', 'persona.json'), {
id: 'note-taker',
env: { SCOPE: 'repo' }
});
const loaded = loadLocalPersonas({ cwd, homeDir });
assert.deepEqual(loaded.warnings, []);
assert.equal(loaded.sources.get('note-taker'), 'cwd:agents');
const spec = loaded.byId.get('note-taker');
assert.equal(spec?.env?.SCOPE, 'repo');
assert.equal(spec?.env?.KEPT, 'yes');
});
});

test('user:agents displays as personal:agents, following user -> personal', () => {
assert.equal(formatPersonaSourceLabel('user:agents'), 'personal:agents');
assert.equal(formatPersonaSourceLabel('cwd:agents'), 'cwd:agents');
});

test('a missing personal agents dir is not an error', () => {
withLayers(({ cwd, homeDir }) => {
const loaded = loadLocalPersonas({ cwd, homeDir });
assert.deepEqual(loaded.warnings, []);
});
});
2 changes: 1 addition & 1 deletion packages/persona-registry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Programmatic resolution for AgentWorkforce personas. It owns the same source
cascade used by the CLI: cwd personas, cwd agents, configured directories
(including the personal directory), then the built-in catalog.
(including the personal directory), personal agents, then the built-in catalog.

```ts
import { resolvePersonaReference } from '@agentworkforce/persona-registry';
Expand Down
Loading
Loading