import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { lastAssistantText, parseOutcomeMarker, parseSkillInvocation, planCompletion, renderPlaybookPrompt } from "../src/auto-advance.js"; import { clearActiveRun, createRunId, listRunIds, loadActiveRunId, loadRun, saveRun, setActiveRun } from "../src/state.js"; import { findPlaybook, loadPlaybooks } from "../src/playbooks.js"; import { getGitignoreAdvisory } from "../src/gitignore.js"; import { renderStepCard, renderValidationErrors } from "../src/render.js"; import { normalizeSkillCommandName, validatePlaybook, validateUniquePlaybookIds } from "../src/validation.js"; import type { LoadedPlaybook, PlaybookRunState } from "../src/types.js"; const WIDGET_ID = "pi-skill-playbook"; const COMMANDS = [ ["list", "list available playbooks"], ["start", "start a playbook run"], ["resume", "resume an active playbook run"], ["status", "show playbook run status"], ["done", "complete the current step"], ["choose", "choose a step outcome"], ["cancel", "cancel an active playbook run"], ] as const; type CommandContext = { cwd: string; hasUI: boolean; ui?: UiLike; }; type SelectOption = { label: string; value: T; }; export default function piSkillPlaybook(pi: ExtensionAPI) { let pendingSkillInvocation: string | undefined; pi.on("session_start", async (_event, ctx) => { if (!ctx.hasUI) return; const activeRunId = await loadActiveRunId(ctx.cwd); if (!activeRunId) { ctx.ui.setWidget(WIDGET_ID, undefined); return; } const run = await loadRun(ctx.cwd, activeRunId); if (!run || run.status !== "active") { await clearActiveRun(ctx.cwd); ctx.ui.setWidget(WIDGET_ID, undefined); return; } const playbook = await findPlaybook(ctx.cwd, run.playbookId); if (!playbook) { ctx.ui.setWidget(WIDGET_ID, [`Playbook run '${run.runId}' references missing playbook '${run.playbookId}'.`], { placement: "belowEditor", }); return; } ctx.ui.setWidget(WIDGET_ID, renderStepCard(playbook, run), { placement: "belowEditor" }); }); pi.on("input", (event) => { if (event.source === "extension") return { action: "continue" }; pendingSkillInvocation = parseSkillInvocation(event.text); return { action: "continue" }; }); pi.on("before_agent_start", async (event, ctx) => { const active = await loadActiveIfAvailable(ctx.cwd); if (!active) return; const prompt = renderPlaybookPrompt(active.playbook, active.run); if (!prompt) return; return { systemPrompt: `${event.systemPrompt}\n\n${prompt}` }; }); pi.on("agent_end", async (event, ctx) => { try { await processAgentCompletion(ctx.cwd, pendingSkillInvocation, lastAssistantText(event.messages), ctx.hasUI ? ctx.ui : undefined); } finally { pendingSkillInvocation = undefined; } }); for (const [command, description] of COMMANDS) { pi.registerCommand(`playbook:${command}`, { description: `Playbook: ${description}.`, handler: async (_args, ctx) => { try { await handlePlaybookCommand(pi, command, ctx); } catch (error) { notify(ctx.hasUI ? ctx.ui : undefined, error instanceof Error ? error.message : String(error), "error"); } }, }); } } export async function handlePlaybookCommand( pi: ExtensionAPI, command: string, ctx: CommandContext, ): Promise { const ui = ctx.hasUI ? ctx.ui : undefined; switch (command) { case "list": await listPlaybooks(pi, ctx.cwd, ui); return; case "start": await startPlaybook(pi, ctx.cwd, ui); return; case "resume": await resumeRun(ctx.cwd, ui); return; case "status": await showStatus(ctx.cwd, ui); return; case "done": await completeCurrentStep(ctx.cwd, ui); return; case "choose": await chooseOutcome(ctx.cwd, ui); return; case "cancel": case "stop": case "abort": await cancelRun(ctx.cwd, ui); return; case "import-web": case "record": notify(ui, `/playbook:${command} is deferred after the Core 6 MVP scaffold.`, "warning"); return; default: notify(ui, usage(), "error"); } } async function listPlaybooks(pi: ExtensionAPI, cwd: string, ui: UiLike | undefined): Promise { const playbooks = await loadPlaybooks(cwd); if (playbooks.length === 0) { notify(ui, "No playbooks found. Create .pi/playbooks/*.yml or copy samples/feature-development.yml.", "info"); return; } const availableSkills = getAvailableSkills(pi); const duplicateResult = validateUniquePlaybookIds(playbooks); const lines = playbooks.flatMap((playbook) => { const result = validatePlaybook(playbook, availableSkills, { requireSkills: false }); const marker = result.valid ? "ok" : "invalid"; return [`${marker} ${playbook.definition.id} - ${playbook.definition.name}`, ...result.errors.map((error) => ` - ${error}`)]; }); if (!duplicateResult.valid) lines.push(...duplicateResult.errors.map((error) => `invalid ${error}`)); notify(ui, lines.join("\n"), duplicateResult.valid ? "info" : "error"); } async function startPlaybook(pi: ExtensionAPI, cwd: string, ui: UiLike | undefined): Promise { const playbook = await pickPlaybook(pi, cwd, ui); if (!playbook) return; await createAndActivateRun(cwd, playbook, undefined, ui); } async function createAndActivateRun(cwd: string, playbook: LoadedPlaybook, runName: string | undefined, ui: UiLike | undefined): Promise { const now = new Date().toISOString(); const run: PlaybookRunState = { runId: createRunId(playbook.definition.id, runName), playbookId: playbook.definition.id, playbookPath: playbook.path, currentStep: playbook.definition.entry, status: "active", createdAt: now, updatedAt: now, history: [], }; await saveRun(cwd, run); await setActiveRun(cwd, run.runId); renderWidget(ui, playbook, run); const advisory = await getGitignoreAdvisory(cwd); notify(ui, [`Started ${run.runId}.`, ...(advisory ? ["", advisory] : [])].join("\n"), advisory ? "warning" : "info"); } async function resumeRun(cwd: string, ui: UiLike | undefined): Promise { const run = await pickActiveRun(cwd, ui, "Resume which playbook run?"); if (!run) return; const playbook = await findPlaybook(cwd, run.playbookId); if (!playbook) throw new Error(`Run '${run.runId}' references missing playbook '${run.playbookId}'.`); await setActiveRun(cwd, run.runId); renderWidget(ui, playbook, run); notify(ui, `Resumed ${run.runId}.`, "info"); } async function cancelRun(cwd: string, ui: UiLike | undefined): Promise { const run = await pickRunToCancel(cwd, ui); if (!run) return; if (run.status !== "active") { if ((await loadActiveRunId(cwd)) === run.runId) await clearActiveRun(cwd); clearWidget(ui); notify(ui, `Run '${run.runId}' is already ${run.status}.`, "info"); return; } const now = new Date().toISOString(); run.status = "cancelled"; run.updatedAt = now; run.history.push({ at: now, step: run.currentStep, outcome: "cancelled", to: "cancelled" }); await saveRun(cwd, run); if ((await loadActiveRunId(cwd)) === run.runId) await clearActiveRun(cwd); clearWidget(ui); notify(ui, `Cancelled playbook run ${run.runId}.`, "info"); } async function showStatus(cwd: string, ui: UiLike | undefined): Promise { const runId = await loadActiveRunId(cwd); if (!runId) { notify(ui, "No active playbook run.", "info"); clearWidget(ui); return; } const run = await loadRun(cwd, runId); if (!run) throw new Error(`Run '${runId}' not found.`); if (run.status !== "active") { notify(ui, `Run '${run.runId}' is ${run.status}.`, "info"); clearWidget(ui); return; } const playbook = await findPlaybook(cwd, run.playbookId); if (!playbook) throw new Error(`Run '${runId}' references missing playbook '${run.playbookId}'.`); const lines = renderStepCard(playbook, run); renderWidget(ui, playbook, run); const advisory = await getGitignoreAdvisory(cwd); notify(ui, [...lines, ...(advisory ? ["", advisory] : [])].join("\n"), advisory ? "warning" : "info"); } async function completeCurrentStep(cwd: string, ui: UiLike | undefined): Promise { const { run, playbook } = await loadActive(cwd); const step = playbook.definition.steps[run.currentStep]; if (!step) throw new Error(`Current step '${run.currentStep}' is missing.`); if (step.transitions.length === 0) { await completeRun(cwd, playbook, run, "complete", "complete", ui); return; } if (step.transitions.length > 1) { notify(ui, `Step attested. Choose outcome: ${step.transitions.map((t) => t.outcome).join(", ")}`, "info"); renderWidget(ui, playbook, run); return; } await advanceRun(cwd, playbook, run, step.transitions[0].outcome, ui); } async function chooseOutcome(cwd: string, ui: UiLike | undefined): Promise { const { run, playbook } = await loadActive(cwd); const selected = await pickOutcome(playbook, run, ui); if (!selected) return; await advanceRun(cwd, playbook, run, selected.outcome, ui); } async function pickPlaybook(pi: ExtensionAPI, cwd: string, ui: UiLike | undefined): Promise { if (!hasSelectionUI(ui)) { notify(ui, "Interactive playbook selection requires the Pi TUI. Run /playbook:start from the command palette.", "error"); return undefined; } const playbooks = await loadPlaybooks(cwd); if (playbooks.length === 0) { notify(ui, "No playbooks found. Create .pi/playbooks/*.yml or copy samples/feature-development.yml into .pi/playbooks/.", "info"); return undefined; } const availableSkills = getAvailableSkills(pi); const duplicateErrors = duplicateIdErrors(playbooks); const candidates = playbooks.map((playbook) => { const validation = validatePlaybook(playbook, availableSkills, { requireSkills: true }); const errors = [...validation.errors, ...(duplicateErrors.get(playbook) ?? [])]; return { playbook, errors, valid: errors.length === 0 }; }); if (candidates.length === 1) { const candidate = candidates[0]; if (!candidate.valid) { notify(ui, `Playbook validation failed:\n${renderValidationErrors(candidate.errors)}`, "error"); return undefined; } return candidate.playbook; } const options = candidates.map((candidate) => ({ label: playbookSelectionLabel(candidate.playbook, candidate.errors), value: candidate, })); const selected = await selectByLabel(ui, "Start which playbook?", options); if (!selected) return undefined; if (!selected.valid) { notify(ui, `Playbook validation failed:\n${renderValidationErrors(selected.errors)}`, "error"); return undefined; } return selected.playbook; } function playbookSelectionLabel(playbook: LoadedPlaybook, errors: string[]): string { const marker = errors.length === 0 ? "ok" : "invalid"; const suffix = errors.length === 0 ? "" : ` — ${errors.join("; ")}`; return `${playbook.definition.id} — ${playbook.definition.name} (${marker})${suffix}`; } function duplicateIdErrors(playbooks: LoadedPlaybook[]): Map { const byId = new Map(); for (const playbook of playbooks) { const id = playbook.definition.id; if (!id) continue; byId.set(id, [...(byId.get(id) ?? []), playbook]); } const result = new Map(); for (const [id, matches] of byId) { if (matches.length < 2) continue; const paths = matches.map((playbook) => playbook.path).join(", "); for (const playbook of matches) result.set(playbook, [`duplicate playbook id '${id}' in ${paths}`]); } return result; } async function pickActiveRun(cwd: string, ui: UiLike | undefined, title: string): Promise { if (!hasSelectionUI(ui)) { notify(ui, "Interactive run selection requires the Pi TUI. Run /playbook:resume from the command palette.", "error"); return undefined; } const runs = await getActiveRuns(cwd); if (runs.length === 0) { clearWidget(ui); notify(ui, "No active playbook runs. Start one with /playbook:start.", "info"); return undefined; } return selectByLabel(ui, title, runs.map((run) => ({ label: activeRunLabel(run), value: run }))); } async function pickRunToCancel(cwd: string, ui: UiLike | undefined): Promise { if (!hasConfirmUI(ui)) { notify(ui, "Interactive cancellation requires the Pi TUI. Run /playbook:cancel from the command palette.", "error"); return undefined; } const runs = await getActiveRuns(cwd); if (runs.length === 0) { clearWidget(ui); notify(ui, "No active playbook runs to cancel.", "info"); return undefined; } const run = runs.length === 1 ? runs[0] : await selectByLabel(ui, "Cancel which playbook run?", runs.map((candidate) => ({ label: activeRunLabel(candidate), value: candidate }))); if (!run) return undefined; const confirmed = await ui.confirm("Cancel playbook run?", `${run.runId} (${run.playbookId}) will be marked cancelled.`); if (!confirmed) { notify(ui, "Playbook cancellation skipped.", "info"); return undefined; } return run; } function activeRunLabel(run: PlaybookRunState): string { return `${run.runId} — ${run.playbookId} (updated ${run.updatedAt})`; } async function pickOutcome(playbook: LoadedPlaybook, run: PlaybookRunState, ui: UiLike | undefined) { if (!hasSelectionUI(ui)) { notify(ui, "Interactive outcome selection requires the Pi TUI. Run /playbook:choose from the command palette.", "error"); return undefined; } const step = playbook.definition.steps[run.currentStep]; if (!step) throw new Error(`Current step '${run.currentStep}' is missing.`); if (step.transitions.length === 0) { notify(ui, "Current step has no branch outcomes. Run /playbook:done to complete it.", "info"); return undefined; } return selectByLabel( ui, `Choose outcome for ${run.currentStep}`, step.transitions.map((transition) => ({ label: `${transition.outcome} → ${transition.to}`, value: transition })), ); } async function selectByLabel( ui: { select(title: string, options: string[]): Promise }, title: string, options: SelectOption[], ): Promise { const selected = await ui.select(title, options.map((option) => option.label)); return options.find((option) => option.label === selected)?.value; } function hasSelectionUI(ui: UiLike | undefined): ui is UiLike & { select: NonNullable } { return typeof ui?.select === "function"; } function hasConfirmUI( ui: UiLike | undefined, ): ui is UiLike & { select: NonNullable; confirm: NonNullable } { return hasSelectionUI(ui) && typeof ui.confirm === "function"; } async function advanceRun(cwd: string, playbook: LoadedPlaybook, run: PlaybookRunState, outcome: string, ui: UiLike | undefined): Promise { const step = playbook.definition.steps[run.currentStep]; if (!step) throw new Error(`Current step '${run.currentStep}' is missing.`); const transition = step.transitions.find((candidate) => candidate.outcome === outcome); if (!transition) { throw new Error(`Outcome '${outcome}' is not valid for step '${run.currentStep}'. Valid: ${step.transitions.map((t) => t.outcome).join(", ")}`); } await completeRun(cwd, playbook, run, transition.outcome, transition.to, ui); } async function completeRun(cwd: string, playbook: LoadedPlaybook, run: PlaybookRunState, outcome: string, to: string, ui: UiLike | undefined): Promise { const now = new Date().toISOString(); run.history.push({ at: now, step: run.currentStep, outcome, to }); run.updatedAt = now; if (to === "complete") { run.status = "completed"; await saveRun(cwd, run); await clearActiveRun(cwd); clearWidget(ui); notify(ui, `Completed playbook run ${run.runId}.`, "info"); return; } run.currentStep = to; await saveRun(cwd, run); await setActiveRun(cwd, run.runId); renderWidget(ui, playbook, run); notify(ui, `Advanced to '${to}'.`, "info"); } async function processAgentCompletion(cwd: string, invokedSkill: string | undefined, assistantText: string, ui: UiLike | undefined): Promise { const active = await loadActiveIfAvailable(cwd); if (!active) return; const marker = parseOutcomeMarker(assistantText); const plan = planCompletion(active.playbook, active.run, invokedSkill, marker); if (!plan) return; if (plan.kind === "auto") { await completeRun(cwd, active.playbook, active.run, plan.outcome ?? "complete", plan.to ?? "complete", ui); return; } if (plan.kind === "suggest") { renderWidget(ui, active.playbook, active.run, plan.message); notify(ui, plan.message, "info"); return; } notify(ui, plan.message, plan.kind === "warning" ? "warning" : "info"); } async function loadActiveIfAvailable(cwd: string): Promise<{ run: PlaybookRunState; playbook: LoadedPlaybook } | undefined> { const runId = await loadActiveRunId(cwd); if (!runId) return undefined; const run = await loadRun(cwd, runId); if (!run || run.status !== "active") return undefined; const playbook = await findPlaybook(cwd, run.playbookId); if (!playbook) return undefined; return { run, playbook }; } async function loadActive(cwd: string): Promise<{ run: PlaybookRunState; playbook: LoadedPlaybook }> { const runId = await loadActiveRunId(cwd); if (!runId) throw new Error("No active playbook run."); const run = await loadRun(cwd, runId); if (!run) throw new Error(`Active run '${runId}' not found.`); if (run.status !== "active") throw new Error(`Active run '${runId}' is ${run.status}.`); const playbook = await findPlaybook(cwd, run.playbookId); if (!playbook) throw new Error(`Run '${run.runId}' references missing playbook '${run.playbookId}'.`); return { run, playbook }; } function getAvailableSkills(pi: ExtensionAPI): ReadonlySet { const skills = pi.getCommands() .filter((command) => command.source === "skill") .map((command) => normalizeSkillCommandName(command.name)); return new Set(skills); } function renderWidget(ui: UiLike | undefined, playbook: LoadedPlaybook, run: PlaybookRunState, notice?: string): void { const lines = renderStepCard(playbook, run); ui?.setWidget(WIDGET_ID, notice ? [...lines, "", notice] : lines, { placement: "belowEditor" }); } function clearWidget(ui: UiLike | undefined): void { ui?.setWidget(WIDGET_ID, undefined); } function notify(ui: UiLike | undefined, message: string, level: "info" | "warning" | "error"): void { ui?.notify(message, level); } function usage(): string { return [ "Usage:", "/playbook:list", "/playbook:start", "/playbook:resume", "/playbook:status", "/playbook:done", "/playbook:choose", "/playbook:cancel", ].join("\n"); } async function getActiveRuns(cwd: string): Promise { const ids = await listRunIds(cwd); const runs = (await Promise.all(ids.map((id) => loadRun(cwd, id)))).filter((run): run is PlaybookRunState => Boolean(run)); return runs.filter((run) => run.status === "active").sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } interface UiLike { notify(message: string, level: "info" | "warning" | "error"): void; select?(title: string, options: string[]): Promise; confirm?(title: string, message: string): Promise; setWidget(id: string, content: string[] | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }): void; }