# Debug Logging & Frontend↔Backend Traceability Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build-configuration-driven logging — verbose in Debug builds (Rider run button), minimal `Warning`+ in Release (installed app) — with both processes writing one shared `claudedo-.log` and a `TaskId` correlation key threading UI→Worker→UI. **Architecture:** A new `ClaudeDo.Logging` library owns all Serilog setup: a `BuildConfig.IsDebug` runtime check (via the entry assembly's `DebuggableAttribute`, no `#if DEBUG`), a default-`TaskId` enricher, and a `LoggingSetup.Configure` method that branches sinks/levels on `IsDebug`. Worker and App both call it. `TaskId` rides Serilog `LogContext`, pushed at the per-task entry points on each side. **Tech Stack:** .NET 8, Serilog (core + File + Console sinks), Serilog.Extensions.Logging (App bridge), Serilog.AspNetCore (Worker, already present), xUnit. --- ### Task 1: Create the `ClaudeDo.Logging` project **Files:** - Create: `src/ClaudeDo.Logging/ClaudeDo.Logging.csproj` - Create: `src/ClaudeDo.Logging/Placeholder.cs` (temporary, removed in Task 2) - Modify: `ClaudeDo.slnx` - [ ] **Step 1: Create the csproj** Create `src/ClaudeDo.Logging/ClaudeDo.Logging.csproj`: ```xml net8.0 enable enable ``` > If NuGet reports a version conflict between `Serilog 4.1.0` and the `Serilog` core pulled transitively by `Serilog.AspNetCore 8.0.3` (Worker), align this `Serilog` version to whatever `Serilog.AspNetCore 8.0.3` resolves (check `dotnet list package --include-transitive`) and rebuild. - [ ] **Step 2: Add a temporary placeholder so the project compiles** Create `src/ClaudeDo.Logging/Placeholder.cs`: ```csharp namespace ClaudeDo.Logging; internal static class Placeholder; ``` - [ ] **Step 3: Register the project in the solution** Edit `ClaudeDo.slnx` — add inside the `/src/` folder, after the `ClaudeDo.Localization` line: ```xml ``` - [ ] **Step 4: Build the new project** Run: `dotnet build src/ClaudeDo.Logging/ClaudeDo.Logging.csproj -c Release` Expected: Build succeeded. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Logging/ClaudeDo.Logging.csproj src/ClaudeDo.Logging/Placeholder.cs ClaudeDo.slnx git commit -m "build(logging): scaffold ClaudeDo.Logging project" ``` --- ### Task 2: `DefaultTaskIdEnricher` (TDD) Adds `TaskId = "-"` to any log event that doesn't already carry a `TaskId` property, so the `[{TaskId}]` column never renders the raw token. A pushed `LogContext` value takes precedence (because `Enrich.FromLogContext()` runs first and the property is then already present). **Files:** - Create: `src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs` - Delete: `src/ClaudeDo.Logging/Placeholder.cs` - Create: `tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs` - Modify: `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` (add project reference) - [ ] **Step 1: Reference `ClaudeDo.Logging` from the test project** Edit `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — add to the existing `ProjectReference` ItemGroup: ```xml ``` - [ ] **Step 2: Write the failing test** Create `tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs`: ```csharp using ClaudeDo.Logging; using Serilog; using Serilog.Context; using Serilog.Core; using Serilog.Events; namespace ClaudeDo.Worker.Tests.Logging; public sealed class DefaultTaskIdEnricherTests { private sealed class CollectingSink : ILogEventSink { public List Events { get; } = new(); public void Emit(LogEvent logEvent) => Events.Add(logEvent); } [Fact] public void AddsDash_WhenNoTaskIdInScope() { var sink = new CollectingSink(); using var logger = new LoggerConfiguration() .Enrich.FromLogContext() .Enrich.With(new DefaultTaskIdEnricher()) .WriteTo.Sink(sink) .CreateLogger(); logger.Information("hello"); var prop = Assert.Single(sink.Events).Properties["TaskId"]; Assert.Equal("\"-\"", prop.ToString()); } [Fact] public void KeepsPushedTaskId_WhenInScope() { var sink = new CollectingSink(); using var logger = new LoggerConfiguration() .Enrich.FromLogContext() .Enrich.With(new DefaultTaskIdEnricher()) .WriteTo.Sink(sink) .CreateLogger(); using (LogContext.PushProperty("TaskId", "task-42")) logger.Information("hello"); var prop = Assert.Single(sink.Events).Properties["TaskId"]; Assert.Equal("\"task-42\"", prop.ToString()); } } ``` - [ ] **Step 3: Run the test to verify it fails** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DefaultTaskIdEnricherTests` Expected: FAIL — `DefaultTaskIdEnricher` does not exist (compile error). - [ ] **Step 4: Implement the enricher and remove the placeholder** Delete `src/ClaudeDo.Logging/Placeholder.cs`. Create `src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs`: ```csharp using Serilog.Core; using Serilog.Events; namespace ClaudeDo.Logging; /// Ensures every log event carries a TaskId property (defaulting to "-") /// so the output template's [{TaskId}] column never renders the raw token. public sealed class DefaultTaskIdEnricher : ILogEventEnricher { public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { if (!logEvent.Properties.ContainsKey("TaskId")) logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TaskId", "-")); } } ``` - [ ] **Step 5: Run the test to verify it passes** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DefaultTaskIdEnricherTests` Expected: PASS (2 tests). - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj git rm src/ClaudeDo.Logging/Placeholder.cs git commit -m "feat(logging): default TaskId enricher with passing tests" ``` --- ### Task 3: `BuildConfig.IsDebug` Detects whether the entry assembly was compiled in the Debug configuration (JIT optimizer disabled) — the runtime replacement for `#if DEBUG`. **Files:** - Create: `src/ClaudeDo.Logging/BuildConfig.cs` - Create: `tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs` - [ ] **Step 1: Write the failing test** The test asserts the property returns *some* bool without throwing, and that the underlying detection logic agrees with the test assembly's own `DebuggableAttribute` (the test runs under whatever config `dotnet test` used). We assert the helper's result equals a locally-computed expectation so it passes under both Debug and Release test runs. Create `tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs`: ```csharp using System.Diagnostics; using System.Reflection; using ClaudeDo.Logging; namespace ClaudeDo.Worker.Tests.Logging; public sealed class BuildConfigTests { [Fact] public void IsDebug_MatchesEntryAssemblyDebuggableAttribute() { var entry = Assembly.GetEntryAssembly(); var expected = entry? .GetCustomAttribute() ?.IsJITOptimizerDisabled ?? false; Assert.Equal(expected, BuildConfig.IsDebug); } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter BuildConfigTests` Expected: FAIL — `BuildConfig` does not exist (compile error). - [ ] **Step 3: Implement `BuildConfig`** Create `src/ClaudeDo.Logging/BuildConfig.cs`: ```csharp using System.Diagnostics; using System.Reflection; namespace ClaudeDo.Logging; /// Runtime build-configuration detection — the replacement for #if DEBUG. /// Debug builds compile with the JIT optimizer disabled; Release builds enable it. public static class BuildConfig { public static bool IsDebug { get; } = Assembly.GetEntryAssembly() ?.GetCustomAttribute() ?.IsJITOptimizerDisabled ?? false; } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter BuildConfigTests` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Logging/BuildConfig.cs tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs git commit -m "feat(logging): runtime Debug-build detection via DebuggableAttribute" ``` --- ### Task 4: `LoggingSetup.Configure` The single shared configuration entry point. Applies enrichers, the output template, and branches sinks/levels on `BuildConfig.IsDebug`. **Files:** - Create: `src/ClaudeDo.Logging/LoggingSetup.cs` - Create: `tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs` - [ ] **Step 1: Write the failing test** Verifies a configured logger actually writes a `Warning` (emitted in both build configs) to a `claudedo-*.log` file under the given log root. Create `tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs`: ```csharp using ClaudeDo.Logging; using Serilog; namespace ClaudeDo.Worker.Tests.Logging; public sealed class LoggingSetupTests { [Fact] public void Configure_WritesSharedLogFile() { var logRoot = Path.Combine(Path.GetTempPath(), "claudedo-logtest-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(logRoot); try { var logger = LoggingSetup.Configure(new LoggerConfiguration(), "test", logRoot).CreateLogger(); logger.Warning("marker-{Marker}", "xyz"); logger.Dispose(); // flush + release the file handle var files = Directory.GetFiles(logRoot, "claudedo-*.log"); var file = Assert.Single(files); var contents = File.ReadAllText(file); Assert.Contains("marker-", contents); Assert.Contains("test/", contents); // {Process} tag in the template } finally { try { Directory.Delete(logRoot, recursive: true); } catch { /* best effort */ } } } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter LoggingSetupTests` Expected: FAIL — `LoggingSetup` does not exist (compile error). - [ ] **Step 3: Implement `LoggingSetup`** Create `src/ClaudeDo.Logging/LoggingSetup.cs`: ```csharp using Serilog; using Serilog.Events; namespace ClaudeDo.Logging; public static class LoggingSetup { private const string OutputTemplate = "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Process}/{SourceContext} [{TaskId}] {Message:lj}{NewLine}{Exception}"; /// Apply the shared ClaudeDo logging configuration. /// Debug builds: Debug level, console + shared file. Release builds: Warning level, shared file only. /// "worker" or "app" — tags every line so the interleaved file is readable. /// Directory for the shared claudedo-.log (created if missing). public static LoggerConfiguration Configure(LoggerConfiguration cfg, string processTag, string logRoot) { Directory.CreateDirectory(logRoot); var logFile = Path.Combine(logRoot, "claudedo-.log"); cfg.Enrich.FromLogContext() .Enrich.WithProperty("Process", processTag) .Enrich.With(new DefaultTaskIdEnricher()); if (BuildConfig.IsDebug) { cfg.MinimumLevel.Debug() .WriteTo.Console(outputTemplate: OutputTemplate) .WriteTo.File( logFile, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 2, shared: true, outputTemplate: OutputTemplate); } else { cfg.MinimumLevel.Warning() .WriteTo.File( logFile, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 2, shared: true, outputTemplate: OutputTemplate); } return cfg; } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter LoggingSetupTests` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Logging/LoggingSetup.cs tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs git commit -m "feat(logging): shared LoggingSetup with build-config sink branching" ``` --- ### Task 5: Wire the Worker to the shared setup **Files:** - Modify: `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` - Modify: `src/ClaudeDo.Worker/Program.cs:34-40` - [ ] **Step 1: Add the project reference** Edit `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — add to the existing `ProjectReference` ItemGroup (the one with `ClaudeDo.Data`): ```xml ``` - [ ] **Step 2: Replace the inline Serilog config** In `src/ClaudeDo.Worker/Program.cs`, replace lines 34-40: ```csharp builder.Host.UseSerilog((ctx, lc) => lc .MinimumLevel.Information() .WriteTo.File( System.IO.Path.Combine(logRoot, "worker-.log"), rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7, shared: true)); ``` with: ```csharp builder.Host.UseSerilog((ctx, lc) => ClaudeDo.Logging.LoggingSetup.Configure(lc, "worker", logRoot)); ``` - [ ] **Step 3: Build the Worker** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release` Expected: Build succeeded. (If the Worker is running and locks the Debug output, this Release build is unaffected.) - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Worker/ClaudeDo.Worker.csproj src/ClaudeDo.Worker/Program.cs git commit -m "feat(logging): route Worker logging through shared LoggingSetup" ``` --- ### Task 6: Wire the App/Ui (currently log-silent) to the shared setup The App uses a plain `ServiceCollection` with **no** logging registered. Add the Serilog→`ILogger` bridge so all `ILogger` injections across App/Ui flow to the shared sinks, and flush on shutdown. **Files:** - Modify: `src/ClaudeDo.App/ClaudeDo.App.csproj` - Modify: `src/ClaudeDo.App/Program.cs` - [ ] **Step 1: Add packages and the project reference** Edit `src/ClaudeDo.App/ClaudeDo.App.csproj` — add to the package `ItemGroup`: ```xml ``` and to the `ProjectReference` ItemGroup: ```xml ``` - [ ] **Step 2: Add the logging registration in `BuildServices`** In `src/ClaudeDo.App/Program.cs`, inside `BuildServices()`, immediately after the `var sc = new ServiceCollection();` line (currently line 78), insert: ```csharp var logRoot = Path.Combine(Path.GetDirectoryName(dbPath)!, "logs"); var serilogLogger = ClaudeDo.Logging.LoggingSetup .Configure(new Serilog.LoggerConfiguration(), "app", logRoot) .CreateLogger(); sc.AddLogging(b => b.AddSerilog(serilogLogger, dispose: true)); ``` Add these usings to the top of `Program.cs` (the `AddSerilog` `ILoggingBuilder` extension lives in the `Serilog` namespace; `AddLogging` lives in `Microsoft.Extensions.DependencyInjection`, already imported): ```csharp using Serilog; using Microsoft.Extensions.Logging; ``` > `dbPath` is already computed just above (`var dbPath = Paths.Expand(settings.DbPath);`). Its parent directory is `~/.todo-app`, so `logs` sits beside the Worker's log root. - [ ] **Step 3: Build the App** Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` Expected: Build succeeded (pulls in Ui + Data + Logging). - [ ] **Step 4: Verify manually from Rider (visual-verification gap)** This is a Debug-build behavior that cannot be asserted in a Release test run. Launch the App from Rider's run button and confirm: - A `claudedo-*.log` appears in `~/.todo-app/logs/`. - Console output (Rider run window) shows `Debug`-level lines tagged `app/...`. Flag to the user that this step needs their eyes. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.App/ClaudeDo.App.csproj src/ClaudeDo.App/Program.cs git commit -m "feat(logging): wire App/Ui logging to shared LoggingSetup" ``` --- ### Task 7: Push `TaskId` into `LogContext` in the Worker Wraps the two per-task entry points so every nested log line (runner, state service, worktree, planning) carries the task's id automatically. **Files:** - Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:47` (`RunAsync`) and `:171` (`ContinueAsync`) - [ ] **Step 1: Add the using directive** In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, add to the top usings: ```csharp using Serilog.Context; ``` - [ ] **Step 2: Push TaskId at the top of `RunAsync`** In `RunAsync` (line 47), insert as the very first statement of the method body (before `string? mcpToken = null;`): ```csharp using var _taskScope = LogContext.PushProperty("TaskId", task.Id); ``` - [ ] **Step 3: Push TaskId at the top of `ContinueAsync`** In `ContinueAsync` (line 171), insert as the very first statement of the method body (before `TaskEntity task;`). The parameter is `taskId`: ```csharp using var _taskScope = LogContext.PushProperty("TaskId", taskId); ``` - [ ] **Step 4: Build the Worker** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release` Expected: Build succeeded. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Runner/TaskRunner.cs git commit -m "feat(logging): tag Worker task execution with TaskId for traceability" ``` --- ### Task 8: Push `TaskId` and add trace lines on the App side `WorkerClient` currently logs nothing. Inject `ILogger`, add a small helper that pushes `TaskId` + emits a `Debug` trace line, and route the fire-and-forget task-targeted hub calls through it. This produces the UI half of the UI→Worker→UI trace under a shared `TaskId`. **Files:** - Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs` - Modify: `src/ClaudeDo.App/Program.cs:101` (registration) - [ ] **Step 1: Add usings and the logger field/ctor param** In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add to the usings: ```csharp using Microsoft.Extensions.Logging; using Serilog.Context; ``` Add a field beside `private readonly HubConnection _hub;` (line 32): ```csharp private readonly ILogger _logger; ``` Change the constructor signature (line 68) from: ```csharp public WorkerClient(string signalRUrl) { ``` to: ```csharp public WorkerClient(string signalRUrl, ILogger logger) { _logger = logger; ``` - [ ] **Step 2: Add the task-scoped invoke helper** In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add this private method next to `TryInvokeAsync` (after line 241): ```csharp /// Invoke a task-targeted hub method under a TaskId log scope, emitting a debug trace line. private async Task InvokeForTaskAsync(string taskId, string method, params object?[] args) { using (LogContext.PushProperty("TaskId", taskId)) { _logger.LogDebug("UI invoking {Method} for task {TaskId}", method, taskId); await _hub.InvokeCoreAsync(method, args); } } ``` - [ ] **Step 3: Route the fire-and-forget task actions through the helper** In the same file, replace each of these method bodies: `RunNowAsync` (line 243): ```csharp public Task RunNowAsync(string taskId) => InvokeForTaskAsync(taskId, "RunNow", taskId); ``` `ContinueTaskAsync` (line 248): ```csharp public Task ContinueTaskAsync(string taskId, string followUpPrompt) => InvokeForTaskAsync(taskId, "ContinueTask", taskId, followUpPrompt); ``` `ResetTaskAsync` (line 253): ```csharp public Task ResetTaskAsync(string taskId) => InvokeForTaskAsync(taskId, "ResetTask", taskId); ``` `CancelTaskAsync` (line 267): ```csharp public Task CancelTaskAsync(string taskId) => InvokeForTaskAsync(taskId, "CancelTask", taskId); ``` `ApproveReviewAsync` (line 389): ```csharp public Task ApproveReviewAsync(string taskId) => InvokeForTaskAsync(taskId, "ApproveReview", taskId); ``` `RejectReviewToQueueAsync` (line 394): ```csharp public Task RejectReviewToQueueAsync(string taskId, string feedback) => InvokeForTaskAsync(taskId, "RejectReviewToQueue", taskId, feedback); ``` `RejectReviewToIdleAsync` (line 399): ```csharp public Task RejectReviewToIdleAsync(string taskId) => InvokeForTaskAsync(taskId, "RejectReviewToIdle", taskId); ``` `CancelReviewAsync` (line 404): ```csharp public Task CancelReviewAsync(string taskId) => InvokeForTaskAsync(taskId, "CancelReview", taskId); ``` > These all previously did `await _hub.InvokeAsync(method, ...)` with no return value, so converting them to expression-bodied delegations preserves behavior. Do **not** touch methods that return DTOs (e.g. `MergeTaskAsync`) or the planning methods — keep this change scoped to the void task actions above. - [ ] **Step 4: Update the DI registration to pass the logger** In `src/ClaudeDo.App/Program.cs`, replace line 101: ```csharp sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService().SignalRUrl)); ``` with: ```csharp sc.AddSingleton(sp => new WorkerClient( sp.GetRequiredService().SignalRUrl, sp.GetRequiredService>())); ``` Add `using Microsoft.Extensions.Logging;` to the top of `Program.cs` if not already present. - [ ] **Step 5: Build the App** Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` Expected: Build succeeded. > Note: `WorkerClient` is faked in tests via the `IWorkerClient` *interface* (hand-rolled fakes implement the interface, they do not subclass `WorkerClient`). This change adds a ctor parameter to the concrete class only and does not alter `IWorkerClient`, so the fakes are unaffected. Confirm by building the test projects in the next step. - [ ] **Step 6: Build the test projects to confirm fakes still compile** Run: `dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release && dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release` Expected: Build succeeded for both. - [ ] **Step 7: Run the full Worker.Tests suite** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release` Expected: PASS (all existing tests + the 4 new logging tests). - [ ] **Step 8: Commit** ```bash git add src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.App/Program.cs git commit -m "feat(logging): tag UI task actions with TaskId + debug trace lines" ``` --- ## Final verification - [ ] **Build the whole desktop + worker stack in Release:** ```bash dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release ``` - [ ] **Run the logging tests:** ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~Logging" ``` Expected: PASS (DefaultTaskIdEnricher × 2, BuildConfig × 1, LoggingSetup × 1). - [ ] **Manual smoke test (visual-verification gap — needs the user):** 1. Run the Worker and App from Rider (Debug build). Confirm both write to one `~/.todo-app/logs/claudedo-*.log` with `app/...` and `worker/...` lines. 2. Run a task; grep that file for the task's id — confirm UI (`UI invoking RunNow…`) and Worker lines share the same `[]`. 3. Build/install the Release app; confirm the log is near-silent (no `Debug`/`Information` noise, `Warning`+ only) and no console window logging.