@davidorex/pi-workflows 0.1.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +169 -0
  3. package/agents/audit-fixer.agent.yaml +18 -0
  4. package/agents/code-explorer.agent.yaml +11 -0
  5. package/agents/decomposer.agent.yaml +51 -0
  6. package/agents/investigator.agent.yaml +49 -0
  7. package/agents/pattern-analyzer.agent.yaml +21 -0
  8. package/agents/phase-author.agent.yaml +16 -0
  9. package/agents/plan-decomposer.agent.yaml +12 -0
  10. package/agents/quality-analyzer.agent.yaml +21 -0
  11. package/agents/researcher.agent.yaml +38 -0
  12. package/agents/spec-implementer.agent.yaml +12 -0
  13. package/agents/structure-analyzer.agent.yaml +21 -0
  14. package/agents/synthesizer.agent.yaml +23 -0
  15. package/agents/verifier.agent.yaml +12 -0
  16. package/package.json +40 -0
  17. package/schemas/decomposition-specs.schema.json +50 -0
  18. package/schemas/execution-results.schema.json +50 -0
  19. package/schemas/investigation-findings.schema.json +45 -0
  20. package/schemas/pattern-analysis.schema.json +43 -0
  21. package/schemas/plan-breakdown.schema.json +22 -0
  22. package/schemas/quality-analysis.schema.json +35 -0
  23. package/schemas/research-findings.schema.json +44 -0
  24. package/schemas/structure-analysis.schema.json +42 -0
  25. package/schemas/synthesis.schema.json +33 -0
  26. package/schemas/verifier-output.schema.json +96 -0
  27. package/src/agent-spec.test.ts +289 -0
  28. package/src/agent-spec.ts +122 -0
  29. package/src/block-validation.test.ts +350 -0
  30. package/src/checkpoint.test.ts +237 -0
  31. package/src/checkpoint.ts +139 -0
  32. package/src/completion.test.ts +143 -0
  33. package/src/completion.ts +68 -0
  34. package/src/dag.test.ts +350 -0
  35. package/src/dag.ts +219 -0
  36. package/src/dispatch.test.ts +473 -0
  37. package/src/dispatch.ts +353 -0
  38. package/src/expression.test.ts +353 -0
  39. package/src/expression.ts +332 -0
  40. package/src/format.test.ts +80 -0
  41. package/src/format.ts +41 -0
  42. package/src/graduated-failure.test.ts +556 -0
  43. package/src/index.test.ts +114 -0
  44. package/src/index.ts +488 -0
  45. package/src/loop.test.ts +549 -0
  46. package/src/output.test.ts +213 -0
  47. package/src/output.ts +70 -0
  48. package/src/parallel-integration.test.ts +175 -0
  49. package/src/resume.test.ts +192 -0
  50. package/src/state.test.ts +192 -0
  51. package/src/state.ts +253 -0
  52. package/src/step-agent.test.ts +327 -0
  53. package/src/step-agent.ts +178 -0
  54. package/src/step-command.test.ts +330 -0
  55. package/src/step-command.ts +132 -0
  56. package/src/step-foreach.test.ts +647 -0
  57. package/src/step-foreach.ts +148 -0
  58. package/src/step-gate.test.ts +185 -0
  59. package/src/step-gate.ts +116 -0
  60. package/src/step-loop.test.ts +626 -0
  61. package/src/step-loop.ts +323 -0
  62. package/src/step-parallel.test.ts +475 -0
  63. package/src/step-parallel.ts +168 -0
  64. package/src/step-pause.test.ts +30 -0
  65. package/src/step-pause.ts +27 -0
  66. package/src/step-shared.test.ts +355 -0
  67. package/src/step-shared.ts +157 -0
  68. package/src/step-transform.test.ts +155 -0
  69. package/src/step-transform.ts +47 -0
  70. package/src/template-integration.test.ts +72 -0
  71. package/src/template.test.ts +162 -0
  72. package/src/template.ts +92 -0
  73. package/src/test-helpers.ts +51 -0
  74. package/src/tui.test.ts +355 -0
  75. package/src/tui.ts +182 -0
  76. package/src/types.ts +195 -0
  77. package/src/verifier-schema.test.ts +226 -0
  78. package/src/workflow-discovery.test.ts +105 -0
  79. package/src/workflow-discovery.ts +113 -0
  80. package/src/workflow-executor.test.ts +1530 -0
  81. package/src/workflow-executor.ts +810 -0
  82. package/src/workflow-sdk.test.ts +238 -0
  83. package/src/workflow-sdk.ts +262 -0
  84. package/src/workflow-spec.test.ts +576 -0
  85. package/src/workflow-spec.ts +471 -0
  86. package/templates/analyzers/base-analyzer.md +9 -0
  87. package/templates/analyzers/macros.md +1 -0
  88. package/templates/analyzers/patterns-task.md +11 -0
  89. package/templates/analyzers/patterns.md +15 -0
  90. package/templates/analyzers/quality-task.md +11 -0
  91. package/templates/analyzers/quality.md +15 -0
  92. package/templates/analyzers/structure-task.md +17 -0
  93. package/templates/analyzers/structure.md +15 -0
  94. package/templates/audit-fixer/task.md +67 -0
  95. package/templates/decomposer/task.md +56 -0
  96. package/templates/explorer/system.md +3 -0
  97. package/templates/explorer/task.md +9 -0
  98. package/templates/investigator/task.md +30 -0
  99. package/templates/phase-author/task.md +74 -0
  100. package/templates/plan-decomposer/task.md +46 -0
  101. package/templates/researcher/task.md +26 -0
  102. package/templates/spec-implementer/task.md +53 -0
  103. package/templates/synthesizer/system.md +3 -0
  104. package/templates/synthesizer/task.md +38 -0
  105. package/templates/verifier/task.md +57 -0
  106. package/workflows/create-phase.workflow.yaml +67 -0
  107. package/workflows/do-gap.workflow.yaml +153 -0
  108. package/workflows/fix-audit.workflow.yaml +217 -0
  109. package/workflows/gap-to-phase.workflow.yaml +98 -0
  110. package/workflows/parallel-analysis.workflow.yaml +62 -0
  111. package/workflows/parallel-explicit.workflow.yaml +42 -0
  112. package/workflows/pausable-analysis.workflow.yaml +44 -0
  113. package/workflows/resumable-analysis.workflow.yaml +42 -0
  114. package/workflows/self-implement.workflow.yaml +63 -0
  115. package/workflows/typed-analysis.workflow.yaml +63 -0
