@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,441 @@
1
+ import type { ExtractionResult } from "../planner/types.js";
2
+ import { extractFields } from "../planner/template-rules.js";
3
+ import type { IntentIR, IntentParseResult, IntentStep, IntentStepKind, SupportedWorkflowFamily } from "./intent-ir.js";
4
+ import { rejectUnsupportedIntent, validateIntent } from "./intent-ir.js";
5
+
6
+ export interface SkillMarkdown {
7
+ title?: string;
8
+ workflow?: string;
9
+ inputs?: Record<string, unknown>;
10
+ steps?: string[];
11
+ verificationTargets?: string[];
12
+ }
13
+
14
+ export function parseSkillMarkdown(markdown: string): SkillMarkdown {
15
+ const skill: SkillMarkdown = {};
16
+
17
+ // Extract title from first # heading
18
+ const titleMatch = markdown.match(/^#\s+(.+)$/m);
19
+ if (titleMatch) {
20
+ skill.title = titleMatch[1].trim();
21
+ }
22
+
23
+ // Extract workflow type
24
+ const workflowMatch = markdown.match(/workflow:\s*(.+)/i);
25
+ if (workflowMatch) {
26
+ skill.workflow = workflowMatch[1].trim();
27
+ }
28
+
29
+ // Extract inputs section
30
+ const inputsMatch = markdown.match(/##\s*inputs?\s*\n(.*?)(?=\n##|\n\n#|$)/is);
31
+ if (inputsMatch) {
32
+ skill.inputs = {};
33
+ const inputLines = inputsMatch[1].match(/^[-*]\s*(.+)$/gm);
34
+ if (inputLines) {
35
+ for (const line of inputLines) {
36
+ const cleanLine = line.replace(/^[-*]\s*/, "").trim();
37
+ const colonIndex = cleanLine.indexOf(":");
38
+ if (colonIndex > 0) {
39
+ const key = cleanLine.substring(0, colonIndex).trim();
40
+ const value = cleanLine.substring(colonIndex + 1).trim();
41
+ skill.inputs[key] = value;
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ // Extract steps
48
+ const stepsMatch = markdown.match(/##\s*steps?\s*\n(.*?)(?=\n##|\n\n#|$)/is);
49
+ if (stepsMatch) {
50
+ const stepLines = stepsMatch[1].match(/^[-*]\s*(.+)$/gm);
51
+ if (stepLines) {
52
+ skill.steps = stepLines.map(line => line.replace(/^[-*]\s*/, "").trim());
53
+ }
54
+ }
55
+
56
+ // Extract verification targets
57
+ const verificationMatch = markdown.match(/##\s*verification\s*\n(.*?)(?=\n##|\n\n#|$)/is);
58
+ if (verificationMatch) {
59
+ const verifyLines = verificationMatch[1].match(/^[-*]\s*(.+)$/gm);
60
+ if (verifyLines) {
61
+ skill.verificationTargets = verifyLines.map(line => line.replace(/^[-*]\s*/, "").trim());
62
+ }
63
+ }
64
+
65
+ return skill;
66
+ }
67
+
68
+ function extractionResultToIntentIR(extracted: ExtractionResult, workflowOverride?: string): IntentParseResult {
69
+ if (extracted.template === "ambiguous") {
70
+ return rejectUnsupportedIntent(
71
+ ["Could not determine workflow type - brief matches multiple or no workflow patterns"],
72
+ undefined,
73
+ [
74
+ "Please clearly specify either:",
75
+ "(1) PR review/merge with source and target branches, or",
76
+ "(2) Patch validation with baseline ref and candidate source."
77
+ ]
78
+ );
79
+ }
80
+
81
+ const family = extracted.template === "custom"
82
+ ? (workflowOverride || "custom")
83
+ : extracted.template;
84
+
85
+ const intent: IntentIR = {
86
+ family,
87
+ goal: family === "pr-review-merge"
88
+ ? "Review and merge PR"
89
+ : family === "patch-validation"
90
+ ? "Validate patch against baseline"
91
+ : `Execute ${family} workflow`,
92
+ inputs: {},
93
+ requiredTools: ["git"],
94
+ humanCheckpoints: [],
95
+ verificationTargets: []
96
+ };
97
+
98
+ // Map extracted fields to inputs
99
+ if (extracted.repoPath) intent.inputs.repoPath = extracted.repoPath;
100
+
101
+ if (family === "pr-review-merge") {
102
+ if (extracted.sourceBranch) intent.inputs.sourceBranch = extracted.sourceBranch;
103
+ if (extracted.targetBranch) intent.inputs.targetBranch = extracted.targetBranch;
104
+ if (extracted.reviewInstructions) intent.inputs.reviewInstructions = extracted.reviewInstructions;
105
+ if (extracted.verificationCommands) {
106
+ intent.inputs.verificationCommands = extracted.verificationCommands;
107
+ intent.verificationTargets = extracted.verificationCommands;
108
+ }
109
+ } else if (family === "patch-validation") {
110
+ if (extracted.baselineRef) intent.inputs.baselineRef = extracted.baselineRef;
111
+ if (extracted.candidateBranch) intent.inputs.candidateBranch = extracted.candidateBranch;
112
+ if (extracted.patchFilePath) intent.inputs.patchFilePath = extracted.patchFilePath;
113
+ if (extracted.reproduceCommands) intent.inputs.reproduceCommands = extracted.reproduceCommands;
114
+ if (extracted.verificationCommands) {
115
+ intent.inputs.verificationCommands = extracted.verificationCommands;
116
+ intent.verificationTargets = extracted.verificationCommands;
117
+ }
118
+ if (extracted.reviewInstructions) intent.inputs.reviewInstructions = extracted.reviewInstructions;
119
+ if (extracted.approvalRequired !== undefined) {
120
+ intent.inputs.approvalRequired = extracted.approvalRequired;
121
+ if (extracted.approvalRequired) {
122
+ intent.humanCheckpoints.push("approval-gate");
123
+ }
124
+ }
125
+ }
126
+
127
+ const validation = validateIntent(intent);
128
+ if (validation) {
129
+ return validation;
130
+ }
131
+
132
+ return { success: true, intent };
133
+ }
134
+
135
+ function parseSkillSteps(steps: string[]): IntentStep[] {
136
+ return steps.map((raw, index) => {
137
+ let kind: IntentStepKind = "tool";
138
+ let label = raw.trim();
139
+
140
+ const kindMatch = label.match(/^\[(tool|llm|human|condition)\]\s*/i);
141
+ if (kindMatch) {
142
+ kind = kindMatch[1].toLowerCase() as IntentStepKind;
143
+ label = label.slice(kindMatch[0].length).trim();
144
+ }
145
+
146
+ const id = `step-${index + 1}`;
147
+
148
+ const step: IntentStep = { id, label, kind };
149
+
150
+ if (kind === "tool") {
151
+ step.command = label;
152
+ } else if (kind === "llm") {
153
+ step.prompt = label;
154
+ } else if (kind === "human") {
155
+ step.description = label;
156
+ } else if (kind === "condition") {
157
+ step.description = label;
158
+ }
159
+
160
+ return step;
161
+ });
162
+ }
163
+
164
+ function normalizeInputValue(value: unknown): unknown {
165
+ if (typeof value !== "string") {
166
+ return value;
167
+ }
168
+
169
+ const trimmedValue = value.trim();
170
+
171
+ // Normalize array-like strings: "[npm test, npm run lint]" -> ["npm test", "npm run lint"]
172
+ // Handle empty arrays: "[]" -> []
173
+ const arrayMatch = trimmedValue.match(/^\[(.*)\]$/);
174
+ if (arrayMatch) {
175
+ const content = arrayMatch[1].trim();
176
+
177
+ // Handle empty array
178
+ if (content.length === 0) {
179
+ return [];
180
+ }
181
+
182
+ // Parse array with quote-aware splitting
183
+ return parseQuotedArray(content);
184
+ }
185
+
186
+ // Normalize boolean-like strings: "true" -> true, "false" -> false
187
+ if (trimmedValue.toLowerCase() === "true") {
188
+ return true;
189
+ }
190
+ if (trimmedValue.toLowerCase() === "false") {
191
+ return false;
192
+ }
193
+
194
+ return trimmedValue.replace(/^["']|["']$/g, "");
195
+ }
196
+
197
+ /**
198
+ * Parse a comma-separated string while respecting quoted strings.
199
+ * Handles both single and double quotes.
200
+ * Examples:
201
+ * "a, b, c" -> ["a", "b", "c"]
202
+ * '"echo hello", "npm test"' -> ["echo hello", "npm test"]
203
+ * '"echo \'hello, world\'", npm test' -> ["echo 'hello, world'", "npm test"]
204
+ */
205
+ function parseQuotedArray(content: string): string[] {
206
+ const items: string[] = [];
207
+ let current = "";
208
+ let inQuote: "'" | '"' | null = null;
209
+ let escaped = false;
210
+
211
+ for (let i = 0; i < content.length; i++) {
212
+ const char = content[i];
213
+
214
+ if (escaped) {
215
+ // Handle escaped character - convert \\ and \" / \' to actual characters
216
+ if (char === '"' || char === "'" || char === "\\") {
217
+ current += char;
218
+ } else {
219
+ current += "\\" + char;
220
+ }
221
+ escaped = false;
222
+ continue;
223
+ }
224
+
225
+ if (char === "\\") {
226
+ escaped = true;
227
+ continue;
228
+ }
229
+
230
+ if (char === '"' || char === "'") {
231
+ if (inQuote === null) {
232
+ // Starting a quoted string
233
+ inQuote = char;
234
+ current += char;
235
+ } else if (inQuote === char) {
236
+ // Ending the current quoted string
237
+ current += char;
238
+ inQuote = null;
239
+ } else {
240
+ // Different quote type inside current quote
241
+ current += char;
242
+ }
243
+ continue;
244
+ }
245
+
246
+ if (char === "," && inQuote === null) {
247
+ // Split point - not inside quotes
248
+ const trimmed = current.trim();
249
+ if (trimmed.length > 0) {
250
+ // Remove outer quotes if present
251
+ items.push(trimmed.replace(/^["']|["']$/g, ''));
252
+ }
253
+ current = "";
254
+ continue;
255
+ }
256
+
257
+ current += char;
258
+ }
259
+
260
+ // Add the last item
261
+ const trimmed = current.trim();
262
+ if (trimmed.length > 0) {
263
+ items.push(trimmed.replace(/^["']|["']$/g, ''));
264
+ }
265
+
266
+ return items.filter(item => item.length > 0);
267
+ }
268
+
269
+ /**
270
+ * Normalize skill inputs and merge verification targets into verificationCommands.
271
+ * This logic is shared between PR review and patch validation workflows.
272
+ */
273
+ function normalizeAndMergeVerification(
274
+ skill: SkillMarkdown
275
+ ): { normalizedInputs: Record<string, unknown>; verificationCommands: string[] } {
276
+ const normalizedInputs: Record<string, unknown> = {};
277
+
278
+ // Normalize all inputs
279
+ if (skill.inputs) {
280
+ for (const [key, value] of Object.entries(skill.inputs)) {
281
+ normalizedInputs[key] = normalizeInputValue(value);
282
+ }
283
+ }
284
+
285
+ for (const commandKey of ["reproduceCommands", "verificationCommands"] as const) {
286
+ const commandValue = normalizedInputs[commandKey];
287
+ if (typeof commandValue === "string") {
288
+ normalizedInputs[commandKey] = [commandValue];
289
+ }
290
+ }
291
+
292
+ // Merge verification targets into verificationCommands
293
+ let verificationCommands = normalizedInputs.verificationCommands as string[] | undefined;
294
+ if (skill.verificationTargets && skill.verificationTargets.length > 0) {
295
+ if (!verificationCommands) {
296
+ verificationCommands = [];
297
+ } else if (!Array.isArray(verificationCommands)) {
298
+ // Shouldn't happen after normalization, but guard anyway
299
+ verificationCommands = [];
300
+ }
301
+ verificationCommands = [...new Set([...verificationCommands, ...skill.verificationTargets])];
302
+ normalizedInputs.verificationCommands = verificationCommands;
303
+ }
304
+
305
+ return {
306
+ normalizedInputs,
307
+ verificationCommands: verificationCommands || skill.verificationTargets || []
308
+ };
309
+ }
310
+
311
+ function skillMarkdownToIntentIR(skill: SkillMarkdown): IntentParseResult {
312
+ // If workflow is explicitly specified, use it directly if supported
313
+ if (skill.workflow) {
314
+ const lowerWorkflow = skill.workflow.toLowerCase();
315
+ if (lowerWorkflow === "pr-review-merge" || lowerWorkflow.includes("pr") || lowerWorkflow.includes("pull request")) {
316
+ const { normalizedInputs, verificationCommands } = normalizeAndMergeVerification(skill);
317
+
318
+ const intent: IntentIR = {
319
+ family: "pr-review-merge",
320
+ goal: "Review and merge PR",
321
+ inputs: normalizedInputs,
322
+ requiredTools: ["git"],
323
+ humanCheckpoints: [],
324
+ verificationTargets: verificationCommands
325
+ };
326
+
327
+ if (skill.steps && skill.steps.length > 0) {
328
+ intent.steps = parseSkillSteps(skill.steps);
329
+ }
330
+
331
+ const validation = validateIntent(intent);
332
+ if (validation) {
333
+ return validation;
334
+ }
335
+
336
+ return { success: true, intent };
337
+ } else if (lowerWorkflow === "patch-validation" || lowerWorkflow.includes("patch") || lowerWorkflow.includes("validation")) {
338
+ const { normalizedInputs, verificationCommands } = normalizeAndMergeVerification(skill);
339
+
340
+ const intent: IntentIR = {
341
+ family: "patch-validation",
342
+ goal: "Validate patch against baseline",
343
+ inputs: normalizedInputs,
344
+ requiredTools: ["git"],
345
+ humanCheckpoints: [],
346
+ verificationTargets: verificationCommands
347
+ };
348
+
349
+ if (skill.steps && skill.steps.length > 0) {
350
+ intent.steps = parseSkillSteps(skill.steps);
351
+ }
352
+
353
+ if (normalizedInputs.approvalRequired === true) {
354
+ intent.humanCheckpoints.push("approval-gate");
355
+ }
356
+
357
+ const validation = validateIntent(intent);
358
+ if (validation) {
359
+ return validation;
360
+ }
361
+
362
+ return { success: true, intent };
363
+ }
364
+ }
365
+
366
+ // Fallback: reconstruct a brief-like string to reuse extraction logic
367
+ const briefParts: string[] = [];
368
+
369
+ if (skill.workflow) {
370
+ briefParts.push(`workflow: ${skill.workflow}`);
371
+ }
372
+
373
+ if (skill.inputs) {
374
+ for (const [key, value] of Object.entries(skill.inputs)) {
375
+ briefParts.push(`${key}: ${value}`);
376
+ }
377
+ }
378
+
379
+ if (skill.verificationTargets && skill.verificationTargets.length > 0) {
380
+ briefParts.push(`verification commands: [${skill.verificationTargets.join(", ")}]`);
381
+ }
382
+
383
+ const reconstructedBrief = briefParts.join("\n");
384
+
385
+ // Parse using existing extraction logic
386
+ const extracted = extractFields(reconstructedBrief);
387
+
388
+ // If the template is "custom" but we have an explicit workflow name, use it directly
389
+ if (extracted.template === "custom" && skill.workflow) {
390
+ const { normalizedInputs, verificationCommands } = normalizeAndMergeVerification(skill);
391
+
392
+ const intent: IntentIR = {
393
+ family: skill.workflow,
394
+ goal: `Execute ${skill.workflow} workflow`,
395
+ inputs: normalizedInputs,
396
+ requiredTools: ["git"],
397
+ humanCheckpoints: [],
398
+ verificationTargets: verificationCommands
399
+ };
400
+
401
+ if (skill.steps && skill.steps.length > 0) {
402
+ intent.steps = parseSkillSteps(skill.steps);
403
+ }
404
+
405
+ return { success: true, intent };
406
+ }
407
+
408
+ return extractionResultToIntentIR(extracted);
409
+ }
410
+
411
+ export function parsePromptOrSkill(input: string): IntentParseResult {
412
+ if (!input || input.trim().length === 0) {
413
+ return rejectUnsupportedIntent(
414
+ ["Input is empty"],
415
+ undefined,
416
+ ["Please provide a workflow description"]
417
+ );
418
+ }
419
+
420
+ // Detect if this is skill markdown (has markdown headings and structure)
421
+ const hasMarkdownStructure = /^#\s+/m.test(input) || /^##\s+/m.test(input);
422
+
423
+ if (hasMarkdownStructure) {
424
+ const skill = parseSkillMarkdown(input);
425
+ return skillMarkdownToIntentIR(skill);
426
+ }
427
+
428
+ // Otherwise treat as freeform brief
429
+ const extracted = extractFields(input);
430
+
431
+ // If the template is "custom", try to extract the explicit workflow name
432
+ let workflowOverride: string | undefined;
433
+ if (extracted.template === "custom") {
434
+ const workflowMatch = input.match(/workflow:\s*(.+)/i);
435
+ if (workflowMatch) {
436
+ workflowOverride = workflowMatch[1].trim();
437
+ }
438
+ }
439
+
440
+ return extractionResultToIntentIR(extracted, workflowOverride);
441
+ }
@@ -0,0 +1,230 @@
1
+ import type { CirNode, CirVerificationHook } from "../cir/types.js";
2
+ import { evaluateConditionExpression, type ExecutionState, type VerificationOutcome } from "../compiler/runtime-helpers.js";
3
+ import type { WorkflowContext, YieldItem } from "pi-duroxide";
4
+
5
+ export type VerificationStrategy = "all-must-pass" | "first-pass" | "any-block";
6
+
7
+ export interface VerificationReport {
8
+ nodeId: string;
9
+ hookResults: Array<{
10
+ hook: CirVerificationHook;
11
+ outcome: VerificationOutcome;
12
+ durationMs: number;
13
+ }>;
14
+ overallStatus: "pass" | "warn" | "block";
15
+ }
16
+
17
+ export function isVerificationSuccess(result: unknown): boolean {
18
+ if (typeof result === "boolean") {
19
+ return result;
20
+ }
21
+
22
+ const signal = resolveBooleanSignal(result);
23
+ if (signal !== undefined) {
24
+ return signal;
25
+ }
26
+
27
+ return Boolean(result);
28
+ }
29
+
30
+ export function interpretVerificationResult(
31
+ hook: CirVerificationHook,
32
+ verifierResult: unknown,
33
+ ): VerificationOutcome {
34
+ if (isVerificationSuccess(verifierResult)) {
35
+ return { status: "pass" };
36
+ }
37
+
38
+ switch (hook.onFail) {
39
+ case "warn":
40
+ return { status: "warn", hook };
41
+ case "block":
42
+ return {
43
+ status: "block",
44
+ hook,
45
+ message: `Verification failed via ${hook.checkNodeId}`,
46
+ };
47
+ case "retry":
48
+ return {
49
+ status: "retry",
50
+ hook,
51
+ maxAttempts: hook.maxAttempts ?? 2,
52
+ };
53
+ }
54
+ }
55
+
56
+ export function* runVerification(
57
+ nodeId: string,
58
+ hooks: CirVerificationHook[],
59
+ nodeMap: Map<string, CirNode>,
60
+ state: ExecutionState,
61
+ ctx: WorkflowContext,
62
+ strategy: VerificationStrategy = "all-must-pass",
63
+ ): Generator<YieldItem, VerificationReport, unknown> {
64
+ const hookResults: VerificationReport["hookResults"] = [];
65
+
66
+ if (!hooks || hooks.length === 0) {
67
+ return { nodeId, hookResults: [], overallStatus: "pass" };
68
+ }
69
+
70
+ let terminalOutcome: VerificationOutcome | undefined;
71
+
72
+ for (const hook of hooks) {
73
+ const verifierNode = getNode(nodeMap, hook.checkNodeId);
74
+ const startTimeMs = Date.now();
75
+ let verifierOutput: unknown;
76
+
77
+ if (hook.kind === "expression") {
78
+ if (verifierNode.kind !== "condition") {
79
+ throw new Error(`Expression verification ${hook.checkNodeId} must reference a condition node`);
80
+ }
81
+ verifierOutput = evaluateConditionExpression(verifierNode.action.conditionExpr, state);
82
+ state.outputs[verifierNode.id] = {
83
+ evaluated: true,
84
+ result: verifierOutput,
85
+ expression: verifierNode.action.conditionExpr,
86
+ };
87
+ } else {
88
+ if (verifierNode.kind === "condition" || verifierNode.kind === "merge") {
89
+ throw new Error(`Verification node ${verifierNode.id} is not directly executable`);
90
+ }
91
+ verifierOutput = yield createVerificationYieldItem(ctx, verifierNode);
92
+ state.outputs[verifierNode.id] = verifierOutput;
93
+ }
94
+
95
+ const durationMs = Date.now() - startTimeMs;
96
+ const outcome = interpretVerificationResult(hook, verifierOutput);
97
+ hookResults.push({ hook, outcome, durationMs });
98
+
99
+ const earlyExit = shouldStopEarly(outcome, strategy);
100
+ if (earlyExit) {
101
+ terminalOutcome = outcome;
102
+ break;
103
+ }
104
+ }
105
+
106
+ const overallStatus = terminalOutcome
107
+ ? terminalOutcome.status === "block" || terminalOutcome.status === "retry" ? "block" : "pass"
108
+ : computeFinalStatus(hookResults);
109
+
110
+ return { nodeId, hookResults, overallStatus };
111
+ }
112
+
113
+ function shouldStopEarly(outcome: VerificationOutcome, strategy: VerificationStrategy): boolean {
114
+ switch (strategy) {
115
+ case "all-must-pass":
116
+ return outcome.status === "block" || outcome.status === "retry";
117
+ case "first-pass":
118
+ return outcome.status === "pass";
119
+ case "any-block":
120
+ return outcome.status === "block" || outcome.status === "retry";
121
+ }
122
+ }
123
+
124
+ function computeFinalStatus(hookResults: VerificationReport["hookResults"]): VerificationReport["overallStatus"] {
125
+ const hasBlock = hookResults.some(r => r.outcome.status === "block");
126
+ if (hasBlock) return "block";
127
+
128
+ return "pass";
129
+ }
130
+
131
+ function createVerificationYieldItem(
132
+ ctx: WorkflowContext,
133
+ node: Exclude<CirNode, { kind: "condition" | "merge" }>,
134
+ ): YieldItem {
135
+ switch (node.kind) {
136
+ case "tool":
137
+ return ctx.pi.tool("bash", {
138
+ command: buildShellCommand(node.action.tool, node.action.args, node.action.cwd, node.action.env),
139
+ description: `Lasso verification node ${node.id}`,
140
+ });
141
+ case "llm": {
142
+ const messages = [];
143
+ if (node.action.system) {
144
+ messages.push({
145
+ role: "system",
146
+ content: [{ type: "text", text: node.action.system }],
147
+ });
148
+ }
149
+ messages.push({
150
+ role: "user",
151
+ content: [{ type: "text", text: node.action.prompt }],
152
+ });
153
+ return ctx.pi.llm(messages, {
154
+ model: node.action.model,
155
+ });
156
+ }
157
+ case "human":
158
+ return ctx.waitForEvent(`lasso:verification:${node.id}`);
159
+ case "subworkflow":
160
+ return ctx.scheduleSubOrchestration(node.action.specRef, node.action.inputs ?? {});
161
+ }
162
+ }
163
+
164
+ function buildShellCommand(
165
+ tool: string,
166
+ args: string[],
167
+ cwd?: string,
168
+ env?: Record<string, string>,
169
+ ): string {
170
+ const baseCommand = [tool, ...args].map(shellQuote).join(" ");
171
+ const envPrefix =
172
+ env && Object.keys(env).length > 0
173
+ ? `env ${Object.entries(env)
174
+ .map(([key, value]) => `${validateEnvironmentVariableName(key)}=${shellQuote(value)}`)
175
+ .join(" ")} `
176
+ : "";
177
+ const command = `${envPrefix}${baseCommand}`.trim();
178
+
179
+ if (!cwd) {
180
+ return command;
181
+ }
182
+
183
+ return `cd ${shellQuote(cwd)} && ${command}`;
184
+ }
185
+
186
+ function getNode(nodeMap: Map<string, CirNode>, nodeId: string): CirNode {
187
+ const node = nodeMap.get(nodeId);
188
+ if (!node) {
189
+ throw new Error(`Verification node ${nodeId} not found in node map`);
190
+ }
191
+ return node;
192
+ }
193
+
194
+ function resolveBooleanSignal(value: unknown): boolean | undefined {
195
+ if (!value || typeof value !== "object") {
196
+ return undefined;
197
+ }
198
+
199
+ const record = value as Record<string, unknown>;
200
+ const flags = ["passed", "ok", "success", "approved"]
201
+ .filter(key => typeof record[key] === "boolean")
202
+ .map(key => ({ key, value: record[key] as boolean }));
203
+
204
+ if (flags.length === 0) {
205
+ return undefined;
206
+ }
207
+
208
+ const uniqueValues = new Set(flags.map(flag => flag.value));
209
+ if (uniqueValues.size > 1) {
210
+ throw new Error(`Ambiguous boolean status fields: ${flags.map(flag => flag.key).join(", ")}`);
211
+ }
212
+
213
+ return flags[0]?.value;
214
+ }
215
+
216
+ function shellQuote(value: string): string {
217
+ if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) {
218
+ return value;
219
+ }
220
+
221
+ return `'${value.replace(/'/g, `'\"'\"'`)}'`;
222
+ }
223
+
224
+ function validateEnvironmentVariableName(key: string): string {
225
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
226
+ throw new Error(`Invalid environment variable name: ${key}`);
227
+ }
228
+
229
+ return key;
230
+ }