@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,428 @@
1
+ # Subworkflows
2
+
3
+ ## What Are Subworkflows
4
+
5
+ A **subworkflow** is a workflow definition referenced as a phase entry inside another workflow's `phases` array. The entire subworkflow's phase sequence runs as a single logical "phase" within the parent. When the subworkflow completes (all its phases finish), control returns to the parent's next phase.
6
+
7
+ Subworkflows enable composition: build small, reusable workflow units and combine them into larger pipelines without duplicating phase definitions.
8
+
9
+ ```
10
+ Parent Workflow
11
+ ├── Phase A: "Gather Requirements"
12
+ ├── Phase B: "Code Review Cycle" ← This is actually a subworkflow
13
+ │ ├── Phase B1: "Static Analysis"
14
+ │ ├── Phase B2: "Peer Review"
15
+ │ └── Phase B3: "Approval Gate"
16
+ ├── Phase C: "Deploy"
17
+ └── Phase D: "Verify"
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Declaring a Subworkflow Reference
23
+
24
+ In `workflow.yaml`, use an **object syntax** to declare a subworkflow reference within the `phases` array:
25
+
26
+ ```yaml
27
+ # my-workflow/workflow.yaml
28
+ name: "Release Pipeline"
29
+ commandName: "release"
30
+ initialMessage: "Starting {workflowName} for: {description}"
31
+ phases:
32
+ - build.md # concrete phase (string = filename)
33
+ - { subworkflow: code-review } # subworkflow reference
34
+ - deploy.md # concrete phase
35
+ ```
36
+
37
+ The `subworkflow` value must match the **directory name** of another workflow loaded from the same workflows root. During the two-pass loading process (see [Resolution and Loading](#resolution-and-loading)), the reference is replaced with a resolved link to the target workflow definition.
38
+
39
+ ### In-memory representation
40
+
41
+ After loading, a subworkflow reference is represented as a [`SubworkflowReference`](../src/types.ts) object:
42
+
43
+ ```typescript
44
+ interface SubworkflowReference {
45
+ subworkflow: true; // discriminator
46
+ workflowKey: string; // directory name of the target workflow
47
+ resolved: WorkflowDefinition | null; // null after Pass 1, populated during Pass 2 resolution
48
+ }
49
+ ```
50
+
51
+ The `resolved` field is initialized as `null` during Pass 1 loading and only populated with the target `WorkflowDefinition` during Pass 2 resolution. Code that traverses phase entries should check for `resolved === null` to detect unresolved references.
52
+
53
+ The type guard `isSubworkflowRef(entry)` distinguishes subworkflow references from plain `PhaseDefinition` objects.
54
+
55
+ ---
56
+
57
+ ## Subworkflow-Only Workflows
58
+
59
+ Some workflows exist solely to be consumed as subworkflows — they should never appear in the `/workflow` slash command menu. Set `show: "workflows"` in their `workflow.yaml`:
60
+
61
+ ```yaml
62
+ # _shared/code-review/workflow.yaml
63
+ name: "Code Review Cycle"
64
+ show: "workflows" # hidden from /workflow command
65
+ loopable: false
66
+ phases:
67
+ - static-analysis.md
68
+ - peer-review.md
69
+ - approval-gate.md
70
+ ```
71
+
72
+ When `show` is `"workflows"`:
73
+
74
+ - The workflow is **excluded** from the `/workflow` command list.
75
+ - `commandName` and `initialMessage` are **optional** (defaults to empty string).
76
+ - The workflow can still be freely referenced as a subworkflow by other workflows.
77
+
78
+ When `show` is omitted or `"user"` (the default), the workflow is visible to users and `commandName`/`initialMessage` are required.
79
+
80
+ ---
81
+
82
+ ## Resolution and Loading
83
+
84
+ Workflows are loaded via a **two-pass** process:
85
+
86
+ ### Pass 1 — Load all directories
87
+
88
+ 1. Scan the global directory (`~/.pi/agent/workflows/`) and project-local directory (`.pi/workflows/`).
89
+ 2. Each subdirectory containing a `workflow.yaml` is loaded as a `WorkflowDefinition`.
90
+ 3. Project definitions override global definitions with the same key (directory name).
91
+ 4. Each definition is validated with `validateWorkflowDefinition()`. Invalid definitions are excluded.
92
+ 5. Subworkflow references are parsed but **not yet resolved** — the `resolved` field is `null`.
93
+
94
+ ### Pass 2 — Resolve references (with cascading)
95
+
96
+ After all definitions are loaded, subworkflow references are resolved in a loop that repeats until stable:
97
+
98
+ 1. For each workflow containing an unresolved `SubworkflowReference`, look up `workflowKey` in the loaded definitions map.
99
+ 2. If the target **does not exist**, the referencing workflow is **excluded** (removed from the valid set) and a warning is logged:
100
+ ```
101
+ [pi-workflows] Workflow "my-pipeline" references non-existent subworkflow "missing-step". Skipping.
102
+ ```
103
+ 3. If the target **exists**, the `resolved` field is populated with the target `WorkflowDefinition`.
104
+ 4. Repeat until no more exclusions occur — this handles [cascading exclusion](#cascading-exclusion).
105
+
106
+ ### Load-time ordering summary
107
+
108
+ ```
109
+ 1. Load all workflow directories → Record<string, WorkflowDefinition>
110
+ 2. Validate each definition → remove invalid
111
+ 3. Detect cycles (DFS) → remove cyclic workflows
112
+ 4. Resolve subworkflow references → populate .resolved, remove broken
113
+ 5. Repeat step 4 until stable → handle cascading
114
+ 6. Check for duplicate commandNames → warn (first wins)
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Stack-Based Navigation
120
+
121
+ The runtime uses a **path stack** (`currentPath`) to track position within potentially nested workflows. Each element is a [`PathSegment`](../src/types.ts):
122
+
123
+ ```typescript
124
+ interface PathSegment {
125
+ workflowKey: string; // which workflow
126
+ phaseIndex: number; // which phase within that workflow
127
+ }
128
+ ```
129
+
130
+ - **Index 0** = top-level (root) workflow.
131
+ - **Last index** = innermost (currently active) scope.
132
+ - A single-element stack means a flat, non-nested workflow.
133
+
134
+ ### Visualizing the stack
135
+
136
+ Consider a parent workflow with a subworkflow that itself contains a nested subworkflow:
137
+
138
+ ```
139
+ Parent "release" phases: [build, {subworkflow: review}, deploy]
140
+ └── review phases: [static, {subworkflow: security}, approval]
141
+ └── security phases: [scan, report]
142
+ ```
143
+
144
+ When the agent is executing the "scan" phase of "security":
145
+
146
+ ```
147
+ currentPath stack (bottom → top):
148
+
149
+ ┌─────────────────────────────────┐
150
+ │ { workflowKey: "release", │ ← root scope
151
+ │ phaseIndex: 1 } │ (at the subworkflow entry)
152
+ ├─────────────────────────────────┤
153
+ │ { workflowKey: "review", │ ← middle scope
154
+ │ phaseIndex: 1 } │ (at the subworkflow entry)
155
+ ├─────────────────────────────────┤
156
+ │ { workflowKey: "security", │ ← innermost (active)
157
+ │ phaseIndex: 0 } │ (at "scan" phase)
158
+ └─────────────────────────────────┘
159
+ ```
160
+
161
+ ### Advance cases
162
+
163
+ `advancePhase()` in [`state.ts`](../src/state.ts) handles four cases when the agent calls `workflow_step` with action `next`:
164
+
165
+ | Case | Condition | Action |
166
+ | ---------------------------- | -------------------------------------------------------- | ---------------------------------------------------------- |
167
+ | **1 — Enter subworkflow** | Current entry is a `SubworkflowReference` | **Push** new `PathSegment` onto stack with `phaseIndex: 0` |
168
+ | **2 — Normal advance** | Current entry is a concrete phase, not the last in scope | Increment `phaseIndex` in the top segment |
169
+ | **3 — Top-level done** | Last phase in root scope (`currentPath.length === 1`) | Set `active = false`, workflow is DONE |
170
+ | **4 — Subworkflow complete** | Last phase in a subworkflow scope | **Pop** the stack, increment parent's `phaseIndex` |
171
+
172
+ #### Case 1 diagram — entering a subworkflow
173
+
174
+ ```
175
+ BEFORE: currentPath = [{ release, phaseIndex: 0 }]
176
+ → current entry = "build" (concrete phase)
177
+
178
+ ADVANCE: current entry at index 0 is concrete, not last
179
+ → phaseIndex++ (Case 2)
180
+
181
+ BEFORE: currentPath = [{ release, phaseIndex: 1 }]
182
+ → current entry = { subworkflow: "review" }
183
+
184
+ ADVANCE: push { review, phaseIndex: 0 } (Case 1)
185
+
186
+ AFTER: currentPath = [{ release, 1 }, { review, 0 }]
187
+ → now executing review's first phase
188
+ ```
189
+
190
+ #### Case 4 diagram — completing a subworkflow
191
+
192
+ ```
193
+ BEFORE: currentPath = [{ release, 1 }, { review, 2 }]
194
+ → review phaseIndex 2 = "approval" (last phase in review)
195
+
196
+ ADVANCE: pop { review, 2 }, increment parent to phaseIndex 2 (Case 4)
197
+
198
+ AFTER: currentPath = [{ release, 2 }]
199
+ → now executing release's "deploy" phase
200
+ ```
201
+
202
+ ---
203
+
204
+ ## Cycle Detection
205
+
206
+ Cycles in the subworkflow reference graph are detected **at load time** using iterative DFS with 3-color marking. This prevents infinite loops during execution.
207
+
208
+ ### Algorithm
209
+
210
+ 1. Build an adjacency list from all subworkflow references.
211
+ 2. Mark every node `WHITE` (unvisited).
212
+ 3. For each `WHITE` node, run iterative DFS:
213
+ - `GRAY` = currently being explored (on the DFS stack).
214
+ - `BLACK` = fully explored, no cycles through this node.
215
+ 4. If a `GRAY` node is encountered during exploration, a **back edge** (cycle) is found.
216
+ 5. Reconstruct and report the cycle path.
217
+
218
+ ### Example
219
+
220
+ Given workflows: `A → B → C → A` (A references B, B references C, C references A):
221
+
222
+ ```
223
+ [pi-workflows] Cycle detected: A → B → C → A. Skipping workflow "A".
224
+ ```
225
+
226
+ All workflows participating in the cycle are **excluded** from the valid set.
227
+
228
+ > **Note:** The cycle detection only considers edges where the target workflow actually exists in the definitions. References to missing workflows are handled separately during [resolution](#resolution-and-loading).
229
+
230
+ ---
231
+
232
+ ## Nesting Depth
233
+
234
+ There is **no explicit depth limit** on subworkflow nesting. The practical constraint is that the reference graph must form a DAG (directed acyclic graph) — enforced by [cycle detection](#cycle-detection). As long as no cycles exist, arbitrarily deep nesting is allowed.
235
+
236
+ ---
237
+
238
+ ## Loop Scope
239
+
240
+ When the agent calls `workflow_step` with action `loop`, only the **innermost scope** is restarted. The `loopPhase()` function resets the top `PathSegment`'s `phaseIndex` to `0`:
241
+
242
+ ```typescript
243
+ // loopPhase resets only the top of the stack
244
+ top.phaseIndex = 0;
245
+ ```
246
+
247
+ ### Example
248
+
249
+ ```
250
+ currentPath = [{ release, 2 }, { review, 1 }]
251
+ parent innermost
252
+
253
+ → loop resets { review, 0 }
254
+
255
+ currentPath = [{ release, 2 }, { review, 0 }]
256
+ ^^^^^^^^^^^^
257
+ restarted to first phase
258
+ ```
259
+
260
+ The parent scope is unaffected. The workflow's `loopable` setting is checked on the **innermost** workflow — if `loopable: false`, the loop is rejected with an error.
261
+
262
+ ---
263
+
264
+ ## Cascading Exclusion
265
+
266
+ When a workflow is excluded (due to a broken reference), other workflows that reference it may also become invalid. The loading process handles this with an iterative loop:
267
+
268
+ ```typescript
269
+ let changed = true;
270
+ while (changed) {
271
+ changed = false;
272
+ // check each workflow's subworkflow references
273
+ // if any reference target is missing → exclude the referencing workflow
274
+ // if any exclusions occurred → set changed = true, loop again
275
+ }
276
+ ```
277
+
278
+ ### Example cascade
279
+
280
+ ```
281
+ Workflows loaded: A, B, C, D
282
+ A references B
283
+ B references C
284
+ C references (non-existent) Z
285
+
286
+ Step 1: C references missing Z → exclude C
287
+ Step 2: B references missing C → exclude B
288
+ Step 3: A references missing B → exclude A
289
+ Step 4: No more broken references → stable
290
+
291
+ Result: Only D remains.
292
+ ```
293
+
294
+ Each exclusion logs a warning identifying the broken reference:
295
+
296
+ ```
297
+ [pi-workflows] Workflow "C" references non-existent subworkflow "Z". Skipping.
298
+ [pi-workflows] Workflow "B" references non-existent subworkflow "C". Skipping.
299
+ [pi-workflows] Workflow "A" references non-existent subworkflow "B". Skipping.
300
+ ```
301
+
302
+ ---
303
+
304
+ ## Shared Phase Pattern
305
+
306
+ A common convention is to organize reusable subworkflows under a `_shared/` directory:
307
+
308
+ ```
309
+ workflows/
310
+ ├── _shared/
311
+ │ ├── code-review/
312
+ │ │ ├── workflow.yaml ← show: "workflows"
313
+ │ │ ├── static-analysis.md
314
+ │ │ ├── peer-review.md
315
+ │ │ └── approval-gate.md
316
+ │ └── testing/
317
+ │ ├── workflow.yaml ← show: "workflows"
318
+ │ ├── unit-tests.md
319
+ │ └── integration-tests.md
320
+ ├── release-pipeline/
321
+ │ ├── workflow.yaml
322
+ │ ├── build.md
323
+ │ └── deploy.md
324
+ └── feature-work/
325
+ ├── workflow.yaml
326
+ ├── design.md
327
+ └── implement.md
328
+ ```
329
+
330
+ Key points for `_shared/` workflows:
331
+
332
+ - Set `show: "workflows"` to hide them from the `/workflow` command.
333
+ - `commandName` and `initialMessage` can be omitted or left empty.
334
+ - Reference them from any other workflow: `{ subworkflow: code-review }` or `{ subworkflow: testing }`.
335
+ - The underscore prefix in `_shared` is a convention only — it has no special meaning to the loader. All subdirectories are scanned regardless of name.
336
+
337
+ ---
338
+
339
+ ## Breadcrumb Display
340
+
341
+ When a workflow is active with nested subworkflows, the status bar and status output show a **breadcrumb trail** of the full path from root to innermost scope.
342
+
343
+ ### Status bar (nested)
344
+
345
+ When `currentPath.length > 1`, the status bar shows progress at **every level** of the stack:
346
+
347
+ ```
348
+ Release Pipeline > Code Review Cycle [2/3] > 🔍 Static Analysis [1/2]
349
+ ```
350
+
351
+ Format: `{workflowName} > {subworkflowName} [current/total] > {emoji} {phaseName} [current/total]`
352
+
353
+ - The top-level workflow name appears without progress (just the name).
354
+ - Every path segment (subworkflow or concrete phase) shows its own `[current/total]` progress within that scope.
355
+ - Subworkflow levels have no emoji (they are not `PhaseDefinition` objects).
356
+ - All segments are joined by `>` — no special separator.
357
+ - The innermost segment always has an emoji (it is the current concrete phase).
358
+
359
+ For deeply nested workflows, this extends naturally:
360
+
361
+ ```
362
+ Top Workflow > Middle [1/1] > Innermost [2/2] > 🔬 Deep Phase 1 [1/2]
363
+ ```
364
+
365
+ ### Status bar (non-nested)
366
+
367
+ When `currentPath.length === 1`, the format is the same structure with a single segment:
368
+
369
+ ```
370
+ Release Pipeline > 🚀 Deploy [3/4]
371
+ ```
372
+
373
+ ### Status command output
374
+
375
+ The `workflow_step` action `status` also includes a `**Path:**` line when nested:
376
+
377
+ ```
378
+ **Workflow:** Release Pipeline (release)
379
+ **Path:** Release Pipeline > Code Review Cycle > Security Scan
380
+ **Phase:** 🔍 Dependency Audit [1/2] (step 5)
381
+ ```
382
+
383
+ ### Prompt injection
384
+
385
+ During context injection, the breadcrumb is embedded in the agent prompt for orientation:
386
+
387
+ ```
388
+ [Workflow path: Release Pipeline > Code Review Cycle ▸ 🔍 Static Analysis]
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Full workflow.yaml Schema
394
+
395
+ For the complete `workflow.yaml` schema including all fields (role instructions, advance reminders, completion messages, etc.), see [configuration-reference.md](configuration-reference.md).
396
+
397
+ ---
398
+
399
+ ## API Reference
400
+
401
+ ### Types
402
+
403
+ | Type | Description |
404
+ | ---------------------- | -------------------------------------------------------------------------------------------------------- |
405
+ | `SubworkflowReference` | A phase entry that delegates to another workflow. Fields: `subworkflow: true`, `workflowKey`, `resolved` |
406
+ | `PathSegment` | A navigation stack element. Fields: `workflowKey`, `phaseIndex` |
407
+ | `PhaseEntry` | Union type: `PhaseDefinition \| SubworkflowReference` |
408
+ | `ActiveWorkflow` | Resolved runtime state. Includes `breadcrumb` array for display |
409
+
410
+ ### Type guards
411
+
412
+ | Function | Signature | Returns |
413
+ | ------------------- | -------------------------------- | ----------------------------------------------- |
414
+ | `isSubworkflowRef` | `(entry: PhaseEntry) => boolean` | `true` if entry is a `SubworkflowReference` |
415
+ | `isPhaseDefinition` | `(entry: PhaseEntry) => boolean` | `true` if entry is a concrete `PhaseDefinition` |
416
+
417
+ ### Key functions
418
+
419
+ | Function | Module | Purpose |
420
+ | -------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
421
+ | `loadWorkflows(cwd?)` | `config/loading.ts` | Two-pass loading: directories → validate → cycle detect → resolve references |
422
+ | `detectCycles(definitions)` | `config/validation.ts` | DFS 3-color cycle detection; returns error messages for cycles found |
423
+ | `validateWorkflowDefinition(key, def)` | `config/validation.ts` | Validates a single definition; relaxed rules when `show: "workflows"` |
424
+ | `advancePhase(state, definitions)` | `state.ts` | Four-case stack navigation (enter/advance/done/breakout) |
425
+ | `loopPhase(state, definitions)` | `state.ts` | Restart innermost scope from phase 0 |
426
+ | `resolveActive(state, definitions)` | `state.ts` | Resolve state to `ActiveWorkflow` with breadcrumb |
427
+ | `phaseEntryName(entry)` | `state.ts` | Returns display name for a `PhaseEntry` — resolves subworkflow name from `resolved` or falls back to `workflowKey` |
428
+ | `createInitialState(key, description)` | `state.ts` | Create fresh state with single-element `currentPath` stack |