Skip to content

fix(persona-kit): tags optional + free-form per cloud#553 schema#114

Merged
khaliqgant merged 6 commits into
mainfrom
fix/persona-kit-tags-optional-free-form
May 13, 2026
Merged

fix(persona-kit): tags optional + free-form per cloud#553 schema#114
khaliqgant merged 6 commits into
mainfrom
fix/persona-kit-tags-optional-free-form

Conversation

@khaliqgant
Copy link
Copy Markdown
Member

Summary

@agentworkforce/persona-kit's parseTags had two bugs that blocked the canonical reference persona (examples/notion-essay-pr) and the first customer (proactive-agents) from deploying today.

Bug 1 — Required when it should be optional

Omitting tags, or setting tags: null, threw persona[<intent>].tags must be a non-empty array of tags. Per cloud#553 the column is tags text[] — denormalized catalog metadata, not a required field.

Bug 2 — Validated against the wrong enum

tags was checked against the closed intent enum (planning | implementation | review | testing | debugging | documentation | release | discovery | analytics). intent is the closed enum; tags is free-form for catalog filtering. The reference persona's ["proactive", "notion", "github"] could never pass.

Real-world repro (today, prod)

$ jq '.intent, .tags' examples/notion-essay-pr/persona.json
"documentation"
["proactive", "notion", "github"]
$ agentworkforce deploy ./persona.json --mode cloud
agentworkforce deploy failed: persona[documentation].tags[0] must be one of: planning, implementation, review, testing, debugging, documentation, release, discovery, analytics

After this PR, examples/notion-essay-pr/persona.json parses cleanly with its existing free-form tags (no fixture changes needed).

Changes

  • parseTags rewritten:
    • undefined / null / []undefined (omitted)
    • otherwise string[], each entry trimmed + non-empty + ≤64 chars
    • deduped and sorted for stable serialization
    • closed-enum validation removed
  • PersonaSpec.tags widened from PersonaTag[] (required closed enum) to readonly string[] (optional).
  • PersonaTag type widened to string so existing imports keep compiling without forcing every consumer to migrate.
  • schemas/persona.schema.json regenerated:
    • tags.items widened from $ref: PersonaTag to { type: 'string', minLength: 1, maxLength: 64 }
    • tags removed from required[]
    • obsolete PersonaTag enum definition removed
  • emit-schema.mjs gains a tags-items post-process mirroring the existing tightenWorkspaceServiceAccountName pattern so the parser's 1..64-char bounds are reflected in the schema (the generator can't infer them from the TS type).
  • CLI (list / show / --filter-tag / persona-picker) handles optional spec.tags, lets --filter-tag <any-string> through, and surfaces the built-in tag tuple as a hint instead of a hard constraint.
  • local-personas.ts drops the closed-enum check for overlay/standalone tags and no longer requires tags on standalone files.
  • Tests — replaced the one closed-enum assertion with eight new cases:
    • parseTags accepts an array of arbitrary string tags
    • parseTags returns undefined when tags is missing
    • parseTags returns undefined when tags is null
    • parseTags returns undefined when tags is an empty array
    • parseTags rejects non-string entries
    • parseTags rejects empty / whitespace-only strings
    • parseTags rejects entries over 64 characters
    • parseTags rejects non-array input
    • parsePersonaSpec omits tags from the spec when the field is missing
  • packages/deploy/src/modes/cloud.test.ts — fixture cast changed from as PersonaSpec to as unknown as PersonaSpec because TS no longer narrows ['documentation'] to the (now-removed) closed enum.

