@harms-haus/pi-workflows 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/LICENSE +21 -0
- package/README.md +113 -0
- package/docs/architecture.md +318 -0
- package/docs/configuration-reference.md +427 -0
- package/docs/contributing.md +132 -0
- package/docs/examples.md +1242 -0
- package/docs/hook-lifecycle.md +380 -0
- package/docs/state-management.md +534 -0
- package/docs/subworkflows.md +428 -0
- package/docs/template-variables.md +383 -0
- package/docs/testing.md +479 -0
- package/package.json +69 -0
- package/skills/workflow-generation/SKILL.md +272 -0
- package/src/TimerManager.ts +67 -0
- package/src/command.ts +199 -0
- package/src/config/index.ts +11 -0
- package/src/config/loading-parse.ts +205 -0
- package/src/config/loading-phases.ts +78 -0
- package/src/config/loading-resolve.ts +82 -0
- package/src/config/loading.ts +202 -0
- package/src/config/templates.ts +25 -0
- package/src/config/validation.ts +258 -0
- package/src/hooks.ts +265 -0
- package/src/index.ts +98 -0
- package/src/prompts.ts +141 -0
- package/src/renderers.ts +46 -0
- package/src/state.ts +426 -0
- package/src/tool.ts +364 -0
- package/src/types.ts +211 -0
package/src/tool.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import type {
|
|
6
|
+
WorkflowState,
|
|
7
|
+
GetState,
|
|
8
|
+
SetState,
|
|
9
|
+
GetDefinitions,
|
|
10
|
+
WorkflowDefinition,
|
|
11
|
+
} from "./types";
|
|
12
|
+
import {
|
|
13
|
+
advancePhase,
|
|
14
|
+
persistState,
|
|
15
|
+
resolveActive,
|
|
16
|
+
isActive,
|
|
17
|
+
loopPhase,
|
|
18
|
+
cloneState,
|
|
19
|
+
} from "./state";
|
|
20
|
+
|
|
21
|
+
// ── Result Types ──
|
|
22
|
+
|
|
23
|
+
/** A text content part returned by action handlers. */
|
|
24
|
+
type TextPart = { type: "text"; text: string };
|
|
25
|
+
|
|
26
|
+
/** Structured result returned by all action handlers. */
|
|
27
|
+
type ActionResult = {
|
|
28
|
+
content: TextPart[];
|
|
29
|
+
details: Record<string, unknown>;
|
|
30
|
+
resultType?: "error" | "cancel" | "complete" | "normal";
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ── Shared Utility Functions ──
|
|
34
|
+
|
|
35
|
+
/** Create a typed text content object (ensures `type` is narrowed to "text" literal). */
|
|
36
|
+
function textPart(text: string): TextPart {
|
|
37
|
+
return { type: "text", text };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Standard "no active workflow" response, with an optional description message. */
|
|
41
|
+
function noActiveWorkflowResponse(description?: string): ActionResult {
|
|
42
|
+
const text =
|
|
43
|
+
description ?? "No active workflow. Use /workflow {name} {description} to start one.";
|
|
44
|
+
return {
|
|
45
|
+
content: [textPart(text)],
|
|
46
|
+
details: { active: false },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Action Handlers ──
|
|
51
|
+
|
|
52
|
+
/** Handle the "status" action: return current workflow status. */
|
|
53
|
+
function handleStatus(
|
|
54
|
+
state: WorkflowState | null,
|
|
55
|
+
definitions: Record<string, WorkflowDefinition>,
|
|
56
|
+
) {
|
|
57
|
+
if (!state || !state.active) {
|
|
58
|
+
return noActiveWorkflowResponse();
|
|
59
|
+
}
|
|
60
|
+
const active = resolveActive(state, definitions);
|
|
61
|
+
if (!active) {
|
|
62
|
+
return {
|
|
63
|
+
content: [
|
|
64
|
+
textPart(
|
|
65
|
+
"The workflow configuration for this session is no longer available. Use workflow_step with action='cancel' to clear it, or reload the workflow definitions.",
|
|
66
|
+
),
|
|
67
|
+
],
|
|
68
|
+
details: { active: false, stale: true },
|
|
69
|
+
resultType: "error",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const { definition, currentPhase, breadcrumb } = active;
|
|
73
|
+
const top = state.currentPath[state.currentPath.length - 1];
|
|
74
|
+
const topDef = top ? definitions[top.workflowKey] : undefined;
|
|
75
|
+
if (!top || !topDef) {
|
|
76
|
+
return {
|
|
77
|
+
content: [textPart("Error: could not resolve current workflow path.")],
|
|
78
|
+
details: {},
|
|
79
|
+
resultType: "error",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const total = topDef.phases.length;
|
|
83
|
+
const current = top.phaseIndex + 1;
|
|
84
|
+
const lines: string[] = [];
|
|
85
|
+
lines.push(`**Workflow:** ${definition.name} (${state.workflowKey})`);
|
|
86
|
+
lines.push(`**Task ID:** ${state.taskId}`);
|
|
87
|
+
lines.push(`**Description:** ${state.taskDescription}`);
|
|
88
|
+
if (state.currentPath.length > 1) {
|
|
89
|
+
// Nested: show breadcrumb path with inner scope progress
|
|
90
|
+
const breadcrumbStr = breadcrumb.map((b) => b.name).join(" > ");
|
|
91
|
+
lines.push(`**Path:** ${breadcrumbStr}`);
|
|
92
|
+
lines.push(
|
|
93
|
+
`**Phase:** ${currentPhase.emoji} ${currentPhase.name} [${current}/${total}] (step ${state.globalStepCount})`,
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
// Linear: keep existing format
|
|
97
|
+
lines.push(`**Phase:** ${currentPhase.emoji} ${currentPhase.name} [${current}/${total}]`);
|
|
98
|
+
}
|
|
99
|
+
lines.push(`**Started:** ${new Date(state.startedAt).toISOString()}`);
|
|
100
|
+
lines.push("");
|
|
101
|
+
lines.push("**What to do:**");
|
|
102
|
+
lines.push(currentPhase.instructions);
|
|
103
|
+
const profiles = currentPhase.availableProfiles;
|
|
104
|
+
lines.push("");
|
|
105
|
+
lines.push(`**Available profiles:** ${profiles?.join(", ") ?? "(none)"}`);
|
|
106
|
+
return {
|
|
107
|
+
content: [textPart(lines.join("\n"))],
|
|
108
|
+
details: { active: true, workflowKey: state.workflowKey, currentPath: state.currentPath },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Handle the "cancel" action: two-step confirmation then cancel. */
|
|
113
|
+
function handleCancel(
|
|
114
|
+
state: WorkflowState | null,
|
|
115
|
+
_definitions: Record<string, WorkflowDefinition>,
|
|
116
|
+
_getState: GetState,
|
|
117
|
+
setState: SetState,
|
|
118
|
+
pi: ExtensionAPI,
|
|
119
|
+
ctx: ExtensionContext,
|
|
120
|
+
) {
|
|
121
|
+
if (!isActive(state)) {
|
|
122
|
+
return noActiveWorkflowResponse("No active workflow to cancel.");
|
|
123
|
+
}
|
|
124
|
+
// Two-step cancellation: first call requests confirmation, second call within the same turn confirms
|
|
125
|
+
if (!state._cancelPending) {
|
|
126
|
+
// Intentionally mutate shared state without persisting — the _cancelPending flag
|
|
127
|
+
// acts as a volatile confirmation marker for the two-step cancel flow.
|
|
128
|
+
// It will be persisted only if the user confirms cancellation on the next call.
|
|
129
|
+
state._cancelPending = true;
|
|
130
|
+
return {
|
|
131
|
+
content: [
|
|
132
|
+
textPart(
|
|
133
|
+
`⚠️ **Confirm cancellation** of workflow "${state.taskDescription}"?\nCall workflow_step with action='cancel' again to confirm. This cannot be undone.`,
|
|
134
|
+
),
|
|
135
|
+
],
|
|
136
|
+
details: { active: true, cancelPending: true },
|
|
137
|
+
resultType: "cancel",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const newState: WorkflowState = {
|
|
141
|
+
...cloneState(state),
|
|
142
|
+
active: false,
|
|
143
|
+
cancelled: true,
|
|
144
|
+
completionNotified: false,
|
|
145
|
+
};
|
|
146
|
+
setState(newState);
|
|
147
|
+
persistState(pi, newState);
|
|
148
|
+
ctx.ui.setStatus("workflow", undefined);
|
|
149
|
+
return {
|
|
150
|
+
content: [textPart(`Workflow cancelled: "${state.taskDescription}"`)],
|
|
151
|
+
details: { active: false, cancelled: true },
|
|
152
|
+
resultType: "cancel",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Handle the "next" action: advance phase, with subworkflow enter/done detection. */
|
|
157
|
+
function handleNext(
|
|
158
|
+
state: WorkflowState | null,
|
|
159
|
+
definitions: Record<string, WorkflowDefinition>,
|
|
160
|
+
_getState: GetState,
|
|
161
|
+
setState: SetState,
|
|
162
|
+
pi: ExtensionAPI,
|
|
163
|
+
ctx: ExtensionContext,
|
|
164
|
+
) {
|
|
165
|
+
if (!isActive(state)) {
|
|
166
|
+
return noActiveWorkflowResponse();
|
|
167
|
+
}
|
|
168
|
+
const active = resolveActive(state, definitions);
|
|
169
|
+
if (!active) {
|
|
170
|
+
return {
|
|
171
|
+
content: [textPart(`Workflow definition '${state.workflowKey}' not found.`)],
|
|
172
|
+
details: { active: false },
|
|
173
|
+
resultType: "error",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const { currentPhase } = active;
|
|
177
|
+
const pathLenBefore = state.currentPath.length;
|
|
178
|
+
const result = advancePhase(state, definitions);
|
|
179
|
+
const newState = result.newState;
|
|
180
|
+
|
|
181
|
+
// Workflow completed (top-level done)
|
|
182
|
+
if (result.to === null) {
|
|
183
|
+
const doneState: WorkflowState = {
|
|
184
|
+
...newState,
|
|
185
|
+
active: false,
|
|
186
|
+
completionNotified: false,
|
|
187
|
+
};
|
|
188
|
+
setState(doneState);
|
|
189
|
+
persistState(pi, doneState);
|
|
190
|
+
ctx.ui.setStatus("workflow", undefined);
|
|
191
|
+
return {
|
|
192
|
+
content: [textPart(`✓ Advanced: ${currentPhase.name} → DONE\n\n🎉 **All phases complete!**`)],
|
|
193
|
+
details: { advanced: true, from: currentPhase.name, to: "DONE" },
|
|
194
|
+
resultType: "complete",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Re-resolve to get the new current phase
|
|
199
|
+
const newActive = resolveActive(newState, definitions);
|
|
200
|
+
if (!newActive) {
|
|
201
|
+
return {
|
|
202
|
+
content: [textPart("Error: could not resolve active workflow after advance.")],
|
|
203
|
+
details: {},
|
|
204
|
+
resultType: "error",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
setState(newState);
|
|
208
|
+
persistState(pi, newState);
|
|
209
|
+
ctx.ui.setStatus("workflow", undefined); // Will be re-set by turn_end hook
|
|
210
|
+
|
|
211
|
+
const pathLenAfter = newState.currentPath.length;
|
|
212
|
+
let advanceVerb = "Advanced";
|
|
213
|
+
if (pathLenAfter > pathLenBefore) {
|
|
214
|
+
// Entered a subworkflow — name it
|
|
215
|
+
const subName =
|
|
216
|
+
newActive.breadcrumb.length > 0
|
|
217
|
+
? (newActive.breadcrumb[newActive.breadcrumb.length - 1]?.name ?? "subworkflow")
|
|
218
|
+
: "subworkflow";
|
|
219
|
+
advanceVerb = `Entered subworkflow '${subName}'`;
|
|
220
|
+
} else if (pathLenAfter < pathLenBefore) {
|
|
221
|
+
// Exited a subworkflow — show where we returned to
|
|
222
|
+
const parentBreadcrumb = newActive.breadcrumb.map((b) => b.name).join(" > ");
|
|
223
|
+
advanceVerb = `Exited subworkflow, returning to ${parentBreadcrumb}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
content: [
|
|
228
|
+
textPart(
|
|
229
|
+
`✓ ${advanceVerb}: ${currentPhase.name} → ${newActive.currentPhase.emoji} ${newActive.currentPhase.name}\n\n` +
|
|
230
|
+
`**What to do in ${newActive.currentPhase.name}:**\n` +
|
|
231
|
+
newActive.currentPhase.instructions,
|
|
232
|
+
),
|
|
233
|
+
],
|
|
234
|
+
details: { advanced: true, from: currentPhase.name, to: newActive.currentPhase.name },
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Handle the "loop" action: restart the current scope from phase 0. */
|
|
239
|
+
function handleLoop(
|
|
240
|
+
state: WorkflowState | null,
|
|
241
|
+
definitions: Record<string, WorkflowDefinition>,
|
|
242
|
+
_getState: GetState,
|
|
243
|
+
setState: SetState,
|
|
244
|
+
pi: ExtensionAPI,
|
|
245
|
+
ctx: ExtensionContext,
|
|
246
|
+
) {
|
|
247
|
+
if (!isActive(state)) {
|
|
248
|
+
return {
|
|
249
|
+
content: [textPart("No active workflow to loop.")],
|
|
250
|
+
details: { active: false },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const result = loopPhase(state, definitions);
|
|
254
|
+
if ("error" in result) {
|
|
255
|
+
return {
|
|
256
|
+
content: [textPart(`⚠️ ${result.error}`)],
|
|
257
|
+
details: { active: true, error: result.error },
|
|
258
|
+
resultType: "error",
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const newState = result.newState;
|
|
262
|
+
setState(newState);
|
|
263
|
+
persistState(pi, newState);
|
|
264
|
+
const newActive = resolveActive(newState, definitions);
|
|
265
|
+
if (!newActive) {
|
|
266
|
+
return {
|
|
267
|
+
content: [textPart("Error: could not resolve active workflow after loop.")],
|
|
268
|
+
details: {},
|
|
269
|
+
resultType: "error",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
ctx.ui.setStatus("workflow", undefined); // will be re-set by turn_end
|
|
273
|
+
// Identify which scope was looped
|
|
274
|
+
const loopedScopeName =
|
|
275
|
+
newActive.breadcrumb.length > 0
|
|
276
|
+
? (newActive.breadcrumb[newActive.breadcrumb.length - 1]?.name ?? "workflow")
|
|
277
|
+
: "workflow";
|
|
278
|
+
return {
|
|
279
|
+
content: [
|
|
280
|
+
textPart(
|
|
281
|
+
`🔄 Looped '${loopedScopeName}' back to: ${newActive.currentPhase.emoji} ${newActive.currentPhase.name}\n\n` +
|
|
282
|
+
`**What to do in ${newActive.currentPhase.name}:**\n` +
|
|
283
|
+
newActive.currentPhase.instructions,
|
|
284
|
+
),
|
|
285
|
+
],
|
|
286
|
+
details: { looped: true, to: result.to },
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Register the workflow_step tool.
|
|
292
|
+
*/
|
|
293
|
+
export function registerWorkflowTool(
|
|
294
|
+
pi: ExtensionAPI,
|
|
295
|
+
getState: GetState,
|
|
296
|
+
getDefinitions: GetDefinitions,
|
|
297
|
+
setState: SetState,
|
|
298
|
+
): void {
|
|
299
|
+
pi.registerTool({
|
|
300
|
+
name: "workflow_step",
|
|
301
|
+
label: "Workflow Step",
|
|
302
|
+
description:
|
|
303
|
+
"Show current workflow status, advance to the next phase, loop back to the start of the current scope, or cancel the active workflow. " +
|
|
304
|
+
"Use this to coordinate any configured workflow.",
|
|
305
|
+
parameters: Type.Object({
|
|
306
|
+
action: StringEnum(["next", "status", "cancel", "loop"] as const, {
|
|
307
|
+
description:
|
|
308
|
+
'"next" to advance to the next phase, "status" to check current state, "cancel" to abort the workflow, "loop" to restart the current scope from phase 0',
|
|
309
|
+
}),
|
|
310
|
+
summary: Type.Optional(
|
|
311
|
+
Type.String({
|
|
312
|
+
description: "Optional summary of what was accomplished in the current phase",
|
|
313
|
+
}),
|
|
314
|
+
),
|
|
315
|
+
}),
|
|
316
|
+
promptSnippet: "Advance the active workflow to the next phase",
|
|
317
|
+
promptGuidelines: [
|
|
318
|
+
"Use workflow_step with action='next' when you finish a phase and want to advance.",
|
|
319
|
+
"Use workflow_step with action='status' to check current workflow state.",
|
|
320
|
+
"Use workflow_step with action='cancel' to abort the current workflow.",
|
|
321
|
+
"Use workflow_step with action='loop' to restart the current scope from the beginning.",
|
|
322
|
+
],
|
|
323
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
324
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
325
|
+
const state = getState();
|
|
326
|
+
const definitions = getDefinitions();
|
|
327
|
+
|
|
328
|
+
switch (params.action) {
|
|
329
|
+
case "status":
|
|
330
|
+
return handleStatus(state, definitions);
|
|
331
|
+
case "cancel":
|
|
332
|
+
return handleCancel(state, definitions, getState, setState, pi, ctx);
|
|
333
|
+
case "next":
|
|
334
|
+
return handleNext(state, definitions, getState, setState, pi, ctx);
|
|
335
|
+
case "loop":
|
|
336
|
+
return handleLoop(state, definitions, getState, setState, pi, ctx);
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
renderCall(args, theme) {
|
|
340
|
+
return new Text(
|
|
341
|
+
theme.fg("toolTitle", theme.bold("workflow_step ")) + theme.fg("accent", args.action),
|
|
342
|
+
0,
|
|
343
|
+
0,
|
|
344
|
+
);
|
|
345
|
+
},
|
|
346
|
+
renderResult(result, _opts, theme) {
|
|
347
|
+
const text = result.content[0];
|
|
348
|
+
if (text && text.type === "text") {
|
|
349
|
+
const t = (text as { type: "text"; text: string }).text;
|
|
350
|
+
const rt = (result as ActionResult).resultType;
|
|
351
|
+
if (rt === "error" || rt === "cancel" || rt === "complete") {
|
|
352
|
+
return new Text(theme.fg("toolOutput", t), 0, 0);
|
|
353
|
+
}
|
|
354
|
+
// For next/loop/status, show just the first line (transition summary)
|
|
355
|
+
// and hide the verbose phase instructions below
|
|
356
|
+
const firstLine = t.split("\n")[0];
|
|
357
|
+
if (firstLine) {
|
|
358
|
+
return new Text(theme.fg("toolOutput", firstLine), 0, 0);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return new Container();
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// ── Phase-level tool control ──
|
|
2
|
+
|
|
3
|
+
/** Exactly one of blacklist or whitelist may be set (not both simultaneously active). */
|
|
4
|
+
export interface PhaseToolConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Tool names to BLOCK during this phase.
|
|
7
|
+
* If set, all tools EXCEPT these are allowed.
|
|
8
|
+
* Mutually exclusive with whitelist.
|
|
9
|
+
*/
|
|
10
|
+
blacklist?: string[];
|
|
11
|
+
/**
|
|
12
|
+
* Tool names to ALLOW during this phase.
|
|
13
|
+
* If set, ONLY these tools are allowed (everything else is blocked).
|
|
14
|
+
* Mutually exclusive with blacklist.
|
|
15
|
+
* The workflow_step tool is ALWAYS allowed regardless.
|
|
16
|
+
*/
|
|
17
|
+
whitelist?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Path Segment ──
|
|
21
|
+
|
|
22
|
+
/** A single segment in the workflow navigation path stack. */
|
|
23
|
+
export interface PathSegment {
|
|
24
|
+
/** The workflow key this segment refers to. */
|
|
25
|
+
workflowKey: string;
|
|
26
|
+
/** Index into that workflow's phases array. */
|
|
27
|
+
phaseIndex: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Phase Definition ──
|
|
31
|
+
|
|
32
|
+
export interface PhaseDefinition {
|
|
33
|
+
/** Machine-readable phase identifier. Must be unique within a workflow. */
|
|
34
|
+
id: string;
|
|
35
|
+
/** Human-readable phase name for UI display. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** Emoji icon for status bar and messages. */
|
|
38
|
+
emoji: string;
|
|
39
|
+
/**
|
|
40
|
+
* Instructions injected into the agent context during this phase.
|
|
41
|
+
* Supports template variables: {taskDescription}, {taskId}, {workflowName},
|
|
42
|
+
* {workflowKey}, {phaseId}, {phaseName}, {previousPhaseName}, {nextPhaseName}.
|
|
43
|
+
*/
|
|
44
|
+
instructions: string;
|
|
45
|
+
/** Tool blocking/allowing configuration for this phase. */
|
|
46
|
+
tools?: PhaseToolConfig;
|
|
47
|
+
/** Subagent profiles available during this phase (listed in context injection). */
|
|
48
|
+
availableProfiles?: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Subworkflow Reference ──
|
|
52
|
+
|
|
53
|
+
/** A phase entry that delegates to another workflow definition. */
|
|
54
|
+
export interface SubworkflowReference {
|
|
55
|
+
/** Discriminator: always true for subworkflow references. */
|
|
56
|
+
subworkflow: true;
|
|
57
|
+
/** The workflow key being referenced. */
|
|
58
|
+
workflowKey: string;
|
|
59
|
+
/** Resolved workflow definition (null until resolved during two-pass loading). */
|
|
60
|
+
resolved: WorkflowDefinition | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Phase Entry Union ──
|
|
64
|
+
|
|
65
|
+
/** A single entry in a workflow's phases array: either a concrete phase or a subworkflow reference. */
|
|
66
|
+
export type PhaseEntry = PhaseDefinition | SubworkflowReference;
|
|
67
|
+
|
|
68
|
+
// ── Type Guards ──
|
|
69
|
+
|
|
70
|
+
/** Returns true if the entry is a SubworkflowReference. */
|
|
71
|
+
export function isSubworkflowRef(
|
|
72
|
+
entry: PhaseEntry | undefined | null,
|
|
73
|
+
): entry is SubworkflowReference {
|
|
74
|
+
if (entry === undefined || entry === null) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return "subworkflow" in entry;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Returns true if the entry is a plain PhaseDefinition (not a subworkflow reference). */
|
|
81
|
+
export function isPhaseDefinition(entry: PhaseEntry | undefined | null): entry is PhaseDefinition {
|
|
82
|
+
if (entry === undefined || entry === null) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return !("subworkflow" in entry);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Workflow Definition ──
|
|
89
|
+
|
|
90
|
+
export interface WorkflowDefinition {
|
|
91
|
+
/** Human-readable workflow name for UI display. */
|
|
92
|
+
name: string;
|
|
93
|
+
/**
|
|
94
|
+
* The slash command name. The extension registers ONE /workflow command.
|
|
95
|
+
* This value is used as the first argument: /workflow {commandName} {description}
|
|
96
|
+
* Must be unique across all configured workflows.
|
|
97
|
+
*/
|
|
98
|
+
commandName: string;
|
|
99
|
+
/**
|
|
100
|
+
* Initial message sent to the agent when the workflow starts.
|
|
101
|
+
* Template variables: {workflowName}, {description}, {firstPhaseId}, {firstPhaseName},
|
|
102
|
+
* {firstPhaseEmoji}, {firstPhaseProfiles}.
|
|
103
|
+
*/
|
|
104
|
+
initialMessage: string;
|
|
105
|
+
/** Prefix for the session name. Defaults to "Workflow: ". */
|
|
106
|
+
sessionNamePrefix?: string;
|
|
107
|
+
/** Max session name length. Defaults to 50. */
|
|
108
|
+
sessionNameMaxLength?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Ordered list of phase entries.
|
|
111
|
+
* Each entry is either a concrete PhaseDefinition or a SubworkflowReference.
|
|
112
|
+
* The workflow advances linearly through this list.
|
|
113
|
+
* Must contain at least 1 phase.
|
|
114
|
+
*/
|
|
115
|
+
phases: PhaseEntry[];
|
|
116
|
+
/** Controls visibility: "user" (default) = shown in /workflow command; "workflows" = only usable as a subworkflow. */
|
|
117
|
+
show?: "user" | "workflows";
|
|
118
|
+
/** Whether the workflow can be looped (restarted from phase 0). Defaults to true. */
|
|
119
|
+
loopable?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Role instruction prepended to every context injection.
|
|
122
|
+
* Template variables: {workflowName}, {blockedToolsList}.
|
|
123
|
+
* If omitted, a sensible default is used.
|
|
124
|
+
*/
|
|
125
|
+
roleInstruction?: string;
|
|
126
|
+
/**
|
|
127
|
+
* Message appended at the end of every context injection reminding the agent
|
|
128
|
+
* to advance when done. Template variables: {workflowName}, {toolName}, {nextPhaseName}.
|
|
129
|
+
*/
|
|
130
|
+
advanceReminder?: string;
|
|
131
|
+
/**
|
|
132
|
+
* The reason shown to the agent when a tool is blocked.
|
|
133
|
+
* Template variables: {workflowName}, {phaseName}, {toolName}, {allowedTools}.
|
|
134
|
+
*/
|
|
135
|
+
blockReasonTemplate?: string;
|
|
136
|
+
/**
|
|
137
|
+
* Message sent when workflow reaches DONE state.
|
|
138
|
+
* Template variables: {workflowName}, {taskDescription}, {taskId}, {phaseCount}.
|
|
139
|
+
*/
|
|
140
|
+
completionMessage?: string;
|
|
141
|
+
/**
|
|
142
|
+
* Message shown when the agent tries to finish (agent_end) but the workflow
|
|
143
|
+
* is still active (not DONE).
|
|
144
|
+
* Template variables: {workflowName}, {phaseName}, {phaseEmoji}, {phaseInstructions}.
|
|
145
|
+
*/
|
|
146
|
+
notDoneReminder?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Runtime State ──
|
|
150
|
+
|
|
151
|
+
export interface WorkflowState {
|
|
152
|
+
/** Whether the workflow is currently active. */
|
|
153
|
+
active: boolean;
|
|
154
|
+
/** The workflow definition key from settings (e.g., "rpir"). */
|
|
155
|
+
workflowKey: string;
|
|
156
|
+
/** Navigation path stack. Index 0 = top-level workflow, last = innermost scope. */
|
|
157
|
+
currentPath: PathSegment[];
|
|
158
|
+
/** Monotonically increasing counter, incremented on every phase change. */
|
|
159
|
+
globalStepCount: number;
|
|
160
|
+
/** Unique task ID. */
|
|
161
|
+
taskId: string;
|
|
162
|
+
/** User's original description. */
|
|
163
|
+
taskDescription: string;
|
|
164
|
+
/** Timestamp when workflow started. */
|
|
165
|
+
startedAt: number;
|
|
166
|
+
/**
|
|
167
|
+
* Tracks whether DONE notification has been sent.
|
|
168
|
+
* Prevents duplicate notifications on repeated agent_end events.
|
|
169
|
+
*/
|
|
170
|
+
completionNotified: boolean;
|
|
171
|
+
/**
|
|
172
|
+
* True if the workflow was cancelled (not completed).
|
|
173
|
+
*/
|
|
174
|
+
cancelled: boolean;
|
|
175
|
+
/** @internal Set after first cancel request, requiring confirmation. */
|
|
176
|
+
_cancelPending?: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Resolved Active Workflow (runtime convenience) ──
|
|
180
|
+
|
|
181
|
+
export interface ActiveWorkflow {
|
|
182
|
+
/** The top-level workflow definition. */
|
|
183
|
+
definition: WorkflowDefinition;
|
|
184
|
+
state: WorkflowState;
|
|
185
|
+
/** The innermost resolved leaf phase (always a concrete PhaseDefinition). */
|
|
186
|
+
currentPhase: PhaseDefinition;
|
|
187
|
+
/** The raw PhaseEntry at the top of the stack (may be a SubworkflowReference). */
|
|
188
|
+
currentPhaseEntry: PhaseEntry;
|
|
189
|
+
/** The next phase entry in the innermost scope, or null if at the end. */
|
|
190
|
+
nextPhase: PhaseEntry | null;
|
|
191
|
+
/** Breadcrumb trail from top-level to innermost scope. */
|
|
192
|
+
breadcrumb: Array<{ workflowKey: string; name: string; phaseName: string; emoji: string }>;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Hook state mutation pattern ──
|
|
196
|
+
|
|
197
|
+
export interface HookStateMutation {
|
|
198
|
+
/** If true, set module state to null (unload workflow). */
|
|
199
|
+
unload: boolean;
|
|
200
|
+
/** If set, replace module state with this value (mutated copy). */
|
|
201
|
+
state?: WorkflowState;
|
|
202
|
+
/** If true, persist the current state via pi.appendEntry. */
|
|
203
|
+
persist: boolean;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── State accessor callbacks ──
|
|
207
|
+
|
|
208
|
+
export type GetState = () => WorkflowState | null;
|
|
209
|
+
export type SetState = (s: WorkflowState | null) => void;
|
|
210
|
+
export type GetDefinitions = () => Record<string, WorkflowDefinition>;
|
|
211
|
+
export type ReloadDefinitions = (cwd?: string) => Promise<Record<string, WorkflowDefinition>>;
|