diff --git a/packages/persona-kit/package.json b/packages/persona-kit/package.json index 41bd7930..7eb69f32 100644 --- a/packages/persona-kit/package.json +++ b/packages/persona-kit/package.json @@ -10,10 +10,12 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./schemas/persona.schema.json": "./schemas/persona.schema.json" }, "files": [ "dist", + "schemas", "README.md", "package.json" ], @@ -26,13 +28,17 @@ "access": "public" }, "scripts": { - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && npm run emit:schema", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "tsc -p tsconfig.json && node --test dist/*.test.js", + "emit:schema": "node ./scripts/emit-schema.mjs", "lint": "tsc -p tsconfig.json --noEmit" }, "dependencies": { "@relayfile/local-mount": "^0.7.0" + }, + "devDependencies": { + "ts-json-schema-generator": "^2.3.0" } } diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json new file mode 100644 index 00000000..1e2b5e1a --- /dev/null +++ b/packages/persona-kit/schemas/persona.schema.json @@ -0,0 +1,500 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/definitions/PersonaSpec", + "definitions": { + "PersonaSpec": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "intent": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/PersonaTag" + }, + "description": "Free-form classification labels (from {@link PERSONA_TAGS } ). Every persona has at least one; a persona may carry multiple tags when it spans concerns (e.g. `['testing', 'implementation']`)." + }, + "description": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "$ref": "#/definitions/PersonaSkill" + } + }, + "inputs": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PersonaInputSpec" + }, + "description": "Prompt-visible runtime inputs. Keys must be env-style names (`OUTPUT_PATH`, `TARGET_DIR`, etc.). Never put secrets here; resolved values are substituted into the persona's system prompt." + }, + "harness": { + "$ref": "#/definitions/Harness", + "description": "Harness binary used to run this persona (`claude`, `codex`, `opencode`)." + }, + "model": { + "type": "string", + "description": "Model identifier passed to the harness." + }, + "systemPrompt": { + "type": "string", + "description": "System prompt body. `$NAME` / `${NAME}` references to inputs are substituted at spawn time." + }, + "harnessSettings": { + "$ref": "#/definitions/HarnessSettings", + "description": "Harness-level knobs (reasoning, timeout, codex sandbox/approval policy, etc.)." + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Environment variables injected into the harness child process. Values may be literal strings or `$VAR` references resolved from the caller's environment at spawn time." + }, + "mcpServers": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/McpServerSpec" + }, + "description": "MCP servers to attach to the harness session.\n- `claude`: passed via `--mcp-config`\n- `codex`: translated into `--config mcp_servers....` overrides\n- `opencode`: currently warns and skips" + }, + "permissions": { + "$ref": "#/definitions/PersonaPermissions", + "description": "Permission policy (allow/deny lists, mode) for the harness session. Only wired for `claude` today (via `--allowedTools`, `--disallowedTools`, `--permission-mode`); other harnesses warn and skip." + }, + "mount": { + "$ref": "#/definitions/PersonaMount", + "description": "Relayfile mount policy for file visibility and writability. Applied by launchers that run the harness inside `@relayfile/local-mount`." + }, + "claudeMd": { + "type": "string", + "description": "Author-supplied path to a `CLAUDE.md` sidecar that should be applied when the persona runs under the claude harness. The path is relative to the JSON file that declared the field; the loader resolves it to an already-absolute path on the parsed spec. Built-in personas inline the content into {@link PersonaSpec.claudeMdContent } at build time." + }, + "claudeMdMode": { + "$ref": "#/definitions/SidecarMdMode", + "description": "Defaults to `overwrite`. See {@link SidecarMdMode } ." + }, + "agentsMd": { + "type": "string", + "description": "Author-supplied path to an `AGENTS.md` sidecar that should be applied when the persona runs under the opencode harness. Same resolution rules as {@link claudeMd } ." + }, + "agentsMdMode": { + "$ref": "#/definitions/SidecarMdMode", + "description": "Defaults to `overwrite`. See {@link SidecarMdMode } ." + }, + "claudeMdContent": { + "type": "string", + "description": "Inlined `CLAUDE.md` content for built-in personas. The catalog generator reads the sibling `.md` at build time and emits its body here so the installed package does not need to ship the file separately. Runtime code prefers this over `claudeMd` when both are set." + }, + "agentsMdContent": { + "type": "string", + "description": "Inlined `AGENTS.md` content for built-in personas." + }, + "cloud": { + "type": "boolean", + "description": "Opt this persona into the `workforce deploy` cloud-agent surface. When `true`, the deploy CLI considers this persona a deployable agent (validates {@link integrations } / {@link schedules } , prompts for integration connect, bundles {@link onEvent } , hands off to the runtime). Local `workforce agent ` flows ignore this flag — non-deploy use keeps working unchanged." + }, + "useSubscription": { + "type": "boolean", + "description": "When `true`, inference for this agent uses the user's connected LLM subscription via `@agent-relay/cloud`'s provider link, rather than workforce-billed tokens. The deploy CLI calls `connectProvider({...})` at deploy time. Only meaningful when {@link cloud } is `true`." + }, + "integrations": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PersonaIntegrationConfig" + }, + "description": "Per-provider integration declarations keyed by Relayfile provider slug (`github`, `linear`, `slack`, `notion`, `jira`). At deploy time the CLI runs `RelayfileSetup.connectIntegration({ allowedIntegrations: [key] })` for each provider not yet connected to the active workspace." + }, + "schedules": { + "type": "array", + "items": { + "$ref": "#/definitions/PersonaSchedule" + }, + "description": "Cron-style clock listeners. Each `name` is unique within the persona." + }, + "memory": { + "$ref": "#/definitions/PersonaMemory", + "description": "Memory subsystem opt-in. Wires the agent-assistant memory adapter at runtime; the persona spec only declares intent, not implementation details (api keys, adapter type, etc. come from workforce env)." + }, + "onEvent": { + "type": "string", + "description": "Relative POSIX path to the TypeScript (or compiled .js / .mjs) file whose default export is the deploy-time event handler. Resolved relative to the persona JSON's directory at deploy time. Required by the JSON Schema whenever {@link cloud } is `true` (any cloud persona needs an entrypoint, regardless of whether triggers are declared); the deploy CLI enforces the same rule. The parser itself keeps the field optional so partially-authored specs still parse." + } + }, + "required": [ + "id", + "intent", + "tags", + "description", + "skills", + "harness", + "model", + "systemPrompt", + "harnessSettings" + ], + "description": "A persona listens for events. Three listener kinds: clock (cron schedules through `schedules[]`), radio (RelayFile integration events through `integrations..triggers[]`), and inbox (RelayCast targeted messages, not yet modeled in v1). The current shape predates the listeners framing; semantics are equivalent.", + "allOf": [ + { + "if": { + "type": "object", + "properties": { + "cloud": { + "const": true + } + }, + "required": [ + "cloud" + ] + }, + "then": { + "required": [ + "onEvent" + ] + } + } + ] + }, + "PersonaTag": { + "type": "string", + "enum": [ + "planning", + "implementation", + "review", + "testing", + "debugging", + "documentation", + "release", + "discovery", + "analytics" + ] + }, + "PersonaSkill": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "source", + "description" + ], + "description": "A skill is a named, reusable capability attached to a persona. `source` points to canonical guidance the persona should apply (e.g. a prpm.dev package URL, an internal runbook, a docs page)." + }, + "PersonaInputSpec": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Human-readable explanation shown in docs/catalog UIs." + }, + "env": { + "type": "string", + "description": "Environment variable to read when the launcher did not provide an explicit value. Defaults to the input key itself." + }, + "default": { + "type": "string", + "description": "Literal fallback used when neither an explicit value nor env var exists." + }, + "optional": { + "type": "boolean", + "description": "When true, the input is allowed to resolve to an empty string. The launcher substitutes `$NAME` with `''` rather than throwing `MissingPersonaInputError`. Use for inputs whose absence is meaningful — e.g. an upstream task description that may or may not be forwarded — and prefer non-optional inputs with a `default` for everything else so misconfigured launches surface loudly." + } + }, + "description": "Prompt-visible runtime input declared by a persona. Inputs are for non-secret run configuration such as output paths, target package names, or mode switches. Launchers resolve each input from explicit values, the process environment, or `default`, then substitute `$NAME` / `${NAME}` in the system prompt before spawning the harness." + }, + "Harness": { + "type": "string", + "enum": [ + "opencode", + "codex", + "claude" + ] + }, + "HarnessSettings": { + "type": "object", + "properties": { + "reasoning": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "timeoutSeconds": { + "type": "number" + }, + "sandboxMode": { + "$ref": "#/definitions/CodexSandboxMode", + "description": "Codex CLI sandbox mode for model-generated shell commands. Prefer `workspace-write` with `workspaceWriteNetworkAccess` when network is the only missing capability; `danger-full-access` is the fully unsandboxed fallback." + }, + "approvalPolicy": { + "$ref": "#/definitions/CodexApprovalPolicy", + "description": "Codex CLI approval policy (`--ask-for-approval`)." + }, + "workspaceWriteNetworkAccess": { + "type": "boolean", + "description": "Allow outbound network access inside Codex's workspace-write sandbox (`sandbox_workspace_write.network_access`)." + }, + "webSearch": { + "type": "boolean", + "description": "Enable the Codex live web-search tool for this runtime." + }, + "dangerouslyBypassApprovalsAndSandbox": { + "type": "boolean", + "description": "Emit codex's single `--dangerously-bypass-approvals-and-sandbox` flag, which collapses \"no sandbox + never ask for approval\" and also suppresses codex's interactive \"are you sure?\" startup confirmation. Mutually exclusive with `sandboxMode`, `approvalPolicy`, and `workspaceWriteNetworkAccess` — those translate to the two-flag form which still prompts." + } + }, + "required": [ + "reasoning", + "timeoutSeconds" + ] + }, + "CodexSandboxMode": { + "type": "string", + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ] + }, + "CodexApprovalPolicy": { + "type": "string", + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ] + }, + "McpServerSpec": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "http", + "sse" + ] + }, + "url": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type", + "url" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "stdio" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type", + "command" + ] + } + ], + "description": "MCP server config, structured to match Claude Code's `--mcp-config` JSON verbatim so the whole object can be passed through untouched. Values inside `headers` / `env` / `args` / `url` / `command` may be literal strings or `$VAR` / `${VAR}` references. Resolution happens in the runner/CLI at spawn time — this package only defines the shape, not the interpolation policy." + }, + "PersonaPermissions": { + "type": "object", + "properties": { + "allow": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tool names/patterns to auto-approve." + }, + "deny": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tool names/patterns to always block." + }, + "mode": { + "$ref": "#/definitions/PermissionMode", + "description": "Permission mode for the session." + } + }, + "description": "Persona-level permission policy for the harness session. Translates to the harness's native allow/deny/mode flags at spawn time. Tool-pattern syntax is passed through verbatim — `\"mcp__posthog\"` to allow every posthog MCP tool, `\"mcp__posthog__projects-get\"` for a specific one, `\"Bash(git *)\"` for a shell pattern. See the target harness's docs for the exact grammar." + }, + "PermissionMode": { + "type": "string", + "enum": [ + "default", + "acceptEdits", + "bypassPermissions", + "plan" + ] + }, + "PersonaMount": { + "type": "object", + "properties": { + "ignoredPatterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "readonlyPatterns": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "description": "Relayfile mount policy for interactive sessions. Patterns use gitignore syntax. `ignoredPatterns` are omitted from the mount entirely; `readonlyPatterns` are copied into the mount but edits do not sync back. Launchers may merge these with project-level `.agentignore` / `.agentreadonly` dotfiles." + }, + "SidecarMdMode": { + "type": "string", + "enum": [ + "overwrite", + "extend" + ] + }, + "PersonaIntegrationConfig": { + "type": "object", + "properties": { + "scope": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "triggers": { + "type": "array", + "items": { + "$ref": "#/definitions/PersonaIntegrationTrigger" + } + } + }, + "description": "Radio listener configuration for a RelayFile provider. The map key is the provider slug (`github`, `linear`, `slack`, `notion`, `jira`). `scope` is provider-specific filter metadata (e.g. `{ repo: \"org/repo\" }` for github, `{ database: \"\" }` for notion). `triggers` are flat — all radio listener events for this provider fan into the same `onEvent` handler, which discriminates on `event.source` + `event.type`." + }, + "PersonaIntegrationTrigger": { + "type": "object", + "properties": { + "on": { + "type": "string" + }, + "match": { + "type": "string" + }, + "where": { + "type": "string" + } + }, + "required": [ + "on" + ], + "description": "A single event trigger declared by an integration. `on` is a Relayfile- adapter-normalized event name (e.g. `pull_request.opened`, `issue.created`, `app_mention`). `match` and `where` are filter sugars the deploy CLI lints against a known registry; unknown values warn but do not fail parse, so the cloud runtime stays the source of truth.\n\nExamples: { on: \"pull_request.opened\" } { on: \"issue_comment.created\", match: \"@mention\" } { on: \"check_run.completed\", where: \"conclusion=failure\" }" + }, + "PersonaSchedule": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "cron": { + "type": "string" + }, + "tz": { + "type": "string" + } + }, + "required": [ + "name", + "cron" + ], + "description": "Clock listener configuration. `name` is unique within the persona and surfaces to the handler as `event.name`. `cron` is a standard 5-field expression. `tz` defaults to `UTC` at the runtime layer (the parser keeps it optional so the spec stays close to what the author wrote)." + }, + "PersonaMemory": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/PersonaMemoryConfig" + } + ] + }, + "PersonaMemoryConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/definitions/PersonaMemoryScope" + } + }, + "ttlDays": { + "type": "number" + }, + "autoPromote": { + "type": "boolean" + }, + "dedupMs": { + "type": "number" + } + }, + "description": "Long-form memory configuration. Defaults are applied by the runtime, not the parser — the spec keeps only what the author actually wrote. `enabled` defaults to true when the object form is present." + }, + "PersonaMemoryScope": { + "type": "string", + "enum": [ + "workspace", + "user", + "global" + ], + "description": "Memory scope semantics, mirroring the agent-assistant memory adapter: `workspace` memory persists across users in a workspace, `user` memory follows an individual user's invocations, and `global` memory is shared across every invocation of the deployed agent." + } + }, + "$id": "https://agentworkforce.dev/schemas/persona.schema.json" +} diff --git a/packages/persona-kit/scripts/emit-schema.mjs b/packages/persona-kit/scripts/emit-schema.mjs new file mode 100644 index 00000000..b5612352 --- /dev/null +++ b/packages/persona-kit/scripts/emit-schema.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { createGenerator } = require('ts-json-schema-generator'); + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const schemaPath = resolve(packageRoot, 'schemas/persona.schema.json'); +const tsconfigPath = resolve(packageRoot, 'tsconfig.json'); +const typesPath = resolve(packageRoot, 'src/types.ts'); + +const generator = createGenerator({ + path: typesPath, + tsconfig: tsconfigPath, + type: 'PersonaSpec', + expose: 'export', + topRef: true, + jsDoc: 'extended', + additionalProperties: true, + sortProps: true, + skipTypeCheck: true +}); + +const schema = generator.createSchema('PersonaSpec'); +schema.$schema = 'https://json-schema.org/draft/2020-12/schema'; +schema.$id = 'https://agentworkforce.dev/schemas/persona.schema.json'; +const personaSpecSchema = schema.definitions?.PersonaSpec; +if (personaSpecSchema) { + personaSpecSchema.allOf = [ + ...(personaSpecSchema.allOf ?? []), + { + if: { + type: 'object', + properties: { + cloud: { const: true } + }, + required: ['cloud'] + }, + then: { + required: ['onEvent'] + } + } + ]; +} + +const serialized = `${JSON.stringify(schema, null, 2)}\n`; +await mkdir(dirname(schemaPath), { recursive: true }); + +let existing = ''; +try { + existing = await readFile(schemaPath, 'utf8'); +} catch { + // First emission. +} + +if (existing !== serialized) { + await writeFile(schemaPath, serialized); +} diff --git a/packages/persona-kit/src/__fixtures__/personas/cron-only.json b/packages/persona-kit/src/__fixtures__/personas/cron-only.json new file mode 100644 index 00000000..568a70a5 --- /dev/null +++ b/packages/persona-kit/src/__fixtures__/personas/cron-only.json @@ -0,0 +1,17 @@ +{ + "id": "cron-only", + "intent": "documentation", + "tags": ["documentation"], + "description": "A cloud persona with schedules and no integration triggers.", + "skills": [], + "cloud": true, + "schedules": [ + { "name": "weekly-digest", "cron": "0 9 * * 6", "tz": "UTC" }, + { "name": "weekday-check", "cron": "0 9 * * 1-5", "tz": "America/New_York" } + ], + "onEvent": "./agent.ts", + "harness": "codex", + "model": "gpt-5", + "systemPrompt": "Summarize scheduled work.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 300 } +} diff --git a/packages/persona-kit/src/__fixtures__/personas/full.json b/packages/persona-kit/src/__fixtures__/personas/full.json new file mode 100644 index 00000000..5b5ebb23 --- /dev/null +++ b/packages/persona-kit/src/__fixtures__/personas/full.json @@ -0,0 +1,86 @@ +{ + "id": "full-deploy", + "intent": "review", + "tags": ["review", "implementation"], + "description": "A full deploy-v1 persona fixture with every deploy field populated.", + "skills": [ + { + "id": "review-rubric", + "source": "https://prpm.dev/packages/@agentworkforce/review-rubric", + "description": "Review code using the team rubric." + } + ], + "inputs": { + "TOPICS": { + "description": "Comma-separated topic list.", + "default": "runtime,deploy,integrations" + }, + "OPTIONAL_CONTEXT": { + "description": "Optional context from the deploy caller.", + "optional": true + } + }, + "cloud": true, + "useSubscription": true, + "integrations": { + "github": { + "scope": { "repo": "AgentWorkforce/workforce" }, + "triggers": [ + { "on": "pull_request.opened" }, + { "on": "issue_comment.created", "match": "@mention" }, + { "on": "check_run.completed", "where": "conclusion=failure" } + ] + }, + "linear": { "triggers": [{ "on": "issue.created" }] }, + "slack": { "triggers": [{ "on": "app_mention" }] }, + "notion": { + "scope": { "database": "db_123" }, + "triggers": [{ "on": "page.updated" }] + }, + "jira": { + "scope": { "project": "ENG" }, + "triggers": [{ "on": "issue.created" }] + } + }, + "schedules": [{ "name": "daily-review", "cron": "0 14 * * 1-5", "tz": "UTC" }], + "memory": { + "enabled": true, + "scopes": ["workspace", "user", "global"], + "ttlDays": 30, + "autoPromote": true, + "dedupMs": 300000 + }, + "onEvent": "./agent.ts", + "env": { "BRAVE_API_KEY": "$BRAVE_API_KEY" }, + "mcpServers": { + "docs": { + "type": "http", + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer $DOCS_TOKEN" } + } + }, + "permissions": { + "allow": ["Bash(git *)"], + "deny": ["Bash(rm -rf *)"], + "mode": "default" + }, + "mount": { + "ignoredPatterns": ["node_modules/**"], + "readonlyPatterns": ["vendor/**"] + }, + "claudeMd": "CLAUDE.md", + "claudeMdMode": "extend", + "agentsMd": "AGENTS.md", + "agentsMdMode": "overwrite", + "harness": "codex", + "model": "gpt-5", + "systemPrompt": "Review code and explain risks clearly.", + "harnessSettings": { + "reasoning": "medium", + "timeoutSeconds": 600, + "sandboxMode": "workspace-write", + "approvalPolicy": "on-request", + "workspaceWriteNetworkAccess": true, + "webSearch": true + } +} diff --git a/packages/persona-kit/src/__fixtures__/personas/invalid-unknown-trigger.json b/packages/persona-kit/src/__fixtures__/personas/invalid-unknown-trigger.json new file mode 100644 index 00000000..dad7f079 --- /dev/null +++ b/packages/persona-kit/src/__fixtures__/personas/invalid-unknown-trigger.json @@ -0,0 +1,21 @@ +{ + "id": "invalid-unknown-trigger", + "intent": "review", + "tags": ["review"], + "description": "A schema-valid persona fixture with one trigger lint warning.", + "skills": [], + "cloud": true, + "integrations": { + "github": { + "triggers": [ + { "on": "pull_request.opened" }, + { "on": "pull_request.evaporated" } + ] + } + }, + "onEvent": "./agent.ts", + "harness": "codex", + "model": "gpt-5", + "systemPrompt": "Review code.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 300 } +} diff --git a/packages/persona-kit/src/__fixtures__/personas/minimal.json b/packages/persona-kit/src/__fixtures__/personas/minimal.json new file mode 100644 index 00000000..929033f7 --- /dev/null +++ b/packages/persona-kit/src/__fixtures__/personas/minimal.json @@ -0,0 +1,11 @@ +{ + "id": "minimal", + "intent": "documentation", + "tags": ["documentation"], + "description": "A minimal persona fixture without deploy fields.", + "skills": [], + "harness": "codex", + "model": "gpt-5", + "systemPrompt": "Write clear documentation.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 300 } +} diff --git a/packages/persona-kit/src/emit-schema.test.ts b/packages/persona-kit/src/emit-schema.test.ts new file mode 100644 index 00000000..35b2b414 --- /dev/null +++ b/packages/persona-kit/src/emit-schema.test.ts @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { parsePersonaSpec } from './parse.js'; +import { lintTriggers } from './triggers.js'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const fixturesDir = resolve(packageRoot, 'src/__fixtures__/personas'); +const schemaPath = resolve(packageRoot, 'schemas/persona.schema.json'); + +test('persona fixtures validate against generated schema and parse', async () => { + const schema = JSON.parse(await readFile(schemaPath, 'utf8')); + const fixtureNames = (await readdir(fixturesDir)).filter((name) => name.endsWith('.json')).sort(); + + assert.deepEqual(fixtureNames, [ + 'cron-only.json', + 'full.json', + 'invalid-unknown-trigger.json', + 'minimal.json' + ]); + + for (const fixtureName of fixtureNames) { + const fixture = JSON.parse(await readFile(resolve(fixturesDir, fixtureName), 'utf8')); + assertSchema(fixture, schema, schema, fixtureName); + + const parsed = parsePersonaSpec(fixture, fixture.intent); + const triggerIssues = lintTriggers(parsed); + if (fixtureName === 'invalid-unknown-trigger.json') { + assert.equal(triggerIssues.length, 1); + assert.equal(triggerIssues[0].code, 'unknown_trigger'); + } else { + assert.deepEqual(triggerIssues, []); + } + } +}); + +test('emit-schema script is idempotent', async () => { + const before = await readFile(schemaPath, 'utf8'); + execFileSync('node', [resolve(packageRoot, 'scripts/emit-schema.mjs')], { + cwd: packageRoot, + stdio: 'pipe' + }); + const after = await readFile(schemaPath, 'utf8'); + assert.equal(after, before); +}); + +test('generated schema requires onEvent for cloud personas', async () => { + const schema = JSON.parse(await readFile(schemaPath, 'utf8')); + const fixture = JSON.parse(await readFile(resolve(fixturesDir, 'minimal.json'), 'utf8')); + + assertSchema({ ...fixture, cloud: true, onEvent: './agent.ts' }, schema, schema, 'cloud-persona'); + assert.throws( + () => assertSchema({ ...fixture, cloud: true }, schema, schema, 'cloud-persona'), + /cloud-persona\.onEvent is required/ + ); +}); + +test('generated schema reflects locked v1 persona fields', async () => { + const schema = JSON.parse(await readFile(schemaPath, 'utf8')) as SchemaNode; + const definitions = schema.definitions as Record; + const personaSpec = definitions.PersonaSpec; + const properties = personaSpec.properties ?? {}; + + assert.equal('sandbox' in properties, false); + assert.equal('traits' in properties, false); + assert.deepEqual(definitions.PersonaMemoryScope.enum, ['workspace', 'user', 'global']); + assert.equal('PersonaSandbox' in definitions, false); + assert.equal('PersonaSandboxConfig' in definitions, false); + assert.equal('PersonaTraits' in definitions, false); +}); + +type SchemaNode = Record & { + $ref?: string; + allOf?: SchemaNode[]; + anyOf?: SchemaNode[]; + definitions?: Record; + if?: SchemaNode; + then?: SchemaNode; + enum?: unknown[]; + const?: unknown; + type?: string | string[]; + properties?: Record; + additionalProperties?: SchemaNode | boolean; + required?: string[]; + items?: SchemaNode; +}; + +function assertSchema(value: unknown, schema: SchemaNode, root: SchemaNode, path: string): void { + if (schema.$ref) { + return assertSchema(value, resolveRef(schema.$ref, root), root, path); + } + if (schema.allOf) { + for (const candidate of schema.allOf) { + assertSchema(value, candidate, root, path); + } + } + if (schema.if && matchesSchema(value, schema.if, root, path) && schema.then) { + assertSchema(value, schema.then, root, path); + } + if (schema.anyOf) { + const errors = []; + for (const candidate of schema.anyOf) { + try { + assertSchema(value, candidate, root, path); + return; + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + throw new Error(`${path} must match one schema in anyOf: ${errors.join('; ')}`); + } + if (schema.enum && !schema.enum.includes(value)) { + throw new Error(`${path} must be one of: ${schema.enum.map((v) => String(v)).join(', ')}`); + } + if (schema.const !== undefined && value !== schema.const) { + throw new Error(`${path} must equal ${JSON.stringify(schema.const)}`); + } + if (schema.type) { + assertType(value, schema.type, path); + } + if (schema.type === 'object' || schema.properties || schema.additionalProperties || schema.required) { + if (!isObject(value)) { + throw new Error(`${path} must be an object`); + } + for (const requiredKey of schema.required ?? []) { + if (!(requiredKey in value)) { + throw new Error(`${path}.${requiredKey} is required`); + } + } + const properties = schema.properties ?? {}; + for (const [key, childValue] of Object.entries(value)) { + const childSchema = properties[key] ?? schema.additionalProperties; + if (childSchema === false) { + throw new Error(`${path}.${key} is not allowed`); + } + if (childSchema && childSchema !== true) { + assertSchema(childValue, childSchema as SchemaNode, root, `${path}.${key}`); + } + } + } + if (schema.type === 'array' || schema.items) { + if (!Array.isArray(value)) { + throw new Error(`${path} must be an array`); + } + if (schema.items) { + value.forEach((item, index) => + assertSchema(item, schema.items as SchemaNode, root, `${path}[${index}]`) + ); + } + } +} + +function matchesSchema(value: unknown, schema: SchemaNode, root: SchemaNode, path: string): boolean { + try { + assertSchema(value, schema, root, path); + return true; + } catch { + return false; + } +} + +function assertType(value: unknown, type: string | string[], path: string): void { + const types = Array.isArray(type) ? type : [type]; + const ok = types.some((candidate) => { + switch (candidate) { + case 'array': + return Array.isArray(value); + case 'integer': + return Number.isInteger(value); + case 'null': + return value === null; + case 'object': + return isObject(value); + default: + return typeof value === candidate; + } + }); + if (!ok) { + throw new Error(`${path} must be ${types.join('|')}`); + } +} + +function resolveRef(ref: string, root: SchemaNode): SchemaNode { + const parts = ref.replace(/^#\//, '').split('/'); + let current: unknown = root; + for (const part of parts) { + current = (current as Record)[part]; + } + return current as SchemaNode; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index f2ca1e30..9498068b 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -81,6 +81,7 @@ export { lintTriggers, type KnownProviderName, type KnownTriggerName, + type TriggerLintCode, type TriggerLintIssue, type TriggerLintLevel } from './triggers.js'; diff --git a/packages/persona-kit/src/triggers.test.ts b/packages/persona-kit/src/triggers.test.ts index 9f741186..c31ef311 100644 --- a/packages/persona-kit/src/triggers.test.ts +++ b/packages/persona-kit/src/triggers.test.ts @@ -59,6 +59,7 @@ test('lintTriggers warns once per unknown provider', () => { ); assert.equal(issues.length, 1); assert.equal(issues[0].level, 'warning'); + assert.equal(issues[0].code, 'unknown_provider'); assert.equal(issues[0].provider, 'mysteryapp'); assert.equal(issues[0].path, 'integrations.mysteryapp'); }); @@ -79,6 +80,7 @@ test('lintTriggers warns per unknown trigger for a known provider', () => { assert.deepEqual(triggers, ['made.up', 'pull_request.really_truly_new_event']); for (const issue of issues) { assert.equal(issue.level, 'warning'); + assert.equal(issue.code, 'unknown_trigger'); assert.equal(issue.provider, 'github'); assert.match(issue.path, /integrations\.github\.triggers\[\d+\]\.on/); } diff --git a/packages/persona-kit/src/triggers.ts b/packages/persona-kit/src/triggers.ts index 3d221e88..cd32bbd4 100644 --- a/packages/persona-kit/src/triggers.ts +++ b/packages/persona-kit/src/triggers.ts @@ -33,8 +33,15 @@ export type KnownTriggerName

