From 4122f83ceba1cd3b2b9c1f8a8a74ad51acd097a2 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 20 Aug 2026 12:53:21 -0400 Subject: [PATCH 1/7] feat(persona-registry): let handler agents into the cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent driven by its `onEvent` entry has no interactive launch to configure, so `harness`, `model`, and `systemPrompt` — already optional on `PersonaSpec` — are optional here too. Requiring them kept exactly the agents the `agents/` directory was added for out of the registry, reporting a valid deployable persona as malformed. `onEvent` and `cloud` now survive parse and merge. An overlay that tweaks env no longer strips the handler entry that makes its base deployable, so the merged spec is a complete agent rather than a partial one. `harnessSettings` stays required: `PersonaSpec` types it non-optional, and `reasoning`/`timeoutSeconds` have no defensible default to invent on a persona's behalf. `onEvent` is validated as a relative path that cannot escape the agent directory, matching the sidecar rule. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/local-personas.test.ts | 85 +++++++++++++++++++ .../persona-registry/src/local-personas.ts | 84 +++++++++++++++--- 2 files changed, 158 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/local-personas.test.ts b/packages/cli/src/local-personas.test.ts index 115defd3..12b00dcf 100644 --- a/packages/cli/src/local-personas.test.ts +++ b/packages/cli/src/local-personas.test.ts @@ -1034,3 +1034,88 @@ 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/); + }); +}); diff --git a/packages/persona-registry/src/local-personas.ts b/packages/persona-registry/src/local-personas.ts index d15274ca..8b556c30 100644 --- a/packages/persona-registry/src/local-personas.ts +++ b/packages/persona-registry/src/local-personas.ts @@ -64,6 +64,14 @@ export interface LocalPersonaOverride { permissions?: PersonaPermissions; /** Replaces the inherited systemPrompt when set. */ systemPrompt?: string; + /** + * Handler entry, relative to this file's directory. Its presence marks the + * persona as a cloud agent: the handler drives the run, so the interactive + * fields an operator-launched persona must declare are optional here. + */ + onEvent?: string; + /** Deployable as a managed cloud agent. */ + cloud?: boolean; /** Replaces the inherited harness when set. */ harness?: Harness; /** Replaces the inherited model when set. */ @@ -493,6 +501,27 @@ function isPlainObject(value: unknown): value is Record { * requirement called out in the schema. Throws on absolute paths, * `..` segments, empty strings, or non-`.md` extensions. */ +/** + * Relative-path guard shared by `onEvent` and, with an added `.md` rule, by + * sidecars. A handler that escaped its agent directory would bundle files the + * persona does not own. + */ +function assertSafeRelativePath(value: unknown, context: string): void { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${context} must be a non-empty string`); + } + if ( + value.startsWith('/') || + value.startsWith('\\') || + /^[A-Za-z]:/.test(value) + ) { + throw new Error(`${context} must be a relative path; got absolute "${value}"`); + } + if (value.split(/[\\/]+/).some((segment) => segment === '..')) { + throw new Error(`${context} must not contain ".." segments`); + } +} + function assertSidecarPath(value: unknown, context: string): void { if (typeof value !== 'string' || !value.trim()) { throw new Error(`${context} must be a non-empty string`); @@ -588,6 +617,15 @@ function parseOverride(value: unknown, context: string): LocalPersonaOverride { `${context}.defaultTier is no longer supported (tiers have been removed)` ); } + if (raw.onEvent !== undefined) { + if (typeof raw.onEvent !== 'string' || !raw.onEvent.trim()) { + throw new Error(`${context}.onEvent must be a non-empty string if provided`); + } + assertSafeRelativePath(raw.onEvent, `${context}.onEvent`); + } + if (raw.cloud !== undefined && typeof raw.cloud !== 'boolean') { + throw new Error(`${context}.cloud must be a boolean if provided`); + } if (raw.harness !== undefined) { if (typeof raw.harness !== 'string' || !HARNESS_VALUES.includes(raw.harness as Harness)) { throw new Error(`${context}.harness must be one of: ${HARNESS_VALUES.join(', ')}`); @@ -631,6 +669,8 @@ function parseOverride(value: unknown, context: string): LocalPersonaOverride { mount: raw.mount as LocalPersonaOverride['mount'], permissions: raw.permissions as LocalPersonaOverride['permissions'], systemPrompt: raw.systemPrompt as string | undefined, + ...(raw.onEvent !== undefined ? { onEvent: (raw.onEvent as string).trim() } : {}), + ...(raw.cloud !== undefined ? { cloud: raw.cloud as boolean } : {}), ...(raw.harness !== undefined ? { harness: raw.harness as Harness } : {}), ...(raw.model !== undefined ? { model: raw.model as string } : {}), ...(raw.harnessSettings !== undefined @@ -819,12 +859,23 @@ function standaloneSpecFromOverride( cwd = process.cwd() ): PersonaSpec { const context = `standalone persona "${override.id}"`; - const harness = requireStandaloneField(override.harness, `${context}.harness`); - if (!HARNESS_VALUES.includes(harness)) { + // A handler agent is driven by its `onEvent` entry, not by an operator at a + // prompt, so the fields configuring an interactive launch are optional here. + // Requiring them made agents that ship a handler invisible to the cascade: + // the `agents/` directory added for exactly those agents could not load + // them, and the error read as though the persona were malformed. + const isHandler = typeof override.onEvent === 'string' && override.onEvent.trim() !== ''; + + const harness = isHandler + ? override.harness + : requireStandaloneField(override.harness, `${context}.harness`); + if (harness !== undefined && !HARNESS_VALUES.includes(harness)) { throw new Error(`${context}.harness must be one of: ${HARNESS_VALUES.join(', ')}`); } - const model = requireStandaloneField(override.model, `${context}.model`); - if (typeof model !== 'string' || !model.trim()) { + const model = isHandler + ? override.model + : requireStandaloneField(override.model, `${context}.model`); + if (model !== undefined && (typeof model !== 'string' || !model.trim())) { throw new Error(`${context}.model must be a non-empty string`); } const fallbackSystemPrompt = override.claudeMdContent ?? override.agentsMdContent; @@ -832,9 +883,12 @@ function standaloneSpecFromOverride( typeof override.systemPrompt === 'string' && override.systemPrompt.trim() ? override.systemPrompt : fallbackSystemPrompt; - if (typeof systemPrompt !== 'string' || !systemPrompt.trim()) { + if (!isHandler && (typeof systemPrompt !== 'string' || !systemPrompt.trim())) { throw new Error(`${context}.systemPrompt must be a non-empty string`); } + // `harnessSettings` stays required even for a handler: `PersonaSpec` types it + // non-optional, and `reasoning`/`timeoutSeconds` have no defensible default to + // invent on the persona's behalf. Every shipped handler example declares it. const settingsRaw = override.harnessSettings; if (!settingsRaw || !isPlainObject(settingsRaw)) { throw new Error(`${context}.harnessSettings must be an object`); @@ -887,9 +941,11 @@ function standaloneSpecFromOverride( cwd ), ...(inputs ? { inputs } : {}), - harness, - model, - systemPrompt, + ...(override.onEvent !== undefined ? { onEvent: override.onEvent } : {}), + ...(override.cloud !== undefined ? { cloud: override.cloud } : {}), + ...(harness !== undefined ? { harness } : {}), + ...(model !== undefined ? { model } : {}), + ...(systemPrompt !== undefined ? { systemPrompt } : {}), harnessSettings, ...(env ? { env } : {}), ...(mcpServers ? { mcpServers } : {}), @@ -1110,6 +1166,10 @@ function mergeOverride( const harness = override.harness ?? base.harness; const model = override.model ?? base.model; const systemPrompt = override.systemPrompt ?? base.systemPrompt; + // An overlay that only tweaks env must not strip the handler entry that + // makes the base a deployable agent. + const onEvent = override.onEvent ?? base.onEvent; + const cloud = override.cloud ?? base.cloud; const harnessSettings: HarnessSettings = parseHarnessSettings({ ...base.harnessSettings, ...(override.harnessSettings ?? {}) @@ -1188,9 +1248,11 @@ function mergeOverride( description: override.description ?? base.description, skills, ...(inputs ? { inputs } : {}), - harness, - model, - systemPrompt, + ...(onEvent !== undefined ? { onEvent } : {}), + ...(cloud !== undefined ? { cloud } : {}), + ...(harness !== undefined ? { harness } : {}), + ...(model !== undefined ? { model } : {}), + ...(systemPrompt !== undefined ? { systemPrompt } : {}), harnessSettings, ...(env ? { env } : {}), ...(mcpServers ? { mcpServers } : {}), From c950a196d4f11c5fe43e67319275aef892b340a3 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 20 Aug 2026 13:05:17 -0400 Subject: [PATCH 2/7] fix(persona-registry): validate onEvent with persona-kit's own rule Hand-rolling the guard drifted from persona-kit twice over. It validated the raw string and stored a trimmed copy, so `" ../x/agent.ts "` cleared the `..` check as the segment `" .."` and escaped the agent directory once trimmed. And it never checked the handler extension, so `onEvent: "README.md"` counted as a handler and skipped the interactive fields the persona never declared. `parseOnEvent` owns both rules and returns the exact string it validated, so the stored value cannot differ from the one that passed. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/local-personas.test.ts | 36 +++++++++++++++++ .../persona-registry/src/local-personas.ts | 39 +++++-------------- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/local-personas.test.ts b/packages/cli/src/local-personas.test.ts index 12b00dcf..50112479 100644 --- a/packages/cli/src/local-personas.test.ts +++ b/packages/cli/src/local-personas.test.ts @@ -1119,3 +1119,39 @@ test('onEvent may not escape the agent directory', () => { assert.match(loaded.warnings[0] ?? '', /onEvent must not contain "\.\." segments/); }); }); + +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); + assert.ok((loaded.warnings[0] ?? '').includes('onEvent'), loaded.warnings[0]); + }); +}); diff --git a/packages/persona-registry/src/local-personas.ts b/packages/persona-registry/src/local-personas.ts index 8b556c30..e191a0df 100644 --- a/packages/persona-registry/src/local-personas.ts +++ b/packages/persona-registry/src/local-personas.ts @@ -21,7 +21,8 @@ import { type PersonaTag, type SidecarMdMode, parseHarnessSettings, - parseInputs + parseInputs, + parseOnEvent } from '@agentworkforce/persona-kit'; import { listBuiltInPersonas, personaCatalog } from '@agentworkforce/workload-router'; @@ -501,27 +502,6 @@ function isPlainObject(value: unknown): value is Record { * requirement called out in the schema. Throws on absolute paths, * `..` segments, empty strings, or non-`.md` extensions. */ -/** - * Relative-path guard shared by `onEvent` and, with an added `.md` rule, by - * sidecars. A handler that escaped its agent directory would bundle files the - * persona does not own. - */ -function assertSafeRelativePath(value: unknown, context: string): void { - if (typeof value !== 'string' || !value.trim()) { - throw new Error(`${context} must be a non-empty string`); - } - if ( - value.startsWith('/') || - value.startsWith('\\') || - /^[A-Za-z]:/.test(value) - ) { - throw new Error(`${context} must be a relative path; got absolute "${value}"`); - } - if (value.split(/[\\/]+/).some((segment) => segment === '..')) { - throw new Error(`${context} must not contain ".." segments`); - } -} - function assertSidecarPath(value: unknown, context: string): void { if (typeof value !== 'string' || !value.trim()) { throw new Error(`${context} must be a non-empty string`); @@ -617,12 +597,13 @@ function parseOverride(value: unknown, context: string): LocalPersonaOverride { `${context}.defaultTier is no longer supported (tiers have been removed)` ); } - if (raw.onEvent !== undefined) { - if (typeof raw.onEvent !== 'string' || !raw.onEvent.trim()) { - throw new Error(`${context}.onEvent must be a non-empty string if provided`); - } - assertSafeRelativePath(raw.onEvent, `${context}.onEvent`); - } + // Delegate to persona-kit rather than re-deriving the rule: it owns the + // containment guard AND the handler-extension check, and it returns the + // exact string it validated, so the stored value can never differ from the + // one that passed. A locally trimmed copy would let " ../x/agent.ts " clear + // a `..` check as the segment " .." and then escape once trimmed. + const onEventValue = + raw.onEvent === undefined ? undefined : parseOnEvent(raw.onEvent, `${context}.onEvent`); if (raw.cloud !== undefined && typeof raw.cloud !== 'boolean') { throw new Error(`${context}.cloud must be a boolean if provided`); } @@ -669,7 +650,7 @@ function parseOverride(value: unknown, context: string): LocalPersonaOverride { mount: raw.mount as LocalPersonaOverride['mount'], permissions: raw.permissions as LocalPersonaOverride['permissions'], systemPrompt: raw.systemPrompt as string | undefined, - ...(raw.onEvent !== undefined ? { onEvent: (raw.onEvent as string).trim() } : {}), + ...(onEventValue !== undefined ? { onEvent: onEventValue } : {}), ...(raw.cloud !== undefined ? { cloud: raw.cloud as boolean } : {}), ...(raw.harness !== undefined ? { harness: raw.harness as Harness } : {}), ...(raw.model !== undefined ? { model: raw.model as string } : {}), From 41b059b941492dc44d686519bd5482d5284001b1 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 20 Aug 2026 13:24:38 -0400 Subject: [PATCH 3/7] fix(persona-registry): normalize onEvent before validating it Order matters in both directions. Validating the raw string and storing a trimmed copy let `" ../x/agent.ts "` clear the `..` check as the segment `" .."` and escape once trimmed. Validating without trimming stored `" ./agent.ts"`, which passes every check and then resolves against a directory named `" ."` at deploy. Trimming before `parseOnEvent` makes the validated value and the stored value the same string. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/local-personas.test.ts | 21 ++++++++++++++++++- .../persona-registry/src/local-personas.ts | 20 ++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/local-personas.test.ts b/packages/cli/src/local-personas.test.ts index 50112479..a28bb7e3 100644 --- a/packages/cli/src/local-personas.test.ts +++ b/packages/cli/src/local-personas.test.ts @@ -1120,6 +1120,24 @@ test('onEvent may not escape the agent directory', () => { }); }); +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'); @@ -1152,6 +1170,7 @@ test('a padded onEvent cannot smuggle a .. segment past the guard', () => { }); const loaded = loadLocalPersonas({ cwd, homeDir }); assert.equal(loaded.byId.has('digest'), false); - assert.ok((loaded.warnings[0] ?? '').includes('onEvent'), loaded.warnings[0]); + // Trimmed before validation, so the traversal guard sees the real path. + assert.match(loaded.warnings[0] ?? '', /onEvent must not contain "\.\." segments/); }); }); diff --git a/packages/persona-registry/src/local-personas.ts b/packages/persona-registry/src/local-personas.ts index e191a0df..b9399471 100644 --- a/packages/persona-registry/src/local-personas.ts +++ b/packages/persona-registry/src/local-personas.ts @@ -597,13 +597,21 @@ function parseOverride(value: unknown, context: string): LocalPersonaOverride { `${context}.defaultTier is no longer supported (tiers have been removed)` ); } - // Delegate to persona-kit rather than re-deriving the rule: it owns the - // containment guard AND the handler-extension check, and it returns the - // exact string it validated, so the stored value can never differ from the - // one that passed. A locally trimmed copy would let " ../x/agent.ts " clear - // a `..` check as the segment " .." and then escape once trimmed. + // Normalize first, then delegate to persona-kit, which owns both the + // containment guard and the handler-extension check. Order matters in both + // directions: validating the raw string and storing a trimmed copy lets + // " ../x/agent.ts " clear the `..` check as the segment " .." and escape + // once trimmed, while validating without trimming stores " ./agent.ts", + // which passes every check and then resolves to a directory named " ." + // at deploy. Trimming up front makes the validated and stored value one + // and the same. const onEventValue = - raw.onEvent === undefined ? undefined : parseOnEvent(raw.onEvent, `${context}.onEvent`); + raw.onEvent === undefined + ? undefined + : parseOnEvent( + typeof raw.onEvent === 'string' ? raw.onEvent.trim() : raw.onEvent, + `${context}.onEvent` + ); if (raw.cloud !== undefined && typeof raw.cloud !== 'boolean') { throw new Error(`${context}.cloud must be a boolean if provided`); } From 508302f758b959e2983b7b5b7679b20b927deda4 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 20 Aug 2026 12:28:29 -0400 Subject: [PATCH 4/7] feat(persona-registry): warn when an agent's persona.json is stale An agent directory whose `persona.json` is older than its `persona.ts` loads the compiled spec with none of the edits sitting in the authoring file, and nothing about the result looks wrong. #316 warned about a persona that was never compiled; this covers the quieter case where it was compiled once. The persona is still served. Dropping it would turn a forgotten compile into a missing persona, which is a worse failure than an out-of-date one. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/local-personas.test.ts | 43 ++++++++++++++++++- .../persona-registry/src/local-personas.ts | 37 +++++++++++++--- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/local-personas.test.ts b/packages/cli/src/local-personas.test.ts index a28bb7e3..c12c9bb5 100644 --- a/packages/cli/src/local-personas.test.ts +++ b/packages/cli/src/local-personas.test.ts @@ -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'; @@ -1174,3 +1174,44 @@ test('a padded onEvent cannot smuggle a .. segment past the guard', () => { 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')); + }); +}); diff --git a/packages/persona-registry/src/local-personas.ts b/packages/persona-registry/src/local-personas.ts index b9399471..f5cdd1b0 100644 --- a/packages/persona-registry/src/local-personas.ts +++ b/packages/persona-registry/src/local-personas.ts @@ -429,18 +429,31 @@ function readNestedLayerEntries( for (const name of names) { const sourceDir = join(dir, name); const path = join(sourceDir, NESTED_PERSONA_FILENAME); - if (isFile(path)) { - entries.push({ label: `${name}/${NESTED_PERSONA_FILENAME}`, path, sourceDir }); + const authored = NESTED_PERSONA_SOURCE_FILENAMES.map((file) => join(sourceDir, file)).find( + (candidate) => fileMtimeMs(candidate) !== undefined + ); + const compiledAt = fileMtimeMs(path); + + if (compiledAt === undefined) { + if (authored) { + warnings.push( + `[${layer.source}] ${name}: ${basename(authored)} has no compiled ${NESTED_PERSONA_FILENAME}; run \`agentworkforce persona compile ${authored}\` to make it loadable.` + ); + } continue; } - const authored = NESTED_PERSONA_SOURCE_FILENAMES.find((file) => - isFile(join(sourceDir, file)) - ); - if (authored) { + + // A stale artifact is the quieter failure: the persona still loads, so + // nothing looks wrong while the edits sitting in the authoring file are + // simply absent. Say so, and keep serving the compiled spec — dropping it + // would turn a forgotten compile into a missing persona. + const authoredAt = authored === undefined ? undefined : fileMtimeMs(authored); + if (authored !== undefined && authoredAt !== undefined && authoredAt > compiledAt) { warnings.push( - `[${layer.source}] ${name}: ${authored} has no compiled ${NESTED_PERSONA_FILENAME}; run \`agentworkforce persona compile ${join(sourceDir, authored)}\` to make it loadable.` + `[${layer.source}] ${name}: ${NESTED_PERSONA_FILENAME} is older than ${basename(authored)}, so this persona is loading without the latest edits; re-run \`agentworkforce persona compile ${authored}\`.` ); } + entries.push({ label: `${name}/${NESTED_PERSONA_FILENAME}`, path, sourceDir }); } return entries; } @@ -1057,6 +1070,16 @@ function isFile(path: string): boolean { } } +/** Modification time of a regular file, or undefined if it is not one. */ +function fileMtimeMs(path: string): number | undefined { + try { + const st = statSync(path); + return st.isFile() ? st.mtimeMs : undefined; + } catch { + return undefined; + } +} + /** * Resolve relative local skill sources (`./skills/foo.md`) declared by an * override against the directory of the JSON file that declared them, the From 3425fa1d9058d2319acf0a4b71eb0b5c11f57d5b Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 20 Aug 2026 12:41:49 -0400 Subject: [PATCH 5/7] fix(persona-registry): measure staleness against the newest authoring file A directory that still carries an abandoned `persona.ts` after development moved to `persona.js` was measured against the file nobody edits, so a `persona.json` newer than the dead source but older than the live one read as fresh. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/local-personas.test.ts | 21 +++++++++++++++++++ .../persona-registry/src/local-personas.ts | 15 +++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/local-personas.test.ts b/packages/cli/src/local-personas.test.ts index c12c9bb5..04af7d80 100644 --- a/packages/cli/src/local-personas.test.ts +++ b/packages/cli/src/local-personas.test.ts @@ -1215,3 +1215,24 @@ test('a persona.json newer than its authoring source is silent', () => { 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/); + }); +}); diff --git a/packages/persona-registry/src/local-personas.ts b/packages/persona-registry/src/local-personas.ts index f5cdd1b0..e7c61910 100644 --- a/packages/persona-registry/src/local-personas.ts +++ b/packages/persona-registry/src/local-personas.ts @@ -429,9 +429,16 @@ function readNestedLayerEntries( for (const name of names) { const sourceDir = join(dir, name); const path = join(sourceDir, NESTED_PERSONA_FILENAME); - const authored = NESTED_PERSONA_SOURCE_FILENAMES.map((file) => join(sourceDir, file)).find( - (candidate) => fileMtimeMs(candidate) !== undefined - ); + // Compare against the NEWEST authoring file, not the first one that + // exists: a directory that still carries an old persona.ts after moving to + // persona.js would otherwise be measured against the file nobody edits. + const authoredCandidate = NESTED_PERSONA_SOURCE_FILENAMES.map((file) => ({ + file: join(sourceDir, file), + mtimeMs: fileMtimeMs(join(sourceDir, file)) + })) + .filter((candidate): candidate is { file: string; mtimeMs: number } => candidate.mtimeMs !== undefined) + .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]; + const authored = authoredCandidate?.file; const compiledAt = fileMtimeMs(path); if (compiledAt === undefined) { @@ -447,7 +454,7 @@ function readNestedLayerEntries( // nothing looks wrong while the edits sitting in the authoring file are // simply absent. Say so, and keep serving the compiled spec — dropping it // would turn a forgotten compile into a missing persona. - const authoredAt = authored === undefined ? undefined : fileMtimeMs(authored); + const authoredAt = authoredCandidate?.mtimeMs; if (authored !== undefined && authoredAt !== undefined && authoredAt > compiledAt) { warnings.push( `[${layer.source}] ${name}: ${NESTED_PERSONA_FILENAME} is older than ${basename(authored)}, so this persona is loading without the latest edits; re-run \`agentworkforce persona compile ${authored}\`.` From 9f7631d9645b23de5e68828061b5be033a4356fe Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 20 Aug 2026 12:34:02 -0400 Subject: [PATCH 6/7] feat(persona-registry): load personal agents from the workforce home `~/.agentworkforce/workforce/agents//persona.json` is a cascade layer, the personal mirror of the `cwd:agents` layer added in #316. An agent that ships its own handler is available in every repo instead of only the one it was checked into. It rides directly behind the personal personas dir rather than at a fixed depth, so moving that dir in the cascade moves the pair together. A repo agent outranks a personal one of the same id. The directory is derived from wherever the personal personas dir resolves, so AGENT_WORKFORCE_CONFIG_DIR and embedder overrides carry it along instead of silently reading the developer's real home. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/README.md | 14 +++- packages/cli/src/local-personas.test.ts | 76 +++++++++++++++++++ packages/persona-registry/README.md | 2 +- .../persona-registry/src/local-personas.ts | 46 +++++++++-- 4 files changed, 130 insertions(+), 8 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 87ed4e0e..d2c5c5ad 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -379,7 +379,9 @@ agentworkforce sources remove Two fixed project sources always lead the cascade: `/.agentworkforce/workforce/personas/*.json`, then -`/.agentworkforce/workforce/agents//persona.json`. +`/.agentworkforce/workforce/agents//persona.json`. A third fixed +source, `~/.agentworkforce/workforce/agents//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 @@ -477,13 +479,21 @@ wins): 2. `/.agentworkforce/workforce/agents//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//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. +5. Internal built-in system personas in `/personas/` — **library** 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//` 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: diff --git a/packages/cli/src/local-personas.test.ts b/packages/cli/src/local-personas.test.ts index 04af7d80..a3506d70 100644 --- a/packages/cli/src/local-personas.test.ts +++ b/packages/cli/src/local-personas.test.ts @@ -1236,3 +1236,79 @@ test('staleness is measured against the newest authoring file, not the first', ( 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, []); + }); +}); diff --git a/packages/persona-registry/README.md b/packages/persona-registry/README.md index 70ee3ee0..32486cac 100644 --- a/packages/persona-registry/README.md +++ b/packages/persona-registry/README.md @@ -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'; diff --git a/packages/persona-registry/src/local-personas.ts b/packages/persona-registry/src/local-personas.ts index e7c61910..89da5847 100644 --- a/packages/persona-registry/src/local-personas.ts +++ b/packages/persona-registry/src/local-personas.ts @@ -112,6 +112,9 @@ 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. + * - `user:agents` → `personal:agents` — the `agents/` sibling of the personal + * personas dir; personal agents-with-handlers, available in + * any repo. Renamed to follow `user` → `personal`. * - `dir:N` → `dir:N` — extra configurable persona dirs (passed * through unchanged so position is still legible). * @@ -121,6 +124,7 @@ export type PersonaSource = string; export function formatPersonaSourceLabel(source: PersonaSource): string { if (source === 'library') return 'built-in'; if (source === 'user') return 'personal'; + if (source === 'user:agents') return 'personal:agents'; return source; } @@ -221,6 +225,16 @@ export function defaultCwdAgentDir(cwd: string): string { return join(cwd, '.agentworkforce', 'workforce', 'agents'); } +/** + * Personal counterpart to {@link defaultCwdAgentDir}: agents-with-handlers a + * user keeps across every repo. Derived from wherever their personal personas + * live so the pair travels together, including under + * `AGENT_WORKFORCE_CONFIG_DIR` and test overrides. + */ +export function userAgentDirFor(userPersonaDir: string): string { + return join(dirname(userPersonaDir), 'agents'); +} + /** Persona filename read from each subdirectory of a nested source dir. */ export const NESTED_PERSONA_FILENAME = 'persona.json'; @@ -388,15 +402,37 @@ export function buildPersonaSourceDirectories( configurable: false, nested: true }, - ...config.personaDirs.map((dir, idx) => ({ - source: sourceForPersonaDir(dir, idx, config.userPersonaDir), - dir, - configurable: true - })) + ...config.personaDirs.flatMap((dir, idx) => { + const entry: PersonaSourceDirectory = { + source: sourceForPersonaDir(dir, idx, config.userPersonaDir), + dir, + configurable: true + }; + // The personal agents dir rides directly behind the personal personas + // dir, the same way cwd:agents rides behind cwd, so the pair keeps its + // relative rank wherever the user has moved it in the cascade. + return dir === config.userPersonaDir + ? [entry, userAgentSourceDir(config.userPersonaDir)] + : [entry]; + }), + // A user who dropped their personal personas dir from the cascade still + // gets their personal agents, ranked last among file layers. + ...(config.personaDirs.includes(config.userPersonaDir) + ? [] + : [userAgentSourceDir(config.userPersonaDir)]) ]; return { directories, config }; } +function userAgentSourceDir(userPersonaDir: string): PersonaSourceDirectory { + return { + source: 'user:agents', + dir: userAgentDirFor(userPersonaDir), + configurable: false, + nested: true + }; +} + /** One persona file to read, with the directory its relative paths resolve against. */ interface LayerEntry { /** Path relative to the layer dir, used in warnings. */ From 91c03cd0988da24530698851309ee88c89922f0d Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 20 Aug 2026 12:54:27 -0400 Subject: [PATCH 7/7] docs(cli): name the personal:agents display label alongside user:agents The cascade list documented only the internal source string, so a reader who then ran `sources list` saw a label the docs never mention. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index d2c5c5ad..adf4bd69 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -481,7 +481,9 @@ wins): `~/.agentworkforce/workforce/personas/*.json` — **user** 4. `~/.agentworkforce/workforce/agents//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. + 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** Local files are **partial overlays**: only the fields you set replace the