@studio-foundation/engine 0.3.0-beta.1

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 (90) hide show
  1. package/ARCHITECTURE.md +57 -0
  2. package/LICENSE +663 -0
  3. package/README.md +118 -0
  4. package/package.json +47 -0
  5. package/src/__tests__/__fixtures__/script-stage/contracts/book-context.contract.yaml +4 -0
  6. package/src/__tests__/engine.conditions-group.test.ts +244 -0
  7. package/src/__tests__/engine.conditions.test.ts +186 -0
  8. package/src/__tests__/engine.resume.test.ts +108 -0
  9. package/src/__tests__/engine.script-stage.test.ts +125 -0
  10. package/src/db/client.ts +5 -0
  11. package/src/engine.context-event.test.ts +175 -0
  12. package/src/engine.ts +491 -0
  13. package/src/events.ts +167 -0
  14. package/src/index.ts +64 -0
  15. package/src/pipeline/agent-loader.test.ts +151 -0
  16. package/src/pipeline/agent-loader.ts +39 -0
  17. package/src/pipeline/condition-evaluator.test.ts +129 -0
  18. package/src/pipeline/condition-evaluator.ts +121 -0
  19. package/src/pipeline/context-pack-loader.ts +63 -0
  20. package/src/pipeline/context-propagation.test.ts +237 -0
  21. package/src/pipeline/context-propagation.ts +235 -0
  22. package/src/pipeline/contract-loader.ts +41 -0
  23. package/src/pipeline/group-orchestrator.ts +483 -0
  24. package/src/pipeline/hook-executor.test.ts +121 -0
  25. package/src/pipeline/hook-executor.ts +87 -0
  26. package/src/pipeline/invariants-loader.test.ts +51 -0
  27. package/src/pipeline/invariants-loader.ts +14 -0
  28. package/src/pipeline/loader.test.ts +128 -0
  29. package/src/pipeline/loader.ts +149 -0
  30. package/src/pipeline/output-validator.test.ts +100 -0
  31. package/src/pipeline/output-validator.ts +40 -0
  32. package/src/pipeline/post-validator.ts +124 -0
  33. package/src/pipeline/skill-loader.test.ts +44 -0
  34. package/src/pipeline/skill-loader.ts +32 -0
  35. package/src/pipeline/stage-executor.ts +654 -0
  36. package/src/pipeline/stage-resolver.ts +8 -0
  37. package/src/pipeline/startup-executor.test.ts +37 -0
  38. package/src/pipeline/startup-executor.ts +32 -0
  39. package/src/pipeline/types.ts +42 -0
  40. package/src/repo-resolver.ts +61 -0
  41. package/src/spawners/direct-engine-spawner.ts +26 -0
  42. package/src/state/run-store.test.ts +157 -0
  43. package/src/state/run-store.ts +362 -0
  44. package/src/state/state-machine.ts +35 -0
  45. package/src/state/status-derivation.ts +36 -0
  46. package/tests/context-pack-loader.test.ts +113 -0
  47. package/tests/context-propagation.test.ts +267 -0
  48. package/tests/direct-engine-spawner.test.ts +102 -0
  49. package/tests/e2e/feature-v5.test.ts +57 -0
  50. package/tests/events.test.ts +56 -0
  51. package/tests/fixtures/agents/test-agent.agent.yaml +5 -0
  52. package/tests/fixtures/contracts/code-gen.contract.yaml +6 -0
  53. package/tests/fixtures/contracts/qa-gate.contract.yaml +15 -0
  54. package/tests/fixtures/contracts/test-contract.contract.yaml +6 -0
  55. package/tests/fixtures/pipelines/group-test.pipeline.yaml +40 -0
  56. package/tests/fixtures/pipelines/simple.pipeline.yaml +15 -0
  57. package/tests/fixtures/pipelines/two-stage.pipeline.yaml +24 -0
  58. package/tests/fixtures/software/pipelines/feature-builder.pipeline.yaml +75 -0
  59. package/tests/fixtures/test-project/agents/test-agent.agent.yaml +5 -0
  60. package/tests/fixtures/test-project/contracts/basic-result.contract.yaml +6 -0
  61. package/tests/fixtures/test-project/contracts/code-gen.contract.yaml +6 -0
  62. package/tests/fixtures/test-project/contracts/maximum-only.contract.yaml +8 -0
  63. package/tests/fixtures/test-project/contracts/qa-gate.contract.yaml +15 -0
  64. package/tests/fixtures/test-project/contracts/strict-result.contract.yaml +7 -0
  65. package/tests/fixtures/test-project/contracts/test-contract.contract.yaml +6 -0
  66. package/tests/fixtures/test-project/pipelines/group-simple.pipeline.yaml +26 -0
  67. package/tests/fixtures/test-project/pipelines/group-test.pipeline.yaml +40 -0
  68. package/tests/fixtures/test-project/pipelines/hook-output-template.pipeline.yaml +19 -0
  69. package/tests/fixtures/test-project/pipelines/hook-reject-on-failure.pipeline.yaml +19 -0
  70. package/tests/fixtures/test-project/pipelines/maximum-only.pipeline.yaml +15 -0
  71. package/tests/fixtures/test-project/pipelines/parallel-collect-all-test.pipeline.yaml +39 -0
  72. package/tests/fixtures/test-project/pipelines/parallel-context-isolation-test.pipeline.yaml +38 -0
  73. package/tests/fixtures/test-project/pipelines/parallel-fail-test.pipeline.yaml +39 -0
  74. package/tests/fixtures/test-project/pipelines/parallel-test.pipeline.yaml +39 -0
  75. package/tests/fixtures/test-project/pipelines/parallel-then-sequential-test.pipeline.yaml +38 -0
  76. package/tests/fixtures/test-project/pipelines/simple.pipeline.yaml +15 -0
  77. package/tests/fixtures/test-project/pipelines/two-stage.pipeline.yaml +24 -0
  78. package/tests/fixtures/test-project/pipelines/with-startup.pipeline.yaml +19 -0
  79. package/tests/loader.test.ts +385 -0
  80. package/tests/post-validator.test.ts +297 -0
  81. package/tests/repo-resolver.test.ts +102 -0
  82. package/tests/run-store.test.ts +143 -0
  83. package/tests/state-machine.test.ts +110 -0
  84. package/tests/unit/context-propagation.test.ts +45 -0
  85. package/tests/unit/engine.test.ts +770 -0
  86. package/tests/unit/group-loop.test.ts +389 -0
  87. package/tests/unit/group-parallel.test.ts +423 -0
  88. package/tests/unit/state/status-derivation.test.ts +88 -0
  89. package/tsconfig.json +24 -0
  90. package/vitest.config.ts +14 -0
package/src/index.ts ADDED
@@ -0,0 +1,64 @@
1
+ // Export barrel for @studio/engine
2
+
3
+ // Main engine
4
+ export { PipelineEngine } from './engine.js';
5
+ export type { EngineConfig, RunInput } from './engine.js';
6
+
7
+ // Events
8
+ export { PipelineEventEmitter } from './events.js';
9
+ export type {
10
+ EngineEvents,
11
+ PipelineEvent,
12
+ PipelineStartEvent,
13
+ PipelineCompleteEvent,
14
+ PipelineCancelledEvent,
15
+ StageStartEvent,
16
+ StageCompleteEvent,
17
+ StageRetryEvent,
18
+ ToolCallSummary,
19
+ TokenUsage,
20
+ GroupStartEvent,
21
+ GroupIterationEvent,
22
+ GroupFeedbackEvent,
23
+ GroupCompleteEvent,
24
+ StageContextEvent,
25
+ StagedToolCallStartEvent,
26
+ StagedToolCallCompleteEvent,
27
+ } from './events.js';
28
+
29
+ // State management
30
+ export { deriveStageStatus } from './state/status-derivation.js';
31
+ export { isValidTransition, transition } from './state/state-machine.js';
32
+ export type { StageLifecycleState } from './state/state-machine.js';
33
+
34
+ // Run store
35
+ export { InMemoryRunStore, SQLiteRunStore, PgRunStore } from './state/run-store.js';
36
+ export type { RunStore, AsyncRunStore, AnyRunStore } from './state/run-store.js';
37
+
38
+ // Pipeline loaders
39
+ export { loadPipeline, loadPipelineByName, parsePipelineYaml } from './pipeline/loader.js';
40
+ export { loadAgentProfile, parseAgentYaml } from './pipeline/agent-loader.js';
41
+ export { loadContract, parseContractYaml } from './pipeline/contract-loader.js';
42
+
43
+ // Context propagation
44
+ export {
45
+ createInitialContext,
46
+ addStageOutput,
47
+ getContextForStage,
48
+ setGroupFeedback,
49
+ clearGroupFeedback,
50
+ } from './pipeline/context-propagation.js';
51
+ export type { PipelineContext, PipelineInput, GroupFeedback } from './pipeline/context-propagation.js';
52
+
53
+ // Contract validation
54
+ export { validateOutput } from './pipeline/output-validator.js';
55
+ export type { OutputValidationResult } from './pipeline/output-validator.js';
56
+ export type { PostValidationResult } from './pipeline/post-validator.js';
57
+ export { validateSchema } from '@studio-foundation/ralph';
58
+
59
+ // Spawners
60
+ export { DirectEngineSpawner } from './spawners/direct-engine-spawner.js';
61
+
62
+ // Repo resolution
63
+ export { resolveRepoPath, cloneRepo } from './repo-resolver.js';
64
+ export type { RepoResolveOptions } from './repo-resolver.js';
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseAgentYaml } from './agent-loader.js';
3
+
4
+ describe('parseAgentYaml', () => {
5
+ it('parses plugins field from agent YAML', () => {
6
+ const yaml = `
7
+ name: code-reviewer
8
+ provider: anthropic
9
+ model: claude-sonnet-4-20250514
10
+ plugins:
11
+ - code-review
12
+ - analysis
13
+ tools:
14
+ - repo_manager-read_file
15
+ `;
16
+ const result = parseAgentYaml(yaml);
17
+ expect(result.plugins).toEqual(['code-review', 'analysis']);
18
+ });
19
+
20
+ it('returns undefined plugins when not specified', () => {
21
+ const yaml = `
22
+ name: analyst
23
+ provider: anthropic
24
+ model: claude-haiku-4-5-20251001
25
+ `;
26
+ const result = parseAgentYaml(yaml);
27
+ expect(result.plugins).toBeUndefined();
28
+ });
29
+ });
30
+
31
+ describe('skill injection logic', () => {
32
+ it('appends skill content to system_prompt when plugins match', () => {
33
+ // Test the injection logic directly
34
+ const agent = parseAgentYaml(`
35
+ name: test-agent
36
+ provider: anthropic
37
+ model: claude-haiku-4-5-20251001
38
+ system_prompt: "You are a reviewer."
39
+ plugins:
40
+ - code-review
41
+ `);
42
+
43
+ // Simulate what engine does
44
+ const pluginSkills: Record<string, string[]> = {
45
+ 'code-review': ['## Skill: review-guidelines\n\nAlways check for bugs.'],
46
+ };
47
+
48
+ if (agent.plugins?.length && pluginSkills) {
49
+ const skillChunks = agent.plugins.flatMap((p) => pluginSkills[p] ?? []);
50
+ if (skillChunks.length > 0) {
51
+ agent.system_prompt = `${agent.system_prompt ?? ''}\n\n${skillChunks.join('\n\n---\n\n')}`;
52
+ }
53
+ }
54
+
55
+ expect(agent.system_prompt).toContain('You are a reviewer.');
56
+ expect(agent.system_prompt).toContain('## Skill: review-guidelines');
57
+ expect(agent.system_prompt).toContain('Always check for bugs.');
58
+ });
59
+
60
+ it('does not modify system_prompt when no matching plugin skills', () => {
61
+ const agent = parseAgentYaml(`
62
+ name: test-agent
63
+ provider: anthropic
64
+ model: claude-haiku-4-5-20251001
65
+ system_prompt: "Original prompt."
66
+ plugins:
67
+ - unknown-plugin
68
+ `);
69
+
70
+ const pluginSkills: Record<string, string[]> = {
71
+ 'code-review': ['## Skill: review-guidelines\n\nAlways check for bugs.'],
72
+ };
73
+
74
+ const originalPrompt = agent.system_prompt;
75
+ if (agent.plugins?.length && pluginSkills) {
76
+ const skillChunks = agent.plugins.flatMap((p) => pluginSkills[p] ?? []);
77
+ if (skillChunks.length > 0) {
78
+ agent.system_prompt = `${agent.system_prompt ?? ''}\n\n${skillChunks.join('\n\n---\n\n')}`;
79
+ }
80
+ }
81
+
82
+ expect(agent.system_prompt).toBe(originalPrompt);
83
+ });
84
+ });
85
+
86
+ describe('skills field parsing', () => {
87
+ it('parses skills field from agent YAML', () => {
88
+ const yaml = `
89
+ name: coder
90
+ provider: anthropic
91
+ model: claude-sonnet-4-6
92
+ skills:
93
+ - git-workflow
94
+ - code-conventions
95
+ `;
96
+ const result = parseAgentYaml(yaml);
97
+ expect(result.skills).toEqual(['git-workflow', 'code-conventions']);
98
+ });
99
+
100
+ it('returns undefined skills when not specified', () => {
101
+ const yaml = `
102
+ name: analyst
103
+ provider: anthropic
104
+ model: claude-haiku-4-5-20251001
105
+ `;
106
+ const result = parseAgentYaml(yaml);
107
+ expect(result.skills).toBeUndefined();
108
+ });
109
+ });
110
+
111
+ describe('project skill injection logic', () => {
112
+ it('appends skill content to system_prompt for declared skills', () => {
113
+ const agent = parseAgentYaml(`
114
+ name: coder
115
+ provider: anthropic
116
+ model: claude-sonnet-4-6
117
+ system_prompt: "You are a developer."
118
+ skills:
119
+ - git-workflow
120
+ `);
121
+
122
+ // Simulate what engine does: format loaded skills and append to system_prompt
123
+ const loadedSkills = [{ name: 'git-workflow', content: '# Git Workflow\n\nAlways branch from main.' }];
124
+ if (agent.skills?.length && loadedSkills.length > 0) {
125
+ const skillChunks = loadedSkills.map((s) => `## Skill: ${s.name}\n\n${s.content}`);
126
+ agent.system_prompt = `${agent.system_prompt ?? ''}\n\n${skillChunks.join('\n\n---\n\n')}`;
127
+ }
128
+
129
+ expect(agent.system_prompt).toContain('You are a developer.');
130
+ expect(agent.system_prompt).toContain('## Skill: git-workflow');
131
+ expect(agent.system_prompt).toContain('Always branch from main.');
132
+ });
133
+
134
+ it('does not modify system_prompt when no skills declared', () => {
135
+ const agent = parseAgentYaml(`
136
+ name: analyst
137
+ provider: anthropic
138
+ model: claude-sonnet-4-6
139
+ system_prompt: "You are an analyst."
140
+ `);
141
+
142
+ const originalPrompt = agent.system_prompt;
143
+ const loadedSkills: Array<{ name: string; content: string }> = [];
144
+ if (agent.skills?.length && loadedSkills.length > 0) {
145
+ const skillChunks = loadedSkills.map((s) => `## Skill: ${s.name}\n\n${s.content}`);
146
+ agent.system_prompt = `${agent.system_prompt ?? ''}\n\n${skillChunks.join('\n\n---\n\n')}`;
147
+ }
148
+
149
+ expect(agent.system_prompt).toBe(originalPrompt);
150
+ });
151
+ });
@@ -0,0 +1,39 @@
1
+ // Load agent profiles from YAML files
2
+
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import * as yaml from 'js-yaml';
6
+ import type { AgentConfig } from '@studio-foundation/contracts';
7
+
8
+ export async function loadAgentProfile(
9
+ name: string,
10
+ agentsDir: string
11
+ ): Promise<AgentConfig> {
12
+ const path = join(agentsDir, `${name}.agent.yaml`);
13
+
14
+ let content: string;
15
+ try {
16
+ content = await readFile(path, 'utf-8');
17
+ } catch (err) {
18
+ throw new Error(`Failed to load agent profile '${name}' at ${path}: ${(err as Error).message}`);
19
+ }
20
+
21
+ return parseAgentYaml(content, path);
22
+ }
23
+
24
+ export function parseAgentYaml(yamlContent: string, sourcePath?: string): AgentConfig {
25
+ const parsed = yaml.load(yamlContent) as Record<string, unknown>;
26
+ const context = sourcePath ? ` (from ${sourcePath})` : '';
27
+
28
+ if (!parsed || typeof parsed !== 'object') {
29
+ throw new Error(`Invalid agent YAML: expected an object${context}`);
30
+ }
31
+
32
+ if (!parsed.name || typeof parsed.name !== 'string') {
33
+ throw new Error(`Agent config missing required field 'name'${context}`);
34
+ }
35
+
36
+ // provider and model are optional — defaults are applied at execution time
37
+
38
+ return parsed as unknown as AgentConfig;
39
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { evaluateCondition } from './condition-evaluator.js';
3
+
4
+ const makeContext = (
5
+ input: Record<string, unknown> | string = {},
6
+ stageOutputs: Map<string, unknown> = new Map(),
7
+ ) => ({ input, stageOutputs });
8
+
9
+ describe('evaluateCondition — input namespace', () => {
10
+ it('returns true when input field equals condition value (>=)', () => {
11
+ const ctx = makeContext({ meals_count: 6 });
12
+ expect(evaluateCondition('input.meals_count >= 6', ctx)).toBe(true);
13
+ });
14
+
15
+ it('returns false when input field is below threshold', () => {
16
+ const ctx = makeContext({ meals_count: 5 });
17
+ expect(evaluateCondition('input.meals_count >= 6', ctx)).toBe(false);
18
+ });
19
+
20
+ it('returns true for strict greater than when value exceeds', () => {
21
+ const ctx = makeContext({ meals_count: 7 });
22
+ expect(evaluateCondition('input.meals_count > 6', ctx)).toBe(true);
23
+ });
24
+
25
+ it('returns false for strict greater than when value equals threshold', () => {
26
+ const ctx = makeContext({ meals_count: 6 });
27
+ expect(evaluateCondition('input.meals_count > 6', ctx)).toBe(false);
28
+ });
29
+
30
+ it('returns true for less than', () => {
31
+ const ctx = makeContext({ priority: 2 });
32
+ expect(evaluateCondition('input.priority < 3', ctx)).toBe(true);
33
+ });
34
+
35
+ it('returns true for less than or equal', () => {
36
+ const ctx = makeContext({ priority: 3 });
37
+ expect(evaluateCondition('input.priority <= 3', ctx)).toBe(true);
38
+ });
39
+
40
+ it('returns true for == equality', () => {
41
+ const ctx = makeContext({ mode: 'fast' });
42
+ expect(evaluateCondition("input.mode == fast", ctx)).toBe(true);
43
+ });
44
+
45
+ it('returns true for === strict equality with number', () => {
46
+ const ctx = makeContext({ count: 0 });
47
+ expect(evaluateCondition('input.count === 0', ctx)).toBe(true);
48
+ });
49
+
50
+ it('returns true for != inequality', () => {
51
+ const ctx = makeContext({ mode: 'slow' });
52
+ expect(evaluateCondition("input.mode != fast", ctx)).toBe(true);
53
+ });
54
+
55
+ it('returns true for !== strict inequality', () => {
56
+ const ctx = makeContext({ count: 1 });
57
+ expect(evaluateCondition('input.count !== 0', ctx)).toBe(true);
58
+ });
59
+
60
+ it('returns false when input field is missing', () => {
61
+ const ctx = makeContext({ other_field: 5 });
62
+ expect(evaluateCondition('input.meals_count >= 6', ctx)).toBe(false);
63
+ });
64
+
65
+ it('returns false when input is a string (not an object)', () => {
66
+ const ctx = makeContext('plain string input');
67
+ expect(evaluateCondition('input.meals_count >= 6', ctx)).toBe(false);
68
+ });
69
+
70
+ it('supports nested field paths', () => {
71
+ const ctx = makeContext({ config: { threshold: 10 } });
72
+ expect(evaluateCondition('input.config.threshold > 5', ctx)).toBe(true);
73
+ });
74
+ });
75
+
76
+ describe('evaluateCondition — stages namespace', () => {
77
+ const stageOutputs = new Map<string, unknown>([
78
+ ['entity-extraction', { counts: { OTHER: 3, PERSON: 1 }, total: 4 }],
79
+ ['stage-with-zero', { count: 0 }],
80
+ ['analysis', { score: 0.85 }],
81
+ ]);
82
+
83
+ it('returns true when stage output field is above threshold', () => {
84
+ const ctx = makeContext({}, stageOutputs);
85
+ expect(evaluateCondition('stages.entity-extraction.output.counts.OTHER > 0', ctx)).toBe(true);
86
+ });
87
+
88
+ it('returns false when stage output field is zero', () => {
89
+ const ctx = makeContext({}, stageOutputs);
90
+ expect(evaluateCondition('stages.stage-with-zero.output.count > 0', ctx)).toBe(false);
91
+ });
92
+
93
+ it('supports stage names with hyphens', () => {
94
+ const ctx = makeContext({}, stageOutputs);
95
+ expect(evaluateCondition('stages.entity-extraction.output.total >= 3', ctx)).toBe(true);
96
+ });
97
+
98
+ it('returns false when stage does not exist in outputs', () => {
99
+ const ctx = makeContext({}, stageOutputs);
100
+ expect(evaluateCondition('stages.nonexistent.output.count > 0', ctx)).toBe(false);
101
+ });
102
+
103
+ it('returns false when nested field path does not exist', () => {
104
+ const ctx = makeContext({}, stageOutputs);
105
+ expect(evaluateCondition('stages.entity-extraction.output.missing.deep > 0', ctx)).toBe(false);
106
+ });
107
+
108
+ it('supports float comparisons', () => {
109
+ const ctx = makeContext({}, stageOutputs);
110
+ expect(evaluateCondition('stages.analysis.output.score >= 0.8', ctx)).toBe(true);
111
+ });
112
+ });
113
+
114
+ describe('evaluateCondition — edge cases', () => {
115
+ it('returns false for an unparseable expression (no operator)', () => {
116
+ const ctx = makeContext({ x: 1 });
117
+ expect(evaluateCondition('input.x', ctx)).toBe(false);
118
+ });
119
+
120
+ it('handles whitespace around operator', () => {
121
+ const ctx = makeContext({ n: 5 });
122
+ expect(evaluateCondition('input.n >= 5', ctx)).toBe(true);
123
+ });
124
+
125
+ it('>=6 is treated correctly (no space before value)', () => {
126
+ const ctx = makeContext({ n: 6 });
127
+ expect(evaluateCondition('input.n >= 6', ctx)).toBe(true);
128
+ });
129
+ });
@@ -0,0 +1,121 @@
1
+ // Secure condition evaluator — no eval(), no external dependencies.
2
+ // Supported syntax:
3
+ // input.<field.path> compared to a literal
4
+ // stages.<stage-name>.output.<field.path> compared to a literal
5
+ // Operators: ===, !==, >=, <=, ==, !=, >, <
6
+ // Returns false for any undefined/invalid path (skip-safe).
7
+
8
+ import type { PipelineInput } from './context-propagation.js';
9
+
10
+ // Longest-first to avoid '>' matching inside '>='
11
+ const OPERATORS = ['===', '!==', '>=', '<=', '==', '!=', '>', '<'] as const;
12
+ type Operator = typeof OPERATORS[number];
13
+
14
+ export function evaluateCondition(
15
+ condition: string,
16
+ context: { input: PipelineInput; stageOutputs: Map<string, unknown> },
17
+ ): boolean {
18
+ const trimmed = condition.trim();
19
+
20
+ // Find operator (longest-first)
21
+ let operator: Operator | undefined;
22
+ let lhsStr = '';
23
+ let rhsStr = '';
24
+
25
+ for (const op of OPERATORS) {
26
+ const idx = trimmed.indexOf(op);
27
+ if (idx !== -1) {
28
+ operator = op;
29
+ lhsStr = trimmed.slice(0, idx).trim();
30
+ rhsStr = trimmed.slice(idx + op.length).trim();
31
+ break;
32
+ }
33
+ }
34
+
35
+ if (!operator || !lhsStr || !rhsStr) return false;
36
+
37
+ const lhsValue = resolveLhs(lhsStr, context);
38
+ if (lhsValue === undefined) return false;
39
+
40
+ const rhsValue = parseRhs(rhsStr);
41
+ return compare(lhsValue, operator, rhsValue);
42
+ }
43
+
44
+ function resolveLhs(
45
+ lhs: string,
46
+ context: { input: PipelineInput; stageOutputs: Map<string, unknown> },
47
+ ): unknown {
48
+ if (lhs.startsWith('input.')) {
49
+ const fieldPath = lhs.slice('input.'.length);
50
+ if (typeof context.input !== 'object' || context.input === null) return undefined;
51
+ return traversePath(context.input as Record<string, unknown>, fieldPath);
52
+ }
53
+
54
+ if (lhs.startsWith('stages.')) {
55
+ // Format: stages.<stage-name>.output.<field.path>
56
+ // Stage names can contain hyphens — split on first '.output.' occurrence
57
+ const rest = lhs.slice('stages.'.length);
58
+ const outputMarker = '.output.';
59
+ const markerIdx = rest.indexOf(outputMarker);
60
+ if (markerIdx === -1) return undefined;
61
+
62
+ const stageName = rest.slice(0, markerIdx);
63
+ const fieldPath = rest.slice(markerIdx + outputMarker.length);
64
+
65
+ const stageOutput = context.stageOutputs.get(stageName);
66
+ if (stageOutput === undefined || stageOutput === null) return undefined;
67
+
68
+ return traversePath(stageOutput as Record<string, unknown>, fieldPath);
69
+ }
70
+
71
+ return undefined;
72
+ }
73
+
74
+ function traversePath(obj: Record<string, unknown>, path: string): unknown {
75
+ const parts = path.split('.');
76
+ let current: unknown = obj;
77
+ for (const part of parts) {
78
+ if (current === null || current === undefined || typeof current !== 'object') {
79
+ return undefined;
80
+ }
81
+ current = (current as Record<string, unknown>)[part];
82
+ }
83
+ return current;
84
+ }
85
+
86
+ function parseRhs(rhs: string): unknown {
87
+ // Number (int or float, optional leading minus)
88
+ if (/^-?\d+(\.\d+)?$/.test(rhs)) return Number(rhs);
89
+ // Boolean
90
+ if (rhs === 'true') return true;
91
+ if (rhs === 'false') return false;
92
+ // Quoted string
93
+ if ((rhs.startsWith('"') && rhs.endsWith('"')) || (rhs.startsWith("'") && rhs.endsWith("'"))) {
94
+ return rhs.slice(1, -1);
95
+ }
96
+ // Plain string (e.g. input.mode == fast)
97
+ return rhs;
98
+ }
99
+
100
+ function compare(lhs: unknown, op: Operator, rhs: unknown): boolean {
101
+ // Coerce lhs to number if rhs is a number and lhs is a string
102
+ let lhsCoerced: unknown = lhs;
103
+ if (typeof rhs === 'number' && typeof lhs === 'string') {
104
+ const n = Number(lhs);
105
+ if (!isNaN(n)) lhsCoerced = n;
106
+ }
107
+
108
+ switch (op) {
109
+ case '>': return typeof lhsCoerced === 'number' && typeof rhs === 'number' && lhsCoerced > rhs;
110
+ case '>=': return typeof lhsCoerced === 'number' && typeof rhs === 'number' && lhsCoerced >= rhs;
111
+ case '<': return typeof lhsCoerced === 'number' && typeof rhs === 'number' && lhsCoerced < rhs;
112
+ case '<=': return typeof lhsCoerced === 'number' && typeof rhs === 'number' && lhsCoerced <= rhs;
113
+ // eslint-disable-next-line eqeqeq
114
+ case '==': return lhsCoerced == rhs;
115
+ case '===': return lhsCoerced === rhs;
116
+ // eslint-disable-next-line eqeqeq
117
+ case '!=': return lhsCoerced != rhs;
118
+ case '!==': return lhsCoerced !== rhs;
119
+ default: return false;
120
+ }
121
+ }
@@ -0,0 +1,63 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import * as yaml from 'js-yaml';
4
+ import type { ContextPackDefinition, ResolvedContextPack } from '@studio-foundation/contracts';
5
+
6
+ export async function loadContextPacks(
7
+ packNames: string[],
8
+ projectConfigPath: string,
9
+ workspacePath?: string,
10
+ ): Promise<ResolvedContextPack[]> {
11
+ if (packNames.length === 0) return [];
12
+
13
+ const packsDir = path.join(projectConfigPath, 'context-packs');
14
+ const results: ResolvedContextPack[] = [];
15
+
16
+ for (const packName of packNames) {
17
+ const packFile = path.join(packsDir, `${packName}.yaml`);
18
+
19
+ let rawContent: string;
20
+ try {
21
+ rawContent = await fs.readFile(packFile, 'utf-8');
22
+ } catch {
23
+ throw new Error(`Context pack "${packName}" not found at ${packFile}`);
24
+ }
25
+
26
+ const definition = yaml.load(rawContent) as ContextPackDefinition;
27
+ const sections: Array<{ title: string; content: string }> = [];
28
+
29
+ // File sections first (in YAML order)
30
+ if (definition.files?.length) {
31
+ if (!workspacePath) {
32
+ throw new Error(
33
+ `Context pack "${packName}" references files but workspace is not configured`,
34
+ );
35
+ }
36
+ for (const fileRef of definition.files) {
37
+ const filePath = path.join(workspacePath, fileRef.path);
38
+ let content: string;
39
+ try {
40
+ content = await fs.readFile(filePath, 'utf-8');
41
+ } catch {
42
+ throw new Error(`File "${fileRef.path}" not found in workspace at ${filePath}`);
43
+ }
44
+ sections.push({ title: fileRef.path, content });
45
+ }
46
+ }
47
+
48
+ // Inline sections after (in YAML order)
49
+ if (definition.inline?.length) {
50
+ for (const inline of definition.inline) {
51
+ sections.push({ title: inline.title, content: inline.content });
52
+ }
53
+ }
54
+
55
+ results.push({
56
+ name: definition.name,
57
+ ...(definition.description !== undefined && { description: definition.description }),
58
+ sections,
59
+ });
60
+ }
61
+
62
+ return results;
63
+ }