pi-skill-playbook 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 eiei114
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # Pi Skill Playbook
2
+
3
+ Pi Skill Playbook is a Pi extension that guides Agent Skill usage flows with visible, human-mediated playbooks.
4
+
5
+ MVP scope:
6
+
7
+ - `/playbook list`
8
+ - `/playbook start <playbook-id> [--run <name>]`
9
+ - `/playbook resume <run-id>`
10
+ - `/playbook status [run-id]`
11
+ - `/playbook done`
12
+ - `/playbook choose <outcome>`
13
+ - strict YAML validation
14
+ - active run widget below the editor
15
+ - marker-based auto advance for single-outcome steps
16
+ - local run state in `.pi/playbook-runs/`
17
+
18
+ Deferred after scaffold:
19
+
20
+ - `/playbook import-web`
21
+ - `/playbook record`
22
+
23
+ ## Install from npm
24
+
25
+ ```bash
26
+ pi install npm:pi-skill-playbook
27
+ ```
28
+
29
+ Package version: `0.1.0`
30
+
31
+ ## Install locally
32
+
33
+ ```bash
34
+ cd C:/Users/Keisu/Projects/OSS/pi-skill-playbook
35
+ npm install
36
+ pi -e .
37
+ ```
38
+
39
+ Or install as a local Pi package:
40
+
41
+ ```bash
42
+ pi install C:/Users/Keisu/Projects/OSS/pi-skill-playbook
43
+ ```
44
+
45
+ ## Add a playbook to a project
46
+
47
+ MVP uses manual sample copy:
48
+
49
+ ```bash
50
+ mkdir -p .pi/playbooks
51
+ cp C:/Users/Keisu/Projects/OSS/pi-skill-playbook/samples/feature-development.yml .pi/playbooks/feature-development.yml
52
+ ```
53
+
54
+ Add personal run state to the target repo's `.gitignore`:
55
+
56
+ ```gitignore
57
+ .pi/playbook-runs/
58
+ ```
59
+
60
+ ## Use
61
+
62
+ ```text
63
+ /playbook list
64
+ /playbook start feature-development --run my-feature
65
+ /playbook done
66
+ /playbook choose pass
67
+ /playbook status
68
+ ```
69
+
70
+ The widget displays the current step, exact skill command, completion criteria, and outcome labels.
71
+
72
+ ## Auto advance
73
+
74
+ Playbooks default to `autoAdvance: auto`.
75
+
76
+ When a user explicitly runs the current step skill with `/skill:<name>`, Pi Skill Playbook injects a short prompt that asks the assistant to leave a visible completion marker:
77
+
78
+ ```text
79
+ PLAYBOOK_OUTCOME: ready-for-prd
80
+ ```
81
+
82
+ If the current step has exactly one outcome, a valid marker advances the run automatically. If the step has multiple outcomes, the marker is shown as a suggestion and the user must confirm with `/playbook choose <outcome>`.
83
+
84
+ Optional playbook setting:
85
+
86
+ ```yaml
87
+ autoAdvance: auto # default: marker can advance single-outcome steps
88
+ autoAdvance: suggest # marker only suggests /playbook done or /playbook choose
89
+ autoAdvance: off # no prompt injection or completion detection
90
+ ```
@@ -0,0 +1,500 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { lastAssistantText, parseOutcomeMarker, parseSkillInvocation, planCompletion, renderPlaybookPrompt } from "../src/auto-advance.js";
3
+ import { clearActiveRun, createRunId, listRunIds, loadActiveRunId, loadRun, saveRun, setActiveRun } from "../src/state.js";
4
+ import { findPlaybook, loadPlaybooks } from "../src/playbooks.js";
5
+ import { getGitignoreAdvisory } from "../src/gitignore.js";
6
+ import { renderStepCard, renderValidationErrors } from "../src/render.js";
7
+ import { normalizeSkillCommandName, validatePlaybook, validateUniquePlaybookIds } from "../src/validation.js";
8
+ import type { LoadedPlaybook, PlaybookRunState } from "../src/types.js";
9
+
10
+ const WIDGET_ID = "pi-skill-playbook";
11
+
12
+ export default function piSkillPlaybook(pi: ExtensionAPI) {
13
+ let completionCwd = process.cwd();
14
+ let pendingSkillInvocation: string | undefined;
15
+
16
+ pi.on("session_start", async (_event, ctx) => {
17
+ completionCwd = ctx.cwd;
18
+ if (!ctx.hasUI) return;
19
+ const activeRunId = await loadActiveRunId(ctx.cwd);
20
+ if (!activeRunId) {
21
+ ctx.ui.setWidget(WIDGET_ID, undefined);
22
+ return;
23
+ }
24
+ const run = await loadRun(ctx.cwd, activeRunId);
25
+ if (!run || run.status !== "active") {
26
+ await clearActiveRun(ctx.cwd);
27
+ ctx.ui.setWidget(WIDGET_ID, undefined);
28
+ return;
29
+ }
30
+ const playbook = await findPlaybook(ctx.cwd, run.playbookId);
31
+ if (!playbook) {
32
+ ctx.ui.setWidget(WIDGET_ID, [`Playbook run '${run.runId}' references missing playbook '${run.playbookId}'.`], {
33
+ placement: "belowEditor",
34
+ });
35
+ return;
36
+ }
37
+ ctx.ui.setWidget(WIDGET_ID, renderStepCard(playbook, run), { placement: "belowEditor" });
38
+ });
39
+
40
+ pi.on("input", (event) => {
41
+ if (event.source === "extension") return { action: "continue" };
42
+ pendingSkillInvocation = parseSkillInvocation(event.text);
43
+ return { action: "continue" };
44
+ });
45
+
46
+ pi.on("before_agent_start", async (event, ctx) => {
47
+ const active = await loadActiveIfAvailable(ctx.cwd);
48
+ if (!active) return;
49
+ const prompt = renderPlaybookPrompt(active.playbook, active.run);
50
+ if (!prompt) return;
51
+ return { systemPrompt: `${event.systemPrompt}\n\n${prompt}` };
52
+ });
53
+
54
+ pi.on("agent_end", async (event, ctx) => {
55
+ try {
56
+ await processAgentCompletion(ctx.cwd, pendingSkillInvocation, lastAssistantText(event.messages), ctx.hasUI ? ctx.ui : undefined);
57
+ } finally {
58
+ pendingSkillInvocation = undefined;
59
+ }
60
+ });
61
+
62
+ pi.registerCommand("playbook", {
63
+ description: "Guide Agent Skill workflows with project-local playbooks",
64
+ getArgumentCompletions: (prefix) => getPlaybookArgumentCompletions(completionCwd, prefix),
65
+ handler: async (args, ctx) => {
66
+ const parsed = parseArgs(args);
67
+ const command = parsed.shift() ?? "status";
68
+
69
+ try {
70
+ switch (command) {
71
+ case "list":
72
+ await listPlaybooks(pi, ctx.cwd, ctx.hasUI ? ctx.ui : undefined);
73
+ return;
74
+ case "start":
75
+ await startPlaybook(pi, ctx.cwd, parsed, ctx.hasUI ? ctx.ui : undefined);
76
+ return;
77
+ case "resume":
78
+ await resumeRun(ctx.cwd, parsed, ctx.hasUI ? ctx.ui : undefined);
79
+ return;
80
+ case "status":
81
+ await showStatus(ctx.cwd, parsed[0], ctx.hasUI ? ctx.ui : undefined);
82
+ return;
83
+ case "done":
84
+ await completeCurrentStep(ctx.cwd, ctx.hasUI ? ctx.ui : undefined);
85
+ return;
86
+ case "choose":
87
+ await chooseOutcome(ctx.cwd, parsed[0], ctx.hasUI ? ctx.ui : undefined);
88
+ return;
89
+ case "cancel":
90
+ case "stop":
91
+ case "abort":
92
+ await cancelRun(ctx.cwd, parsed[0], ctx.hasUI ? ctx.ui : undefined);
93
+ return;
94
+ case "import-web":
95
+ case "record":
96
+ notify(ctx.hasUI ? ctx.ui : undefined, `/${command} is deferred after the Core 6 MVP scaffold.` , "warning");
97
+ return;
98
+ default:
99
+ notify(ctx.hasUI ? ctx.ui : undefined, usage(), "error");
100
+ }
101
+ } catch (error) {
102
+ notify(ctx.hasUI ? ctx.ui : undefined, error instanceof Error ? error.message : String(error), "error");
103
+ }
104
+ },
105
+ });
106
+ }
107
+
108
+ async function listPlaybooks(pi: ExtensionAPI, cwd: string, ui: UiLike | undefined): Promise<void> {
109
+ const playbooks = await loadPlaybooks(cwd);
110
+ if (playbooks.length === 0) {
111
+ notify(ui, "No playbooks found. Create .pi/playbooks/*.yml or copy samples/feature-development.yml.", "info");
112
+ return;
113
+ }
114
+
115
+ const availableSkills = getAvailableSkills(pi);
116
+ const duplicateResult = validateUniquePlaybookIds(playbooks);
117
+ const lines = playbooks.flatMap((playbook) => {
118
+ const result = validatePlaybook(playbook, availableSkills, { requireSkills: false });
119
+ const marker = result.valid ? "ok" : "invalid";
120
+ return [`${marker} ${playbook.definition.id} - ${playbook.definition.name}`, ...result.errors.map((error) => ` - ${error}`)];
121
+ });
122
+ if (!duplicateResult.valid) lines.push(...duplicateResult.errors.map((error) => `invalid ${error}`));
123
+ notify(ui, lines.join("\n"), duplicateResult.valid ? "info" : "error");
124
+ }
125
+
126
+ async function startPlaybook(pi: ExtensionAPI, cwd: string, args: string[], ui: UiLike | undefined): Promise<void> {
127
+ const playbookId = args[0];
128
+ if (!playbookId) throw new Error("Usage: /playbook start <playbook-id> [--run <name>]");
129
+
130
+ const playbook = await findPlaybook(cwd, playbookId);
131
+ if (!playbook) throw new Error(`Playbook '${playbookId}' not found in .pi/playbooks/.`);
132
+
133
+ const validation = validatePlaybook(playbook, getAvailableSkills(pi), { requireSkills: true });
134
+ if (!validation.valid) {
135
+ throw new Error(`Playbook validation failed:\n${renderValidationErrors(validation.errors)}`);
136
+ }
137
+
138
+ const runName = readFlagValue(args.slice(1), "--run");
139
+ const now = new Date().toISOString();
140
+ const run: PlaybookRunState = {
141
+ runId: createRunId(playbook.definition.id, runName),
142
+ playbookId: playbook.definition.id,
143
+ playbookPath: playbook.path,
144
+ currentStep: playbook.definition.entry,
145
+ status: "active",
146
+ createdAt: now,
147
+ updatedAt: now,
148
+ history: [],
149
+ };
150
+
151
+ await saveRun(cwd, run);
152
+ await setActiveRun(cwd, run.runId);
153
+ renderWidget(ui, playbook, run);
154
+ const advisory = await getGitignoreAdvisory(cwd);
155
+ notify(ui, [`Started ${run.runId}.`, ...(advisory ? ["", advisory] : [])].join("\n"), advisory ? "warning" : "info");
156
+ }
157
+
158
+ async function resumeRun(cwd: string, args: string[], ui: UiLike | undefined): Promise<void> {
159
+ const runId = args[0];
160
+ if (!runId) throw new Error("Usage: /playbook resume <run-id>");
161
+ const run = await loadRun(cwd, runId);
162
+ if (!run) throw new Error(`Run '${runId}' not found.`);
163
+ if (run.status !== "active") throw new Error(`Run '${runId}' is ${run.status}.`);
164
+ const playbook = await findPlaybook(cwd, run.playbookId);
165
+ if (!playbook) throw new Error(`Run '${runId}' references missing playbook '${run.playbookId}'.`);
166
+ await setActiveRun(cwd, run.runId);
167
+ renderWidget(ui, playbook, run);
168
+ notify(ui, `Resumed ${run.runId}.`, "info");
169
+ }
170
+
171
+ async function cancelRun(cwd: string, explicitRunId: string | undefined, ui: UiLike | undefined): Promise<void> {
172
+ const runId = explicitRunId ?? (await loadActiveRunId(cwd));
173
+ if (!runId) throw new Error("Usage: /playbook cancel [run-id]");
174
+ const run = await loadRun(cwd, runId);
175
+ if (!run) throw new Error(`Run '${runId}' not found.`);
176
+
177
+ if (run.status !== "active") {
178
+ if ((await loadActiveRunId(cwd)) === run.runId) await clearActiveRun(cwd);
179
+ clearWidget(ui);
180
+ notify(ui, `Run '${run.runId}' is already ${run.status}.`, "info");
181
+ return;
182
+ }
183
+
184
+ const now = new Date().toISOString();
185
+ run.status = "cancelled";
186
+ run.updatedAt = now;
187
+ run.history.push({ at: now, step: run.currentStep, outcome: "cancelled", to: "cancelled" });
188
+ await saveRun(cwd, run);
189
+ if ((await loadActiveRunId(cwd)) === run.runId) await clearActiveRun(cwd);
190
+ clearWidget(ui);
191
+ notify(ui, `Cancelled playbook run ${run.runId}.`, "info");
192
+ }
193
+
194
+ async function showStatus(cwd: string, explicitRunId: string | undefined, ui: UiLike | undefined): Promise<void> {
195
+ const runId = explicitRunId ?? (await loadActiveRunId(cwd));
196
+ if (!runId) {
197
+ notify(ui, "No active playbook run.", "info");
198
+ clearWidget(ui);
199
+ return;
200
+ }
201
+ const run = await loadRun(cwd, runId);
202
+ if (!run) throw new Error(`Run '${runId}' not found.`);
203
+ if (run.status !== "active") {
204
+ notify(ui, `Run '${run.runId}' is ${run.status}.`, "info");
205
+ if (!explicitRunId) clearWidget(ui);
206
+ return;
207
+ }
208
+ const playbook = await findPlaybook(cwd, run.playbookId);
209
+ if (!playbook) throw new Error(`Run '${runId}' references missing playbook '${run.playbookId}'.`);
210
+ const lines = renderStepCard(playbook, run);
211
+ renderWidget(ui, playbook, run);
212
+ const advisory = await getGitignoreAdvisory(cwd);
213
+ notify(ui, [...lines, ...(advisory ? ["", advisory] : [])].join("\n"), advisory ? "warning" : "info");
214
+ }
215
+
216
+ async function completeCurrentStep(cwd: string, ui: UiLike | undefined): Promise<void> {
217
+ const { run, playbook } = await loadActive(cwd);
218
+ const step = playbook.definition.steps[run.currentStep];
219
+ if (!step) throw new Error(`Current step '${run.currentStep}' is missing.`);
220
+
221
+ if (step.transitions.length === 0) {
222
+ await completeRun(cwd, playbook, run, "complete", "complete", ui);
223
+ return;
224
+ }
225
+ if (step.transitions.length > 1) {
226
+ notify(ui, `Step attested. Choose outcome: ${step.transitions.map((t) => t.outcome).join(", ")}`, "info");
227
+ renderWidget(ui, playbook, run);
228
+ return;
229
+ }
230
+ await advanceRun(cwd, playbook, run, step.transitions[0].outcome, ui);
231
+ }
232
+
233
+ async function chooseOutcome(cwd: string, outcome: string | undefined, ui: UiLike | undefined): Promise<void> {
234
+ if (!outcome) throw new Error("Usage: /playbook choose <outcome>");
235
+ const { run, playbook } = await loadActive(cwd);
236
+ await advanceRun(cwd, playbook, run, outcome, ui);
237
+ }
238
+
239
+ async function advanceRun(cwd: string, playbook: LoadedPlaybook, run: PlaybookRunState, outcome: string, ui: UiLike | undefined): Promise<void> {
240
+ const step = playbook.definition.steps[run.currentStep];
241
+ if (!step) throw new Error(`Current step '${run.currentStep}' is missing.`);
242
+ const transition = step.transitions.find((candidate) => candidate.outcome === outcome);
243
+ if (!transition) {
244
+ throw new Error(`Outcome '${outcome}' is not valid for step '${run.currentStep}'. Valid: ${step.transitions.map((t) => t.outcome).join(", ")}`);
245
+ }
246
+ await completeRun(cwd, playbook, run, transition.outcome, transition.to, ui);
247
+ }
248
+
249
+ async function completeRun(cwd: string, playbook: LoadedPlaybook, run: PlaybookRunState, outcome: string, to: string, ui: UiLike | undefined): Promise<void> {
250
+ const now = new Date().toISOString();
251
+ run.history.push({ at: now, step: run.currentStep, outcome, to });
252
+ run.updatedAt = now;
253
+
254
+ if (to === "complete") {
255
+ run.status = "completed";
256
+ await saveRun(cwd, run);
257
+ await clearActiveRun(cwd);
258
+ clearWidget(ui);
259
+ notify(ui, `Completed playbook run ${run.runId}.`, "info");
260
+ return;
261
+ }
262
+
263
+ run.currentStep = to;
264
+ await saveRun(cwd, run);
265
+ await setActiveRun(cwd, run.runId);
266
+ renderWidget(ui, playbook, run);
267
+ notify(ui, `Advanced to '${to}'.`, "info");
268
+ }
269
+
270
+ async function processAgentCompletion(cwd: string, invokedSkill: string | undefined, assistantText: string, ui: UiLike | undefined): Promise<void> {
271
+ const active = await loadActiveIfAvailable(cwd);
272
+ if (!active) return;
273
+
274
+ const marker = parseOutcomeMarker(assistantText);
275
+ const plan = planCompletion(active.playbook, active.run, invokedSkill, marker);
276
+ if (!plan) return;
277
+
278
+ if (plan.kind === "auto") {
279
+ await completeRun(cwd, active.playbook, active.run, plan.outcome ?? "complete", plan.to ?? "complete", ui);
280
+ return;
281
+ }
282
+
283
+ if (plan.kind === "suggest") {
284
+ renderWidget(ui, active.playbook, active.run, plan.message);
285
+ notify(ui, plan.message, "info");
286
+ return;
287
+ }
288
+
289
+ notify(ui, plan.message, plan.kind === "warning" ? "warning" : "info");
290
+ }
291
+
292
+ async function loadActiveIfAvailable(cwd: string): Promise<{ run: PlaybookRunState; playbook: LoadedPlaybook } | undefined> {
293
+ const runId = await loadActiveRunId(cwd);
294
+ if (!runId) return undefined;
295
+ const run = await loadRun(cwd, runId);
296
+ if (!run || run.status !== "active") return undefined;
297
+ const playbook = await findPlaybook(cwd, run.playbookId);
298
+ if (!playbook) return undefined;
299
+ return { run, playbook };
300
+ }
301
+
302
+ async function loadActive(cwd: string): Promise<{ run: PlaybookRunState; playbook: LoadedPlaybook }> {
303
+ const runId = await loadActiveRunId(cwd);
304
+ if (!runId) throw new Error("No active playbook run.");
305
+ const run = await loadRun(cwd, runId);
306
+ if (!run) throw new Error(`Active run '${runId}' not found.`);
307
+ if (run.status !== "active") throw new Error(`Active run '${runId}' is ${run.status}.`);
308
+ const playbook = await findPlaybook(cwd, run.playbookId);
309
+ if (!playbook) throw new Error(`Run '${run.runId}' references missing playbook '${run.playbookId}'.`);
310
+ return { run, playbook };
311
+ }
312
+
313
+ function getAvailableSkills(pi: ExtensionAPI): ReadonlySet<string> {
314
+ const skills = pi.getCommands()
315
+ .filter((command) => command.source === "skill")
316
+ .map((command) => normalizeSkillCommandName(command.name));
317
+ return new Set(skills);
318
+ }
319
+
320
+ function renderWidget(ui: UiLike | undefined, playbook: LoadedPlaybook, run: PlaybookRunState, notice?: string): void {
321
+ const lines = renderStepCard(playbook, run);
322
+ ui?.setWidget(WIDGET_ID, notice ? [...lines, "", notice] : lines, { placement: "belowEditor" });
323
+ }
324
+
325
+ function clearWidget(ui: UiLike | undefined): void {
326
+ ui?.setWidget(WIDGET_ID, undefined);
327
+ }
328
+
329
+ function notify(ui: UiLike | undefined, message: string, level: "info" | "warning" | "error"): void {
330
+ ui?.notify(message, level);
331
+ }
332
+
333
+ function usage(): string {
334
+ return [
335
+ "Usage:",
336
+ "/playbook list",
337
+ "/playbook start <playbook-id> [--run <name>]",
338
+ "/playbook resume <run-id>",
339
+ "/playbook status [run-id]",
340
+ "/playbook done",
341
+ "/playbook choose <outcome>",
342
+ "/playbook cancel [run-id]",
343
+ ].join("\n");
344
+ }
345
+
346
+ type CompletionItem = { value: string; label: string; description?: string };
347
+
348
+ export async function getPlaybookArgumentCompletions(cwd: string, prefix: string): Promise<CompletionItem[] | null> {
349
+ try {
350
+ return await getPlaybookArgumentCompletionsUnsafe(cwd, prefix);
351
+ } catch {
352
+ return null;
353
+ }
354
+ }
355
+
356
+ async function getPlaybookArgumentCompletionsUnsafe(cwd: string, prefix: string): Promise<CompletionItem[] | null> {
357
+ const parsed = parseCompletionPrefix(prefix);
358
+ const command = parsed.command;
359
+ if (!command) {
360
+ return completeCommands(parsed.currentToken);
361
+ }
362
+
363
+ if (parsed.completedTokens.length === 0) {
364
+ return completeCommands(parsed.currentToken);
365
+ }
366
+
367
+ switch (command) {
368
+ case "start":
369
+ return completeStartArguments(cwd, parsed);
370
+ case "resume":
371
+ return completeRunArgument(cwd, parsed, command, { activeOnly: true });
372
+ case "status":
373
+ return completeRunArgument(cwd, parsed, command, { activeOnly: false, optional: true });
374
+ case "cancel":
375
+ case "stop":
376
+ case "abort":
377
+ return completeRunArgument(cwd, parsed, command, { activeOnly: true, optional: true });
378
+ case "choose":
379
+ return completeOutcomeArgument(cwd, parsed);
380
+ default:
381
+ return null;
382
+ }
383
+ }
384
+
385
+ function completeCommands(token: string): CompletionItem[] | null {
386
+ const commands = ["list", "start", "resume", "status", "done", "choose", "cancel"];
387
+ return toCompletionItems(commands, token, (command) => command, (command) => command);
388
+ }
389
+
390
+ async function completeStartArguments(cwd: string, parsed: CompletionPrefix): Promise<CompletionItem[] | null> {
391
+ const args = parsed.completedTokens.slice(1);
392
+ if (args.length === 0) {
393
+ const playbooks = await loadPlaybooks(cwd);
394
+ return toCompletionItems(
395
+ playbooks,
396
+ parsed.currentToken,
397
+ (playbook) => `start ${playbook.definition.id}`,
398
+ (playbook) => playbook.definition.id,
399
+ (playbook) => playbook.definition.name,
400
+ );
401
+ }
402
+
403
+ if (args.length >= 1 && !args.includes("--run")) {
404
+ return toCompletionItems(["--run"], parsed.currentToken, (flag) => `start ${args.join(" ")} ${flag} `, (flag) => flag);
405
+ }
406
+ return null;
407
+ }
408
+
409
+ async function completeRunArgument(
410
+ cwd: string,
411
+ parsed: CompletionPrefix,
412
+ command: string,
413
+ options: { activeOnly: boolean; optional?: boolean },
414
+ ): Promise<CompletionItem[] | null> {
415
+ const args = parsed.completedTokens.slice(1);
416
+ if (args.length > 0 || (options.optional && parsed.currentToken === "" && !parsed.trailingWhitespace)) return null;
417
+ const runs = await getRunCompletionCandidates(cwd, options.activeOnly);
418
+ return toCompletionItems(
419
+ runs,
420
+ parsed.currentToken,
421
+ (run) => `${command} ${run.runId}`,
422
+ (run) => run.runId,
423
+ (run) => `${run.playbookId} (${run.status})`,
424
+ );
425
+ }
426
+
427
+ async function completeOutcomeArgument(cwd: string, parsed: CompletionPrefix): Promise<CompletionItem[] | null> {
428
+ const args = parsed.completedTokens.slice(1);
429
+ if (args.length > 0) return null;
430
+ const { run, playbook } = await loadActive(cwd);
431
+ const step = playbook.definition.steps[run.currentStep];
432
+ if (!step) return null;
433
+ return toCompletionItems(
434
+ step.transitions,
435
+ parsed.currentToken,
436
+ (transition) => `choose ${transition.outcome}`,
437
+ (transition) => transition.outcome,
438
+ (transition) => `to ${transition.to}`,
439
+ );
440
+ }
441
+
442
+ async function getRunCompletionCandidates(cwd: string, activeOnly: boolean): Promise<PlaybookRunState[]> {
443
+ const ids = await listRunIds(cwd);
444
+ const runs = (await Promise.all(ids.map((id) => loadRun(cwd, id)))).filter((run): run is PlaybookRunState => Boolean(run));
445
+ const filtered = activeOnly ? runs.filter((run) => run.status === "active") : runs;
446
+ return filtered.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
447
+ }
448
+
449
+ function toCompletionItems<T>(
450
+ values: T[],
451
+ token: string,
452
+ valueFor: (item: T) => string,
453
+ labelFor: (item: T) => string,
454
+ descriptionFor?: (item: T) => string | undefined,
455
+ ): CompletionItem[] | null {
456
+ const normalizedToken = token.trim().toLowerCase();
457
+ const items = values
458
+ .filter((item) => matchesCompletion(labelFor(item), normalizedToken))
459
+ .map((item) => {
460
+ const description = descriptionFor?.(item);
461
+ return { value: valueFor(item), label: labelFor(item), ...(description ? { description } : {}) };
462
+ });
463
+ return items.length > 0 ? items : null;
464
+ }
465
+
466
+ function matchesCompletion(value: string, token: string): boolean {
467
+ if (token === "") return true;
468
+ return value.toLowerCase().includes(token);
469
+ }
470
+
471
+ type CompletionPrefix = {
472
+ completedTokens: string[];
473
+ currentToken: string;
474
+ command: string | undefined;
475
+ trailingWhitespace: boolean;
476
+ };
477
+
478
+ function parseCompletionPrefix(prefix: string): CompletionPrefix {
479
+ const trailingWhitespace = /\s$/.test(prefix);
480
+ const tokens = parseArgs(prefix);
481
+ const completedTokens = trailingWhitespace ? tokens : tokens.slice(0, -1);
482
+ const currentToken = trailingWhitespace ? "" : tokens.at(-1) ?? "";
483
+ return { completedTokens, currentToken, command: completedTokens[0] ?? (!trailingWhitespace && tokens.length > 1 ? tokens[0] : undefined), trailingWhitespace };
484
+ }
485
+
486
+ function parseArgs(args: string): string[] {
487
+ const matches = args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
488
+ return matches.map((arg) => arg.replace(/^(["'])(.*)\1$/, "$2"));
489
+ }
490
+
491
+ function readFlagValue(args: string[], flag: string): string | undefined {
492
+ const index = args.indexOf(flag);
493
+ if (index === -1) return undefined;
494
+ return args[index + 1];
495
+ }
496
+
497
+ interface UiLike {
498
+ notify(message: string, level: "info" | "warning" | "error"): void;
499
+ setWidget(id: string, content: string[] | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
500
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "pi-skill-playbook",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Pi extension for passive, human-mediated Agent Skill playbooks.",
6
+ "keywords": ["pi-package", "pi-extension", "agent-skills", "playbook"],
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/eiei114/pi-skill-playbook.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/eiei114/pi-skill-playbook/issues"
13
+ },
14
+ "homepage": "https://github.com/eiei114/pi-skill-playbook#readme",
15
+ "license": "MIT",
16
+ "files": [
17
+ "extensions/",
18
+ "src/",
19
+ "samples/",
20
+ "LICENSE",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "check": "tsc --noEmit",
25
+ "test": "node --test --import tsx tests/*.test.ts",
26
+ "build": "npm run check"
27
+ },
28
+ "pi": {
29
+ "extensions": ["./extensions/index.ts"]
30
+ },
31
+ "dependencies": {
32
+ "yaml": "^2.8.1"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^24.0.0",
36
+ "tsx": "^4.20.0",
37
+ "typescript": "^5.8.0"
38
+ },
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-coding-agent": "*"
41
+ }
42
+ }
@@ -0,0 +1,67 @@
1
+ version: 1
2
+ id: feature-development
3
+ name: Feature Development Playbook
4
+ entry: grill
5
+ autoAdvance: auto
6
+
7
+ skills:
8
+ grill-with-docs:
9
+ role: entry
10
+ to-prd:
11
+ role: internal
12
+ to-issues:
13
+ role: internal
14
+ tdd:
15
+ role: internal
16
+ review:
17
+ role: internal
18
+
19
+ steps:
20
+ grill:
21
+ primarySkill: grill-with-docs
22
+ commandHint: "/skill:grill-with-docs <feature idea>"
23
+ doneWhen:
24
+ - Problem boundary is clear.
25
+ - Key terminology is resolved.
26
+ transitions:
27
+ - outcome: ready-for-prd
28
+ to: prd
29
+
30
+ prd:
31
+ primarySkill: to-prd
32
+ commandHint: "/skill:to-prd create PRD from resolved design"
33
+ doneWhen:
34
+ - PRD exists.
35
+ transitions:
36
+ - outcome: ready-for-issues
37
+ to: issues
38
+
39
+ issues:
40
+ primarySkill: to-issues
41
+ commandHint: "/skill:to-issues break PRD into implementation issues"
42
+ doneWhen:
43
+ - Issues are independently grabbable.
44
+ transitions:
45
+ - outcome: ready-for-implementation
46
+ to: implement
47
+
48
+ implement:
49
+ primarySkill: tdd
50
+ commandHint: "/skill:tdd implement the next issue"
51
+ doneWhen:
52
+ - Tests pass.
53
+ - Implementation is complete.
54
+ transitions:
55
+ - outcome: ready-for-review
56
+ to: review
57
+
58
+ review:
59
+ primarySkill: review
60
+ commandHint: "/skill:review review this branch against the plan"
61
+ doneWhen:
62
+ - Review result is known.
63
+ transitions:
64
+ - outcome: pass
65
+ to: complete
66
+ - outcome: fail
67
+ to: implement
@@ -0,0 +1,181 @@
1
+ import type { AdvanceMode, LoadedPlaybook, PlaybookRunState, PlaybookStep } from "./types.js";
2
+
3
+ export type OutcomeMarker =
4
+ | { kind: "outcome"; outcome: string }
5
+ | { kind: "done" };
6
+
7
+ export interface CompletionPlan {
8
+ kind: "auto" | "suggest" | "ignore" | "warning";
9
+ outcome?: string;
10
+ to?: string;
11
+ message: string;
12
+ }
13
+
14
+ export function getAdvanceMode(playbook: LoadedPlaybook): AdvanceMode {
15
+ return playbook.definition.autoAdvance ?? "auto";
16
+ }
17
+
18
+ export function parseSkillInvocation(input: string): string | undefined {
19
+ const match = input.match(/^\s*\/skill:([a-z0-9][a-z0-9-]*)\b/i);
20
+ return match?.[1]?.toLowerCase();
21
+ }
22
+
23
+ export function parseOutcomeMarker(text: string): OutcomeMarker | undefined {
24
+ const outcomeMatch = text.match(/^\s*PLAYBOOK_OUTCOME:\s*([a-z0-9][a-z0-9-]*)\s*$/im);
25
+ if (outcomeMatch?.[1]) return { kind: "outcome", outcome: outcomeMatch[1].toLowerCase() };
26
+ if (/^\s*PLAYBOOK_DONE\s*$/im.test(text)) return { kind: "done" };
27
+ return undefined;
28
+ }
29
+
30
+ export function renderPlaybookPrompt(playbook: LoadedPlaybook, run: PlaybookRunState): string | undefined {
31
+ if (getAdvanceMode(playbook) === "off") return undefined;
32
+ const step = playbook.definition.steps[run.currentStep];
33
+ if (!step) return undefined;
34
+
35
+ const outcomes = step.transitions.map((transition) => transition.outcome);
36
+ const markerInstruction = outcomes.length === 0
37
+ ? "If you complete this final step, end your response with exactly: PLAYBOOK_DONE"
38
+ : outcomes.length === 1
39
+ ? `If you complete this step, end your response with exactly: PLAYBOOK_OUTCOME: ${outcomes[0]}`
40
+ : `If you complete this step, end your response with one recommended outcome marker such as: PLAYBOOK_OUTCOME: ${outcomes[0]}`;
41
+ const multiOutcomeNote = outcomes.length > 1
42
+ ? "This step has multiple outcomes; the marker will be shown to the user for explicit confirmation, not auto-applied."
43
+ : "Single-outcome completion markers may advance the active playbook automatically.";
44
+
45
+ return [
46
+ "Active Pi Skill Playbook run:",
47
+ `- Playbook: ${playbook.definition.name}`,
48
+ `- Run: ${run.runId}`,
49
+ `- Step: ${run.currentStep}`,
50
+ `- Primary skill: ${step.primarySkill}`,
51
+ `- Valid outcomes: ${outcomes.length > 0 ? outcomes.join(", ") : "complete"}`,
52
+ markerInstruction,
53
+ multiOutcomeNote,
54
+ "Do not emit a marker unless the active step is genuinely complete.",
55
+ ].join("\n");
56
+ }
57
+
58
+ export function planCompletion(
59
+ playbook: LoadedPlaybook,
60
+ run: PlaybookRunState,
61
+ invokedSkill: string | undefined,
62
+ marker: OutcomeMarker | undefined,
63
+ ): CompletionPlan | undefined {
64
+ const mode = getAdvanceMode(playbook);
65
+ if (mode === "off") return undefined;
66
+
67
+ const step = playbook.definition.steps[run.currentStep];
68
+ if (!step) return { kind: "warning", message: `Current step '${run.currentStep}' is missing.` };
69
+
70
+ const matchingInvocation = invokedSkill === step.primarySkill;
71
+ if (!matchingInvocation) {
72
+ if (marker) {
73
+ return {
74
+ kind: "warning",
75
+ message: "Ignored PLAYBOOK_OUTCOME because current step skill was not invoked.",
76
+ };
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ if (!marker) return suggestPlan(step, run.currentStep);
82
+
83
+ const resolved = resolveMarker(step, marker);
84
+ if (!resolved) {
85
+ const valid = validOutcomeText(step);
86
+ return { kind: "warning", message: `Ignored invalid playbook marker for step '${run.currentStep}'. Valid: ${valid}` };
87
+ }
88
+
89
+ if (step.transitions.length > 1) {
90
+ return {
91
+ kind: "suggest",
92
+ outcome: resolved.outcome,
93
+ to: resolved.to,
94
+ message: `Completion marked for step '${run.currentStep}'. Confirm outcome: /playbook choose ${resolved.outcome}`,
95
+ };
96
+ }
97
+
98
+ if (mode === "suggest") {
99
+ return {
100
+ kind: "suggest",
101
+ outcome: resolved.outcome,
102
+ to: resolved.to,
103
+ message: resolved.outcome === "complete"
104
+ ? `Completion marked for final step '${run.currentStep}'. Run /playbook done to complete.`
105
+ : `Completion marked for step '${run.currentStep}'. Run /playbook done to advance to '${resolved.to}'.`,
106
+ };
107
+ }
108
+
109
+ return {
110
+ kind: "auto",
111
+ outcome: resolved.outcome,
112
+ to: resolved.to,
113
+ message: resolved.to === "complete" ? `Auto-completing playbook run from step '${run.currentStep}'.` : `Auto-advancing to '${resolved.to}'.`,
114
+ };
115
+ }
116
+
117
+ export function textFromMessage(message: unknown): string {
118
+ if (!isRecord(message)) return "";
119
+ return textFromContent(message.content);
120
+ }
121
+
122
+ export function lastAssistantText(messages: unknown[]): string {
123
+ for (let index = messages.length - 1; index >= 0; index--) {
124
+ const message = messages[index];
125
+ if (isRecord(message) && message.role === "assistant") return textFromMessage(message);
126
+ }
127
+ return "";
128
+ }
129
+
130
+ function suggestPlan(step: PlaybookStep, stepId: string): CompletionPlan {
131
+ if (step.transitions.length === 0) {
132
+ return { kind: "suggest", outcome: "complete", to: "complete", message: `Completion suspected for final step '${stepId}'. Run /playbook done to complete.` };
133
+ }
134
+ if (step.transitions.length === 1) {
135
+ const transition = step.transitions[0]!;
136
+ return {
137
+ kind: "suggest",
138
+ outcome: transition.outcome,
139
+ to: transition.to,
140
+ message: `Completion suspected for step '${stepId}'. Run /playbook done to advance to '${transition.to}'.`,
141
+ };
142
+ }
143
+ return {
144
+ kind: "suggest",
145
+ message: `Completion suspected for step '${stepId}'. Choose outcome: ${step.transitions.map((transition) => `/playbook choose ${transition.outcome}`).join(" | ")}`,
146
+ };
147
+ }
148
+
149
+ function resolveMarker(step: PlaybookStep, marker: OutcomeMarker): { outcome: string; to: string } | undefined {
150
+ if (marker.kind === "done") {
151
+ if (step.transitions.length === 0) return { outcome: "complete", to: "complete" };
152
+ if (step.transitions.length === 1) {
153
+ const transition = step.transitions[0]!;
154
+ return { outcome: transition.outcome, to: transition.to };
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ const transition = step.transitions.find((candidate) => candidate.outcome === marker.outcome);
160
+ if (!transition) return undefined;
161
+ return { outcome: transition.outcome, to: transition.to };
162
+ }
163
+
164
+ function validOutcomeText(step: PlaybookStep): string {
165
+ if (step.transitions.length === 0) return "PLAYBOOK_DONE";
166
+ return step.transitions.map((transition) => transition.outcome).join(", ");
167
+ }
168
+
169
+ function textFromContent(content: unknown): string {
170
+ if (typeof content === "string") return content;
171
+ if (!Array.isArray(content)) return "";
172
+ return content.map((item) => {
173
+ if (typeof item === "string") return item;
174
+ if (isRecord(item) && item.type === "text" && typeof item.text === "string") return item.text;
175
+ return "";
176
+ }).filter(Boolean).join("\n");
177
+ }
178
+
179
+ function isRecord(value: unknown): value is Record<string, any> {
180
+ return typeof value === "object" && value !== null && !Array.isArray(value);
181
+ }
@@ -0,0 +1,14 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { RUNS_DIR } from "./state.js";
4
+
5
+ export async function getGitignoreAdvisory(cwd: string): Promise<string | undefined> {
6
+ try {
7
+ const gitignore = await readFile(join(cwd, ".gitignore"), "utf8");
8
+ const lines = gitignore.split(/\r?\n/).map((line) => line.trim());
9
+ if (lines.includes(RUNS_DIR) || lines.includes(`${RUNS_DIR}/`)) return undefined;
10
+ } catch {
11
+ // Missing .gitignore still needs advisory.
12
+ }
13
+ return `Run state is personal. Add this to .gitignore:\n${RUNS_DIR}/`;
14
+ }
@@ -0,0 +1,33 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parsePlaybookYaml } from "./validation.js";
4
+ import type { LoadedPlaybook } from "./types.js";
5
+
6
+ export const PLAYBOOK_DIR = ".pi/playbooks";
7
+
8
+ export async function loadPlaybooks(cwd: string): Promise<LoadedPlaybook[]> {
9
+ const dir = join(cwd, PLAYBOOK_DIR);
10
+ let files: string[];
11
+ try {
12
+ files = await readdir(dir);
13
+ } catch (error) {
14
+ if (typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "ENOENT") {
15
+ return [];
16
+ }
17
+ throw error;
18
+ }
19
+
20
+ const yamlFiles = files.filter((file) => /\.ya?ml$/i.test(file));
21
+ const playbooks: LoadedPlaybook[] = [];
22
+ for (const file of yamlFiles) {
23
+ const fullPath = join(dir, file);
24
+ const source = await readFile(fullPath, "utf8");
25
+ playbooks.push(parsePlaybookYaml(source, fullPath));
26
+ }
27
+ return playbooks;
28
+ }
29
+
30
+ export async function findPlaybook(cwd: string, id: string): Promise<LoadedPlaybook | undefined> {
31
+ const playbooks = await loadPlaybooks(cwd);
32
+ return playbooks.find((playbook) => playbook.definition.id === id);
33
+ }
package/src/render.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { LoadedPlaybook, PlaybookRunState } from "./types.js";
2
+
3
+ export function renderStepCard(playbook: LoadedPlaybook, run: PlaybookRunState): string[] {
4
+ if (run.status === "completed") {
5
+ return [
6
+ `Playbook complete: ${playbook.definition.name}`,
7
+ `Run: ${run.runId}`,
8
+ ];
9
+ }
10
+
11
+ const step = playbook.definition.steps[run.currentStep];
12
+ if (!step) {
13
+ return [
14
+ `Playbook error: ${playbook.definition.name}`,
15
+ `Run: ${run.runId}`,
16
+ `Missing step: ${run.currentStep}`,
17
+ ];
18
+ }
19
+
20
+ const doneWhen = step.doneWhen.map((item) => ` - ${item}`);
21
+ const outcomes = step.transitions.length > 0
22
+ ? step.transitions.map((transition) => `${transition.outcome} -> ${transition.to}`).join(", ")
23
+ : "complete";
24
+
25
+ return [
26
+ `Playbook: ${playbook.definition.name}`,
27
+ `Run: ${run.runId}`,
28
+ `Step: ${run.currentStep}`,
29
+ `Next: ${step.commandHint}`,
30
+ "Done when:",
31
+ ...doneWhen,
32
+ `Outcomes: ${outcomes}`,
33
+ ];
34
+ }
35
+
36
+ export function renderValidationErrors(errors: string[]): string {
37
+ return errors.map((error) => `- ${error}`).join("\n");
38
+ }
package/src/state.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ActiveRunState, PlaybookRunState } from "./types.js";
4
+
5
+ export const RUNS_DIR = ".pi/playbook-runs";
6
+ const ACTIVE_FILE = "active.json";
7
+
8
+ export function runsDir(cwd: string): string {
9
+ return join(cwd, RUNS_DIR);
10
+ }
11
+
12
+ export function runFile(cwd: string, runId: string): string {
13
+ return join(runsDir(cwd), `${runId}.json`);
14
+ }
15
+
16
+ export function activeFile(cwd: string): string {
17
+ return join(runsDir(cwd), ACTIVE_FILE);
18
+ }
19
+
20
+ export async function saveRun(cwd: string, run: PlaybookRunState): Promise<void> {
21
+ await mkdir(runsDir(cwd), { recursive: true });
22
+ await writeFile(runFile(cwd, run.runId), `${JSON.stringify(run, null, 2)}\n`, "utf8");
23
+ }
24
+
25
+ export async function loadRun(cwd: string, runId: string): Promise<PlaybookRunState | undefined> {
26
+ try {
27
+ return JSON.parse(await readFile(runFile(cwd, runId), "utf8")) as PlaybookRunState;
28
+ } catch (error) {
29
+ if (isNotFound(error)) return undefined;
30
+ throw error;
31
+ }
32
+ }
33
+
34
+ export async function listRunIds(cwd: string): Promise<string[]> {
35
+ let files: string[];
36
+ try {
37
+ files = await readdir(runsDir(cwd));
38
+ } catch (error) {
39
+ if (isNotFound(error)) return [];
40
+ throw error;
41
+ }
42
+ return files
43
+ .filter((file) => file.endsWith(".json") && file !== ACTIVE_FILE)
44
+ .map((file) => file.replace(/\.json$/, ""))
45
+ .sort();
46
+ }
47
+
48
+ export async function setActiveRun(cwd: string, runId: string): Promise<void> {
49
+ await mkdir(runsDir(cwd), { recursive: true });
50
+ const state: ActiveRunState = { runId };
51
+ await writeFile(activeFile(cwd), `${JSON.stringify(state, null, 2)}\n`, "utf8");
52
+ }
53
+
54
+ export async function loadActiveRunId(cwd: string): Promise<string | undefined> {
55
+ try {
56
+ const state = JSON.parse(await readFile(activeFile(cwd), "utf8")) as ActiveRunState;
57
+ return typeof state.runId === "string" ? state.runId : undefined;
58
+ } catch (error) {
59
+ if (isNotFound(error)) return undefined;
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ export async function clearActiveRun(cwd: string): Promise<void> {
65
+ await rm(activeFile(cwd), { force: true });
66
+ }
67
+
68
+ export function createRunId(playbookId: string, runName?: string): string {
69
+ const base = slugify(runName ?? playbookId);
70
+ const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
71
+ return `${base}-${stamp}`;
72
+ }
73
+
74
+ export function slugify(value: string): string {
75
+ const slug = value
76
+ .trim()
77
+ .toLowerCase()
78
+ .replace(/[^a-z0-9]+/g, "-")
79
+ .replace(/^-+|-+$/g, "");
80
+ return slug || "playbook-run";
81
+ }
82
+
83
+ function isNotFound(error: unknown): boolean {
84
+ return typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "ENOENT";
85
+ }
package/src/types.ts ADDED
@@ -0,0 +1,62 @@
1
+ export type SkillRole = "entry" | "internal";
2
+ export type AdvanceMode = "auto" | "suggest" | "off";
3
+
4
+ export interface PlaybookSkillDefinition {
5
+ role: SkillRole;
6
+ }
7
+
8
+ export interface PlaybookTransition {
9
+ outcome: string;
10
+ to: string;
11
+ }
12
+
13
+ export interface PlaybookStep {
14
+ primarySkill: string;
15
+ commandHint: string;
16
+ doneWhen: string[];
17
+ transitions: PlaybookTransition[];
18
+ }
19
+
20
+ export interface PlaybookDefinition {
21
+ version: 1;
22
+ id: string;
23
+ name: string;
24
+ entry: string;
25
+ autoAdvance?: AdvanceMode;
26
+ skills: Record<string, PlaybookSkillDefinition>;
27
+ steps: Record<string, PlaybookStep>;
28
+ sources?: Array<{ url: string; title?: string; accessedAt: string }>;
29
+ }
30
+
31
+ export interface LoadedPlaybook {
32
+ path: string;
33
+ definition: PlaybookDefinition;
34
+ }
35
+
36
+ export interface PlaybookRunHistoryEntry {
37
+ at: string;
38
+ step: string;
39
+ outcome: string;
40
+ to: string;
41
+ }
42
+
43
+ export interface PlaybookRunState {
44
+ runId: string;
45
+ playbookId: string;
46
+ playbookPath: string;
47
+ currentStep: string;
48
+ status: "active" | "completed" | "cancelled";
49
+ createdAt: string;
50
+ updatedAt: string;
51
+ history: PlaybookRunHistoryEntry[];
52
+ }
53
+
54
+ export interface ActiveRunState {
55
+ runId: string;
56
+ }
57
+
58
+ export interface ValidationResult {
59
+ valid: boolean;
60
+ errors: string[];
61
+ warnings: string[];
62
+ }
@@ -0,0 +1,126 @@
1
+ import { parse } from "yaml";
2
+ import type { LoadedPlaybook, PlaybookDefinition, ValidationResult } from "./types.js";
3
+
4
+ const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
5
+
6
+ export function parsePlaybookYaml(source: string, path: string): LoadedPlaybook {
7
+ const parsed = parse(source) as unknown;
8
+ if (!isRecord(parsed)) {
9
+ throw new Error(`${path}: playbook must be a YAML object`);
10
+ }
11
+ return { path, definition: parsed as PlaybookDefinition };
12
+ }
13
+
14
+ export function validatePlaybook(
15
+ loaded: LoadedPlaybook,
16
+ availableSkills: ReadonlySet<string> = new Set(),
17
+ options: { requireSkills?: boolean } = {},
18
+ ): ValidationResult {
19
+ const errors: string[] = [];
20
+ const warnings: string[] = [];
21
+ const pb = loaded.definition as PlaybookDefinition;
22
+ const pathPrefix = loaded.path ? `${loaded.path}: ` : "";
23
+
24
+ if (pb.version !== 1) errors.push(`${pathPrefix}version must be 1`);
25
+ if (!isNonEmptyString(pb.id)) errors.push(`${pathPrefix}id is required`);
26
+ else if (!ID_PATTERN.test(pb.id)) errors.push(`${pathPrefix}id must be lower-kebab-case`);
27
+ if (!isNonEmptyString(pb.name)) errors.push(`${pathPrefix}name is required`);
28
+ if (!isNonEmptyString(pb.entry)) errors.push(`${pathPrefix}entry is required`);
29
+ if (pb.autoAdvance !== undefined && pb.autoAdvance !== "auto" && pb.autoAdvance !== "suggest" && pb.autoAdvance !== "off") {
30
+ errors.push(`${pathPrefix}autoAdvance must be 'auto', 'suggest', or 'off'`);
31
+ }
32
+ if (!isRecord(pb.skills)) errors.push(`${pathPrefix}skills map is required`);
33
+ if (!isRecord(pb.steps)) errors.push(`${pathPrefix}steps map is required`);
34
+
35
+ if (!isRecord(pb.steps)) return { valid: errors.length === 0, errors, warnings };
36
+
37
+ if (isNonEmptyString(pb.entry) && !(pb.entry in pb.steps)) {
38
+ errors.push(`${pathPrefix}entry step '${pb.entry}' is missing`);
39
+ }
40
+
41
+ for (const [skillName, skill] of Object.entries(pb.skills ?? {})) {
42
+ if (!ID_PATTERN.test(skillName)) warnings.push(`${pathPrefix}skill '${skillName}' is not lower-kebab-case`);
43
+ if (!isRecord(skill)) {
44
+ errors.push(`${pathPrefix}skills.${skillName} must be an object`);
45
+ continue;
46
+ }
47
+ if (skill.role !== "entry" && skill.role !== "internal") {
48
+ errors.push(`${pathPrefix}skills.${skillName}.role must be 'entry' or 'internal'`);
49
+ }
50
+ }
51
+
52
+ for (const [stepId, step] of Object.entries(pb.steps)) {
53
+ if (!ID_PATTERN.test(stepId)) errors.push(`${pathPrefix}step '${stepId}' must be lower-kebab-case`);
54
+ if (!isRecord(step)) {
55
+ errors.push(`${pathPrefix}steps.${stepId} must be an object`);
56
+ continue;
57
+ }
58
+
59
+ if (!isNonEmptyString(step.primarySkill)) {
60
+ errors.push(`${pathPrefix}steps.${stepId}.primarySkill is required`);
61
+ } else {
62
+ if (!isRecord(pb.skills) || !(step.primarySkill in pb.skills)) {
63
+ errors.push(`${pathPrefix}steps.${stepId}.primarySkill '${step.primarySkill}' is not declared in skills`);
64
+ }
65
+ if (options.requireSkills && !availableSkills.has(step.primarySkill)) {
66
+ errors.push(`${pathPrefix}steps.${stepId}.primarySkill '${step.primarySkill}' is not an available Agent Skill`);
67
+ }
68
+ }
69
+
70
+ if (!isNonEmptyString(step.commandHint)) errors.push(`${pathPrefix}steps.${stepId}.commandHint is required`);
71
+ if (!Array.isArray(step.doneWhen) || step.doneWhen.some((item) => !isNonEmptyString(item))) {
72
+ errors.push(`${pathPrefix}steps.${stepId}.doneWhen must be a non-empty string array`);
73
+ }
74
+ if (!Array.isArray(step.transitions)) {
75
+ errors.push(`${pathPrefix}steps.${stepId}.transitions must be an array`);
76
+ } else {
77
+ const outcomes = new Set<string>();
78
+ for (const transition of step.transitions) {
79
+ if (!isRecord(transition)) {
80
+ errors.push(`${pathPrefix}steps.${stepId}.transitions contains a non-object transition`);
81
+ continue;
82
+ }
83
+ if (!isNonEmptyString(transition.outcome)) {
84
+ errors.push(`${pathPrefix}steps.${stepId}.transition.outcome is required`);
85
+ } else if (outcomes.has(transition.outcome)) {
86
+ errors.push(`${pathPrefix}steps.${stepId}.transition outcome '${transition.outcome}' is duplicated`);
87
+ } else {
88
+ outcomes.add(transition.outcome);
89
+ }
90
+ if (!isNonEmptyString(transition.to)) {
91
+ errors.push(`${pathPrefix}steps.${stepId}.transition.to is required`);
92
+ } else if (transition.to !== "complete" && !(transition.to in pb.steps)) {
93
+ errors.push(`${pathPrefix}steps.${stepId}.transition '${transition.outcome}' targets missing step '${transition.to}'`);
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ return { valid: errors.length === 0, errors, warnings };
100
+ }
101
+
102
+ export function validateUniquePlaybookIds(playbooks: LoadedPlaybook[]): ValidationResult {
103
+ const errors: string[] = [];
104
+ const warnings: string[] = [];
105
+ const seen = new Map<string, string>();
106
+ for (const playbook of playbooks) {
107
+ const id = playbook.definition.id;
108
+ if (!isNonEmptyString(id)) continue;
109
+ const previous = seen.get(id);
110
+ if (previous) errors.push(`duplicate playbook id '${id}' in ${previous} and ${playbook.path}`);
111
+ else seen.set(id, playbook.path);
112
+ }
113
+ return { valid: errors.length === 0, errors, warnings };
114
+ }
115
+
116
+ export function normalizeSkillCommandName(name: string): string {
117
+ return name.replace(/^\//, "").replace(/^skill:/, "");
118
+ }
119
+
120
+ function isRecord(value: unknown): value is Record<string, any> {
121
+ return typeof value === "object" && value !== null && !Array.isArray(value);
122
+ }
123
+
124
+ function isNonEmptyString(value: unknown): value is string {
125
+ return typeof value === "string" && value.trim().length > 0;
126
+ }