Skip to content

[persona-kit 3/8] Compose top-level persona-kit orchestration API #66

@willwashburn

Description

@willwashburn

Part of the persona-kit consolidation. Depends on #65. This is issue 3 of 8.

Goal

Now that the parsers, types, and side-effecting helpers all live inside @agentworkforce/persona-kit (from #65), introduce the top-level orchestration API that callers actually want:

  • A pure plan-builder that composes everything a persona needs into a single inspectable value.
  • A side-effecting executor that runs the plan in the right order and returns a handle that reverses every side effect.
  • Piecewise functions for advanced callers who want their own orchestration.

This is the API both the workforce CLI (#67) and the relay SDK (relay-side issue) will consume.

API to add — packages/persona-kit/src/index.ts

Plan types

export interface PersonaSpawnPlan {
  /** The fully resolved persona this plan was built from. */
  persona: ResolvedPersona;

  /** Which CLI to spawn ('claude' | 'codex' | 'opencode'). */
  cli: Harness;

  /** argv (excluding the cli itself) that the harness should be spawned with. */
  args: string[];

  /** Optional initial prompt — used by codex's argv-driven prompt mode. */
  initialPrompt?: string;

  /** MCP / harness config files to materialize before spawn (and restore after). */
  configFiles: InteractiveConfigFile[];

  /** Pure skills install plan (from materializeSkills). */
  skills: SkillMaterializationPlan;

  /** Resolved mount policy, or undefined if the persona declares none. */
  mount?: ResolvedMountPolicy;

  /** Sidecar markdown writes (claudeMd / agentsMd) staged for the run. */
  sidecars: ResolvedSidecarWrite[];

  /** Inputs as resolved env bindings, ready to merge into spawn env. */
  inputs: ResolvedInputBinding[];

  /** Final env (process.env merged with input bindings + persona env). */
  env: Record<string, string>;
}

Pure plan builder

export interface PlanOptions {
  /** Working directory the harness will be spawned in (default: process.cwd()). */
  cwd?: string;

  /**
   * Stage skills under this absolute directory instead of the repo's
   * .claude/skills/. Claude harness only — throws otherwise (matching the
   * existing materializeSkills behavior).
   */
  installRoot?: string;

  /** Extra env bindings to merge in. Persona env wins on conflict. */
  envOverrides?: Record<string, string>;
}

export function buildPersonaSpawnPlan(
  persona: ResolvedPersona,
  options?: PlanOptions
): PersonaSpawnPlan;

buildPersonaSpawnPlan is pure — no filesystem writes, no subprocesses. Composes buildInteractiveSpec, materializeSkills, mount/sidecar/input resolvers (all from persona-kit) into one inspectable value.

Side-effecting executor

export interface ExecutionHandle {
  /** Reverse every side effect in LIFO order. Idempotent; safe to call twice. */
  dispose(): Promise<void>;
}

export interface ExecuteOptions {
  /** Working directory for skill installs and sidecar writes. */
  cwd: string;

  /**
   * Whether to remove .claude/skills/<name> and friends when dispose() runs.
   * Default true — leaving someone's repo with a half-installed skills dir
   * after a one-shot spawn is the bigger surprise.
   */
  cleanupSkillsOnDispose?: boolean;
}

export function executePersonaSpawnPlan(
  plan: PersonaSpawnPlan,
  options: ExecuteOptions
): Promise<ExecutionHandle>;

executePersonaSpawnPlan runs the plan in a deterministic order with abort-on-failure. After this returns, the harness can be spawned. Order:

  1. applyPersonaMount(plan.mount, options) — mount policy first; nothing else cares if this fails.
  2. runSkillInstalls(plan.skills, options) — install before sidecars/configfiles so a failing skill doesn't strand a half-written sidecar on disk.
  3. writePersonaSidecars(plan.sidecars, options) — claudeMd / agentsMd to disk with restore tracking.
  4. materializePersonaConfigFiles(plan.configFiles, options) — opencode.json and friends.
  5. (No-op for renderPersonaInputs — inputs are already in plan.env.)

If any step throws, prior steps' handles are disposed before the error propagates. Caller never sees partial state.

Piecewise helpers (for advanced orchestration)

Already lifted in #65 as standalone functions; export them from the top-level barrel:

export function applyPersonaMount(mount: ResolvedMountPolicy | undefined, options: { cwd: string }): Promise<MountHandle | undefined>;
export function runSkillInstalls(plan: SkillMaterializationPlan, options: { cwd: string; cleanupOnDispose?: boolean }): Promise<SkillsHandle>;
export function writePersonaSidecars(sidecars: ResolvedSidecarWrite[], options: { cwd: string }): Promise<SidecarsHandle>;
export function materializePersonaConfigFiles(configFiles: InteractiveConfigFile[], options: { cwd: string }): Promise<ConfigFilesHandle>;

Each *Handle has the same dispose() shape as ExecutionHandle. Composability over magic.

Lower-level (re-exports for advanced callers)

export { buildInteractiveSpec, materializeSkills, parsePersonaFile, loadPersonas, resolvePersonaTier };
export type { PersonaSpec, ResolvedPersona, PersonaSkill, PersonaMount, PersonaInputSpec, Harness, PersonaTier, McpServerSpec, SkillMaterializationPlan, SkillInstall, InteractiveConfigFile };

Tasks

  • Implement buildPersonaSpawnPlan — composes existing helpers, no new logic. The function should be ~30 lines.
  • Implement executePersonaSpawnPlan with the LIFO-dispose-on-error contract.
  • Implement piecewise *Handle shapes consistently — each handle is { dispose(): Promise<void> }.
  • Define ResolvedMountPolicy, ResolvedSidecarWrite, ResolvedInputBinding (output types of the resolvers).
  • Document the public API in packages/persona-kit/README.md with a 'persona JSON → running agent' example end-to-end.
  • Add a sub-export split if the bundle leaks node: modules: @agentworkforce/persona-kit/plan (pure) vs @agentworkforce/persona-kit/execute (node-only). Verify by importing only /plan from a workerd-style env.

Tests (in this PR; expanded in 7/8)

  • buildPersonaSpawnPlan round-trip: persona JSON → plan → snapshot. Cover all three harnesses, with and without skills/mount/sidecars/inputs.
  • executePersonaSpawnPlan happy path: real child_process.spawn against a fixture install command (e.g., echo). Verify execution order, verify dispose() reverses everything.
  • executePersonaSpawnPlan failure path: install step throws → mount handle was disposed → no orphan files on disk → caller gets the original error.
  • Empty-skills path: buildPersonaSpawnPlan for a persona with no skills returns plan.skills.installs.length === 0 and executePersonaSpawnPlan is a no-op for the skills phase (still emits the claude scaffold mkdir if applicable, per existing buildInstallArtifacts behavior).

Constraints

  • The plan is the contract between author-time tools (relay's getPersonaSpawnPlan) and runtime. It must be JSON-serializable — no functions, no Symbols. Verify with JSON.parse(JSON.stringify(plan)) round-trip in tests.
  • The executor is the only side-effecting top-level function. Anything that does I/O has 'execute' or a verb-y name; pure functions don't.
  • Don't introduce a config object that mixes plan + execution options. Keep PlanOptions and ExecuteOptions separate.

Verification

  • pnpm --filter @agentworkforce/persona-kit test passes.
  • pnpm -r build and pnpm -r test across the monorepo still pass.
  • No callsite outside persona-kit needs to change as a result of this PR (workforce CLI migration is 4/8).

Reference

Plan section 1.3. Test cadence: run tests after each public function lands (plan builder → executor → piecewise) instead of stacking and debugging at the end.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions