@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,417 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { lowerHarnessSpecToCir } from "../../src/cir/lower.js";
3
+ import { validateCirWorkflow } from "../../src/cir/validate.js";
4
+ import type { HarnessSpec } from "../../src/spec/types.js";
5
+
6
+ function createCanonicalHarnessSpec(): HarnessSpec {
7
+ return {
8
+ name: "pr-review-merge",
9
+ executionPolicy: {
10
+ timeout: 300,
11
+ continueOnFailure: false,
12
+ failureClassification: [
13
+ {
14
+ pattern: "timeout",
15
+ category: "transient",
16
+ retry: true
17
+ }
18
+ ]
19
+ },
20
+ humanPolicy: {
21
+ defaultTimeout: 900,
22
+ allowAsync: true,
23
+ notificationChannels: ["slack"]
24
+ },
25
+ observabilityPolicy: {
26
+ tracing: true,
27
+ metrics: true,
28
+ logLevel: "info"
29
+ },
30
+ graph: {
31
+ entryNodeId: "load-pr",
32
+ nodes: [
33
+ {
34
+ id: "load-pr",
35
+ kind: "tool",
36
+ tool: "git",
37
+ args: ["diff", "main...feature"],
38
+ retryPolicy: {
39
+ maxAttempts: 3,
40
+ backoff: "exponential",
41
+ initialDelay: 5,
42
+ retryOn: ["transient"]
43
+ }
44
+ },
45
+ {
46
+ id: "verify-pr",
47
+ kind: "tool",
48
+ tool: "npm",
49
+ args: ["test"],
50
+ executionPolicy: {
51
+ timeout: 60
52
+ },
53
+ verificationPolicy: {
54
+ rules: [
55
+ {
56
+ kind: "llm",
57
+ checkNodeId: "post-verify-check",
58
+ onFail: "retry",
59
+ maxAttempts: 2
60
+ }
61
+ ]
62
+ }
63
+ },
64
+ {
65
+ id: "review-ok",
66
+ kind: "condition",
67
+ condition: "review.clean",
68
+ thenNodeId: "human-approval",
69
+ elseNodeId: "end-rejected"
70
+ },
71
+ {
72
+ id: "human-approval",
73
+ kind: "human",
74
+ prompt: "Approve merge?",
75
+ interactionType: "approval"
76
+ },
77
+ {
78
+ id: "merge",
79
+ kind: "merge",
80
+ waitFor: ["post-verify-check", "human-approval"]
81
+ },
82
+ {
83
+ id: "finish",
84
+ kind: "subworkflow",
85
+ specRef: "local-merge",
86
+ inputs: {
87
+ merge: {
88
+ strategy: "squash"
89
+ },
90
+ dryRun: false
91
+ }
92
+ },
93
+ {
94
+ id: "end-rejected",
95
+ kind: "subworkflow",
96
+ specRef: "reject-pr"
97
+ },
98
+ {
99
+ id: "post-verify-check",
100
+ kind: "llm",
101
+ provider: "anthropic",
102
+ model: "claude-sonnet",
103
+ prompt: "Did the verification output indicate success?"
104
+ }
105
+ ],
106
+ edges: [
107
+ { from: "load-pr", to: "verify-pr" },
108
+ { from: "load-pr", to: "review-ok" },
109
+ { from: "verify-pr", to: "post-verify-check" },
110
+ { from: "post-verify-check", to: "merge" },
111
+ { from: "human-approval", to: "merge" },
112
+ { from: "merge", to: "finish" }
113
+ ]
114
+ }
115
+ };
116
+ }
117
+
118
+ describe("lowerHarnessSpecToCir", () => {
119
+ it("lowers a canonical harness spec into explicit CIR actions and transitions", () => {
120
+ const workflow = lowerHarnessSpecToCir(createCanonicalHarnessSpec());
121
+ expect(validateCirWorkflow(workflow).valid).toBe(true);
122
+
123
+ expect(workflow).toMatchObject({
124
+ name: "pr-review-merge",
125
+ entryNodeId: "load-pr",
126
+ globalPolicies: {
127
+ execution: {
128
+ timeout: 300,
129
+ continueOnFailure: false,
130
+ failureClassification: [
131
+ {
132
+ pattern: "timeout",
133
+ category: "transient",
134
+ retry: true
135
+ }
136
+ ]
137
+ },
138
+ human: {
139
+ defaultTimeout: 900,
140
+ allowAsync: true,
141
+ notificationChannels: ["slack"]
142
+ },
143
+ observability: {
144
+ tracing: true,
145
+ metrics: true,
146
+ logLevel: "info"
147
+ }
148
+ }
149
+ });
150
+
151
+ const nodeMap = new Map(workflow.nodes.map(node => [node.id, node]));
152
+
153
+ expect(nodeMap.get("load-pr")).toMatchObject({
154
+ kind: "tool",
155
+ source: {
156
+ specNodeId: "load-pr",
157
+ specNodeKind: "tool",
158
+ specPath: "graph.nodes[0]"
159
+ },
160
+ retry: {
161
+ maxAttempts: 3,
162
+ backoff: "exponential",
163
+ initialDelay: 5,
164
+ retryOn: ["transient"]
165
+ },
166
+ execution: {
167
+ timeout: 300,
168
+ continueOnFailure: false
169
+ },
170
+ failureRouting: [
171
+ {
172
+ pattern: "timeout",
173
+ category: "transient",
174
+ retry: true
175
+ }
176
+ ],
177
+ action: {
178
+ tool: "git",
179
+ args: ["diff", "main...feature"]
180
+ }
181
+ });
182
+
183
+ expect(nodeMap.get("verify-pr")).toMatchObject({
184
+ kind: "tool",
185
+ execution: {
186
+ timeout: 60,
187
+ continueOnFailure: false
188
+ },
189
+ failureRouting: [
190
+ {
191
+ pattern: "timeout",
192
+ category: "transient",
193
+ retry: true
194
+ }
195
+ ],
196
+ verification: [
197
+ {
198
+ checkNodeId: "post-verify-check",
199
+ onFail: "retry",
200
+ maxAttempts: 2
201
+ }
202
+ ]
203
+ });
204
+
205
+ expect(nodeMap.get("review-ok")).toMatchObject({
206
+ kind: "condition",
207
+ action: {
208
+ conditionExpr: "review.clean"
209
+ }
210
+ });
211
+
212
+ expect(nodeMap.get("human-approval")).toMatchObject({
213
+ kind: "human",
214
+ action: {
215
+ prompt: "Approve merge?",
216
+ interactionType: "approval",
217
+ timeout: 900
218
+ }
219
+ });
220
+
221
+ expect(nodeMap.get("merge")).toMatchObject({
222
+ kind: "merge",
223
+ action: {
224
+ join: {
225
+ waitFor: ["post-verify-check", "human-approval"],
226
+ strategy: "all"
227
+ }
228
+ }
229
+ });
230
+
231
+ expect(nodeMap.get("finish")).toMatchObject({
232
+ kind: "subworkflow",
233
+ action: {
234
+ inputs: {
235
+ merge: {
236
+ strategy: "squash"
237
+ },
238
+ dryRun: false
239
+ }
240
+ },
241
+ terminal: true
242
+ });
243
+
244
+ expect(nodeMap.get("end-rejected")).toMatchObject({
245
+ kind: "subworkflow",
246
+ terminal: true
247
+ });
248
+
249
+ expect(workflow.transitions).toEqual(
250
+ expect.arrayContaining([
251
+ {
252
+ from: "load-pr",
253
+ to: "verify-pr",
254
+ when: "success",
255
+ source: {
256
+ kind: "graph-edge",
257
+ specPath: "graph.edges[0]"
258
+ }
259
+ },
260
+ {
261
+ from: "load-pr",
262
+ to: "review-ok",
263
+ when: "success",
264
+ source: {
265
+ kind: "graph-edge",
266
+ specPath: "graph.edges[1]"
267
+ }
268
+ },
269
+ {
270
+ from: "review-ok",
271
+ to: "human-approval",
272
+ when: "condition-true",
273
+ source: {
274
+ kind: "condition-then",
275
+ specNodeId: "review-ok",
276
+ specPath: "graph.nodes[2].thenNodeId"
277
+ }
278
+ },
279
+ {
280
+ from: "review-ok",
281
+ to: "end-rejected",
282
+ when: "condition-false",
283
+ source: {
284
+ kind: "condition-else",
285
+ specNodeId: "review-ok",
286
+ specPath: "graph.nodes[2].elseNodeId"
287
+ }
288
+ },
289
+ {
290
+ from: "verify-pr",
291
+ to: "post-verify-check",
292
+ when: "success",
293
+ source: {
294
+ kind: "graph-edge",
295
+ specPath: "graph.edges[2]"
296
+ }
297
+ },
298
+ {
299
+ from: "post-verify-check",
300
+ to: "merge",
301
+ when: "success",
302
+ source: {
303
+ kind: "graph-edge",
304
+ specPath: "graph.edges[3]"
305
+ }
306
+ },
307
+ {
308
+ from: "human-approval",
309
+ to: "merge",
310
+ when: "success",
311
+ source: {
312
+ kind: "graph-edge",
313
+ specPath: "graph.edges[4]"
314
+ }
315
+ },
316
+ {
317
+ from: "merge",
318
+ to: "finish",
319
+ when: "success",
320
+ source: {
321
+ kind: "graph-edge",
322
+ specPath: "graph.edges[5]"
323
+ }
324
+ }
325
+ ])
326
+ );
327
+
328
+ expect(
329
+ workflow.transitions.filter(transition => transition.from === "review-ok").map(transition => transition.when),
330
+ ).toEqual(["condition-true", "condition-false"]);
331
+ });
332
+
333
+ it("rejects condition nodes that also declare outgoing graph edges", () => {
334
+ const spec: HarnessSpec = {
335
+ name: "ambiguous-condition",
336
+ graph: {
337
+ entryNodeId: "start",
338
+ nodes: [
339
+ {
340
+ id: "start",
341
+ kind: "tool",
342
+ tool: "echo",
343
+ args: ["start"]
344
+ },
345
+ {
346
+ id: "branch",
347
+ kind: "condition",
348
+ condition: "result.ok",
349
+ thenNodeId: "yes",
350
+ elseNodeId: "no"
351
+ },
352
+ {
353
+ id: "yes",
354
+ kind: "tool",
355
+ tool: "echo",
356
+ args: ["yes"]
357
+ },
358
+ {
359
+ id: "no",
360
+ kind: "tool",
361
+ tool: "echo",
362
+ args: ["no"]
363
+ }
364
+ ],
365
+ edges: [
366
+ {
367
+ from: "start",
368
+ to: "branch"
369
+ },
370
+ {
371
+ from: "branch",
372
+ to: "yes"
373
+ }
374
+ ]
375
+ }
376
+ };
377
+
378
+ expect(() => lowerHarnessSpecToCir(spec)).toThrow(/Condition node "branch" cannot declare outgoing graph edges/);
379
+ });
380
+
381
+ it("clones mutable retry and subworkflow input structures out of the spec", () => {
382
+ const spec = createCanonicalHarnessSpec();
383
+ const workflow = lowerHarnessSpecToCir(spec);
384
+ const loadPrNode = spec.graph.nodes.find(node => node.id === "load-pr");
385
+ const finishNode = spec.graph.nodes.find(node => node.id === "finish");
386
+
387
+ if (!loadPrNode || loadPrNode.kind !== "tool" || !loadPrNode.retryPolicy?.retryOn) {
388
+ throw new Error("load-pr retry policy is missing");
389
+ }
390
+
391
+ if (!finishNode || finishNode.kind !== "subworkflow" || !finishNode.inputs) {
392
+ throw new Error("finish inputs are missing");
393
+ }
394
+
395
+ loadPrNode.retryPolicy.retryOn[0] = "resource";
396
+ (finishNode.inputs as { merge: { strategy: string } }).merge.strategy = "merge-commit";
397
+
398
+ const nodeMap = new Map(workflow.nodes.map(node => [node.id, node]));
399
+
400
+ expect(nodeMap.get("load-pr")).toMatchObject({
401
+ retry: {
402
+ retryOn: ["transient"]
403
+ }
404
+ });
405
+
406
+ expect(nodeMap.get("finish")).toMatchObject({
407
+ action: {
408
+ inputs: {
409
+ merge: {
410
+ strategy: "squash"
411
+ },
412
+ dryRun: false
413
+ }
414
+ }
415
+ });
416
+ });
417
+ });
@@ -0,0 +1,266 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { optimizeCirWorkflow } from "../../src/cir/optimize.js";
3
+ import type { CirToolNode, CirWorkflow } from "../../src/cir/types.js";
4
+
5
+ function createLinearWorkflow(): CirWorkflow {
6
+ return {
7
+ name: "linear",
8
+ entryNodeId: "a",
9
+ nodes: [
10
+ { id: "a", kind: "tool", source: { specNodeId: "a", specNodeKind: "tool", specPath: "graph.nodes[0]" }, action: { tool: "bash", args: ["step-a"] } },
11
+ { id: "b", kind: "tool", source: { specNodeId: "b", specNodeKind: "tool", specPath: "graph.nodes[1]" }, action: { tool: "git", args: ["status"] } },
12
+ { id: "c", kind: "tool", source: { specNodeId: "c", specNodeKind: "tool", specPath: "graph.nodes[2]" }, action: { tool: "npm", args: ["test"] }, terminal: true },
13
+ ],
14
+ transitions: [
15
+ { from: "a", to: "b", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[0]" } },
16
+ { from: "b", to: "c", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[1]" } },
17
+ ],
18
+ };
19
+ }
20
+
21
+ describe("optimizeCirWorkflow", () => {
22
+ describe("dead-node elimination", () => {
23
+ it("removes unreachable nodes", () => {
24
+ const workflow = createLinearWorkflow();
25
+ workflow.nodes.push({
26
+ id: "orphan",
27
+ kind: "tool",
28
+ source: { specNodeId: "orphan", specNodeKind: "tool", specPath: "graph.nodes[3]" },
29
+ action: { tool: "echo", args: ["orphan"] },
30
+ terminal: true,
31
+ });
32
+
33
+ const { optimized } = optimizeCirWorkflow(workflow);
34
+
35
+ expect(optimized.nodes.map(n => n.id)).toEqual(["a", "b", "c"]);
36
+ });
37
+
38
+ it("removes transitions involving dead nodes", () => {
39
+ const workflow = createLinearWorkflow();
40
+ workflow.nodes.push({
41
+ id: "orphan",
42
+ kind: "tool",
43
+ source: { specNodeId: "orphan", specNodeKind: "tool", specPath: "graph.nodes[3]" },
44
+ action: { tool: "echo", args: ["orphan"] },
45
+ terminal: true,
46
+ });
47
+ workflow.transitions.push({
48
+ from: "orphan",
49
+ to: "c",
50
+ when: "success",
51
+ source: { kind: "graph-edge", specPath: "graph.edges[2]" },
52
+ });
53
+
54
+ const { optimized } = optimizeCirWorkflow(workflow);
55
+
56
+ expect(optimized.transitions.every(t => t.from !== "orphan" && t.to !== "orphan")).toBe(true);
57
+ });
58
+
59
+ it("preserves all reachable nodes", () => {
60
+ const workflow = createLinearWorkflow();
61
+
62
+ const { optimized } = optimizeCirWorkflow(workflow);
63
+
64
+ expect(optimized.nodes.map(n => n.id)).toEqual(["a", "b", "c"]);
65
+ });
66
+
67
+ it("handles empty graph", () => {
68
+ const workflow: CirWorkflow = {
69
+ name: "empty",
70
+ entryNodeId: "start",
71
+ nodes: [
72
+ { id: "start", kind: "tool", source: { specNodeId: "start", specNodeKind: "tool", specPath: "graph.nodes[0]" }, action: { tool: "echo", args: [] }, terminal: true },
73
+ ],
74
+ transitions: [],
75
+ };
76
+
77
+ const { optimized } = optimizeCirWorkflow(workflow);
78
+
79
+ expect(optimized.nodes).toHaveLength(1);
80
+ expect(optimized.nodes[0].id).toBe("start");
81
+ });
82
+ });
83
+
84
+ describe("single-branch merge elision", () => {
85
+ it("elides merge with single waitFor entry", () => {
86
+ const workflow: CirWorkflow = {
87
+ name: "single-merge",
88
+ entryNodeId: "a",
89
+ nodes: [
90
+ { id: "a", kind: "tool", source: { specNodeId: "a", specNodeKind: "tool", specPath: "graph.nodes[0]" }, action: { tool: "bash", args: ["step-a"] } },
91
+ { id: "m", kind: "merge", source: { specNodeId: "m", specNodeKind: "merge", specPath: "graph.nodes[1]" }, action: { join: { waitFor: ["a"], strategy: "all" } } },
92
+ { id: "b", kind: "tool", source: { specNodeId: "b", specNodeKind: "tool", specPath: "graph.nodes[2]" }, action: { tool: "git", args: ["status"] }, terminal: true },
93
+ ],
94
+ transitions: [
95
+ { from: "a", to: "m", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[0]" } },
96
+ { from: "m", to: "b", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[1]" } },
97
+ ],
98
+ };
99
+
100
+ const { optimized } = optimizeCirWorkflow(workflow);
101
+
102
+ expect(optimized.nodes.map(n => n.id)).toEqual(["a", "b"]);
103
+ expect(optimized.transitions).toHaveLength(1);
104
+ expect(optimized.transitions[0]).toEqual(expect.objectContaining({ from: "a", to: "b", when: "success" }));
105
+ });
106
+
107
+ it("preserves merge with multiple waitFor entries", () => {
108
+ const workflow: CirWorkflow = {
109
+ name: "multi-merge",
110
+ entryNodeId: "start",
111
+ nodes: [
112
+ { id: "start", kind: "condition", source: { specNodeId: "start", specNodeKind: "condition", specPath: "graph.nodes[0]" }, action: { conditionExpr: "true" } },
113
+ { id: "left", kind: "tool", source: { specNodeId: "left", specNodeKind: "tool", specPath: "graph.nodes[1]" }, action: { tool: "echo", args: ["left"] } },
114
+ { id: "right", kind: "tool", source: { specNodeId: "right", specNodeKind: "tool", specPath: "graph.nodes[2]" }, action: { tool: "echo", args: ["right"] } },
115
+ { id: "m", kind: "merge", source: { specNodeId: "m", specNodeKind: "merge", specPath: "graph.nodes[3]" }, action: { join: { waitFor: ["left", "right"], strategy: "all" } } },
116
+ { id: "end", kind: "tool", source: { specNodeId: "end", specNodeKind: "tool", specPath: "graph.nodes[4]" }, action: { tool: "echo", args: ["end"] }, terminal: true },
117
+ ],
118
+ transitions: [
119
+ { from: "start", to: "left", when: "condition-true", source: { kind: "condition-then", specNodeId: "start", specPath: "graph.nodes[0].thenNodeId" } },
120
+ { from: "start", to: "right", when: "condition-false", source: { kind: "condition-else", specNodeId: "start", specPath: "graph.nodes[0].elseNodeId" } },
121
+ { from: "left", to: "m", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[0]" } },
122
+ { from: "right", to: "m", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[1]" } },
123
+ { from: "m", to: "end", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[2]" } },
124
+ ],
125
+ };
126
+
127
+ const { optimized } = optimizeCirWorkflow(workflow);
128
+
129
+ expect(optimized.nodes.some(n => n.id === "m")).toBe(true);
130
+ });
131
+ });
132
+
133
+ describe("adjacent tool-node fusion", () => {
134
+ it("fuses adjacent tool nodes with same tool and env", () => {
135
+ const workflow: CirWorkflow = {
136
+ name: "fuse",
137
+ entryNodeId: "a",
138
+ nodes: [
139
+ { id: "a", kind: "tool", source: { specNodeId: "a", specNodeKind: "tool", specPath: "graph.nodes[0]" }, action: { tool: "bash", args: ["npm install"] } },
140
+ { id: "b", kind: "tool", source: { specNodeId: "b", specNodeKind: "tool", specPath: "graph.nodes[1]" }, action: { tool: "bash", args: ["npm test"] }, terminal: true },
141
+ ],
142
+ transitions: [
143
+ { from: "a", to: "b", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[0]" } },
144
+ ],
145
+ };
146
+
147
+ const { optimized } = optimizeCirWorkflow(workflow);
148
+
149
+ expect(optimized.nodes).toHaveLength(1);
150
+ const fused = optimized.nodes[0] as CirToolNode;
151
+ expect(fused.id).toBe("a");
152
+ expect(fused.action.args).toEqual(["npm install && npm test"]);
153
+ expect(fused.terminal).toBe(true);
154
+ });
155
+
156
+ it("does not fuse nodes with different tools", () => {
157
+ const workflow: CirWorkflow = {
158
+ name: "no-fuse-diff-tool",
159
+ entryNodeId: "a",
160
+ nodes: [
161
+ { id: "a", kind: "tool", source: { specNodeId: "a", specNodeKind: "tool", specPath: "graph.nodes[0]" }, action: { tool: "bash", args: ["npm install"] } },
162
+ { id: "b", kind: "tool", source: { specNodeId: "b", specNodeKind: "tool", specPath: "graph.nodes[1]" }, action: { tool: "git", args: ["push"] }, terminal: true },
163
+ ],
164
+ transitions: [
165
+ { from: "a", to: "b", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[0]" } },
166
+ ],
167
+ };
168
+
169
+ const { optimized } = optimizeCirWorkflow(workflow);
170
+
171
+ expect(optimized.nodes).toHaveLength(2);
172
+ });
173
+
174
+ it("does not fuse nodes when first has retryPolicy", () => {
175
+ const workflow: CirWorkflow = {
176
+ name: "no-fuse-retry",
177
+ entryNodeId: "a",
178
+ nodes: [
179
+ { id: "a", kind: "tool", source: { specNodeId: "a", specNodeKind: "tool", specPath: "graph.nodes[0]" }, action: { tool: "bash", args: ["npm install"] }, retry: { maxAttempts: 3, backoff: "exponential" } },
180
+ { id: "b", kind: "tool", source: { specNodeId: "b", specNodeKind: "tool", specPath: "graph.nodes[1]" }, action: { tool: "bash", args: ["npm test"] }, terminal: true },
181
+ ],
182
+ transitions: [
183
+ { from: "a", to: "b", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[0]" } },
184
+ ],
185
+ };
186
+
187
+ const { optimized } = optimizeCirWorkflow(workflow);
188
+
189
+ expect(optimized.nodes).toHaveLength(2);
190
+ });
191
+
192
+ it("does not fuse nodes when second has verificationPolicy", () => {
193
+ const workflow: CirWorkflow = {
194
+ name: "no-fuse-verification",
195
+ entryNodeId: "a",
196
+ nodes: [
197
+ { id: "a", kind: "tool", source: { specNodeId: "a", specNodeKind: "tool", specPath: "graph.nodes[0]" }, action: { tool: "bash", args: ["npm install"] } },
198
+ { id: "b", kind: "tool", source: { specNodeId: "b", specNodeKind: "tool", specPath: "graph.nodes[1]" }, action: { tool: "bash", args: ["npm test"] }, verification: [{ kind: "tool", checkNodeId: "v", onFail: "block" }], terminal: true },
199
+ { id: "v", kind: "tool", source: { specNodeId: "v", specNodeKind: "tool", specPath: "graph.nodes[2]" }, action: { tool: "echo", args: ["ok"] }, terminal: true },
200
+ ],
201
+ transitions: [
202
+ { from: "a", to: "b", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[0]" } },
203
+ ],
204
+ };
205
+
206
+ const { optimized } = optimizeCirWorkflow(workflow);
207
+
208
+ expect(optimized.nodes).toHaveLength(3);
209
+ });
210
+ });
211
+
212
+ describe("pass tracking", () => {
213
+ it("reports which passes ran", () => {
214
+ const workflow = createLinearWorkflow();
215
+ workflow.nodes.push({
216
+ id: "orphan",
217
+ kind: "tool",
218
+ source: { specNodeId: "orphan", specNodeKind: "tool", specPath: "graph.nodes[3]" },
219
+ action: { tool: "echo", args: ["orphan"] },
220
+ terminal: true,
221
+ });
222
+
223
+ const { passes } = optimizeCirWorkflow(workflow);
224
+
225
+ expect(passes).toContain("dead-node-elimination");
226
+ });
227
+
228
+ it("reports merge elision pass", () => {
229
+ const workflow: CirWorkflow = {
230
+ name: "single-merge",
231
+ entryNodeId: "a",
232
+ nodes: [
233
+ { id: "a", kind: "tool", source: { specNodeId: "a", specNodeKind: "tool", specPath: "graph.nodes[0]" }, action: { tool: "echo", args: ["a"] } },
234
+ { id: "m", kind: "merge", source: { specNodeId: "m", specNodeKind: "merge", specPath: "graph.nodes[1]" }, action: { join: { waitFor: ["a"], strategy: "all" } } },
235
+ { id: "b", kind: "tool", source: { specNodeId: "b", specNodeKind: "tool", specPath: "graph.nodes[2]" }, action: { tool: "echo", args: ["b"] }, terminal: true },
236
+ ],
237
+ transitions: [
238
+ { from: "a", to: "m", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[0]" } },
239
+ { from: "m", to: "b", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[1]" } },
240
+ ],
241
+ };
242
+
243
+ const { passes } = optimizeCirWorkflow(workflow);
244
+
245
+ expect(passes).toContain("single-branch-merge-elision");
246
+ });
247
+
248
+ it("reports fusion pass", () => {
249
+ const workflow: CirWorkflow = {
250
+ name: "fuse",
251
+ entryNodeId: "a",
252
+ nodes: [
253
+ { id: "a", kind: "tool", source: { specNodeId: "a", specNodeKind: "tool", specPath: "graph.nodes[0]" }, action: { tool: "bash", args: ["npm install"] } },
254
+ { id: "b", kind: "tool", source: { specNodeId: "b", specNodeKind: "tool", specPath: "graph.nodes[1]" }, action: { tool: "bash", args: ["npm test"] }, terminal: true },
255
+ ],
256
+ transitions: [
257
+ { from: "a", to: "b", when: "success", source: { kind: "graph-edge", specPath: "graph.edges[0]" } },
258
+ ],
259
+ };
260
+
261
+ const { passes } = optimizeCirWorkflow(workflow);
262
+
263
+ expect(passes).toContain("adjacent-tool-node-fusion");
264
+ });
265
+ });
266
+ });