@@ -0,0 +1,1530 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import { executeWorkflow } from "./workflow-executor.ts";
4
+ import type { WorkflowSpec } from "./types.ts";
5
+ import { mockCtx, mockPi, makeSpec } from "./test-helpers.ts";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import os from "node:os";
9
+
10
+ // Skip if pi is not available
11
+ let hasPi = false;
12
+ try {
13
+ const { execSync } = await import("node:child_process");
14
+ execSync("pi --version", { stdio: "ignore" });
15
+ hasPi = true;
16
+ } catch {}
17
+
18
+ describe("executeWorkflow", { skip: !hasPi ? "pi not available" : undefined }, () => {
19
+ it("runs a single-step workflow", async () => {
20
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-exec-"));
21
+ const spec: WorkflowSpec = {
22
+ name: "test",
23
+ description: "test workflow",
24
+ steps: {
25
+ greet: { agent: "default" },
26
+ },
27
+ source: "project",
28
+ filePath: path.join(tmpDir, "test.project.yaml"),
29
+ };
30
+
31
+ const pi = mockPi();
32
+ const result = await executeWorkflow(spec, {}, {
33
+ ctx: mockCtx(tmpDir),
34
+ pi,
35
+ loadAgent: () => ({ name: "default" }),
36
+ });
37
+
38
+ assert.strictEqual(result.status, "completed");
39
+ assert.ok(result.steps.greet);
40
+ assert.strictEqual(result.steps.greet.status, "completed");
41
+ assert.ok(result.totalDurationMs > 0);
42
+ assert.ok(pi._messages.length >= 1); // sendMessage called
43
+
44
+ fs.rmSync(tmpDir, { recursive: true });
45
+ }, { timeout: 60000 });
46
+
47
+ it("fails fast on step failure", async () => {
48
+ // Use a pre-aborted signal to reliably trigger failure on the first step.
49
+ // pi does not necessarily fail for unknown agent names, so the original
50
+ // approach of using a nonexistent agent is not reliable. An already-aborted
51
+ // signal exercises the executor's cancellation/fail-fast path: the first
52
+ // step is marked failed with "Workflow cancelled" and the second step
53
+ // is never reached.
54
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-exec-"));
55
+ const spec: WorkflowSpec = {
56
+ name: "test",
57
+ description: "test",
58
+ steps: {
59
+ willFail: { agent: "default" },
60
+ shouldNotRun: { agent: "default" },
61
+ },
62
+ source: "project",
63
+ filePath: path.join(tmpDir, "test.project.yaml"),
64
+ };
65
+
66
+ const controller = new AbortController();
67
+ controller.abort(); // pre-abort
68
+
69
+ const result = await executeWorkflow(spec, {}, {
70
+ ctx: mockCtx(tmpDir),
71
+ pi: mockPi(),
72
+ signal: controller.signal,
73
+ loadAgent: () => ({ name: "default" }),
74
+ });
75
+
76
+ assert.strictEqual(result.status, "failed");
77
+ assert.ok(result.steps.willFail);
78
+ assert.strictEqual(result.steps.willFail.error, "Workflow cancelled");
79
+ assert.ok(!result.steps.shouldNotRun); // never executed
80
+
81
+ fs.rmSync(tmpDir, { recursive: true });
82
+ }, { timeout: 30000 });
83
+
84
+ it("validates workflow input", async () => {
85
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-exec-"));
86
+ const spec: WorkflowSpec = {
87
+ name: "test",
88
+ description: "test",
89
+ input: {
90
+ type: "object",
91
+ required: ["name"],
92
+ properties: { name: { type: "string" } },
93
+ },
94
+ steps: { s: { agent: "default" } },
95
+ source: "project",
96
+ filePath: path.join(tmpDir, "test.project.yaml"),
97
+ };
98
+
99
+ await assert.rejects(
100
+ () => executeWorkflow(spec, { name: 123 }, {
101
+ ctx: mockCtx(tmpDir),
102
+ pi: mockPi(),
103
+ loadAgent: () => ({ name: "default" }),
104
+ }),
105
+ (err: unknown) => err instanceof Error && err.message.includes("Validation failed"),
106
+ );
107
+
108
+ fs.rmSync(tmpDir, { recursive: true });
109
+ });
110
+
111
+ it("resolves expressions between steps", async () => {
112
+ // This test verifies that step 2 can reference step 1's output.
113
+ // Since we can't easily control what pi outputs, we verify the
114
+ // expression resolution doesn't throw and both steps complete.
115
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-exec-"));
116
+ const spec: WorkflowSpec = {
117
+ name: "test",
118
+ description: "test",
119
+ steps: {
120
+ first: { agent: "default" },
121
+ second: {
122
+ agent: "default",
123
+ input: { prior: "${{ steps.first.textOutput }}" },
124
+ },
125
+ },
126
+ source: "project",
127
+ filePath: path.join(tmpDir, "test.project.yaml"),
128
+ };
129
+
130
+ const result = await executeWorkflow(spec, {}, {
131
+ ctx: mockCtx(tmpDir),
132
+ pi: mockPi(),
133
+ loadAgent: () => ({ name: "default" }),
134
+ });
135
+
136
+ assert.strictEqual(result.status, "completed");
137
+ assert.strictEqual(Object.keys(result.steps).length, 2);
138
+
139
+ fs.rmSync(tmpDir, { recursive: true });
140
+ }, { timeout: 120000 });
141
+
142
+ it("persists state to disk", async () => {
143
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-exec-"));
144
+ const spec: WorkflowSpec = {
145
+ name: "test",
146
+ description: "test",
147
+ steps: { s: { agent: "default" } },
148
+ source: "project",
149
+ filePath: path.join(tmpDir, "test.project.yaml"),
150
+ };
151
+
152
+ const result = await executeWorkflow(spec, {}, {
153
+ ctx: mockCtx(tmpDir),
154
+ pi: mockPi(),
155
+ loadAgent: () => ({ name: "default" }),
156
+ });
157
+
158
+ // Verify state.json exists in run directory
159
+ const stateFile = path.join(result.runDir, "state.json");
160
+ assert.ok(fs.existsSync(stateFile));
161
+ const savedState = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
162
+ assert.strictEqual(savedState.status, "completed");
163
+
164
+ fs.rmSync(tmpDir, { recursive: true });
165
+ }, { timeout: 60000 });
166
+ });
167
+
168
+ // ── When conditionals ──
169
+ // These tests don't require pi on PATH since gate/transform/when steps
170
+ // don't use subprocess dispatch.
171
+
172
+ describe("when conditionals", () => {
173
+ it("skips step when condition is falsy", async () => {
174
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-when-"));
175
+ const spec: WorkflowSpec = {
176
+ name: "test-when",
177
+ description: "test when conditionals",
178
+ steps: {
179
+ setup: {
180
+ agent: "transform",
181
+ transform: {
182
+ mapping: { ready: false, value: 42 },
183
+ },
184
+ },
185
+ conditional: {
186
+ agent: "transform",
187
+ when: "${{ steps.setup.output.ready }}",
188
+ transform: {
189
+ mapping: { result: "should not appear" },
190
+ },
191
+ },
192
+ after: {
193
+ agent: "transform",
194
+ transform: {
195
+ mapping: { final: "done" },
196
+ },
197
+ },
198
+ },
199
+ source: "project",
200
+ filePath: path.join(tmpDir, "test.project.yaml"),
201
+ };
202
+
203
+ const result = await executeWorkflow(spec, {}, {
204
+ ctx: mockCtx(tmpDir),
205
+ pi: mockPi(),
206
+ loadAgent: () => ({ name: "default" }),
207
+ });
208
+
209
+ assert.strictEqual(result.status, "completed");
210
+ assert.strictEqual(result.steps.conditional.status, "skipped");
211
+ // Subsequent step still runs
212
+ assert.strictEqual(result.steps.after.status, "completed");
213
+ assert.deepStrictEqual(result.steps.after.output, { final: "done" });
214
+
215
+ fs.rmSync(tmpDir, { recursive: true });
216
+ });
217
+
218
+ it("runs step when condition is truthy", async () => {
219
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-when-"));
220
+ const spec: WorkflowSpec = {
221
+ name: "test-when-truthy",
222
+ description: "test when conditionals truthy",
223
+ steps: {
224
+ setup: {
225
+ agent: "transform",
226
+ transform: {
227
+ mapping: { ready: true },
228
+ },
229
+ },
230
+ conditional: {
231
+ agent: "transform",
232
+ when: "${{ steps.setup.output.ready }}",
233
+ transform: {
234
+ mapping: { result: "executed" },
235
+ },
236
+ },
237
+ },
238
+ source: "project",
239
+ filePath: path.join(tmpDir, "test.project.yaml"),
240
+ };
241
+
242
+ const result = await executeWorkflow(spec, {}, {
243
+ ctx: mockCtx(tmpDir),
244
+ pi: mockPi(),
245
+ loadAgent: () => ({ name: "default" }),
246
+ });
247
+
248
+ assert.strictEqual(result.status, "completed");
249
+ assert.strictEqual(result.steps.conditional.status, "completed");
250
+ assert.deepStrictEqual(result.steps.conditional.output, { result: "executed" });
251
+
252
+ fs.rmSync(tmpDir, { recursive: true });
253
+ });
254
+ });
255
+
256
+ // ── Gate steps ──
257
+
258
+ describe("gate steps", () => {
259
+ it("passes on exit code 0", async () => {
260
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-gate-"));
261
+ const spec: WorkflowSpec = {
262
+ name: "test-gate",
263
+ description: "test gate step",
264
+ steps: {
265
+ verify: {
266
+ agent: "gate",
267
+ gate: {
268
+ check: "echo ok",
269
+ },
270
+ },
271
+ },
272
+ source: "project",
273
+ filePath: path.join(tmpDir, "test.project.yaml"),
274
+ };
275
+
276
+ const result = await executeWorkflow(spec, {}, {
277
+ ctx: mockCtx(tmpDir),
278
+ pi: mockPi(),
279
+ loadAgent: () => ({ name: "default" }),
280
+ });
281
+
282
+ assert.strictEqual(result.status, "completed");
283
+ assert.strictEqual(result.steps.verify.status, "completed");
284
+ const gateOutput = result.steps.verify.output as { passed: boolean; exitCode: number; output: string };
285
+ assert.strictEqual(gateOutput.passed, true);
286
+ assert.strictEqual(gateOutput.exitCode, 0);
287
+ assert.strictEqual(gateOutput.output, "ok");
288
+
289
+ fs.rmSync(tmpDir, { recursive: true });
290
+ });
291
+
292
+ it("fails workflow on gate failure with onFail: fail (default)", async () => {
293
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-gate-"));
294
+ const spec: WorkflowSpec = {
295
+ name: "test-gate-fail",
296
+ description: "test gate failure",
297
+ steps: {
298
+ verify: {
299
+ agent: "gate",
300
+ gate: {
301
+ check: "exit 1",
302
+ },
303
+ },
304
+ after: {
305
+ agent: "transform",
306
+ transform: {
307
+ mapping: { shouldNotRun: true },
308
+ },
309
+ },
310
+ },
311
+ source: "project",
312
+ filePath: path.join(tmpDir, "test.project.yaml"),
313
+ };
314
+
315
+ const result = await executeWorkflow(spec, {}, {
316
+ ctx: mockCtx(tmpDir),
317
+ pi: mockPi(),
318
+ loadAgent: () => ({ name: "default" }),
319
+ });
320
+
321
+ assert.strictEqual(result.status, "failed");
322
+ assert.strictEqual(result.steps.verify.status, "failed");
323
+ assert.ok(result.steps.verify.error?.includes("Gate check failed"));
324
+ // Second step should not have run
325
+ assert.ok(!result.steps.after);
326
+
327
+ fs.rmSync(tmpDir, { recursive: true });
328
+ });
329
+
330
+ it("continues on gate failure with onFail: continue", async () => {
331
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-gate-"));
332
+ const spec: WorkflowSpec = {
333
+ name: "test-gate-continue",
334
+ description: "test gate failure with continue",
335
+ steps: {
336
+ verify: {
337
+ agent: "gate",
338
+ gate: {
339
+ check: "exit 1",
340
+ onFail: "continue",
341
+ },
342
+ },
343
+ after: {
344
+ agent: "transform",
345
+ transform: {
346
+ mapping: { ran: true },
347
+ },
348
+ },
349
+ },
350
+ source: "project",
351
+ filePath: path.join(tmpDir, "test.project.yaml"),
352
+ };
353
+
354
+ const result = await executeWorkflow(spec, {}, {
355
+ ctx: mockCtx(tmpDir),
356
+ pi: mockPi(),
357
+ loadAgent: () => ({ name: "default" }),
358
+ });
359
+
360
+ assert.strictEqual(result.status, "completed");
361
+ // Gate step is completed (not failed) because onFail: continue
362
+ assert.strictEqual(result.steps.verify.status, "completed");
363
+ const gateOutput = result.steps.verify.output as { passed: boolean };
364
+ assert.strictEqual(gateOutput.passed, false);
365
+ // Next step ran
366
+ assert.strictEqual(result.steps.after.status, "completed");
367
+ assert.deepStrictEqual(result.steps.after.output, { ran: true });
368
+
369
+ fs.rmSync(tmpDir, { recursive: true });
370
+ });
371
+
372
+ it("resolves expressions in gate check", async () => {
373
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-gate-"));
374
+ const spec: WorkflowSpec = {
375
+ name: "test-gate-expr",
376
+ description: "test gate expression resolution",
377
+ steps: {
378
+ setup: {
379
+ agent: "transform",
380
+ transform: {
381
+ mapping: { cmd: "echo resolved" },
382
+ },
383
+ },
384
+ verify: {
385
+ agent: "gate",
386
+ gate: {
387
+ check: "${{ steps.setup.output.cmd }}",
388
+ },
389
+ },
390
+ },
391
+ source: "project",
392
+ filePath: path.join(tmpDir, "test.project.yaml"),
393
+ };
394
+
395
+ const result = await executeWorkflow(spec, {}, {
396
+ ctx: mockCtx(tmpDir),
397
+ pi: mockPi(),
398
+ loadAgent: () => ({ name: "default" }),
399
+ });
400
+
401
+ assert.strictEqual(result.status, "completed");
402
+ assert.strictEqual(result.steps.verify.status, "completed");
403
+ const gateOutput = result.steps.verify.output as { passed: boolean; output: string };
404
+ assert.strictEqual(gateOutput.passed, true);
405
+ assert.strictEqual(gateOutput.output, "resolved");
406
+
407
+ fs.rmSync(tmpDir, { recursive: true });
408
+ });
409
+ });
410
+
411
+ // ── Artifacts ──
412
+
413
+ describe("artifacts", () => {
414
+ it("writes artifact files after workflow completion", async () => {
415
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-artifact-"));
416
+ const spec: WorkflowSpec = {
417
+ name: "test-artifact",
418
+ description: "test artifact writing",
419
+ steps: {
420
+ produce: {
421
+ agent: "transform",
422
+ transform: {
423
+ mapping: { report: "test report content", count: 42 },
424
+ },
425
+ },
426
+ },
427
+ artifacts: {
428
+ textReport: {
429
+ path: path.join(tmpDir, "reports", "latest.txt"),
430
+ from: "steps.produce.output.report",
431
+ },
432
+ jsonReport: {
433
+ path: path.join(tmpDir, "reports", "data.json"),
434
+ from: "steps.produce.output",
435
+ },
436
+ },
437
+ source: "project",
438
+ filePath: path.join(tmpDir, "test.project.yaml"),
439
+ };
440
+
441
+ const result = await executeWorkflow(spec, {}, {
442
+ ctx: mockCtx(tmpDir),
443
+ pi: mockPi(),
444
+ loadAgent: () => ({ name: "default" }),
445
+ });
446
+
447
+ assert.strictEqual(result.status, "completed");
448
+ assert.ok(result.artifacts);
449
+ assert.ok(result.artifacts!.textReport);
450
+ assert.ok(result.artifacts!.jsonReport);
451
+
452
+ // Verify text artifact written as string (not JSON-wrapped)
453
+ const textContent = fs.readFileSync(result.artifacts!.textReport, "utf-8");
454
+ assert.strictEqual(textContent, "test report content");
455
+
456
+ // Verify JSON artifact written as formatted JSON
457
+ const jsonContent = JSON.parse(fs.readFileSync(result.artifacts!.jsonReport, "utf-8"));
458
+ assert.deepStrictEqual(jsonContent, { report: "test report content", count: 42 });
459
+
460
+ fs.rmSync(tmpDir, { recursive: true });
461
+ });
462
+
463
+ it("resolves expressions in artifact path", async () => {
464
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-artifact-"));
465
+ const spec: WorkflowSpec = {
466
+ name: "test-artifact-path",
467
+ description: "test artifact path expression",
468
+ steps: {
469
+ produce: {
470
+ agent: "transform",
471
+ transform: {
472
+ mapping: { value: "data" },
473
+ },
474
+ },
475
+ },
476
+ artifacts: {
477
+ report: {
478
+ path: path.join(tmpDir, "reports", "run-${{ runId }}.json"),
479
+ from: "steps.produce.output",
480
+ },
481
+ },
482
+ source: "project",
483
+ filePath: path.join(tmpDir, "test.project.yaml"),
484
+ };
485
+
486
+ const result = await executeWorkflow(spec, {}, {
487
+ ctx: mockCtx(tmpDir),
488
+ pi: mockPi(),
489
+ loadAgent: () => ({ name: "default" }),
490
+ });
491
+
492
+ assert.strictEqual(result.status, "completed");
493
+ assert.ok(result.artifacts);
494
+ // The artifact path should contain the runId
495
+ assert.ok(result.artifacts!.report.includes(result.runId));
496
+ assert.ok(fs.existsSync(result.artifacts!.report));
497
+
498
+ fs.rmSync(tmpDir, { recursive: true });
499
+ });
500
+
501
+ it("handles relative artifact paths resolved against workflow dir", async () => {
502
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-artifact-"));
503
+ const spec: WorkflowSpec = {
504
+ name: "test-artifact-rel",
505
+ description: "test relative artifact path",
506
+ steps: {
507
+ produce: {
508
+ agent: "transform",
509
+ transform: {
510
+ mapping: { result: "output" },
511
+ },
512
+ },
513
+ },
514
+ artifacts: {
515
+ report: {
516
+ path: "latest.json",
517
+ from: "steps.produce.output",
518
+ },
519
+ },
520
+ source: "project",
521
+ filePath: path.join(tmpDir, "test.project.yaml"),
522
+ };
523
+
524
+ const result = await executeWorkflow(spec, {}, {
525
+ ctx: mockCtx(tmpDir),
526
+ pi: mockPi(),
527
+ loadAgent: () => ({ name: "default" }),
528
+ });
529
+
530
+ assert.strictEqual(result.status, "completed");
531
+ assert.ok(result.artifacts);
532
+ // Artifact path should be under .pi/workflow-runs/<workflow-name>/
533
+ const workflowDir = path.join(tmpDir, ".pi", "workflow-runs", "test-artifact-rel");
534
+ const expectedPath = path.resolve(workflowDir, "latest.json");
535
+ assert.strictEqual(result.artifacts!.report, expectedPath);
536
+ assert.ok(fs.existsSync(expectedPath));
537
+
538
+ fs.rmSync(tmpDir, { recursive: true });
539
+ });
540
+
541
+ it("artifact failure is non-fatal", async () => {
542
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-artifact-"));
543
+ const notifications: Array<{ msg: string; level: string }> = [];
544
+ const spec: WorkflowSpec = {
545
+ name: "test-artifact-fail",
546
+ description: "test artifact failure handling",
547
+ steps: {
548
+ produce: {
549
+ agent: "transform",
550
+ transform: {
551
+ mapping: { value: "data" },
552
+ },
553
+ },
554
+ },
555
+ artifacts: {
556
+ bad: {
557
+ path: path.join(tmpDir, "reports", "output.json"),
558
+ from: "steps.nonexistent.output", // expression will fail
559
+ },
560
+ },
561
+ source: "project",
562
+ filePath: path.join(tmpDir, "test.project.yaml"),
563
+ };
564
+
565
+ const ctx = {
566
+ cwd: tmpDir,
567
+ hasUI: true,
568
+ ui: {
569
+ setWidget: () => {},
570
+ notify: (msg: string, level: string) => notifications.push({ msg, level }),
571
+ setStatus: () => {},
572
+ setWorkingMessage: () => {},
573
+ },
574
+ } as any;
575
+
576
+ const result = await executeWorkflow(spec, {}, {
577
+ ctx,
578
+ pi: mockPi(),
579
+ loadAgent: () => ({ name: "default" }),
580
+ });
581
+
582
+ // Workflow still completes despite artifact failure
583
+ assert.strictEqual(result.status, "completed");
584
+ // No artifacts written
585
+ assert.ok(!result.artifacts || Object.keys(result.artifacts).length === 0);
586
+ // Warning notification was sent
587
+ assert.ok(notifications.some((n) => n.msg.includes("bad") && n.level === "warning"));
588
+
589
+ fs.rmSync(tmpDir, { recursive: true });
590
+ });
591
+
592
+ it("validates artifact targeting .project/ against block schema", async () => {
593
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-artifact-"));
594
+
595
+ // Create .project/schemas/ with a schema that requires { items: array }
596
+ const schemasDir = path.join(tmpDir, ".project", "schemas");
597
+ fs.mkdirSync(schemasDir, { recursive: true });
598
+ fs.writeFileSync(path.join(schemasDir, "test-block.schema.json"), JSON.stringify({
599
+ type: "object",
600
+ required: ["items"],
601
+ properties: { items: { type: "array" } },
602
+ }));
603
+
604
+ const spec: WorkflowSpec = {
605
+ name: "test-artifact-block",
606
+ description: "test block-api routing for .project/ artifacts",
607
+ steps: {
608
+ produce: {
609
+ agent: "transform",
610
+ transform: {
611
+ mapping: { bad: "data" }, // does NOT match schema (missing items)
612
+ },
613
+ },
614
+ },
615
+ artifacts: {
616
+ block: {
617
+ path: path.join(tmpDir, ".project", "test-block.json"),
618
+ from: "steps.produce.output",
619
+ },
620
+ },
621
+ source: "test",
622
+ version: "1.0",
623
+ filePath: path.join(tmpDir, "test.project.yaml"),
624
+ };
625
+
626
+ const notifications: { msg: string; level: string }[] = [];
627
+ const result = await executeWorkflow(spec, {}, {
628
+ ctx: {
629
+ cwd: tmpDir,
630
+ hasUI: true,
631
+ ui: {
632
+ setWidget: () => {},
633
+ notify: (msg: string, level: string) => { notifications.push({ msg, level }); },
634
+ setStatus: () => {},
635
+ setWorkingMessage: () => {},
636
+ },
637
+ } as unknown as ExtensionContext,
638
+ pi: mockPi(),
639
+ loadAgent: () => ({ name: "default" }),
640
+ });
641
+
642
+ // Workflow completes (artifact failure is non-fatal)
643
+ assert.strictEqual(result.status, "completed");
644
+ // Block file should NOT have been written (validation failed)
645
+ assert.ok(!fs.existsSync(path.join(tmpDir, ".project", "test-block.json")));
646
+ // Warning about validation failure
647
+ assert.ok(notifications.some(n => n.msg.includes("test-block") && n.level === "warning"));
648
+
649
+ fs.rmSync(tmpDir, { recursive: true });
650
+ });
651
+
652
+ it("includes artifacts in formatResult output", async () => {
653
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-artifact-"));
654
+ const spec: WorkflowSpec = {
655
+ name: "test-artifact-format",
656
+ description: "test artifact in formatResult",
657
+ steps: {
658
+ produce: {
659
+ agent: "transform",
660
+ transform: {
661
+ mapping: { value: "data" },
662
+ },
663
+ },
664
+ },
665
+ artifacts: {
666
+ report: {
667
+ path: path.join(tmpDir, "reports", "latest.json"),
668
+ from: "steps.produce.output",
669
+ },
670
+ },
671
+ source: "project",
672
+ filePath: path.join(tmpDir, "test.project.yaml"),
673
+ };
674
+
675
+ const pi = mockPi();
676
+ const result = await executeWorkflow(spec, {}, {
677
+ ctx: mockCtx(tmpDir),
678
+ pi,
679
+ loadAgent: () => ({ name: "default" }),
680
+ });
681
+
682
+ // The sendMessage content should include artifact info
683
+ const lastMsg = pi._messages[pi._messages.length - 1];
684
+ const content = lastMsg.msg.content;
685
+ assert.ok(content.includes("Artifacts:"));
686
+ assert.ok(content.includes("report"));
687
+
688
+ fs.rmSync(tmpDir, { recursive: true });
689
+ });
690
+ });
691
+
692
+ // ── Transform steps ──
693
+
694
+ describe("transform steps", () => {
695
+ it("produces output from expression mapping", async () => {
696
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-transform-"));
697
+ const spec: WorkflowSpec = {
698
+ name: "test-transform",
699
+ description: "test transform step",
700
+ steps: {
701
+ merge: {
702
+ agent: "transform",
703
+ transform: {
704
+ mapping: {
705
+ greeting: "hello",
706
+ count: 42,
707
+ nested: { deep: true },
708
+ },
709
+ },
710
+ },
711
+ },
712
+ source: "project",
713
+ filePath: path.join(tmpDir, "test.project.yaml"),
714
+ };
715
+
716
+ const result = await executeWorkflow(spec, {}, {
717
+ ctx: mockCtx(tmpDir),
718
+ pi: mockPi(),
719
+ loadAgent: () => ({ name: "default" }),
720
+ });
721
+
722
+ assert.strictEqual(result.status, "completed");
723
+ assert.strictEqual(result.steps.merge.status, "completed");
724
+ assert.strictEqual(result.steps.merge.agent, "transform");
725
+ const output = result.steps.merge.output as Record<string, unknown>;
726
+ assert.strictEqual(output.greeting, "hello");
727
+ assert.strictEqual(output.count, 42);
728
+ assert.deepStrictEqual(output.nested, { deep: true });
729
+
730
+ fs.rmSync(tmpDir, { recursive: true });
731
+ });
732
+
733
+ it("costs nothing (usage.cost === 0, usage.turns === 0)", async () => {
734
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-transform-"));
735
+ const spec: WorkflowSpec = {
736
+ name: "test-transform-cost",
737
+ description: "test transform zero cost",
738
+ steps: {
739
+ merge: {
740
+ agent: "transform",
741
+ transform: {
742
+ mapping: { result: "free" },
743
+ },
744
+ },
745
+ },
746
+ source: "project",
747
+ filePath: path.join(tmpDir, "test.project.yaml"),
748
+ };
749
+
750
+ const result = await executeWorkflow(spec, {}, {
751
+ ctx: mockCtx(tmpDir),
752
+ pi: mockPi(),
753
+ loadAgent: () => ({ name: "default" }),
754
+ });
755
+
756
+ assert.strictEqual(result.status, "completed");
757
+ assert.strictEqual(result.steps.merge.usage.cost, 0);
758
+ assert.strictEqual(result.steps.merge.usage.turns, 0);
759
+ assert.strictEqual(result.steps.merge.usage.input, 0);
760
+ assert.strictEqual(result.steps.merge.usage.output, 0);
761
+ assert.strictEqual(result.totalUsage.cost, 0);
762
+ assert.strictEqual(result.totalUsage.turns, 0);
763
+
764
+ fs.rmSync(tmpDir, { recursive: true });
765
+ });
766
+ });
767
+
768
+ // ── Phase 2 integration tests ──
769
+ // These exercise combined phase 2 features: when, gate, transform, loop
770
+
771
+ function defaultOptions(tmpDir?: string) {
772
+ const cwd = tmpDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "wf-p2-"));
773
+ return {
774
+ ctx: mockCtx(cwd),
775
+ pi: mockPi(),
776
+ loadAgent: () => ({ name: "default" }),
777
+ };
778
+ }
779
+
780
+ describe("phase 2 integration", () => {
781
+ it("runs a workflow with when, gate, transform", async () => {
782
+ const spec = makeSpec({
783
+ steps: {
784
+ source: {
785
+ transform: {
786
+ mapping: { data: "initial" },
787
+ },
788
+ },
789
+ check: {
790
+ gate: { check: "echo pass", onPass: "continue" },
791
+ },
792
+ conditional: {
793
+ when: "${{ steps.check.output.passed }}",
794
+ transform: {
795
+ mapping: {
796
+ wasChecked: "${{ steps.check.output.passed }}",
797
+ sourceStatus: "${{ steps.source.status }}",
798
+ },
799
+ },
800
+ },
801
+ },
802
+ });
803
+
804
+ const result = await executeWorkflow(spec, {}, defaultOptions());
805
+ assert.strictEqual(result.status, "completed");
806
+ assert.strictEqual(result.steps.check.output.passed, true);
807
+ assert.strictEqual(result.steps.conditional.output.wasChecked, true);
808
+ assert.strictEqual(result.steps.conditional.usage.cost, 0);
809
+ });
810
+
811
+ it("skips conditional step when gate fails", async () => {
812
+ const spec = makeSpec({
813
+ steps: {
814
+ check: {
815
+ gate: { check: "exit 1", onFail: "continue" },
816
+ },
817
+ conditional: {
818
+ when: "${{ steps.check.output.passed }}",
819
+ transform: {
820
+ mapping: { result: "should not appear" },
821
+ },
822
+ },
823
+ final: {
824
+ transform: {
825
+ mapping: { done: true },
826
+ },
827
+ },
828
+ },
829
+ });
830
+
831
+ const result = await executeWorkflow(spec, {}, defaultOptions());
832
+ assert.strictEqual(result.status, "completed");
833
+ assert.strictEqual(result.steps.check.output.passed, false);
834
+ assert.strictEqual(result.steps.conditional.status, "skipped");
835
+ assert.strictEqual(result.steps.final.status, "completed");
836
+ });
837
+
838
+ it("runs a loop with gate break", async () => {
839
+ const spec = makeSpec({
840
+ steps: {
841
+ retry: {
842
+ loop: {
843
+ maxAttempts: 5,
844
+ steps: {
845
+ check: {
846
+ gate: {
847
+ check: "echo pass",
848
+ onPass: "break",
849
+ onFail: "continue",
850
+ },
851
+ },
852
+ },
853
+ },
854
+ },
855
+ },
856
+ });
857
+
858
+ const result = await executeWorkflow(spec, {}, defaultOptions());
859
+ assert.strictEqual(result.status, "completed");
860
+ assert.strictEqual(result.steps.retry.output.iterations, 1);
861
+ });
862
+
863
+ it("combines transform, loop, and artifacts", async () => {
864
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-p2-"));
865
+ const spec: WorkflowSpec = {
866
+ name: "test-combined",
867
+ description: "combined phase 2 test",
868
+ steps: {
869
+ setup: {
870
+ transform: {
871
+ mapping: { prefix: "test" },
872
+ },
873
+ },
874
+ retry: {
875
+ loop: {
876
+ maxAttempts: 2,
877
+ steps: {
878
+ check: {
879
+ gate: {
880
+ check: "echo pass",
881
+ onPass: "break",
882
+ onFail: "continue",
883
+ },
884
+ },
885
+ },
886
+ },
887
+ },
888
+ summary: {
889
+ transform: {
890
+ mapping: {
891
+ setupResult: "${{ steps.setup.output.prefix }}",
892
+ loopIterations: "${{ steps.retry.output.iterations }}",
893
+ loopStatus: "${{ steps.retry.status }}",
894
+ },
895
+ },
896
+ },
897
+ },
898
+ artifacts: {
899
+ report: {
900
+ path: path.join(tmpDir, "artifacts", "summary.json"),
901
+ from: "steps.summary.output",
902
+ },
903
+ },
904
+ source: "project",
905
+ filePath: path.join(tmpDir, "test.project.yaml"),
906
+ };
907
+
908
+ const result = await executeWorkflow(spec, {}, {
909
+ ctx: mockCtx(tmpDir),
910
+ pi: mockPi(),
911
+ loadAgent: () => ({ name: "default" }),
912
+ });
913
+
914
+ assert.strictEqual(result.status, "completed");
915
+
916
+ // Verify transform output
917
+ const summaryOutput = result.steps.summary.output as Record<string, unknown>;
918
+ assert.strictEqual(summaryOutput.setupResult, "test");
919
+ assert.strictEqual(summaryOutput.loopIterations, 1);
920
+ assert.strictEqual(summaryOutput.loopStatus, "completed");
921
+
922
+ // Verify artifact was written
923
+ assert.ok(result.artifacts);
924
+ assert.ok(result.artifacts!.report);
925
+ const artifactContent = JSON.parse(fs.readFileSync(result.artifacts!.report, "utf-8"));
926
+ assert.strictEqual(artifactContent.setupResult, "test");
927
+
928
+ fs.rmSync(tmpDir, { recursive: true });
929
+ });
930
+ });
931
+
932
+ describe("parallel execution (DAG-inferred)", () => {
933
+ it("runs independent steps in parallel when they have explicit deps", async () => {
934
+ // Two steps both depend on a source step → they form a parallel layer
935
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-parallel-"));
936
+ const spec: WorkflowSpec = {
937
+ name: "test-parallel",
938
+ description: "test parallel",
939
+ steps: {
940
+ source: {
941
+ transform: { mapping: { data: "hello" } },
942
+ },
943
+ analyzerA: {
944
+ transform: {
945
+ mapping: { result: "${{ steps.source.output.data }}-A" },
946
+ },
947
+ },
948
+ analyzerB: {
949
+ transform: {
950
+ mapping: { result: "${{ steps.source.output.data }}-B" },
951
+ },
952
+ },
953
+ merge: {
954
+ transform: {
955
+ mapping: {
956
+ a: "${{ steps.analyzerA.output.result }}",
957
+ b: "${{ steps.analyzerB.output.result }}",
958
+ },
959
+ },
960
+ },
961
+ },
962
+ source: "project",
963
+ filePath: path.join(tmpDir, "test.project.yaml"),
964
+ };
965
+
966
+ const result = await executeWorkflow(spec, {}, {
967
+ ctx: mockCtx(tmpDir),
968
+ pi: mockPi(),
969
+ loadAgent: () => ({ name: "default" }),
970
+ });
971
+
972
+ assert.strictEqual(result.status, "completed");
973
+ assert.strictEqual(result.steps.source.status, "completed");
974
+ assert.strictEqual(result.steps.analyzerA.status, "completed");
975
+ assert.strictEqual(result.steps.analyzerB.status, "completed");
976
+ assert.strictEqual(result.steps.merge.status, "completed");
977
+
978
+ const mergeOutput = result.steps.merge.output as Record<string, unknown>;
979
+ assert.strictEqual(mergeOutput.a, "hello-A");
980
+ assert.strictEqual(mergeOutput.b, "hello-B");
981
+
982
+ fs.rmSync(tmpDir, { recursive: true });
983
+ });
984
+
985
+ it("fails fast when parallel step fails", async () => {
986
+ // source → (analyzerA, failGate) → merge
987
+ // failGate fails, so merge should not run
988
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-parallel-"));
989
+ const spec: WorkflowSpec = {
990
+ name: "test-parallel-fail",
991
+ description: "test parallel fail",
992
+ steps: {
993
+ source: {
994
+ transform: { mapping: { data: "hello" } },
995
+ },
996
+ analyzerA: {
997
+ transform: {
998
+ mapping: { result: "${{ steps.source.output.data }}" },
999
+ },
1000
+ },
1001
+ failGate: {
1002
+ gate: { check: "exit 1" },
1003
+ when: "${{ steps.source.output.data }}",
1004
+ },
1005
+ merge: {
1006
+ transform: {
1007
+ mapping: {
1008
+ a: "${{ steps.analyzerA.output.result }}",
1009
+ b: "${{ steps.failGate.output }}",
1010
+ },
1011
+ },
1012
+ },
1013
+ },
1014
+ source: "project",
1015
+ filePath: path.join(tmpDir, "test.project.yaml"),
1016
+ };
1017
+
1018
+ const result = await executeWorkflow(spec, {}, {
1019
+ ctx: mockCtx(tmpDir),
1020
+ pi: mockPi(),
1021
+ loadAgent: () => ({ name: "default" }),
1022
+ });
1023
+
1024
+ assert.strictEqual(result.status, "failed");
1025
+ assert.strictEqual(result.steps.failGate.status, "failed");
1026
+ // merge should not have run
1027
+ assert.ok(!result.steps.merge);
1028
+
1029
+ fs.rmSync(tmpDir, { recursive: true });
1030
+ });
1031
+
1032
+ it("sequential steps without deps remain sequential", async () => {
1033
+ // Steps without ${{ steps.X }} deps are sequential by declaration order
1034
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-seq-"));
1035
+ const spec: WorkflowSpec = {
1036
+ name: "test-seq",
1037
+ description: "test sequential",
1038
+ steps: {
1039
+ a: { transform: { mapping: { x: 1 } } },
1040
+ b: { transform: { mapping: { y: 2 } } },
1041
+ c: { transform: { mapping: { z: 3 } } },
1042
+ },
1043
+ source: "project",
1044
+ filePath: path.join(tmpDir, "test.project.yaml"),
1045
+ };
1046
+
1047
+ const result = await executeWorkflow(spec, {}, {
1048
+ ctx: mockCtx(tmpDir),
1049
+ pi: mockPi(),
1050
+ loadAgent: () => ({ name: "default" }),
1051
+ });
1052
+
1053
+ assert.strictEqual(result.status, "completed");
1054
+ assert.strictEqual(result.steps.a.status, "completed");
1055
+ assert.strictEqual(result.steps.b.status, "completed");
1056
+ assert.strictEqual(result.steps.c.status, "completed");
1057
+
1058
+ fs.rmSync(tmpDir, { recursive: true });
1059
+ });
1060
+ });
1061
+
1062
+ describe("explicit parallel step", () => {
1063
+ it("runs sub-steps concurrently", async () => {
1064
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-explpar-"));
1065
+ const spec: WorkflowSpec = {
1066
+ name: "test-explicit-parallel",
1067
+ description: "test explicit parallel",
1068
+ steps: {
1069
+ source: {
1070
+ transform: { mapping: { data: "hello" } },
1071
+ },
1072
+ analyzers: {
1073
+ parallel: {
1074
+ security: {
1075
+ transform: {
1076
+ mapping: { result: "${{ steps.source.output.data }}-sec" },
1077
+ },
1078
+ },
1079
+ performance: {
1080
+ transform: {
1081
+ mapping: { result: "${{ steps.source.output.data }}-perf" },
1082
+ },
1083
+ },
1084
+ },
1085
+ },
1086
+ merge: {
1087
+ transform: {
1088
+ mapping: {
1089
+ results: "${{ steps.analyzers.output }}",
1090
+ },
1091
+ },
1092
+ },
1093
+ },
1094
+ source: "project",
1095
+ filePath: path.join(tmpDir, "test.project.yaml"),
1096
+ };
1097
+
1098
+ const result = await executeWorkflow(spec, {}, {
1099
+ ctx: mockCtx(tmpDir),
1100
+ pi: mockPi(),
1101
+ loadAgent: () => ({ name: "default" }),
1102
+ });
1103
+
1104
+ assert.strictEqual(result.status, "completed");
1105
+ assert.strictEqual(result.steps.analyzers.agent, "parallel");
1106
+ assert.strictEqual(result.steps.analyzers.status, "completed");
1107
+ const output = result.steps.analyzers.output as Record<string, unknown>;
1108
+ assert.ok(output.security);
1109
+ assert.ok(output.performance);
1110
+
1111
+ fs.rmSync(tmpDir, { recursive: true });
1112
+ });
1113
+
1114
+ it("fails if any sub-step fails", async () => {
1115
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-explpar-fail-"));
1116
+ const spec: WorkflowSpec = {
1117
+ name: "test-parallel-subfail",
1118
+ description: "test parallel sub-step failure",
1119
+ steps: {
1120
+ both: {
1121
+ parallel: {
1122
+ good: {
1123
+ transform: { mapping: { ok: true } },
1124
+ },
1125
+ bad: {
1126
+ gate: { check: "exit 1", onFail: "fail" },
1127
+ },
1128
+ },
1129
+ },
1130
+ },
1131
+ source: "project",
1132
+ filePath: path.join(tmpDir, "test.project.yaml"),
1133
+ };
1134
+
1135
+ const result = await executeWorkflow(spec, {}, {
1136
+ ctx: mockCtx(tmpDir),
1137
+ pi: mockPi(),
1138
+ loadAgent: () => ({ name: "default" }),
1139
+ });
1140
+
1141
+ assert.strictEqual(result.steps.both.status, "failed");
1142
+ assert.strictEqual(result.status, "failed");
1143
+
1144
+ fs.rmSync(tmpDir, { recursive: true });
1145
+ });
1146
+ });
1147
+
1148
+ describe("onExhausted error handling", () => {
1149
+ it("records expression error in onExhausted result", async () => {
1150
+ const spec = makeSpec({
1151
+ steps: {
1152
+ retry: {
1153
+ loop: {
1154
+ maxAttempts: 1,
1155
+ steps: {
1156
+ check: {
1157
+ gate: { check: "exit 1", onFail: "continue" },
1158
+ },
1159
+ },
1160
+ onExhausted: {
1161
+ agent: "default",
1162
+ input: {
1163
+ // Reference a non-existent step to trigger expression error
1164
+ bad: "${{ steps.nonexistent.required_field }}",
1165
+ },
1166
+ },
1167
+ },
1168
+ },
1169
+ },
1170
+ });
1171
+
1172
+ const result = await executeWorkflow(spec, {}, defaultOptions());
1173
+ const loopOutput = result.steps.retry.output;
1174
+ // The exhausted step should have run (agent) but with error noted
1175
+ assert.ok(loopOutput.lastIteration._exhausted);
1176
+ });
1177
+ });
1178
+
1179
+ describe("verify step as typed agent output", () => {
1180
+ it("mock dispatch returning verifier-output JSON produces typed step output", async () => {
1181
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-verify-"));
1182
+ const verifierOutput = {
1183
+ status: "passed",
1184
+ score: "2/2",
1185
+ truths: [
1186
+ { truth: "Tests pass", status: "verified", evidence: "exit code 0" },
1187
+ ],
1188
+ criteria_results: [
1189
+ { criterion: "Tests pass", verify_method: "command", status: "passed", evidence: "3 passing" },
1190
+ { criterion: "File exists", verify_method: "inspect", status: "passed", evidence: "found" },
1191
+ ],
1192
+ gaps: [],
1193
+ };
1194
+
1195
+ const spec: WorkflowSpec = {
1196
+ name: "test-verify",
1197
+ description: "test verify step",
1198
+ steps: {
1199
+ implement: {
1200
+ transform: {
1201
+ mapping: { code: "done", files: ["src/index.ts"] },
1202
+ },
1203
+ },
1204
+ verify: {
1205
+ transform: {
1206
+ mapping: verifierOutput,
1207
+ },
1208
+ },
1209
+ },
1210
+ source: "project",
1211
+ filePath: path.join(tmpDir, "test.project.yaml"),
1212
+ };
1213
+
1214
+ const result = await executeWorkflow(spec, {}, {
1215
+ ctx: mockCtx(tmpDir),
1216
+ pi: mockPi(),
1217
+ loadAgent: () => ({ name: "verifier" }),
1218
+ });
1219
+
1220
+ assert.strictEqual(result.status, "completed");
1221
+ assert.strictEqual(result.steps.verify.status, "completed");
1222
+ const output = result.steps.verify.output as any;
1223
+ assert.strictEqual(output.status, "passed");
1224
+ assert.strictEqual(output.score, "2/2");
1225
+ assert.strictEqual(output.truths.length, 1);
1226
+ assert.strictEqual(output.criteria_results.length, 2);
1227
+
1228
+ fs.rmSync(tmpDir, { recursive: true });
1229
+ });
1230
+
1231
+ it("downstream step reads verify output via expressions", async () => {
1232
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-verify-expr-"));
1233
+ const spec: WorkflowSpec = {
1234
+ name: "test-verify-expr",
1235
+ description: "test verify expression access",
1236
+ steps: {
1237
+ verify: {
1238
+ transform: {
1239
+ mapping: {
1240
+ status: "gaps_found",
1241
+ score: "1/3",
1242
+ truths: [{ truth: "Tests pass", status: "verified", evidence: "ok" }],
1243
+ criteria_results: [{ criterion: "C1", verify_method: "command", status: "passed", evidence: "ok" }],
1244
+ gaps: [{ truth: "Feature X", status: "failed", reason: "Not implemented" }],
1245
+ },
1246
+ },
1247
+ },
1248
+ react: {
1249
+ transform: {
1250
+ mapping: {
1251
+ verifyStatus: "${{ steps.verify.output.status }}",
1252
+ verifyGaps: "${{ steps.verify.output.gaps }}",
1253
+ gapCount: "${{ steps.verify.output.gaps.length }}",
1254
+ },
1255
+ },
1256
+ },
1257
+ },
1258
+ source: "project",
1259
+ filePath: path.join(tmpDir, "test.project.yaml"),
1260
+ };
1261
+
1262
+ const result = await executeWorkflow(spec, {}, {
1263
+ ctx: mockCtx(tmpDir),
1264
+ pi: mockPi(),
1265
+ loadAgent: () => ({ name: "default" }),
1266
+ });
1267
+
1268
+ assert.strictEqual(result.status, "completed");
1269
+ const reactOutput = result.steps.react.output as any;
1270
+ assert.strictEqual(reactOutput.verifyStatus, "gaps_found");
1271
+ assert.ok(Array.isArray(reactOutput.verifyGaps));
1272
+ assert.strictEqual(reactOutput.verifyGaps.length, 1);
1273
+ assert.strictEqual(reactOutput.verifyGaps[0].reason, "Not implemented");
1274
+ assert.strictEqual(reactOutput.gapCount, 1);
1275
+
1276
+ fs.rmSync(tmpDir, { recursive: true });
1277
+ });
1278
+ });
1279
+
1280
+ // ── Self-implement workflow integration tests ──
1281
+ // These use transforms to simulate agent outputs, avoiding real subprocess dispatch.
1282
+
1283
+ describe("self-implement workflow", () => {
1284
+ it("parses and executes with mock data — all steps complete, gate passes", async () => {
1285
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-self-impl-"));
1286
+ const planOutput = {
1287
+ plans: [
1288
+ {
1289
+ name: "step-foreach",
1290
+ intent: "Implement forEach step type",
1291
+ tasks: ["Create step-foreach.ts", "Add tests"],
1292
+ files_to_change: ["src/step-foreach.ts", "src/step-foreach.test.ts"],
1293
+ acceptance_criteria: ["forEach iterates array", "Tests pass"],
1294
+ context_needed: ["src/types.ts"],
1295
+ parallel_group: "a",
1296
+ },
1297
+ {
1298
+ name: "step-command",
1299
+ intent: "Implement command step type",
1300
+ tasks: ["Create step-command.ts", "Add tests"],
1301
+ files_to_change: ["src/step-command.ts", "src/step-command.test.ts"],
1302
+ acceptance_criteria: ["Command executes", "Tests pass"],
1303
+ context_needed: ["src/types.ts"],
1304
+ parallel_group: "a",
1305
+ },
1306
+ ],
1307
+ };
1308
+
1309
+ const execResult = {
1310
+ status: "complete",
1311
+ tasks: [{ name: "implement", status: "done", files_modified: ["src/step-foreach.ts"] }],
1312
+ decisions: [],
1313
+ issues: [],
1314
+ test_count: 5,
1315
+ commit_hash: "",
1316
+ };
1317
+
1318
+ const verifyOutput = {
1319
+ status: "passed",
1320
+ score: "3/3",
1321
+ truths: [{ truth: "All tests pass", status: "verified", evidence: "exit 0" }],
1322
+ criteria_results: [
1323
+ { criterion: "forEach works", verify_method: "command", status: "passed", evidence: "ok" },
1324
+ { criterion: "command works", verify_method: "command", status: "passed", evidence: "ok" },
1325
+ { criterion: "Tests pass", verify_method: "command", status: "passed", evidence: "3 passing" },
1326
+ ],
1327
+ gaps: [],
1328
+ };
1329
+
1330
+ // Build a spec that uses transforms to simulate agent outputs
1331
+ const spec: WorkflowSpec = {
1332
+ name: "self-implement",
1333
+ description: "mock self-implement",
1334
+ input: {
1335
+ type: "object",
1336
+ required: ["phaseSpec", "architecture", "conventions"],
1337
+ properties: {
1338
+ phaseSpec: { type: "object" },
1339
+ architecture: { type: "object" },
1340
+ conventions: { type: "object" },
1341
+ },
1342
+ },
1343
+ steps: {
1344
+ plan: {
1345
+ transform: { mapping: planOutput },
1346
+ },
1347
+ implement: {
1348
+ forEach: "${{ steps.plan.output.plans }}",
1349
+ as: "plan",
1350
+ transform: {
1351
+ mapping: execResult,
1352
+ },
1353
+ },
1354
+ verify: {
1355
+ transform: { mapping: verifyOutput },
1356
+ },
1357
+ check: {
1358
+ gate: {
1359
+ check: "echo '${{ steps.verify.output.status }}' | grep -q passed",
1360
+ onFail: "fail",
1361
+ },
1362
+ },
1363
+ },
1364
+ source: "project",
1365
+ filePath: path.join(tmpDir, "test.project.yaml"),
1366
+ };
1367
+
1368
+ const result = await executeWorkflow(spec, {
1369
+ phaseSpec: { name: "test-phase", success_criteria: [] },
1370
+ architecture: { modules: [] },
1371
+ conventions: { rules: [] },
1372
+ }, {
1373
+ ctx: mockCtx(tmpDir),
1374
+ pi: mockPi(),
1375
+ loadAgent: () => ({ name: "default" }),
1376
+ });
1377
+
1378
+ assert.strictEqual(result.status, "completed");
1379
+ assert.strictEqual(result.steps.plan.status, "completed");
1380
+ assert.strictEqual(result.steps.implement.status, "completed");
1381
+ assert.strictEqual(result.steps.verify.status, "completed");
1382
+ assert.strictEqual(result.steps.check.status, "completed");
1383
+
1384
+ // Gate passed
1385
+ const gateOutput = result.steps.check.output as { passed: boolean };
1386
+ assert.strictEqual(gateOutput.passed, true);
1387
+
1388
+ fs.rmSync(tmpDir, { recursive: true });
1389
+ });
1390
+
1391
+ it("fails gate when verification finds gaps", async () => {
1392
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-self-impl-fail-"));
1393
+ const planOutput = {
1394
+ plans: [
1395
+ {
1396
+ name: "plan-a",
1397
+ intent: "Do something",
1398
+ tasks: ["Task 1"],
1399
+ acceptance_criteria: ["Works"],
1400
+ },
1401
+ ],
1402
+ };
1403
+
1404
+ const execResult = {
1405
+ status: "complete",
1406
+ tasks: [{ name: "task-1", status: "done" }],
1407
+ decisions: [],
1408
+ issues: [],
1409
+ test_count: 1,
1410
+ commit_hash: "",
1411
+ };
1412
+
1413
+ const verifyOutput = {
1414
+ status: "gaps_found",
1415
+ score: "1/3",
1416
+ truths: [{ truth: "Feature missing", status: "failed", evidence: "not found" }],
1417
+ criteria_results: [
1418
+ { criterion: "Feature works", verify_method: "inspect", status: "failed", evidence: "missing" },
1419
+ ],
1420
+ gaps: [{ truth: "Feature missing", status: "failed", reason: "Not implemented" }],
1421
+ };
1422
+
1423
+ const spec: WorkflowSpec = {
1424
+ name: "self-implement-fail",
1425
+ description: "mock self-implement with gaps",
1426
+ steps: {
1427
+ plan: {
1428
+ transform: { mapping: planOutput },
1429
+ },
1430
+ implement: {
1431
+ forEach: "${{ steps.plan.output.plans }}",
1432
+ as: "plan",
1433
+ transform: { mapping: execResult },
1434
+ },
1435
+ verify: {
1436
+ transform: { mapping: verifyOutput },
1437
+ },
1438
+ check: {
1439
+ gate: {
1440
+ check: "echo '${{ steps.verify.output.status }}' | grep -q passed",
1441
+ onFail: "fail",
1442
+ },
1443
+ },
1444
+ },
1445
+ source: "project",
1446
+ filePath: path.join(tmpDir, "test.project.yaml"),
1447
+ };
1448
+
1449
+ const result = await executeWorkflow(spec, {}, {
1450
+ ctx: mockCtx(tmpDir),
1451
+ pi: mockPi(),
1452
+ loadAgent: () => ({ name: "default" }),
1453
+ });
1454
+
1455
+ assert.strictEqual(result.status, "failed");
1456
+ assert.strictEqual(result.steps.check.status, "failed");
1457
+ const gateOutput = result.steps.check.output as { passed: boolean };
1458
+ assert.strictEqual(gateOutput.passed, false);
1459
+
1460
+ fs.rmSync(tmpDir, { recursive: true });
1461
+ });
1462
+
1463
+ it("forEach iterates over plans from decomposition", async () => {
1464
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-self-impl-foreach-"));
1465
+ const planOutput = {
1466
+ plans: [
1467
+ { name: "plan-alpha", intent: "Alpha work", tasks: ["A1", "A2"], acceptance_criteria: ["Alpha done"] },
1468
+ { name: "plan-beta", intent: "Beta work", tasks: ["B1"], acceptance_criteria: ["Beta done"] },
1469
+ { name: "plan-gamma", intent: "Gamma work", tasks: ["G1"], acceptance_criteria: ["Gamma done"] },
1470
+ ],
1471
+ };
1472
+
1473
+ const spec: WorkflowSpec = {
1474
+ name: "self-implement-foreach",
1475
+ description: "test forEach plan iteration",
1476
+ steps: {
1477
+ plan: {
1478
+ transform: { mapping: planOutput },
1479
+ },
1480
+ implement: {
1481
+ forEach: "${{ steps.plan.output.plans }}",
1482
+ as: "plan",
1483
+ transform: {
1484
+ mapping: {
1485
+ planName: "${{ plan.name }}",
1486
+ planIntent: "${{ plan.intent }}",
1487
+ },
1488
+ },
1489
+ },
1490
+ },
1491
+ source: "project",
1492
+ filePath: path.join(tmpDir, "test.project.yaml"),
1493
+ };
1494
+
1495
+ const result = await executeWorkflow(spec, {}, {
1496
+ ctx: mockCtx(tmpDir),
1497
+ pi: mockPi(),
1498
+ loadAgent: () => ({ name: "default" }),
1499
+ });
1500
+
1501
+ assert.strictEqual(result.status, "completed");
1502
+ assert.strictEqual(result.steps.implement.status, "completed");
1503
+ const output = result.steps.implement.output as any[];
1504
+ assert.strictEqual(output.length, 3);
1505
+ assert.strictEqual(output[0].planName, "plan-alpha");
1506
+ assert.strictEqual(output[0].planIntent, "Alpha work");
1507
+ assert.strictEqual(output[1].planName, "plan-beta");
1508
+ assert.strictEqual(output[2].planName, "plan-gamma");
1509
+
1510
+ fs.rmSync(tmpDir, { recursive: true });
1511
+ });
1512
+ });
1513
+
1514
+ describe("kill grace period constant", () => {
1515
+ it("uses SIGKILL_GRACE_MS (not hardcoded magic numbers)", async () => {
1516
+ // This is a static check — grep the source for hardcoded kill timeouts
1517
+ const { readFileSync } = await import("node:fs");
1518
+ const executorSrc = readFileSync(new URL("./workflow-executor.ts", import.meta.url), "utf-8");
1519
+ const dispatchSrc = readFileSync(new URL("./dispatch.ts", import.meta.url), "utf-8");
1520
+
1521
+ // Verify SIGKILL_GRACE_MS is defined
1522
+ assert.ok(executorSrc.includes("SIGKILL_GRACE_MS"));
1523
+ assert.ok(dispatchSrc.includes("SIGKILL_GRACE_MS"));
1524
+
1525
+ // Verify no remaining hardcoded kill timeouts (2000, 3000, 5000 near SIGKILL)
1526
+ const hardcodedPattern = /setTimeout.*(?:2000|5000).*SIGKILL|SIGKILL.*(?:2000|5000)/;
1527
+ assert.ok(!hardcodedPattern.test(executorSrc), "workflow-executor.ts still has hardcoded kill timeout");
1528
+ assert.ok(!hardcodedPattern.test(dispatchSrc), "dispatch.ts still has hardcoded kill timeout");
1529
+ });
1530
+ });