@davidorex/pi-workflows 0.2.0 → 0.3.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 (215) 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 +172 -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 +675 -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 +156 -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 +57 -0
  84. package/dist/step-shared.d.ts.map +1 -0
  85. package/dist/step-shared.js +125 -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 +164 -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 +726 -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 +438 -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 +464 -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 +84 -0
  140. package/skills/pi-workflows/SKILL.md +247 -0
  141. package/templates/architecture-designer/task.md +117 -0
  142. package/templates/architecture-inferrer/task.md +61 -0
  143. package/templates/gap-identifier/task.md +103 -0
  144. package/templates/handoff-writer/task.md +91 -0
  145. package/templates/plan-creator/task.md +143 -0
  146. package/templates/project-definer/task.md +67 -0
  147. package/templates/project-inferrer/task.md +56 -0
  148. package/templates/requirements-gatherer/task.md +90 -0
  149. package/workflows/analyze-existing-project.workflow.yaml +74 -0
  150. package/workflows/create-handoff.workflow.yaml +58 -0
  151. package/workflows/create-phase.workflow.yaml +1 -1
  152. package/workflows/do-gap.workflow.yaml +4 -4
  153. package/workflows/fix-audit.workflow.yaml +1 -1
  154. package/workflows/gap-to-phase.workflow.yaml +1 -1
  155. package/workflows/init-new-project.workflow.yaml +55 -0
  156. package/workflows/plan-from-requirements.workflow.yaml +51 -0
  157. package/workflows/self-implement.workflow.yaml +3 -3
  158. package/workflows/typed-analysis.workflow.yaml +4 -4
  159. package/src/agent-spec.test.ts +0 -289
  160. package/src/agent-spec.ts +0 -122
  161. package/src/block-validation.test.ts +0 -350
  162. package/src/checkpoint.test.ts +0 -237
  163. package/src/checkpoint.ts +0 -140
  164. package/src/completion.test.ts +0 -143
  165. package/src/completion.ts +0 -68
  166. package/src/dag.test.ts +0 -350
  167. package/src/dag.ts +0 -219
  168. package/src/dispatch.test.ts +0 -475
  169. package/src/dispatch.ts +0 -353
  170. package/src/expression.test.ts +0 -353
  171. package/src/expression.ts +0 -332
  172. package/src/format.test.ts +0 -80
  173. package/src/format.ts +0 -41
  174. package/src/graduated-failure.test.ts +0 -556
  175. package/src/index.test.ts +0 -114
  176. package/src/index.ts +0 -516
  177. package/src/loop.test.ts +0 -551
  178. package/src/output.test.ts +0 -213
  179. package/src/output.ts +0 -70
  180. package/src/parallel-integration.test.ts +0 -175
  181. package/src/resume.test.ts +0 -192
  182. package/src/state.test.ts +0 -192
  183. package/src/state.ts +0 -254
  184. package/src/step-agent.test.ts +0 -327
  185. package/src/step-agent.ts +0 -178
  186. package/src/step-command.test.ts +0 -330
  187. package/src/step-command.ts +0 -132
  188. package/src/step-foreach.test.ts +0 -647
  189. package/src/step-foreach.ts +0 -148
  190. package/src/step-gate.test.ts +0 -185
  191. package/src/step-gate.ts +0 -116
  192. package/src/step-loop.test.ts +0 -626
  193. package/src/step-loop.ts +0 -323
  194. package/src/step-parallel.test.ts +0 -475
  195. package/src/step-parallel.ts +0 -168
  196. package/src/step-pause.test.ts +0 -30
  197. package/src/step-shared.test.ts +0 -355
  198. package/src/step-shared.ts +0 -157
  199. package/src/step-transform.test.ts +0 -155
  200. package/src/step-transform.ts +0 -47
  201. package/src/template-integration.test.ts +0 -72
  202. package/src/template.test.ts +0 -162
  203. package/src/test-helpers.ts +0 -51
  204. package/src/tui.test.ts +0 -355
  205. package/src/tui.ts +0 -182
  206. package/src/types.ts +0 -195
  207. package/src/verifier-schema.test.ts +0 -226
  208. package/src/workflow-discovery.test.ts +0 -105
  209. package/src/workflow-discovery.ts +0 -114
  210. package/src/workflow-executor.test.ts +0 -1532
  211. package/src/workflow-executor.ts +0 -810
  212. package/src/workflow-sdk.test.ts +0 -238
  213. package/src/workflow-sdk.ts +0 -262
  214. package/src/workflow-spec.test.ts +0 -576
  215. package/src/workflow-spec.ts +0 -471
