diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 35bf1d4..b3f0328 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -154,7 +154,7 @@ Commands: --filter-harness only show this harness (${HARNESS_VALUES.join(' | ')}) --filter-tag only show personas carrying this tag - (${PERSONA_TAGS.join(' | ')}) + (free-form; common built-in tags: ${PERSONA_TAGS.join(' | ')}) --no-display-description hide the DESCRIPTION column show Print the fully-resolved spec for a single persona, including which cascade layer defined it (cwd, user, @@ -2010,7 +2010,7 @@ interface PersonaListRow { harness: string; model: string; intent: string; - tags: PersonaTag[]; + tags: readonly PersonaTag[]; description: string; } @@ -2023,7 +2023,7 @@ function collectPersonaRows(): PersonaListRow[] { harness: spec.harness, model: spec.model, intent: spec.intent, - tags: spec.tags, + tags: spec.tags ?? [], description: spec.description }); }; @@ -2146,10 +2146,11 @@ function parseListArgs(args: readonly string[]): { filterHarness = v as Harness; } else if (arg === '--filter-tag') { const v = valueOf(i++, arg); - if (!(PERSONA_TAGS as readonly string[]).includes(v)) { - die(`list: invalid --filter-tag "${v}". Must be one of: ${PERSONA_TAGS.join(', ')}`); + const trimmed = v.trim(); + if (!trimmed) { + die('list: --filter-tag requires a non-empty value.'); } - filterTag = v as PersonaTag; + filterTag = trimmed as PersonaTag; } else if (arg === '--display-description') { display.description = true; } else if (arg === '--no-display-description') { @@ -2254,7 +2255,7 @@ function formatPersonaShow(spec: PersonaSpec, source: PersonaSource): string { lines.push(`PERSONA ${spec.id}`); lines.push(`SOURCE ${source}`); lines.push(`INTENT ${spec.intent}`); - lines.push(`TAGS ${spec.tags.length ? spec.tags.join(', ') : '(none)'}`); + lines.push(`TAGS ${spec.tags && spec.tags.length ? spec.tags.join(', ') : '(none)'}`); lines.push(`DESCRIPTION ${spec.description}`); lines.push(''); @@ -3593,7 +3594,7 @@ export function buildPickCandidates(): PickCandidate[] { byId.set(spec.id, { id: spec.id, intent: spec.intent, - tags: [...spec.tags], + tags: spec.tags ? [...spec.tags] : [], description: spec.description }); } @@ -3601,7 +3602,7 @@ export function buildPickCandidates(): PickCandidate[] { byId.set(id, { id, intent: spec.intent, - tags: [...spec.tags], + tags: spec.tags ? [...spec.tags] : [], description: spec.description }); } diff --git a/packages/cli/src/local-personas.ts b/packages/cli/src/local-personas.ts index a22186b..cbe2157 100644 --- a/packages/cli/src/local-personas.ts +++ b/packages/cli/src/local-personas.ts @@ -6,7 +6,6 @@ import { CODEX_APPROVAL_POLICIES, CODEX_SANDBOX_MODES, HARNESS_VALUES, - PERSONA_TAGS, SIDECAR_MD_MODES, type CodexApprovalPolicy, type CodexSandboxMode, @@ -423,16 +422,24 @@ function parseOverride(value: unknown, context: string): LocalPersonaOverride { if (raw.description !== undefined && typeof raw.description !== 'string') { throw new Error(`${context}.description must be a string if provided`); } - if (raw.tags !== undefined) { - if (!Array.isArray(raw.tags) || raw.tags.length === 0) { - throw new Error(`${context}.tags must be a non-empty array of tags if provided`); + let normalizedTags: PersonaTag[] | undefined; + if (raw.tags !== undefined && raw.tags !== null) { + if (!Array.isArray(raw.tags)) { + throw new Error(`${context}.tags must be an array of strings if provided`); } + const tags = new Set(); for (const [idx, tag] of raw.tags.entries()) { - if (!PERSONA_TAGS.includes(tag as PersonaTag)) { - throw new Error( - `${context}.tags[${idx}] must be one of: ${PERSONA_TAGS.join(', ')}` - ); + if (typeof tag !== 'string' || !tag.trim()) { + throw new Error(`${context}.tags[${idx}] must be a non-empty string`); } + const trimmed = tag.trim(); + if (trimmed.length > 64) { + throw new Error(`${context}.tags[${idx}] must be ≤64 characters`); + } + tags.add(trimmed); + } + if (tags.size > 0) { + normalizedTags = Array.from(tags).sort() as PersonaTag[]; } } @@ -489,7 +496,7 @@ function parseOverride(value: unknown, context: string): LocalPersonaOverride { id: raw.id, extends: raw.extends as string | undefined, intent: raw.intent as string | undefined, - tags: raw.tags as PersonaTag[] | undefined, + ...(normalizedTags !== undefined ? { tags: normalizedTags } : {}), description: raw.description as string | undefined, skills: raw.skills as PersonaSpec['skills'] | undefined, inputs, @@ -804,7 +811,8 @@ function standaloneSpecFromOverride( return { id: override.id, intent: override.intent, - tags: requireStandaloneField(override.tags, `${context}.tags`), + // Tags are optional per cloud#553 (denormalized catalog metadata). + ...(override.tags ? { tags: override.tags } : {}), description: requireStandaloneField(override.description, `${context}.description`), skills: override.skills ?? [], ...(inputs ? { inputs } : {}), @@ -1009,10 +1017,11 @@ function mergeOverride( const claudeMdMode = override.claudeMdMode ?? base.claudeMdMode; const agentsMdMode = override.agentsMdMode ?? base.agentsMdMode; + const mergedTags = override.tags ?? base.tags; return { id: override.id, intent: base.intent, - tags: override.tags ?? base.tags, + ...(mergedTags ? { tags: mergedTags } : {}), description: override.description ?? base.description, skills: override.skills ?? base.skills, ...(inputs ? { inputs } : {}), diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index ae25831..c4cc44d 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -31,12 +31,13 @@ const ENV_KEYS = [ 'WORKFORCE_DEPLOY_RETRY_BACKOFF_MS' ] as const; -function persona(overrides: Record = {}): PersonaSpec { +function persona(overrides: Partial = {}): PersonaSpec { return { id: 'demo', intent: 'documentation', - tags: ['documentation'], + tags: ['documentation'] as const, description: 'test persona', + skills: [], harness: 'codex', model: 'openai-codex/test', systemPrompt: 'help', @@ -45,7 +46,7 @@ function persona(overrides: Record = {}): PersonaSpec { schedules: [{ name: 'daily', cron: '0 9 * * *' }], onEvent: './agent.ts', ...overrides - } as PersonaSpec; + }; } async function withBundle(): Promise<{ bundle: BundleResult; cleanup: () => Promise }> { @@ -200,20 +201,21 @@ test('cloud URL precedence is flag env, cloud env, persona deployUrl, then defau return calls.find((call) => call.url.endsWith('/deployments'))?.url; } - const personaWithUrl = persona({ cloud: { deployUrl: 'https://persona.example.test/' } as unknown }); + const personaWithUrl = persona() as unknown as Omit & { cloud: { deployUrl: string } }; + personaWithUrl.cloud = { deployUrl: 'https://persona.example.test/' }; assert.equal( await deployedUrl({ WORKFORCE_DEPLOY_CLOUD_URL: 'https://flag.example.test/', WORKFORCE_CLOUD_URL: 'https://env.example.test/' - }, personaWithUrl), + }, personaWithUrl as unknown as PersonaSpec), 'https://flag.example.test/api/v1/workspaces/ws-test/deployments' ); assert.equal( - await deployedUrl({ WORKFORCE_CLOUD_URL: 'https://env.example.test/' }, personaWithUrl), + await deployedUrl({ WORKFORCE_CLOUD_URL: 'https://env.example.test/' }, personaWithUrl as unknown as PersonaSpec), 'https://env.example.test/api/v1/workspaces/ws-test/deployments' ); assert.equal( - await deployedUrl({}, personaWithUrl), + await deployedUrl({}, personaWithUrl as unknown as PersonaSpec), 'https://persona.example.test/api/v1/workspaces/ws-test/deployments' ); assert.equal( diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json index b024989..7788906 100644 --- a/packages/persona-kit/schemas/persona.schema.json +++ b/packages/persona-kit/schemas/persona.schema.json @@ -14,9 +14,11 @@ "tags": { "type": "array", "items": { - "$ref": "#/definitions/PersonaTag" + "type": "string", + "minLength": 1, + "maxLength": 64 }, - "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": "Free-form catalog labels mirroring `tags text[]` in cloud#553. Tags are denormalized metadata for catalog filtering — they are NOT a closed enum and do NOT overlap with {@link intent } . Authors are free to label personas with provider names, project codes, or workflow shapes (e.g. `['proactive', 'notion', 'github']`). Optional; omitted, `null`, and empty-array values are treated identically." }, "description": { "type": "string" @@ -130,7 +132,6 @@ "required": [ "id", "intent", - "tags", "description", "skills", "harness", @@ -160,20 +161,6 @@ } ] }, - "PersonaTag": { - "type": "string", - "enum": [ - "planning", - "implementation", - "review", - "testing", - "debugging", - "documentation", - "release", - "discovery", - "analytics" - ] - }, "PersonaSkill": { "type": "object", "properties": { diff --git a/packages/persona-kit/scripts/emit-schema.mjs b/packages/persona-kit/scripts/emit-schema.mjs index 73533ad..84d5242 100644 --- a/packages/persona-kit/scripts/emit-schema.mjs +++ b/packages/persona-kit/scripts/emit-schema.mjs @@ -80,6 +80,26 @@ function tightenWorkspaceServiceAccountName(node) { } tightenWorkspaceServiceAccountName(schema); +// Post-process: tighten the persona `tags[]` items to match parseTags +// (non-empty, ≤64 chars). The TS type widens to `readonly string[]` after +// cloud#553 — the generator emits a bare `{ "type": "string" }` because +// the bounds live in the parser. Without this, the schema accepts empty +// or 1MB tag strings that the parser would then reject. Match the +// `tightenWorkspaceServiceAccountName` post-process pattern above. +const PERSONA_TAG_MAX_LEN = 64; +const personaSpecForTags = schema.definitions?.PersonaSpec; +if ( + personaSpecForTags && + personaSpecForTags.properties?.tags?.type === 'array' && + personaSpecForTags.properties.tags.items?.type === 'string' +) { + personaSpecForTags.properties.tags.items = { + ...personaSpecForTags.properties.tags.items, + minLength: 1, + maxLength: PERSONA_TAG_MAX_LEN + }; +} + const serialized = `${JSON.stringify(schema, null, 2)}\n`; await mkdir(dirname(schemaPath), { recursive: true }); diff --git a/packages/persona-kit/src/constants.ts b/packages/persona-kit/src/constants.ts index 2bd5da6..9aaea3a 100644 --- a/packages/persona-kit/src/constants.ts +++ b/packages/persona-kit/src/constants.ts @@ -1,6 +1,11 @@ import type { Harness, HarnessSkillTarget } from './types.js'; export const HARNESS_VALUES = ['opencode', 'codex', 'claude'] as const; +/** + * Suggested persona tags used by built-in personas. Tags are free-form + * (`tags text[]` in cloud#553) — this tuple is kept only as a UX hint for + * built-in catalog filtering. New personas may use any string. + */ export const PERSONA_TAGS = [ 'planning', 'implementation', diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index 4682348..1bfc4d2 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -325,9 +325,70 @@ test('parseHarnessSettings rejects non-boolean dangerouslyBypassApprovalsAndSand ); }); -test('parseTags rejects empty arrays and unknown tags', () => { - assert.throws(() => parseTags([], 'tags'), /must be a non-empty array/); - assert.throws(() => parseTags(['nonsense-tag'], 'tags'), /tags\[0\] must be one of:/); +test('parseTags accepts an array of arbitrary string tags', () => { + // Free-form per cloud#553 `tags text[]` — no closed-enum check. + // Output is deduped and sorted for stable serialization. + assert.deepEqual( + parseTags(['proactive', 'notion', 'github'], 'tags'), + ['github', 'notion', 'proactive'] + ); +}); + +test('parseTags returns undefined when tags is missing', () => { + assert.equal(parseTags(undefined, 'tags'), undefined); +}); + +test('parseTags returns undefined when tags is null', () => { + assert.equal(parseTags(null, 'tags'), undefined); +}); + +test('parseTags returns undefined when tags is an empty array', () => { + // Empty array and "no tags" are semantically identical — both collapse + // to undefined so the field stays omitted from the parsed spec. + assert.equal(parseTags([], 'tags'), undefined); +}); + +test('parseTags rejects non-string entries', () => { + assert.throws( + () => parseTags(['proactive', 123], 'tags'), + /tags\[1\] must be a string/ + ); +}); + +test('parseTags rejects empty / whitespace-only strings', () => { + assert.throws( + () => parseTags(['proactive', ' '], 'tags'), + /tags\[1\] must be a non-empty string/ + ); + assert.throws( + () => parseTags([''], 'tags'), + /tags\[0\] must be a non-empty string/ + ); +}); + +test('parseTags rejects entries over 64 characters', () => { + const tooLong = 'x'.repeat(65); + assert.throws( + () => parseTags(['proactive', tooLong], 'tags'), + /tags\[1\] must be ≤64 characters/ + ); +}); + +test('parseTags rejects non-array input', () => { + assert.throws( + () => parseTags('proactive', 'tags'), + /tags must be an array of strings if provided/ + ); +}); + +test('parsePersonaSpec omits tags from the spec when the field is missing', () => { + // Tags are optional; the parsed spec should not synthesize an empty array + // when the input omits the field — the schema treats absence and `[]` + // identically per cloud#553's denormalized `tags text[]`. + const raw = { ...validSpec() } as Record; + delete raw.tags; + const spec = parsePersonaSpec(raw, 'documentation'); + assert.equal(spec.tags, undefined); }); test('parseSkills returns [] for undefined, validates shape per entry', () => { diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index bef3f63..03bfe5b 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -32,6 +32,13 @@ import type { SidecarMdMode } from './types.js'; +/** + * Max byte/char length for a single persona tag. Tags are denormalized + * catalog metadata (mirroring `tags text[]` in cloud#553); they should + * stay short enough to render in list/table UIs without truncation. + */ +const PERSONA_TAG_MAX_LEN = 64; + export function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null; } @@ -44,28 +51,66 @@ export function isIntent(value: unknown): value is PersonaIntent { return typeof value === 'string' && PERSONA_INTENTS.includes(value as PersonaIntent); } +/** + * Backwards-compat shim. Tags were briefly modeled as a closed enum (the + * intent enum, mistakenly); they are denormalized free-form catalog + * metadata per cloud#553's `tags text[]`. Kept exported only because + * earlier package versions surfaced this helper — every reachable string + * is a valid tag now. + * + * @deprecated Tags are free-form. Validate shape with {@link parseTags}. + */ export function isTag(value: unknown): value is PersonaTag { - return typeof value === 'string' && PERSONA_TAGS.includes(value as PersonaTag); + return typeof value === 'string' && value.trim().length > 0; } export function isSidecarMode(value: unknown): value is SidecarMdMode { return typeof value === 'string' && SIDECAR_MD_MODES.includes(value as SidecarMdMode); } -export function parseTags(value: unknown, context: string): PersonaTag[] { - if (!Array.isArray(value) || value.length === 0) { - throw new Error(`${context} must be a non-empty array of tags`); +/** + * Parse the persona-level `tags` field. Tags are denormalized catalog + * metadata (mirroring `tags text[]` in cloud#553), not a closed enum — + * authors are free to label personas with provider names, intents, or + * project codes (`["proactive", "notion", "github"]`). + * + * Shape rules: + * - `undefined` / `null` → `undefined` (tags are optional) + * - `[]` → `undefined` (an empty array is the same as "no tags") + * - otherwise must be a `string[]`; each entry must trim to a non-empty + * string ≤ {@link PERSONA_TAG_MAX_LEN} chars + * - entries are trimmed, deduped, and sorted for stable serialization + * + * Returns `readonly string[]` so callers can treat the result as + * immutable; the closed `PersonaTag` enum no longer applies. + */ +export function parseTags( + value: unknown, + context: string +): readonly string[] | undefined { + if (value === undefined || value === null) return undefined; + if (!Array.isArray(value)) { + throw new Error(`${context} must be an array of strings if provided`); } - const out: PersonaTag[] = []; + if (value.length === 0) return undefined; + + const out = new Set(); for (const [idx, entry] of value.entries()) { - if (!isTag(entry)) { + if (typeof entry !== 'string') { + throw new Error(`${context}[${idx}] must be a string`); + } + const trimmed = entry.trim(); + if (!trimmed) { + throw new Error(`${context}[${idx}] must be a non-empty string`); + } + if (trimmed.length > PERSONA_TAG_MAX_LEN) { throw new Error( - `${context}[${idx}] must be one of: ${PERSONA_TAGS.join(', ')}` + `${context}[${idx}] must be ≤${PERSONA_TAG_MAX_LEN} characters` ); } - if (!out.includes(entry)) out.push(entry); + out.add(trimmed); } - return out; + return Array.from(out).sort(); } export function assertSidecarPath(value: unknown, context: string): void { @@ -820,7 +865,7 @@ export function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): return { id, intent, - tags: parsedTags, + ...(parsedTags ? { tags: parsedTags } : {}), description, skills: parsedSkills, ...(parsedInputs ? { inputs: parsedInputs } : {}), diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index 5a8c76a..1eb71b1 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -4,14 +4,18 @@ import type { HARNESS_VALUES, PERMISSION_MODES, PERSONA_INTENTS, - PERSONA_TAGS, SIDECAR_MD_MODES, SKILL_SOURCE_KINDS } from './constants.js'; export type Harness = (typeof HARNESS_VALUES)[number]; export type PersonaIntent = (typeof PERSONA_INTENTS)[number]; -export type PersonaTag = (typeof PERSONA_TAGS)[number]; +/** + * Persona tag. Denormalized catalog metadata (mirrors `tags text[]` in + * cloud#553) — free-form, not a closed enum. The legacy {@link PERSONA_TAGS} + * tuple is preserved only for back-compat hints in list/filter UIs. + */ +export type PersonaTag = string; export type CodexSandboxMode = (typeof CODEX_SANDBOX_MODES)[number]; export type CodexApprovalPolicy = (typeof CODEX_APPROVAL_POLICIES)[number]; export type SidecarMdMode = (typeof SIDECAR_MD_MODES)[number]; @@ -236,11 +240,14 @@ export interface PersonaSpec { id: string; intent: string; /** - * 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']`). + * Free-form catalog labels mirroring `tags text[]` in cloud#553. Tags are + * denormalized metadata for catalog filtering — they are NOT a closed enum + * and do NOT overlap with {@link intent}. Authors are free to label personas + * with provider names, project codes, or workflow shapes + * (e.g. `['proactive', 'notion', 'github']`). Optional; omitted, `null`, + * and empty-array values are treated identically. */ - tags: PersonaTag[]; + tags?: readonly string[]; description: string; skills: PersonaSkill[]; /**