# Monaco Script Editor (Roslyn-backed IntelliSense) Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.

**Goal:** Replace OtOpcUa's CDN-Monaco-over-textarea script editor with a reusable, Roslyn-backed Monaco editor offering full IntelliSense (completions, hover, signature help, live diagnostics, formatting, tag-path completion), re-seated on OtOpcUa's *real* script compile context so the editor accepts/rejects exactly what publish enforces.

**Architecture:** A new `ScriptAnalysis/` backend inside the AdminUI exposes `/api/script-analysis/*` minimal-API endpoints backed by `ScriptAnalysisService`, which compiles the user's source inside OtOpcUa's genuine evaluator wrapper (`public static object Run(ScriptGlobals<VirtualTagContext> globals){ var ctx = globals.ctx; #line 1 «source» }`) using `ScriptSandbox.Build(typeof(VirtualTagContext))` for imports + references, then computes Roslyn completions/hover/diagnostics from the semantic model and layers OtOpcUa's `ForbiddenTypeAnalyzer` + `DependencyExtractor` as extra diagnostics. A vendored Monaco + a reusable `MonacoEditor.razor` + ported `monaco-init.js` register language providers that POST to those endpoints. The editor is wired into the ScriptEdit page and the virtual-tag modal.

**Tech Stack:** .NET 10, Blazor Server (`InteractiveServer`), Roslyn (`Microsoft.CodeAnalysis.CSharp`, transitive via `Core.Scripting`), Monaco Editor (vendored, v0.42 from scadabridge), xUnit + Shouldly, EF Core InMemory (tests).

**Reference sources to port FROM (sister repo `scadabridge`):**
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/MonacoEditor.razor`
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/monaco-init.js`
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/lib/monaco/` (vendored Monaco, 15 MB / 121 files)
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/{ScriptAnalysisContracts,ScriptAnalysisEndpoints,ScriptAnalysisService}.cs`

**OtOpcUa types to reuse (do NOT modify):**
- `ScriptSandbox.Build(Type) → SandboxConfig(IReadOnlyList<MetadataReference> References, IReadOnlyList<string> Imports)` — `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs:46`
- `ForbiddenTypeAnalyzer.Analyze(Compilation) → IReadOnlyList<ForbiddenTypeRejection(TextSpan Span, string TypeName, string Namespace, string Message)>` — `ForbiddenTypeAnalyzer.cs:175,309`
- `DependencyExtractor.Extract(string) → DependencyExtractionResult(IReadOnlySet<string> Reads, IReadOnlySet<string> Writes, IReadOnlyList<DependencyRejection(TextSpan Span, string Message)> Rejections){ bool IsValid }` — `DependencyExtractor.cs:42,147,156`
- `VirtualTagContext : ScriptContext` (`GetTag(string)→DataValueSnapshot`, `SetVirtualTag(string,object?)`, `Now`, `Logger`, static `ScriptContext.Deadband(double,double,double)`) and `ScriptGlobals<TContext>{ TContext ctx }` — `src/Core/Scripting.Abstractions/`
- `DataValueSnapshot(object? Value, uint StatusCode, DateTime? SourceTimestampUtc, DateTime ServerTimestampUtc)` — `Core.Abstractions/DataValueSnapshot.cs:17`
- `OtOpcUaConfigDbContext` with `DbSet<Tag> Tags`, `DbSet<VirtualTag> VirtualTags`, `DbSet<Equipment> Equipment`, `DbSet<Script> Scripts` — `Configuration/OtOpcUaConfigDbContext.cs`

**Existing OtOpcUa files to change:** `Components/Pages/ScriptEdit.razor`, `Components/Shared/Uns/VirtualTagModal.razor`, `Uns/IUnsTreeService.cs` + `UnsTreeService.cs`, `Components/App.razor`, the AdminUI `.csproj`; **delete** `wwwroot/js/monaco-loader.js`. Endpoints mapped in `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (the `hasAdmin` block, after `app.MapAdminUI<App>()`).

**Hard rules (carry through every task):** AdminUI + new `ScriptAnalysis/` + AdminUI.Tests + the one `Program.cs` map-line only. NO Configuration entity/migration change. Stage by path — never `git add .`; never stage `sql_login.txt` or `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; never echo the gateway API key into a new tracked file; no force-push; no `--no-verify`. The agent does NOT sign in to the AdminUI — the user signs in for the live `/run`.

**Branch:** Create `feat/monaco-script-editor` off `master` (HEAD = the commit after `7a03d01`/this plan) before any code task. The design doc (`7a03d01`) and this plan + `.tasks.json` are committed to `master` first; the build runs on the feature branch.

**Authorization:** New endpoints use `.RequireAuthorization("FleetAdmin")` (policy defined in `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:113` as `RequireRole("Administrator")` — script design is an admin action, same tier as deploy).