package/src/dispatch.ts DELETED
@@ -1,353 +0,0 @@
1
- /**
2
- * Subprocess dispatch for workflow steps — spawns pi in JSON mode,
3
- * collects events, usage, and output. Supports cancellation and timeout.
4
- */
5
- import { spawn } from "node:child_process";
6
- import fs from "node:fs";
7
- import path from "node:path";
8
- import os from "node:os";
9
- import type { StepSpec, StepResult, StepUsage, AgentSpec } from "./types.ts";
10
- import { SIGKILL_GRACE_MS } from "./step-shared.ts";
11
-
12
- export interface ModelConfig {
13
- default?: string;
14
- by_role?: Record<string, string>;
15
- }
16
-
17
- export interface DispatchOptions {
18
- cwd: string;
19
- sessionLogDir: string; // directory for session log file (e.g. <runDir>/sessions/)
20
- stepName: string; // used for naming the session log file
21
- signal?: AbortSignal; // for cancellation
22
- timeoutMs?: number; // timeout in milliseconds — kills subprocess after deadline
23
- onEvent?: (event: ProcessEvent) => void; // live event callback for TUI updates
24
- modelConfig?: ModelConfig; // project-level model assignments by role
25
- }
26
-
27
- export interface ProcessEvent {
28
- type: string;
29
- raw: unknown; // the parsed JSON line
30
- // Extracted fields for common events:
31
- toolName?: string; // for tool_execution_start/end
32
- toolArgs?: string; // preview string for tool_execution_start
33
- messageText?: string; // text content for message_end (assistant messages)
34
- usage?: Partial<StepUsage>; // usage delta for message_end
35
- }
36
-
37
- /**
38
- * Prompt length threshold (chars) for switching to @file argument passing.
39
- * Avoids OS-level argument length limits (typically 128–256 KB) with headroom
40
- * for the rest of the CLI args. 8000 chars is well under all limits while
41
- * keeping short prompts as direct args for simplicity.
42
- */
43
- const PROMPT_ARG_LIMIT = 8000;
44
-
45
- /** Maximum stdout buffer size (10 MB) to prevent OOM from runaway subprocesses. */
46
- const MAX_STDOUT_BYTES = 10 * 1024 * 1024;
47
-
48
- export function extractText(content: unknown): string {
49
- if (!content || !Array.isArray(content)) return "";
50
- for (const part of content) {
51
- if (part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part) {
52
- return String(part.text);
53
- }
54
- }
55
- return "";
56
- }
57
-
58
- export function extractToolArgsPreview(args: unknown): string {
59
- if (!args || typeof args !== "object") return "";
60
- const obj = args as Record<string, unknown>;
61
- for (const key of ["command", "path", "pattern", "query", "task"]) {
62
- if (typeof obj[key] === "string") {
63
- const val = obj[key] as string;
64
- return val.length > 60 ? val.slice(0, 57) + "..." : val;
65
- }
66
- }
67
- return "";
68
- }
69
-
70
- export function buildArgs(step: StepSpec, agentSpec: AgentSpec, prompt: string, options: DispatchOptions): string[] {
71
- const args = ["--mode", "json"];
72
-
73
- // Session log
74
- args.push("--session-dir", options.sessionLogDir);
75
-
76
- // Model: step > agent spec > model-config by role > model-config default
77
- const model = step.model ?? agentSpec.model
78
- ?? (agentSpec.role && options.modelConfig?.by_role?.[agentSpec.role])
79
- ?? options.modelConfig?.default;
80
- if (model) {
81
- const modelArg = `${model}:${agentSpec.thinking ?? "off"}`;
82
- args.push("--models", modelArg);
83
- }
84
-
85
- // Tool filtering
86
- if (agentSpec.tools?.length) {
87
- const builtinTools: string[] = [];
88
- const extensionPaths: string[] = [];
89
- for (const tool of agentSpec.tools) {
90
- if (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")) {
91
- extensionPaths.push(tool);
92
- } else {
93
- builtinTools.push(tool);
94
- }
95
- }
96
- if (builtinTools.length > 0) args.push("--tools", builtinTools.join(","));
97
- for (const ext of extensionPaths) args.push("--extension", ext);
98
- }
99
-
100
- // Extension scoping
101
- if (agentSpec.extensions !== undefined) {
102
- args.push("--no-extensions");
103
- for (const ext of agentSpec.extensions) args.push("--extension", ext);
104
- }
105
-
106
- // Skill scoping
107
- if (agentSpec.skills?.length) {
108
- args.push("--no-skills");
109
- // Skills are injected via --append-system-prompt, not CLI flags
110
- }
111
-
112
- // System prompt (if agent spec has one)
113
- // Write to temp file if present, pass via --append-system-prompt
114
- // (handled in dispatch() body, not buildArgs)
115
-
116
- // Prompt — use @file for long prompts
117
- args.push("-p");
118
- args.push(prompt); // or @<tmpfile> if prompt > 8000 chars
119
-
120
- return args;
121
- }
122
-
123
- /**
124
- * Spawn a pi subprocess for a workflow step and collect the result.
125
- *
126
- * Builds CLI args from the step spec and agent spec.
127
- * Streams stdout as newline-delimited JSON.
128
- * Collects messages, usage, timing.
129
- * Returns StepResult when the process exits.
130
- */
131
- export async function dispatch(
132
- step: StepSpec,
133
- agentSpec: AgentSpec,
134
- prompt: string,
135
- options: DispatchOptions,
136
- ): Promise<StepResult> {
137
- const startTime = Date.now();
138
- const args = buildArgs(step, agentSpec, prompt, options);
139
-
140
- // Handle long prompts: write to temp file
141
- let tmpDir: string | null = null;
142
- if (prompt.length > PROMPT_ARG_LIMIT) {
143
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-wf-"));
144
- const promptFile = path.join(tmpDir, "prompt.md");
145
- fs.writeFileSync(promptFile, prompt, { mode: 0o600 });
146
- // Replace last two args ("-p", prompt) with ("-p", "@<file>")
147
- args[args.length - 1] = `@${promptFile}`;
148
- }
149
-
150
- // Handle system prompt: write to temp file
151
- if (agentSpec.systemPrompt) {
152
- if (!tmpDir) tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-wf-"));
153
- const sysFile = path.join(tmpDir, "system.md");
154
- fs.writeFileSync(sysFile, agentSpec.systemPrompt, { mode: 0o600 });
155
- // Insert before -p flag
156
- const pIdx = args.indexOf("-p");
157
- args.splice(pIdx, 0, "--append-system-prompt", sysFile);
158
- }
159
-
160
- const proc = spawn("pi", args, {
161
- cwd: options.cwd,
162
- env: process.env,
163
- stdio: ["ignore", "pipe", "pipe"],
164
- });
165
-
166
- // Cancellation support
167
- if (options.signal) {
168
- const kill = () => {
169
- proc.kill("SIGTERM");
170
- setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, SIGKILL_GRACE_MS);
171
- };
172
- if (options.signal.aborted) kill();
173
- else options.signal.addEventListener("abort", kill, { once: true });
174
- }
175
-
176
- // Timeout support: SIGTERM after deadline, SIGKILL after 5s grace
177
- let timedOut = false;
178
- let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
179
- let killTimer: ReturnType<typeof setTimeout> | undefined;
180
- if (options.timeoutMs) {
181
- timeoutTimer = setTimeout(() => {
182
- timedOut = true;
183
- proc.kill("SIGTERM");
184
- killTimer = setTimeout(() => {
185
- if (!proc.killed) proc.kill("SIGKILL");
186
- }, SIGKILL_GRACE_MS);
187
- }, options.timeoutMs);
188
- }
189
-
190
- // Collect result
191
- const messages: unknown[] = [];
192
- const usage: StepUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
193
- let lastAssistantText = "";
194
- let stderrBuf = "";
195
-
196
- function processEvent(evt: { type?: string; message?: any; toolName?: string; args?: any }) {
197
- // Track messages
198
- if (evt.type === "message_end" && evt.message) {
199
- messages.push(evt.message);
200
- if (evt.message.role === "assistant") {
201
- usage.turns++;
202
- const u = evt.message.usage;
203
- if (u) {
204
- usage.input += u.input || 0;
205
- usage.output += u.output || 0;
206
- usage.cacheRead += u.cacheRead || 0;
207
- usage.cacheWrite += u.cacheWrite || 0;
208
- usage.cost += u.cost?.total || 0;
209
- }
210
- // Extract text from last assistant message
211
- const text = extractText(evt.message.content);
212
- if (text) lastAssistantText = text;
213
- }
214
- }
215
-
216
- // Forward to TUI callback
217
- if (options.onEvent) {
218
- options.onEvent({
219
- type: evt.type || "unknown",
220
- raw: evt,
221
- toolName: evt.type === "tool_execution_start" ? evt.toolName : undefined,
222
- toolArgs: evt.type === "tool_execution_start" ? extractToolArgsPreview(evt.args) : undefined,
223
- messageText: evt.type === "message_end" && evt.message?.role === "assistant"
224
- ? extractText(evt.message.content)
225
- : undefined,
226
- usage: evt.type === "message_end" && evt.message?.role === "assistant" && evt.message.usage
227
- ? {
228
- input: evt.message.usage.input,
229
- output: evt.message.usage.output,
230
- cost: evt.message.usage.cost?.total,
231
- }
232
- : undefined,
233
- });
234
- }
235
- }
236
-
237
- // Stream stdout as newline-delimited JSON
238
- let buf = "";
239
- let bufBytes = 0;
240
- let stdoutTruncated = false;
241
- proc.stdout!.on("data", (chunk: Buffer) => {
242
- bufBytes += chunk.length;
243
- if (bufBytes > MAX_STDOUT_BYTES) {
244
- if (!stdoutTruncated) {
245
- stdoutTruncated = true;
246
- // Process any remaining partial line in buf before discarding
247
- if (buf.trim()) {
248
- try {
249
- const evt = JSON.parse(buf);
250
- processEvent(evt);
251
- } catch {
252
- // incomplete JSON line — discard
253
- }
254
- buf = "";
255
- }
256
- }
257
- return;
258
- }
259
- buf += chunk.toString();
260
- const lines = buf.split("\n");
261
- buf = lines.pop() || "";
262
- for (const line of lines) {
263
- if (!line.trim()) continue;
264
- try {
265
- const evt = JSON.parse(line);
266
- processEvent(evt);
267
- } catch {
268
- // Skip unparseable lines
269
- }
270
- }
271
- });
272
-
273
- proc.stderr!.on("data", (chunk: Buffer) => {
274
- stderrBuf += chunk.toString();
275
- });
276
-
277
- // Wait for process to exit
278
- const exitCode = await new Promise<number | null>((resolve) => {
279
- proc.on("close", (code) => {
280
- // Process remaining buffer
281
- if (buf.trim()) {
282
- try {
283
- const evt = JSON.parse(buf);
284
- processEvent(evt);
285
- } catch {
286
- // Skip unparseable remainder
287
- }
288
- }
289
- resolve(code);
290
- });
291
- });
292
-
293
- // Clear timeout timers
294
- if (timeoutTimer) clearTimeout(timeoutTimer);
295
- if (killTimer) clearTimeout(killTimer);
296
-
297
- // Cleanup temp files
298
- if (tmpDir) {
299
- try {
300
- fs.rmSync(tmpDir, { recursive: true, force: true });
301
- } catch {
302
- // Best-effort cleanup
303
- }
304
- }
305
-
306
- // Timeout overrides exit code interpretation
307
- if (timedOut) {
308
- const warnings: string[] = [];
309
- if (stdoutTruncated) {
310
- warnings.push(
311
- `Stdout exceeded ${MAX_STDOUT_BYTES / (1024 * 1024)}MB limit — ` +
312
- `output stream was truncated at ${bufBytes} bytes. ` +
313
- `Usage counters (tokens, cost, turns) and textOutput may be incomplete.`
314
- );
315
- }
316
- return {
317
- step: options.stepName,
318
- agent: step.agent,
319
- status: "failed",
320
- output: undefined,
321
- textOutput: lastAssistantText,
322
- sessionLog: options.sessionLogDir,
323
- usage,
324
- durationMs: Date.now() - startTime,
325
- error: `Step timed out after ${options.timeoutMs! / 1000}s`,
326
- truncated: stdoutTruncated || undefined,
327
- warnings: warnings.length > 0 ? warnings : undefined,
328
- };
329
- }
330
-
331
- const warnings: string[] = [];
332
- if (stdoutTruncated) {
333
- warnings.push(
334
- `Stdout exceeded ${MAX_STDOUT_BYTES / (1024 * 1024)}MB limit — ` +
335
- `output stream was truncated at ${bufBytes} bytes. ` +
336
- `Usage counters (tokens, cost, turns) and textOutput may be incomplete.`
337
- );
338
- }
339
-
340
- return {
341
- step: options.stepName,
342
- agent: step.agent,
343
- status: exitCode === 0 ? "completed" : "failed",
344
- output: undefined, // structured output handled by caller (workflow-executor)
345
- textOutput: lastAssistantText,
346
- sessionLog: options.sessionLogDir,
347
- usage,
348
- durationMs: Date.now() - startTime,
349
- error: exitCode !== 0 ? (stderrBuf.trim() || "Process exited with code " + exitCode) : undefined,
350
- truncated: stdoutTruncated || undefined,
351
- warnings: warnings.length > 0 ? warnings : undefined,
352
- };
353
- }
@@ -1,353 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert";
3
- import { resolveExpressions, resolveExpression, evaluateCondition, ExpressionError } from "./expression.ts";
4
- import type { ExpressionScope, CompletionScope } from "./types.ts";
5
-
6
- const scope: ExpressionScope = {
7
- input: {
8
- description: "null pointer in login",
9
- maxAttempts: 3,
10
- tags: ["auth", "critical"],
11
- },
12
- steps: {
13
- diagnose: {
14
- step: "diagnose",
15
- agent: "diagnostician",
16
- status: "completed",
17
- output: {
18
- summary: "NPE in UserService.login()",
19
- rootCause: "session param is null",
20
- fixLocation: { file: "src/auth/user-service.ts", line: 47 },
21
- reproCommand: "npm test -- --grep login",
22
- },
23
- textOutput: "Found the bug...",
24
- usage: { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0, cost: 0.03, turns: 2 },
25
- durationMs: 42000,
26
- },
27
- },
28
- };
29
-
30
- describe("resolveExpression", () => {
31
- it("resolves input properties", () => {
32
- assert.strictEqual(resolveExpression("input.description", scope), "null pointer in login");
33
- });
34
-
35
- it("resolves input number", () => {
36
- assert.strictEqual(resolveExpression("input.maxAttempts", scope), 3);
37
- });
38
-
39
- it("resolves step output object", () => {
40
- const result = resolveExpression("steps.diagnose.output", scope);
41
- assert.strictEqual(typeof result, "object");
42
- assert.strictEqual((result as any).summary, "NPE in UserService.login()");
43
- });
44
-
45
- it("resolves nested step output", () => {
46
- assert.strictEqual(
47
- resolveExpression("steps.diagnose.output.fixLocation.file", scope),
48
- "src/auth/user-service.ts",
49
- );
50
- });
51
-
52
- it("resolves step metadata", () => {
53
- assert.strictEqual(resolveExpression("steps.diagnose.status", scope), "completed");
54
- assert.strictEqual(resolveExpression("steps.diagnose.usage.cost", scope), 0.03);
55
- });
56
-
57
- it("returns undefined for missing optional property on input", () => {
58
- assert.strictEqual(resolveExpression("input.nonexistent", scope), undefined);
59
- });
60
-
61
- it("returns undefined for missing optional property on step output", () => {
62
- assert.strictEqual(resolveExpression("steps.diagnose.output.missingField", scope), undefined);
63
- });
64
-
65
- it("throws on unexecuted step", () => {
66
- assert.throws(
67
- () => resolveExpression("steps.fix.output", scope),
68
- (err: unknown) => err instanceof ExpressionError && err.message.includes("fix"),
69
- );
70
- });
71
-
72
- it("throws on invalid root path", () => {
73
- assert.throws(
74
- () => resolveExpression("typo.something", scope),
75
- (err: unknown) => err instanceof ExpressionError,
76
- );
77
- });
78
- });
79
-
80
- // ── Filter tests ──
81
-
82
- describe("resolveExpression filters", () => {
83
- it("applies duration filter", () => {
84
- assert.strictEqual(
85
- resolveExpression("steps.diagnose.durationMs | duration", scope),
86
- "42s",
87
- );
88
- });
89
-
90
- it("applies currency filter", () => {
91
- assert.strictEqual(
92
- resolveExpression("steps.diagnose.usage.cost | currency", scope),
93
- "$0.03",
94
- );
95
- });
96
-
97
- it("applies json filter", () => {
98
- const result = resolveExpression("steps.diagnose.output.fixLocation | json", scope);
99
- assert.strictEqual(typeof result, "string");
100
- const parsed = JSON.parse(result as string);
101
- assert.strictEqual(parsed.file, "src/auth/user-service.ts");
102
- assert.strictEqual(parsed.line, 47);
103
- });
104
-
105
- it("applies length filter to array", () => {
106
- const result = resolveExpression("input.tags | length", scope);
107
- assert.strictEqual(result, 2);
108
- });
109
-
110
- it("applies length filter to string", () => {
111
- const result = resolveExpression("input.description | length", scope);
112
- assert.strictEqual(result, 21);
113
- });
114
-
115
- it("applies length filter to non-array/string returns 0", () => {
116
- const result = resolveExpression("input.maxAttempts | length", scope);
117
- assert.strictEqual(result, 0);
118
- });
119
-
120
- it("applies keys filter to object", () => {
121
- const result = resolveExpression("steps.diagnose.output.fixLocation | keys", scope);
122
- assert.ok(Array.isArray(result));
123
- assert.ok((result as string[]).includes("file"));
124
- assert.ok((result as string[]).includes("line"));
125
- });
126
-
127
- it("applies filter filter removes falsy values", () => {
128
- const scopeWithFalsy = {
129
- ...scope,
130
- input: { ...scope.input, items: [1, 0, null, "hello", "", false, true] },
131
- };
132
- const result = resolveExpression("input.items | filter", scopeWithFalsy);
133
- assert.deepStrictEqual(result, [1, "hello", true]);
134
- });
135
-
136
- it("handles whitespace around pipe", () => {
137
- assert.strictEqual(
138
- resolveExpression("steps.diagnose.durationMs | duration", scope),
139
- "42s",
140
- );
141
- });
142
-
143
- it("throws on unknown filter", () => {
144
- assert.throws(
145
- () => resolveExpression("steps.diagnose.durationMs | bogus", scope),
146
- (err: unknown) => err instanceof ExpressionError && err.message.includes("bogus"),
147
- );
148
- });
149
-
150
- it("applies filter in embedded expression", () => {
151
- const result = resolveExpressions(
152
- "Took ${{ steps.diagnose.durationMs | duration }}, cost ${{ steps.diagnose.usage.cost | currency }}",
153
- scope,
154
- );
155
- assert.strictEqual(result, "Took 42s, cost $0.03");
156
- });
157
-
158
- it("applies filter in whole-value expression", () => {
159
- const result = resolveExpressions("${{ steps.diagnose.durationMs | duration }}", scope);
160
- assert.strictEqual(result, "42s");
161
- });
162
- });
163
-
164
- // ── Wider scope (CompletionScope) tests ──
165
-
166
- describe("resolveExpression with CompletionScope", () => {
167
- const completionScope: CompletionScope = {
168
- input: { path: "/src" },
169
- steps: scope.steps,
170
- totalUsage: { input: 1500, output: 700, cacheRead: 0, cacheWrite: 0, cost: 0.05, turns: 3 },
171
- totalDurationMs: 92000,
172
- runDir: "/tmp/runs/test-run",
173
- runId: "test-20260313-120000-abcd",
174
- workflow: "explore-summarize",
175
- status: "completed",
176
- output: "Final summary text",
177
- };
178
-
179
- it("resolves root-level completion fields", () => {
180
- assert.strictEqual(resolveExpression("workflow", completionScope), "explore-summarize");
181
- assert.strictEqual(resolveExpression("status", completionScope), "completed");
182
- assert.strictEqual(resolveExpression("runDir", completionScope), "/tmp/runs/test-run");
183
- assert.strictEqual(resolveExpression("runId", completionScope), "test-20260313-120000-abcd");
184
- });
185
-
186
- it("resolves totalUsage fields", () => {
187
- assert.strictEqual(
188
- resolveExpression("totalUsage.cost", completionScope),
189
- 0.05,
190
- );
191
- });
192
-
193
- it("applies filters on completion fields", () => {
194
- assert.strictEqual(
195
- resolveExpression("totalDurationMs | duration", completionScope),
196
- "1m32s",
197
- );
198
- assert.strictEqual(
199
- resolveExpression("totalUsage.cost | currency", completionScope),
200
- "$0.05",
201
- );
202
- });
203
-
204
- it("still resolves input and steps from completion scope", () => {
205
- assert.strictEqual(
206
- resolveExpression("input.path", completionScope),
207
- "/src",
208
- );
209
- assert.strictEqual(
210
- resolveExpression("steps.diagnose.status", completionScope),
211
- "completed",
212
- );
213
- });
214
- });
215
-
216
- // ── evaluateCondition tests ──
217
-
218
- describe("evaluateCondition", () => {
219
- it("returns true for truthy value", () => {
220
- assert.strictEqual(evaluateCondition("input.description", scope), true);
221
- });
222
-
223
- it("returns true for truthy number", () => {
224
- assert.strictEqual(evaluateCondition("input.maxAttempts", scope), true);
225
- });
226
-
227
- it("returns false for undefined (missing optional field)", () => {
228
- assert.strictEqual(evaluateCondition("input.nonexistent", scope), false);
229
- });
230
-
231
- it("supports negation with ! prefix", () => {
232
- assert.strictEqual(evaluateCondition("!input.nonexistent", scope), true);
233
- assert.strictEqual(evaluateCondition("!input.description", scope), false);
234
- });
235
-
236
- it("supports equality with string literal", () => {
237
- assert.strictEqual(evaluateCondition("input.description == 'null pointer in login'", scope), true);
238
- assert.strictEqual(evaluateCondition("input.description == 'something else'", scope), false);
239
- });
240
-
241
- it("supports numeric comparison", () => {
242
- assert.strictEqual(evaluateCondition("input.maxAttempts > 2", scope), true);
243
- assert.strictEqual(evaluateCondition("input.maxAttempts > 5", scope), false);
244
- assert.strictEqual(evaluateCondition("input.maxAttempts >= 3", scope), true);
245
- assert.strictEqual(evaluateCondition("input.maxAttempts < 10", scope), true);
246
- assert.strictEqual(evaluateCondition("input.maxAttempts <= 3", scope), true);
247
- });
248
-
249
- it("treats boolean false as falsy", () => {
250
- const boolScope = { flag: false } as unknown as Record<string, unknown>;
251
- assert.strictEqual(evaluateCondition("flag", boolScope), false);
252
- });
253
-
254
- it("supports != operator", () => {
255
- assert.strictEqual(evaluateCondition("steps.diagnose.status != 'failed'", scope), true);
256
- assert.strictEqual(evaluateCondition("steps.diagnose.status != 'completed'", scope), false);
257
- });
258
-
259
- it("supports comparison with boolean literal", () => {
260
- const boolScope = { enabled: true } as unknown as Record<string, unknown>;
261
- assert.strictEqual(evaluateCondition("enabled == true", boolScope), true);
262
- assert.strictEqual(evaluateCondition("enabled == false", boolScope), false);
263
- });
264
- });
265
-
266
- describe("resolveExpressions", () => {
267
- it("resolves whole-value expression preserving type", () => {
268
- const result = resolveExpressions("${{ steps.diagnose.output }}", scope);
269
- assert.strictEqual(typeof result, "object");
270
- assert.strictEqual((result as any).summary, "NPE in UserService.login()");
271
- });
272
-
273
- it("resolves whole-value expression preserving number", () => {
274
- const result = resolveExpressions("${{ input.maxAttempts }}", scope);
275
- assert.strictEqual(result, 3);
276
- });
277
-
278
- it("resolves whole-value expression preserving array", () => {
279
- const result = resolveExpressions("${{ input.tags }}", scope);
280
- assert.ok(Array.isArray(result));
281
- assert.deepStrictEqual(result, ["auth", "critical"]);
282
- });
283
-
284
- it("resolves embedded expression as string interpolation", () => {
285
- const result = resolveExpressions(
286
- "Bug in ${{ steps.diagnose.output.fixLocation.file }} at line ${{ steps.diagnose.output.fixLocation.line }}",
287
- scope,
288
- );
289
- assert.strictEqual(result, "Bug in src/auth/user-service.ts at line 47");
290
- });
291
-
292
- it("resolves object values recursively", () => {
293
- const input = {
294
- description: "${{ input.description }}",
295
- diagnosis: "${{ steps.diagnose.output }}",
296
- nested: {
297
- file: "${{ steps.diagnose.output.fixLocation.file }}",
298
- plain: "no expression here",
299
- },
300
- };
301
- const result = resolveExpressions(input, scope) as any;
302
- assert.strictEqual(result.description, "null pointer in login");
303
- assert.strictEqual(typeof result.diagnosis, "object");
304
- assert.strictEqual(result.nested.file, "src/auth/user-service.ts");
305
- assert.strictEqual(result.nested.plain, "no expression here");
306
- });
307
-
308
- it("resolves array elements", () => {
309
- const input = ["${{ input.description }}", "${{ input.maxAttempts }}"];
310
- const result = resolveExpressions(input, scope) as unknown[];
311
- assert.strictEqual(result[0], "null pointer in login");
312
- assert.strictEqual(result[1], 3);
313
- });
314
-
315
- it("passes through non-string primitives", () => {
316
- assert.strictEqual(resolveExpressions(42, scope), 42);
317
- assert.strictEqual(resolveExpressions(true, scope), true);
318
- assert.strictEqual(resolveExpressions(null, scope), null);
319
- });
320
-
321
- it("passes through strings with no expressions", () => {
322
- assert.strictEqual(resolveExpressions("plain string", scope), "plain string");
323
- });
324
-
325
- it("handles whitespace in expressions", () => {
326
- assert.strictEqual(
327
- resolveExpressions("${{ input.description }}", scope),
328
- "null pointer in login",
329
- );
330
- });
331
-
332
- it("renders undefined as empty string in embedded expressions", () => {
333
- const result = resolveExpressions(
334
- "Question: ${{ input.nonexistent }}",
335
- scope,
336
- );
337
- assert.strictEqual(result, "Question: ");
338
- });
339
-
340
- it("resolves whole-value undefined for missing optional field", () => {
341
- const result = resolveExpressions("${{ input.nonexistent }}", scope);
342
- assert.strictEqual(result, undefined);
343
- });
344
-
345
- it("stringifies objects in embedded expressions", () => {
346
- const result = resolveExpressions(
347
- "Location: ${{ steps.diagnose.output.fixLocation }}",
348
- scope,
349
- );
350
- assert.ok(typeof result === "string");
351
- assert.ok((result as string).includes("src/auth/user-service.ts"));
352
- });
353
- });