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 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 feature-development --run my-feature
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 ready-for-prd
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 <id> [--run <name>]` | Start a new playbook run |
96
- | `/playbook:resume <run-id>` | Resume a paused active run |
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 <outcome>` | Choose an outcome for multi-branch steps |
100
- | `/playbook:cancel [run-id]` | Cancel an active run |
101
+ | `/playbook:choose` | Select an outcome for multi-branch steps |
102
+ | `/playbook:cancel` | Select and confirm an active run cancellation |
101
103
 
102
- Legacy space-separated forms (`/playbook start`) remain available for compatibility.
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.
@@ -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
+ ```
@@ -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) throw new Error("Usage: /playbook:start <playbook-id> [--run <name>]");
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) throw new Error("Usage: /playbook:resume <run-id>");
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
- const runId = explicitRunId ?? (await loadActiveRunId(cwd));
223
- if (!runId) throw new Error("Usage: /playbook:cancel [run-id]");
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 <playbook-id> [--run <name>]",
388
- "/playbook:resume <run-id>",
564
+ "/playbook:start",
565
+ "/playbook:resume",
389
566
  "/playbook:status [run-id]",
390
567
  "/playbook:done",
391
- "/playbook:choose <outcome>",
392
- "/playbook:cancel [run-id]",
393
- "Legacy space-separated /playbook <subcommand> forms remain available for compatibility.",
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.1.5",
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"]