pi-skill-playbook 0.1.5 → 1.0.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
@@ -22,7 +22,7 @@ Define ordered skill workflows as YAML playbooks in your project, then drive the
22
22
  - **Marker-based auto advance** — Single-outcome steps advance automatically when the assistant emits a visible `PLAYBOOK_OUTCOME:` marker. Multi-outcome steps always require explicit confirmation.
23
23
  - **Active run widget** — Displays current step, skill command, completion criteria, and outcome labels below the editor.
24
24
  - **Strict YAML validation** — Playbooks are validated on load for structure, transitions, and skill references.
25
- - **Tab completion** — Commands, playbook IDs, run IDs, outcomes, and flags all support tab completion.
25
+ - **Selection UI** — Playbook, run, and outcome selection use the Pi TUI selector instead of memorized ids.
26
26
  - **Local run state** — Run state is stored in `.pi/playbook-runs/` inside the target project, never in git.
27
27
 
28
28
  ## Install
@@ -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:status [run-id]` | Show current step and completion criteria |
97
+ | `/playbook:start` | Select a playbook and start a new run |
98
+ | `/playbook:resume` | Select an active run to resume |
99
+ | `/playbook:status` | 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
+ All commands are argument-free. Use the Pi TUI selection UI to pick playbooks, runs, and outcomes.
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,18 @@
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,26 +19,21 @@ const COMMANDS = [
19
19
  ["cancel", "cancel an active playbook run"],
20
20
  ] as const;
21
21
 
22
- const COLON_COMMAND_ALIASES = COMMANDS.map(([command, description]) => ({
23
- name: `playbook:${command}`,
24
- command,
25
- description,
26
- }));
27
-
28
- const COLON_COMPLETION_COMMANDS = new Set(["start", "resume", "status", "cancel", "choose"]);
29
-
30
22
  type CommandContext = {
31
23
  cwd: string;
32
24
  hasUI: boolean;
33
25
  ui?: UiLike;
34
26
  };
35
27
 
28
+ type SelectOption<T> = {
29
+ label: string;
30
+ value: T;
31
+ };
32
+
36
33
  export default function piSkillPlaybook(pi: ExtensionAPI) {
37
- let completionCwd = process.cwd();
38
34
  let pendingSkillInvocation: string | undefined;
39
35
 
40
36
  pi.on("session_start", async (_event, ctx) => {
41
- completionCwd = ctx.cwd;
42
37
  if (!ctx.hasUI) return;
43
38
  const activeRunId = await loadActiveRunId(ctx.cwd);
44
39
  if (!activeRunId) {
@@ -83,29 +78,12 @@ export default function piSkillPlaybook(pi: ExtensionAPI) {
83
78
  }
84
79
  });
85
80
 
86
- pi.registerCommand("playbook", {
87
- description: "Guide Agent Skill workflows with project-local playbooks",
88
- getArgumentCompletions: (prefix) => getPlaybookArgumentCompletions(completionCwd, prefix),
89
- handler: async (args, ctx) => {
90
- const parsed = parseArgs(args);
91
- const command = parsed.shift() ?? "status";
92
- try {
93
- await handlePlaybookCommand(pi, command, parsed, ctx);
94
- } catch (error) {
95
- notify(ctx.hasUI ? ctx.ui : undefined, error instanceof Error ? error.message : String(error), "error");
96
- }
97
- },
98
- });
99
-
100
- for (const alias of COLON_COMMAND_ALIASES) {
101
- pi.registerCommand(alias.name, {
102
- description: `Playbook: ${alias.description}. Alias for /playbook ${alias.command}.`,
103
- ...(COLON_COMPLETION_COMMANDS.has(alias.command)
104
- ? { getArgumentCompletions: (prefix) => getPlaybookColonArgumentCompletions(completionCwd, alias.command, prefix) }
105
- : {}),
106
- handler: async (args, ctx) => {
81
+ for (const [command, description] of COMMANDS) {
82
+ pi.registerCommand(`playbook:${command}`, {
83
+ description: `Playbook: ${description}.`,
84
+ handler: async (_args, ctx) => {
107
85
  try {
108
- await handlePlaybookCommand(pi, alias.command, parseArgs(args), ctx);
86
+ await handlePlaybookCommand(pi, command, ctx);
109
87
  } catch (error) {
110
88
  notify(ctx.hasUI ? ctx.ui : undefined, error instanceof Error ? error.message : String(error), "error");
111
89
  }
@@ -114,10 +92,9 @@ export default function piSkillPlaybook(pi: ExtensionAPI) {
114
92
  }
115
93
  }
116
94
 
117
- async function handlePlaybookCommand(
95
+ export async function handlePlaybookCommand(
118
96
  pi: ExtensionAPI,
119
97
  command: string,
120
- args: string[],
121
98
  ctx: CommandContext,
122
99
  ): Promise<void> {
123
100
  const ui = ctx.hasUI ? ctx.ui : undefined;
@@ -127,24 +104,24 @@ async function handlePlaybookCommand(
127
104
  await listPlaybooks(pi, ctx.cwd, ui);
128
105
  return;
129
106
  case "start":
130
- await startPlaybook(pi, ctx.cwd, args, ui);
107
+ await startPlaybook(pi, ctx.cwd, ui);
131
108
  return;
132
109
  case "resume":
133
- await resumeRun(ctx.cwd, args, ui);
110
+ await resumeRun(ctx.cwd, ui);
134
111
  return;
135
112
  case "status":
136
- await showStatus(ctx.cwd, args[0], ui);
113
+ await showStatus(ctx.cwd, ui);
137
114
  return;
138
115
  case "done":
139
116
  await completeCurrentStep(ctx.cwd, ui);
140
117
  return;
141
118
  case "choose":
142
- await chooseOutcome(ctx.cwd, args[0], ui);
119
+ await chooseOutcome(ctx.cwd, ui);
143
120
  return;
144
121
  case "cancel":
145
122
  case "stop":
146
123
  case "abort":
147
- await cancelRun(ctx.cwd, args[0], ui);
124
+ await cancelRun(ctx.cwd, ui);
148
125
  return;
149
126
  case "import-web":
150
127
  case "record":
@@ -173,19 +150,13 @@ async function listPlaybooks(pi: ExtensionAPI, cwd: string, ui: UiLike | undefin
173
150
  notify(ui, lines.join("\n"), duplicateResult.valid ? "info" : "error");
174
151
  }
175
152
 
176
- async function startPlaybook(pi: ExtensionAPI, cwd: string, args: string[], ui: UiLike | undefined): Promise<void> {
177
- const playbookId = args[0];
178
- if (!playbookId) throw new Error("Usage: /playbook:start <playbook-id> [--run <name>]");
179
-
180
- const playbook = await findPlaybook(cwd, playbookId);
181
- if (!playbook) throw new Error(`Playbook '${playbookId}' not found in .pi/playbooks/.`);
182
-
183
- const validation = validatePlaybook(playbook, getAvailableSkills(pi), { requireSkills: true });
184
- if (!validation.valid) {
185
- throw new Error(`Playbook validation failed:\n${renderValidationErrors(validation.errors)}`);
186
- }
153
+ async function startPlaybook(pi: ExtensionAPI, cwd: string, ui: UiLike | undefined): Promise<void> {
154
+ const playbook = await pickPlaybook(pi, cwd, ui);
155
+ if (!playbook) return;
156
+ await createAndActivateRun(cwd, playbook, undefined, ui);
157
+ }
187
158
 
188
- const runName = readFlagValue(args.slice(1), "--run");
159
+ async function createAndActivateRun(cwd: string, playbook: LoadedPlaybook, runName: string | undefined, ui: UiLike | undefined): Promise<void> {
189
160
  const now = new Date().toISOString();
190
161
  const run: PlaybookRunState = {
191
162
  runId: createRunId(playbook.definition.id, runName),
@@ -205,24 +176,20 @@ async function startPlaybook(pi: ExtensionAPI, cwd: string, args: string[], ui:
205
176
  notify(ui, [`Started ${run.runId}.`, ...(advisory ? ["", advisory] : [])].join("\n"), advisory ? "warning" : "info");
206
177
  }
207
178
 
208
- async function resumeRun(cwd: string, args: string[], ui: UiLike | undefined): Promise<void> {
209
- const runId = args[0];
210
- if (!runId) throw new Error("Usage: /playbook:resume <run-id>");
211
- const run = await loadRun(cwd, runId);
212
- if (!run) throw new Error(`Run '${runId}' not found.`);
213
- if (run.status !== "active") throw new Error(`Run '${runId}' is ${run.status}.`);
179
+ async function resumeRun(cwd: string, ui: UiLike | undefined): Promise<void> {
180
+ const run = await pickActiveRun(cwd, ui, "Resume which playbook run?");
181
+ if (!run) return;
182
+
214
183
  const playbook = await findPlaybook(cwd, run.playbookId);
215
- if (!playbook) throw new Error(`Run '${runId}' references missing playbook '${run.playbookId}'.`);
184
+ if (!playbook) throw new Error(`Run '${run.runId}' references missing playbook '${run.playbookId}'.`);
216
185
  await setActiveRun(cwd, run.runId);
217
186
  renderWidget(ui, playbook, run);
218
187
  notify(ui, `Resumed ${run.runId}.`, "info");
219
188
  }
220
189
 
221
- 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]");
224
- const run = await loadRun(cwd, runId);
225
- if (!run) throw new Error(`Run '${runId}' not found.`);
190
+ async function cancelRun(cwd: string, ui: UiLike | undefined): Promise<void> {
191
+ const run = await pickRunToCancel(cwd, ui);
192
+ if (!run) return;
226
193
 
227
194
  if (run.status !== "active") {
228
195
  if ((await loadActiveRunId(cwd)) === run.runId) await clearActiveRun(cwd);
@@ -241,8 +208,8 @@ async function cancelRun(cwd: string, explicitRunId: string | undefined, ui: UiL
241
208
  notify(ui, `Cancelled playbook run ${run.runId}.`, "info");
242
209
  }
243
210
 
244
- async function showStatus(cwd: string, explicitRunId: string | undefined, ui: UiLike | undefined): Promise<void> {
245
- const runId = explicitRunId ?? (await loadActiveRunId(cwd));
211
+ async function showStatus(cwd: string, ui: UiLike | undefined): Promise<void> {
212
+ const runId = await loadActiveRunId(cwd);
246
213
  if (!runId) {
247
214
  notify(ui, "No active playbook run.", "info");
248
215
  clearWidget(ui);
@@ -252,7 +219,7 @@ async function showStatus(cwd: string, explicitRunId: string | undefined, ui: Ui
252
219
  if (!run) throw new Error(`Run '${runId}' not found.`);
253
220
  if (run.status !== "active") {
254
221
  notify(ui, `Run '${run.runId}' is ${run.status}.`, "info");
255
- if (!explicitRunId) clearWidget(ui);
222
+ clearWidget(ui);
256
223
  return;
257
224
  }
258
225
  const playbook = await findPlaybook(cwd, run.playbookId);
@@ -280,10 +247,161 @@ async function completeCurrentStep(cwd: string, ui: UiLike | undefined): Promise
280
247
  await advanceRun(cwd, playbook, run, step.transitions[0].outcome, ui);
281
248
  }
282
249
 
283
- async function chooseOutcome(cwd: string, outcome: string | undefined, ui: UiLike | undefined): Promise<void> {
284
- if (!outcome) throw new Error("Usage: /playbook:choose <outcome>");
250
+ async function chooseOutcome(cwd: string, ui: UiLike | undefined): Promise<void> {
285
251
  const { run, playbook } = await loadActive(cwd);
286
- await advanceRun(cwd, playbook, run, outcome, ui);
252
+ const selected = await pickOutcome(playbook, run, ui);
253
+ if (!selected) return;
254
+ await advanceRun(cwd, playbook, run, selected.outcome, ui);
255
+ }
256
+
257
+ async function pickPlaybook(pi: ExtensionAPI, cwd: string, ui: UiLike | undefined): Promise<LoadedPlaybook | undefined> {
258
+ if (!hasSelectionUI(ui)) {
259
+ notify(ui, "Interactive playbook selection requires the Pi TUI. Run /playbook:start from the command palette.", "error");
260
+ return undefined;
261
+ }
262
+
263
+ const playbooks = await loadPlaybooks(cwd);
264
+ if (playbooks.length === 0) {
265
+ notify(ui, "No playbooks found. Create .pi/playbooks/*.yml or copy samples/feature-development.yml into .pi/playbooks/.", "info");
266
+ return undefined;
267
+ }
268
+
269
+ const availableSkills = getAvailableSkills(pi);
270
+ const duplicateErrors = duplicateIdErrors(playbooks);
271
+ const candidates = playbooks.map((playbook) => {
272
+ const validation = validatePlaybook(playbook, availableSkills, { requireSkills: true });
273
+ const errors = [...validation.errors, ...(duplicateErrors.get(playbook) ?? [])];
274
+ return { playbook, errors, valid: errors.length === 0 };
275
+ });
276
+
277
+ if (candidates.length === 1) {
278
+ const candidate = candidates[0];
279
+ if (!candidate.valid) {
280
+ notify(ui, `Playbook validation failed:\n${renderValidationErrors(candidate.errors)}`, "error");
281
+ return undefined;
282
+ }
283
+ return candidate.playbook;
284
+ }
285
+
286
+ const options = candidates.map((candidate) => ({
287
+ label: playbookSelectionLabel(candidate.playbook, candidate.errors),
288
+ value: candidate,
289
+ }));
290
+ const selected = await selectByLabel(ui, "Start which playbook?", options);
291
+ if (!selected) return undefined;
292
+ if (!selected.valid) {
293
+ notify(ui, `Playbook validation failed:\n${renderValidationErrors(selected.errors)}`, "error");
294
+ return undefined;
295
+ }
296
+ return selected.playbook;
297
+ }
298
+
299
+ function playbookSelectionLabel(playbook: LoadedPlaybook, errors: string[]): string {
300
+ const marker = errors.length === 0 ? "ok" : "invalid";
301
+ const suffix = errors.length === 0 ? "" : ` — ${errors.join("; ")}`;
302
+ return `${playbook.definition.id} — ${playbook.definition.name} (${marker})${suffix}`;
303
+ }
304
+
305
+ function duplicateIdErrors(playbooks: LoadedPlaybook[]): Map<LoadedPlaybook, string[]> {
306
+ const byId = new Map<string, LoadedPlaybook[]>();
307
+ for (const playbook of playbooks) {
308
+ const id = playbook.definition.id;
309
+ if (!id) continue;
310
+ byId.set(id, [...(byId.get(id) ?? []), playbook]);
311
+ }
312
+
313
+ const result = new Map<LoadedPlaybook, string[]>();
314
+ for (const [id, matches] of byId) {
315
+ if (matches.length < 2) continue;
316
+ const paths = matches.map((playbook) => playbook.path).join(", ");
317
+ for (const playbook of matches) result.set(playbook, [`duplicate playbook id '${id}' in ${paths}`]);
318
+ }
319
+ return result;
320
+ }
321
+
322
+ async function pickActiveRun(cwd: string, ui: UiLike | undefined, title: string): Promise<PlaybookRunState | undefined> {
323
+ if (!hasSelectionUI(ui)) {
324
+ notify(ui, "Interactive run selection requires the Pi TUI. Run /playbook:resume from the command palette.", "error");
325
+ return undefined;
326
+ }
327
+
328
+ const runs = await getActiveRuns(cwd);
329
+ if (runs.length === 0) {
330
+ clearWidget(ui);
331
+ notify(ui, "No active playbook runs. Start one with /playbook:start.", "info");
332
+ return undefined;
333
+ }
334
+
335
+ return selectByLabel(ui, title, runs.map((run) => ({ label: activeRunLabel(run), value: run })));
336
+ }
337
+
338
+ async function pickRunToCancel(cwd: string, ui: UiLike | undefined): Promise<PlaybookRunState | undefined> {
339
+ if (!hasConfirmUI(ui)) {
340
+ notify(ui, "Interactive cancellation requires the Pi TUI. Run /playbook:cancel from the command palette.", "error");
341
+ return undefined;
342
+ }
343
+
344
+ const runs = await getActiveRuns(cwd);
345
+ if (runs.length === 0) {
346
+ clearWidget(ui);
347
+ notify(ui, "No active playbook runs to cancel.", "info");
348
+ return undefined;
349
+ }
350
+
351
+ const run = runs.length === 1
352
+ ? runs[0]
353
+ : await selectByLabel(ui, "Cancel which playbook run?", runs.map((candidate) => ({ label: activeRunLabel(candidate), value: candidate })));
354
+ if (!run) return undefined;
355
+
356
+ const confirmed = await ui.confirm("Cancel playbook run?", `${run.runId} (${run.playbookId}) will be marked cancelled.`);
357
+ if (!confirmed) {
358
+ notify(ui, "Playbook cancellation skipped.", "info");
359
+ return undefined;
360
+ }
361
+ return run;
362
+ }
363
+
364
+ function activeRunLabel(run: PlaybookRunState): string {
365
+ return `${run.runId} — ${run.playbookId} (updated ${run.updatedAt})`;
366
+ }
367
+
368
+ async function pickOutcome(playbook: LoadedPlaybook, run: PlaybookRunState, ui: UiLike | undefined) {
369
+ if (!hasSelectionUI(ui)) {
370
+ notify(ui, "Interactive outcome selection requires the Pi TUI. Run /playbook:choose from the command palette.", "error");
371
+ return undefined;
372
+ }
373
+
374
+ const step = playbook.definition.steps[run.currentStep];
375
+ if (!step) throw new Error(`Current step '${run.currentStep}' is missing.`);
376
+ if (step.transitions.length === 0) {
377
+ notify(ui, "Current step has no branch outcomes. Run /playbook:done to complete it.", "info");
378
+ return undefined;
379
+ }
380
+
381
+ return selectByLabel(
382
+ ui,
383
+ `Choose outcome for ${run.currentStep}`,
384
+ step.transitions.map((transition) => ({ label: `${transition.outcome} → ${transition.to}`, value: transition })),
385
+ );
386
+ }
387
+
388
+ async function selectByLabel<T>(
389
+ ui: { select(title: string, options: string[]): Promise<string | undefined> },
390
+ title: string,
391
+ options: SelectOption<T>[],
392
+ ): Promise<T | undefined> {
393
+ const selected = await ui.select(title, options.map((option) => option.label));
394
+ return options.find((option) => option.label === selected)?.value;
395
+ }
396
+
397
+ function hasSelectionUI(ui: UiLike | undefined): ui is UiLike & { select: NonNullable<UiLike["select"]> } {
398
+ return typeof ui?.select === "function";
399
+ }
400
+
401
+ function hasConfirmUI(
402
+ ui: UiLike | undefined,
403
+ ): ui is UiLike & { select: NonNullable<UiLike["select"]>; confirm: NonNullable<UiLike["confirm"]> } {
404
+ return hasSelectionUI(ui) && typeof ui.confirm === "function";
287
405
  }
288
406
 
289
407
  async function advanceRun(cwd: string, playbook: LoadedPlaybook, run: PlaybookRunState, outcome: string, ui: UiLike | undefined): Promise<void> {
@@ -384,188 +502,24 @@ function usage(): string {
384
502
  return [
385
503
  "Usage:",
386
504
  "/playbook:list",
387
- "/playbook:start <playbook-id> [--run <name>]",
388
- "/playbook:resume <run-id>",
389
- "/playbook:status [run-id]",
505
+ "/playbook:start",
506
+ "/playbook:resume",
507
+ "/playbook:status",
390
508
  "/playbook:done",
391
- "/playbook:choose <outcome>",
392
- "/playbook:cancel [run-id]",
393
- "Legacy space-separated /playbook <subcommand> forms remain available for compatibility.",
509
+ "/playbook:choose",
510
+ "/playbook:cancel",
394
511
  ].join("\n");
395
512
  }
396
513
 
397
- type CompletionItem = { value: string; label: string; description?: string };
398
-
399
- export async function getPlaybookArgumentCompletions(cwd: string, prefix: string): Promise<CompletionItem[] | null> {
400
- try {
401
- return await getPlaybookArgumentCompletionsUnsafe(cwd, prefix);
402
- } catch {
403
- return null;
404
- }
405
- }
406
-
407
- export async function getPlaybookColonArgumentCompletions(
408
- cwd: string,
409
- command: string,
410
- prefix: string,
411
- ): Promise<CompletionItem[] | null> {
412
- try {
413
- const normalizedPrefix = prefix.trimStart();
414
- const legacyPrefix = normalizedPrefix ? `${command} ${normalizedPrefix}` : command;
415
- const items = await getPlaybookArgumentCompletionsUnsafe(cwd, legacyPrefix);
416
- if (!items) return null;
417
- const legacyToken = `${command} `;
418
- return items.map((item) => ({
419
- ...item,
420
- value: item.value.startsWith(legacyToken) ? item.value.slice(legacyToken.length) : item.value,
421
- }));
422
- } catch {
423
- return null;
424
- }
425
- }
426
-
427
- async function getPlaybookArgumentCompletionsUnsafe(cwd: string, prefix: string): Promise<CompletionItem[] | null> {
428
- const parsed = parseCompletionPrefix(prefix);
429
- const command = parsed.command;
430
- if (!command) {
431
- return completeCommands(parsed.currentToken);
432
- }
433
-
434
- if (parsed.completedTokens.length === 0) {
435
- return completeCommands(parsed.currentToken);
436
- }
437
-
438
- switch (command) {
439
- case "start":
440
- return completeStartArguments(cwd, parsed);
441
- case "resume":
442
- return completeRunArgument(cwd, parsed, command, { activeOnly: true });
443
- case "status":
444
- return completeRunArgument(cwd, parsed, command, { activeOnly: false, optional: true });
445
- case "cancel":
446
- case "stop":
447
- case "abort":
448
- return completeRunArgument(cwd, parsed, command, { activeOnly: true, optional: true });
449
- case "choose":
450
- return completeOutcomeArgument(cwd, parsed);
451
- default:
452
- return null;
453
- }
454
- }
455
-
456
- function completeCommands(token: string): CompletionItem[] | null {
457
- const commands = ["list", "start", "resume", "status", "done", "choose", "cancel"];
458
- return toCompletionItems(commands, token, (command) => command, (command) => command);
459
- }
460
-
461
- async function completeStartArguments(cwd: string, parsed: CompletionPrefix): Promise<CompletionItem[] | null> {
462
- const args = parsed.completedTokens.slice(1);
463
- if (args.length === 0) {
464
- const playbooks = await loadPlaybooks(cwd);
465
- return toCompletionItems(
466
- playbooks,
467
- parsed.currentToken,
468
- (playbook) => `start ${playbook.definition.id}`,
469
- (playbook) => playbook.definition.id,
470
- (playbook) => playbook.definition.name,
471
- );
472
- }
473
-
474
- if (args.length >= 1 && !args.includes("--run")) {
475
- return toCompletionItems(["--run"], parsed.currentToken, (flag) => `start ${args.join(" ")} ${flag} `, (flag) => flag);
476
- }
477
- return null;
478
- }
479
-
480
- async function completeRunArgument(
481
- cwd: string,
482
- parsed: CompletionPrefix,
483
- command: string,
484
- options: { activeOnly: boolean; optional?: boolean },
485
- ): Promise<CompletionItem[] | null> {
486
- const args = parsed.completedTokens.slice(1);
487
- if (args.length > 0 || (options.optional && parsed.currentToken === "" && !parsed.trailingWhitespace)) return null;
488
- const runs = await getRunCompletionCandidates(cwd, options.activeOnly);
489
- return toCompletionItems(
490
- runs,
491
- parsed.currentToken,
492
- (run) => `${command} ${run.runId}`,
493
- (run) => run.runId,
494
- (run) => `${run.playbookId} (${run.status})`,
495
- );
496
- }
497
-
498
- async function completeOutcomeArgument(cwd: string, parsed: CompletionPrefix): Promise<CompletionItem[] | null> {
499
- const args = parsed.completedTokens.slice(1);
500
- if (args.length > 0) return null;
501
- const { run, playbook } = await loadActive(cwd);
502
- const step = playbook.definition.steps[run.currentStep];
503
- if (!step) return null;
504
- return toCompletionItems(
505
- step.transitions,
506
- parsed.currentToken,
507
- (transition) => `choose ${transition.outcome}`,
508
- (transition) => transition.outcome,
509
- (transition) => `to ${transition.to}`,
510
- );
511
- }
512
-
513
- async function getRunCompletionCandidates(cwd: string, activeOnly: boolean): Promise<PlaybookRunState[]> {
514
+ async function getActiveRuns(cwd: string): Promise<PlaybookRunState[]> {
514
515
  const ids = await listRunIds(cwd);
515
516
  const runs = (await Promise.all(ids.map((id) => loadRun(cwd, id)))).filter((run): run is PlaybookRunState => Boolean(run));
516
- const filtered = activeOnly ? runs.filter((run) => run.status === "active") : runs;
517
- return filtered.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
518
- }
519
-
520
- function toCompletionItems<T>(
521
- values: T[],
522
- token: string,
523
- valueFor: (item: T) => string,
524
- labelFor: (item: T) => string,
525
- descriptionFor?: (item: T) => string | undefined,
526
- ): CompletionItem[] | null {
527
- const normalizedToken = token.trim().toLowerCase();
528
- const items = values
529
- .filter((item) => matchesCompletion(labelFor(item), normalizedToken))
530
- .map((item) => {
531
- const description = descriptionFor?.(item);
532
- return { value: valueFor(item), label: labelFor(item), ...(description ? { description } : {}) };
533
- });
534
- return items.length > 0 ? items : null;
535
- }
536
-
537
- function matchesCompletion(value: string, token: string): boolean {
538
- if (token === "") return true;
539
- return value.toLowerCase().includes(token);
540
- }
541
-
542
- type CompletionPrefix = {
543
- completedTokens: string[];
544
- currentToken: string;
545
- command: string | undefined;
546
- trailingWhitespace: boolean;
547
- };
548
-
549
- function parseCompletionPrefix(prefix: string): CompletionPrefix {
550
- const trailingWhitespace = /\s$/.test(prefix);
551
- const tokens = parseArgs(prefix);
552
- const completedTokens = trailingWhitespace ? tokens : tokens.slice(0, -1);
553
- const currentToken = trailingWhitespace ? "" : tokens.at(-1) ?? "";
554
- return { completedTokens, currentToken, command: completedTokens[0] ?? (!trailingWhitespace && tokens.length > 1 ? tokens[0] : undefined), trailingWhitespace };
555
- }
556
-
557
- function parseArgs(args: string): string[] {
558
- const matches = args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
559
- return matches.map((arg) => arg.replace(/^(["'])(.*)\1$/, "$2"));
560
- }
561
-
562
- function readFlagValue(args: string[], flag: string): string | undefined {
563
- const index = args.indexOf(flag);
564
- if (index === -1) return undefined;
565
- return args[index + 1];
517
+ return runs.filter((run) => run.status === "active").sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
566
518
  }
567
519
 
568
520
  interface UiLike {
569
521
  notify(message: string, level: "info" | "warning" | "error"): void;
522
+ select?(title: string, options: string[]): Promise<string | undefined>;
523
+ confirm?(title: string, message: string): Promise<boolean>;
570
524
  setWidget(id: string, content: string[] | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
571
525
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skill-playbook",
3
- "version": "0.1.5",
3
+ "version": "1.0.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"]
@@ -91,7 +91,7 @@ export function planCompletion(
91
91
  kind: "suggest",
92
92
  outcome: resolved.outcome,
93
93
  to: resolved.to,
94
- message: `Completion marked for step '${run.currentStep}'. Confirm outcome: /playbook:choose ${resolved.outcome}`,
94
+ message: `Completion marked for step '${run.currentStep}'. Confirm outcome with /playbook:choose.`,
95
95
  };
96
96
  }
97
97
 
@@ -142,7 +142,7 @@ function suggestPlan(step: PlaybookStep, stepId: string): CompletionPlan {
142
142
  }
143
143
  return {
144
144
  kind: "suggest",
145
- message: `Completion suspected for step '${stepId}'. Choose outcome: ${step.transitions.map((transition) => `/playbook:choose ${transition.outcome}`).join(" | ")}`,
145
+ message: `Completion suspected for step '${stepId}'. Run /playbook:choose to select an outcome.`,
146
146
  };
147
147
  }
148
148