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:
applyPersonaMount(plan.mount, options) — mount policy first; nothing else cares if this fails.
runSkillInstalls(plan.skills, options) — install before sidecars/configfiles so a failing skill doesn't strand a half-written sidecar on disk.
writePersonaSidecars(plan.sidecars, options) — claudeMd / agentsMd to disk with restore tracking.
materializePersonaConfigFiles(plan.configFiles, options) — opencode.json and friends.
- (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
Tests (in this PR; expanded in 7/8)
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
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.
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: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.tsPlan types
Pure plan builder
buildPersonaSpawnPlanis pure — no filesystem writes, no subprocesses. ComposesbuildInteractiveSpec,materializeSkills, mount/sidecar/input resolvers (all from persona-kit) into one inspectable value.Side-effecting executor
executePersonaSpawnPlanruns the plan in a deterministic order with abort-on-failure. After this returns, the harness can be spawned. Order:applyPersonaMount(plan.mount, options)— mount policy first; nothing else cares if this fails.runSkillInstalls(plan.skills, options)— install before sidecars/configfiles so a failing skill doesn't strand a half-written sidecar on disk.writePersonaSidecars(plan.sidecars, options)— claudeMd / agentsMd to disk with restore tracking.materializePersonaConfigFiles(plan.configFiles, options)— opencode.json and friends.renderPersonaInputs— inputs are already inplan.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:
Each
*Handlehas the samedispose()shape asExecutionHandle. Composability over magic.Lower-level (re-exports for advanced callers)
Tasks
buildPersonaSpawnPlan— composes existing helpers, no new logic. The function should be ~30 lines.executePersonaSpawnPlanwith the LIFO-dispose-on-error contract.*Handleshapes consistently — each handle is{ dispose(): Promise<void> }.ResolvedMountPolicy,ResolvedSidecarWrite,ResolvedInputBinding(output types of the resolvers).packages/persona-kit/README.mdwith a 'persona JSON → running agent' example end-to-end.node:modules:@agentworkforce/persona-kit/plan(pure) vs@agentworkforce/persona-kit/execute(node-only). Verify by importing only/planfrom a workerd-style env.Tests (in this PR; expanded in 7/8)
buildPersonaSpawnPlanround-trip: persona JSON → plan → snapshot. Cover all three harnesses, with and without skills/mount/sidecars/inputs.executePersonaSpawnPlanhappy path: realchild_process.spawnagainst a fixture install command (e.g.,echo). Verify execution order, verifydispose()reverses everything.executePersonaSpawnPlanfailure path: install step throws → mount handle was disposed → no orphan files on disk → caller gets the original error.buildPersonaSpawnPlanfor a persona with no skills returnsplan.skills.installs.length === 0andexecutePersonaSpawnPlanis a no-op for the skills phase (still emits the claude scaffold mkdir if applicable, per existingbuildInstallArtifactsbehavior).Constraints
getPersonaSpawnPlan) and runtime. It must be JSON-serializable — no functions, no Symbols. Verify withJSON.parse(JSON.stringify(plan))round-trip in tests.PlanOptionsandExecuteOptionsseparate.Verification
pnpm --filter @agentworkforce/persona-kit testpasses.pnpm -r buildandpnpm -r testacross the monorepo still pass.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.