@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.
@@ -0,0 +1,380 @@
1
+ # Hook Lifecycle
2
+
3
+ The pi-workflows extension registers **4 hook functions** on pi framework lifecycle events. Each hook receives the current [`WorkflowState`](#hookstatemutation-and-workflowstate) and the loaded [`WorkflowDefinition`](configuration-reference.md) map, then either mutates state, injects messages, or returns control signals back to the framework.
4
+
5
+ For the overall extension architecture, see [architecture.md](architecture.md). For state semantics and the `resolveActive` / `isActive` helpers, see [state-management.md](state-management.md).
6
+
7
+ ---
8
+
9
+ ## Registration
10
+
11
+ All four hooks are wired in [`src/index.ts`](../src/index.ts) via `pi.on()`:
12
+
13
+ | Framework Event | Hook Function | Called When |
14
+ | -------------------- | ------------------------ | ------------------------------------------------ |
15
+ | `session_start` | `updateStatus` | Session loaded or created |
16
+ | `session_tree` | `updateStatus` | Session branch changed |
17
+ | `turn_end` | `updateStatus` | Agent turn completed |
18
+ | `tool_call` | `handleToolCall` | Agent requests a tool invocation |
19
+ | `before_agent_start` | `handleBeforeAgentStart` | Before the agent begins a new turn |
20
+ | `agent_end` | `handleAgentEnd` | Agent stops (completion, error, or interruption) |
21
+
22
+ `session_start` and `session_tree` also handle definition loading and state reconstruction before calling `updateStatus`.
23
+
24
+ ---
25
+
26
+ ## updateStatus
27
+
28
+ ```ts
29
+ function updateStatus(
30
+ ctx: { ui: { setStatus: (key: string, text: string | undefined) => void } },
31
+ state: WorkflowState | null,
32
+ definitions: Record<string, WorkflowDefinition>,
33
+ ): void;
34
+ ```
35
+
36
+ **Called on:** `session_start`, `session_tree`, `turn_end`
37
+
38
+ ### Logic
39
+
40
+ 1. **Early-return conditions** → calls `ctx.ui.setStatus("workflow", undefined)` to clear the status bar and returns immediately if any of these conditions are met:
41
+ - `!state` (null state)
42
+ - `!state.active` (inactive state)
43
+ - `state.currentPath.length === 0` (empty path)
44
+ - `!(state.currentPath[0].workflowKey in definitions)` (missing root definition)
45
+
46
+ 2. **Build status parts array** → starts with the top-level workflow name.
47
+
48
+ 3. **Loop through `currentPath` segments** → for each segment, looks up its definition and phase entry:
49
+ - For `SubworkflowReference` entries: adds `{name} [current/total]` (no emoji)
50
+ - For `PhaseDefinition` entries: adds `{emoji} {name} [current/total]`
51
+
52
+ 4. **Bounds check** → if any segment's `phaseIndex` is out of bounds (>= phases.length), clears status bar and returns.
53
+
54
+ 5. **Format and set** → joins all parts with `>` and calls `ctx.ui.setStatus("workflow", statusString)`.
55
+
56
+ ### Status Format
57
+
58
+ All status strings use `>` as the segment separator. Every level in the path — including subworkflow containers — shows its own `[N/M]` progress counter. Emojis appear only on concrete `PhaseDefinition` entries; subworkflow levels (`SubworkflowReference`) show name + progress without an emoji.
59
+
60
+ **Linear workflow** (`currentPath.length === 1`):
61
+
62
+ ```
63
+ {workflowName} > {phaseEmoji} {phaseName} [{current}/{total}]
64
+ ```
65
+
66
+ Example: `CI/CD Pipeline > 📋 Planning [1/3]`
67
+
68
+ **Nested workflow** (`currentPath.length === 2`) — shows the subworkflow container with its own progress, then the inner phase:
69
+
70
+ ```
71
+ {workflowName} > {subworkflowName} [{subCurrent}/{subTotal}] > {phaseEmoji} {phaseName} [{current}/{total}]
72
+ ```
73
+
74
+ Example: `Release Pipeline > Code Review [2/3] > 🔍 Static Analysis [1/2]`
75
+
76
+ **Deep nesting** (`currentPath.length > 2`) — each subworkflow level repeats the `{name} [N/M]` pattern, terminated by the leaf phase with its emoji:
77
+
78
+ ```
79
+ {workflowName} > {sub1} [N/M] > {sub2} [N/M] > {phaseEmoji} {phaseName} [N/M]
80
+ ```
81
+
82
+ Example: `RPIR Development > Implementation [3/5] > Testing [2/2] > 🧪 Unit Tests [1/4]`
83
+
84
+ Progress numbers are 1-indexed (`phaseIndex + 1`). Each level's total comes from the corresponding workflow definition's `phases.length`.
85
+
86
+ ---
87
+
88
+ ## handleToolCall
89
+
90
+ ```ts
91
+ function handleToolCall(
92
+ event: ToolCallEvent,
93
+ state: WorkflowState | null,
94
+ definitions: Record<string, WorkflowDefinition>,
95
+ ): { block: true; reason: string } | void;
96
+ ```
97
+
98
+ **Called on:** `tool_call`
99
+
100
+ Returns `{ block: true; reason: string }` to block the tool call, or `void` (undefined) to allow it.
101
+
102
+ ### Decision Flow
103
+
104
+ ```
105
+ state is null or inactive → allow (return void)
106
+
107
+
108
+ resolveActive fails → allow
109
+
110
+
111
+ toolName === "workflow_step" → always allow
112
+
113
+
114
+ phase.tools is undefined → allow (no restrictions for this phase)
115
+
116
+
117
+ phase.tools.blacklist is set AND toolName is in blockedTools → BLOCK
118
+
119
+
120
+ phase.tools.whitelist is set AND toolName is NOT in whitelist → BLOCK
121
+
122
+
123
+ otherwise → allow
124
+ ```
125
+
126
+ Key behaviors:
127
+
128
+ - **`workflow_step` is always allowed** regardless of blacklist/whitelist. This ensures the agent can always advance phases.
129
+ - If a phase has no `tools` property, all tools are permitted.
130
+ - Exactly one of `blacklist` or `whitelist` may be set per phase (mutually exclusive).
131
+
132
+ ### Block Reason Template
133
+
134
+ When a tool is blocked, the reason string is generated from the definition's `blockReasonTemplate` (or the built-in default). Available template variables:
135
+
136
+ | Variable | Description |
137
+ | ---------------- | -------------------------------------- |
138
+ | `{workflowName}` | Human-readable workflow name |
139
+ | `{phaseName}` | Current phase name |
140
+ | `{toolName}` | The tool that was blocked |
141
+ | `{allowedTools}` | Human-readable list of permitted tools |
142
+
143
+ **Default template:**
144
+
145
+ ```
146
+ [workflow] The tool "{toolName}" is blocked during the {phaseName} phase.
147
+ Refer to the current phase instructions for allowed tools and approaches.
148
+ When finished, call workflow_step to advance to the next phase.
149
+ ```
150
+
151
+ When the block comes from a blacklist, `{allowedTools}` resolves to `"all except: "` + the joined blacklist. When from a whitelist, it resolves to the joined whitelist.
152
+
153
+ ---
154
+
155
+ ## handleBeforeAgentStart
156
+
157
+ ```ts
158
+ function handleBeforeAgentStart(
159
+ state: WorkflowState | null,
160
+ definitions: Record<string, WorkflowDefinition>,
161
+ ): { message: { customType: string; content: string; display: boolean } } | void;
162
+ ```
163
+
164
+ **Called on:** `before_agent_start`
165
+
166
+ ### Logic
167
+
168
+ 1. If state is inactive or null, or `resolveActive` fails → return `void` (no injection).
169
+ 2. Otherwise, calls [`buildContextPrompt(active)`](#buildcontextprompt-details) to generate the full context string.
170
+ 3. Returns a hidden message with `customType: "workflow:context"` and `display: false`.
171
+
172
+ The returned message is injected into the conversation before the agent begins its turn. Because `display: false`, the user does not see it, but the agent reads it as context.
173
+
174
+ ### buildContextPrompt Details
175
+
176
+ The prompt is assembled from these sections in order:
177
+
178
+ | Section | Source | Notes |
179
+ | ---------------------- | ------------------------------------------------ | ----------------------------------------------- |
180
+ | **Header line** | `[Workflow path: {breadcrumb} ▸ {emoji} {name}]` | Breadcrumb from `active.breadcrumb` |
181
+ | **Role instruction** | `definition.roleInstruction` or default | Template-resolved with workflow/phase variables |
182
+ | **Task details** | `taskDescription`, `taskId` | From `state` |
183
+ | **Current phase** | Emoji + name | From `active.currentPhase` |
184
+ | **Progress** | `globalStepCount` and `phaseIndex`/total | Format varies for linear vs nested |
185
+ | **Phase instructions** | `currentPhase.instructions` | Template-resolved |
186
+ | **Profiles** | `availableProfiles` + all workflow profiles | Lists per-phase and global profiles |
187
+ | **Advance reminder** | `definition.advanceReminder` or default | Reminds agent to call `workflow_step` |
188
+
189
+ **Default role instruction:**
190
+
191
+ > You are the ORCHESTRATOR for this workflow. You must NOT use the edit or write tools directly. All implementation work must be delegated to subagents via the delegate_to_subagents tool. Follow the phase instructions precisely.
192
+
193
+ **Default advance reminder:**
194
+
195
+ > When you finish this phase, call the workflow_step tool with action='next' to advance to the next phase. If you need to restart the current scope from the beginning, use action='loop'.
196
+
197
+ Template variables available to `roleInstruction`, `instructions`, and `advanceReminder`:
198
+
199
+ `{workflowName}`, `{workflowKey}`, `{description}`, `{taskId}`, `{phaseId}`, `{phaseName}`, `{previousPhaseName}`, `{nextPhaseName}`, `{blockedToolsList}`, `{toolName}`, `{breadcrumbPath}`, `{globalStepCount}`
200
+
201
+ ---
202
+
203
+ ## handleAgentEnd
204
+
205
+ ```ts
206
+ function handleAgentEnd(
207
+ pi: ExtensionAPI,
208
+ state: WorkflowState | null,
209
+ definitions: Record<string, WorkflowDefinition>,
210
+ ctx: ExtensionContext,
211
+ event: AgentEndEvent,
212
+ ): HookStateMutation;
213
+ ```
214
+
215
+ **Called on:** `agent_end`
216
+
217
+ This hook has three distinct code paths. It returns a [`HookStateMutation`](#hookstatemutation-interface) to tell `index.ts` how to update module state.
218
+
219
+ ### Case A — Workflow just completed (DONE)
220
+
221
+ **Condition:** `state` exists, `state.active === false`, `state.completionNotified === false`.
222
+
223
+ There are two sub-cases:
224
+
225
+ #### Normal completion (`state.cancelled === false`)
226
+
227
+ 1. Looks up the definition via `state.workflowKey`.
228
+ 2. Resolves the `completionMessage` template (or default) with variables: `{workflowName}`, `{taskDescription}`, `{taskId}`, `{phaseCount}`.
229
+ 3. Sends a visible message via `pi.sendMessage` with `customType: "workflow:complete"`, `display: true`, `triggerTurn: false`.
230
+ 4. Sets `state.completionNotified = true`.
231
+ 5. Clears the status bar.
232
+ 6. Returns `{ unload: true, persist: true }`.
233
+
234
+ **Default completion message:**
235
+
236
+ ```
237
+ ✅ **{workflowName} Complete**
238
+
239
+ **Task:** {taskDescription}
240
+ **Task ID:** {taskId}
241
+ **Phases completed:** {phaseCount}
242
+ ```
243
+
244
+ #### Cancellation (`state.cancelled === true`)
245
+
246
+ Same flow as normal completion, but resolves `completionMessage` if set, otherwise falls back to `DEFAULT_CANCELLED_MESSAGE` (not `DEFAULT_COMPLETION_MESSAGE`). Returns `{ unload: true, persist: false }` (no persistence of cancelled state).
247
+
248
+ > **Note:** There is no separate `cancelledMessage` field on `WorkflowDefinition`. The cancellation path reuses `completionMessage` with `DEFAULT_CANCELLED_MESSAGE` as fallback. Additionally, the `/cancel-workflow` command (`src/command.ts`) sends a hardcoded message with no template resolution and unloads state immediately, so this hook branch only fires when cancelled state is reached through other paths (e.g., session resume).
249
+
250
+ **Default cancelled message:**
251
+
252
+ ```
253
+ ❌ **{workflowName} Cancelled**
254
+
255
+ **Task:** {taskDescription}
256
+ **Task ID:** {taskId}
257
+ ```
258
+
259
+ ### Case B — Workflow still active (agent stopped mid-workflow)
260
+
261
+ **Condition:** `state.active === true`.
262
+
263
+ 1. Checks [`wasAborted(event.messages)`](#wasaborted-check) — if the user interrupted the agent, returns `{ unload: false, persist: false }` (no enforcement).
264
+ 2. Resolves the active workflow. If resolution fails, returns no-op.
265
+ 3. Resolves the `notDoneReminder` template (or default) with variables: `{workflowName}`, `{phaseName}`, `{phaseEmoji}`, `{phaseInstructions}`, `{taskDescription}`, `{taskId}`, `{workflowKey}`.
266
+ 4. Delegates to `startCountdown(pi, ctx, reminder)`, which behaves differently based on `ctx.hasUI`:
267
+ - **With UI** — displays a `workflow-countdown` widget above the editor and uses `timerManager.startInterval(1000, ...)` to tick down from 3 seconds, updating the widget each second. When the countdown reaches zero, calls `timerManager.clearAll()`, removes the widget, and injects the reminder via `pi.sendUserMessage(reminder)`.
268
+ - **Without UI** — sends an immediate `pi.sendMessage` with `customType: "workflow:countdown"`, `display: true`, `triggerTurn: false`, then uses `timerManager.startTimeout(3000, ...)` to inject the reminder after 3 seconds.
269
+ In both paths, if the user started typing during the grace period, the `pi.sendUserMessage` call is caught and silently ignored. All timer handles are tracked by the [`TimerManager`](../src/TimerManager.ts) singleton (`timerManager`), which prevents stale callbacks and allows clean cancellation via `timerManager.clearAll()`.
270
+ 5. Returns `{ unload: false, persist: false }`.
271
+
272
+ **Default not-done reminder:**
273
+
274
+ ```
275
+ ⚠️ The {workflowName} is still active. Current phase: {phaseEmoji} {phaseName}.
276
+
277
+ You must NOT stop yet. The workflow requires you to complete the current phase
278
+ and call workflow_step to advance.
279
+
280
+ Current phase instructions:
281
+ {phaseInstructions}
282
+
283
+ Continue working on the current phase and call workflow_step when done.
284
+ ```
285
+
286
+ ### Case C — Already notified or no state
287
+
288
+ **Condition:** State is null, or `completionNotified === true`, or any other unhandled case.
289
+
290
+ Returns `{ unload: false, persist: false }` — a no-op.
291
+
292
+ ### wasAborted Check
293
+
294
+ The `wasAborted` helper walks `event.messages` in reverse to find the last assistant message. If that message has `stopReason === "aborted"`, the agent was interrupted by the user. This prevents the auto-continue countdown from firing when the user deliberately stopped the agent.
295
+
296
+ ---
297
+
298
+ ## HookStateMutation Interface
299
+
300
+ ```ts
301
+ interface HookStateMutation {
302
+ /** If true, set module state to null (unload workflow). */
303
+ unload: boolean;
304
+ /** If set, replace module state with this value (mutated copy). */
305
+ state?: WorkflowState;
306
+ /** If true, persist the current state via pi.appendEntry. */
307
+ persist: boolean;
308
+ }
309
+ ```
310
+
311
+ Returned by `handleAgentEnd` and consumed by `index.ts` in the `agent_end` handler:
312
+
313
+ ```ts
314
+ const mutation = handleAgentEnd(pi, state, definitions, ctx, event);
315
+ if (mutation.unload) {
316
+ state = null; // Unload: clear module state
317
+ } else if (mutation.state) {
318
+ state = mutation.state; // Replace: use the returned state
319
+ }
320
+ if (mutation.persist && state) {
321
+ persistState(pi, state); // Persist: write to session entry log
322
+ }
323
+ ```
324
+
325
+ ### Mutation Semantics by Case
326
+
327
+ | Case | `unload` | `state` | `persist` | Effect |
328
+ | ------------------------ | -------- | ------- | --------- | ------------------------------------ |
329
+ | No-op | `false` | — | `false` | No changes |
330
+ | Normal completion | `true` | — | `true` | State persisted, then unloaded |
331
+ | Cancellation | `true` | — | `false` | State discarded, unloaded |
332
+ | Still active / countdown | `false` | — | `false` | No state change; auto-continue fires |
333
+
334
+ ---
335
+
336
+ ## Message Custom Types
337
+
338
+ The hooks produce three distinct `customType` values, each with a registered renderer in [`src/renderers.ts`](../src/renderers.ts):
339
+
340
+ | Custom Type | Produced By | `display` | `triggerTurn` | Rendered Appearance |
341
+ | -------------------- | ------------------------- | --------- | ------------- | -------------------------------------------- |
342
+ | `workflow:context` | `handleBeforeAgentStart` | `false` | — | `🔄 [Workflow Context injected]` (dim) |
343
+ | `workflow:complete` | `handleAgentEnd` (Case A) | `true` | `false` | Bold success/completion message in green |
344
+ | `workflow:countdown` | `handleAgentEnd` (Case B) | `true` | `false` | `⏳ Auto-continuing workflow in 3s...` (dim) |
345
+
346
+ ### Renderer Details
347
+
348
+ Each custom type has a dedicated renderer registered via `pi.registerMessageRenderer`:
349
+
350
+ - **`workflow:context`** — Renders a minimal dim accent line. Because `display: false`, this message is hidden from the user's main conversation view; the renderer produces a subtle indicator only.
351
+ - **`workflow:complete`** — Renders the full completion or cancellation message in bold green (`theme.fg("success", ...)`).
352
+ - **`workflow:countdown`** — Renders the countdown timer text in accent color with dim styling, showing the user they have a grace period to interrupt.
353
+
354
+ ---
355
+
356
+ ## Hook Interaction Flow
357
+
358
+ The following timeline shows how the hooks interact during a typical workflow run:
359
+
360
+ ```
361
+ 1. session_start / session_tree
362
+ └─ loadWorkflows() → reconstructState() → updateStatus()
363
+
364
+ 2. [Each agent turn]
365
+ ├─ before_agent_start
366
+ │ └─ handleBeforeAgentStart() → injects workflow:context message
367
+ ├─ [agent runs, may call tools]
368
+ │ └─ tool_call (per tool)
369
+ │ └─ handleToolCall() → may block with reason
370
+ └─ agent_end
371
+ └─ handleAgentEnd() → HookStateMutation
372
+ ├─ Case A: DONE → workflow:complete message, unload
373
+ ├─ Case B: still active → workflow:countdown, 3s auto-continue
374
+ └─ Case C: no-op
375
+
376
+ 3. turn_end
377
+ └─ updateStatus() → refreshes status bar with current phase
378
+ ```
379
+
380
+ Each hook reads state immutably (except `handleAgentEnd`, which may set `completionNotified`). All state mutations flow back through `index.ts` via the `HookStateMutation` return value — hooks never call `setState` directly.