@mhingston5/lasso 0.1.0 → 0.2.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.
Binary file
Binary file
File without changes
package/README.md CHANGED
@@ -4,8 +4,42 @@
4
4
  <img src="docs/agent-wrangling.png" width="320" alt="Agent Wrangling" />
5
5
  </p>
6
6
 
7
- Lasso is a dynamic harness engine built on `pi-duroxide`. It goes from intent to
8
- executable workflow — and repairs the harness while it runs.
7
+ ## Table of contents
8
+
9
+ - [What is Lasso?](#what-is-lasso)
10
+ - [Why Lasso exists](#why-lasso-exists)
11
+ - [What Lasso does](#what-lasso-does)
12
+ - [How it works](#how-it-works)
13
+ - [Slash commands](#slash-commands)
14
+ - [Bundled workflows](#bundled-workflows)
15
+ - [Request examples](#request-examples)
16
+ - [Custom workflows](#custom-workflows)
17
+ - [HarnessSpec reference](#harnessspec-reference)
18
+ - [Library API](#library-api)
19
+ - [Compiler](#compiler)
20
+ - [Compiler feedback](#compiler-feedback)
21
+ - [Verification engine](#verification-engine)
22
+ - [Compiler optimizations](#compiler-optimizations)
23
+ - [Harness mutations](#harness-mutations)
24
+ - [Guardrails](#guardrails)
25
+ - [Failure mode generation](#failure-mode-generation)
26
+ - [Risk assessment](#risk-assessment)
27
+ - [Per-node harnesses](#per-node-harnesses)
28
+ - [Trace-based synthesis](#trace-based-synthesis)
29
+ - [Adaptive runtime](#adaptive-runtime)
30
+ - [Lineage persistence](#lineage-persistence)
31
+ - [Harness memory](#harness-memory)
32
+ - [Environment model](#environment-model)
33
+ - [Failure ontology](#failure-ontology)
34
+ - [Capabilities](#capabilities)
35
+ - [Meta-harness](#meta-harness)
36
+ - [Multi-harness composition](#multi-harness-composition)
37
+ - [How Lasso fits with pi-duroxide](#how-lasso-fits-with-pi-duroxide)
38
+ - [Non-goals](#non-goals)
39
+
40
+ ---
41
+
42
+ Lasso goes from intent to executable workflow — and repairs the harness while it runs.
9
43
 
10
44
  ```text
11
45
  Intent
@@ -13,20 +47,21 @@ Intent
13
47
  → Memory query (past patterns, what worked/failed)
14
48
  → Graph synthesis (planner + capabilities)
15
49
  → Failure prediction (auth, tool, network, resource)
50
+ → Risk assessment (probability × impact, threshold filtering)
16
51
  → Policy synthesis (mutations: add verification, retry, approval)
17
52
  → Compilation (validate → lower → optimize → execute)
18
- Runtime adaptation (trace mutate → continueAsNew)
53
+ Per-node harnesses (guardrails, verification hooks)
54
+ → Runtime adaptation (trace → synthesize → continueAsNew)
19
55
  ```
20
56
 
21
- If `pi-duroxide` is the durable runtime engine, **Lasso is the layer that
22
- generates, optimizes, and repairs the harnesses that run on it**.
23
-
24
57
  ## What is Lasso?
25
58
 
26
- Lasso is a **pi coding agent extension** a TypeScript package that plugs into
27
- pi via the `pi` field in `package.json`. When installed, it:
59
+ Lasso is a **runtime harness synthesizer** built on [pi-duroxide](https://github.com/mhingston/pi-duroxide). It
60
+ synthesizes deterministic scaffolding around non-deterministic parts predicting
61
+ failures, assessing risks, and generating per-node guardrails before execution.
62
+ It's a TypeScript package that plugs into pi via the `pi` field in `package.json`. When installed, it:
28
63
 
29
- 1. Boots `pi-duroxide` (the durable workflow runtime)
64
+ 1. Boots [pi-duroxide](https://github.com/mhingston/pi-duroxide) (the durable workflow runtime)
30
65
  2. Registers 5 slash commands (`/lasso:plan`, `/lasso:run`, etc.)
31
66
  3. Exports a library API for programmatic use
32
67
 
@@ -75,41 +110,11 @@ const signature = classifyFailure(error, { nodeId: "deploy" });
75
110
  > target repo. Use a throwaway clone or disposable worktree, not your primary
76
111
  > checkout.
77
112
 
78
- ## Table of contents
79
-
80
- - [What is Lasso?](#what-is-lasso)
81
- - [Why Lasso exists](#why-lasso-exists)
82
- - [What Lasso does](#what-lasso-does)
83
- - [How it works](#how-it-works)
84
- - [Slash commands](#slash-commands)
85
- - [Bundled workflows](#bundled-workflows)
86
- - [Request examples](#request-examples)
87
- - [Custom workflows](#custom-workflows)
88
- - [HarnessSpec reference](#harnessspec-reference)
89
- - [Library API](#library-api)
90
- - [Compiler](#compiler)
91
- - [Compiler feedback](#compiler-feedback)
92
- - [Verification engine](#verification-engine)
93
- - [Compiler optimizations](#compiler-optimizations)
94
- - [Harness mutations](#harness-mutations)
95
- - [Guardrails](#guardrails)
96
- - [Failure mode generation](#failure-mode-generation)
97
- - [Adaptive runtime](#adaptive-runtime)
98
- - [Lineage persistence](#lineage-persistence)
99
- - [Harness memory](#harness-memory)
100
- - [Environment model](#environment-model)
101
- - [Failure ontology](#failure-ontology)
102
- - [Capabilities](#capabilities)
103
- - [Meta-harness](#meta-harness)
104
- - [Multi-harness composition](#multi-harness-composition)
105
- - [How Lasso fits with pi-duroxide](#how-lasso-fits-with-pi-duroxide)
106
- - [Non-goals](#non-goals)
107
-
108
113
  ---
109
114
 
110
115
  ## Why Lasso exists
111
116
 
112
- `pi-duroxide` gives you a durable workflow runtime. That is the right layer when
117
+ [pi-duroxide](https://github.com/mhingston/pi-duroxide) gives you a durable workflow runtime. That is the right layer when
113
118
  you already know what workflow you want to run.
114
119
 
115
120
  Lasso sits one level higher. It:
@@ -134,7 +139,7 @@ Use Lasso when you want workflow automation that is:
134
139
 
135
140
  Lasso takes a declarative `HarnessSpec`, validates it, lowers it to CIR,
136
141
  optimizes it, and compiles it into a replay-safe workflow that runs on
137
- `pi-duroxide`.
142
+ [pi-duroxide](https://github.com/mhingston/pi-duroxide).
138
143
 
139
144
  Out of the box, it ships with:
140
145
 
@@ -155,9 +160,13 @@ buildTaskGraph() → TaskGraph
155
160
 
156
161
  analyzeRisks() → RiskModel
157
162
 
163
+ generateFailureModes() → FailureMode[] + Risk[]
164
+
165
+ assessRisks() → RiskAssessment (overallScore, threshold filtering)
166
+
158
167
  synthesizePolicy() → PolicyBundle
159
168
 
160
- synthesizeHarness() → HarnessSpec
169
+ synthesizeHarness() → HarnessSpec (with per-node guardrails & verification hooks)
161
170
 
162
171
  compileHarnessSpec() → CompiledWorkflow → pi-duroxide
163
172
  ```
@@ -169,7 +178,9 @@ Workflow executes
169
178
 
170
179
  Execution trace captured (timestamps, I/O snapshots, failures)
171
180
 
172
- deriveMutationsFromTrace() → HarnessMutation[]
181
+ synthesizeFromTrace(trace, currentSpec, env) → HarnessSynthesisResult
182
+ → classifies repeated failures, slow nodes, cost spikes
183
+ → derives mutations
173
184
 
174
185
  mutateHarness(spec, mutations) → new spec
175
186
 
@@ -391,6 +402,13 @@ All top-level objects are **strict**. Unknown fields are rejected.
391
402
  | `merge` | `waitFor`, `strategy` | Fork-join synchronization |
392
403
  | `subworkflow` | `specRef`, `inputs` | `ctx.scheduleSubOrchestration()` |
393
404
 
405
+ **Per-node fields** (available on all node kinds via `BaseNode`):
406
+
407
+ | Field | Type | Notes |
408
+ | --- | --- | --- |
409
+ | `guardrails` | `NodeGuardrails` | Per-node limits (timeout, retries, cost, constraints) |
410
+ | `verificationHooks` | `VerificationHook[]` | Inline checks that run after this node completes |
411
+
394
412
  ### Validation rules
395
413
 
396
414
  1. Node IDs must be unique
@@ -501,6 +519,131 @@ Failure modes are cross-referenced with environment constraints: if auth
501
519
  constraint detected, auth failure probability is boosted. Each mode includes
502
520
  triggers, mitigations, and recovery actions.
503
521
 
522
+ `generateFailureModes()` now returns `risks: Risk[]` alongside `failureModes`,
523
+ converting each failure mode into a quantified risk with probability, impact,
524
+ and score.
525
+
526
+ ### Risk assessment
527
+
528
+ First-class `Risk` type with quantitative scoring. Each risk carries probability
529
+ (0-1), impact (0-1), and a composite score. `assessRisks()` filters by threshold
530
+ and returns a structured assessment.
531
+
532
+ ```typescript
533
+ import { generateFailureModes, assessRisks } from "lasso";
534
+
535
+ const generation = generateFailureModes("Deploy my app to staging", env);
536
+ // generation.risks — Risk[] converted from failure modes
537
+
538
+ const assessment = assessRisks(generation.risks);
539
+ // assessment.overallScore — average risk score (0-1)
540
+ // assessment.risksAboveThreshold — risks scoring >= highRiskThreshold (default 0.7)
541
+ // assessment.highRiskThreshold — the threshold used
542
+
543
+ // Custom threshold
544
+ const strict = assessRisks(generation.risks, { highRiskThreshold: 0.5 });
545
+ ```
546
+
547
+ **Risk interface:**
548
+
549
+ | Field | Type | Description |
550
+ | --- | --- | --- |
551
+ | `id` | `string` | Unique risk identifier |
552
+ | `probability` | `number` (0-1) | Likelihood of occurrence |
553
+ | `impact` | `number` (0-1) | Severity if it occurs |
554
+ | `score` | `number` | `probability × impact` |
555
+ | `signals` | `string[]` | Triggers or indicators |
556
+ | `mitigations` | `HarnessMutation[]` | Suggested mitigations as executable mutations |
557
+ | `failureClass` | `FailureClass` | Classification (auth, tool, network, etc.) |
558
+ | `description` | `string` | Human-readable description |
559
+
560
+ ### Per-node harnesses
561
+
562
+ Every node in a `HarnessSpec` can carry its own guardrails and verification
563
+ hooks. These override global settings and run only during that node's execution.
564
+
565
+ ```json
566
+ {
567
+ "id": "deploy",
568
+ "kind": "tool",
569
+ "tool": "bash",
570
+ "args": ["./deploy.sh"],
571
+ "guardrails": {
572
+ "timeoutSeconds": 120,
573
+ "maxRetries": 2,
574
+ "maxCostUsd": 0.10,
575
+ "constraints": ["exit_code == 0"]
576
+ },
577
+ "verificationHooks": [
578
+ {
579
+ "name": "health-check",
580
+ "kind": "tool",
581
+ "check": "curl -sf http://localhost:3000/health",
582
+ "onFail": "block",
583
+ "maxAttempts": 3
584
+ }
585
+ ]
586
+ }
587
+ ```
588
+
589
+ **NodeGuardrails:**
590
+
591
+ | Field | Type | Description |
592
+ | --- | --- | --- |
593
+ | `timeoutSeconds` | `number` | Max execution time for this node |
594
+ | `maxRetries` | `number` | Max retries (overrides global retryPolicy) |
595
+ | `maxCostUsd` | `number` | Max LLM cost for this node |
596
+ | `constraints` | `string[]` | Custom expressions that must hold true |
597
+
598
+ **VerificationHook:**
599
+
600
+ | Field | Type | Description |
601
+ | --- | --- | --- |
602
+ | `name` | `string` | Hook identifier |
603
+ | `kind` | `"tool" \| "llm" \| "expression"` | Type of check |
604
+ | `check` | `string` | Tool name, LLM prompt, or expression |
605
+ | `onFail` | `"block" \| "warn" \| "retry"` | Action on failure |
606
+ | `maxAttempts` | `number` | Max verification attempts (optional) |
607
+
608
+ Per-node guardrails override global `executionPolicy` settings. Verification
609
+ hooks run inline after the node completes, with retry/block/warn semantics.
610
+
611
+ ### Trace-based synthesis
612
+
613
+ `synthesizeFromTrace()` analyzes an execution trace mid-flight, classifies
614
+ failures, and derives mutations — wired into the compiler's adaptation loop.
615
+
616
+ ```typescript
617
+ import { DefaultMetaHarness } from "lasso";
618
+
619
+ const meta = new DefaultMetaHarness(config);
620
+
621
+ const trace = {
622
+ completedNodes: [
623
+ { nodeId: "build", startedAt: 1, completedAt: 2, costUsd: 0.05 },
624
+ ],
625
+ failedNodes: [
626
+ { nodeId: "deploy", startedAt: 2, failedAt: 3, error: "auth expired", failureClass: "auth", retryCount: 3 },
627
+ ],
628
+ totalCostUsd: 0.15,
629
+ capturedAt: Date.now(),
630
+ };
631
+
632
+ const result = await meta.synthesizeFromTrace(trace, currentSpec, environment);
633
+ // result.mutations — HarnessMutation[] derived from trace analysis
634
+ // result.spec — mutated HarnessSpec
635
+ // result.rationale — human-readable explanation of changes
636
+ // result.decision — "continue" | "needs_operator_input" | "stop"
637
+ ```
638
+
639
+ The synthesis classifies:
640
+ - **Repeated failures** — same node failing across retries → add verification or block
641
+ - **Slow nodes** — duration spikes → tighten timeout guardrails
642
+ - **Cost spikes** — LLM cost above expected → swap to cheaper model
643
+
644
+ This feeds directly into the `continueAsNew` path, producing a new harness
645
+ version with repairs applied.
646
+
504
647
  ### Verification engine
505
648
 
506
649
  Standalone module with compositional strategies:
@@ -685,15 +828,15 @@ Lasso is distributed as a **pi extension** (`package.json` has a `"pi"` field
685
828
  pointing to `./src/index.ts`). When you `pi install` it:
686
829
 
687
830
  1. pi loads `src/index.ts`, which exports a default extension function
688
- 2. That function (`src/pi/extension.ts`) first boots `pi-duroxide`
831
+ 2. That function (`src/pi/extension.ts`) first boots [pi-duroxide](https://github.com/mhingston/pi-duroxide)
689
832
  3. Then it registers the 5 slash commands with pi's `ExtensionAPI`
690
833
 
691
834
  The layering:
692
835
 
693
- - `pi-duroxide` owns workflow lifecycle, replay, timers, events, and runtime registration
836
+ - [pi-duroxide](https://github.com/mhingston/pi-duroxide) owns workflow lifecycle, replay, timers, events, and runtime registration
694
837
  - Lasso owns spec validation, CIR lowering, optimization, compilation, and operator-facing commands
695
838
 
696
- In other words: `pi-duroxide` is the durable runtime engine; Lasso is the
839
+ In other words: [pi-duroxide](https://github.com/mhingston/pi-duroxide) is the durable runtime engine; Lasso is the
697
840
  harness generation, optimization, and adaptation layer built on top of it.
698
841
 
699
842
  ## Non-goals
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mhingston5/lasso",
3
3
  "description": "Lasso is a local-first workflow compiler for pi-duroxide.",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "author": "Mark Hingston",
6
6
  "repository": "https://github.com/mhingston/lasso",
7
7
  "license": "MIT",
package/src/cir/lower.ts CHANGED
@@ -104,6 +104,8 @@ function lowerNode(spec: HarnessSpec, node: TaskNode, index: number, transitions
104
104
  ...(node.retryPolicy ? { retry: cloneRetryPolicy(node.retryPolicy) } : {}),
105
105
  ...(verification ? { verification } : {}),
106
106
  ...(failureRouting ? { failureRouting } : {}),
107
+ ...(node.guardrails ? { guardrails: { ...node.guardrails } } : {}),
108
+ ...(node.verificationHooks ? { verificationHooks: node.verificationHooks.map(h => ({ ...h })) } : {}),
107
109
  terminal: outgoingCount === 0,
108
110
  } as const;
109
111
 
package/src/cir/types.ts CHANGED
@@ -5,11 +5,13 @@ import type {
5
5
  HumanPolicy,
6
6
  LlmNode,
7
7
  MergeNode,
8
+ NodeGuardrails,
8
9
  ObservabilityPolicy,
9
10
  RetryPolicy,
10
11
  SubworkflowNode,
11
12
  TaskNode,
12
13
  ToolNode,
14
+ VerificationHook,
13
15
  VerificationRule,
14
16
  } from "../spec/types.js";
15
17
 
@@ -69,6 +71,10 @@ export interface CirNodeBase<K extends TaskNode["kind"] = TaskNode["kind"]> {
69
71
  verification?: CirVerificationHook[];
70
72
  failureRouting?: CirFailureRoutingHint[];
71
73
  terminal?: boolean;
74
+ /** Per-node guardrails from spec */
75
+ guardrails?: NodeGuardrails;
76
+ /** Per-node verification hooks from spec */
77
+ verificationHooks?: VerificationHook[];
72
78
  }
73
79
 
74
80
  export interface CirToolNode extends CirNodeBase<"tool"> {
@@ -5,6 +5,7 @@ import { lowerHarnessSpecToCir } from "../cir/lower.js";
5
5
  import { optimizeCirWorkflow } from "../cir/optimize.js";
6
6
  import { validateCirWorkflow } from "../cir/validate.js";
7
7
  import type { HarnessSpec } from "../spec/types.js";
8
+ import { buildTraceEntries } from "../metaharness/trace-adapter.js";
8
9
  import { validateHarnessSpec } from "../spec/validate.js";
9
10
  import { addFailure, createHarnessState, recordNodeResult, updateMetrics } from "../state/snapshots.js";
10
11
  import type { HarnessState } from "../state/types.js";
@@ -13,6 +14,7 @@ import {
13
14
  checkGuardrails,
14
15
  evaluateConditionExpression,
15
16
  GuardrailExceededError,
17
+ isVerificationSuccess,
16
18
  recordTrace,
17
19
  runWithRetry,
18
20
  type ExecutionState,
@@ -22,6 +24,9 @@ import { unwrapAdaptiveInput, prepareRuntimeReplan, type AdaptiveRuntimeMetadata
22
24
  import type { LineageEntry } from "../versioning/types.js";
23
25
  import type { HarnessExecutionTrace } from "../versioning/types.js";
24
26
  import { buildReferenceHarnessSpec } from "../reference/catalog.js";
27
+ import type { ExecutionTrace } from "../metaharness/types.js";
28
+ import { deriveMutationsFromTrace } from "../mutation/derive.js";
29
+ import { mutateHarness } from "../mutation/engine.js";
25
30
 
26
31
  export interface CompiledHarnessResult {
27
32
  status: "completed";
@@ -189,13 +194,54 @@ function createWorkflowGenerator(
189
194
  throw new GuardrailExceededError(guardrailResult.reason!);
190
195
  }
191
196
 
192
- const output = yield* executeNodeWithPolicies(ctx, state, node, effectiveNodeMap, effectiveCir.name);
197
+ // Per-node guardrails: check constraints and cost before execution
198
+ const specNode = getSpecNode(effectiveSpec, node.id);
199
+ if (specNode?.guardrails) {
200
+ checkPerNodeGuardrails(specNode.guardrails, state, node.id);
201
+ }
202
+
203
+ // Per-node timeout: record start time before yield
204
+ const nodeStartTime = specNode?.guardrails?.timeoutSeconds !== undefined ? Date.now() : undefined;
205
+ const nodeStartCost = specNode?.guardrails?.maxCostUsd !== undefined ? state.estimatedCostUsd : undefined;
206
+
207
+ // Override retry with per-node maxRetries if present
208
+ const effectiveNode = specNode?.guardrails?.maxRetries !== undefined
209
+ ? { ...node, retry: { maxAttempts: specNode.guardrails.maxRetries + 1, backoff: node.retry?.backoff ?? "constant", initialDelay: node.retry?.initialDelay ?? 0, maxDelay: node.retry?.maxDelay, retryOn: node.retry?.retryOn } }
210
+ : node;
211
+
212
+ const output = yield* executeNodeWithPolicies(ctx, state, effectiveNode, effectiveNodeMap, effectiveCir.name);
193
213
  state.outputs[node.id] = output;
194
214
  state.stepCount += 1;
195
215
  if (node.kind === "llm") {
196
216
  state.estimatedCostUsd += 0.01;
197
217
  }
198
218
 
219
+ // Per-node timeout: check after yield returns
220
+ if (nodeStartTime !== undefined && specNode?.guardrails?.timeoutSeconds !== undefined) {
221
+ const elapsedMs = Date.now() - nodeStartTime;
222
+ const timeoutMs = specNode.guardrails.timeoutSeconds * 1000;
223
+ if (elapsedMs > timeoutMs) {
224
+ throw new GuardrailExceededError(
225
+ `Per-node timeout exceeded for node ${node.id} (${elapsedMs}ms > ${timeoutMs}ms)`,
226
+ );
227
+ }
228
+ }
229
+
230
+ // Per-node cost: check delta, not cumulative
231
+ if (nodeStartCost !== undefined && specNode?.guardrails?.maxCostUsd !== undefined) {
232
+ const nodeCost = state.estimatedCostUsd - nodeStartCost;
233
+ if (nodeCost > specNode.guardrails.maxCostUsd) {
234
+ throw new GuardrailExceededError(
235
+ `Per-node cost limit exceeded for node ${node.id} ($${nodeCost.toFixed(4)}/$${specNode.guardrails.maxCostUsd.toFixed(2)})`,
236
+ );
237
+ }
238
+ }
239
+
240
+ // Per-node verification hooks
241
+ if (specNode?.verificationHooks && specNode.verificationHooks.length > 0) {
242
+ yield* runPerNodeVerificationHooks(ctx, state, node, specNode.verificationHooks, effectiveNodeMap);
243
+ }
244
+
199
245
  const parallelMergePlan = effectiveParallelMergePlans.get(node.id);
200
246
  if (parallelMergePlan) {
201
247
  const branchNodes = parallelMergePlan.branchNodeIds.map(branchNodeId => getNode(effectiveNodeMap, branchNodeId));
@@ -582,8 +628,31 @@ function* buildCompletedResultWithContinuation(
582
628
  const replanDecision = prepareRuntimeReplan(adaptiveMetadata, state.input, result);
583
629
 
584
630
  if (replanDecision.decision === "continue_as_new") {
631
+ const traceMutations = synthesizeTraceMutations(state, adaptiveMetadata);
632
+
633
+ let nextInput = replanDecision.nextInput;
634
+ if (traceMutations.length > 0) {
635
+ const baseSpec = buildReferenceHarnessSpec(replanDecision.nextRequest);
636
+ const mutated = mutateHarness(baseSpec, traceMutations);
637
+ nextInput = {
638
+ ...nextInput,
639
+ __lassoAdaptiveRuntime: {
640
+ ...nextInput.__lassoAdaptiveRuntime,
641
+ currentVersion: {
642
+ ...nextInput.__lassoAdaptiveRuntime.currentVersion,
643
+ spec: mutated.spec,
644
+ },
645
+ pendingMutations: [
646
+ ...(nextInput.__lassoAdaptiveRuntime.pendingMutations ?? []),
647
+ ...traceMutations,
648
+ ],
649
+ },
650
+ };
651
+ ctx.traceInfo(`Lasso trace synthesis: applied ${traceMutations.length} mutation(s) from execution trace`);
652
+ }
653
+
585
654
  ctx.traceInfo(`Lasso adaptive runtime: continuing as new with version ${replanDecision.nextVersion.version}`);
586
- yield ctx.continueAsNew(replanDecision.nextInput);
655
+ yield ctx.continueAsNew(nextInput);
587
656
  } else {
588
657
  ctx.traceInfo(`Lasso adaptive runtime: ${replanDecision.decision}`);
589
658
  }
@@ -599,3 +668,204 @@ function formatUnknownError(error: unknown): string {
599
668
 
600
669
  return String(error);
601
670
  }
671
+
672
+ // NOTE: Timestamps here are approximations — the total harness duration is
673
+ // applied uniformly to all nodes. Per-node timing should be sourced from
674
+ // trace entries when available.
675
+ function synthesizeTraceMutations(
676
+ state: ExecutionState,
677
+ adaptiveMetadata: AdaptiveRuntimeMetadata,
678
+ ): ReturnType<typeof deriveMutationsFromTrace> {
679
+ const executionTrace: ExecutionTrace = {
680
+ completedNodes: [],
681
+ failedNodes: [],
682
+ currentNodeId: undefined,
683
+ capturedAt: Date.now(),
684
+ };
685
+
686
+ for (const failure of state.harnessState.failures) {
687
+ executionTrace.failedNodes.push({
688
+ nodeId: failure.nodeId ?? "unknown",
689
+ startedAt: Date.now() - (state.harnessState.metrics.durationMs ?? 0),
690
+ failedAt: Date.now(),
691
+ error: failure.message,
692
+ retryCount: 0,
693
+ });
694
+ }
695
+
696
+ for (const [nodeId, output] of Object.entries(state.harnessState.nodeResults ?? {})) {
697
+ if (!executionTrace.failedNodes.some(f => f.nodeId === nodeId)) {
698
+ executionTrace.completedNodes.push({
699
+ nodeId,
700
+ startedAt: Date.now() - (state.harnessState.metrics.durationMs ?? 0),
701
+ completedAt: Date.now(),
702
+ output,
703
+ });
704
+ }
705
+ }
706
+
707
+ const traceEntries = buildTraceEntries(executionTrace);
708
+
709
+ if (traceEntries.length === 0) {
710
+ return [];
711
+ }
712
+
713
+ const harnessTrace: HarnessExecutionTrace = {
714
+ entries: traceEntries,
715
+ totalDurationMs: state.harnessState.metrics.durationMs ?? 0,
716
+ nodeCount: executionTrace.completedNodes.length + executionTrace.failedNodes.length,
717
+ failureCount: executionTrace.failedNodes.length,
718
+ startTimeMs: Date.now() - (state.harnessState.metrics.durationMs ?? 0),
719
+ endTimeMs: Date.now(),
720
+ };
721
+
722
+ return deriveMutationsFromTrace(harnessTrace, adaptiveMetadata.currentVersion.spec);
723
+ }
724
+
725
+ function getSpecNode(spec: HarnessSpec, nodeId: string): import("../spec/types.js").TaskNode | undefined {
726
+ return spec.graph.nodes.find(node => node.id === nodeId);
727
+ }
728
+
729
+ function checkPerNodeGuardrails(
730
+ guardrails: import("../spec/types.js").NodeGuardrails,
731
+ state: ExecutionState,
732
+ nodeId: string,
733
+ ): void {
734
+ if (guardrails.constraints) {
735
+ for (const constraint of guardrails.constraints) {
736
+ const result = evaluateConditionExpression(constraint, state);
737
+ if (!result) {
738
+ throw new GuardrailExceededError(
739
+ `Constraint failed for node ${nodeId}: "${constraint}"`,
740
+ );
741
+ }
742
+ }
743
+ }
744
+
745
+
746
+ }
747
+
748
+ function* runPerNodeVerificationHooks(
749
+ ctx: WorkflowContext,
750
+ state: ExecutionState,
751
+ node: CirNode,
752
+ hooks: import("../spec/types.js").VerificationHook[],
753
+ nodeMap: Map<string, CirNode>,
754
+ ): Generator<YieldItem, void, unknown> {
755
+ for (const hook of hooks) {
756
+ let hookAttempts = 0;
757
+ const maxAttempts = hook.maxAttempts ?? 2;
758
+
759
+ while (true) {
760
+ if (hook.kind === "expression") {
761
+ const result = evaluateConditionExpression(hook.check, state);
762
+ if (result) {
763
+ break; // Hook passed, move to next hook
764
+ }
765
+
766
+ // Expression failed
767
+ if (hook.onFail === "block") {
768
+ throw new Error(
769
+ `Verification hook "${hook.name}" blocked: expression "${hook.check}" evaluated to false for node ${node.id}`,
770
+ );
771
+ }
772
+ if (hook.onFail === "warn") {
773
+ ctx.traceWarn(
774
+ `[lasso] Verification hook "${hook.name}" warning: expression "${hook.check}" evaluated to false for node ${node.id}`,
775
+ );
776
+ break; // Warn but continue to next hook
777
+ }
778
+ if (hook.onFail === "retry") {
779
+ hookAttempts++;
780
+ if (hookAttempts < maxAttempts) {
781
+ recordTrace(ctx, state, node, "retry", {
782
+ reason: "verification-hook",
783
+ hook: hook.name,
784
+ attemptNumber: hookAttempts + 1,
785
+ });
786
+ // Re-execute the node
787
+ yield* executeNodeWithPolicies(ctx, state, node as Exclude<CirNode, { kind: "condition" | "merge" }>, nodeMap, "current");
788
+ continue; // Re-check the same hook
789
+ }
790
+ throw new Error(
791
+ `Verification hook "${hook.name}" retry exhausted for node ${node.id}`,
792
+ );
793
+ }
794
+ }
795
+
796
+ // For tool/llm hooks, create an inline verifier node
797
+ const verifierNodeId = `__verify_hook_${hook.name}`;
798
+ let verifierNode: CirNode;
799
+
800
+ if (hook.kind === "llm") {
801
+ verifierNode = {
802
+ id: verifierNodeId,
803
+ kind: "llm",
804
+ source: {
805
+ specNodeId: node.id,
806
+ specNodeKind: node.kind,
807
+ specPath: `verificationHook:${hook.name}`,
808
+ },
809
+ action: {
810
+ provider: "anthropic",
811
+ model: "claude-sonnet",
812
+ prompt: hook.check,
813
+ },
814
+ };
815
+ } else {
816
+ verifierNode = {
817
+ id: verifierNodeId,
818
+ kind: "tool",
819
+ source: {
820
+ specNodeId: node.id,
821
+ specNodeKind: node.kind,
822
+ specPath: `verificationHook:${hook.name}`,
823
+ },
824
+ action: {
825
+ tool: "bash",
826
+ args: [hook.check],
827
+ },
828
+ };
829
+ }
830
+
831
+ const verifierOutput = yield createActionYieldItem(ctx, verifierNode as Exclude<CirNode, { kind: "condition" | "merge" }>, "current");
832
+ state.outputs[verifierNodeId] = verifierOutput;
833
+
834
+ const passed = isVerificationSuccess(verifierOutput);
835
+ if (passed) {
836
+ break; // Hook passed, move to next hook
837
+ }
838
+
839
+ // Verification failed
840
+ if (hook.onFail === "block") {
841
+ throw new Error(
842
+ `Verification hook "${hook.name}" blocked: verifier returned false for node ${node.id}`,
843
+ );
844
+ }
845
+
846
+ if (hook.onFail === "warn") {
847
+ ctx.traceWarn(
848
+ `[lasso] Verification hook "${hook.name}" warning: verifier returned false for node ${node.id}`,
849
+ );
850
+ break; // Warn but continue to next hook
851
+ }
852
+
853
+ if (hook.onFail === "retry") {
854
+ hookAttempts++;
855
+ if (hookAttempts < maxAttempts) {
856
+ recordTrace(ctx, state, node, "retry", {
857
+ reason: "verification-hook",
858
+ hook: hook.name,
859
+ attemptNumber: hookAttempts + 1,
860
+ });
861
+ // Re-execute the node
862
+ yield* executeNodeWithPolicies(ctx, state, node as Exclude<CirNode, { kind: "condition" | "merge" }>, nodeMap, "current");
863
+ continue; // Re-check the same hook
864
+ }
865
+ throw new Error(
866
+ `Verification hook "${hook.name}" retry exhausted for node ${node.id}`,
867
+ );
868
+ }
869
+ }
870
+ }
871
+ }