@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,237 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { findIncompleteRun, validateResumeCompatibility, computeResumePoint, formatIncompleteRun } from "./checkpoint.ts";
7
+ import { writeState } from "./state.ts";
8
+ import type { ExecutionState } from "./types.ts";
9
+ import { makeSpec } from "./test-helpers.ts";
10
+
11
+ describe("findIncompleteRun", () => {
12
+ it("returns null when no runs directory exists", () => {
13
+ const result = findIncompleteRun("/nonexistent", "test-workflow");
14
+ assert.strictEqual(result, null);
15
+ });
16
+
17
+ it("returns null when all runs are completed", (t) => {
18
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ckpt-"));
19
+ t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
20
+
21
+ const runDir = path.join(tmpDir, ".pi", "workflow-runs", "test", "runs", "test-20260314-120000-abcd");
22
+ fs.mkdirSync(runDir, { recursive: true });
23
+ writeState(runDir, { input: {}, steps: {}, status: "completed" });
24
+
25
+ const result = findIncompleteRun(tmpDir, "test");
26
+ assert.strictEqual(result, null);
27
+ });
28
+
29
+ it("finds a run with status 'running' (interrupted)", (t) => {
30
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ckpt-"));
31
+ t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
32
+
33
+ const runDir = path.join(tmpDir, ".pi", "workflow-runs", "test", "runs", "test-20260314-120000-abcd");
34
+ fs.mkdirSync(runDir, { recursive: true });
35
+
36
+ const state: ExecutionState = {
37
+ input: { path: "/src" },
38
+ steps: {
39
+ explore: {
40
+ step: "explore", agent: "explorer", status: "completed",
41
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 1 },
42
+ durationMs: 1000, textOutput: "found stuff",
43
+ },
44
+ },
45
+ status: "running",
46
+ };
47
+ writeState(runDir, state);
48
+
49
+ const result = findIncompleteRun(tmpDir, "test");
50
+ assert.ok(result);
51
+ assert.strictEqual(result.runId, "test-20260314-120000-abcd");
52
+ assert.deepStrictEqual(result.completedSteps, ["explore"]);
53
+ assert.strictEqual(result.failedStep, undefined);
54
+ });
55
+
56
+ it("finds a run with status 'failed'", (t) => {
57
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ckpt-"));
58
+ t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
59
+
60
+ const runDir = path.join(tmpDir, ".pi", "workflow-runs", "test", "runs", "test-20260314-120000-abcd");
61
+ fs.mkdirSync(runDir, { recursive: true });
62
+
63
+ const state: ExecutionState = {
64
+ input: {},
65
+ steps: {
66
+ step1: {
67
+ step: "step1", agent: "a", status: "completed",
68
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
69
+ durationMs: 0,
70
+ },
71
+ step2: {
72
+ step: "step2", agent: "b", status: "failed",
73
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
74
+ durationMs: 0, error: "timeout",
75
+ },
76
+ },
77
+ status: "failed",
78
+ };
79
+ writeState(runDir, state);
80
+
81
+ const result = findIncompleteRun(tmpDir, "test");
82
+ assert.ok(result);
83
+ assert.deepStrictEqual(result.completedSteps, ["step1"]);
84
+ assert.strictEqual(result.failedStep, "step2");
85
+ });
86
+
87
+ it("returns most recent incomplete run", (t) => {
88
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-ckpt-"));
89
+ t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
90
+
91
+ const runsBase = path.join(tmpDir, ".pi", "workflow-runs", "test", "runs");
92
+
93
+ // Older run (completed)
94
+ const oldDir = path.join(runsBase, "test-20260314-100000-aaaa");
95
+ fs.mkdirSync(oldDir, { recursive: true });
96
+ writeState(oldDir, { input: {}, steps: {}, status: "completed" });
97
+
98
+ // Newer run (failed)
99
+ const newDir = path.join(runsBase, "test-20260314-120000-bbbb");
100
+ fs.mkdirSync(newDir, { recursive: true });
101
+ writeState(newDir, { input: {}, steps: {}, status: "failed" });
102
+
103
+ const result = findIncompleteRun(tmpDir, "test");
104
+ assert.ok(result);
105
+ assert.strictEqual(result.runId, "test-20260314-120000-bbbb");
106
+ });
107
+ });
108
+
109
+ describe("validateResumeCompatibility", () => {
110
+ it("returns null when compatible", () => {
111
+ const state: ExecutionState = {
112
+ input: {},
113
+ steps: {
114
+ step1: {
115
+ step: "step1", agent: "a", status: "completed",
116
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
117
+ durationMs: 0,
118
+ },
119
+ },
120
+ status: "failed",
121
+ specVersion: "1",
122
+ };
123
+ const spec = makeSpec({ version: "1", steps: { step1: { agent: "a" }, step2: { agent: "b" } } });
124
+ assert.strictEqual(validateResumeCompatibility(state, spec), null);
125
+ });
126
+
127
+ it("rejects version mismatch", () => {
128
+ const state: ExecutionState = {
129
+ input: {},
130
+ steps: {},
131
+ status: "failed",
132
+ specVersion: "1",
133
+ };
134
+ const spec = makeSpec({ version: "2", steps: { step1: { agent: "a" } } });
135
+ const msg = validateResumeCompatibility(state, spec);
136
+ assert.ok(msg?.includes("version"));
137
+ });
138
+
139
+ it("rejects when completed step no longer exists in spec", () => {
140
+ const state: ExecutionState = {
141
+ input: {},
142
+ steps: {
143
+ removed_step: {
144
+ step: "removed_step", agent: "a", status: "completed",
145
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
146
+ durationMs: 0,
147
+ },
148
+ },
149
+ status: "failed",
150
+ };
151
+ const spec = makeSpec({ steps: { different_step: { agent: "a" } } });
152
+ const msg = validateResumeCompatibility(state, spec);
153
+ assert.ok(msg?.includes("removed_step"));
154
+ });
155
+ });
156
+
157
+ describe("computeResumePoint", () => {
158
+ it("returns first layer with pending steps", () => {
159
+ const plan = [
160
+ { steps: ["a"] },
161
+ { steps: ["b", "c"] },
162
+ { steps: ["d"] },
163
+ ];
164
+ const completed = new Set(["a"]);
165
+ const result = computeResumePoint(plan, completed);
166
+ assert.ok(result);
167
+ assert.strictEqual(result.resumeLayerIndex, 1);
168
+ assert.deepStrictEqual(result.pendingStepsInLayer, ["b", "c"]);
169
+ });
170
+
171
+ it("returns only pending steps in a partially completed layer", () => {
172
+ const plan = [
173
+ { steps: ["a"] },
174
+ { steps: ["b", "c", "d"] },
175
+ { steps: ["e"] },
176
+ ];
177
+ const completed = new Set(["a", "b", "d"]);
178
+ const result = computeResumePoint(plan, completed);
179
+ assert.ok(result);
180
+ assert.strictEqual(result.resumeLayerIndex, 1);
181
+ assert.deepStrictEqual(result.pendingStepsInLayer, ["c"]);
182
+ });
183
+
184
+ it("returns null when all steps are completed", () => {
185
+ const plan = [
186
+ { steps: ["a"] },
187
+ { steps: ["b"] },
188
+ ];
189
+ const completed = new Set(["a", "b"]);
190
+ const result = computeResumePoint(plan, completed);
191
+ assert.strictEqual(result, null);
192
+ });
193
+
194
+ it("handles first layer being the resume point", () => {
195
+ const plan = [
196
+ { steps: ["a", "b"] },
197
+ { steps: ["c"] },
198
+ ];
199
+ const completed = new Set<string>();
200
+ const result = computeResumePoint(plan, completed);
201
+ assert.ok(result);
202
+ assert.strictEqual(result.resumeLayerIndex, 0);
203
+ assert.deepStrictEqual(result.pendingStepsInLayer, ["a", "b"]);
204
+ });
205
+ });
206
+
207
+ describe("formatIncompleteRun", () => {
208
+ it("formats an interrupted run", () => {
209
+ const run = {
210
+ runId: "test-20260314-120000-abcd",
211
+ runDir: "/tmp/runs/test-20260314-120000-abcd",
212
+ state: { input: {}, steps: {}, status: "running" as const },
213
+ completedSteps: ["explore", "analyze"],
214
+ updatedAt: "2026-03-14T12:00:00.000Z",
215
+ };
216
+ const spec = makeSpec({ steps: { explore: { agent: "a" }, analyze: { agent: "b" }, synthesize: { agent: "c" } } });
217
+ const msg = formatIncompleteRun(run, spec);
218
+ assert.ok(msg.includes("interrupted"));
219
+ assert.ok(msg.includes("2/3"));
220
+ });
221
+
222
+ it("formats a failed run with step name", () => {
223
+ const run = {
224
+ runId: "test-20260314-120000-abcd",
225
+ runDir: "/tmp/runs/test-20260314-120000-abcd",
226
+ state: { input: {}, steps: {}, status: "failed" as const },
227
+ completedSteps: ["explore"],
228
+ failedStep: "analyze",
229
+ updatedAt: "2026-03-14T12:00:00.000Z",
230
+ };
231
+ const spec = makeSpec({ steps: { explore: { agent: "a" }, analyze: { agent: "b" }, synthesize: { agent: "c" } } });
232
+ const msg = formatIncompleteRun(run, spec);
233
+ assert.ok(msg.includes("failed"));
234
+ assert.ok(msg.includes("analyze"));
235
+ assert.ok(msg.includes("1/3"));
236
+ });
237
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Checkpoint and resume logic for workflow runs.
3
+ *
4
+ * Finds incomplete runs, validates they match the current spec,
5
+ * and provides state needed to resume execution.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import type { ExecutionState, WorkflowSpec } from "./types.ts";
10
+ import { readState } from "./state.ts";
11
+
12
+ export interface IncompleteRun {
13
+ runId: string;
14
+ runDir: string;
15
+ state: ExecutionState;
16
+ completedSteps: string[]; // step names with status completed or skipped
17
+ failedStep?: string; // name of the step that failed (if any)
18
+ updatedAt?: string; // last update timestamp
19
+ }
20
+
21
+ /**
22
+ * Find the most recent incomplete run for a workflow.
23
+ *
24
+ * Scans .pi/workflow-runs/<name>/runs/ for state.json files
25
+ * with status "running" or "failed". Returns the most recent one
26
+ * (by directory name, which encodes timestamp).
27
+ *
28
+ * Returns null if no incomplete runs exist.
29
+ */
30
+ export function findIncompleteRun(cwd: string, workflowName: string): IncompleteRun | null {
31
+ const runsDir = path.join(cwd, ".pi", "workflow-runs", workflowName, "runs");
32
+ if (!fs.existsSync(runsDir)) return null;
33
+
34
+ const entries = fs.readdirSync(runsDir)
35
+ .filter(e => {
36
+ const stat = fs.statSync(path.join(runsDir, e));
37
+ return stat.isDirectory();
38
+ })
39
+ .sort() // lexicographic — timestamp-based IDs sort chronologically
40
+ .reverse(); // most recent first
41
+
42
+ for (const runId of entries) {
43
+ const runDir = path.join(runsDir, runId);
44
+ const state = readState(runDir);
45
+ if (!state) continue;
46
+
47
+ if (state.status === "running" || state.status === "failed" || state.status === "paused") {
48
+ const completedSteps = Object.entries(state.steps)
49
+ .filter(([, r]) => r.status === "completed" || r.status === "skipped")
50
+ .map(([name]) => name);
51
+
52
+ const failedStep = Object.entries(state.steps)
53
+ .find(([, r]) => r.status === "failed")
54
+ ?.[0];
55
+
56
+ return {
57
+ runId,
58
+ runDir,
59
+ state,
60
+ completedSteps,
61
+ failedStep,
62
+ updatedAt: state.updatedAt,
63
+ };
64
+ }
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Validate that a saved state is compatible with the current workflow spec.
72
+ *
73
+ * Checks:
74
+ * 1. Spec version matches (if both specify one)
75
+ * 2. All completed steps still exist in the spec
76
+ *
77
+ * Returns null if compatible, or a string describing the incompatibility.
78
+ */
79
+ export function validateResumeCompatibility(
80
+ state: ExecutionState,
81
+ spec: WorkflowSpec,
82
+ ): string | null {
83
+ // Check version match
84
+ if (state.specVersion && spec.version && state.specVersion !== spec.version) {
85
+ return `Spec version changed: run has '${state.specVersion}', current is '${spec.version}'`;
86
+ }
87
+
88
+ // Check all completed steps still exist in spec
89
+ const specStepNames = new Set(Object.keys(spec.steps));
90
+ for (const stepName of Object.keys(state.steps)) {
91
+ if (!specStepNames.has(stepName)) {
92
+ return `Step '${stepName}' was completed in the saved run but no longer exists in the workflow spec`;
93
+ }
94
+ }
95
+
96
+ return null; // compatible
97
+ }
98
+
99
+ /**
100
+ * Determine which steps need to run when resuming.
101
+ *
102
+ * Given the execution plan (layers of step names) and the set of
103
+ * already-completed steps, returns the layer index to resume from
104
+ * and which steps within that layer still need to run.
105
+ *
106
+ * For a partially-complete parallel layer (some steps done, some not),
107
+ * returns only the incomplete steps from that layer.
108
+ */
109
+ export function computeResumePoint(
110
+ plan: Array<{ steps: string[] }>,
111
+ completedSteps: Set<string>,
112
+ ): { resumeLayerIndex: number; pendingStepsInLayer: string[] } | null {
113
+ for (let i = 0; i < plan.length; i++) {
114
+ const layer = plan[i];
115
+ const pending = layer.steps.filter(s => !completedSteps.has(s));
116
+
117
+ if (pending.length > 0) {
118
+ return { resumeLayerIndex: i, pendingStepsInLayer: pending };
119
+ }
120
+ }
121
+
122
+ // All steps completed — nothing to resume
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * Format a human-readable summary of an incomplete run for display.
128
+ */
129
+ export function formatIncompleteRun(run: IncompleteRun, spec: WorkflowSpec): string {
130
+ const totalSteps = Object.keys(spec.steps).length;
131
+ const completed = run.completedSteps.length;
132
+ const status = run.state.status === "paused" ? "paused"
133
+ : run.state.status === "running" ? "interrupted"
134
+ : "failed";
135
+ const failedInfo = run.failedStep ? ` at step '${run.failedStep}'` : "";
136
+ const timeInfo = run.updatedAt ? ` (last updated: ${run.updatedAt})` : "";
137
+
138
+ return `Found ${status} run${failedInfo}: ${completed}/${totalSteps} steps completed${timeInfo}. Run ID: ${run.runId}`;
139
+ }
@@ -0,0 +1,143 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import { resolveCompletion } from "./completion.ts";
4
+ import type { CompletionSpec, WorkflowResult, StepResult, StepUsage } from "./types.ts";
5
+
6
+ const usage: StepUsage = { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0, cost: 0.03, turns: 2 };
7
+
8
+ const stepResult: StepResult = {
9
+ step: "summarize",
10
+ agent: "summarizer",
11
+ status: "completed",
12
+ output: { headline: "Found 3 issues" },
13
+ textOutput: "The codebase has 3 critical issues.",
14
+ usage,
15
+ durationMs: 45000,
16
+ };
17
+
18
+ const result: WorkflowResult = {
19
+ workflow: "explore-summarize",
20
+ runId: "explore-summarize-20260313-120000-abcd",
21
+ status: "completed",
22
+ steps: { summarize: stepResult },
23
+ output: { headline: "Found 3 issues" },
24
+ totalUsage: { input: 2000, output: 1000, cacheRead: 0, cacheWrite: 0, cost: 0.05, turns: 4 },
25
+ totalDurationMs: 92000,
26
+ runDir: "/tmp/runs/explore-summarize-20260313-120000-abcd",
27
+ };
28
+
29
+ const input = { path: "/src", question: "What are the main components?" };
30
+
31
+ describe("resolveCompletion", () => {
32
+ describe("template form", () => {
33
+ it("resolves expressions in template", () => {
34
+ const spec: CompletionSpec = {
35
+ template: "## Results\n\n${{ steps.summarize.textOutput }}\n\nDone in ${{ totalDurationMs | duration }}.",
36
+ };
37
+ const content = resolveCompletion(spec, result, input);
38
+ assert.ok(content.includes("## Results"));
39
+ assert.ok(content.includes("The codebase has 3 critical issues."));
40
+ assert.ok(content.includes("Done in 1m32s."));
41
+ });
42
+
43
+ it("resolves input and workflow metadata in template", () => {
44
+ const spec: CompletionSpec = {
45
+ template: "Explored ${{ input.path }} for workflow '${{ workflow }}'. Status: ${{ status }}.",
46
+ };
47
+ const content = resolveCompletion(spec, result, input);
48
+ assert.strictEqual(content, "Explored /src for workflow 'explore-summarize'. Status: completed.");
49
+ });
50
+
51
+ it("applies currency filter in template", () => {
52
+ const spec: CompletionSpec = {
53
+ template: "Cost: ${{ totalUsage.cost | currency }}",
54
+ };
55
+ const content = resolveCompletion(spec, result, input);
56
+ assert.strictEqual(content, "Cost: $0.05");
57
+ });
58
+
59
+ it("includes runDir in template", () => {
60
+ const spec: CompletionSpec = {
61
+ template: "Logs at: ${{ runDir }}/sessions/",
62
+ };
63
+ const content = resolveCompletion(spec, result, input);
64
+ assert.strictEqual(content, "Logs at: /tmp/runs/explore-summarize-20260313-120000-abcd/sessions/");
65
+ });
66
+
67
+ it("renders undefined as empty in template", () => {
68
+ const spec: CompletionSpec = {
69
+ template: "Answer: ${{ input.nonexistent }}",
70
+ };
71
+ const content = resolveCompletion(spec, result, input);
72
+ assert.strictEqual(content, "Answer: ");
73
+ });
74
+ });
75
+
76
+ describe("message form", () => {
77
+ it("resolves message with no include", () => {
78
+ const spec: CompletionSpec = {
79
+ message: "Present the findings to the user. The workflow '${{ workflow }}' is done.",
80
+ };
81
+ const content = resolveCompletion(spec, result, input);
82
+ assert.ok(content.includes("Present the findings to the user."));
83
+ assert.ok(content.includes("'explore-summarize' is done."));
84
+ });
85
+
86
+ it("resolves message with include paths", () => {
87
+ const spec: CompletionSpec = {
88
+ message: "Show the user these results.",
89
+ include: ["steps.summarize.textOutput", "totalUsage"],
90
+ };
91
+ const content = resolveCompletion(spec, result, input);
92
+ assert.ok(content.includes("Show the user these results."));
93
+ assert.ok(content.includes("steps.summarize.textOutput"));
94
+ assert.ok(content.includes("The codebase has 3 critical issues."));
95
+ assert.ok(content.includes("totalUsage"));
96
+ assert.ok(content.includes('"cost"'));
97
+ });
98
+
99
+ it("formats included objects as JSON blocks", () => {
100
+ const spec: CompletionSpec = {
101
+ message: "Review:",
102
+ include: ["steps.summarize.output"],
103
+ };
104
+ const content = resolveCompletion(spec, result, input);
105
+ assert.ok(content.includes("```json"));
106
+ assert.ok(content.includes('"headline"'));
107
+ });
108
+
109
+ it("formats included primitives as plain text", () => {
110
+ const spec: CompletionSpec = {
111
+ message: "Data:",
112
+ include: ["steps.summarize.textOutput"],
113
+ };
114
+ const content = resolveCompletion(spec, result, input);
115
+ assert.ok(content.includes("The codebase has 3 critical issues."));
116
+ // Primitive values should NOT be in JSON blocks
117
+ assert.ok(!content.includes("```json\nThe codebase"));
118
+ });
119
+ });
120
+
121
+ describe("error handling", () => {
122
+ it("throws on invalid expression in template", () => {
123
+ const spec: CompletionSpec = {
124
+ template: "Bad: ${{ steps.nonexistent.output }}",
125
+ };
126
+ assert.throws(
127
+ () => resolveCompletion(spec, result, input),
128
+ (err: unknown) => err instanceof Error && err.message.includes("nonexistent"),
129
+ );
130
+ });
131
+
132
+ it("throws on invalid include path", () => {
133
+ const spec: CompletionSpec = {
134
+ message: "ok",
135
+ include: ["steps.nonexistent.output"],
136
+ };
137
+ assert.throws(
138
+ () => resolveCompletion(spec, result, input),
139
+ (err: unknown) => err instanceof Error && err.message.includes("nonexistent"),
140
+ );
141
+ });
142
+ });
143
+ });
@@ -0,0 +1,68 @@
1
+ import type { CompletionSpec, CompletionScope, WorkflowResult } from "./types.ts";
2
+ import { resolveExpression, resolveExpressions } from "./expression.ts";
3
+
4
+ /**
5
+ * Resolve a completion spec into the final message string injected into
6
+ * the main LLM conversation after workflow execution.
7
+ *
8
+ * Two forms:
9
+ * - Template: the template string is resolved with ${{ }} expressions
10
+ * and returned as the full content.
11
+ * - Message + include: the message is resolved, then each include path
12
+ * is resolved and appended as structured data.
13
+ *
14
+ * @param spec - the CompletionSpec from the workflow YAML
15
+ * @param result - the completed WorkflowResult
16
+ * @param executionInput - the original workflow input
17
+ * @returns the resolved message string
18
+ */
19
+ export function resolveCompletion(
20
+ spec: CompletionSpec,
21
+ result: WorkflowResult,
22
+ executionInput: unknown,
23
+ ): string {
24
+ const scope: CompletionScope = {
25
+ input: executionInput,
26
+ steps: result.steps,
27
+ totalUsage: result.totalUsage,
28
+ totalDurationMs: result.totalDurationMs,
29
+ runDir: result.runDir,
30
+ runId: result.runId,
31
+ workflow: result.workflow,
32
+ status: result.status,
33
+ output: result.output,
34
+ };
35
+
36
+ if (spec.template) {
37
+ const resolved = resolveExpressions(spec.template, scope);
38
+ return String(resolved ?? "");
39
+ }
40
+
41
+ // Message form
42
+ const parts: string[] = [];
43
+
44
+ if (spec.message) {
45
+ const resolvedMessage = resolveExpressions(spec.message, scope);
46
+ parts.push(String(resolvedMessage ?? ""));
47
+ }
48
+
49
+ if (spec.include && spec.include.length > 0) {
50
+ const included: Record<string, unknown> = {};
51
+ for (const path of spec.include) {
52
+ const value = resolveExpression(path, scope);
53
+ included[path] = value;
54
+ }
55
+
56
+ parts.push("");
57
+ parts.push("---");
58
+ for (const [path, value] of Object.entries(included)) {
59
+ if (value !== null && typeof value === "object") {
60
+ parts.push(`\n### ${path}\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``);
61
+ } else {
62
+ parts.push(`\n### ${path}\n${String(value ?? "")}`);
63
+ }
64
+ }
65
+ }
66
+
67
+ return parts.join("\n");
68
+ }