Untouched (by design)

  • examples/notion-essay-pr/persona.json keeps tags: ["proactive", "notion", "github"] — the canonical example now proves the fix.
  • All 15 personas in packages/personas-core/personas/*.json and all 6 fixtures in packages/persona-kit/src/__fixtures__/personas/*.json still parse unchanged — they happened to be using built-in-tuple values, which are still valid string tags.
  • The proactive-agents user's manual workaround (.agentworkforce/notion-essay-pr/persona.json with tags: ["documentation"]) was not reverted — that file belongs to the user.

Test plan

  • corepack pnpm -F @agentworkforce/persona-kit typecheck green
  • corepack pnpm -F @agentworkforce/persona-kit test → 170 pass (was 162; net +8)
  • corepack pnpm run check green (lint + typecheck + tests across the workspace)
  • node packages/persona-kit/scripts/emit-schema.mjs produces a no-diff schema after the initial regeneration
  • Manual parse of examples/notion-essay-pr/persona.json succeeds — returns tags: ['github', 'notion', 'proactive'] (deduped + sorted)

🤖 Generated with Claude Code

parseTags rejected free-form strings (only accepted intent-enum values) AND
required the field to be present + non-empty. Both are wrong per the schema
in cloud#553 (`tags text[]` — denorm metadata, not a closed enum).

This broke the canonical reference persona (examples/notion-essay-pr) whose
tags are ['proactive', 'notion', 'github'] — none of which are in the intent
enum. First customer (proactive-agents) hit it deploying today.

- parseTags returns undefined when tags is missing/null/empty
- accepts any string[] with 1..64-char entries, deduped+sorted
- removes the closed-enum constraint
- widens PersonaSpec.tags to readonly string[] (optional)
- regenerates persona.schema.json with the new shape
  (tags items: { type: string, minLength: 1, maxLength: 64 }; tags
   dropped from `required`; PersonaTag enum definition removed)
- emit-schema.mjs gains a post-process for tags-items bounds matching
  the existing tightenWorkspaceServiceAccountName pattern
- cli list/show + local-personas accept any string tag; --filter-tag is
  free-form with built-in tags surfaced as a hint
- tests cover all the optional/free-form/validation paths

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Migrates persona tags from a closed enum to optional free-form strings with trimming, deduplication, sorting, and 1–64 character validation; updates types, parser, JSON schema/emitter, local-personas merging, CLI list/show/picker behavior, and tests.

Changes

Free-form persona tags migration

Layer / File(s) Summary
Type contract updates
packages/persona-kit/src/types.ts, packages/persona-kit/src/constants.ts
PersonaTagstring; PersonaSpec.tags now optional readonly string[]; JSDoc notes PERSONA_TAGS is a UX hint.
Parser implementation with validation
packages/persona-kit/src/parse.ts
Add PERSONA_TAG_MAX_LEN; parseTags accepts optional free-form tags, trims, rejects empty/whitespace, enforces ≤64 chars, dedupes+sorts, returns `readonly string[]
JSON schema and generation
packages/persona-kit/schemas/persona.schema.json, packages/persona-kit/scripts/emit-schema.mjs
Schema tags.items changed to inline string with minLength:1/maxLength:64; tags removed from required; PersonaTag enum removed; emit script injects length constraints post-generation.
Local persona parsing & merge
packages/cli/src/local-personas.ts
Drop PERSONA_TAGS import; parseOverride treats tags as optional, validates/trims each tag, normalizes via dedupe+sort, omits tags when none; standalone spec and merge include tags only when truthy.
CLI list/show/picker updates
packages/cli/src/cli.ts
--filter-tag help documents free-form tags; PersonaListRow.tags is readonly; collectPersonaRows() uses spec.tags ?? []; parseListArgs() trims and rejects empty filter values and accepts any non-empty tag; formatPersonaShow() shows (none) when absent; picker copies tags only when spec.tags exists.
Test coverage and validation
packages/persona-kit/src/parse.test.ts, packages/deploy/src/modes/cloud.test.ts
parse.test.ts updated: new expectations for optional free-form tags, trimming, dedupe+sort, type/length validation, and omission when absent; cloud.test.ts helper typing and personaWithUrl setup adjusted.

Sequence Diagram

sequenceDiagram
  participant User
  participant CLI
  participant LocalPersonas
  participant Parser
  participant Schema

  User->>CLI: run agentworkforce list --filter-tag " custom "
  CLI->>CLI: parseListArgs() trim & reject empty/whitespace
  CLI->>LocalPersonas: load/build persona specs
  LocalPersonas->>Parser: parseTags(raw.tags)
  Parser->>Parser: trim, validate (1–64), dedupe, sort
  Parser-->>LocalPersonas: readonly string[] | undefined
  LocalPersonas->>CLI: specs with tags? (omit when undefined)
  CLI->>CLI: collectPersonaRows() uses spec.tags ?? []
  CLI->>CLI: formatPersonaShow() shows tags or "(none)"
  CLI-->>User: rendered persona list/show
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • AgentWorkforce/workforce#73: Main PR updates persona-kit’s tag parsing/types/schema to make tags free-form strings (removing enum/PERSONA_TAGS validation).
  • AgentWorkforce/workforce#48: Related CLI picker candidate/tag handling changes affecting persona tag propagation.

Poem

🐰
Tags once boxed and small, now roam free,
Trimmed and sorted, tidy as can be.
Optional friends who may come or go,
CLI whispers "(none)" when they don't show.
A rabbit hops to celebrate the flow.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(persona-kit): tags optional + free-form per cloud#553 schema' accurately and concisely summarizes the main change: making persona tags optional and free-form instead of required and enum-constrained.
Description check ✅ Passed The description provides detailed context on the two bugs fixed, includes a real-world reproduction case, explains all significant changes across multiple files, and documents the test plan and verification steps.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/persona-kit-tags-optional-free-form

Comment @coderabbitai help to get the list of available commands and usage tips.

coderabbitai[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 parseOverride stores runtime null for tags when input is null, violating the declared type

When raw.tags is null, the guard at local-personas.ts:425 (raw.tags !== undefined && raw.tags !== null) skips validation, but line 492 stores tags: raw.tags as PersonaTag[] | undefined — casting null to PersonaTag[] | undefined. The LocalPersonaOverride interface declares tags?: PersonaTag[] (no null). While downstream usage happens to work (both ?? and truthiness checks treat null correctly), this type unsoundness is fragile — any future code that tests override.tags === undefined to detect "not provided" will incorrectly treat an explicit null override as "tags present" rather than "tags absent".

(Refers to line 492)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 29dcdf6. parseOverride now omits the tags key entirely from its return value when no tags were provided (using a conditional spread, same pattern as harness/model/harnessSettings). Combined with the existing raw.tags === null guard at line 426, LocalPersonaOverride.tags is now sound at runtime: either a trimmed/deduped/sorted PersonaTag[], or the property is absent. Never null, never [], never an explicit-undefined key, so override.tags === undefined / "tags" in override both correctly detect "not provided".

Comment on lines 429 to +436
}
const tags = new Set<string>();
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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 local-personas.ts parseOverride stores untrimmed tags while parse.ts parseTags trims them

The parseOverride function in local-personas.ts:429-434 validates that each tag's trimmed form is non-empty and ≤64 chars, but stores the original untrimmed value at local-personas.ts:492 (tags: raw.tags as PersonaTag[]). Meanwhile, parseTags in parse.ts:102-111 trims each entry before storing it (and also dedupes and sorts). This means a local persona override declaring tags: [" documentation "] passes validation but stores the whitespace-padded string. Downstream, --filter-tag documentation at cli.ts:2174 (r.tags.includes(filterTag)) won't match " documentation ", and tags: ["review", "review"] won't be deduped, unlike the same input through parseTags.

Suggested change
}
const tags = new Set<string>();
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) {
const seen = new Set<string>();
for (const [idx, tag] of raw.tags.entries()) {
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`);
}
seen.add(trimmed);
}
raw.tags = [...seen].sort();
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed by 431c403, which lands trim + dedupe + sort + collapse-empty for parseOverride in packages/cli/src/local-personas.ts (lines 425-444). Verified the current code matches your suggested patch — tags is stored only when at least one trimmed entry survives, deduped via Set and sorted. Closing.

miyaontherelay and others added 4 commits May 13, 2026 17:44
…parseOverride null path

- packages/deploy/src/modes/cloud.test.ts: route the PersonaSpec<->{cloud:{deployUrl}} casts through `unknown` at the construct site and at all three deployedUrl(...) call sites so TS no longer rejects the bidirectional conversion (CI typecheck blocker).
- packages/cli/src/local-personas.ts: omit `tags` from the parseOverride return object when no tags were provided, instead of returning `tags: undefined`. Combined with the existing `raw.tags === null` guard, this guarantees `LocalPersonaOverride.tags` is either a trimmed/deduped/sorted PersonaTag[] or absent — never null at runtime, never an empty array, never an explicit-undefined key. Addresses Devin BUG_0002.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/cli/src/local-personas.ts (1)

814-815: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use length checks when conditionally emitting tags.

Line 815 and Line 1024 currently use truthiness checks; empty arrays are truthy and can leak tags: [] into resolved specs. Use ?.length to preserve omit-when-empty semantics consistently.

Suggested patch
-    ...(override.tags ? { tags: override.tags } : {}),
+    ...(override.tags?.length ? { tags: override.tags } : {}),
@@
-    ...(mergedTags ? { tags: mergedTags } : {}),
+    ...(mergedTags?.length ? { tags: mergedTags } : {}),

Also applies to: 1020-1024

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/local-personas.ts` around lines 814 - 815, The current
conditional spreads use truthiness checks on override.tags which will still
include tags: [] for empty arrays; change those checks to use length checks
(e.g., override.tags?.length ? { tags: override.tags } : {}) so tags is omitted
when empty. Update every occurrence where override.tags is conditionally emitted
(the spread expression that builds the resolved spec) to use ?.length instead of
a plain truthiness check so empty arrays are not leaked into the resolved specs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@packages/cli/src/local-personas.ts`:
- Around line 814-815: The current conditional spreads use truthiness checks on
override.tags which will still include tags: [] for empty arrays; change those
checks to use length checks (e.g., override.tags?.length ? { tags: override.tags
} : {}) so tags is omitted when empty. Update every occurrence where
override.tags is conditionally emitted (the spread expression that builds the
resolved spec) to use ?.length instead of a plain truthiness check so empty
arrays are not leaked into the resolved specs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3114a675-4274-4573-b080-4fc39a5d327c

📥 Commits

Reviewing files that changed from the base of the PR and between e386c65 and 29dcdf6.

📒 Files selected for processing (2)
  • packages/cli/src/local-personas.ts
  • packages/deploy/src/modes/cloud.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/deploy/src/modes/cloud.test.ts

@khaliqgant khaliqgant merged commit 0d3f7f8 into main May 13, 2026
2 checks passed
@khaliqgant khaliqgant deleted the fix/persona-kit-tags-optional-free-form branch May 13, 2026 16:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants