diff --git a/examples/review-agent/persona.json b/examples/review-agent/persona.json index 4c69399b..325ec883 100644 --- a/examples/review-agent/persona.json +++ b/examples/review-agent/persona.json @@ -1,26 +1,45 @@ { "id": "review-agent", "intent": "review", - "tags": ["review"], + "tags": [ + "review" + ], "description": "Reviews opened PRs, responds to @mentions in comments, attempts autofix on red CI.", "cloud": true, "useSubscription": true, "integrations": { "github": { "triggers": [ - { "on": "pull_request.opened" }, - { "on": "issue_comment.created", "match": "@mention" }, - { "on": "pull_request_review_comment.created", "match": "@mention" }, - { "on": "check_run.completed", "where": "conclusion=failure" } + { + "on": "pull_request.opened" + }, + { + "on": "issue_comment.created", + "match": "@mention" + }, + { + "on": "pull_request_review_comment.created", + "match": "@mention" + }, + { + "on": "check_run.completed", + "where": "conclusion=failure" + } ] }, "slack": { - "triggers": [{ "on": "app_mention" }] + "triggers": [ + { + "on": "app_mention" + } + ] } }, "memory": { "enabled": true, - "scopes": ["workspace"] + "scopes": [ + "workspace" + ] }, "onEvent": "./agent.ts", "harness": "codex", diff --git a/examples/review-agent/persona.ts b/examples/review-agent/persona.ts new file mode 100644 index 00000000..4389ebf0 --- /dev/null +++ b/examples/review-agent/persona.ts @@ -0,0 +1,39 @@ +import { definePersona } from '@agentworkforce/persona-kit'; + +export default definePersona({ + id: 'review-agent', + intent: 'review', + tags: ['review'], + description: + 'Reviews opened PRs, responds to @mentions in comments, attempts autofix on red CI.', + cloud: true, + useSubscription: true, + integrations: { + github: { + triggers: [ + { on: 'pull_request.opened' }, + { on: 'issue_comment.created', match: '@mention' }, + { on: 'pull_request_review_comment.created', match: '@mention' }, + { on: 'check_run.completed', where: 'conclusion=failure' } + ] + }, + slack: { + triggers: [{ on: 'app_mention' }] + } + }, + memory: { + enabled: true, + scopes: ['workspace'] + }, + onEvent: './agent.ts', + harness: 'codex', + model: 'gpt-5.4', + systemPrompt: + 'Review pull requests for correctness, regression risk, security concerns, and missing tests. Be concise and concrete.', + harnessSettings: { + reasoning: 'medium', + timeoutSeconds: 1200, + sandboxMode: 'workspace-write', + workspaceWriteNetworkAccess: true + } +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 4a756d62..d94f250a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,6 +16,7 @@ "@agentworkforce/workload-router": "workspace:*", "@relayburn/sdk": "^2.5.2", "@relayfile/local-mount": "^0.7.24", + "esbuild": "^0.25.0", "ora": "^9.4.0" }, "repository": { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 646d7397..e5e6eddb 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -87,6 +87,7 @@ import { type PersonaSource } from './local-personas.js'; import { installPersonas, type PersonaInstallResult } from './persona-install.js'; +import { runPersonaCompileCommand } from './persona-compile.js'; import { pickPersona, type PickCandidate, type PickResult } from './persona-picker.js'; import { recordRecent, loadRecents, runPersonaPickerTui, type TuiCandidate } from './persona-tui.js'; @@ -191,6 +192,9 @@ Commands: including which cascade layer defined it (cwd, user, dir:, library). Flags: --json emit the resolved PersonaSpec as JSON + persona compile + Compile a typed persona.ts authoring file to sibling + persona.json after validating it with persona-kit. install [flags] Copy persona JSON files from an npm package or local package directory into @@ -4268,6 +4272,15 @@ export async function main(): Promise { runShow(rest); } + if (subcommand === 'persona') { + try { + await runPersonaCompileCommand(rest); + return; + } catch (err) { + die((err as Error)?.message ?? String(err), false); + } + } + if (subcommand === 'install') { runPersonaInstall(rest); } diff --git a/packages/cli/src/persona-compile.test.ts b/packages/cli/src/persona-compile.test.ts new file mode 100644 index 00000000..cf11d344 --- /dev/null +++ b/packages/cli/src/persona-compile.test.ts @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { compilePersonaFile } from './persona-compile.js'; + +test('compilePersonaFile bundles persona.ts, validates it, and writes persona.json', async () => { + const root = mkdtempSync(join(tmpdir(), 'aw-persona-compile-')); + try { + const inputPath = join(root, 'persona.ts'); + const outputPath = join(root, 'persona.json'); + writeFileSync( + inputPath, + `import { definePersona } from '@agentworkforce/persona-kit'; + +export default definePersona({ + id: 'compiled-persona', + intent: 'review', + description: 'Compiled persona fixture.', + inputs: { + TARGET: 'repo' + }, + integrations: { + github: { + triggers: [ + { on: 'pull_request.opened' }, + { on: 'off_registry.github_event' } + ] + }, + linear: { triggers: [{ on: 'issue.updated' }] }, + slack: { triggers: [{ on: 'message.channels' }] }, + notion: { triggers: [{ on: 'page.created' }] }, + jira: { triggers: [{ on: 'issue.created' }] }, + unknown: { triggers: [{ on: 'whatever.happened' }] } + }, + onEvent: './agent.ts', + harnessSettings: { + reasoning: 'medium', + timeoutSeconds: 60 + } +}); +`, + 'utf8' + ); + + const result = await compilePersonaFile(inputPath); + const compiled = JSON.parse(readFileSync(outputPath, 'utf8')) as { + id: string; + inputs?: Record; + integrations?: Record }>; + }; + + assert.equal(result.personaId, 'compiled-persona'); + assert.equal(result.outputPath, outputPath); + assert.equal(compiled.id, 'compiled-persona'); + assert.equal(compiled.inputs?.TARGET, 'repo'); + assert.equal( + compiled.integrations?.github.triggers?.[1].on, + 'off_registry.github_event' + ); + assert.equal(compiled.integrations?.unknown.triggers?.[0].on, 'whatever.happened'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('compilePersonaFile fails loudly when validation rejects the authored spec', async () => { + const root = mkdtempSync(join(tmpdir(), 'aw-persona-compile-invalid-')); + try { + const inputPath = join(root, 'persona.ts'); + writeFileSync( + inputPath, + `export default { + id: 'bad', + intent: 'review', + description: 'Bad persona fixture.', + harnessSettings: { reasoning: 'turbo', timeoutSeconds: 60 }, + onEvent: './agent.ts' +}; +`, + 'utf8' + ); + + await assert.rejects( + () => compilePersonaFile(inputPath), + /harnessSettings\.reasoning must be low\|medium\|high/ + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/persona-compile.ts b/packages/cli/src/persona-compile.ts new file mode 100644 index 00000000..df4e5fc0 --- /dev/null +++ b/packages/cli/src/persona-compile.ts @@ -0,0 +1,117 @@ +import { builtinModules } from 'node:module'; +import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { isIntent, isObject, parsePersonaSpec } from '@agentworkforce/persona-kit'; +import { build } from 'esbuild'; + +export interface PersonaCompileResult { + inputPath: string; + outputPath: string; + personaId: string; +} + +const NODE_EXTERNALS = [ + ...builtinModules, + ...builtinModules.map((name) => `node:${name}`), + 'node:*' +]; + +export async function compilePersonaFile( + inputPath: string, + outputPath?: string +): Promise { + const absInput = resolve(inputPath); + await assertReadableFile(absInput, 'persona compile input'); + + const absOutput = resolve(outputPath ?? join(dirname(absInput), 'persona.json')); + const tempDir = await mkdtemp(join(tmpdir(), 'agentworkforce-persona-compile-')); + const bundledPath = join(tempDir, `${basename(absInput).replace(/\W+/g, '-')}.mjs`); + + try { + await build({ + entryPoints: [absInput], + outfile: bundledPath, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + sourcemap: 'inline', + logLevel: 'silent', + external: NODE_EXTERNALS, + resolveExtensions: ['.ts', '.mts', '.cts', '.tsx', '.js', '.mjs', '.cjs', '.jsx', '.json'], + nodePaths: packageNodePaths() + }); + + const mod = await import(pathToFileURL(bundledPath).href); + const spec = mod.default as unknown; + if (!isObject(spec)) { + throw new Error('persona compile: default export must be a persona object'); + } + if (typeof spec.intent !== 'string') { + throw new Error('persona compile: default export must include a string intent'); + } + if (!isIntent(spec.intent)) { + throw new Error(`persona compile: intent "${spec.intent}" is invalid`); + } + const parsed = parsePersonaSpec(spec, spec.intent); + + await mkdir(dirname(absOutput), { recursive: true }); + await writeFile(absOutput, JSON.stringify(spec, null, 2) + '\n', 'utf8'); + + return { + inputPath: absInput, + outputPath: absOutput, + personaId: parsed.id + }; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +} + +export async function runPersonaCompileCommand(args: string[]): Promise { + const [action, personaPath, ...rest] = args; + if (!action || action === '-h' || action === '--help') { + process.stdout.write('Usage: agentworkforce persona compile \n'); + process.exit(action ? 0 : 1); + } + if (action !== 'compile') { + throw new Error(`persona: unknown action "${action}". Expected: compile`); + } + if (!personaPath) { + throw new Error('persona compile: missing '); + } + if (rest.length > 0) { + throw new Error(`persona compile: unexpected argument "${rest[0]}"`); + } + + const result = await compilePersonaFile(personaPath); + process.stdout.write( + `Compiled ${result.inputPath} -> ${result.outputPath} (${result.personaId})\n` + ); +} + +async function assertReadableFile(abs: string, label: string): Promise { + try { + const st = await stat(abs); + if (!st.isFile()) { + throw new Error(`${label}: ${abs} is not a regular file`); + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`${label}: file not found at ${abs}`); + } + throw err; + } +} + +function packageNodePaths(): string[] { + const here = dirname(fileURLToPath(import.meta.url)); + return [ + join(here, '..', 'node_modules'), + join(here, '..', '..', 'node_modules'), + join(process.cwd(), 'node_modules') + ]; +} diff --git a/packages/persona-kit/src/define.test.ts b/packages/persona-kit/src/define.test.ts new file mode 100644 index 00000000..38a1b276 --- /dev/null +++ b/packages/persona-kit/src/define.test.ts @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { definePersona, parsePersonaSpec } from './index.js'; + +test('definePersona returns authored specs that parse successfully', () => { + const persona = definePersona({ + id: 'typed-author', + intent: 'review', + description: 'Typed authoring fixture.', + inputs: { + TOPIC: 'pull requests', + TARGET_REPO: { + description: 'Repository to inspect.', + default: 'AgentWorkforce/workforce' + } + }, + integrations: { + github: { + triggers: [ + { on: 'pull_request.opened' }, + { on: 'off_registry.github_event' } + ] + }, + linear: { triggers: [{ on: 'issue.created' }] }, + slack: { triggers: [{ on: 'app_mention' }] }, + notion: { triggers: [{ on: 'page.updated' }] }, + jira: { triggers: [{ on: 'comment.created' }] }, + customProvider: { triggers: [{ on: 'custom.event' }] } + }, + onEvent: './agent.ts', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } + }); + + const parsed = parsePersonaSpec(persona, 'review'); + + assert.equal(parsed.id, 'typed-author'); + assert.equal(parsed.skills.length, 0); + assert.equal(parsed.inputs?.TOPIC.default, 'pull requests'); + assert.equal( + parsed.integrations?.github.triggers?.[1].on, + 'off_registry.github_event' + ); + assert.equal(parsed.integrations?.customProvider.triggers?.[0].on, 'custom.event'); +}); + +test('definePersona type allows interactive personas without onEvent', () => { + const persona = definePersona({ + id: 'interactive-author', + intent: 'documentation', + description: 'Interactive authoring fixture.', + harness: 'codex', + model: 'gpt-5.4', + systemPrompt: 'Write accurate docs.', + harnessSettings: { reasoning: 'low', timeoutSeconds: 120 } + }); + + assert.equal(persona.harness, 'codex'); +}); diff --git a/packages/persona-kit/src/define.ts b/packages/persona-kit/src/define.ts new file mode 100644 index 00000000..87ef435f --- /dev/null +++ b/packages/persona-kit/src/define.ts @@ -0,0 +1,85 @@ +import type { + Harness, + HarnessSettings, + IntegrationSource, + McpServerSpec, + PersonaInputSpec, + PersonaMemory, + PersonaMount, + PersonaPermissions, + PersonaSchedule, + PersonaSkill, + SidecarMdMode, + WatchRule +} from './types.js'; +import type { KnownProviderName, KnownTriggerName } from './triggers.js'; + +export type TriggerNameFor

= P extends KnownProviderName + ? KnownTriggerName

| (string & {}) + : string; + +export interface TypedTrigger

{ + on: TriggerNameFor

; + match?: string; + where?: string; +} + +export interface TypedIntegrationConfig

{ + source?: IntegrationSource; + scope?: Record; + triggers?: readonly TypedTrigger

[]; +} + +export type TypedIntegrations = { + [P in KnownProviderName]?: TypedIntegrationConfig

; +} & { + [provider: string]: TypedIntegrationConfig | undefined; +}; + +export interface PersonaDefinitionBase { + id: string; + intent: string; + tags?: readonly string[]; + description: string; + skills?: readonly PersonaSkill[]; + inputs?: Record; + harnessSettings: HarnessSettings; + env?: Record; + mcpServers?: Record; + permissions?: PersonaPermissions; + mount?: PersonaMount; + claudeMd?: string; + claudeMdMode?: SidecarMdMode; + agentsMd?: string; + agentsMdMode?: SidecarMdMode; + claudeMdContent?: string; + agentsMdContent?: string; + cloud?: boolean; + useSubscription?: boolean; + integrations?: TypedIntegrations; + schedules?: readonly PersonaSchedule[]; + watch?: readonly WatchRule[]; + memory?: PersonaMemory; +} + +type InteractivePersonaDefinition = PersonaDefinitionBase & { + onEvent?: undefined; + harness: Harness; + model: string; + systemPrompt: string; +}; + +type HandlerPersonaDefinition = PersonaDefinitionBase & { + onEvent: string; + harness?: Harness; + model?: string; + systemPrompt?: string; +}; + +export type PersonaDefinition = + | InteractivePersonaDefinition + | HandlerPersonaDefinition; + +export function definePersona(input: T): T { + return input; +} diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index 34e08b38..557b1686 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -47,6 +47,16 @@ export type { WatchRule } from './types.js'; +// Typed persona authoring +export { + definePersona, + type PersonaDefinition, + type TriggerNameFor, + type TypedIntegrationConfig, + type TypedIntegrations, + type TypedTrigger +} from './define.js'; + // Parsers + sidecar resolver export { assertInputName, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 521fc70d..d471abf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@relayfile/local-mount': specifier: ^0.7.24 version: 0.7.24 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 ora: specifier: ^9.4.0 version: 9.4.0