diff --git a/brokkr-command/Brokkr.Command.csproj b/brokkr-command/Brokkr.Command.csproj
new file mode 100644
index 0000000..5e322cd
--- /dev/null
+++ b/brokkr-command/Brokkr.Command.csproj
@@ -0,0 +1,16 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ ..\..\CultLib
+
+
+
+
+
+
+
+
+
diff --git a/brokkr-command/Program.cs b/brokkr-command/Program.cs
new file mode 100644
index 0000000..e3f2185
--- /dev/null
+++ b/brokkr-command/Program.cs
@@ -0,0 +1,158 @@
+// brokkr-command: a caller. It writes one Unity command intent into the project's directory store and reads the
+// receipt the editor service answers with. It never writes receipts, host snapshots or the agent-commands flag,
+// and it never opens the store as a single file: the editor owns execution and its own state.
+using System.Globalization;
+using GameCult.Brokkr;
+using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+
+try
+{
+ return await Cli.RunAsync(args);
+}
+catch (Exception error) when (error is ArgumentException or TimeoutException or InvalidOperationException)
+{
+ Console.Error.WriteLine(error.Message);
+ return 2;
+}
+
+internal static class Cli
+{
+ // Every writable field of brokkr.unity.command_intent.v0 except the schema tag, the id and the action, which
+ // have their own options.
+ private static readonly Dictionary> Fields =
+ new(StringComparer.Ordinal)
+ {
+ ["--target-object-id"] = (command, value) => command.targetObjectId = value,
+ ["--name"] = (command, value) => command.name = value,
+ ["--component-type"] = (command, value) => command.componentType = value,
+ ["--property-path"] = (command, value) => command.propertyPath = value,
+ ["--value"] = (command, value) => command.value = value,
+ ["--asset-path"] = (command, value) => command.assetPath = value,
+ ["--parent-object-id"] = (command, value) => command.parentObjectId = value,
+ ["--local-position"] = (command, value) => command.localPosition = value,
+ ["--local-euler-angles"] = (command, value) => command.localEulerAngles = value,
+ ["--local-scale"] = (command, value) => command.localScale = value,
+ ["--view"] = (command, value) => command.viewKind = value,
+ ["--output"] = (command, value) => command.outputPath = value,
+ ["--width"] = (command, value) => command.width = Integer("--width", value),
+ ["--height"] = (command, value) => command.height = Integer("--height", value)
+ };
+
+ internal static async Task RunAsync(string[] args)
+ {
+ var options = Parse(args);
+ var cachePath = Path.GetFullPath(Required(options, "--unity-cache"));
+ var action = Required(options, "--action");
+ var readHost = string.Equals(action, "readHost", StringComparison.Ordinal);
+
+ using var cache = await CultCacheMessagePack.OpenAsync(cachePath, new CultCacheOpenOptions
+ {
+ UseDirectoryStore = true,
+ ReadOnly = readHost
+ });
+ return readHost ? await ReadHostAsync(cache) : await WriteIntentAsync(cache, action, options);
+ }
+
+ private static async Task ReadHostAsync(CultCache cache)
+ {
+ await cache.PullAllBackingStoresAsync();
+ var host = ReadHostSnapshot(cache)
+ ?? throw new InvalidOperationException("Brokkr host snapshot 'unity/host/current' is unavailable.");
+ Console.WriteLine($"project: {host.projectPath}");
+ Console.WriteLine($"observed: {host.observedAt}");
+ Console.WriteLine($"scene: {host.activeScenePath}");
+ Console.WriteLine($"agent-commands: {(host.agentCommandsEnabled ? "on" : "off")}");
+ Console.WriteLine($"objects: {host.sceneObjects.Length}");
+ foreach (var sceneObject in host.sceneObjects.OrderBy(item => item.path, StringComparer.Ordinal))
+ {
+ var components = string.Join(", ", sceneObject.components.Select(component => component.typeName));
+ Console.WriteLine($"- {sceneObject.path} active={sceneObject.activeSelf} layer={sceneObject.layer} components=[{components}]");
+ foreach (var component in sceneObject.components)
+ foreach (var property in component.properties.Where(property =>
+ !property.path.StartsWith("m_", StringComparison.Ordinal) &&
+ !string.IsNullOrWhiteSpace(property.value)))
+ Console.WriteLine($" {component.typeName}.{property.path}={property.value}");
+ }
+
+ return 0;
+ }
+
+ private static async Task WriteIntentAsync(CultCache cache, string action, Dictionary options)
+ {
+ var command = new BrokkrUnityCommand
+ {
+ // A command id is single-use, so every invocation is a new act with a new id. --command-id exists for
+ // callers that need to name their own; reusing an answered id is a caller defect.
+ commandId = options.TryGetValue("--command-id", out var commandId) && !string.IsNullOrWhiteSpace(commandId)
+ ? commandId
+ : Guid.NewGuid().ToString("N"),
+ action = action
+ };
+ foreach (var (name, value) in options)
+ if (Fields.TryGetValue(name, out var assign))
+ assign(command, value);
+
+ await cache.AddAsync(command, new CultRecordHandle(BrokkrCommandLedger.CommandKey(command.commandId)));
+ await cache.FlushAsync();
+
+ var waitMilliseconds = options.TryGetValue("--wait-ms", out var wait) ? Integer("--wait-ms", wait) : 10000;
+ if (waitMilliseconds <= 0)
+ {
+ Console.WriteLine(command.commandId);
+ return 0;
+ }
+
+ var receiptKey = BrokkrCommandLedger.ReceiptKey(command.commandId);
+ var deadline = DateTimeOffset.UtcNow.AddMilliseconds(waitMilliseconds);
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ await Task.Delay(100);
+ await cache.PullAllBackingStoresAsync();
+ if (!cache.TryGet(receiptKey, out BrokkrUnityCommandReceipt? receipt) || receipt == null)
+ continue;
+
+ Console.WriteLine($"{receipt.status}: {receipt.message}");
+ if (!string.IsNullOrWhiteSpace(receipt.objectId))
+ Console.WriteLine(receipt.objectId);
+ // The exit code is the receipt's verdict, never the write's success.
+ return string.Equals(receipt.status, "accepted", StringComparison.Ordinal) ? 0 : 1;
+ }
+
+ var host = ReadHostSnapshot(cache);
+ var why = host is { agentCommandsEnabled: false }
+ ? " The editor reports agent commands off; the operator enables them in the Brokkr window."
+ : "";
+ throw new TimeoutException(
+ $"Timed out waiting for Brokkr receipt '{receiptKey.Value}'; the intent stays written.{why}");
+ }
+
+ private static BrokkrHostSnapshot? ReadHostSnapshot(CultCache cache) =>
+ cache.TryGet(new CultRecordKey("unity/host/current"), out BrokkrHostSnapshot? host) ? host : null;
+
+ private static Dictionary Parse(string[] args)
+ {
+ var values = new Dictionary(StringComparer.Ordinal);
+ for (var index = 0; index < args.Length; index += 2)
+ {
+ if (index + 1 >= args.Length || !args[index].StartsWith("--", StringComparison.Ordinal))
+ throw new ArgumentException("Arguments must be --name value pairs.");
+ var name = args[index];
+ if (name is not ("--unity-cache" or "--action" or "--command-id" or "--wait-ms") && !Fields.ContainsKey(name))
+ throw new ArgumentException($"Unknown option {name}.");
+ values[name] = args[index + 1];
+ }
+
+ return values;
+ }
+
+ private static string Required(Dictionary options, string name) =>
+ options.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)
+ ? value
+ : throw new ArgumentException($"{name} is required.");
+
+ private static int Integer(string name, string value) =>
+ int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
+ ? parsed
+ : throw new ArgumentException($"{name} must be an integer.");
+}
diff --git a/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj b/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj
index d3f6e46..816b32f 100644
--- a/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj
+++ b/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj
@@ -9,6 +9,7 @@
+
diff --git a/brokkr-contracts.Tests/BrokkrCommandCliTests.cs b/brokkr-contracts.Tests/BrokkrCommandCliTests.cs
new file mode 100644
index 0000000..f400d87
--- /dev/null
+++ b/brokkr-contracts.Tests/BrokkrCommandCliTests.cs
@@ -0,0 +1,188 @@
+using System.Diagnostics;
+using GameCult.Brokkr;
+using GameCult.Caching;
+
+namespace Brokkr.Contracts.Tests;
+
+// brokkr-command as a process, against the real directory store and the editor probe.
+public sealed class BrokkrCommandCliTests
+{
+ private static readonly string CliDll = Path.Combine(AppContext.BaseDirectory, "Brokkr.Command.dll");
+
+ private static async Task<(int Exit, string Out, string Error)> RunAsync(params string[] args)
+ {
+ var start = new ProcessStartInfo("dotnet")
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ };
+ start.ArgumentList.Add(CliDll);
+ foreach (var arg in args) start.ArgumentList.Add(arg);
+ using var process = Process.Start(start)!;
+ var stdout = process.StandardOutput.ReadToEndAsync();
+ var stderr = process.StandardError.ReadToEndAsync();
+ await process.WaitForExitAsync();
+ return (process.ExitCode, await stdout, await stderr);
+ }
+
+ private static BrokkrUnityCommand[] StoredIntents(ScratchProject project)
+ {
+ using var audit = project.OpenCache();
+ return audit.AllStoredDocuments.Select(entry => entry.Document).OfType().ToArray();
+ }
+
+ [Fact]
+ public async Task EveryIntentFieldReachesTheStore()
+ {
+ using var project = new ScratchProject();
+
+ var (exit, output, _) = await RunAsync(
+ "--unity-cache", project.StorePath, "--action", "setGameObjectTransform", "--command-id", "all-fields",
+ "--wait-ms", "0",
+ "--target-object-id", "obj-1", "--name", "Widget", "--component-type", "Light", "--property-path", "m_Range",
+ "--value", "12", "--asset-path", "Assets/x.prefab", "--parent-object-id", "parent-1",
+ "--local-position", "1,2,3", "--local-euler-angles", "4,5,6", "--local-scale", "7,8,9",
+ "--view", "scene", "--output", "artifacts/shot.png", "--width", "640", "--height", "480");
+
+ Assert.Equal(0, exit);
+ Assert.Equal("all-fields", output.Trim());
+ var intent = Assert.Single(StoredIntents(project));
+ Assert.Equal(
+ new[]
+ {
+ "all-fields", "setGameObjectTransform", "obj-1", "Widget", "Light", "m_Range", "12", "Assets/x.prefab",
+ "parent-1", "1,2,3", "4,5,6", "7,8,9", "scene", "artifacts/shot.png"
+ },
+ new[]
+ {
+ intent.commandId, intent.action, intent.targetObjectId, intent.name, intent.componentType,
+ intent.propertyPath, intent.value, intent.assetPath, intent.parentObjectId, intent.localPosition,
+ intent.localEulerAngles, intent.localScale, intent.viewKind, intent.outputPath
+ });
+ Assert.Equal((640, 480), (intent.width, intent.height));
+ }
+
+ [Fact]
+ public async Task EveryInvocationMintsAFreshId()
+ {
+ using var project = new ScratchProject();
+
+ var first = await RunAsync("--unity-cache", project.StorePath, "--action", "refreshAssets", "--wait-ms", "0");
+ var second = await RunAsync("--unity-cache", project.StorePath, "--action", "refreshAssets", "--wait-ms", "0");
+
+ Assert.Equal(0, first.Exit);
+ Assert.Equal(0, second.Exit);
+ Assert.NotEqual(first.Out.Trim(), second.Out.Trim());
+ Assert.Equal(2, StoredIntents(project).Length);
+ }
+
+ [Fact]
+ public async Task ExitCodeIsTheReceiptsVerdict()
+ {
+ using var project = new ScratchProject();
+ using var editor = await EditorProbe.StartAsync(project.StorePath);
+ editor.StatusFor = command => command.action == "createGameObject" ? "failed" : "accepted";
+ using var stop = new CancellationTokenSource();
+ var loop = Task.Run(async () =>
+ {
+ while (!stop.IsCancellationRequested)
+ {
+ await editor.TickAsync();
+ await Task.Delay(50);
+ }
+ });
+
+ var accepted = await RunAsync("--unity-cache", project.StorePath, "--action", "refreshAssets", "--wait-ms", "20000");
+ var failed = await RunAsync("--unity-cache", project.StorePath, "--action", "createGameObject", "--wait-ms", "20000");
+ stop.Cancel();
+ await loop;
+
+ Assert.Equal(0, accepted.Exit);
+ Assert.StartsWith("accepted:", accepted.Out);
+ Assert.Equal(1, failed.Exit);
+ Assert.StartsWith("failed:", failed.Out);
+ }
+
+ [Fact]
+ public async Task NoEditorAnswerIsATimeoutAndLeavesOnlyTheIntent()
+ {
+ using var project = new ScratchProject();
+
+ var result = await RunAsync("--unity-cache", project.StorePath, "--action", "refreshAssets", "--wait-ms", "600");
+
+ Assert.Equal(2, result.Exit);
+ Assert.Contains("Timed out", result.Error);
+ using var audit = project.OpenCache();
+ var documents = audit.AllStoredDocuments.ToArray();
+ Assert.Single(documents);
+ Assert.IsType(documents[0].Document);
+ }
+
+ [Fact]
+ public async Task UnknownOptionIsRefusedBeforeAnythingIsWritten()
+ {
+ using var project = new ScratchProject();
+
+ var result = await RunAsync("--unity-cache", project.StorePath, "--action", "refreshAssets", "--bogus", "1");
+
+ Assert.Equal(2, result.Exit);
+ Assert.Contains("Unknown option --bogus", result.Error);
+ Assert.False(Directory.Exists(Path.GetDirectoryName(project.StorePath)));
+ }
+
+ [Fact]
+ public async Task ReadHostShowsWhetherTheSinkIsEnabled()
+ {
+ using var project = new ScratchProject();
+ using var editor = await EditorProbe.StartAsync(project.StorePath);
+ await editor.TickAsync();
+
+ var result = await RunAsync("--unity-cache", project.StorePath, "--action", "readHost");
+
+ Assert.Equal(0, result.Exit);
+ Assert.Contains("agent-commands: on", result.Out);
+ Assert.Contains("project: probe", result.Out);
+ }
+
+ [Theory]
+ [InlineData("createGameObject")]
+ [InlineData("attachComponent")]
+ [InlineData("setGameObjectTransform")]
+ [InlineData("setGameObjectActive")]
+ [InlineData("setGameObjectParent")]
+ [InlineData("setComponentProperty")]
+ [InlineData("instantiatePrefab")]
+ [InlineData("createPrefabVariant")]
+ [InlineData("assignMaterial")]
+ [InlineData("createScriptableObject")]
+ [InlineData("refreshAssets")]
+ [InlineData("setEditorPlayState")]
+ [InlineData("setEditorPaused")]
+ [InlineData("captureEditorView")]
+ public async Task EveryActionNameReachesTheEditorOnceWithOneReceipt(string action)
+ {
+ using var project = new ScratchProject();
+ using var editor = await EditorProbe.StartAsync(project.StorePath);
+ using var stop = new CancellationTokenSource();
+ var loop = Task.Run(async () =>
+ {
+ while (!stop.IsCancellationRequested)
+ {
+ await editor.TickAsync();
+ await Task.Delay(50);
+ }
+ });
+
+ var result = await RunAsync("--unity-cache", project.StorePath, "--action", action, "--wait-ms", "20000");
+ await Task.Delay(200);
+ stop.Cancel();
+ await loop;
+
+ Assert.Equal(0, result.Exit);
+ var executed = Assert.Single(editor.Executed);
+ Assert.Equal(action, executed.action);
+ using var audit = project.OpenCache();
+ var receipt = Assert.Single(audit.AllStoredDocuments.Select(entry => entry.Document).OfType());
+ Assert.Equal(executed.commandId, receipt.commandId);
+ }
+}
diff --git a/brokkr-contracts.Tests/EditorProbe.cs b/brokkr-contracts.Tests/EditorProbe.cs
index 133c504..6e00a78 100644
--- a/brokkr-contracts.Tests/EditorProbe.cs
+++ b/brokkr-contracts.Tests/EditorProbe.cs
@@ -9,13 +9,16 @@ namespace Brokkr.Contracts.Tests;
// The scenario harness: an editor without Unity. It opens the store exactly as BrokkrCultMeshMirror does (a CultMesh
// node with StartServer=false over a directory store and durable shard logs) and runs one service tick in the
// service order: pull, drain pending intents through the ledger, receipt, soft flush, host snapshot, soft flush.
-// "Executing" an intent only records its id, so the scenarios measure delivery and once-only execution, not Unity.
+// "Executing" an intent only records it, so the scenarios measure delivery and once-only execution, not Unity.
internal sealed class EditorProbe : IDisposable
{
private readonly CultMeshNode node;
private readonly BrokkrCommandLedger ledger;
- internal List Executed { get; } = new();
+ internal List Executed { get; } = new();
+
+ // What the "editor" answers with. Real Unity answers accepted or failed per the executor.
+ internal Func StatusFor { get; set; } = _ => "accepted";
private EditorProbe(CultMeshNode node)
{
@@ -41,13 +44,13 @@ internal sealed class EditorProbe : IDisposable
await ledger.PullAsync();
while (ledger.TryNextPending(out var command))
{
- Executed.Add(command.commandId);
+ Executed.Add(command);
await node.Database.PutAsync(
BrokkrCommandLedger.ReceiptKey(command.commandId),
new BrokkrUnityCommandReceipt
{
commandId = command.commandId,
- status = "accepted",
+ status = StatusFor(command),
message = "probe",
observedAt = DateTime.UtcNow.ToString("O")
});
@@ -56,7 +59,7 @@ internal sealed class EditorProbe : IDisposable
await node.Database.PutAsync(
new CultRecordKey("unity/host/current"),
- new BrokkrHostSnapshot { observedAt = DateTime.UtcNow.ToString("O"), projectPath = "probe" });
+ new BrokkrHostSnapshot { observedAt = DateTime.UtcNow.ToString("O"), projectPath = "probe", agentCommandsEnabled = true });
await node.FlushAsync(soft: true);
}
diff --git a/brokkr-contracts.Tests/EditorScenarioTests.cs b/brokkr-contracts.Tests/EditorScenarioTests.cs
index c34ef44..3294d5b 100644
--- a/brokkr-contracts.Tests/EditorScenarioTests.cs
+++ b/brokkr-contracts.Tests/EditorScenarioTests.cs
@@ -38,7 +38,7 @@ public sealed class EditorScenarioTests
await editorLoop;
Assert.Equal(commands, editor.Executed.Count);
- Assert.Equal(ids.OrderBy(id => id), editor.Executed.OrderBy(id => id));
+ Assert.Equal(ids.OrderBy(id => id), editor.Executed.Select(command => command.commandId).OrderBy(id => id));
using var audit = project.OpenCache();
var receipts = audit.AllStoredDocuments.Select(entry => entry.Document).OfType().ToArray();
Assert.Equal(ids.OrderBy(id => id), receipts.Select(receipt => receipt.commandId).OrderBy(id => id));
diff --git a/docs/provider-contract.md b/docs/provider-contract.md
index f1a3dd1..e8cd2ff 100644
--- a/docs/provider-contract.md
+++ b/docs/provider-contract.md
@@ -72,9 +72,17 @@ Primary sync documents:
Operational commands:
+- `dotnet run --project brokkr-command/Brokkr.Command.csproj -- --unity-cache .brokkr/unity-editor.ccmp --action readHost`
+- `dotnet run --project brokkr-command/Brokkr.Command.csproj -- --unity-cache .brokkr/unity-editor.ccmp --action createGameObject --name Marker --local-position 0,1,0`
- `brokkr-daemon sync-once --unity-cache .brokkr/unity-editor.ccmp --blender-cache .brokkr/blender-editor.ccmp`
- `brokkr-daemon sync-loop --unity-cache .brokkr/unity-editor.ccmp --blender-cache .brokkr/blender-editor.ccmp --interval-ms 500`
+`brokkr-command` is a caller. Each invocation writes one intent through the
+directory store (every intent field has a `--kebab-case` option; `--view` and
+`--output` fill the capture fields), waits for the receipt, and exits 0 for
+`accepted`, 1 for any other status, and 2 for a usage error or a timeout. It
+never writes receipts, host snapshots or the agent-commands flag.
+
`sync-loop` owns scheduling only. It repeatedly invokes the same sync decision
primitive as `sync-once`, writes command intents and sync receipts through the
mirrors, and prints per-pass telemetry to stdout.
diff --git a/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs
index 49204bf..42a2d47 100644
--- a/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs
+++ b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs
@@ -57,6 +57,11 @@ namespace GameCult.Brokkr
[Key(10)] public string localPosition = "";
[Key(11)] public string localEulerAngles = "";
[Key(12)] public string localScale = "";
+ // Editor view capture (A4): viewKind is scene or game; outputPath is relative to the project root.
+ [Key(13)] public string viewKind = "";
+ [Key(14)] public string outputPath = "";
+ [Key(15)] public int width;
+ [Key(16)] public int height;
[Key(13)] public string[] materialNames = Array.Empty();
}
@@ -112,6 +117,11 @@ namespace GameCult.Brokkr
[Key(10)] public string localPosition = "";
[Key(11)] public string localEulerAngles = "";
[Key(12)] public string localScale = "";
+ // Editor view capture (A4): viewKind is scene or game; outputPath is relative to the project root.
+ [Key(13)] public string viewKind = "";
+ [Key(14)] public string outputPath = "";
+ [Key(15)] public int width;
+ [Key(16)] public int height;
}
[MessagePackObject]