@davidorex/pi-workflows 0.2.0 → 0.3.1

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.
Files changed (216) hide show
  1. package/CHANGELOG.md +7 -4
  2. package/README.md +12 -2
  3. package/agents/architecture-designer.agent.yaml +58 -0
  4. package/agents/architecture-inferrer.agent.yaml +60 -0
  5. package/agents/gap-identifier.agent.yaml +66 -0
  6. package/agents/handoff-writer.agent.yaml +64 -0
  7. package/agents/plan-creator.agent.yaml +63 -0
  8. package/agents/project-definer.agent.yaml +53 -0
  9. package/agents/project-inferrer.agent.yaml +63 -0
  10. package/agents/requirements-gatherer.agent.yaml +58 -0
  11. package/dist/agent-spec.d.ts +27 -0
  12. package/dist/agent-spec.d.ts.map +1 -0
  13. package/dist/agent-spec.js +112 -0
  14. package/dist/agent-spec.js.map +1 -0
  15. package/dist/checkpoint.d.ts +50 -0
  16. package/dist/checkpoint.d.ts.map +1 -0
  17. package/dist/checkpoint.js +109 -0
  18. package/dist/checkpoint.js.map +1 -0
  19. package/dist/completion.d.ts +18 -0
  20. package/dist/completion.d.ts.map +1 -0
  21. package/dist/completion.js +58 -0
  22. package/dist/completion.js.map +1 -0
  23. package/dist/dag.d.ts +56 -0
  24. package/dist/dag.d.ts.map +1 -0
  25. package/dist/dag.js +178 -0
  26. package/dist/dag.js.map +1 -0
  27. package/dist/dispatch.d.ts +35 -0
  28. package/dist/dispatch.d.ts.map +1 -0
  29. package/dist/dispatch.js +311 -0
  30. package/dist/dispatch.js.map +1 -0
  31. package/dist/expression.d.ts +64 -0
  32. package/dist/expression.d.ts.map +1 -0
  33. package/dist/expression.js +294 -0
  34. package/dist/expression.js.map +1 -0
  35. package/dist/format.d.ts +19 -0
  36. package/dist/format.d.ts.map +1 -0
  37. package/dist/format.js +42 -0
  38. package/dist/format.js.map +1 -0
  39. package/dist/index.d.ts +8 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +724 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/output.d.ts +12 -0
  44. package/dist/output.d.ts.map +1 -0
  45. package/dist/output.js +59 -0
  46. package/dist/output.js.map +1 -0
  47. package/dist/state.d.ts +66 -0
  48. package/dist/state.d.ts.map +1 -0
  49. package/dist/state.js +227 -0
  50. package/dist/state.js.map +1 -0
  51. package/dist/step-agent.d.ts +35 -0
  52. package/dist/step-agent.d.ts.map +1 -0
  53. package/dist/step-agent.js +171 -0
  54. package/dist/step-agent.js.map +1 -0
  55. package/dist/step-command.d.ts +21 -0
  56. package/dist/step-command.d.ts.map +1 -0
  57. package/dist/step-command.js +150 -0
  58. package/dist/step-command.js.map +1 -0
  59. package/dist/step-foreach.d.ts +33 -0
  60. package/dist/step-foreach.d.ts.map +1 -0
  61. package/dist/step-foreach.js +112 -0
  62. package/dist/step-foreach.js.map +1 -0
  63. package/dist/step-gate.d.ts +18 -0
  64. package/dist/step-gate.d.ts.map +1 -0
  65. package/dist/step-gate.js +137 -0
  66. package/dist/step-gate.js.map +1 -0
  67. package/dist/step-loop.d.ts +32 -0
  68. package/dist/step-loop.d.ts.map +1 -0
  69. package/dist/step-loop.js +278 -0
  70. package/dist/step-loop.js.map +1 -0
  71. package/dist/step-monitor.d.ts +56 -0
  72. package/dist/step-monitor.d.ts.map +1 -0
  73. package/dist/step-monitor.js +317 -0
  74. package/dist/step-monitor.js.map +1 -0
  75. package/dist/step-parallel.d.ts +43 -0
  76. package/dist/step-parallel.d.ts.map +1 -0
  77. package/dist/step-parallel.js +122 -0
  78. package/dist/step-parallel.js.map +1 -0
  79. package/{src/step-pause.ts → dist/step-pause.d.ts} +3 -13
  80. package/dist/step-pause.d.ts.map +1 -0
  81. package/dist/step-pause.js +22 -0
  82. package/dist/step-pause.js.map +1 -0
  83. package/dist/step-shared.d.ts +63 -0
  84. package/dist/step-shared.d.ts.map +1 -0
  85. package/dist/step-shared.js +136 -0
  86. package/dist/step-shared.js.map +1 -0
  87. package/dist/step-transform.d.ts +11 -0
  88. package/dist/step-transform.d.ts.map +1 -0
  89. package/dist/step-transform.js +41 -0
  90. package/dist/step-transform.js.map +1 -0
  91. package/dist/template.d.ts +51 -0
  92. package/dist/template.d.ts.map +1 -0
  93. package/{src/template.ts → dist/template.js} +32 -38
  94. package/dist/template.js.map +1 -0
  95. package/dist/test-helpers.d.ts +17 -0
  96. package/dist/test-helpers.d.ts.map +1 -0
  97. package/dist/test-helpers.js +46 -0
  98. package/dist/test-helpers.js.map +1 -0
  99. package/dist/tui.d.ts +47 -0
  100. package/dist/tui.d.ts.map +1 -0
  101. package/dist/tui.js +143 -0
  102. package/dist/tui.js.map +1 -0
  103. package/dist/types.d.ts +165 -0
  104. package/dist/types.d.ts.map +1 -0
  105. package/dist/types.js +3 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/workflow-discovery.d.ts +23 -0
  108. package/dist/workflow-discovery.d.ts.map +1 -0
  109. package/dist/workflow-discovery.js +103 -0
  110. package/dist/workflow-discovery.js.map +1 -0
  111. package/dist/workflow-executor.d.ts +50 -0
  112. package/dist/workflow-executor.d.ts.map +1 -0
  113. package/dist/workflow-executor.js +738 -0
  114. package/dist/workflow-executor.js.map +1 -0
  115. package/dist/workflow-sdk.d.ts +44 -0
  116. package/dist/workflow-sdk.d.ts.map +1 -0
  117. package/dist/workflow-sdk.js +457 -0
  118. package/dist/workflow-sdk.js.map +1 -0
  119. package/dist/workflow-spec.d.ts +33 -0
  120. package/dist/workflow-spec.d.ts.map +1 -0
  121. package/dist/workflow-spec.js +476 -0
  122. package/dist/workflow-spec.js.map +1 -0
  123. package/dist/workflows-dir.d.ts +3 -0
  124. package/dist/workflows-dir.d.ts.map +1 -0
  125. package/{src/workflows-dir.ts → dist/workflows-dir.js} +1 -0
  126. package/dist/workflows-dir.js.map +1 -0
  127. package/package.json +53 -38
  128. package/schemas/decomposition-specs.schema.json +48 -48
  129. package/schemas/execution-results.schema.json +48 -48
  130. package/schemas/investigation-findings.schema.json +43 -43
  131. package/schemas/pattern-analysis.schema.json +41 -41
  132. package/schemas/phase.schema.json +48 -0
  133. package/schemas/plan-breakdown.schema.json +20 -20
  134. package/schemas/quality-analysis.schema.json +33 -33
  135. package/schemas/research-findings.schema.json +42 -42
  136. package/schemas/structure-analysis.schema.json +40 -40
  137. package/schemas/synthesis.schema.json +31 -31
  138. package/schemas/verifier-output.schema.json +94 -94
  139. package/skill-narrative.md +107 -0
  140. package/skills/pi-workflows/SKILL.md +185 -0
  141. package/skills/pi-workflows/references/bundled-resources.md +87 -0
  142. package/templates/architecture-designer/task.md +117 -0
  143. package/templates/architecture-inferrer/task.md +61 -0
  144. package/templates/gap-identifier/task.md +103 -0
  145. package/templates/handoff-writer/task.md +91 -0
  146. package/templates/plan-creator/task.md +143 -0
  147. package/templates/project-definer/task.md +67 -0
  148. package/templates/project-inferrer/task.md +56 -0
  149. package/templates/requirements-gatherer/task.md +90 -0
  150. package/workflows/analyze-existing-project.workflow.yaml +94 -0
  151. package/workflows/create-handoff.workflow.yaml +66 -0
  152. package/workflows/create-phase.workflow.yaml +1 -1
  153. package/workflows/do-gap.workflow.yaml +4 -4
  154. package/workflows/fix-audit.workflow.yaml +1 -1
  155. package/workflows/gap-to-phase.workflow.yaml +1 -1
  156. package/workflows/init-new-project.workflow.yaml +81 -0
  157. package/workflows/plan-from-requirements.workflow.yaml +59 -0
  158. package/workflows/self-implement.workflow.yaml +3 -3
  159. package/workflows/typed-analysis.workflow.yaml +4 -4
  160. package/src/agent-spec.test.ts +0 -289
  161. package/src/agent-spec.ts +0 -122
  162. package/src/block-validation.test.ts +0 -350
  163. package/src/checkpoint.test.ts +0 -237
  164. package/src/checkpoint.ts +0 -140
  165. package/src/completion.test.ts +0 -143
  166. package/src/completion.ts +0 -68
  167. package/src/dag.test.ts +0 -350
  168. package/src/dag.ts +0 -219
  169. package/src/dispatch.test.ts +0 -475
  170. package/src/dispatch.ts +0 -353
  171. package/src/expression.test.ts +0 -353
  172. package/src/expression.ts +0 -332
  173. package/src/format.test.ts +0 -80
  174. package/src/format.ts +0 -41
  175. package/src/graduated-failure.test.ts +0 -556
  176. package/src/index.test.ts +0 -114
  177. package/src/index.ts +0 -516
  178. package/src/loop.test.ts +0 -551
  179. package/src/output.test.ts +0 -213
  180. package/src/output.ts +0 -70
  181. package/src/parallel-integration.test.ts +0 -175
  182. package/src/resume.test.ts +0 -192
  183. package/src/state.test.ts +0 -192
  184. package/src/state.ts +0 -254
  185. package/src/step-agent.test.ts +0 -327
  186. package/src/step-agent.ts +0 -178
  187. package/src/step-command.test.ts +0 -330
  188. package/src/step-command.ts +0 -132
  189. package/src/step-foreach.test.ts +0 -647
  190. package/src/step-foreach.ts +0 -148
  191. package/src/step-gate.test.ts +0 -185
  192. package/src/step-gate.ts +0 -116
  193. package/src/step-loop.test.ts +0 -626
  194. package/src/step-loop.ts +0 -323
  195. package/src/step-parallel.test.ts +0 -475
  196. package/src/step-parallel.ts +0 -168
  197. package/src/step-pause.test.ts +0 -30
  198. package/src/step-shared.test.ts +0 -355
  199. package/src/step-shared.ts +0 -157
  200. package/src/step-transform.test.ts +0 -155
  201. package/src/step-transform.ts +0 -47
  202. package/src/template-integration.test.ts +0 -72
  203. package/src/template.test.ts +0 -162
  204. package/src/test-helpers.ts +0 -51
  205. package/src/tui.test.ts +0 -355
  206. package/src/tui.ts +0 -182
  207. package/src/types.ts +0 -195
  208. package/src/verifier-schema.test.ts +0 -226
  209. package/src/workflow-discovery.test.ts +0 -105
  210. package/src/workflow-discovery.ts +0 -114
  211. package/src/workflow-executor.test.ts +0 -1532
  212. package/src/workflow-executor.ts +0 -810
  213. package/src/workflow-sdk.test.ts +0 -238
  214. package/src/workflow-sdk.ts +0 -262
  215. package/src/workflow-spec.test.ts +0 -576
  216. package/src/workflow-spec.ts +0 -471