**Key fidelity notes (read once):**
- The analysis return type is `object` (a `Script` is shared across virtual tags of differing `DataType`; per-tag return coercion stays a runtime concern).
- The wrapper preamble (usings + `Run` signature + `var ctx = globals.ctx;`) precedes a `#line 1` directive, then the raw user source (column-aligned, not re-indented). Therefore: **diagnostics** — `tree.GetLineSpan(span)` / `d.Location.GetLineSpan()` return *mapped* (user-source) line numbers automatically; **completions/hover/signature** — use the *physical* offset `prefixLength + offsetWithinUserSource`.
- Filter out diagnostics physically located in the preamble (`location.SourceSpan.Start < prefixLength`) so wrapper-level noise (e.g. CS0161 "not all code paths return a value" on the `Run` method when the user hasn't typed `return` yet) doesn't show as a phantom marker. (Missing-`return` enforcement stays a runtime/publish concern for v1.)
- We use `CSharpCompilation` (not `CSharpScript`), so `Microsoft.CodeAnalysis.CSharp.Scripting` is **not** needed; Roslyn core (`Microsoft.CodeAnalysis.CSharp`, incl. `NormalizeWhitespace`) arrives transitively through the `Core.Scripting` project reference. Add `Microsoft.CodeAnalysis.CSharp.Workspaces` only if a build error names a missing type.

---

### Task 0: AdminUI project references + feature branch

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (prerequisite for backend tasks)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (ItemGroup of ProjectReferences, ~lines 19–40)

**Step 1: Create the branch**
```bash
git checkout -b feat/monaco-script-editor
```

**Step 2: Add the two project references** (alongside the existing `Configuration` ref):
```xml
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj" />
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj" />
```
(Confirm the exact directory names of those two projects on disk first — `ls src/Core/ | grep Scripting` — and match them.)

**Step 3: Build to confirm Roslyn resolves transitively**
```bash
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
```
Expected: build succeeds. (`ScriptSandbox`, `CSharpCompilation`, `VirtualTagContext` are now referenceable.)

**Step 4: Commit**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
git commit -m "build(adminui): reference Core.Scripting for the script-analysis backend"
```

---

### Task 1: Vendor Monaco + reusable MonacoEditor.razor + minimal monaco-init.js + App.razor wiring

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2 (disjoint files)

**Files:**
- Create (copy): `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/lib/monaco/**` (from scadabridge)
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor:23-25` (add one `<script>`)

No unit tests (UI/JS — proven by the live `/run` in Task 12).

**Step 1: Vendor Monaco** (file copy, then stage the folder by path):
```bash
cp -R /Users/dohertj2/Desktop/scadabridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/lib/monaco \
      src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/lib/monaco
```
(~15 MB / 121 files under `wwwroot/lib/monaco/vs/`. This is the air-gap-safe replacement for the CDN.)

**Step 2: Create `monaco-init.js`** — port scadabridge's file but **MINIMAL for now** (no language providers yet; those land in Task 9). Keep only the AMD loader + the editor lifecycle + value sync. Critically, set the base path to OtOpcUa's `_content`:
```javascript
(function () {
    const VS_BASE = "/_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/monaco/vs";
    const editors = {};
    let readyPromise = null;

    function ensureLoaded() {
        if (readyPromise) return readyPromise;
        readyPromise = new Promise(function (resolve, reject) {
            const loader = document.createElement("script");
            loader.src = VS_BASE + "/loader.js";
            loader.onload = function () {
                require.config({ paths: { vs: VS_BASE } });
                require(["vs/editor/editor.main"], function () {
                    registerCSharpProviders();   // no-op until Task 9 fills it in
                    resolve();
                });
            };
            loader.onerror = function (e) { reject(e); };
            document.head.appendChild(loader);
        });
        return readyPromise;
    }

    function registerCSharpProviders() { /* Task 9: completions/hover/signature/format/inlay */ }

    async function fetchDiagnostics(model) { return []; }  // Task 9 wires the real fetch

    async function createEditor(id, host, options, dotNetRef) {
        await ensureLoaded();
        if (!host) return;
        const editor = monaco.editor.create(host, {
            value: options.value || "", language: options.language || "csharp",
            theme: "vs", minimap: { enabled: false }, scrollBeyondLastLine: false,
            automaticLayout: true, fontSize: 13, lineNumbers: "on",
            renderLineHighlight: "line", readOnly: !!options.readOnly,
            tabSize: 4, insertSpaces: true, wordWrap: "off", fixedOverflowWidgets: true
        });
        let diagTimer = null;
        const scheduleDiagnostics = function () {
            if (diagTimer) clearTimeout(diagTimer);
            diagTimer = setTimeout(async function () {
                const model = editor.getModel(); if (!model) return;
                const markers = await fetchDiagnostics(model);
                monaco.editor.setModelMarkers(model, "otopcua", markers);
                dotNetRef.invokeMethodAsync("OnMarkersChanged", markers).catch(function () {});
            }, 500);
        };
        editor.onDidChangeModelContent(function () {
            dotNetRef.invokeMethodAsync("OnValueChanged", editor.getValue()).catch(function () {});
            if (options.language === "csharp") scheduleDiagnostics();
        });
        editors[id] = { editor: editor, dotNetRef: dotNetRef };
        if (options.language === "csharp") scheduleDiagnostics();
    }
    function setEditorOption(id, name, value) {
        const e = editors[id]; if (!e) return;
        if (name === "theme") { monaco.editor.setTheme(value); return; }
        const u = {}; u[name] = value; e.editor.updateOptions(u);
    }
    function format(id) { editors[id]?.editor.getAction("editor.action.formatDocument")?.run(); }
    function revealLine(id, line, col) {
        const e = editors[id]; if (!e) return;
        e.editor.revealLineInCenter(line); e.editor.setPosition({ lineNumber: line, column: col || 1 }); e.editor.focus();
    }
    function setValue(id, v) { const e = editors[id]; if (e && e.editor.getValue() !== v) e.editor.setValue(v || ""); }
    function getValue(id) { const e = editors[id]; return e ? e.editor.getValue() : null; }
    function setMarkers(id, m) { const e = editors[id]; const model = e && e.editor.getModel(); if (model) monaco.editor.setModelMarkers(model, "otopcua", m || []); }
    function dispose(id) { const e = editors[id]; if (!e) return; try { e.editor.dispose(); } catch (x) {} delete editors[id]; }

    window.MonacoBlazor = { createEditor, setValue, getValue, setMarkers, setEditorOption, format, revealLine, dispose };
    // expose the editors map + helpers Task 9 will close over:
    window.__otopcuaEditors = editors;
})();
```
(Task 9 reopens this file to add the providers + the real `fetchDiagnostics`. Keeping `editors` on `window.__otopcuaEditors` lets Task 9's providers find the model→editor mapping if needed; for v1 the providers don't need per-editor .NET context, so this may go unused — fine.)

**Step 3: Create `MonacoEditor.razor`** — port scadabridge's component but **strip all SCADA context** (`ScriptKind`, `DeclaredParameters`, `DeclaredParameterShapes`, `SiblingScripts`, `SelfAttributes`, `Children`, `Parent`, `GetContext`, the `ScadaContext` record). Keep: `Value`/`ValueChanged`, `Language="csharp"`, `Height`, `ReadOnly`, `ShowToolbar`, `MarkersChanged`, the toolbar (Format/Wrap/Minimap/Theme), `OnAfterRenderAsync` (create on first render + `setValue` on `Value` change), `[JSInvokable] OnValueChanged`/`OnMarkersChanged`, `RevealLineAsync`, `SafeInvokeAsync`, `DisposeAsync`. Namespace: `ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared`. Use a `DiagnosticMarker[]` parameter type for `OnMarkersChanged` (the DTO created in Task 2 — until then, type it `object[]` and tighten in Task 9, OR sequence Task 2 before this; simplest: keep `OnMarkersChanged(object[] markers)` here and let Task 9 finalize). The host div + create-options match scadabridge.

**Step 4: Wire the script in `App.razor`** — add before `<script src="_framework/blazor.web.js">`:
```html
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
```

**Step 5: Build**
```bash
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
```
Expected: succeeds.

**Step 6: Commit** (stage by path; the monaco folder is large but committed deliberately):
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/lib/monaco \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor
git commit -m "feat(adminui): vendor Monaco + reusable MonacoEditor component (no providers yet)"
```

