@mhingston5/lasso 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 (124) hide show
  1. package/README.md +707 -0
  2. package/docs/agent-wrangling.png +0 -0
  3. package/package.json +26 -0
  4. package/src/capabilities/matcher.ts +25 -0
  5. package/src/capabilities/registry.ts +103 -0
  6. package/src/capabilities/types.ts +15 -0
  7. package/src/cir/lower.ts +253 -0
  8. package/src/cir/optimize.ts +251 -0
  9. package/src/cir/types.ts +131 -0
  10. package/src/cir/validate.ts +265 -0
  11. package/src/compiler/compile.ts +601 -0
  12. package/src/compiler/feedback.ts +471 -0
  13. package/src/compiler/runtime-helpers.ts +455 -0
  14. package/src/composition/chain.ts +58 -0
  15. package/src/composition/conditional.ts +76 -0
  16. package/src/composition/parallel.ts +75 -0
  17. package/src/composition/types.ts +105 -0
  18. package/src/environment/analyzer.ts +56 -0
  19. package/src/environment/discovery.ts +179 -0
  20. package/src/environment/types.ts +68 -0
  21. package/src/failures/classifiers.ts +134 -0
  22. package/src/failures/generator.ts +421 -0
  23. package/src/failures/map-reference-failures.ts +23 -0
  24. package/src/failures/ontology.ts +210 -0
  25. package/src/failures/recovery.ts +214 -0
  26. package/src/failures/types.ts +14 -0
  27. package/src/index.ts +67 -0
  28. package/src/memory/advisor.ts +132 -0
  29. package/src/memory/extractor.ts +166 -0
  30. package/src/memory/store.ts +107 -0
  31. package/src/memory/types.ts +53 -0
  32. package/src/metaharness/engine.ts +256 -0
  33. package/src/metaharness/predictor.ts +168 -0
  34. package/src/metaharness/types.ts +40 -0
  35. package/src/mutation/derive.ts +308 -0
  36. package/src/mutation/diff.ts +52 -0
  37. package/src/mutation/engine.ts +256 -0
  38. package/src/mutation/types.ts +84 -0
  39. package/src/pi/command-input.ts +209 -0
  40. package/src/pi/commands.ts +351 -0
  41. package/src/pi/extension.ts +16 -0
  42. package/src/planner/synthesize.ts +83 -0
  43. package/src/planner/template-rules.ts +183 -0
  44. package/src/planner/types.ts +42 -0
  45. package/src/reference/catalog.ts +128 -0
  46. package/src/reference/patch-validation-strategies.ts +170 -0
  47. package/src/reference/patch-validation.ts +174 -0
  48. package/src/reference/pr-review-merge.ts +155 -0
  49. package/src/reference/strategies.ts +126 -0
  50. package/src/reference/types.ts +33 -0
  51. package/src/replanner/risk-rules.ts +161 -0
  52. package/src/replanner/runtime.ts +308 -0
  53. package/src/replanner/synthesize.ts +619 -0
  54. package/src/replanner/types.ts +73 -0
  55. package/src/spec/schema.ts +254 -0
  56. package/src/spec/types.ts +319 -0
  57. package/src/spec/validate.ts +296 -0
  58. package/src/state/snapshots.ts +43 -0
  59. package/src/state/types.ts +12 -0
  60. package/src/synthesis/graph-builder.ts +267 -0
  61. package/src/synthesis/harness-builder.ts +113 -0
  62. package/src/synthesis/intent-ir.ts +63 -0
  63. package/src/synthesis/policy-builder.ts +320 -0
  64. package/src/synthesis/risk-analyzer.ts +182 -0
  65. package/src/synthesis/skill-parser.ts +441 -0
  66. package/src/verification/engine.ts +230 -0
  67. package/src/versioning/file-store.ts +103 -0
  68. package/src/versioning/history.ts +43 -0
  69. package/src/versioning/store.ts +16 -0
  70. package/src/versioning/types.ts +31 -0
  71. package/test/capabilities/matcher.test.ts +67 -0
  72. package/test/capabilities/registry.test.ts +136 -0
  73. package/test/capabilities/synthesis.test.ts +264 -0
  74. package/test/cir/lower.test.ts +417 -0
  75. package/test/cir/optimize.test.ts +266 -0
  76. package/test/cir/validate.test.ts +368 -0
  77. package/test/compiler/adaptive-runtime.test.ts +157 -0
  78. package/test/compiler/compile.test.ts +1198 -0
  79. package/test/compiler/feedback.test.ts +784 -0
  80. package/test/compiler/guardrails.test.ts +191 -0
  81. package/test/compiler/trace.test.ts +404 -0
  82. package/test/composition/chain.test.ts +328 -0
  83. package/test/composition/conditional.test.ts +241 -0
  84. package/test/composition/parallel.test.ts +215 -0
  85. package/test/environment/analyzer.test.ts +204 -0
  86. package/test/environment/discovery.test.ts +149 -0
  87. package/test/failures/classifiers.test.ts +287 -0
  88. package/test/failures/generator.test.ts +203 -0
  89. package/test/failures/ontology.test.ts +439 -0
  90. package/test/failures/recovery.test.ts +300 -0
  91. package/test/helpers/createFixtureRepo.ts +84 -0
  92. package/test/helpers/createPatchValidationFixture.ts +144 -0
  93. package/test/helpers/runCompiledWorkflow.ts +208 -0
  94. package/test/memory/advisor.test.ts +332 -0
  95. package/test/memory/extractor.test.ts +295 -0
  96. package/test/memory/store.test.ts +244 -0
  97. package/test/metaharness/engine.test.ts +575 -0
  98. package/test/metaharness/predictor.test.ts +436 -0
  99. package/test/mutation/derive-failure.test.ts +209 -0
  100. package/test/mutation/engine.test.ts +622 -0
  101. package/test/package-smoke.test.ts +29 -0
  102. package/test/pi/command-input.test.ts +153 -0
  103. package/test/pi/commands.test.ts +623 -0
  104. package/test/planner/classify-template.test.ts +32 -0
  105. package/test/planner/synthesize.test.ts +901 -0
  106. package/test/reference/PatchValidation.failures.test.ts +137 -0
  107. package/test/reference/PatchValidation.test.ts +326 -0
  108. package/test/reference/PrReviewMerge.failures.test.ts +121 -0
  109. package/test/reference/PrReviewMerge.test.ts +55 -0
  110. package/test/reference/catalog-open.test.ts +70 -0
  111. package/test/replanner/runtime.test.ts +207 -0
  112. package/test/replanner/synthesize.test.ts +303 -0
  113. package/test/spec/validate.test.ts +1056 -0
  114. package/test/state/snapshots.test.ts +264 -0
  115. package/test/synthesis/custom-workflow.test.ts +264 -0
  116. package/test/synthesis/graph-builder.test.ts +370 -0
  117. package/test/synthesis/harness-builder.test.ts +128 -0
  118. package/test/synthesis/policy-builder.test.ts +149 -0
  119. package/test/synthesis/risk-analyzer.test.ts +230 -0
  120. package/test/synthesis/skill-parser.test.ts +796 -0
  121. package/test/verification/engine.test.ts +509 -0
  122. package/test/versioning/history.test.ts +144 -0
  123. package/test/versioning/store.test.ts +254 -0
  124. package/vitest.config.ts +9 -0