@@ -1,810 +0,0 @@
1
- /**
2
- * Workflow executor — orchestrates step execution with DAG-based layering,
3
- * parallel dispatch, timeout enforcement, state persistence, and TUI updates.
4
- */
5
- import fs from "node:fs";
6
- import path from "node:path";
7
- import type { WorkflowSpec, WorkflowResult, AgentSpec, ExecutionState, ExpressionScope, StepSpec } from "./types.ts";
8
- import type { ProgressWidgetState, StepOutputSummary } from "./tui.ts";
9
- import { validate, validateFromFile } from "@davidorex/pi-project/src/schema-validator.ts";
10
- import { resolveExpressions, evaluateCondition } from "./expression.ts";
11
- import { dispatch } from "./dispatch.ts";
12
- import { generateRunId, initRunDir, getWorkflowDir, writeState, writeMetrics, buildResult, formatResult } from "./state.ts";
13
- import { resolveCompletion } from "./completion.ts";
14
- import { createProgressWidget } from "./tui.ts";
15
- import { truncateTail } from "@mariozechner/pi-coding-agent";
16
- import { extractDependencies, buildPlanFromDeps } from "./dag.ts";
17
- import type { ExecutionPlan } from "./dag.ts";
18
- import { zeroUsage, resolveSchemaPath, persistStep, WIDGET_ID, SIGKILL_GRACE_MS } from "./step-shared.ts";
19
- import { createTemplateEnv } from "./template.ts";
20
- import type nunjucks from "nunjucks";
21
- import { executeGate } from "./step-gate.ts";
22
- import { executeTransform } from "./step-transform.ts";
23
- import { executeLoop } from "./step-loop.ts";
24
- import { executeParallelLayer, executeParallelStep } from "./step-parallel.ts";
25
- import { executeAgentStep } from "./step-agent.ts";
26
- import { executePause } from "./step-pause.ts";
27
- import { executeCommand } from "./step-command.ts";
28
- import { executeForEach } from "./step-foreach.ts";
29
- import { snapshotBlockFiles, validateChangedBlocks, rollbackBlockFiles } from "@davidorex/pi-project/src/block-validation.ts";
30
- import { readBlock, writeBlock } from "@davidorex/pi-project/src/block-api.ts";
31
- import { PROJECT_DIR } from "@davidorex/pi-project/src/project-dir.ts";
32
- import type { BlockSnapshot } from "@davidorex/pi-project/src/block-validation.ts";
33
- import type { RetryConfig } from "./types.ts";
34
-
35
- // Re-export SIGKILL_GRACE_MS so tests that grep this file still find it
36
- export { SIGKILL_GRACE_MS };
37
-
38
- /** Module-level flag set by the Ctrl+H keybinding handler. */
39
- let pauseRequested = false;
40
-
41
- /** Set by the extension keybinding to request a pause after the current step. */
42
- export function requestPause(): void {
43
- pauseRequested = true;
44
- }
45
-
46
- /** Clear the pause flag (called at the start of each workflow execution). */
47
- function clearPauseRequest(): void {
48
- pauseRequested = false;
49
- }
50
-
51
- export interface ExecuteOptions {
52
- /** pi extension context (for TUI, cwd, etc.) */
53
- ctx: any;
54
- /** pi extension API (for sendMessage) */
55
- pi: any;
56
- /** AbortSignal for cancellation (e.g. user presses Ctrl+C) */
57
- signal?: AbortSignal;
58
- /**
59
- * Agent spec loader. Given an agent name, returns the AgentSpec.
60
- * The executor does not know how to load agent specs — the caller provides this.
61
- * If the agent is not found, return a minimal spec with just the name.
62
- */
63
- loadAgent: (name: string) => AgentSpec;
64
- /** Injectable dispatch function for testing; defaults to real dispatch. */
65
- dispatchFn?: typeof dispatch;
66
- /** Project-level model config; loaded from .project/model-config.json if not provided. */
67
- modelConfig?: import("./dispatch.ts").ModelConfig;
68
- /** Resume from an incomplete run instead of starting fresh. */
69
- resume?: {
70
- runId: string;
71
- runDir: string;
72
- state: ExecutionState;
73
- };
74
- }
75
-
76
- /**
77
- * Build a conservative execution plan that preserves declaration-order
78
- * sequencing for steps without explicit `${{ steps.X }}` dependencies.
79
- *
80
- * Steps with no explicit dependencies implicitly depend on the previous
81
- * step in declaration order. This ensures backward compatibility with
82
- * workflows written for sequential execution while still allowing
83
- * DAG-inferred parallelism for steps that DO have explicit dependencies
84
- * (e.g., diamond patterns where two steps both depend on an earlier step).
85
- */
86
- function buildConservativePlan(spec: WorkflowSpec): ExecutionPlan {
87
- const deps = extractDependencies(spec);
88
- const allSteps = Object.keys(spec.steps);
89
-
90
- // Add implicit declaration-order dependency for steps with no explicit deps.
91
- // If a step has no ${{ steps.X }} references at all, it depends on the
92
- // immediately preceding step in YAML order.
93
- for (let i = 1; i < allSteps.length; i++) {
94
- const stepDeps = deps.get(allSteps[i])!;
95
- if (stepDeps.size === 0) {
96
- stepDeps.add(allSteps[i - 1]);
97
- }
98
- }
99
-
100
- return buildPlanFromDeps(allSteps, deps);
101
- }
102
-
103
- /** Options passed to single-step and parallel-layer execution helpers. */
104
- interface StepExecOptions {
105
- ctx: any;
106
- pi: any;
107
- signal?: AbortSignal;
108
- loadAgent: (name: string) => AgentSpec;
109
- runDir: string;
110
- spec: WorkflowSpec;
111
- widgetState: ProgressWidgetState;
112
- templateEnv?: nunjucks.Environment;
113
- dispatchFn?: typeof dispatch;
114
- modelConfig?: import("./dispatch.ts").ModelConfig;
115
- }
116
-
117
- /**
118
- * Determine if a step type supports retry.
119
- * Agent, forEach, and loop steps are retryable.
120
- * Command, gate, and transform are deterministic and not retryable.
121
- */
122
- function isRetryableStepType(stepSpec: StepSpec): boolean {
123
- if (stepSpec.command || stepSpec.gate || stepSpec.transform) return false;
124
- return true;
125
- }
126
-
127
- /**
128
- * Execute a single step (agent, gate, transform, loop, or parallel).
129
- *
130
- * This is the central step type dispatcher. It delegates to the appropriate
131
- * step executor module based on the step spec type.
132
- * Returns true if the workflow should continue, false if it should stop
133
- * (due to failure, break, or cancellation).
134
- *
135
- * Supports per-step retry: if step.retry is configured and the step type
136
- * is retryable, failures (including block validation) trigger rollback
137
- * and re-execution with error context injected into the prompt.
138
- */
139
- async function executeSingleStep(
140
- stepName: string,
141
- stepSpec: StepSpec,
142
- state: ExecutionState,
143
- options: StepExecOptions,
144
- ): Promise<boolean> {
145
- const { ctx, signal, loadAgent, runDir, spec, widgetState } = options;
146
-
147
- // Check cancellation
148
- if (signal?.aborted) {
149
- state.steps[stepName] = {
150
- step: stepName,
151
- agent: stepSpec.agent,
152
- status: "failed",
153
- usage: zeroUsage(),
154
- durationMs: 0,
155
- error: "Workflow cancelled",
156
- };
157
- state.status = "failed";
158
- return false;
159
- }
160
-
161
- // Build expression scope
162
- const scope: ExpressionScope = { input: state.input, steps: state.steps };
163
- // Expose forEach bindings (as name + forEach metadata) if present on the state
164
- const stateAny = state as Record<string, unknown>;
165
- if (stateAny.forEach !== undefined) {
166
- scope.forEach = stateAny.forEach;
167
- }
168
- // Expose any custom bindings (e.g. from forEach as)
169
- for (const key of Object.keys(stateAny)) {
170
- if (key !== "input" && key !== "steps" && key !== "status" && key !== "loop" &&
171
- key !== "workflowName" && key !== "specVersion" && key !== "startedAt" &&
172
- key !== "updatedAt" && key !== "forEach") {
173
- scope[key] = stateAny[key];
174
- }
175
- }
176
-
177
- // Evaluate `when` conditional
178
- if (stepSpec.when) {
179
- const conditionExpr = stepSpec.when.replace(/^\$\{\{\s*/, "").replace(/\s*\}\}$/, "");
180
- const shouldRun = evaluateCondition(conditionExpr, scope);
181
- if (!shouldRun) {
182
- persistStep(state, stepName, {
183
- step: stepName,
184
- agent: stepSpec.agent ?? "skipped",
185
- status: "skipped",
186
- usage: zeroUsage(),
187
- durationMs: 0,
188
- }, runDir, widgetState, ctx);
189
- return true;
190
- }
191
- }
192
-
193
- // Determine retry config
194
- const retryConfig = stepSpec.retry;
195
- const maxAttempts = (retryConfig?.maxAttempts ?? 1);
196
- const canRetry = isRetryableStepType(stepSpec) && maxAttempts > 1;
197
- const totalAttempts = canRetry ? maxAttempts : 1;
198
-
199
- const priorErrors: string[] = [];
200
-
201
- for (let attempt = 1; attempt <= totalAttempts; attempt++) {
202
- // Check cancellation between retry attempts
203
- if (attempt > 1 && signal?.aborted) {
204
- state.steps[stepName] = {
205
- step: stepName,
206
- agent: stepSpec.agent,
207
- status: "failed",
208
- usage: zeroUsage(),
209
- durationMs: 0,
210
- error: "Workflow cancelled",
211
- };
212
- state.status = "failed";
213
- return false;
214
- }
215
-
216
- // Snapshot project block files before step execution for post-step validation
217
- const blockSnapshot = snapshotBlockFiles(ctx.cwd);
218
-
219
- // Update widget: mark this step as current
220
- widgetState.currentStep = stepName;
221
- if (ctx.hasUI) {
222
- ctx.ui.setWidget(WIDGET_ID, createProgressWidget(widgetState));
223
- }
224
-
225
- // Build retry context for this attempt (if retrying)
226
- const retryContext = attempt > 1 ? {
227
- attempt,
228
- priorErrors: [...priorErrors],
229
- steeringMessage: retryConfig?.steeringMessage,
230
- } : undefined;
231
-
232
- // Execute the step, then validate any changed block files
233
- // Reset failed status before re-attempt
234
- if (attempt > 1) {
235
- delete state.steps[stepName];
236
- state.status = "running";
237
- }
238
-
239
- const continueWorkflow = await executeStepByType(
240
- stepName, stepSpec, state, scope, options, retryContext,
241
- );
242
-
243
- // Post-step block validation: if the step succeeded, validate changed project block files
244
- let blockValidationFailed = false;
245
- if (continueWorkflow && state.steps[stepName]?.status === "completed") {
246
- try {
247
- validateChangedBlocks(ctx.cwd, blockSnapshot);
248
- } catch (err) {
249
- const msg = err instanceof Error ? err.message : String(err);
250
- blockValidationFailed = true;
251
- priorErrors.push(msg);
252
-
253
- // Always rollback — invalid data must not persist
254
- rollbackBlockFiles(ctx.cwd, blockSnapshot);
255
-
256
- if (attempt < totalAttempts) {
257
- // Retry
258
- continue;
259
- }
260
-
261
- // Last attempt — mark failed
262
- state.steps[stepName].status = "failed";
263
- state.steps[stepName].error = msg;
264
- state.steps[stepName].attempt = attempt;
265
- state.steps[stepName].totalAttempts = attempt;
266
- state.steps[stepName].priorErrors = [...priorErrors];
267
- state.status = "failed";
268
- persistStep(state, stepName, state.steps[stepName], runDir, widgetState, ctx);
269
-
270
- // Check onExhausted
271
- if (retryConfig?.onExhausted === "skip") {
272
- state.steps[stepName].status = "skipped";
273
- state.steps[stepName].warnings = [
274
- ...(state.steps[stepName].warnings ?? []),
275
- `Step skipped after ${attempt} failed attempts (onExhausted: skip)`,
276
- ];
277
- state.status = "running";
278
- persistStep(state, stepName, state.steps[stepName], runDir, widgetState, ctx);
279
- return true;
280
- }
281
- return false;
282
- }
283
- }
284
-
285
- if (!continueWorkflow || state.steps[stepName]?.status === "failed") {
286
- const errorMsg = state.steps[stepName]?.error ?? "Step failed";
287
- priorErrors.push(errorMsg);
288
-
289
- if (attempt < totalAttempts && canRetry) {
290
- // Rollback block files and retry
291
- rollbackBlockFiles(ctx.cwd, blockSnapshot);
292
- continue;
293
- }
294
-
295
- // Last attempt or not retryable — annotate and return
296
- if (state.steps[stepName]) {
297
- state.steps[stepName].attempt = attempt;
298
- state.steps[stepName].totalAttempts = attempt;
299
- state.steps[stepName].priorErrors = attempt > 1 ? [...priorErrors] : undefined;
300
- persistStep(state, stepName, state.steps[stepName], runDir, widgetState, ctx);
301
- }
302
-
303
- // Check onExhausted
304
- if (canRetry && retryConfig?.onExhausted === "skip") {
305
- if (state.steps[stepName]) {
306
- state.steps[stepName].status = "skipped";
307
- state.steps[stepName].warnings = [
308
- ...(state.steps[stepName].warnings ?? []),
309
- `Step skipped after ${attempt} failed attempts (onExhausted: skip)`,
310
- ];
311
- state.status = "running";
312
- persistStep(state, stepName, state.steps[stepName], runDir, widgetState, ctx);
313
- }
314
- return true;
315
- }
316
-
317
- return false;
318
- }
319
-
320
- // Success — annotate with attempt tracking
321
- if (state.steps[stepName]) {
322
- state.steps[stepName].attempt = attempt;
323
- state.steps[stepName].totalAttempts = attempt;
324
- state.steps[stepName].priorErrors = attempt > 1 ? [...priorErrors] : undefined;
325
- }
326
-
327
- // Parse step output into a summary for TUI display
328
- const stepOutput = state.steps[stepName]?.output;
329
- if (stepOutput && typeof stepOutput === "object") {
330
- const out = stepOutput as Record<string, unknown>;
331
- const summary: StepOutputSummary = {};
332
- if (Array.isArray(out.tasks)) {
333
- summary.tasks = out.tasks
334
- .filter((t: unknown) => t && typeof t === "object")
335
- .map((t: Record<string, unknown>) => ({
336
- name: String(t.name ?? "?"),
337
- status: String(t.status ?? "?"),
338
- files: Array.isArray(t.files_modified) ? t.files_modified.map(String) : undefined,
339
- }));
340
- }
341
- if (typeof out.test_count === "number") {
342
- summary.testCount = out.test_count;
343
- }
344
- if (typeof out.notes === "string") {
345
- summary.note = out.notes;
346
- }
347
- if (summary.tasks || summary.testCount || summary.note) {
348
- widgetState.outputSummaries.set(stepName, summary);
349
- if (ctx.hasUI) {
350
- ctx.ui.setWidget(WIDGET_ID, createProgressWidget(widgetState));
351
- }
352
- }
353
- }
354
-
355
- return continueWorkflow;
356
- }
357
-
358
- // Should not reach here, but safety net
359
- return false;
360
- }
361
-
362
- /** Retry context passed to step executors on retry attempts. */
363
- export interface RetryContext {
364
- attempt: number;
365
- priorErrors: string[];
366
- steeringMessage?: string;
367
- }
368
-
369
- /**
370
- * Execute a step based on its type (agent, gate, transform, etc.).
371
- * Factored out of executeSingleStep to allow post-step validation.
372
- */
373
- async function executeStepByType(
374
- stepName: string,
375
- stepSpec: StepSpec,
376
- state: ExecutionState,
377
- scope: ExpressionScope,
378
- options: StepExecOptions,
379
- retryContext?: RetryContext,
380
- ): Promise<boolean> {
381
- const { ctx, signal, loadAgent, runDir, spec, widgetState } = options;
382
-
383
- // ── ForEach step (wraps any step type) ──
384
- if (stepSpec.forEach) {
385
- const asName = stepSpec.as ?? "item";
386
- const forEachResult = await executeForEach(
387
- stepName, stepSpec, state, stepSpec.forEach, asName,
388
- executeSingleStep, options,
389
- );
390
- persistStep(state, stepName, forEachResult, runDir, widgetState, ctx);
391
- if (forEachResult.status === "failed") {
392
- state.status = "failed";
393
- return false;
394
- }
395
- return true;
396
- }
397
-
398
- // ── Gate step ──
399
- if (stepSpec.gate) {
400
- const resolvedCheck = String(resolveExpressions(stepSpec.gate.check, scope));
401
- const resolvedGate = { ...stepSpec.gate, check: resolvedCheck };
402
- const resolvedGateOutputPath = stepSpec.output?.path
403
- ? String(resolveExpressions(stepSpec.output.path, scope))
404
- : undefined;
405
- const gateResult = await executeGate(resolvedGate, stepName, {
406
- cwd: ctx.cwd,
407
- signal,
408
- timeoutMs: stepSpec.timeout ? stepSpec.timeout.seconds * 1000 : undefined,
409
- runDir,
410
- outputPath: resolvedGateOutputPath,
411
- });
412
- const gateOutput = gateResult.output as { passed: boolean; exitCode: number; output: string };
413
-
414
- if (gateOutput.passed) {
415
- const onPass = stepSpec.gate.onPass ?? "continue";
416
- persistStep(state, stepName, gateResult, runDir, widgetState, ctx);
417
- return onPass !== "break";
418
- } else {
419
- const onFail = stepSpec.gate.onFail ?? "fail";
420
- if (onFail === "fail") {
421
- gateResult.status = "failed";
422
- gateResult.error = `Gate check failed (exit ${gateOutput.exitCode}): ${gateOutput.output}`;
423
- persistStep(state, stepName, gateResult, runDir, widgetState, ctx);
424
- state.status = "failed";
425
- return false;
426
- } else if (onFail === "continue") {
427
- persistStep(state, stepName, gateResult, runDir, widgetState, ctx);
428
- return true;
429
- } else if (onFail === "break") {
430
- persistStep(state, stepName, gateResult, runDir, widgetState, ctx);
431
- return false;
432
- }
433
- }
434
- }
435
-
436
- // ── Command step ──
437
- if (stepSpec.command) {
438
- const resolvedCommand = String(resolveExpressions(stepSpec.command, scope));
439
- const resolvedCommandOutputPath = stepSpec.output?.path
440
- ? String(resolveExpressions(stepSpec.output.path, scope))
441
- : undefined;
442
- const commandResult = await executeCommand(resolvedCommand, stepName, {
443
- cwd: ctx.cwd,
444
- signal,
445
- timeoutMs: stepSpec.timeout ? stepSpec.timeout.seconds * 1000 : undefined,
446
- runDir,
447
- outputPath: resolvedCommandOutputPath,
448
- }, stepSpec.output?.format);
449
- persistStep(state, stepName, commandResult, runDir, widgetState, ctx);
450
- if (commandResult.status === "failed") {
451
- state.status = "failed";
452
- return false;
453
- }
454
- return true;
455
- }
456
-
457
- // ── Transform step ──
458
- if (stepSpec.transform) {
459
- const resolvedTransformOutputPath = stepSpec.output?.path
460
- ? String(resolveExpressions(stepSpec.output.path, scope))
461
- : undefined;
462
- const transformResult = executeTransform(stepSpec.transform, stepName, scope, runDir, resolvedTransformOutputPath);
463
- persistStep(state, stepName, transformResult, runDir, widgetState, ctx);
464
- if (transformResult.status === "failed") {
465
- state.status = "failed";
466
- return false;
467
- }
468
- return true;
469
- }
470
-
471
- // ── Loop step ──
472
- if (stepSpec.loop) {
473
- const resolvedLoopOutputPath = stepSpec.output?.path
474
- ? String(resolveExpressions(stepSpec.output.path, scope))
475
- : undefined;
476
- const loopResult = await executeLoop(stepSpec.loop, stepName, state, {
477
- ctx, pi: options.pi, signal, loadAgent, runDir, spec,
478
- dispatchAgent: (s, a, p, o) => (options.dispatchFn ?? dispatch)(s, a, p, { ...o, modelConfig: options.modelConfig }),
479
- templateEnv: options.templateEnv,
480
- outputPath: resolvedLoopOutputPath,
481
- });
482
- persistStep(state, stepName, loopResult, runDir, widgetState, ctx);
483
- if (loopResult.status === "failed") {
484
- state.status = "failed";
485
- return false;
486
- }
487
- return true;
488
- }
489
-
490
- // ── Parallel step ──
491
- if (stepSpec.parallel) {
492
- const parallelResult = await executeParallelStep(
493
- stepSpec.parallel, stepName, state, executeSingleStep, options,
494
- );
495
- persistStep(state, stepName, parallelResult, runDir, widgetState, ctx);
496
- if (parallelResult.status === "failed") {
497
- state.status = "failed";
498
- return false;
499
- }
500
- return true;
501
- }
502
-
503
- // ── Pause step ──
504
- if (stepSpec.pause !== undefined) {
505
- const message = typeof stepSpec.pause === "string" ? stepSpec.pause : undefined;
506
- const pauseResult = executePause(stepName, message);
507
- persistStep(state, stepName, pauseResult, runDir, widgetState, ctx);
508
- state.status = "paused";
509
- if (ctx.hasUI) {
510
- ctx.ui.notify(message || "Workflow paused. Use /workflow resume or Ctrl+J to continue.", "info");
511
- }
512
- return false;
513
- }
514
-
515
- // ── Agent step (default) ──
516
- const agentResult = await executeAgentStep(stepName, stepSpec, state, {
517
- ctx, signal, loadAgent, runDir,
518
- specFilePath: spec.filePath,
519
- widgetState,
520
- templateEnv: options.templateEnv,
521
- dispatchFn: options.dispatchFn,
522
- modelConfig: options.modelConfig,
523
- retryContext,
524
- onStepActivity: (activity) => {
525
- if (!widgetState.activities) widgetState.activities = new Map();
526
- const existing = widgetState.activities.get(stepName) || [];
527
- if (existing.length >= 5) existing.shift();
528
- existing.push(activity);
529
- widgetState.activities.set(stepName, existing);
530
- if (ctx.hasUI) {
531
- ctx.ui.setWidget(WIDGET_ID, createProgressWidget(widgetState));
532
- }
533
- },
534
- });
535
- persistStep(state, stepName, agentResult, runDir, widgetState, ctx);
536
- if (agentResult.status === "failed") {
537
- state.status = "failed";
538
- return false;
539
- }
540
- return true;
541
- }
542
-
543
- /**
544
- * Execute a workflow from a parsed spec and validated input.
545
- *
546
- * Runs steps sequentially (phase 1), resolving ${{ }} expressions,
547
- * dispatching subprocesses, validating outputs, persisting state,
548
- * updating TUI, and injecting the result into the conversation.
549
- *
550
- * Supports step types: agent (default), gate, transform, loop.
551
- * Supports `when` conditionals for skipping steps.
552
- *
553
- * Returns the WorkflowResult (also injected into conversation via sendMessage).
554
- */
555
- export async function executeWorkflow(
556
- spec: WorkflowSpec,
557
- input: unknown,
558
- options: ExecuteOptions,
559
- ): Promise<WorkflowResult> {
560
- const { ctx, pi, signal, loadAgent } = options;
561
- clearPauseRequest();
562
-
563
- // 1. Validate input against workflow input schema (if defined)
564
- // Skip on resume — input was validated on first run.
565
- if (!options.resume && spec.input) {
566
- validate(spec.input, input, `workflow input for '${spec.name}'`);
567
- }
568
-
569
- // 2. Initialize or resume run
570
- let runId: string;
571
- let runDir: string;
572
- let state: ExecutionState;
573
-
574
- if (options.resume) {
575
- runId = options.resume.runId;
576
- runDir = options.resume.runDir;
577
- state = options.resume.state;
578
- // Reset failed steps so they re-run
579
- for (const [name, result] of Object.entries(state.steps)) {
580
- if (result.status === "failed") {
581
- delete state.steps[name];
582
- }
583
- }
584
- state.status = "running";
585
- input = state.input; // use original input
586
- } else {
587
- runId = generateRunId(spec.name);
588
- runDir = initRunDir(ctx.cwd, spec.name, runId);
589
- state = {
590
- input,
591
- steps: {},
592
- status: "running",
593
- workflowName: spec.name,
594
- specVersion: spec.version,
595
- startedAt: new Date().toISOString(),
596
- };
597
- }
598
-
599
- // 3. Show TUI progress widget
600
- const widgetState: ProgressWidgetState = {
601
- spec,
602
- state,
603
- startTime: Date.now(),
604
- activities: new Map(),
605
- outputSummaries: new Map(),
606
- };
607
- if (options.resume) {
608
- widgetState.resumedSteps = Object.keys(state.steps).filter(
609
- s => state.steps[s].status === "completed" || state.steps[s].status === "skipped"
610
- ).length;
611
- }
612
- if (ctx.hasUI) {
613
- ctx.ui.setWidget(WIDGET_ID, createProgressWidget(widgetState));
614
- }
615
-
616
- // 4. Set working message
617
- if (ctx.hasUI) {
618
- ctx.ui.setWorkingMessage(`Running ${spec.name} workflow...`);
619
- }
620
-
621
- // 5. Load model config (project-level model assignments by role)
622
- let modelConfig = options.modelConfig;
623
- if (!modelConfig) {
624
- try {
625
- modelConfig = readBlock(ctx.cwd, "model-config") as typeof modelConfig;
626
- } catch { /* no config file — models come from agent specs or step specs */ }
627
- }
628
-
629
- // 6. Build execution plan and execute layers
630
- const plan = buildConservativePlan(spec);
631
- const templateEnv = createTemplateEnv(ctx.cwd);
632
- const stepOpts: StepExecOptions = { ctx, pi, signal, loadAgent, runDir, spec, widgetState, templateEnv, dispatchFn: options.dispatchFn, modelConfig };
633
-
634
- for (const layer of plan) {
635
- if (signal?.aborted) {
636
- // Mark first unprocessed step as cancelled
637
- for (const sn of layer.steps) {
638
- if (!state.steps[sn]) {
639
- state.steps[sn] = {
640
- step: sn,
641
- agent: spec.steps[sn].agent,
642
- status: "failed",
643
- usage: zeroUsage(),
644
- durationMs: 0,
645
- error: "Workflow cancelled",
646
- };
647
- }
648
- }
649
- state.status = "failed";
650
- break;
651
- }
652
-
653
- // Filter to only pending steps in this layer (resume support)
654
- const pendingSteps = layer.steps.filter(
655
- s => !state.steps[s] || state.steps[s].status === "failed"
656
- );
657
- if (pendingSteps.length === 0) continue; // entire layer already done
658
-
659
- if (pendingSteps.length === 1) {
660
- const stepName = pendingSteps[0];
661
- const stepSpec = spec.steps[stepName];
662
- const cont = await executeSingleStep(stepName, stepSpec, state, stepOpts);
663
- if (!cont) break;
664
- } else {
665
- // Run only the pending steps concurrently
666
- const pendingLayer = { steps: pendingSteps };
667
- await executeParallelLayer(pendingLayer, spec, state, executeSingleStep, stepOpts);
668
- if (state.status === "failed" || state.status === "paused") break;
669
- }
670
-
671
- // Check keybinding-initiated pause (between layers/steps)
672
- if (pauseRequested) {
673
- pauseRequested = false;
674
- state.status = "paused";
675
- try {
676
- writeState(runDir, state);
677
- } catch (err) {
678
- const msg = err instanceof Error ? err.message : String(err);
679
- if (ctx.hasUI) {
680
- ctx.ui.notify(`Warning: state write failed after pause — resume may not work: ${msg}`, "error");
681
- }
682
- }
683
- if (ctx.hasUI) {
684
- ctx.ui.notify("Workflow paused. Use /workflow resume or Ctrl+J to continue.", "info");
685
- }
686
- break;
687
- }
688
- }
689
-
690
- // 5. Finalize
691
- if (state.status === "running") {
692
- state.status = "completed";
693
- }
694
- try {
695
- writeState(runDir, state);
696
- } catch (err) {
697
- const msg = err instanceof Error ? err.message : String(err);
698
- if (ctx.hasUI) {
699
- ctx.ui.notify(`Warning: final state write failed — run history may be incomplete: ${msg}`, "error");
700
- }
701
- }
702
- writeMetrics(runDir, state.steps);
703
-
704
- // 6. Process artifacts
705
- const writtenArtifacts: Record<string, string> = {};
706
- if (spec.artifacts) {
707
- const workflowDir = getWorkflowDir(ctx.cwd, spec.name);
708
- const artifactScope: Record<string, unknown> = {
709
- input: state.input,
710
- steps: state.steps,
711
- runId,
712
- runDir,
713
- };
714
-
715
- for (const [name, artifactSpec] of Object.entries(spec.artifacts)) {
716
- try {
717
- // Resolve the output path (may contain expressions)
718
- // Relative paths resolve against the workflow's output directory
719
- const resolvedPath = String(resolveExpressions(artifactSpec.path, artifactScope));
720
- const absolutePath = path.isAbsolute(resolvedPath)
721
- ? resolvedPath
722
- : path.resolve(workflowDir, resolvedPath);
723
-
724
- // Resolve the data source — wrap `from` as ${{ from }} for expression resolution
725
- const fromExpr = artifactSpec.from.startsWith("${{") ? artifactSpec.from : `\${{ ${artifactSpec.from} }}`;
726
- const data = resolveExpressions(fromExpr, artifactScope);
727
-
728
- // Validate against schema if specified
729
- if (artifactSpec.schema) {
730
- const schemaPath = resolveSchemaPath(artifactSpec.schema, spec.filePath);
731
- validateFromFile(schemaPath, data, `artifact '${name}'`);
732
- }
733
-
734
- // Route project block JSON targets through block-api for block-schema validation
735
- const workflowPrefix = path.join(ctx.cwd, PROJECT_DIR) + path.sep;
736
- if (absolutePath.startsWith(workflowPrefix) && absolutePath.endsWith(".json")) {
737
- const blockName = path.basename(absolutePath, ".json");
738
- writeBlock(ctx.cwd, blockName, data);
739
- writtenArtifacts[name] = absolutePath;
740
- } else {
741
- // Write non-block artifacts directly
742
- fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
743
- if (typeof data === "string") {
744
- fs.writeFileSync(absolutePath, data);
745
- } else {
746
- fs.writeFileSync(absolutePath, JSON.stringify(data, null, 2));
747
- }
748
- writtenArtifacts[name] = absolutePath;
749
- }
750
- } catch (err) {
751
- // Artifact write failure is non-fatal — log warning, don't fail the workflow
752
- const msg = err instanceof Error ? err.message : String(err);
753
- if (ctx.hasUI) {
754
- ctx.ui.notify(`Artifact '${name}' failed: ${msg}`, "warning");
755
- }
756
- }
757
- }
758
- }
759
-
760
- // 7. Clean up TUI
761
- if (ctx.hasUI) {
762
- ctx.ui.setWidget(WIDGET_ID, undefined);
763
- ctx.ui.setWorkingMessage(undefined);
764
- }
765
-
766
- // 8. Build and inject result
767
- const result = buildResult(spec, runId, runDir, state, state.status as "completed" | "failed" | "paused");
768
-
769
- // Attach written artifact paths to the result
770
- if (Object.keys(writtenArtifacts).length > 0) {
771
- result.artifacts = writtenArtifacts;
772
- }
773
- const triggerTurn = spec.triggerTurn !== false;
774
-
775
- if (state.status === "paused") {
776
- const completedCount = Object.values(state.steps).filter(s => s.status === "completed").length;
777
- const totalCount = Object.keys(spec.steps).length;
778
- const pauseContent = `Workflow '${spec.name}' paused (${completedCount}/${totalCount} steps completed). Use /workflow resume ${spec.name} or Ctrl+J to continue.`;
779
- pi.sendMessage(
780
- { customType: "workflow-result", content: pauseContent, display: "verbose" },
781
- { triggerTurn: false },
782
- );
783
- } else {
784
- let content: string;
785
- if (spec.completion) {
786
- try {
787
- content = resolveCompletion(spec.completion, result, input);
788
- } catch (err) {
789
- const msg = err instanceof Error ? err.message : String(err);
790
- content = formatResult(result) + `\n\nCompletion template error: ${msg}`;
791
- }
792
- } else {
793
- content = formatResult(result);
794
- }
795
-
796
- // P7: Truncate completion message to avoid context overflow (50KB / 2000 lines)
797
- const truncated = truncateTail(content, 2000, 50 * 1024);
798
- content = truncated.content;
799
- if (truncated.truncated) {
800
- content += `\n\n[Truncated: output exceeded ${truncated.truncatedBy === "bytes" ? "50KB" : "2000 lines"}. Full output in run dir: ${runDir}]`;
801
- }
802
-
803
- pi.sendMessage(
804
- { customType: "workflow-result", content, display: "verbose" },
805
- { triggerTurn },
806
- );
807
- }
808
-
809
- return result;
810
- }