diff --git a/.github/workflows/classic-ci.yml b/.github/workflows/classic-ci.yml
new file mode 100644
index 0000000..059ea84
--- /dev/null
+++ b/.github/workflows/classic-ci.yml
@@ -0,0 +1,30 @@
+name: FluNET.Classic CI
+
+on:
+ push:
+ branches:
+ - 'classic/**'
+ pull_request:
+ branches:
+ - 'classic/main'
+
+jobs:
+ build-test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore
+ run: dotnet restore FluNET.sln
+
+ - name: Build
+ run: dotnet build FluNET.sln --configuration Release --no-restore
+
+ - name: Test
+ run: dotnet test FluNET.sln --configuration Release --no-build --verbosity normal
diff --git a/src/FluNET.CLI/FluNET.CLI.csproj b/src/FluNET.CLI/FluNET.CLI.csproj
new file mode 100644
index 0000000..42d1564
--- /dev/null
+++ b/src/FluNET.CLI/FluNET.CLI.csproj
@@ -0,0 +1,13 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ flu
+
+
+
+
+
+
diff --git a/src/FluNET.CLI/Program.cs b/src/FluNET.CLI/Program.cs
new file mode 100644
index 0000000..08078e1
--- /dev/null
+++ b/src/FluNET.CLI/Program.cs
@@ -0,0 +1,199 @@
+using FluNET.Compilation;
+using FluNET.Diagnostics;
+using FluNET.Execution;
+using FluNET.Execution.Capabilities;
+using FluNET.Language;
+
+return await FluCli.RunAsync(args);
+
+internal static class FluCli
+{
+ public static async Task RunAsync(string[] args)
+ {
+ var registry = new LanguageRegistry();
+ LanguageBuildResult language = registry.Build();
+
+ if (args.Length == 0 || IsHelp(args[0]))
+ {
+ PrintHelp();
+ return 0;
+ }
+
+ string command = args[0].ToLowerInvariant();
+ return command switch
+ {
+ "verbs" => ShowVerbs(language.Snapshot),
+ "verb" => ShowVerb(language.Snapshot, args),
+ "modules" => ShowModules(language.Snapshot),
+ "language" => ShowLanguage(language),
+ "check" => Check(language.Snapshot, args),
+ "explain" => Explain(language.Snapshot, args),
+ "run" => await RunScriptAsync(language.Snapshot, args),
+ _ => Unknown(command)
+ };
+ }
+
+ private static int ShowVerbs(LanguageSnapshot language)
+ {
+ foreach (IGrouping group in language.Verbs
+ .OrderBy(x => x.Text)
+ .GroupBy(x => x.Text, StringComparer.OrdinalIgnoreCase))
+ Console.WriteLine($"{group.Key,-16} {group.Count()} implementation(s)");
+ return 0;
+ }
+
+ private static int ShowVerb(LanguageSnapshot language, string[] args)
+ {
+ if (args.Length < 2) return Missing("verb keyword");
+ Console.WriteLine(LanguageIntrospection.ExplainVerb(language, args[1]));
+ return language.GetVerbOverloads(args[1]).Count > 0 ? 0 : 2;
+ }
+
+ private static int ShowModules(LanguageSnapshot language)
+ {
+ if (language.Modules.Count == 0)
+ {
+ Console.WriteLine("No explicit language modules registered.");
+ return 0;
+ }
+
+ foreach (ModuleDescriptor module in language.Modules.OrderBy(x => x.ModuleName))
+ Console.WriteLine($"{module.ModuleName} {module.Version}");
+ return 0;
+ }
+
+ private static int ShowLanguage(LanguageBuildResult language)
+ {
+ PrintDiagnostics(language.Diagnostics);
+ Console.WriteLine(LanguageIntrospection.ToJson(language.Snapshot));
+ return language.Success ? 0 : 2;
+ }
+
+ private static int Check(LanguageSnapshot language, string[] args)
+ {
+ if (!TryReadScript(args, out string? source, out int error)) return error;
+ ClassicCompilation compilation = new ClassicCompiler(language).Compile(source!);
+ PrintDiagnostics(compilation.Diagnostics);
+ if (compilation.Success)
+ Console.WriteLine($"OK: {compilation.Pipelines.Count} pipeline(s).");
+ return compilation.Success ? 0 : 2;
+ }
+
+ private static int Explain(LanguageSnapshot language, string[] args)
+ {
+ if (!TryReadScript(args, out string? source, out int error)) return error;
+ ClassicCompilation compilation = new ClassicCompiler(language).Compile(source!);
+ PrintDiagnostics(compilation.Diagnostics);
+ if (!compilation.Success) return 2;
+
+ for (int p = 0; p < compilation.Pipelines.Count; p++)
+ {
+ Console.WriteLine($"Pipeline {p + 1}:");
+ foreach (var sentence in compilation.Pipelines[p].Sentences)
+ {
+ Console.WriteLine($" {sentence.Verb.Text} -> {sentence.Verb.VerbType.FullName}");
+ Console.WriteLine($" result: {sentence.ResultType?.FullName ?? "void/unknown"}");
+ Console.WriteLine($" cost: {sentence.BindingCost}");
+ foreach (var role in sentence.Roles)
+ Console.WriteLine($" {role.Descriptor.Kind}: {role.Descriptor.ValueType.Name} ({role.Descriptor.Direction}, {role.Descriptor.Cardinality})");
+ }
+ }
+ return 0;
+ }
+
+ private static async Task RunScriptAsync(LanguageSnapshot language, string[] args)
+ {
+ if (!TryReadScript(args, out string? source, out int error)) return error;
+
+ string[] allowed = ReadAllowedCapabilities(args);
+ ICapabilityPolicy capabilities = allowed.Length == 0
+ ? AllowAllCapabilityPolicy.Instance
+ : new ExplicitCapabilityPolicy(allowed);
+
+ var engine = new ClassicScriptEngine(language, capabilities: capabilities);
+ try
+ {
+ ClassicScriptResult result = await engine.RunAsync(source!);
+ PrintDiagnostics(result.Compilation.Diagnostics);
+ if (!result.Success) return 2;
+ if (result.Result != null) Console.WriteLine(result.Result);
+ return 0;
+ }
+ catch (CapabilityDeniedException ex)
+ {
+ Console.Error.WriteLine(ex.Message);
+ return 3;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Runtime error: {ex.Message}");
+ return 4;
+ }
+ }
+
+ private static string[] ReadAllowedCapabilities(string[] args)
+ {
+ int index = Array.FindIndex(args, x => x.Equals("--allow", StringComparison.OrdinalIgnoreCase));
+ if (index < 0 || index + 1 >= args.Length) return [];
+ return args[index + 1].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ }
+
+ private static bool TryReadScript(string[] args, out string? source, out int error)
+ {
+ source = null;
+ error = 0;
+ if (args.Length < 2)
+ {
+ error = Missing("script file");
+ return false;
+ }
+
+ string file = args[1];
+ if (!File.Exists(file))
+ {
+ Console.Error.WriteLine($"Script not found: {file}");
+ error = 2;
+ return false;
+ }
+
+ source = File.ReadAllText(file);
+ return true;
+ }
+
+ private static void PrintDiagnostics(IEnumerable diagnostics)
+ {
+ foreach (Diagnostic diagnostic in diagnostics)
+ {
+ string location = diagnostic.Span is null ? string.Empty : $" [{diagnostic.Span.Start}..{diagnostic.Span.End})";
+ Console.Error.WriteLine($"{diagnostic.Code} {diagnostic.Severity}: {diagnostic.Message}{location}");
+ }
+ }
+
+ private static int Unknown(string command)
+ {
+ Console.Error.WriteLine($"Unknown command: {command}");
+ PrintHelp();
+ return 2;
+ }
+
+ private static int Missing(string value)
+ {
+ Console.Error.WriteLine($"Missing {value}.");
+ return 2;
+ }
+
+ private static bool IsHelp(string value) => value is "-h" or "--help" or "help";
+
+ private static void PrintHelp()
+ {
+ Console.WriteLine("FluNET.Classic 0.1");
+ Console.WriteLine();
+ Console.WriteLine(" flu verbs");
+ Console.WriteLine(" flu verb ");
+ Console.WriteLine(" flu modules");
+ Console.WriteLine(" flu language");
+ Console.WriteLine(" flu check ");
+ Console.WriteLine(" flu explain ");
+ Console.WriteLine(" flu run [--allow capability1,capability2]");
+ }
+}
diff --git a/src/FluNet.Engine/Binding/BoundNodes.cs b/src/FluNet.Engine/Binding/BoundNodes.cs
new file mode 100644
index 0000000..b387232
--- /dev/null
+++ b/src/FluNet.Engine/Binding/BoundNodes.cs
@@ -0,0 +1,28 @@
+using FluNET.Diagnostics;
+using FluNET.Language;
+using FluNET.Language.Metadata;
+using FluNET.Syntax.Ast;
+
+namespace FluNET.Binding;
+
+public sealed record BoundValue(
+ ExpressionNode Source,
+ Type ExpectedType,
+ Type ActualType,
+ object? ConstantValue,
+ int ConversionCost,
+ ValueConversion? Conversion = null);
+
+public sealed record BoundRole(ClauseDescriptor Descriptor, IReadOnlyList Values);
+public sealed record BoundSentence(VerbDescriptor Verb, ConstructorDescriptor? Constructor, IReadOnlyList Roles, Type? ResultType, int BindingCost);
+public sealed record BoundPipeline(IReadOnlyList Sentences, Type? ResultType);
+
+public sealed record BindingResult(T? Value, IReadOnlyList Diagnostics)
+{
+ public bool Success => Value != null && Diagnostics.All(x => x.Severity != DiagnosticSeverity.Error);
+}
+
+public sealed record BindingContext(
+ IReadOnlyDictionary? VariableTypes = null,
+ Type? PipelineType = null,
+ IServiceProvider? Services = null);
diff --git a/src/FluNet.Engine/Binding/ExpressionRuntimeEvaluator.cs b/src/FluNet.Engine/Binding/ExpressionRuntimeEvaluator.cs
new file mode 100644
index 0000000..1f12230
--- /dev/null
+++ b/src/FluNet.Engine/Binding/ExpressionRuntimeEvaluator.cs
@@ -0,0 +1,67 @@
+using FluNET.Syntax.Ast;
+using System.Collections.Concurrent;
+using System.Reflection;
+using System.Text.RegularExpressions;
+
+namespace FluNET.Binding;
+
+///
+/// Evaluates bound expression sources against runtime variables/pipeline values.
+/// Reflection accessors are cached per CLR type/property.
+///
+public sealed class ExpressionRuntimeEvaluator
+{
+ private static readonly Regex Interpolation = new(@"\[(?[A-Za-z_][A-Za-z0-9_\.]*)\]", RegexOptions.Compiled);
+ private readonly ConcurrentDictionary<(Type Type, string Property), PropertyInfo?> _properties = new();
+
+ public object? Evaluate(ExpressionNode expression, ActivationContext context)
+ {
+ return expression switch
+ {
+ VariableExpression variable => ResolveVariable(variable.Name, context),
+ PropertyExpression property => ResolveProperty(property, context),
+ PipelineValueExpression => context.PipelineValue,
+ InterpolatedStringExpression interpolated => Interpolate(interpolated.Template, context),
+ LiteralExpression literal => literal.Value,
+ ReferenceExpression reference => reference.Reference,
+ _ => null
+ };
+ }
+
+ private object? ResolveVariable(string name, ActivationContext context)
+ {
+ if (context.Variables != null && context.Variables.TryGetValue(name, out object? value))
+ return value;
+ throw new InvalidOperationException($"Variable '{name}' has no runtime value.");
+ }
+
+ private object? ResolveProperty(PropertyExpression property, ActivationContext context)
+ {
+ object? target = Evaluate(property.Target, context);
+ if (target == null) return null;
+
+ PropertyInfo? info = _properties.GetOrAdd((target.GetType(), property.Property), key =>
+ key.Type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .FirstOrDefault(x => x.Name.Equals(key.Property, StringComparison.OrdinalIgnoreCase)));
+
+ if (info == null)
+ throw new InvalidOperationException($"Property '{property.Property}' does not exist on '{target.GetType().FullName}'.");
+
+ return info.GetValue(target);
+ }
+
+ private string Interpolate(string template, ActivationContext context) =>
+ Interpolation.Replace(template, match =>
+ {
+ ExpressionNode expression = ParsePath(match.Groups["path"].Value);
+ return Evaluate(expression, context)?.ToString() ?? string.Empty;
+ });
+
+ private static ExpressionNode ParsePath(string path)
+ {
+ string[] parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
+ ExpressionNode current = new VariableExpression(parts[0]);
+ for (int i = 1; i < parts.Length; i++) current = new PropertyExpression(current, parts[i]);
+ return current;
+ }
+}
diff --git a/src/FluNet.Engine/Binding/IValueConverter.cs b/src/FluNet.Engine/Binding/IValueConverter.cs
new file mode 100644
index 0000000..c69ab99
--- /dev/null
+++ b/src/FluNet.Engine/Binding/IValueConverter.cs
@@ -0,0 +1,82 @@
+using System.Globalization;
+
+namespace FluNET.Binding;
+
+public sealed record ValueConversion(
+ Type SourceType,
+ Type TargetType,
+ int Cost,
+ Func