---

### Task 2: ScriptAnalysis contracts + service analysis seam + endpoints + DI

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1 (disjoint files)

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs`
- Modify: the AdminUI service-registration site (find `AddAdminUI`/`AddOtOpcUaAdminUI` extension, else the `hasAdmin` block in `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (~line 185, after `app.MapAdminUI<App>()`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptAnalysisServiceWrapperTests.cs`

**Step 1: Contracts** — port scadabridge's `ScriptAnalysisContracts.cs` but **drop** `ScriptKind` and every SCADA shape (`ScriptShape`, `ParameterShape`, `AttributeShape`, `CompositionContext`) and all the context fields on the requests. Final shapes:
```csharp
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;

public record DiagnoseRequest(string Code);
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
public record DiagnosticMarker(int Severity, int StartLineNumber, int StartColumn,
    int EndLineNumber, int EndColumn, string Message, string Code);

public record CompletionsRequest(string CodeText, int Line, int Column);
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
public record CompletionItem(string Label, string InsertText, string Detail, string Kind, int InsertTextRules = 0);

public record HoverRequest(string CodeText, int Line, int Column);
public record HoverResponse(string? Markdown);

public record SignatureHelpRequest(string CodeText, int Line, int Column);
public record SignatureHelpResponse(string? Label, IReadOnlyList<SignatureHelpParameter>? Parameters, int ActiveParameter);
public record SignatureHelpParameter(string Label, string? Documentation);

public record FormatRequest(string Code);
public record FormatResponse(string Code);

public record InlayHintsRequest(string Code);
public record InlayHintsResponse(IReadOnlyList<InlayHint> Hints);
public record InlayHint(int Line, int Column, string Label);
```

**Step 2: Service shell + the shared analysis seam.** Create `ScriptAnalysisService` with the wrapper/compilation core all methods share, plus empty public methods (filled in Tasks 3–8). Inject `IScriptTagCatalog` (interface created in Task 5; for now declare the ctor param and an interface stub — OR sequence Task 5 before Task 6 and leave the ctor parameterless until Task 6; simplest: parameterless ctor now, add `IScriptTagCatalog` in Task 6).
```csharp
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;          // ScriptSandbox, ForbiddenTypeAnalyzer, DependencyExtractor
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions; // VirtualTagContext, ScriptGlobals (confirm namespace)

namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;

public sealed class ScriptAnalysisService
{
    private static readonly SandboxConfig Sandbox = ScriptSandbox.Build(typeof(VirtualTagContext));
    private static readonly string Preamble = BuildPreamble();
    private static readonly CSharpCompilationOptions CompileOptions = new(
        OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release,
        allowUnsafe: false, warningLevel: 4, nullableContextOptions: NullableContextOptions.Enable);

    private static string BuildPreamble()
    {
        var usings = string.Join("\n", Sandbox.Imports.Select(i => $"using {i};"));
        return usings + "\n\n" +
            "namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled;\n" +
            "public static class CompiledScript\n{\n" +
            "    public static object Run(ScriptGlobals<VirtualTagContext> globals)\n    {\n" +
            "        var ctx = globals.ctx;\n#line 1\n";
        // user source is appended after this, then "    }\n}\n"
    }

    /// <summary>Wrap user source in OtOpcUa's evaluator shape; returns the tree, semantic model, and the prefix length (chars before user source).</summary>
    private static (SyntaxTree Tree, SemanticModel Model, int Prefix) Analyze(string userSource)
    {
        var prefix = Preamble;
        var full = prefix + (userSource ?? "") + "\n    }\n}\n";
        var tree = CSharpSyntaxTree.ParseText(full);
        var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions);
        return (tree, compilation.GetSemanticModel(tree), prefix.Length);
    }

    /// <summary>(line,col) in user-source coords → physical offset in the wrapped document.</summary>
    private static int OffsetInWrapped(string userSource, int line, int column, int prefix)
        => prefix + Math.Clamp(PositionToOffset(userSource, line, column), 0, (userSource ?? "").Length);

    private static int PositionToOffset(string code, int line, int column)
    {
        int offset = 0, curLine = 1, curCol = 1;
        for (int i = 0; i < code.Length; i++)
        {
            if (curLine == line && curCol == column) return offset;
            if (code[i] == '\n') { curLine++; curCol = 1; } else { curCol++; }
            offset = i + 1;
        }
        return code.Length;
    }

    public DiagnoseResponse Diagnose(DiagnoseRequest req) => new(Array.Empty<DiagnosticMarker>());      // Task 3
    public Task<CompletionsResponse> CompleteAsync(CompletionsRequest req) => Task.FromResult(new CompletionsResponse(Array.Empty<CompletionItem>())); // Tasks 4,6
    public HoverResponse Hover(HoverRequest req) => new((string?)null);                                  // Task 7
    public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req) => new(null, null, 0);          // Task 7
    public FormatResponse Format(FormatRequest req) => new(req.Code);                                    // Task 8
    public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty<InlayHint>());        // Task 8 (stays empty)
}
```
(Verify the exact namespace of `ScriptGlobals`/`VirtualTagContext` — the explorer reported `ZB.MOM.WW.OtOpcUa.Core.Scripting` for the types living under the `Scripting.Abstractions` project; match the actual `namespace` declarations.)

**Step 3: Endpoints** — port `ScriptAnalysisEndpoints.cs`, drop the `/run` route, swap auth:
```csharp
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
{
    var group = endpoints.MapGroup("/api/script-analysis").RequireAuthorization("FleetAdmin");
    group.MapPost("/diagnostics",    (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r)));
    group.MapPost("/completions",    async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
    group.MapPost("/hover",          (HoverRequest r, ScriptAnalysisService s) => Results.Ok(s.Hover(r)));
    group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r)));
    group.MapPost("/format",         (FormatRequest r, ScriptAnalysisService s) => Results.Ok(s.Format(r)));
    group.MapPost("/inlay-hints",    (InlayHintsRequest r, ScriptAnalysisService s) => Results.Ok(s.InlayHints(r)));
    return endpoints;
}
```

**Step 4: DI** — register `services.AddScoped<ScriptAnalysisService>();` (and in Task 5, `services.AddScoped<IScriptTagCatalog, ScriptTagCatalog>();`) in the AdminUI service-registration extension.

**Step 5: Map** — in `Program.cs` `hasAdmin` block, after `app.MapAdminUI<App>();`:
```csharp
app.MapScriptAnalysisEndpoints();
```

**Step 6: Test the wrapper seam** (the one piece of Task 2 with logic worth a test). Since `Analyze`/`OffsetInWrapped` are private, expose a tiny internal test hook OR test indirectly via Task 3. To keep Task 2 self-verifying, add an `internal` method `AnalyzeForTest(string) => Analyze(...)` guarded by `[assembly: InternalsVisibleTo("ZB.MOM.WW.OtOpcUa.AdminUI.Tests")]`, and assert that for source `return 1;` the compilation reports **no** user-span diagnostics and that a member-access after `ctx.` resolves to `VirtualTagContext`. (If you prefer, fold this verification into Task 3's diagnostics tests and keep Task 2 to a build-only check — acceptable.)

**Step 7: Build + commit**
```bash
dotnet build ZB.MOM.WW.OtOpcUa.slnx
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis \
        src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs \
        <the-service-registration-file> \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptAnalysisServiceWrapperTests.cs
git commit -m "feat(adminui): script-analysis contracts, wrapper seam, endpoints + DI"
```

---

### Task 3: Diagnostics — Roslyn ∪ ForbiddenTypeAnalyzer ∪ DependencyExtractor

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5 (disjoint files)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` (`Diagnose`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs`

**Step 1: Write failing tests** (xUnit + Shouldly):
```csharp
public sealed class DiagnoseTests
{
    private static readonly ScriptAnalysisService Svc = new();
    private static IReadOnlyList<DiagnosticMarker> Diag(string code) => Svc.Diagnose(new DiagnoseRequest(code)).Markers;

    [Fact] public void Clean_script_has_no_markers()
        => Diag("""return (double)ctx.GetTag("A").Value * 2.0;""").ShouldBeEmpty();

    [Fact] public void Roslyn_error_is_reported_on_the_user_line()
    {
        var m = Diag("return ctx.NoSuchMember();");
        m.ShouldNotBeEmpty();
        m[0].Severity.ShouldBe(8);
        m[0].StartLineNumber.ShouldBe(1);   // #line 1 maps to user source
    }

    [Fact] public void Forbidden_type_is_flagged()
        => Diag("""var c = new System.Net.Http.HttpClient(); return 0;""")
            .ShouldContain(m => m.Message.Contains("HttpClient") || m.Code.StartsWith("OTSCRIPT") || m.Severity == 8);

    [Fact] public void Dynamic_tag_path_is_flagged()
    {
        var m = Diag("""var p = "A"; return ctx.GetTag(p).Value;""");
        m.ShouldContain(x => x.Message.Contains("literal"));   // DependencyExtractor rejection
    }

    [Fact] public void Preamble_diagnostics_are_suppressed()
        => Diag("var x = 1;").ShouldNotContain(m => m.Code == "CS0161");  // missing-return on Run() is hidden
}
```
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter FullyQualifiedName~DiagnoseTests` → FAIL.

**Step 2: Implement `Diagnose`:**
```csharp
public DiagnoseResponse Diagnose(DiagnoseRequest req)
{
    if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
    var (tree, _, prefix) = Analyze(req.Code);
    var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions);
    var markers = new List<DiagnosticMarker>();

    // (1) Roslyn — only diagnostics physically inside the user source (suppress preamble noise like CS0161 on Run()).
    foreach (var d in compilation.GetDiagnostics())
    {
        if (d.Severity < DiagnosticSeverity.Info || !d.Location.IsInSource) continue;
        if (d.Location.SourceSpan.Start < prefix) continue;
        markers.Add(ToMarker(d.Location.GetLineSpan(), Sev(d.Severity), d.GetMessage(), d.Id));
    }
    // (2) ForbiddenTypeAnalyzer — spans are in the wrapped tree; GetLineSpan honors #line → user coords.
    foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation))
        markers.Add(ToMarker(tree.GetLineSpan(r.Span), 8, r.Message, "OTSCRIPT_FORBIDDEN"));
    // (3) DependencyExtractor — spans are offsets in the *user* source; map directly.
    foreach (var r in DependencyExtractor.Extract(req.Code).Rejections)
        markers.Add(ToUserMarker(req.Code, r.Span, r.Message, "OTSCRIPT_DYNPATH"));

    return new DiagnoseResponse(markers);
}

private static int Sev(DiagnosticSeverity s) => s switch {
    DiagnosticSeverity.Error => 8, DiagnosticSeverity.Warning => 4, DiagnosticSeverity.Info => 2, _ => 1 };

private static DiagnosticMarker ToMarker(FileLinePositionSpan span, int sev, string msg, string code) => new(
    sev, span.StartLinePosition.Line + 1, span.StartLinePosition.Character + 1,
    span.EndLinePosition.Line + 1, span.EndLinePosition.Character + 1, msg, code);

private static DiagnosticMarker ToUserMarker(string userSource, Microsoft.CodeAnalysis.Text.TextSpan span, string msg, string code)
{
    var (sl, sc) = LineCol(userSource, span.Start);
    var (el, ec) = LineCol(userSource, span.End);
    return new(8, sl, sc, el, ec, msg, code);
}
private static (int, int) LineCol(string code, int offset)
{
    int line = 1, col = 1;
    for (int i = 0; i < offset && i < code.Length; i++)
        if (code[i] == '\n') { line++; col = 1; } else col++;
    return (line, col);
}
```

**Step 3: Run tests** → PASS. (If `ForbiddenTypeAnalyzer` reports nothing for `HttpClient` because the reference set already excludes `System.Net.Http`, the Roslyn pass will instead emit a CS-level "type/namespace not found" error in user-span — the test's `||` covers that. Confirm which path fires and tighten the assert.)

**Step 4: Commit**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs
git commit -m "feat(adminui): script diagnostics (Roslyn + forbidden-type + dynamic-path)"
```

---

### Task 4: Completions — scope + dot-member

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5 (disjoint files)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` (`CompleteAsync` + `ToCompletionItem` + `TryGetDotMembers`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs`

**Step 1: Failing tests:**
```csharp
public sealed class CompletionTests
{
    private static readonly ScriptAnalysisService Svc = new();
    private static async Task<IReadOnlyList<CompletionItem>> Complete(string code, int line, int col)
        => (await Svc.CompleteAsync(new CompletionsRequest(code, line, col))).Items;

    [Fact] public async Task Dot_after_ctx_offers_context_members()
    {
        var items = await Complete("ctx.", 1, 5);                 // caret right after "ctx."
        var labels = items.Select(i => i.Label).ToList();
        labels.ShouldContain("GetTag");
        labels.ShouldContain("SetVirtualTag");
        labels.ShouldContain("Now");
    }

    [Fact] public async Task Scope_completion_includes_ctx_local()
        => (await Complete("c", 1, 2)).Select(i => i.Label).ShouldContain("ctx");
}
```
Run filter `~CompletionTests` → FAIL.

**Step 2: Implement** — port scadabridge's `CompleteAsync` core, but build the document via `Analyze(...)` and map the offset via `OffsetInWrapped(...)`; the string-literal branch is added in Task 6 (leave a `// Task 6: tag-path literals` placeholder):
```csharp
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req)
{
    if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty<CompletionItem>());
    var (tree, model, prefix) = Analyze(req.CodeText);
    var position = OffsetInWrapped(req.CodeText, req.Line, req.Column, prefix);
    var root = await tree.GetRootAsync();
    var token = root.FindToken(Math.Max(0, position - 1));

    // Task 6: if token is a string literal inside ctx.GetTag/SetVirtualTag → tag-path completion.

    var dot = TryGetDotMembers(token, model);
    if (dot != null) return new CompletionsResponse(dot);

    var scoped = model.LookupSymbols(position)
        .Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
        .GroupBy(s => s.Name).Select(g => g.First())
        .Select(ToCompletionItem).Take(200).ToList();
    return new CompletionsResponse(scoped);
}
```
Copy `TryGetDotMembers` and `ToCompletionItem` verbatim from scadabridge (`ScriptAnalysisService.cs:885,1271`) — they are pure Roslyn, no domain coupling.

**Step 3: Run tests** → PASS.

**Step 4: Commit**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs
git commit -m "feat(adminui): scope + dot-member script completions"
```

---

### Task 5: IScriptTagCatalog (tag + virtual-tag path provider)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3, Task 4 (disjoint files)

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs` (+ `ScriptTagCatalog`)
- Modify: AdminUI service registration (`AddScoped<IScriptTagCatalog, ScriptTagCatalog>()`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs`

**Step 0 (verify the canonical path format — do this FIRST):** Read how the runtime resolves a `ctx.GetTag("…")` literal to a configured tag (the virtual-tag engine's subscription/read-cache key construction, and how the equipment-namespace browse path is built). Confirm the exact separator and segment order a script author types — i.e. whether it is `Area/Line/Equipment/Name`, `Equipment/Name`, or `FolderPath/Name`. **Match the catalog's projected strings to that.** If the format genuinely can't be pinned down, project both the leaf `Name` and a best-effort full path and note the ambiguity in a code comment + the task report (do NOT silently guess).

**Step 1: Failing test** (in-memory EF — see `Microsoft.EntityFrameworkCore.InMemory`, already referenced by AdminUI.Tests):
```csharp
public sealed class ScriptTagCatalogTests
{
    private static OtOpcUaConfigDbContext NewDb()
    {
        var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
        return new OtOpcUaConfigDbContext(opts);
    }

    [Fact] public async Task Returns_configured_tag_and_virtual_tag_paths()
    {
        await using var db = NewDb();
        // seed one Equipment + one Tag + one VirtualTag (use the confirmed path convention from Step 0)
        // ...
        await db.SaveChangesAsync();
        var catalog = new ScriptTagCatalog(/* IDbContextFactory or db */);
        var paths = await catalog.GetPathsAsync(null, default);
        paths.ShouldContain(/* the expected tag path */);
        paths.ShouldContain(/* the expected virtual-tag path */);
    }

    [Fact] public async Task Filter_prefix_narrows_results() { /* assert prefix filtering */ }
}
```

**Step 2: Implement:**
```csharp
public interface IScriptTagCatalog
{
    Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct);
}

public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> dbf) : IScriptTagCatalog
{
    public async Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
    {
        await using var db = await dbf.CreateDbContextAsync(ct);
        // Build paths per the Step 0 convention. Pull the minimal columns, project to the
        // path string, distinct, filtered by `filter` (StartsWith/Contains, case-insensitive),
        // capped (e.g. Take(200)) so completion stays responsive.
        // ...
        return result;
    }
}
```
(Use `IDbContextFactory<OtOpcUaConfigDbContext>` to match `UnsTreeService`'s pattern.)

**Step 3: Run tests** → PASS. **Step 4: Register in DI. Step 5: Commit.**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs \
        <service-registration-file> \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs
git commit -m "feat(adminui): IScriptTagCatalog for tag-path completion"
```

---

### Task 6: Tag-path string-literal completion

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (edits ScriptAnalysisService.cs; needs Tasks 4 + 5)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` (inject `IScriptTagCatalog`, fill the Task-6 placeholder in `CompleteAsync`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/TagPathCompletionTests.cs`

**Step 1: Failing test** with a fake catalog:
```csharp
private sealed class FakeCatalog : IScriptTagCatalog
{
    public Task<IReadOnlyList<string>> GetPathsAsync(string? f, CancellationToken ct)
        => Task.FromResult<IReadOnlyList<string>>(new[] { "Line1/Speed", "Line1/Temp" });
}

[Fact] public async Task String_literal_in_GetTag_offers_tag_paths()
{
    var svc = new ScriptAnalysisService(new FakeCatalog());
    var items = (await svc.CompleteAsync(new CompletionsRequest("""ctx.GetTag("")""", 1, 12))).Items; // caret inside ""
    items.Select(i => i.Label).ShouldContain("Line1/Speed");
}
```
(Adjust the column to land inside the empty string literal — count: `ctx.GetTag("` is 12 chars, so column 13 is between the quotes; verify with the test.)

**Step 2: Implement** the placeholder: when `token.IsKind(SyntaxKind.StringLiteralToken)` and its enclosing `InvocationExpressionSyntax` is a `MemberAccessExpressionSyntax` named `GetTag`/`SetVirtualTag` on `ctx`, fetch `await _catalog.GetPathsAsync(token.ValueText, ct)` and map to `CompletionItem(label: path, insertText: path, detail: "tag path", kind: "Field")`. Add the ctor `public ScriptAnalysisService(IScriptTagCatalog catalog)` and store it; update DI/tests that `new ScriptAnalysisService()` to pass a catalog (the no-arg tests in Tasks 3/4 can switch to a `FakeCatalog` or you add a parameterless ctor that defers — prefer requiring the catalog and updating those test fixtures).

**Step 3: Run the full AdminUI.Tests** → green. **Step 4: Commit.**

---

### Task 7: Hover + signature help

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (edits ScriptAnalysisService.cs)

**Files:**
- Modify: `ScriptAnalysisService.cs` (`Hover`, `SignatureHelp`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/HoverSignatureTests.cs`

**Step 1: Failing tests** — hovering `GetTag` returns markdown containing `DataValueSnapshot`/`GetTag`; signature help inside `ctx.GetTag(` returns a label mentioning `path`.

**Step 2: Implement** using the semantic model from `Analyze(...)`:
- **Hover:** `model.GetSymbolInfo(node)` / `GetTypeInfo` at the mapped offset; format `symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)` + XML doc summary if present into markdown. (Simpler than scadabridge's domain hover — no `Parameters[...]` branch.)
- **SignatureHelp:** find the enclosing `InvocationExpressionSyntax`; resolve the called method symbol via `model.GetSymbolInfo(invocation.Expression)` (handle `OverloadResolutionFailure` candidates); build `SignatureHelpResponse` from the method's parameters; `ActiveParameter` = commas-before-cursor in the argument list. Reuse scadabridge's comma-counting/active-index logic (`ScriptAnalysisService.cs:840`).

**Step 3: Tests PASS. Step 4: Commit.**

---

### Task 8: Format (+ InlayHints stub)

**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (edits ScriptAnalysisService.cs)

**Files:**
- Modify: `ScriptAnalysisService.cs` (`Format`; leave `InlayHints` empty as scadabridge does)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/FormatTests.cs`

**Step 1: Failing test** — `Format(new FormatRequest("var x=1;return x;"))` returns code containing newlines / canonical spacing (`!= input`, parses clean).

**Step 2: Implement** — copy scadabridge's `Format` verbatim (`CSharpSyntaxTree.ParseText(code, new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script))` → `root.NormalizeWhitespace("    ", "\n").ToFullString()`, try/catch returns input). `InlayHints` stays `new(Array.Empty<InlayHint>())`.

**Step 3: Test PASS. Step 4: Commit.**

---

### Task 9: Wire the 6 Monaco language providers in monaco-init.js

**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 11 (disjoint files); needs Tasks 2–8 endpoints live

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js` (`registerCSharpProviders`, `fetchDiagnostics`)

**Step 1:** Port scadabridge's six provider registrations + `fetchDiagnostics`, but **strip the SCADA context** (`lookupContext`, `GetContext`, all `declaredParameters/siblingScripts/...` body fields). Request bodies become exactly the v1 DTO shapes:
- completions → `{ codeText: model.getValue(), line, column }`
- hover → `{ codeText, line, column }`
- signature-help → `{ codeText, line, column }`
- format → `{ code: model.getValue() }`
- inlay-hints → `{ code: model.getValue() }`
- diagnostics → `{ code: model.getValue() }`

Keep `KIND_MAP`, the snippet-range handling, the response→Monaco mapping, and `monaco.editor.setModelMarkers(model, "otopcua", markers)`. Each provider keeps the `try/catch → return empty` guard so a transient endpoint failure degrades gracefully.

**Step 2: Build (no test — JS).** **Step 3: Commit.**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js
git commit -m "feat(adminui): wire Monaco language providers to /api/script-analysis"
```

---

### Task 10: Swap ScriptEdit page to MonacoEditor

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 11 (disjoint files); needs Task 1

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor`
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-loader.js`

**Step 1:** Replace the `<InputTextArea id="script-source" @bind-Value="_form.SourceCode" .../>` with:
```razor
<MonacoEditor @bind-Value="_form.SourceCode" Height="420px" />
```
(`@bind-Value` wires `Value` + `ValueChanged`.) Remove the `OnAfterRenderAsync` block that injected `monaco-loader.js` via `JS.InvokeVoidAsync("eval", ...)` + `Task.Delay(50)` + `otOpcUaScriptEditor.attach`, and the now-unused `IJSRuntime`/`_loaded` plumbing if nothing else uses them. Keep the SHA-256-on-save path untouched.

**Step 2:** `git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-loader.js`.

**Step 3: Build.** **Step 4: Commit.**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor
git rm  src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-loader.js
git commit -m "feat(adminui): ScriptEdit uses MonacoEditor; drop CDN loader"
```

---

### Task 11: VirtualTagModal inline script-source panel

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10 (disjoint files); needs Task 1

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` + `UnsTreeService.cs` (3 new methods)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ScriptSourceServiceTests.cs`

**Step 1: Failing service tests** (in-memory EF): `GetScriptSourceAsync(scriptId)` returns `(SourceCode, RowVersion, Name)`; `CountVirtualTagsUsingScriptAsync(scriptId)` returns the number of `VirtualTags` with that `ScriptId`; `UpdateScriptSourceAsync(scriptId, source, rowVersion)` recomputes the SHA-256 `SourceHash`, succeeds on a matching RowVersion, and returns a concurrency error on a stale one.

**Step 2: Implement** the 3 service methods (mirror the existing `LoadScriptsAsync`/Update patterns in `UnsTreeService`; reuse the page's existing `HashSource`/SHA-256 helper — extract it to the service so both call sites share it). Result type: reuse the existing `(bool Ok, string? Error)`-style result used by `UpdateTagAsync`.

**Step 3:** In `VirtualTagModal.razor`, below the Script dropdown, add a collapsible **"Script source"** panel shown when a script is selected:
- On script selection (or modal open with an existing `ScriptId`), call `GetScriptSourceAsync` + `CountVirtualTagsUsingScriptAsync` and show a notice: `Editing shared script "{Name}" — used by {N} virtual tag(s). Changes affect all of them.`
- `<MonacoEditor @bind-Value="_scriptSource" Height="300px" />`
- A dedicated **Save script** button calling `UpdateScriptSourceAsync(scriptId, _scriptSource, _scriptRowVersion)`; on success refresh `_scriptRowVersion` + show a saved indicator; on concurrency error show the standard reload message. This Save is **separate** from the virtual-tag Create/Save (`SaveAsync`) — it does not touch `_form`.

**Step 4: Tests green; build.** **Step 5: Commit.**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ScriptSourceServiceTests.cs
git commit -m "feat(adminui): inline script-source editor in the virtual-tag modal"
```

---

### Task 12: Live verification in docker-dev

**Classification:** verification (no code)
**Estimated implement time:** ~5 min (interactive; user signs in)
**Parallelizable with:** none (needs all prior tasks)

**Files:** none.

**Steps:**
1. Rebuild central in docker-dev:
   ```bash
   docker compose -f docker-dev/docker-compose.yml build central-1
   docker compose -f docker-dev/docker-compose.yml up -d --no-deps --force-recreate central-1 central-2
   ```
2. **Ask the user to sign in** to the AdminUI (the agent must NOT enter credentials).
3. Drive the browser (mcp__claude-in-chrome) and verify on `/scripts/{id}`:
   - Editor renders (Monaco, line numbers, theme); typing two-way binds.
   - Type `ctx.` → completion lists `GetTag`/`SetVirtualTag`/`Now`/`Logger`.
   - Type a bad line (`ctx.Nope();`) → red squiggle on the right line.
   - `ctx.GetTag("` → tag-path suggestions appear.
   - Hover `GetTag` → type info; Format button reflows; theme toggle works.
4. Verify the VirtualTagModal: open it, pick a script → the "Script source" panel loads with the "used by N" notice; edit + Save script; reopen to confirm persistence.
5. Reset the rig to baseline (remove any test artifacts created).

Report the results (with screenshots). Done = build clean + `dotnet test ZB.MOM.WW.OtOpcUa.slnx` green + this `/run` pass.

---

### Task 13: Docs + memory + finish

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none

**Files:**
- Create: `docs/ScriptEditor.md` (the Monaco/Roslyn editor: architecture, endpoints, how to extend completions, vendoring/air-gap, the wrapper-fidelity notes)
- Modify: `docs/README.md` (index row), `CLAUDE.md` (a short "Scripting / script editor" note pointing at `docs/ScriptEditor.md`)
- Memory: update `MEMORY.md` + a new `project_monaco_script_editor.md` (architecture + the `#line`/offset-mapping + preamble-suppression gotchas + the tag-path-format finding from Task 5 Step 0)

**Steps:** write the docs, update the index + CLAUDE.md, save memory, build clean, commit. Then run **superpowers-extended-cc:finishing-a-development-branch** to merge `feat/monaco-script-editor` → `master`.

---

## Execution order & parallelism summary

```
Task 0 (refs+branch)
  ├─ Task 1 (frontend: vendor + MonacoEditor + minimal init)   ∥  Task 2 (backend: contracts+seam+endpoints+DI)
Task 2 ─┬─ Task 3 (diagnostics)            ∥  Task 5 (IScriptTagCatalog)
        ├─ Task 4 (completions)            ∥  Task 5
        ├─ Task 6 (tag-path completion)    ← needs 4 + 5
        ├─ Task 7 (hover + signature)
        └─ Task 8 (format + inlay)
Task 9 (wire JS providers)   ← needs 2–8 endpoints   ∥  Task 10 (ScriptEdit swap) ∥ Task 11 (modal panel)
Task 12 (live /run)          ← needs all
Task 13 (docs + finish)
```
Note: Tasks 3,4,6,7,8 all edit `ScriptAnalysisService.cs` — serialize them (not mutually parallelizable) even though their test files are disjoint. Task 5 (catalog) and Task 1 (frontend) are the genuine cross-cutting parallel opportunities.
