@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.
- package/README.md +707 -0
- package/docs/agent-wrangling.png +0 -0
- package/package.json +26 -0
- package/src/capabilities/matcher.ts +25 -0
- package/src/capabilities/registry.ts +103 -0
- package/src/capabilities/types.ts +15 -0
- package/src/cir/lower.ts +253 -0
- package/src/cir/optimize.ts +251 -0
- package/src/cir/types.ts +131 -0
- package/src/cir/validate.ts +265 -0
- package/src/compiler/compile.ts +601 -0
- package/src/compiler/feedback.ts +471 -0
- package/src/compiler/runtime-helpers.ts +455 -0
- package/src/composition/chain.ts +58 -0
- package/src/composition/conditional.ts +76 -0
- package/src/composition/parallel.ts +75 -0
- package/src/composition/types.ts +105 -0
- package/src/environment/analyzer.ts +56 -0
- package/src/environment/discovery.ts +179 -0
- package/src/environment/types.ts +68 -0
- package/src/failures/classifiers.ts +134 -0
- package/src/failures/generator.ts +421 -0
- package/src/failures/map-reference-failures.ts +23 -0
- package/src/failures/ontology.ts +210 -0
- package/src/failures/recovery.ts +214 -0
- package/src/failures/types.ts +14 -0
- package/src/index.ts +67 -0
- package/src/memory/advisor.ts +132 -0
- package/src/memory/extractor.ts +166 -0
- package/src/memory/store.ts +107 -0
- package/src/memory/types.ts +53 -0
- package/src/metaharness/engine.ts +256 -0
- package/src/metaharness/predictor.ts +168 -0
- package/src/metaharness/types.ts +40 -0
- package/src/mutation/derive.ts +308 -0
- package/src/mutation/diff.ts +52 -0
- package/src/mutation/engine.ts +256 -0
- package/src/mutation/types.ts +84 -0
- package/src/pi/command-input.ts +209 -0
- package/src/pi/commands.ts +351 -0
- package/src/pi/extension.ts +16 -0
- package/src/planner/synthesize.ts +83 -0
- package/src/planner/template-rules.ts +183 -0
- package/src/planner/types.ts +42 -0
- package/src/reference/catalog.ts +128 -0
- package/src/reference/patch-validation-strategies.ts +170 -0
- package/src/reference/patch-validation.ts +174 -0
- package/src/reference/pr-review-merge.ts +155 -0
- package/src/reference/strategies.ts +126 -0
- package/src/reference/types.ts +33 -0
- package/src/replanner/risk-rules.ts +161 -0
- package/src/replanner/runtime.ts +308 -0
- package/src/replanner/synthesize.ts +619 -0
- package/src/replanner/types.ts +73 -0
- package/src/spec/schema.ts +254 -0
- package/src/spec/types.ts +319 -0
- package/src/spec/validate.ts +296 -0
- package/src/state/snapshots.ts +43 -0
- package/src/state/types.ts +12 -0
- package/src/synthesis/graph-builder.ts +267 -0
- package/src/synthesis/harness-builder.ts +113 -0
- package/src/synthesis/intent-ir.ts +63 -0
- package/src/synthesis/policy-builder.ts +320 -0
- package/src/synthesis/risk-analyzer.ts +182 -0
- package/src/synthesis/skill-parser.ts +441 -0
- package/src/verification/engine.ts +230 -0
- package/src/versioning/file-store.ts +103 -0
- package/src/versioning/history.ts +43 -0
- package/src/versioning/store.ts +16 -0
- package/src/versioning/types.ts +31 -0
- package/test/capabilities/matcher.test.ts +67 -0
- package/test/capabilities/registry.test.ts +136 -0
- package/test/capabilities/synthesis.test.ts +264 -0
- package/test/cir/lower.test.ts +417 -0
- package/test/cir/optimize.test.ts +266 -0
- package/test/cir/validate.test.ts +368 -0
- package/test/compiler/adaptive-runtime.test.ts +157 -0
- package/test/compiler/compile.test.ts +1198 -0
- package/test/compiler/feedback.test.ts +784 -0
- package/test/compiler/guardrails.test.ts +191 -0
- package/test/compiler/trace.test.ts +404 -0
- package/test/composition/chain.test.ts +328 -0
- package/test/composition/conditional.test.ts +241 -0
- package/test/composition/parallel.test.ts +215 -0
- package/test/environment/analyzer.test.ts +204 -0
- package/test/environment/discovery.test.ts +149 -0
- package/test/failures/classifiers.test.ts +287 -0
- package/test/failures/generator.test.ts +203 -0
- package/test/failures/ontology.test.ts +439 -0
- package/test/failures/recovery.test.ts +300 -0
- package/test/helpers/createFixtureRepo.ts +84 -0
- package/test/helpers/createPatchValidationFixture.ts +144 -0
- package/test/helpers/runCompiledWorkflow.ts +208 -0
- package/test/memory/advisor.test.ts +332 -0
- package/test/memory/extractor.test.ts +295 -0
- package/test/memory/store.test.ts +244 -0
- package/test/metaharness/engine.test.ts +575 -0
- package/test/metaharness/predictor.test.ts +436 -0
- package/test/mutation/derive-failure.test.ts +209 -0
- package/test/mutation/engine.test.ts +622 -0
- package/test/package-smoke.test.ts +29 -0
- package/test/pi/command-input.test.ts +153 -0
- package/test/pi/commands.test.ts +623 -0
- package/test/planner/classify-template.test.ts +32 -0
- package/test/planner/synthesize.test.ts +901 -0
- package/test/reference/PatchValidation.failures.test.ts +137 -0
- package/test/reference/PatchValidation.test.ts +326 -0
- package/test/reference/PrReviewMerge.failures.test.ts +121 -0
- package/test/reference/PrReviewMerge.test.ts +55 -0
- package/test/reference/catalog-open.test.ts +70 -0
- package/test/replanner/runtime.test.ts +207 -0
- package/test/replanner/synthesize.test.ts +303 -0
- package/test/spec/validate.test.ts +1056 -0
- package/test/state/snapshots.test.ts +264 -0
- package/test/synthesis/custom-workflow.test.ts +264 -0
- package/test/synthesis/graph-builder.test.ts +370 -0
- package/test/synthesis/harness-builder.test.ts +128 -0
- package/test/synthesis/policy-builder.test.ts +149 -0
- package/test/synthesis/risk-analyzer.test.ts +230 -0
- package/test/synthesis/skill-parser.test.ts +796 -0
- package/test/verification/engine.test.ts +509 -0
- package/test/versioning/history.test.ts +144 -0
- package/test/versioning/store.test.ts +254 -0
- 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
|
+
}
|