= (typeof KNOWN_TRIGGE export type TriggerLintLevel = 'warning'; +/** + * Machine-readable issue category, so callers can branch on + * `issue.code` without parsing the human-readable `message`. + */ +export type TriggerLintCode = 'unknown_provider' | 'unknown_trigger'; + export interface TriggerLintIssue { level: TriggerLintLevel; + code: TriggerLintCode; /** Provider slug the issue was raised under (`github`, `linear`, …). */ provider: string; /** The trigger name that was flagged. */ @@ -69,6 +76,7 @@ export function lintTriggers(persona: PersonaSpec): TriggerLintIssue[] { // catalogued yet. issues.push({ level: 'warning', + code: 'unknown_provider', provider, trigger: '*', path: `integrations.${provider}`, @@ -81,6 +89,7 @@ export function lintTriggers(persona: PersonaSpec): TriggerLintIssue[] { if (!known.includes(trigger.on)) { issues.push({ level: 'warning', + code: 'unknown_trigger', provider, trigger: trigger.on, path: `integrations.${provider}.triggers[${idx}].on`, diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index b231c827..fb5c4bcb 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -177,7 +177,12 @@ export interface PersonaSchedule { tz?: string; } -/** Memory scope semantics, mirroring @agent-assistant/memory. */ +/** + * Memory scope semantics, mirroring the agent-assistant memory adapter: + * `workspace` memory persists across users in a workspace, `user` memory + * follows an individual user's invocations, and `global` memory is shared + * across every invocation of the deployed agent. + */ export type PersonaMemoryScope = 'workspace' | 'user' | 'global'; /** @@ -312,10 +317,11 @@ export interface PersonaSpec { /** * Relative POSIX path to the TypeScript (or compiled .js / .mjs) file * whose default export is the deploy-time event handler. Resolved - * relative to the persona JSON's directory at deploy time. Required when - * {@link cloud} is `true` and any trigger is declared; the deploy CLI - * enforces this at deploy time, the parser keeps it optional so partially- - * authored specs still parse. + * relative to the persona JSON's directory at deploy time. Required by + * the JSON Schema whenever {@link cloud} is `true` (any cloud persona + * needs an entrypoint, regardless of whether triggers are declared); the + * deploy CLI enforces the same rule. The parser itself keeps the field + * optional so partially-authored specs still parse. */ onEvent?: string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8c3cbf9..0a18a168 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,10 @@ importers: '@relayfile/local-mount': specifier: ^0.7.0 version: 0.7.0 + devDependencies: + ts-json-schema-generator: + specifier: ^2.3.0 + version: 2.9.0 packages/personas-core: {} @@ -958,6 +962,9 @@ packages: resolution: {integrity: sha512-G/gWDykZNL0NVcd1qXkoKm45jxJECp6q53DSomM5QKMsyAMEsGksVq+HwgonqYxfFJEzzHi6ljtWKXVS1pl0/Q==} engines: {node: '>=18.0.0'} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} @@ -994,12 +1001,20 @@ packages: axios@1.16.0: resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1053,6 +1068,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1171,6 +1190,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1233,6 +1256,11 @@ packages: peerDependencies: ws: '*' + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -1243,6 +1271,10 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1267,6 +1299,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -1284,6 +1320,10 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -1300,6 +1340,10 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1351,6 +1395,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + shell-quote@1.8.3: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} @@ -1403,6 +1451,11 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + ts-json-schema-generator@2.9.0: + resolution: {integrity: sha512-NR5ZE108uiPtBHBJNGnhwoUaUx5vWTDJzDFG9YlRoqxPU76n+5FClRh92dcGgysbe1smRmYalM9Saj97GW1J4Q==} + engines: {node: '>=22.0.0'} + hasBin: true + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2663,6 +2716,8 @@ snapshots: '@smithy/core': 3.24.1 tslib: 2.8.1 + '@types/json-schema@7.0.15': {} + '@types/node@22.19.15': dependencies: undici-types: 6.21.0 @@ -2697,10 +2752,16 @@ snapshots: transitivePeerDependencies: - debug + balanced-match@4.0.4: {} + base64-js@1.5.1: {} bowser@2.14.1: {} + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -2749,6 +2810,8 @@ snapshots: commander@12.1.0: {} + commander@14.0.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2887,6 +2950,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + gopd@1.2.0: {} has-symbols@1.1.0: {} @@ -2934,6 +3003,8 @@ snapshots: dependencies: ws: 8.20.0 + json5@2.2.3: {} + lodash.camelcase@4.3.0: {} log-symbols@7.0.1: @@ -2943,6 +3014,8 @@ snapshots: long@5.3.2: {} + lru-cache@11.3.6: {} + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -2960,6 +3033,10 @@ snapshots: mimic-function@5.0.1: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + minipass@7.1.3: {} minizlib@3.1.0: @@ -2972,6 +3049,8 @@ snapshots: node-addon-api@7.1.1: {} + normalize-path@3.0.0: {} + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -2991,6 +3070,11 @@ snapshots: path-expression-matcher@1.5.0: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.3.6 + minipass: 7.1.3 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -3046,6 +3130,8 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + shell-quote@1.8.3: {} signal-exit@4.1.0: {} @@ -3098,6 +3184,17 @@ snapshots: dependencies: is-number: 7.0.0 + ts-json-schema-generator@2.9.0: + dependencies: + '@types/json-schema': 7.0.15 + commander: 14.0.3 + glob: 13.0.6 + json5: 2.2.3 + normalize-path: 3.0.0 + safe-stable-stringify: 2.5.0 + tslib: 2.8.1 + typescript: 5.9.3 + tslib@2.8.1: {} typescript@5.9.3: {}