@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,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