@@ -0,0 +1,328 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { chainHarnesses } from "../../src/composition/chain.js";
3
+ import type { HarnessStage } from "../../src/composition/types.js";
4
+ import type { HarnessSpec, TaskNode } from "../../src/spec/types.js";
5
+
6
+ function makeSpec(name: string, nodes: TaskNode[], edges: { from: string; to: string }[] = []): HarnessSpec {
7
+ return {
8
+ name,
9
+ graph: {
10
+ entryNodeId: nodes[0]?.id ?? "entry",
11
+ nodes,
12
+ edges,
13
+ },
14
+ };
15
+ }
16
+
17
+ describe("chainHarnesses", () => {
18
+ describe("basic chaining", () => {
19
+ it("chains a single stage and returns its spec", () => {
20
+ const stages: HarnessStage[] = [
21
+ {
22
+ name: "research",
23
+ spec: makeSpec("research", [
24
+ { id: "search", kind: "tool", tool: "grep", args: ["term"] },
25
+ ]),
26
+ inputMapping: {},
27
+ },
28
+ ];
29
+
30
+ const result = chainHarnesses(stages);
31
+
32
+ expect(result.stageCount).toBe(1);
33
+ expect(result.totalNodes).toBe(1);
34
+ expect(result.combinedSpec.name).toBe("research");
35
+ expect(result.combinedSpec.graph.nodes.length).toBe(1);
36
+ });
37
+
38
+ it("chains two stages with edge connection", () => {
39
+ const stages: HarnessStage[] = [
40
+ {
41
+ name: "research",
42
+ spec: makeSpec("research", [
43
+ { id: "search", kind: "tool", tool: "grep", args: ["term"] },
44
+ ]),
45
+ inputMapping: {},
46
+ },
47
+ {
48
+ name: "plan",
49
+ spec: makeSpec("plan", [
50
+ { id: "write-plan", kind: "llm", provider: "openai", model: "gpt-4", prompt: "plan" },
51
+ ]),
52
+ inputMapping: {},
53
+ },
54
+ ];
55
+
56
+ const result = chainHarnesses(stages);
57
+
58
+ expect(result.stageCount).toBe(2);
59
+ expect(result.totalNodes).toBe(2);
60
+ expect(result.combinedSpec.graph.edges.length).toBeGreaterThanOrEqual(1);
61
+ });
62
+
63
+ it("chains three stages sequentially", () => {
64
+ const stages: HarnessStage[] = [
65
+ {
66
+ name: "research",
67
+ spec: makeSpec("research", [
68
+ { id: "search", kind: "tool", tool: "grep", args: ["term"] },
69
+ ]),
70
+ inputMapping: {},
71
+ },
72
+ {
73
+ name: "plan",
74
+ spec: makeSpec("plan", [
75
+ { id: "write-plan", kind: "llm", provider: "openai", model: "gpt-4", prompt: "plan" },
76
+ ]),
77
+ inputMapping: {},
78
+ },
79
+ {
80
+ name: "execute",
81
+ spec: makeSpec("execute", [
82
+ { id: "run", kind: "tool", tool: "bash", args: ["make"] },
83
+ ]),
84
+ inputMapping: {},
85
+ },
86
+ ];
87
+
88
+ const result = chainHarnesses(stages);
89
+
90
+ expect(result.stageCount).toBe(3);
91
+ expect(result.totalNodes).toBe(3);
92
+ const entryNode = result.combinedSpec.graph.nodes.find(
93
+ (n) => n.id === result.combinedSpec.graph.entryNodeId,
94
+ );
95
+ expect(entryNode).toBeDefined();
96
+ });
97
+ });
98
+
99
+ describe("node ID collision avoidance", () => {
100
+ it("prefixes node IDs with stage name to avoid collisions", () => {
101
+ const stages: HarnessStage[] = [
102
+ {
103
+ name: "research",
104
+ spec: makeSpec("research", [
105
+ { id: "step-1", kind: "tool", tool: "grep", args: ["a"] },
106
+ ]),
107
+ inputMapping: {},
108
+ },
109
+ {
110
+ name: "plan",
111
+ spec: makeSpec("plan", [
112
+ { id: "step-1", kind: "tool", tool: "echo", args: ["b"] },
113
+ ]),
114
+ inputMapping: {},
115
+ },
116
+ ];
117
+
118
+ const result = chainHarnesses(stages);
119
+
120
+ const nodeIds = result.combinedSpec.graph.nodes.map((n) => n.id);
121
+ expect(nodeIds).toContain("research:step-1");
122
+ expect(nodeIds).toContain("plan:step-1");
123
+ expect(nodeIds.length).toBe(2);
124
+ });
125
+
126
+ it("produces unique node IDs across all stages", () => {
127
+ const stages: HarnessStage[] = [
128
+ {
129
+ name: "alpha",
130
+ spec: makeSpec("alpha", [
131
+ { id: "node", kind: "tool", tool: "a", args: [] },
132
+ { id: "shared", kind: "tool", tool: "b", args: [] },
133
+ ], [{ from: "node", to: "shared" }]),
134
+ inputMapping: {},
135
+ },
136
+ {
137
+ name: "beta",
138
+ spec: makeSpec("beta", [
139
+ { id: "node", kind: "tool", tool: "c", args: [] },
140
+ { id: "shared", kind: "tool", tool: "d", args: [] },
141
+ ], [{ from: "node", to: "shared" }]),
142
+ inputMapping: {},
143
+ },
144
+ ];
145
+
146
+ const result = chainHarnesses(stages);
147
+
148
+ const nodeIds = result.combinedSpec.graph.nodes.map((n) => n.id);
149
+ const uniqueIds = new Set(nodeIds);
150
+ expect(uniqueIds.size).toBe(nodeIds.length);
151
+ });
152
+ });
153
+
154
+ describe("edge connections", () => {
155
+ it("rewires internal edges with prefixed node IDs", () => {
156
+ const stages: HarnessStage[] = [
157
+ {
158
+ name: "stage-a",
159
+ spec: makeSpec("stage-a", [
160
+ { id: "first", kind: "tool", tool: "a", args: [] },
161
+ { id: "second", kind: "tool", tool: "b", args: [] },
162
+ ], [{ from: "first", to: "second" }]),
163
+ inputMapping: {},
164
+ },
165
+ ];
166
+
167
+ const result = chainHarnesses(stages);
168
+
169
+ const edge = result.combinedSpec.graph.edges[0];
170
+ expect(edge.from).toBe("stage-a:first");
171
+ expect(edge.to).toBe("stage-a:second");
172
+ });
173
+
174
+ it("connects last node of stage N to first node of stage N+1", () => {
175
+ const stages: HarnessStage[] = [
176
+ {
177
+ name: "research",
178
+ spec: makeSpec("research", [
179
+ { id: "search", kind: "tool", tool: "grep", args: [] },
180
+ ]),
181
+ inputMapping: {},
182
+ },
183
+ {
184
+ name: "plan",
185
+ spec: makeSpec("plan", [
186
+ { id: "write", kind: "llm", provider: "openai", model: "gpt-4", prompt: "p" },
187
+ ]),
188
+ inputMapping: {},
189
+ },
190
+ ];
191
+
192
+ const result = chainHarnesses(stages);
193
+
194
+ const interStageEdge = result.combinedSpec.graph.edges.find(
195
+ (e) => e.from === "research:search" && e.to === "plan:write",
196
+ );
197
+ expect(interStageEdge).toBeDefined();
198
+ });
199
+
200
+ it("connects multi-node stages correctly", () => {
201
+ const stages: HarnessStage[] = [
202
+ {
203
+ name: "research",
204
+ spec: makeSpec("research", [
205
+ { id: "search", kind: "tool", tool: "a", args: [] },
206
+ { id: "analyze", kind: "llm", provider: "openai", model: "gpt-4", prompt: "p" },
207
+ ], [{ from: "search", to: "analyze" }]),
208
+ inputMapping: {},
209
+ },
210
+ {
211
+ name: "plan",
212
+ spec: makeSpec("plan", [
213
+ { id: "draft", kind: "llm", provider: "openai", model: "gpt-4", prompt: "d" },
214
+ { id: "review", kind: "tool", tool: "b", args: [] },
215
+ ], [{ from: "draft", to: "review" }]),
216
+ inputMapping: {},
217
+ },
218
+ ];
219
+
220
+ const result = chainHarnesses(stages);
221
+
222
+ const interStageEdge = result.combinedSpec.graph.edges.find(
223
+ (e) => e.from === "research:analyze" && e.to === "plan:draft",
224
+ );
225
+ expect(interStageEdge).toBeDefined();
226
+ });
227
+ });
228
+
229
+ describe("input mapping", () => {
230
+ it("records input mapping in combined spec metadata", () => {
231
+ const stages: HarnessStage[] = [
232
+ {
233
+ name: "research",
234
+ spec: makeSpec("research", [
235
+ { id: "search", kind: "tool", tool: "grep", args: [] },
236
+ ]),
237
+ inputMapping: {},
238
+ },
239
+ {
240
+ name: "plan",
241
+ spec: makeSpec("plan", [
242
+ { id: "write", kind: "llm", provider: "openai", model: "gpt-4", prompt: "p" },
243
+ ]),
244
+ inputMapping: { "research:output": "plan:input" },
245
+ },
246
+ ];
247
+
248
+ const result = chainHarnesses(stages);
249
+
250
+ expect(result.stageCount).toBe(2);
251
+ });
252
+ });
253
+
254
+ describe("condition stages", () => {
255
+ it("includes condition field on stage", () => {
256
+ const stages: HarnessStage[] = [
257
+ {
258
+ name: "research",
259
+ spec: makeSpec("research", [
260
+ { id: "search", kind: "tool", tool: "grep", args: [] },
261
+ ]),
262
+ inputMapping: {},
263
+ },
264
+ {
265
+ name: "deep-research",
266
+ spec: makeSpec("deep-research", [
267
+ { id: "deep-search", kind: "tool", tool: "grep", args: ["--deep"] },
268
+ ]),
269
+ inputMapping: {},
270
+ condition: "results.length > 10",
271
+ },
272
+ ];
273
+
274
+ const result = chainHarnesses(stages);
275
+
276
+ expect(result.stageCount).toBe(2);
277
+ expect(result.combinedSpec.graph.nodes.length).toBe(2);
278
+ });
279
+ });
280
+
281
+ describe("composition result", () => {
282
+ it("calculates total nodes correctly", () => {
283
+ const stages: HarnessStage[] = [
284
+ {
285
+ name: "a",
286
+ spec: makeSpec("a", [
287
+ { id: "a1", kind: "tool", tool: "a", args: [] },
288
+ { id: "a2", kind: "tool", tool: "b", args: [] },
289
+ ]),
290
+ inputMapping: {},
291
+ },
292
+ {
293
+ name: "b",
294
+ spec: makeSpec("b", [
295
+ { id: "b1", kind: "tool", tool: "c", args: [] },
296
+ ]),
297
+ inputMapping: {},
298
+ },
299
+ ];
300
+
301
+ const result = chainHarnesses(stages);
302
+
303
+ expect(result.totalNodes).toBe(3);
304
+ });
305
+
306
+ it("estimates duration based on node count", () => {
307
+ const stages: HarnessStage[] = [
308
+ {
309
+ name: "a",
310
+ spec: makeSpec("a", [
311
+ { id: "a1", kind: "tool", tool: "a", args: [] },
312
+ ]),
313
+ inputMapping: {},
314
+ },
315
+ ];
316
+
317
+ const result = chainHarnesses(stages);
318
+
319
+ expect(result.estimatedDurationMs).toBeGreaterThan(0);
320
+ });
321
+ });
322
+
323
+ describe("empty stages", () => {
324
+ it("throws on empty stages array", () => {
325
+ expect(() => chainHarnesses([])).toThrow();
326
+ });
327
+ });
328
+ });
@@ -0,0 +1,241 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { conditionalHarness } from "../../src/composition/conditional.js";
3
+ import type { HarnessSpec, TaskNode } from "../../src/spec/types.js";
4
+
5
+ function makeSpec(name: string, nodes: TaskNode[], edges: { from: string; to: string }[] = []): HarnessSpec {
6
+ return {
7
+ name,
8
+ graph: {
9
+ entryNodeId: nodes[0]?.id ?? "entry",
10
+ nodes,
11
+ edges,
12
+ },
13
+ };
14
+ }
15
+
16
+ describe("conditionalHarness", () => {
17
+ describe("basic conditional", () => {
18
+ it("creates a condition node at entry", () => {
19
+ const trueHarness = makeSpec("true-path", [
20
+ { id: "t1", kind: "tool", tool: "echo", args: ["true"] },
21
+ ]);
22
+
23
+ const result = conditionalHarness("hasData", trueHarness);
24
+
25
+ const conditionNode = result.combinedSpec.graph.nodes.find(
26
+ (n) => n.kind === "condition",
27
+ );
28
+ expect(conditionNode).toBeDefined();
29
+ expect((conditionNode as any).condition).toBe("hasData");
30
+ });
31
+
32
+ it("connects condition true branch to true harness", () => {
33
+ const trueHarness = makeSpec("true-path", [
34
+ { id: "t1", kind: "tool", tool: "echo", args: ["true"] },
35
+ ]);
36
+
37
+ const result = conditionalHarness("hasData", trueHarness);
38
+
39
+ const conditionNode = result.combinedSpec.graph.nodes.find(
40
+ (n) => n.kind === "condition",
41
+ ) as any;
42
+ expect(conditionNode.thenNodeId).toBe("true-path:t1");
43
+ });
44
+
45
+ it("connects condition false branch to true harness when no false harness provided", () => {
46
+ const trueHarness = makeSpec("true-path", [
47
+ { id: "t1", kind: "tool", tool: "echo", args: ["true"] },
48
+ ]);
49
+
50
+ const result = conditionalHarness("hasData", trueHarness);
51
+
52
+ const conditionNode = result.combinedSpec.graph.nodes.find(
53
+ (n) => n.kind === "condition",
54
+ ) as any;
55
+ expect(conditionNode.elseNodeId).toBe("true-path:t1");
56
+ });
57
+ });
58
+
59
+ describe("with false harness", () => {
60
+ it("connects false branch to false harness", () => {
61
+ const trueHarness = makeSpec("true-path", [
62
+ { id: "t1", kind: "tool", tool: "echo", args: ["true"] },
63
+ ]);
64
+ const falseHarness = makeSpec("false-path", [
65
+ { id: "f1", kind: "tool", tool: "echo", args: ["false"] },
66
+ ]);
67
+
68
+ const result = conditionalHarness("hasData", trueHarness, falseHarness);
69
+
70
+ const conditionNode = result.combinedSpec.graph.nodes.find(
71
+ (n) => n.kind === "condition",
72
+ ) as any;
73
+ expect(conditionNode.thenNodeId).toBe("true-path:t1");
74
+ expect(conditionNode.elseNodeId).toBe("false-path:f1");
75
+ });
76
+
77
+ it("includes nodes from both branches", () => {
78
+ const trueHarness = makeSpec("true-path", [
79
+ { id: "t1", kind: "tool", tool: "echo", args: ["true"] },
80
+ ]);
81
+ const falseHarness = makeSpec("false-path", [
82
+ { id: "f1", kind: "tool", tool: "echo", args: ["false"] },
83
+ ]);
84
+
85
+ const result = conditionalHarness("hasData", trueHarness, falseHarness);
86
+
87
+ const nodeIds = result.combinedSpec.graph.nodes.map((n) => n.id);
88
+ expect(nodeIds).toContain("true-path:t1");
89
+ expect(nodeIds).toContain("false-path:f1");
90
+ });
91
+ });
92
+
93
+ describe("node ID prefixing", () => {
94
+ it("prefixes true harness node IDs", () => {
95
+ const trueHarness = makeSpec("alpha", [
96
+ { id: "step", kind: "tool", tool: "a", args: [] },
97
+ ]);
98
+
99
+ const result = conditionalHarness("cond", trueHarness);
100
+
101
+ const nodeIds = result.combinedSpec.graph.nodes.map((n) => n.id);
102
+ expect(nodeIds).toContain("alpha:step");
103
+ });
104
+
105
+ it("prefixes false harness node IDs", () => {
106
+ const trueHarness = makeSpec("alpha", [
107
+ { id: "step", kind: "tool", tool: "a", args: [] },
108
+ ]);
109
+ const falseHarness = makeSpec("beta", [
110
+ { id: "step", kind: "tool", tool: "b", args: [] },
111
+ ]);
112
+
113
+ const result = conditionalHarness("cond", trueHarness, falseHarness);
114
+
115
+ const nodeIds = result.combinedSpec.graph.nodes.map((n) => n.id);
116
+ expect(nodeIds).toContain("alpha:step");
117
+ expect(nodeIds).toContain("beta:step");
118
+ });
119
+ });
120
+
121
+ describe("edge rewiring", () => {
122
+ it("rewires internal edges in true harness", () => {
123
+ const trueHarness = makeSpec("true-path", [
124
+ { id: "t1", kind: "tool", tool: "a", args: [] },
125
+ { id: "t2", kind: "tool", tool: "b", args: [] },
126
+ ], [{ from: "t1", to: "t2" }]);
127
+
128
+ const result = conditionalHarness("cond", trueHarness);
129
+
130
+ const edge = result.combinedSpec.graph.edges.find(
131
+ (e) => e.from === "true-path:t1" && e.to === "true-path:t2",
132
+ );
133
+ expect(edge).toBeDefined();
134
+ });
135
+
136
+ it("rewires internal edges in false harness", () => {
137
+ const trueHarness = makeSpec("true-path", [
138
+ { id: "t1", kind: "tool", tool: "a", args: [] },
139
+ ]);
140
+ const falseHarness = makeSpec("false-path", [
141
+ { id: "f1", kind: "tool", tool: "b", args: [] },
142
+ { id: "f2", kind: "tool", tool: "c", args: [] },
143
+ ], [{ from: "f1", to: "f2" }]);
144
+
145
+ const result = conditionalHarness("cond", trueHarness, falseHarness);
146
+
147
+ const edge = result.combinedSpec.graph.edges.find(
148
+ (e) => e.from === "false-path:f1" && e.to === "false-path:f2",
149
+ );
150
+ expect(edge).toBeDefined();
151
+ });
152
+ });
153
+
154
+ describe("merge after conditional", () => {
155
+ it("creates merge node after both branches", () => {
156
+ const trueHarness = makeSpec("true-path", [
157
+ { id: "t1", kind: "tool", tool: "a", args: [] },
158
+ ]);
159
+ const falseHarness = makeSpec("false-path", [
160
+ { id: "f1", kind: "tool", tool: "b", args: [] },
161
+ ]);
162
+
163
+ const result = conditionalHarness("cond", trueHarness, falseHarness);
164
+
165
+ const mergeNode = result.combinedSpec.graph.nodes.find(
166
+ (n) => n.kind === "merge",
167
+ );
168
+ expect(mergeNode).toBeDefined();
169
+ });
170
+
171
+ it("merge node waits for both branch terminals", () => {
172
+ const trueHarness = makeSpec("true-path", [
173
+ { id: "t1", kind: "tool", tool: "a", args: [] },
174
+ ]);
175
+ const falseHarness = makeSpec("false-path", [
176
+ { id: "f1", kind: "tool", tool: "b", args: [] },
177
+ ]);
178
+
179
+ const result = conditionalHarness("cond", trueHarness, falseHarness);
180
+
181
+ const mergeNode = result.combinedSpec.graph.nodes.find(
182
+ (n) => n.kind === "merge",
183
+ ) as any;
184
+ expect(mergeNode).toBeDefined();
185
+ expect(mergeNode.waitFor).toContain("true-path:t1");
186
+ expect(mergeNode.waitFor).toContain("false-path:f1");
187
+ });
188
+ });
189
+
190
+ describe("composition result", () => {
191
+ it("counts stages as 2 (true + false)", () => {
192
+ const trueHarness = makeSpec("true-path", [
193
+ { id: "t1", kind: "tool", tool: "a", args: [] },
194
+ ]);
195
+ const falseHarness = makeSpec("false-path", [
196
+ { id: "f1", kind: "tool", tool: "b", args: [] },
197
+ ]);
198
+
199
+ const result = conditionalHarness("cond", trueHarness, falseHarness);
200
+
201
+ expect(result.stageCount).toBe(2);
202
+ });
203
+
204
+ it("counts stage as 1 when no false harness", () => {
205
+ const trueHarness = makeSpec("true-path", [
206
+ { id: "t1", kind: "tool", tool: "a", args: [] },
207
+ ]);
208
+
209
+ const result = conditionalHarness("cond", trueHarness);
210
+
211
+ expect(result.stageCount).toBe(1);
212
+ });
213
+
214
+ it("estimates duration", () => {
215
+ const trueHarness = makeSpec("true-path", [
216
+ { id: "t1", kind: "tool", tool: "a", args: [] },
217
+ ]);
218
+
219
+ const result = conditionalHarness("cond", trueHarness);
220
+
221
+ expect(result.estimatedDurationMs).toBeGreaterThan(0);
222
+ });
223
+ });
224
+
225
+ describe("multi-node harnesses", () => {
226
+ it("handles true harness with multiple nodes", () => {
227
+ const trueHarness = makeSpec("true-path", [
228
+ { id: "t1", kind: "tool", tool: "a", args: [] },
229
+ { id: "t2", kind: "llm", provider: "openai", model: "gpt-4", prompt: "p" },
230
+ { id: "t3", kind: "tool", tool: "b", args: [] },
231
+ ], [
232
+ { from: "t1", to: "t2" },
233
+ { from: "t2", to: "t3" },
234
+ ]);
235
+
236
+ const result = conditionalHarness("cond", trueHarness);
237
+
238
+ expect(result.combinedSpec.graph.nodes.filter((n) => n.id.startsWith("true-path:")).length).toBe(3);
239
+ });
240
+ });
241
+ });