@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
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
# State Management
|
|
2
|
+
|
|
3
|
+
The pi-workflows runtime tracks the lifecycle of a single workflow execution through a module-level closure variable, persisted to the session branch for crash recovery.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## State Architecture
|
|
8
|
+
|
|
9
|
+
State lives as a single closure-scoped variable in [`src/index.ts`](../src/index.ts):
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
let state: WorkflowState | null = null;
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| Value | Meaning |
|
|
16
|
+
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
17
|
+
| `null` | No workflow has been started, or the previous workflow completed/was cancelled and unloaded. |
|
|
18
|
+
| `WorkflowState` with `active: true` | A workflow is currently executing. The agent is expected to be working through phases. |
|
|
19
|
+
| `WorkflowState` with `active: false` | The workflow has finished all phases (or was cancelled) but the completion notification has not yet been sent. A transient state consumed by the `agent_end` hook. |
|
|
20
|
+
|
|
21
|
+
There is **never more than one** active workflow per session. Starting a new workflow while one is active prompts the user for confirmation.
|
|
22
|
+
|
|
23
|
+
### Lifecycle overview
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
┌──────────────────┐
|
|
27
|
+
/workflow cmd ──► │ createInitialState│──► persist
|
|
28
|
+
└────────┬─────────┘
|
|
29
|
+
│
|
|
30
|
+
▼
|
|
31
|
+
┌─────────────────────┐
|
|
32
|
+
│ active: true │◄─── loopPhase() resets
|
|
33
|
+
│ agent executes │ innermost scope
|
|
34
|
+
│ current phase │
|
|
35
|
+
└──────┬──────────────┘
|
|
36
|
+
│
|
|
37
|
+
workflow_step │ action='next'
|
|
38
|
+
│
|
|
39
|
+
▼
|
|
40
|
+
┌─── advancePhase() ───┐
|
|
41
|
+
│ │
|
|
42
|
+
┌──────┴──────┐ ┌──────┴──────┐
|
|
43
|
+
│ more phases │ │ all done │
|
|
44
|
+
│ stay active │ │ active:false│
|
|
45
|
+
└──────┬──────┘ └──────┬──────┘
|
|
46
|
+
│ │
|
|
47
|
+
▼ ▼
|
|
48
|
+
persist, agent_end hook sends
|
|
49
|
+
next turn completion notification
|
|
50
|
+
│
|
|
51
|
+
▼
|
|
52
|
+
state = null (unload)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## WorkflowState Fields
|
|
58
|
+
|
|
59
|
+
Defined in [`src/types.ts`](../src/types.ts):
|
|
60
|
+
|
|
61
|
+
| Field | Type | Description |
|
|
62
|
+
| -------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
63
|
+
| `active` | `boolean` | `true` when the agent should be working on phases. Set to `false` when the top-level workflow completes or is cancelled. |
|
|
64
|
+
| `workflowKey` | `string` | The top-level workflow definition key from settings (e.g. `"rpir"`, `"code-review"`). |
|
|
65
|
+
| `currentPath` | `PathSegment[]` | Navigation stack. Index 0 = root workflow, last index = innermost scope. |
|
|
66
|
+
| `globalStepCount` | `number` | Monotonically increasing counter. Incremented on every `advancePhase()`, `loopPhase()`, and subworkflow entry. |
|
|
67
|
+
| `taskId` | `string` | Unique identifier for this workflow run. Format: `wf-{timestamp}-{random6}`. |
|
|
68
|
+
| `taskDescription` | `string` | The user's original task description from the `/workflow` command. |
|
|
69
|
+
| `startedAt` | `number` | Unix timestamp (ms) when the workflow was created via `Date.now()`. |
|
|
70
|
+
| `completionNotified` | `boolean` | Whether the DONE notification has already been sent. Prevents duplicate messages on repeated `agent_end` events. |
|
|
71
|
+
| `cancelled` | `boolean` | `true` if the workflow was cancelled (not completed normally). Controls which completion message template is used. |
|
|
72
|
+
| `_cancelPending` | `boolean` (optional) | Internal flag set after the first cancel request, requiring a second call to confirm cancellation within the same turn. |
|
|
73
|
+
|
|
74
|
+
### Task ID format
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
wf-1747234567890-a3f9k2
|
|
78
|
+
│ │ │
|
|
79
|
+
│ │ └── 6 random base-36 chars (Math.random().toString(36).slice(2, 8))
|
|
80
|
+
│ └─────────────── Date.now() timestamp
|
|
81
|
+
└─────────────────── literal prefix
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Example: `wf-1747234567890-a3f9k2`
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## PathSegment
|
|
89
|
+
|
|
90
|
+
Each element in the `currentPath` stack represents one scope level:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
interface PathSegment {
|
|
94
|
+
workflowKey: string; // which workflow definition this scope refers to
|
|
95
|
+
phaseIndex: number; // current position in that workflow's phases array
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Stack semantics:**
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
currentPath[0] = root (top-level) workflow
|
|
103
|
+
currentPath[length - 1] = innermost (currently executing) scope
|
|
104
|
+
currentPath.length === 1 → flat, non-nested workflow
|
|
105
|
+
currentPath.length > 1 → inside one or more subworkflows
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
For details on how subworkflows create nested scopes, see [docs/subworkflows.md](subworkflows.md).
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Copy-on-Write Pattern
|
|
113
|
+
|
|
114
|
+
All state-transition functions in [`src/state.ts`](../src/state.ts) follow a **copy-on-write** pattern: they receive the current `WorkflowState`, produce a **new** cloned state object, mutate the clone, and return it alongside metadata. The original input is never modified.
|
|
115
|
+
|
|
116
|
+
The canonical clone helper is `cloneState()`, exported from `state.ts`:
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
export function cloneState(state: WorkflowState): WorkflowState {
|
|
120
|
+
return {
|
|
121
|
+
...state,
|
|
122
|
+
currentPath: state.currentPath.map((s) => ({ ...s })),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
This shallow-copies the top-level object and deep-copies the `currentPath` array (with new segment objects), ensuring each transition produces an independent state snapshot.
|
|
128
|
+
|
|
129
|
+
### Return types
|
|
130
|
+
|
|
131
|
+
| Function | Return type |
|
|
132
|
+
| ----------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
133
|
+
| `advancePhase(state, defs)` | `{ advanced: true; from: string; to: string \| null; newState: WorkflowState }` |
|
|
134
|
+
| `loopPhase(state, defs)` | `{ looped: true; to: string; newState: WorkflowState } \| { looped: false; error: string }` |
|
|
135
|
+
| `autoEnterSubworkflowRefs(state, entry)` | `{ phaseName: string \| null; newState: WorkflowState }` |
|
|
136
|
+
|
|
137
|
+
Callers (e.g. `tool.ts`, `command.ts`) receive `newState` and pass it to `setState()` to update the closure.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Phase Transitions
|
|
142
|
+
|
|
143
|
+
`advancePhase()` in [`src/state.ts`](../src/state.ts) handles all forward navigation. It clones the input state, examines the **top of the stack** (innermost scope) and the **current phase entry** at that position, then applies one of four cases:
|
|
144
|
+
|
|
145
|
+
### Case 1: Entering a subworkflow (push)
|
|
146
|
+
|
|
147
|
+
When the current entry at the top of the stack is a `SubworkflowReference`:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
BEFORE (input state):
|
|
151
|
+
currentPath = [{ release, phaseIndex: 1 }]
|
|
152
|
+
↑ points to { subworkflow: "review" }
|
|
153
|
+
|
|
154
|
+
ACTION: clone state (via cloneState()), push { review, phaseIndex: 0 } onto clone
|
|
155
|
+
|
|
156
|
+
AFTER (newState — input is untouched):
|
|
157
|
+
currentPath = [{ release, 1 }, { review, 0 }]
|
|
158
|
+
↑ now executing review's first phase
|
|
159
|
+
|
|
160
|
+
newState.globalStepCount++
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Case 2: Normal advance (increment)
|
|
164
|
+
|
|
165
|
+
When the current entry is a concrete `PhaseDefinition` and is **not** the last in scope:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
BEFORE (input state):
|
|
169
|
+
currentPath = [{ release, phaseIndex: 0 }]
|
|
170
|
+
↑ "build" phase, not the last
|
|
171
|
+
|
|
172
|
+
ACTION: clone state (via cloneState()), increment clone's top.phaseIndex
|
|
173
|
+
|
|
174
|
+
AFTER (newState — input is untouched):
|
|
175
|
+
currentPath = [{ release, 1 }]
|
|
176
|
+
↑ now at next phase
|
|
177
|
+
|
|
178
|
+
newState.globalStepCount++
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Case 3: Top-level completion (set inactive)
|
|
182
|
+
|
|
183
|
+
When the current entry is the **last** phase in scope and the stack has only **one** segment (root workflow):
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
BEFORE (input state):
|
|
187
|
+
currentPath = [{ release, phaseIndex: 3 }]
|
|
188
|
+
↑ last phase "verify"
|
|
189
|
+
|
|
190
|
+
ACTION: clone state (via cloneState()), set clone.active = false, clone.completionNotified = false
|
|
191
|
+
|
|
192
|
+
AFTER (newState):
|
|
193
|
+
currentPath = [{ release, 3 }] (unchanged)
|
|
194
|
+
active: false → workflow is DONE
|
|
195
|
+
|
|
196
|
+
newState.globalStepCount++
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The `agent_end` hook detects `active: false` + `completionNotified: false`, sends the completion message, then unloads state.
|
|
200
|
+
|
|
201
|
+
### Case 4: Subworkflow breakout (pop + advance parent)
|
|
202
|
+
|
|
203
|
+
When the current entry is the **last** phase in scope and the stack has **more than one** segment (inside a subworkflow):
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
BEFORE (input state):
|
|
207
|
+
currentPath = [{ release, 1 }, { review, 2 }]
|
|
208
|
+
↑ last phase in "review"
|
|
209
|
+
|
|
210
|
+
ACTION: clone state (via cloneState()), pop clone's stack, increment clone's parent.phaseIndex
|
|
211
|
+
|
|
212
|
+
AFTER (newState — input is untouched):
|
|
213
|
+
currentPath = [{ release, 2 }]
|
|
214
|
+
↑ parent advances past the subworkflow reference
|
|
215
|
+
|
|
216
|
+
newState.globalStepCount++
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### ASCII summary of the four cases
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
┌─────────────────────────────┐
|
|
223
|
+
│ Read top of currentPath │
|
|
224
|
+
│ Read phaseEntry at top index │
|
|
225
|
+
└──────────┬──────────────────┘
|
|
226
|
+
│
|
|
227
|
+
┌─────────────┴─────────────┐
|
|
228
|
+
│ Is entry a SubworkflowRef? │
|
|
229
|
+
└──┬──────────────────┬─────┘
|
|
230
|
+
YES │ │ NO
|
|
231
|
+
▼ │
|
|
232
|
+
┌──────────────┐ │
|
|
233
|
+
│ CASE 1: PUSH │ │
|
|
234
|
+
│ clone, push │ │
|
|
235
|
+
│ new segment │ │
|
|
236
|
+
└──────────────┘ │
|
|
237
|
+
▼
|
|
238
|
+
┌───────────────────────┐
|
|
239
|
+
│ Is this the last phase│
|
|
240
|
+
│ in the current scope? │
|
|
241
|
+
└──┬──────────────┬────┘
|
|
242
|
+
NO │ │ YES
|
|
243
|
+
▼ ▼
|
|
244
|
+
┌──────────────┐ ┌─────────────────┐
|
|
245
|
+
│ CASE 2: │ │ Stack length? │
|
|
246
|
+
│ INCREMENT │ └──┬──────────┬───┘
|
|
247
|
+
│ clone, idx++ │ 1 │ │ >1
|
|
248
|
+
└──────────────┘ ▼ ▼
|
|
249
|
+
┌────────┐ ┌──────────────┐
|
|
250
|
+
│ CASE 3 │ │ CASE 4: │
|
|
251
|
+
│ DONE │ │ POP + ADVANCE│
|
|
252
|
+
│ clone, │ │ clone, pop, │
|
|
253
|
+
│active- │ │ parent idx++ │
|
|
254
|
+
│ =false │ │ │
|
|
255
|
+
└────────┘ └──────────────┘
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Loop Phase
|
|
261
|
+
|
|
262
|
+
`loopPhase()` restarts the **innermost scope** from phase 0. It clones the input state, resets the top segment's `phaseIndex`, increments `globalStepCount`, and returns `{ looped: true, to, newState }`. If the workflow has `loopable: false`, it returns `{ looped: false, error }` instead — without cloning.
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
const s = cloneState(state);
|
|
266
|
+
const top = s.currentPath[s.currentPath.length - 1];
|
|
267
|
+
top.phaseIndex = 0;
|
|
268
|
+
s.globalStepCount++;
|
|
269
|
+
return { looped: true, to: phaseName, newState: s };
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Guards:**
|
|
273
|
+
|
|
274
|
+
- If the innermost workflow definition has `loopable: false`, the operation is rejected:
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
{ error: "Looping is disabled for this workflow." }
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
- `loopable` defaults to `true` when omitted from the workflow definition.
|
|
281
|
+
|
|
282
|
+
**Example — looping inside a nested subworkflow:**
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
BEFORE (input state):
|
|
286
|
+
currentPath = [{ release, 2 }, { review, 1 }]
|
|
287
|
+
↑ innermost at phase 1
|
|
288
|
+
|
|
289
|
+
ACTION: loopPhase() → clone, set clone's top.phaseIndex = 0
|
|
290
|
+
|
|
291
|
+
AFTER (newState):
|
|
292
|
+
currentPath = [{ release, 2 }, { review, 0 }]
|
|
293
|
+
↑ restarted
|
|
294
|
+
|
|
295
|
+
Input state is untouched.
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Persistence
|
|
301
|
+
|
|
302
|
+
State is persisted after every state transition (each of which returns a new state object) so the session can recover after a crash or reload.
|
|
303
|
+
|
|
304
|
+
### Storage mechanism
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
pi.appendEntry("workflow:state", { ...state });
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Each call appends a **new** entry to the session branch (no in-place updates). The latest entry is found by scanning in reverse during reconstruction.
|
|
311
|
+
|
|
312
|
+
### When persistence occurs
|
|
313
|
+
|
|
314
|
+
| Trigger | Location | Condition |
|
|
315
|
+
| ----------------------- | ------------------------------------- | ----------------------------------------------------------------- |
|
|
316
|
+
| Workflow creation | `registerWorkflowCommand` handler | Immediately after `createInitialState()` |
|
|
317
|
+
| Phase advance | `workflow_step` tool, action `next` | After `advancePhase()` returns new state |
|
|
318
|
+
| Phase loop | `workflow_step` tool, action `loop` | After `loopPhase()` returns new state |
|
|
319
|
+
| Cancellation (tool) | `workflow_step` tool, action `cancel` | After setting `cancelled: true`, `active: false` |
|
|
320
|
+
| Cancellation (command) | `/cancel-workflow` command | After setting `cancelled: true`, `active: false` |
|
|
321
|
+
| Completion notification | `agent_end` hook | After sending the DONE message (marks `completionNotified: true`) |
|
|
322
|
+
|
|
323
|
+
### Entry structure in the session branch
|
|
324
|
+
|
|
325
|
+
```json
|
|
326
|
+
{
|
|
327
|
+
"type": "custom",
|
|
328
|
+
"customType": "workflow:state",
|
|
329
|
+
"data": {
|
|
330
|
+
"active": true,
|
|
331
|
+
"workflowKey": "rpir",
|
|
332
|
+
"currentPath": [{ "workflowKey": "rpir", "phaseIndex": 2 }],
|
|
333
|
+
"globalStepCount": 2,
|
|
334
|
+
"taskId": "wf-1747234567890-a3f9k2",
|
|
335
|
+
"taskDescription": "Refactor authentication module",
|
|
336
|
+
"startedAt": 1747234567890,
|
|
337
|
+
"completionNotified": false,
|
|
338
|
+
"cancelled": false
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## Reconstruction (Crash Recovery)
|
|
346
|
+
|
|
347
|
+
When a session starts or the session tree changes, `reconstructState()` scans the session branch entries in **reverse chronological order** to find the most recent workflow state.
|
|
348
|
+
|
|
349
|
+
### Algorithm
|
|
350
|
+
|
|
351
|
+
```
|
|
352
|
+
1. Get the full session branch (array of entries)
|
|
353
|
+
2. Iterate from the last entry backwards
|
|
354
|
+
3. Match entries where:
|
|
355
|
+
type === "custom"
|
|
356
|
+
customType === "workflow:state"
|
|
357
|
+
data.workflowKey exists
|
|
358
|
+
4. Apply migrations (see below)
|
|
359
|
+
5. Validate structure
|
|
360
|
+
6. Return the reconstructed state (or null if none found)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Migration: `currentPhaseIndex` → `currentPath`
|
|
364
|
+
|
|
365
|
+
Old versions of pi-workflows used a single `currentPhaseIndex` field (no subworkflow support). The reconstruction migrates this on-the-fly:
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
if (data.currentPhaseIndex !== undefined && !data.currentPath) {
|
|
369
|
+
data.currentPath = [
|
|
370
|
+
{
|
|
371
|
+
workflowKey: data.workflowKey,
|
|
372
|
+
phaseIndex: data.currentPhaseIndex,
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
delete data.currentPhaseIndex;
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Migration: missing `globalStepCount`
|
|
380
|
+
|
|
381
|
+
Early persisted states may lack `globalStepCount`:
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
if (data.currentPath && data.globalStepCount === undefined) {
|
|
385
|
+
data.globalStepCount = data.currentPath[0]?.phaseIndex ?? 0;
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
This is a best-effort approximation using the root phase index as the step count.
|
|
390
|
+
|
|
391
|
+
### Validation
|
|
392
|
+
|
|
393
|
+
After migration, the path structure is validated to prevent crashes from corrupted or tampered data:
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
// Reject empty paths
|
|
397
|
+
if (!Array.isArray(data.currentPath) || data.currentPath.length === 0) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Reject malformed segments
|
|
402
|
+
for (const seg of data.currentPath) {
|
|
403
|
+
if (typeof seg.workflowKey !== "string" || typeof seg.phaseIndex !== "number") {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Invalid states are silently discarded — the session starts with `state = null` (no active workflow).
|
|
410
|
+
|
|
411
|
+
### When reconstruction runs
|
|
412
|
+
|
|
413
|
+
| Event | Handler in `index.ts` |
|
|
414
|
+
| --------------- | -------------------------------------------------------------- |
|
|
415
|
+
| `session_start` | Definitions are loaded, then `reconstructState(ctx)` is called |
|
|
416
|
+
| `session_tree` | Same flow — reload definitions and reconstruct state |
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## ActiveWorkflow Resolution
|
|
421
|
+
|
|
422
|
+
`resolveActive()` converts the raw `WorkflowState` + definitions into a fully resolved `ActiveWorkflow` object with a breadcrumb trail. This is the primary interface used by hooks and the tool handler.
|
|
423
|
+
|
|
424
|
+
### Resolution steps
|
|
425
|
+
|
|
426
|
+
```
|
|
427
|
+
1. Return null if state is null or inactive
|
|
428
|
+
2. Walk currentPath — validate every segment's workflowKey exists in definitions
|
|
429
|
+
3. Read innermost (top) segment
|
|
430
|
+
4. Get the current PhaseEntry at that position
|
|
431
|
+
5. If the entry is a SubworkflowReference, drill into its first concrete PhaseDefinition
|
|
432
|
+
6. Build breadcrumb from all path segments
|
|
433
|
+
7. Return ActiveWorkflow
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### Return value
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
interface ActiveWorkflow {
|
|
440
|
+
definition: WorkflowDefinition; // top-level workflow definition
|
|
441
|
+
state: WorkflowState; // current state
|
|
442
|
+
currentPhase: PhaseDefinition; // innermost concrete phase (never a SubworkflowRef)
|
|
443
|
+
currentPhaseEntry: PhaseEntry; // raw entry at top of stack (may be SubworkflowRef)
|
|
444
|
+
nextPhase: PhaseEntry | null; // next entry in innermost scope, or null
|
|
445
|
+
breadcrumb: Array<{
|
|
446
|
+
workflowKey: string;
|
|
447
|
+
name: string;
|
|
448
|
+
phaseName: string;
|
|
449
|
+
emoji: string;
|
|
450
|
+
}>;
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Subworkflow drilling
|
|
455
|
+
|
|
456
|
+
When the innermost scope's current entry is a `SubworkflowReference`, `resolveActive()` drills into the subworkflow's first phase to find a concrete `PhaseDefinition`:
|
|
457
|
+
|
|
458
|
+
```
|
|
459
|
+
currentPath = [{ release, 1 }]
|
|
460
|
+
↑ points to { subworkflow: "review" }
|
|
461
|
+
|
|
462
|
+
resolveActive drills:
|
|
463
|
+
review.phases[0] → concrete PhaseDefinition (e.g. "Static Analysis")
|
|
464
|
+
|
|
465
|
+
→ currentPhase = "Static Analysis" (not the SubworkflowReference)
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### Breadcrumb construction
|
|
469
|
+
|
|
470
|
+
The breadcrumb array has one entry per path segment. The innermost entry gets the current phase's emoji; ancestor entries get an empty emoji:
|
|
471
|
+
|
|
472
|
+
```
|
|
473
|
+
Path: [{ release, 1 }, { review, 0 }]
|
|
474
|
+
|
|
475
|
+
Breadcrumb:
|
|
476
|
+
[
|
|
477
|
+
{ workflowKey: "release", name: "Release Pipeline", phaseName: "Release Pipeline", emoji: "" },
|
|
478
|
+
{ workflowKey: "review", name: "Code Review", phaseName: "Static Analysis", emoji: "🔍" }
|
|
479
|
+
]
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
This drives the status bar display:
|
|
483
|
+
|
|
484
|
+
```
|
|
485
|
+
Release Pipeline > Code Review [2/3] > 🔍 Static Analysis [1/2]
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
## State Mutation Pattern
|
|
491
|
+
|
|
492
|
+
Hooks don't modify the closure variable directly. Instead, they return a `HookStateMutation`:
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
interface HookStateMutation {
|
|
496
|
+
unload: boolean; // if true, set state = null
|
|
497
|
+
state?: WorkflowState; // if set, replace state with this value
|
|
498
|
+
persist: boolean; // if true, persist via pi.appendEntry
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
The `index.ts` event handler applies the mutation:
|
|
503
|
+
|
|
504
|
+
```typescript
|
|
505
|
+
const mutation = handleAgentEnd(pi, state, definitions, ctx, event);
|
|
506
|
+
if (mutation.unload) {
|
|
507
|
+
state = null;
|
|
508
|
+
} else if (mutation.state) {
|
|
509
|
+
state = mutation.state;
|
|
510
|
+
}
|
|
511
|
+
if (mutation.persist && state) {
|
|
512
|
+
persistState(pi, state);
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
The `workflow_step` tool and `/workflow` command use accessor callbacks (`getState`, `setState`) instead, since they are registered with references to the same closure.
|
|
517
|
+
|
|
518
|
+
---
|
|
519
|
+
|
|
520
|
+
## Two-Step Cancellation
|
|
521
|
+
|
|
522
|
+
Cancelling a workflow via the tool requires two consecutive `workflow_step` calls with `action: "cancel"`:
|
|
523
|
+
|
|
524
|
+
1. **First call** — sets `_cancelPending = true`, returns a confirmation prompt.
|
|
525
|
+
2. **Second call** (same turn) — creates a new state object with `active: false`, `cancelled: true`, persists it, and updates the closure.
|
|
526
|
+
|
|
527
|
+
The `/cancel-workflow` command bypasses this two-step flow and immediately cancels.
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## Related Documentation
|
|
532
|
+
|
|
533
|
+
- [Subworkflows](subworkflows.md) — How subworkflow references create nested path stacks and the full loading/resolution process
|
|
534
|
+
- [Configuration Reference](configuration-reference.md) — Workflow definition schema including `loopable`, `show`, phase definitions, and template variables
|