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 +12 -10
- package/docs/adr/0001-marker-based-auto-advance.md +15 -0
- package/docs/examples.md +18 -0
- package/extensions/index.ts +196 -242
- package/package.json +5 -2
- package/src/auto-advance.ts +2 -2
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
|
-
- **
|
|
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
|
|
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:status
|
|
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
|
|
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
|
-
|
|
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.
|
package/docs/examples.md
ADDED
|
@@ -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.
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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,
|
|
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,
|
|
107
|
+
await startPlaybook(pi, ctx.cwd, ui);
|
|
131
108
|
return;
|
|
132
109
|
case "resume":
|
|
133
|
-
await resumeRun(ctx.cwd,
|
|
110
|
+
await resumeRun(ctx.cwd, ui);
|
|
134
111
|
return;
|
|
135
112
|
case "status":
|
|
136
|
-
await showStatus(ctx.cwd,
|
|
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,
|
|
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,
|
|
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,
|
|
177
|
-
const
|
|
178
|
-
if (!
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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,
|
|
209
|
-
const
|
|
210
|
-
if (!
|
|
211
|
-
|
|
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,
|
|
222
|
-
const
|
|
223
|
-
if (!
|
|
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,
|
|
245
|
-
const runId =
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
388
|
-
"/playbook:resume
|
|
389
|
-
"/playbook:status
|
|
505
|
+
"/playbook:start",
|
|
506
|
+
"/playbook:resume",
|
|
507
|
+
"/playbook:status",
|
|
390
508
|
"/playbook:done",
|
|
391
|
-
"/playbook:choose
|
|
392
|
-
"/playbook:cancel
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"]
|
package/src/auto-advance.ts
CHANGED
|
@@ -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
|
|
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}'.
|
|
145
|
+
message: `Completion suspected for step '${stepId}'. Run /playbook:choose to select an outcome.`,
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
|