pi-skill-playbook 0.1.5 → 0.2.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/README.md +10 -8
- package/docs/adr/0001-marker-based-auto-advance.md +15 -0
- package/docs/examples.md +29 -0
- package/extensions/index.ts +190 -11
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -69,19 +69,21 @@ pi -e .
|
|
|
69
69
|
.pi/playbook-runs/
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
3. **Start a run
|
|
72
|
+
3. **Start a run** from the Pi TUI:
|
|
73
73
|
|
|
74
74
|
```
|
|
75
75
|
/playbook:list
|
|
76
|
-
/playbook:start
|
|
76
|
+
/playbook:start
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
When more than one playbook exists, Pi shows a selector with validation status for each playbook. The run id is generated automatically.
|
|
80
|
+
|
|
79
81
|
4. **Drive the workflow**:
|
|
80
82
|
|
|
81
83
|
```
|
|
82
84
|
/skill:grill-with-docs <feature idea>
|
|
83
85
|
/playbook:done
|
|
84
|
-
/playbook:choose
|
|
86
|
+
/playbook:choose
|
|
85
87
|
/playbook:status
|
|
86
88
|
```
|
|
87
89
|
|
|
@@ -92,14 +94,14 @@ The widget displays the current step, exact skill command, completion criteria,
|
|
|
92
94
|
| Command | Description |
|
|
93
95
|
|---|---|
|
|
94
96
|
| `/playbook:list` | List available playbooks with validation status |
|
|
95
|
-
| `/playbook:start
|
|
96
|
-
| `/playbook:resume
|
|
97
|
+
| `/playbook:start` | Select a playbook and start a new run |
|
|
98
|
+
| `/playbook:resume` | Select an active run to resume |
|
|
97
99
|
| `/playbook:status [run-id]` | Show current step and completion criteria |
|
|
98
100
|
| `/playbook:done` | Complete the current step (auto-advances if single outcome) |
|
|
99
|
-
| `/playbook:choose
|
|
100
|
-
| `/playbook:cancel
|
|
101
|
+
| `/playbook:choose` | Select an outcome for multi-branch steps |
|
|
102
|
+
| `/playbook:cancel` | Select and confirm an active run cancellation |
|
|
101
103
|
|
|
102
|
-
Legacy
|
|
104
|
+
Legacy explicit-argument forms (`/playbook:start <id>`, `/playbook:resume <run-id>`, `/playbook:choose <outcome>`, `/playbook:cancel <run-id>`, and space-separated `/playbook start`) remain available for scripts and non-interactive use.
|
|
103
105
|
|
|
104
106
|
### Auto advance
|
|
105
107
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Marker-based auto advance
|
|
2
|
+
|
|
3
|
+
Pi Skill Playbook defaults to marker-based **Auto Advance**: a run may advance automatically only when the active step's primary skill was explicitly invoked with `/skill:<name>` and the assistant emits a visible `PLAYBOOK_OUTCOME: <outcome>` marker. This keeps single-outcome workflows low-friction without turning playbooks into hidden skill automation; markerless completions only produce an advance suggestion, and multi-outcome steps still require explicit outcome choice.
|
|
4
|
+
|
|
5
|
+
## Considered Options
|
|
6
|
+
|
|
7
|
+
- Fully automatic advancement after any matching skill invocation: rejected because failed or partial skill runs could silently advance the run.
|
|
8
|
+
- Suggest-only advancement: rejected as too close to the existing `/playbook done` workflow.
|
|
9
|
+
- Hidden structured metadata: rejected because visible markers make state changes auditable in conversation history.
|
|
10
|
+
|
|
11
|
+
## Consequences
|
|
12
|
+
|
|
13
|
+
- Playbook prompts must tell the assistant the valid outcomes and marker format for the active step.
|
|
14
|
+
- `autoAdvance` defaults to `auto`, with `suggest` and `off` available per playbook.
|
|
15
|
+
- Auto advance changes run state and widget state only; it never runs the next skill or writes commands into the editor.
|
package/docs/examples.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
## Selection-first TUI flow
|
|
4
|
+
|
|
5
|
+
Copy or create YAML playbooks in the target project under `.pi/playbooks/`, then use argument-free commands from the Pi TUI:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
/playbook:start
|
|
9
|
+
/skill:grill-with-docs <feature idea>
|
|
10
|
+
/playbook:done
|
|
11
|
+
/playbook:choose
|
|
12
|
+
/playbook:status
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- `/playbook:start` opens a playbook selector when multiple playbooks exist and generates the run id automatically.
|
|
16
|
+
- `/playbook:resume` opens an active-run selector.
|
|
17
|
+
- `/playbook:choose` opens a selector for the current step's valid outcomes.
|
|
18
|
+
- `/playbook:cancel` selects an active run when needed and asks for confirmation before marking it cancelled.
|
|
19
|
+
|
|
20
|
+
## Script-compatible explicit args
|
|
21
|
+
|
|
22
|
+
Non-interactive scripts can still pass ids explicitly:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
/playbook:start feature-development --run scripted-feature
|
|
26
|
+
/playbook:resume feature-development-20260608120000
|
|
27
|
+
/playbook:choose ready-for-prd
|
|
28
|
+
/playbook:cancel feature-development-20260608120000
|
|
29
|
+
```
|
package/extensions/index.ts
CHANGED
|
@@ -33,6 +33,11 @@ type CommandContext = {
|
|
|
33
33
|
ui?: UiLike;
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
type SelectOption<T> = {
|
|
37
|
+
label: string;
|
|
38
|
+
value: T;
|
|
39
|
+
};
|
|
40
|
+
|
|
36
41
|
export default function piSkillPlaybook(pi: ExtensionAPI) {
|
|
37
42
|
let completionCwd = process.cwd();
|
|
38
43
|
let pendingSkillInvocation: string | undefined;
|
|
@@ -114,7 +119,7 @@ export default function piSkillPlaybook(pi: ExtensionAPI) {
|
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
|
|
117
|
-
async function handlePlaybookCommand(
|
|
122
|
+
export async function handlePlaybookCommand(
|
|
118
123
|
pi: ExtensionAPI,
|
|
119
124
|
command: string,
|
|
120
125
|
args: string[],
|
|
@@ -175,7 +180,12 @@ async function listPlaybooks(pi: ExtensionAPI, cwd: string, ui: UiLike | undefin
|
|
|
175
180
|
|
|
176
181
|
async function startPlaybook(pi: ExtensionAPI, cwd: string, args: string[], ui: UiLike | undefined): Promise<void> {
|
|
177
182
|
const playbookId = args[0];
|
|
178
|
-
if (!playbookId)
|
|
183
|
+
if (!playbookId) {
|
|
184
|
+
const playbook = await pickPlaybook(pi, cwd, ui);
|
|
185
|
+
if (!playbook) return;
|
|
186
|
+
await createAndActivateRun(cwd, playbook, undefined, ui);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
179
189
|
|
|
180
190
|
const playbook = await findPlaybook(cwd, playbookId);
|
|
181
191
|
if (!playbook) throw new Error(`Playbook '${playbookId}' not found in .pi/playbooks/.`);
|
|
@@ -186,6 +196,10 @@ async function startPlaybook(pi: ExtensionAPI, cwd: string, args: string[], ui:
|
|
|
186
196
|
}
|
|
187
197
|
|
|
188
198
|
const runName = readFlagValue(args.slice(1), "--run");
|
|
199
|
+
await createAndActivateRun(cwd, playbook, runName, ui);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function createAndActivateRun(cwd: string, playbook: LoadedPlaybook, runName: string | undefined, ui: UiLike | undefined): Promise<void> {
|
|
189
203
|
const now = new Date().toISOString();
|
|
190
204
|
const run: PlaybookRunState = {
|
|
191
205
|
runId: createRunId(playbook.definition.id, runName),
|
|
@@ -207,7 +221,12 @@ async function startPlaybook(pi: ExtensionAPI, cwd: string, args: string[], ui:
|
|
|
207
221
|
|
|
208
222
|
async function resumeRun(cwd: string, args: string[], ui: UiLike | undefined): Promise<void> {
|
|
209
223
|
const runId = args[0];
|
|
210
|
-
if (!runId)
|
|
224
|
+
if (!runId) {
|
|
225
|
+
const run = await pickActiveRun(cwd, ui, "Resume which playbook run?");
|
|
226
|
+
if (!run) return;
|
|
227
|
+
await resumeRun(cwd, [run.runId], ui);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
211
230
|
const run = await loadRun(cwd, runId);
|
|
212
231
|
if (!run) throw new Error(`Run '${runId}' not found.`);
|
|
213
232
|
if (run.status !== "active") throw new Error(`Run '${runId}' is ${run.status}.`);
|
|
@@ -219,8 +238,12 @@ async function resumeRun(cwd: string, args: string[], ui: UiLike | undefined): P
|
|
|
219
238
|
}
|
|
220
239
|
|
|
221
240
|
async function cancelRun(cwd: string, explicitRunId: string | undefined, ui: UiLike | undefined): Promise<void> {
|
|
222
|
-
|
|
223
|
-
if (!runId)
|
|
241
|
+
let runId = explicitRunId;
|
|
242
|
+
if (!runId) {
|
|
243
|
+
const run = await pickRunToCancel(cwd, ui);
|
|
244
|
+
if (!run) return;
|
|
245
|
+
runId = run.runId;
|
|
246
|
+
}
|
|
224
247
|
const run = await loadRun(cwd, runId);
|
|
225
248
|
if (!run) throw new Error(`Run '${runId}' not found.`);
|
|
226
249
|
|
|
@@ -281,11 +304,165 @@ async function completeCurrentStep(cwd: string, ui: UiLike | undefined): Promise
|
|
|
281
304
|
}
|
|
282
305
|
|
|
283
306
|
async function chooseOutcome(cwd: string, outcome: string | undefined, ui: UiLike | undefined): Promise<void> {
|
|
284
|
-
if (!outcome) throw new Error("Usage: /playbook:choose <outcome>");
|
|
285
307
|
const { run, playbook } = await loadActive(cwd);
|
|
308
|
+
if (!outcome) {
|
|
309
|
+
const selected = await pickOutcome(playbook, run, ui);
|
|
310
|
+
if (!selected) return;
|
|
311
|
+
outcome = selected.outcome;
|
|
312
|
+
}
|
|
286
313
|
await advanceRun(cwd, playbook, run, outcome, ui);
|
|
287
314
|
}
|
|
288
315
|
|
|
316
|
+
async function pickPlaybook(pi: ExtensionAPI, cwd: string, ui: UiLike | undefined): Promise<LoadedPlaybook | undefined> {
|
|
317
|
+
if (!hasSelectionUI(ui)) {
|
|
318
|
+
notify(ui, "Interactive playbook selection requires the Pi TUI. For scripts, use /playbook:start <playbook-id> [--run <name>].", "error");
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const playbooks = await loadPlaybooks(cwd);
|
|
323
|
+
if (playbooks.length === 0) {
|
|
324
|
+
notify(ui, "No playbooks found. Create .pi/playbooks/*.yml or copy samples/feature-development.yml into .pi/playbooks/.", "info");
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const availableSkills = getAvailableSkills(pi);
|
|
329
|
+
const duplicateErrors = duplicateIdErrors(playbooks);
|
|
330
|
+
const candidates = playbooks.map((playbook) => {
|
|
331
|
+
const validation = validatePlaybook(playbook, availableSkills, { requireSkills: true });
|
|
332
|
+
const errors = [...validation.errors, ...(duplicateErrors.get(playbook) ?? [])];
|
|
333
|
+
return { playbook, errors, valid: errors.length === 0 };
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
if (candidates.length === 1) {
|
|
337
|
+
const candidate = candidates[0];
|
|
338
|
+
if (!candidate.valid) {
|
|
339
|
+
notify(ui, `Playbook validation failed:\n${renderValidationErrors(candidate.errors)}`, "error");
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
return candidate.playbook;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const options = candidates.map((candidate) => ({
|
|
346
|
+
label: playbookSelectionLabel(candidate.playbook, candidate.errors),
|
|
347
|
+
value: candidate,
|
|
348
|
+
}));
|
|
349
|
+
const selected = await selectByLabel(ui, "Start which playbook?", options);
|
|
350
|
+
if (!selected) return undefined;
|
|
351
|
+
if (!selected.valid) {
|
|
352
|
+
notify(ui, `Playbook validation failed:\n${renderValidationErrors(selected.errors)}`, "error");
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
return selected.playbook;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function playbookSelectionLabel(playbook: LoadedPlaybook, errors: string[]): string {
|
|
359
|
+
const marker = errors.length === 0 ? "ok" : "invalid";
|
|
360
|
+
const suffix = errors.length === 0 ? "" : ` — ${errors.join("; ")}`;
|
|
361
|
+
return `${playbook.definition.id} — ${playbook.definition.name} (${marker})${suffix}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function duplicateIdErrors(playbooks: LoadedPlaybook[]): Map<LoadedPlaybook, string[]> {
|
|
365
|
+
const byId = new Map<string, LoadedPlaybook[]>();
|
|
366
|
+
for (const playbook of playbooks) {
|
|
367
|
+
const id = playbook.definition.id;
|
|
368
|
+
if (!id) continue;
|
|
369
|
+
byId.set(id, [...(byId.get(id) ?? []), playbook]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const result = new Map<LoadedPlaybook, string[]>();
|
|
373
|
+
for (const [id, matches] of byId) {
|
|
374
|
+
if (matches.length < 2) continue;
|
|
375
|
+
const paths = matches.map((playbook) => playbook.path).join(", ");
|
|
376
|
+
for (const playbook of matches) result.set(playbook, [`duplicate playbook id '${id}' in ${paths}`]);
|
|
377
|
+
}
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function pickActiveRun(cwd: string, ui: UiLike | undefined, title: string): Promise<PlaybookRunState | undefined> {
|
|
382
|
+
if (!hasSelectionUI(ui)) {
|
|
383
|
+
notify(ui, "Interactive run selection requires the Pi TUI. For scripts, pass the run id explicitly.", "error");
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const runs = await getRunCompletionCandidates(cwd, true);
|
|
388
|
+
if (runs.length === 0) {
|
|
389
|
+
clearWidget(ui);
|
|
390
|
+
notify(ui, "No active playbook runs. Start one with /playbook:start.", "info");
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return selectByLabel(ui, title, runs.map((run) => ({ label: activeRunLabel(run), value: run })));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function pickRunToCancel(cwd: string, ui: UiLike | undefined): Promise<PlaybookRunState | undefined> {
|
|
398
|
+
if (!hasConfirmUI(ui)) {
|
|
399
|
+
notify(ui, "Interactive cancellation requires the Pi TUI. For scripts, use /playbook:cancel <run-id>.", "error");
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const runs = await getRunCompletionCandidates(cwd, true);
|
|
404
|
+
if (runs.length === 0) {
|
|
405
|
+
clearWidget(ui);
|
|
406
|
+
notify(ui, "No active playbook runs to cancel.", "info");
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const run = runs.length === 1
|
|
411
|
+
? runs[0]
|
|
412
|
+
: await selectByLabel(ui, "Cancel which playbook run?", runs.map((candidate) => ({ label: activeRunLabel(candidate), value: candidate })));
|
|
413
|
+
if (!run) return undefined;
|
|
414
|
+
|
|
415
|
+
const confirmed = await ui.confirm("Cancel playbook run?", `${run.runId} (${run.playbookId}) will be marked cancelled.`);
|
|
416
|
+
if (!confirmed) {
|
|
417
|
+
notify(ui, "Playbook cancellation skipped.", "info");
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
return run;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function activeRunLabel(run: PlaybookRunState): string {
|
|
424
|
+
return `${run.runId} — ${run.playbookId} (updated ${run.updatedAt})`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function pickOutcome(playbook: LoadedPlaybook, run: PlaybookRunState, ui: UiLike | undefined) {
|
|
428
|
+
if (!hasSelectionUI(ui)) {
|
|
429
|
+
notify(ui, "Interactive outcome selection requires the Pi TUI. For scripts, use /playbook:choose <outcome>.", "error");
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const step = playbook.definition.steps[run.currentStep];
|
|
434
|
+
if (!step) throw new Error(`Current step '${run.currentStep}' is missing.`);
|
|
435
|
+
if (step.transitions.length === 0) {
|
|
436
|
+
notify(ui, "Current step has no branch outcomes. Run /playbook:done to complete it.", "info");
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return selectByLabel(
|
|
441
|
+
ui,
|
|
442
|
+
`Choose outcome for ${run.currentStep}`,
|
|
443
|
+
step.transitions.map((transition) => ({ label: `${transition.outcome} → ${transition.to}`, value: transition })),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function selectByLabel<T>(
|
|
448
|
+
ui: { select(title: string, options: string[]): Promise<string | undefined> },
|
|
449
|
+
title: string,
|
|
450
|
+
options: SelectOption<T>[],
|
|
451
|
+
): Promise<T | undefined> {
|
|
452
|
+
const selected = await ui.select(title, options.map((option) => option.label));
|
|
453
|
+
return options.find((option) => option.label === selected)?.value;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function hasSelectionUI(ui: UiLike | undefined): ui is UiLike & { select: NonNullable<UiLike["select"]> } {
|
|
457
|
+
return typeof ui?.select === "function";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function hasConfirmUI(
|
|
461
|
+
ui: UiLike | undefined,
|
|
462
|
+
): ui is UiLike & { select: NonNullable<UiLike["select"]>; confirm: NonNullable<UiLike["confirm"]> } {
|
|
463
|
+
return hasSelectionUI(ui) && typeof ui.confirm === "function";
|
|
464
|
+
}
|
|
465
|
+
|
|
289
466
|
async function advanceRun(cwd: string, playbook: LoadedPlaybook, run: PlaybookRunState, outcome: string, ui: UiLike | undefined): Promise<void> {
|
|
290
467
|
const step = playbook.definition.steps[run.currentStep];
|
|
291
468
|
if (!step) throw new Error(`Current step '${run.currentStep}' is missing.`);
|
|
@@ -384,13 +561,13 @@ function usage(): string {
|
|
|
384
561
|
return [
|
|
385
562
|
"Usage:",
|
|
386
563
|
"/playbook:list",
|
|
387
|
-
"/playbook:start
|
|
388
|
-
"/playbook:resume
|
|
564
|
+
"/playbook:start",
|
|
565
|
+
"/playbook:resume",
|
|
389
566
|
"/playbook:status [run-id]",
|
|
390
567
|
"/playbook:done",
|
|
391
|
-
"/playbook:choose
|
|
392
|
-
"/playbook:cancel
|
|
393
|
-
"Legacy
|
|
568
|
+
"/playbook:choose",
|
|
569
|
+
"/playbook:cancel",
|
|
570
|
+
"Legacy explicit args remain available for scripts: start <playbook-id> [--run <name>], resume <run-id>, choose <outcome>, cancel <run-id>.",
|
|
394
571
|
].join("\n");
|
|
395
572
|
}
|
|
396
573
|
|
|
@@ -567,5 +744,7 @@ function readFlagValue(args: string[], flag: string): string | undefined {
|
|
|
567
744
|
|
|
568
745
|
interface UiLike {
|
|
569
746
|
notify(message: string, level: "info" | "warning" | "error"): void;
|
|
747
|
+
select?(title: string, options: string[]): Promise<string | undefined>;
|
|
748
|
+
confirm?(title: string, message: string): Promise<boolean>;
|
|
570
749
|
setWidget(id: string, content: string[] | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
|
|
571
750
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-skill-playbook",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi extension for passive, human-mediated Agent Skill playbooks.",
|
|
6
6
|
"keywords": ["pi-package", "pi-extension", "agent-skills", "playbook"],
|
|
@@ -16,15 +16,18 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"extensions/",
|
|
18
18
|
"src/",
|
|
19
|
+
"docs/",
|
|
19
20
|
"samples/",
|
|
20
21
|
"LICENSE",
|
|
21
22
|
"README.md"
|
|
22
23
|
],
|
|
23
24
|
"scripts": {
|
|
24
25
|
"check": "tsc --noEmit",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
25
27
|
"test": "node --test --import tsx tests/*.test.ts",
|
|
26
28
|
"build": "npm run check",
|
|
27
|
-
"validate:package": "npm pack --dry-run"
|
|
29
|
+
"validate:package": "npm pack --dry-run",
|
|
30
|
+
"ci": "npm run typecheck && npm test && npm run validate:package"
|
|
28
31
|
},
|
|
29
32
|
"pi": {
|
|
30
33
|
"extensions": ["./extensions/index.ts"]
|