@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,455 @@
1
+ import type { CirFailureRoutingHint, CirNode, CirVerificationHook } from "../cir/types.js";
2
+ import type { WorkflowContext, YieldItem } from "pi-duroxide";
3
+ import { addFailure, updateMetrics } from "../state/snapshots.js";
4
+ import type { HarnessState } from "../state/types.js";
5
+
6
+ export type TracePhase =
7
+ | "enter"
8
+ | "success"
9
+ | "failure"
10
+ | "retry"
11
+ | "verification-pass"
12
+ | "verification-fail"
13
+ | "merge"
14
+ | "condition-true"
15
+ | "condition-false";
16
+
17
+ export interface ExecutionTraceEntry {
18
+ nodeId: string;
19
+ source: CirNode["source"];
20
+ phase: TracePhase;
21
+ details?: Record<string, unknown>;
22
+ startedAt?: number;
23
+ completedAt?: number;
24
+ inputSnapshot?: unknown;
25
+ outputSnapshot?: unknown;
26
+ }
27
+
28
+ export interface ExecutionState {
29
+ input: unknown;
30
+ outputs: Record<string, unknown>;
31
+ trace: ExecutionTraceEntry[];
32
+ harnessState: HarnessState;
33
+ startTimeMs: number;
34
+ stepCount: number;
35
+ estimatedCostUsd: number;
36
+ }
37
+
38
+ export interface GuardrailState {
39
+ stepCount: number;
40
+ estimatedCostUsd: number;
41
+ maxSteps?: number;
42
+ costLimitUsd?: number;
43
+ }
44
+
45
+ export interface GuardrailResult {
46
+ withinLimits: boolean;
47
+ reason?: string;
48
+ }
49
+
50
+ export class GuardrailExceededError extends Error {
51
+ constructor(message: string) {
52
+ super(message);
53
+ this.name = "GuardrailExceededError";
54
+ }
55
+ }
56
+
57
+ export function checkGuardrails(state: GuardrailState): GuardrailResult {
58
+ if (state.maxSteps !== undefined && state.stepCount >= state.maxSteps) {
59
+ return {
60
+ withinLimits: false,
61
+ reason: `Step limit reached (${state.stepCount}/${state.maxSteps})`,
62
+ };
63
+ }
64
+
65
+ if (state.costLimitUsd !== undefined && state.estimatedCostUsd > state.costLimitUsd) {
66
+ return {
67
+ withinLimits: false,
68
+ reason: `Cost limit exceeded ($${state.estimatedCostUsd.toFixed(2)}/$${state.costLimitUsd.toFixed(2)})`,
69
+ };
70
+ }
71
+
72
+ return { withinLimits: true };
73
+ }
74
+
75
+ export interface FailureClassificationResult {
76
+ category: CirFailureRoutingHint["category"] | "permanent";
77
+ retryable: boolean;
78
+ matchedPattern?: string;
79
+ }
80
+
81
+ export type VerificationOutcome =
82
+ | { status: "pass" }
83
+ | { status: "warn"; hook: CirVerificationHook }
84
+ | { status: "block"; hook: CirVerificationHook; message: string }
85
+ | { status: "retry"; hook: CirVerificationHook; maxAttempts: number };
86
+
87
+ export function buildShellCommand(
88
+ tool: string,
89
+ args: string[],
90
+ cwd?: string,
91
+ env?: Record<string, string>,
92
+ ): string {
93
+ const baseCommand = [tool, ...args].map(shellQuote).join(" ");
94
+ const envPrefix =
95
+ env && Object.keys(env).length > 0
96
+ ? `env ${Object.entries(env)
97
+ .map(([key, value]) => `${validateEnvironmentVariableName(key)}=${shellQuote(value)}`)
98
+ .join(" ")} `
99
+ : "";
100
+ const command = `${envPrefix}${baseCommand}`.trim();
101
+
102
+ if (!cwd) {
103
+ return command;
104
+ }
105
+
106
+ return `cd ${shellQuote(cwd)} && ${command}`;
107
+ }
108
+
109
+ export function evaluateConditionExpression(expression: string, state: ExecutionState): boolean {
110
+ const trimmed = expression.trim();
111
+ const negate = trimmed.startsWith("!");
112
+ const path = negate ? trimmed.slice(1).trim() : trimmed;
113
+ const value = resolveConditionValue(path, state);
114
+ const result = normaliseBoolean(value);
115
+ return negate ? !result : result;
116
+ }
117
+
118
+ export function isVerificationSuccess(result: unknown): boolean {
119
+ if (typeof result === "boolean") {
120
+ return result;
121
+ }
122
+
123
+ const signal = resolveBooleanSignal(result);
124
+ if (signal !== undefined) {
125
+ return signal;
126
+ }
127
+
128
+ return Boolean(result);
129
+ }
130
+
131
+ export function interpretVerificationResult(
132
+ hook: CirVerificationHook,
133
+ verifierResult: unknown,
134
+ ): VerificationOutcome {
135
+ if (isVerificationSuccess(verifierResult)) {
136
+ return { status: "pass" };
137
+ }
138
+
139
+ switch (hook.onFail) {
140
+ case "warn":
141
+ return { status: "warn", hook };
142
+ case "block":
143
+ return {
144
+ status: "block",
145
+ hook,
146
+ message: `Verification failed via ${hook.checkNodeId}`,
147
+ };
148
+ case "retry":
149
+ return {
150
+ status: "retry",
151
+ hook,
152
+ maxAttempts: hook.maxAttempts ?? 2,
153
+ };
154
+ }
155
+ }
156
+
157
+ export function classifyFailure(
158
+ error: unknown,
159
+ failureRouting: CirFailureRoutingHint[] | undefined,
160
+ ): FailureClassificationResult {
161
+ const message = getErrorMessage(error);
162
+
163
+ if (failureRouting) {
164
+ for (const hint of failureRouting) {
165
+ if (message.includes(hint.pattern)) {
166
+ return {
167
+ category: hint.category,
168
+ retryable: hint.retry,
169
+ matchedPattern: hint.pattern,
170
+ };
171
+ }
172
+ }
173
+ }
174
+
175
+ return {
176
+ category: "permanent",
177
+ retryable: false,
178
+ };
179
+ }
180
+
181
+ export function computeRetryDelayMs(
182
+ retryPolicy: NonNullable<CirNode["retry"]>,
183
+ attemptNumber: number,
184
+ ): number {
185
+ const baseDelaySeconds = retryPolicy.initialDelay ?? 1;
186
+ let delaySeconds = baseDelaySeconds;
187
+
188
+ switch (retryPolicy.backoff) {
189
+ case "constant":
190
+ delaySeconds = baseDelaySeconds;
191
+ break;
192
+ case "linear":
193
+ delaySeconds = baseDelaySeconds * attemptNumber;
194
+ break;
195
+ case "exponential":
196
+ delaySeconds = baseDelaySeconds * 2 ** (attemptNumber - 1);
197
+ break;
198
+ }
199
+
200
+ if (retryPolicy.maxDelay !== undefined) {
201
+ delaySeconds = Math.min(delaySeconds, retryPolicy.maxDelay);
202
+ }
203
+
204
+ return delaySeconds * 1000;
205
+ }
206
+
207
+ export function shouldRetryFailure(
208
+ retryPolicy: NonNullable<CirNode["retry"]>,
209
+ classification: FailureClassificationResult,
210
+ ): boolean {
211
+ if (!classification.retryable) {
212
+ return false;
213
+ }
214
+
215
+ if (!retryPolicy.retryOn || retryPolicy.retryOn.length === 0) {
216
+ return true;
217
+ }
218
+
219
+ return retryPolicy.retryOn.includes(classification.category as "transient" | "resource");
220
+ }
221
+
222
+ export function recordTrace(
223
+ ctx: WorkflowContext,
224
+ state: ExecutionState,
225
+ node: CirNode,
226
+ phase: TracePhase,
227
+ details?: Record<string, unknown>,
228
+ inputSnapshot?: unknown,
229
+ outputSnapshot?: unknown,
230
+ ): void {
231
+ const entry: ExecutionTraceEntry = {
232
+ nodeId: node.id,
233
+ source: node.source,
234
+ phase,
235
+ ...(details ? { details } : {}),
236
+ ...(phase === "enter" ? { startedAt: Date.now() } : {}),
237
+ ...(phase === "success" || phase === "failure" ? { completedAt: Date.now() } : {}),
238
+ ...(inputSnapshot !== undefined ? { inputSnapshot: capSnapshot(inputSnapshot) } : {}),
239
+ ...(outputSnapshot !== undefined ? { outputSnapshot: capSnapshot(outputSnapshot) } : {}),
240
+ };
241
+
242
+ state.trace.push(entry);
243
+ ctx.setCustomStatus({
244
+ currentNodeId: node.id,
245
+ phase,
246
+ trace: state.trace,
247
+ });
248
+
249
+ const message = `[lasso] ${node.id} ${phase}`;
250
+ switch (phase) {
251
+ case "failure":
252
+ case "verification-fail":
253
+ ctx.traceWarn(message);
254
+ break;
255
+ case "retry":
256
+ ctx.traceInfo(message);
257
+ break;
258
+ default:
259
+ ctx.traceDebug(message);
260
+ break;
261
+ }
262
+ }
263
+
264
+ const MAX_SNAPSHOT_BYTES = 1024;
265
+
266
+ function capSnapshot(value: unknown): unknown {
267
+ const json = JSON.stringify(value);
268
+ if (json.length <= MAX_SNAPSHOT_BYTES) {
269
+ return value;
270
+ }
271
+ return json.slice(0, MAX_SNAPSHOT_BYTES) + "…(truncated)";
272
+ }
273
+
274
+ export function* runWithRetry<T>(
275
+ ctx: WorkflowContext,
276
+ state: ExecutionState,
277
+ node: CirNode,
278
+ executeAttempt: () => Generator<YieldItem, T, unknown>,
279
+ ): Generator<YieldItem, T, unknown> {
280
+ const maxAttempts = node.retry?.maxAttempts ?? 1;
281
+ let attemptNumber = 1;
282
+
283
+ while (true) {
284
+ try {
285
+ return yield* executeAttempt();
286
+ } catch (error) {
287
+ const classification = classifyFailure(error, node.failureRouting);
288
+ const errorMessage = getErrorMessage(error);
289
+
290
+ recordTrace(ctx, state, node, "failure", {
291
+ attemptNumber,
292
+ category: classification.category,
293
+ message: errorMessage,
294
+ ...(classification.matchedPattern ? { matchedPattern: classification.matchedPattern } : {}),
295
+ });
296
+
297
+ // Record normalized failure to harnessState
298
+ const rootCause = mapClassificationToRootCause(classification, errorMessage);
299
+ addFailure(state.harnessState, {
300
+ domainType: "lasso",
301
+ rootCause,
302
+ nodeId: node.id,
303
+ message: errorMessage,
304
+ });
305
+
306
+ if (!node.retry || attemptNumber >= maxAttempts || !shouldRetryFailure(node.retry, classification)) {
307
+ throw error;
308
+ }
309
+
310
+ // Increment retry counter
311
+ updateMetrics(state.harnessState, {
312
+ retries: state.harnessState.metrics.retries + 1,
313
+ });
314
+
315
+ const delayMs = computeRetryDelayMs(node.retry, attemptNumber);
316
+ recordTrace(ctx, state, node, "retry", {
317
+ nextAttempt: attemptNumber + 1,
318
+ delayMs,
319
+ });
320
+ if (delayMs > 0) {
321
+ yield ctx.scheduleTimer(delayMs);
322
+ }
323
+ attemptNumber += 1;
324
+ }
325
+ }
326
+ }
327
+
328
+ function mapClassificationToRootCause(
329
+ classification: FailureClassificationResult,
330
+ message: string,
331
+ ): import("../failures/types.js").FailureRecord["rootCause"] {
332
+ // Map CIR failure classification to normalized root causes
333
+ if (classification.category === "transient") {
334
+ if (message.toLowerCase().includes("timeout")) {
335
+ return "tool_timeout";
336
+ }
337
+ if (message.toLowerCase().includes("rate limit")) {
338
+ return "rate_limited";
339
+ }
340
+ return "unknown";
341
+ }
342
+
343
+ if (classification.category === "resource") {
344
+ return "rate_limited";
345
+ }
346
+
347
+ // Permanent failures
348
+ if (message.toLowerCase().includes("auth") || message.toLowerCase().includes("unauthorized")) {
349
+ return "auth_required";
350
+ }
351
+
352
+ return "dependency_failure";
353
+ }
354
+
355
+ function resolveConditionValue(path: string, state: ExecutionState): unknown {
356
+ const root = {
357
+ input: state.input,
358
+ outputs: state.outputs,
359
+ };
360
+
361
+ const directValue = tryResolvePath(root, path);
362
+ if (directValue.found) {
363
+ return directValue.value;
364
+ }
365
+
366
+ if (!path.startsWith("outputs.") && !path.startsWith("input.")) {
367
+ const outputValue = tryResolvePath(root, `outputs.${path}`);
368
+ if (outputValue.found) {
369
+ return outputValue.value;
370
+ }
371
+
372
+ const inputValue = tryResolvePath(root, `input.${path}`);
373
+ if (inputValue.found) {
374
+ return inputValue.value;
375
+ }
376
+ }
377
+
378
+ return undefined;
379
+ }
380
+
381
+ function tryResolvePath(root: Record<string, unknown>, path: string): { found: boolean; value: unknown } {
382
+ const segments = path
383
+ .split(".")
384
+ .map(segment => segment.trim())
385
+ .filter(Boolean);
386
+
387
+ let current: unknown = root;
388
+ for (const segment of segments) {
389
+ if (!current || typeof current !== "object" || !(segment in (current as Record<string, unknown>))) {
390
+ return { found: false, value: undefined };
391
+ }
392
+ current = (current as Record<string, unknown>)[segment];
393
+ }
394
+
395
+ return { found: true, value: current };
396
+ }
397
+
398
+ function normaliseBoolean(value: unknown): boolean {
399
+ if (typeof value === "boolean") {
400
+ return value;
401
+ }
402
+
403
+ const signal = resolveBooleanSignal(value);
404
+ if (signal !== undefined) {
405
+ return signal;
406
+ }
407
+
408
+ return Boolean(value);
409
+ }
410
+
411
+ function getErrorMessage(error: unknown): string {
412
+ if (error instanceof Error) {
413
+ return error.message;
414
+ }
415
+
416
+ return String(error);
417
+ }
418
+
419
+ function resolveBooleanSignal(value: unknown): boolean | undefined {
420
+ if (!value || typeof value !== "object") {
421
+ return undefined;
422
+ }
423
+
424
+ const record = value as Record<string, unknown>;
425
+ const flags = ["passed", "ok", "success", "approved"]
426
+ .filter(key => typeof record[key] === "boolean")
427
+ .map(key => ({ key, value: record[key] as boolean }));
428
+
429
+ if (flags.length === 0) {
430
+ return undefined;
431
+ }
432
+
433
+ const uniqueValues = new Set(flags.map(flag => flag.value));
434
+ if (uniqueValues.size > 1) {
435
+ throw new Error(`Ambiguous boolean status fields: ${flags.map(flag => flag.key).join(", ")}`);
436
+ }
437
+
438
+ return flags[0]?.value;
439
+ }
440
+
441
+ function shellQuote(value: string): string {
442
+ if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) {
443
+ return value;
444
+ }
445
+
446
+ return `'${value.replace(/'/g, `'\"'\"'`)}'`;
447
+ }
448
+
449
+ function validateEnvironmentVariableName(key: string): string {
450
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
451
+ throw new Error(`Invalid environment variable name: ${key}`);
452
+ }
453
+
454
+ return key;
455
+ }
@@ -0,0 +1,58 @@
1
+ import type { HarnessSpec, TaskNode, TaskEdge } from "../spec/types.js";
2
+ import type { HarnessStage, CompositionResult } from "./types.js";
3
+ import { prefixSpec, findTerminalNodes } from "./types.js";
4
+
5
+ const NODE_DURATION_MS = 500;
6
+
7
+ export function chainHarnesses(stages: HarnessStage[]): CompositionResult {
8
+ if (stages.length === 0) {
9
+ throw new Error("Cannot chain empty stages array");
10
+ }
11
+
12
+ if (stages.length === 1) {
13
+ const prefixed = prefixSpec(stages[0].name, stages[0].spec);
14
+ return {
15
+ combinedSpec: prefixed,
16
+ stageCount: 1,
17
+ totalNodes: prefixed.graph.nodes.length,
18
+ estimatedDurationMs: prefixed.graph.nodes.length * NODE_DURATION_MS,
19
+ };
20
+ }
21
+
22
+ const allNodes: TaskNode[] = [];
23
+ const allEdges: TaskEdge[] = [];
24
+ let prevPrefixed: HarnessSpec | undefined;
25
+
26
+ for (let i = 0; i < stages.length; i++) {
27
+ const prefixed = prefixSpec(stages[i].name, stages[i].spec);
28
+ allNodes.push(...prefixed.graph.nodes);
29
+ allEdges.push(...prefixed.graph.edges);
30
+
31
+ if (i > 0 && prevPrefixed) {
32
+ const prevTerminals = findTerminalNodes(prevPrefixed);
33
+ const currentEntry = prefixed.graph.entryNodeId;
34
+
35
+ for (const terminal of prevTerminals) {
36
+ allEdges.push({ from: terminal, to: currentEntry });
37
+ }
38
+ }
39
+
40
+ prevPrefixed = prefixed;
41
+ }
42
+
43
+ const combinedSpec: HarnessSpec = {
44
+ name: stages.map((s) => s.name).join("->"),
45
+ graph: {
46
+ entryNodeId: prefixSpec(stages[0].name, stages[0].spec).graph.entryNodeId,
47
+ nodes: allNodes,
48
+ edges: allEdges,
49
+ },
50
+ };
51
+
52
+ return {
53
+ combinedSpec,
54
+ stageCount: stages.length,
55
+ totalNodes: allNodes.length,
56
+ estimatedDurationMs: allNodes.length * NODE_DURATION_MS,
57
+ };
58
+ }
@@ -0,0 +1,76 @@
1
+ import type { HarnessSpec, TaskNode, TaskEdge, ConditionNode, MergeNode } from "../spec/types.js";
2
+ import { prefixSpec, findTerminalNodes } from "./types.js";
3
+ import type { CompositionResult } from "./types.js";
4
+
5
+ const NODE_DURATION_MS = 500;
6
+
7
+ export function conditionalHarness(
8
+ condition: string,
9
+ trueHarness: HarnessSpec,
10
+ falseHarness?: HarnessSpec,
11
+ ): CompositionResult {
12
+ const conditionNodeId = "_conditional";
13
+
14
+ const prefixedTrue = prefixSpec(trueHarness.name, trueHarness);
15
+ const allNodes: TaskNode[] = [...prefixedTrue.graph.nodes];
16
+ const allEdges: TaskEdge[] = [...prefixedTrue.graph.edges];
17
+
18
+ let trueTerminals = findTerminalNodes(prefixedTrue);
19
+
20
+ let stageCount = 1;
21
+ let falseTerminals: string[] = [];
22
+ let prefixedFalseEntryNodeId: string | undefined;
23
+
24
+ if (falseHarness) {
25
+ stageCount = 2;
26
+ const prefixedFalse = prefixSpec(falseHarness.name, falseHarness);
27
+ allNodes.push(...prefixedFalse.graph.nodes);
28
+ allEdges.push(...prefixedFalse.graph.edges);
29
+ falseTerminals = findTerminalNodes(prefixedFalse);
30
+ prefixedFalseEntryNodeId = prefixedFalse.graph.entryNodeId;
31
+ }
32
+
33
+ const conditionNode: ConditionNode = {
34
+ id: conditionNodeId,
35
+ kind: "condition",
36
+ condition,
37
+ thenNodeId: prefixedTrue.graph.entryNodeId,
38
+ elseNodeId: prefixedFalseEntryNodeId ?? prefixedTrue.graph.entryNodeId,
39
+ };
40
+ allNodes.unshift(conditionNode);
41
+
42
+ let mergeNodeId: string | undefined;
43
+ if (falseHarness && falseTerminals.length > 0) {
44
+ mergeNodeId = "_conditional_merge";
45
+ const mergeNode: MergeNode = {
46
+ id: mergeNodeId,
47
+ kind: "merge",
48
+ waitFor: [...trueTerminals, ...falseTerminals],
49
+ strategy: "any",
50
+ };
51
+ allNodes.push(mergeNode);
52
+
53
+ for (const terminal of trueTerminals) {
54
+ allEdges.push({ from: terminal, to: mergeNodeId });
55
+ }
56
+ for (const terminal of falseTerminals) {
57
+ allEdges.push({ from: terminal, to: mergeNodeId });
58
+ }
59
+ }
60
+
61
+ const combinedSpec: HarnessSpec = {
62
+ name: `conditional(${condition})`,
63
+ graph: {
64
+ entryNodeId: conditionNodeId,
65
+ nodes: allNodes,
66
+ edges: allEdges,
67
+ },
68
+ };
69
+
70
+ return {
71
+ combinedSpec,
72
+ stageCount,
73
+ totalNodes: allNodes.length,
74
+ estimatedDurationMs: allNodes.length * NODE_DURATION_MS,
75
+ };
76
+ }
@@ -0,0 +1,75 @@
1
+ import type { HarnessSpec, TaskNode, TaskEdge, MergeNode, ToolNode } from "../spec/types.js";
2
+ import { prefixSpec, findTerminalNodes } from "./types.js";
3
+ import type { CompositionResult } from "./types.js";
4
+
5
+ const NODE_DURATION_MS = 500;
6
+
7
+ export function parallelHarnesses(harnesses: HarnessSpec[]): CompositionResult {
8
+ if (harnesses.length === 0) {
9
+ throw new Error("Cannot parallel empty harnesses array");
10
+ }
11
+
12
+ if (harnesses.length === 1) {
13
+ const prefixed = prefixSpec(harnesses[0].name, harnesses[0]);
14
+ return {
15
+ combinedSpec: prefixed,
16
+ stageCount: 1,
17
+ totalNodes: prefixed.graph.nodes.length,
18
+ estimatedDurationMs: prefixed.graph.nodes.length * NODE_DURATION_MS,
19
+ };
20
+ }
21
+
22
+ const entryNodeId = "_parallel_entry";
23
+ const mergeNodeId = "_parallel_merge";
24
+
25
+ const entryNode: ToolNode = {
26
+ id: entryNodeId,
27
+ kind: "tool",
28
+ tool: "echo",
29
+ args: ["_parallel_entry"],
30
+ };
31
+
32
+ const allNodes: TaskNode[] = [entryNode];
33
+ const allEdges: TaskEdge[] = [];
34
+
35
+ const branchTerminals: string[] = [];
36
+
37
+ for (const harness of harnesses) {
38
+ const prefixed = prefixSpec(harness.name, harness);
39
+ allNodes.push(...prefixed.graph.nodes);
40
+ allEdges.push(...prefixed.graph.edges);
41
+
42
+ const terminals = findTerminalNodes(prefixed);
43
+ branchTerminals.push(...terminals);
44
+
45
+ allEdges.push({ from: entryNodeId, to: prefixed.graph.entryNodeId });
46
+ }
47
+
48
+ const mergeNode: MergeNode = {
49
+ id: mergeNodeId,
50
+ kind: "merge",
51
+ waitFor: branchTerminals,
52
+ strategy: "all",
53
+ };
54
+ allNodes.push(mergeNode);
55
+
56
+ for (const terminal of branchTerminals) {
57
+ allEdges.push({ from: terminal, to: mergeNodeId });
58
+ }
59
+
60
+ const combinedSpec: HarnessSpec = {
61
+ name: harnesses.map((h) => h.name).join("||"),
62
+ graph: {
63
+ entryNodeId,
64
+ nodes: allNodes,
65
+ edges: allEdges,
66
+ },
67
+ };
68
+
69
+ return {
70
+ combinedSpec,
71
+ stageCount: harnesses.length,
72
+ totalNodes: allNodes.length,
73
+ estimatedDurationMs: allNodes.length * NODE_DURATION_MS,
74
+ };
75
+ }