Generated from PRD v3 (
PRD.md). Polished via 8 review rounds (23x Opus + 1x GPT-5.4-Pro). Covers: executability, dependencies, PRD fidelity, sizing, testing, file paths, security/threading, architecture, MCP compliance, injection pipeline, CI feasibility, serialization, disposal, packaging, red team. Each bead is one atomic work unit completable in a single agent session (~100K token context). Beads ordered by dependency. Execute in order; parallelizable beads noted. Cross-reference: SeePRD.md§Key Tool Schemas for all DTO field definitions.Global build rules (apply to ALL new .csproj files):
- Every new .csproj MUST include
<LangVersion>latest</LangVersion>—Directory.build.propssetsLangVersion=10globally, which breaks C# 12 primary constructors used in the MCP tool pattern. Override per-project until BEAD-027 updates the global setting.- Every new NuGet dependency MUST have a
<PackageVersion>entry inDirectory.packages.props— the repo uses Central Package Management (ManagePackageVersionsCentrally=true). A<PackageReference>without a matching<PackageVersion>causes NU1104 at restore.- New test projects target
net8.0-windows— to referenceSnoopWPF.Agent.Tools(net8.0-windows only).Global security rules (apply to ALL beads):
- NEVER call
TypeDescriptor.GetConverter()— all type conversion uses hardcoded converter table (BEAD-017). DtoProjection must usePropertyInformation.StringValueorvalue.ToString()for display, never TypeDescriptor.- All WPF object access must happen on the Dispatcher — DtoProjection, TriggerInspector, BehaviorInspector, ScreenshotCapture are helpers that are always called from within a
Dispatcher.Invokeblock in SnoopInspector. They must NOT provide their own async entry points or access WPF objects independently.- Property values are never logged — exception payloads must be sanitized before logging (strip paths, tokens, property values).
- Concurrency cap: SnoopInspector must use a
SemaphoreSlim(maxConcurrency: 3)around Dispatcher dispatch to prevent starvation of the UI thread. At most 3 MCP requests may be in-flight on the Dispatcher simultaneously.- For non-DependencyObject nodes: use
type.GetProperties(BindingFlags.Public | BindingFlags.Instance)(reflection) NOTTypeDescriptor.GetProperties()— the latter activates registeredTypeDescriptionProviders which may invoke custom converters.- Target process ownership check (injection mode): Before injection, verify target process owner SID matches current user. Emit warning and require
--forceflag if mismatched. (BEAD-024)
These commands must pass after every bead:
dotnet build Snoop.sln # Full solution builds
dotnet test Snoop.Core.Tests # Existing tests passAfter Milestone 1 (BEAD-003-infra onward):
dotnet test SnoopWPF.Agent.Tests # Unit tests pass
dotnet test Snoop.Core.Tests # Existing tests still pass (BEAD-003d modifies Snoop.Core)After Milestone 2 (BEAD-014 onward):
dotnet test SnoopWPF.Agent.IntegrationTests # Integration tests passBEAD-000a, BEAD-000b, BEAD-000d are fully parallel. BEAD-000c depends on BEAD-000b (reuses its app).
PRD ref: US-000a Goal: Confirm Claude Code connects to both MCP transports before committing to architecture. Estimated: 3 .cs files + NOTES.md (~150 LOC)
Steps:
- Create
spike/TransportSpike/folder (outside solution — throwaway) - Create minimal .NET 8 console app. Important: This project uses Central Package Management — add
<PackageVersion Include="ModelContextProtocol" Version="X.Y.Z" />and<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="X.Y.Z" />toDirectory.packages.props(check nuget.org for latest stable 1.x). The spike.csprojuses<PackageReference Include="ModelContextProtocol" />without version. - Implement one dummy MCP tool (
echo) that returns its input - Test stdio transport: configure in Claude Code
claude_desktop_config.json, verify tool call works - Test HTTP/SSE transport: start Kestrel on
127.0.0.1, configure bearer token, verify Claude Code connects - Test
ImageContentblock: return a small PNG base64 from a tool, verify Claude renders it - Record pinned
ModelContextProtocolversion inspike/TransportSpike/NOTES.md - Record decision: which transport for NuGet mode, which for injection mode
Acceptance Criteria:
- stdio transport: Claude Code calls dummy tool and gets response
- HTTP/SSE transport: Claude Code connects with bearer token and calls tool
- ImageContent: Claude renders inline image from tool response
-
ModelContextProtocol+ModelContextProtocol.AspNetCoreversions pinned inDirectory.packages.props - Decision recorded in
spike/TransportSpike/NOTES.md
PRD ref: US-000b
Goal: Prove that TreeService, PropertyInformation, and DTO projection work headlessly in-process.
Estimated: 2 .cs files + NOTES.md (~80 LOC)
Steps:
- Create
spike/EngineSpike/— .NET 8 WPF app with a Button - On
Loaded, callvar treeService = TreeService.From(TreeType.Visual);thenvar root = treeService.Construct(Application.Current, parent: null);. Walkroot.Childrento enumerate visual tree nodes. - Read types and names from each
TreeItem - Call
var props = PropertyInformation.GetProperties(someElement);to get all properties. Readprops[0].StringValueon the Dispatcher. CallTeardown()on eachPropertyInformationwhen done — all in a singleDispatcher.Invoke. - Project to a simple DTO record, verify fields populated
- Verify no deadlocks, no crashes
Acceptance Criteria:
-
TreeService.From(TreeType.Visual).Construct(Application.Current, null)returns children -
PropertyInformation.GetProperties(element)reads property values without deadlock - DTO projection populates type, name, value fields
- No crashes or hangs
Deliverable: spike/EngineSpike/NOTES.md
PRD ref: US-000c
Depends on: BEAD-000b (reuses its WPF app)
Goal: Determine if WPF tree inspection works on windows-latest (no display).
Estimated: 1 workflow YAML + NOTES.md (~40 LOC)
Steps:
- Create
.github/workflows/spike-headless.yml - Launch BEAD-000b spike app on
windows-latest - Run tree traversal and property inspection — document results
- Test screenshot capture — document results (may fail headlessly)
- Test with both net6.0 and net8.0 TargetFramework
Acceptance Criteria:
- WPF app launches on
windows-latest - Tree traversal works without display
- Screenshot results documented (pass or documented limitation)
- Both net6.0 and net8.0 tested
Deliverable: spike/NOTES-headless-ci.md
PRD ref: US-000d Goal: Verify framed JSON over named pipes works across net462/net6.0/net8.0. Estimated: 4 .cs files + NOTES.md (~200 LOC)
Steps:
- Create
spike/PipeSpike/— two console apps (host + client) - Host creates
NamedPipeServerStreamwithPipeOptions.CurrentUserOnly(net6+) - Client connects, roundtrip
{4-byte LE length}{UTF-8 JSON} - Also test net462 path:
PipeSecurity/PipeAccessRule(noPipeOptions.CurrentUserOnly) - Test cancel frame (
{"id": 1, "cancel": true}) - Enforce max 10MB frame size — test rejection
- Measure latency for typical payloads (100 nodes, 80 properties)
Acceptance Criteria:
- net8.0 host ↔ net6.0 client: handshake + request/response works
- net462 pipe ACLs work (PipeSecurity path)
- Cancel frame delivered and processed
- 10MB frame size enforced — oversized frame rejected gracefully
Deliverable: spike/PipeSpike/NOTES.md
PRD ref: US-001 Blocks: BEAD-003-infra, BEAD-003d, BEAD-004a, BEAD-022, BEAD-023 Goal: Create the shared contracts library — DTOs, interfaces, error codes, protocol types. Estimated: 24 files, ~500 LOC (pure data types, no WPF internals)
Files to create:
SnoopWPF.Agent.Contracts/SnoopWPF.Agent.Contracts.csprojSnoopWPF.Agent.Contracts/DTOs/*.cs(14 DTO files)SnoopWPF.Agent.Contracts/ISnoopInspector.csSnoopWPF.Agent.Contracts/SnoopErrorCode.csSnoopWPF.Agent.Contracts/SnoopException.csSnoopWPF.Agent.Contracts/CursorPage.csSnoopWPF.Agent.Contracts/Protocol/*.cs(5 protocol files)
Steps:
- Create
SnoopWPF.Agent.Contracts.csproj—netstandard2.0, ZERO dependencies - Create all DTO classes as mutable POCOs with parameterless constructors and settable properties (NOT records). Serializer attributes ARE required — add
[DataContract]on each class and[DataMember(Name = "camelCaseName")]on each property. These attributes are inSystem.Runtime.Serialization.Primitives(part of netstandard2.0 — zero external NuGet deps). TheNameparameter on[DataMember]forces DCJS (net462) to emit camelCase matching STJ'sCamelCasepolicy. Do NOT use[JsonPropertyName](STJ-only, not in netstandard2.0). Dictionary replacement: ReplaceDictionary<string,string>fields withList<NameValuePairDto>(whereNameValuePairDtohasName+Valuestrings) — DCJS serializesDictionaryas a JSON array of{Key,Value}pairs, incompatible with STJ's JSON object format. - Run
dotnet sln Snoop.sln add SnoopWPF.Agent.Contracts/SnoopWPF.Agent.Contracts.csproj— flat structure, no solution folder
DTO field specifications (all fields are settable properties):
NodeDto: NodeId (string), TypeName, Name, DisplayName, ChildCount (int), HasBindingError (bool), Depth (int), ChildrenTruncated (bool), Properties (List, nullable), Children (List, nullable)
NameValuePairDto: Name (string), Value (string) — replaces Dictionary for DCJS/STJ compatibility
PropertyDto: Name, TypeName, Value, ValueSource, IsLocallySet (bool), IsDataBound (bool), HasBindingError (bool), BindingError (string, nullable), IsReadOnly (bool), HasTypeConverter (bool), IsRedacted (bool)
BindingInfoDto: HasBinding (bool), BindingType, Path, ElementName, RelativeSource, Mode, UpdateSourceTrigger, ConverterTypeName, SourceType, Status, Error (nullable), DataContextIsNull (bool), DataContextType, ResolvedValue (string, nullable), ChildBindings (List, nullable)
DiagnosticItemDto: Name, Description, Area, Level, NodeId, NodePath (List)
ResourceDto: Key, ValueTypeName, ValueSummary, Origin, DictionarySource
TriggerDto: TriggerType, IsActive (bool), Source (string: "Style", "ControlTemplate", "DataTemplate", or "Element" — matches TriggerSource enum values in Snoop.Core), Conditions (List), Setters (List)
— Also create TriggerConditionDto: Property, Value
— Also create TriggerSetterDto: Property, Value
BehaviorDto: TypeName, AssemblyName, Properties (List)
WindowDto: NodeId (string), Title, TypeName, Width (double), Height (double), DispatcherId (int)
SetPropertyResultDto: Success (bool), PreviousValue, NewValue, Error (string, nullable)
ScreenshotMetadataDto: Width (int), Height (int), NodeId (string)
SessionInfoDto: ProcessName, Pid (int), DotnetVersion, MutationEnabled (bool), Dispatchers (List), Capabilities (List)
DispatcherInfoDto: Id (int), ThreadId (int), WindowNodeIds (List)
FindElementResultDto: Results (List), TotalScanned (int), Truncated (bool)
— Also create FindElementHitDto: Node (NodeDto), Path (List)
InspectElementDto: NodeId (string), TypeName, Name, DisplayName, Path (List), ParentNodeId, ChildCount (int), Depth (int), DispatcherId (int), IsVisible (bool), ActualWidth (double), ActualHeight (double), DataContextType, HasBindingErrors (bool), BindingErrorCount (int), TriggerCount (int?, nullable), BehaviorCount (int?, nullable)
- Create
ISnoopInspectorinterface with these exact 15 method signatures:
Task<SessionInfoDto> GetSessionInfoAsync(CancellationToken ct);
Task<List<WindowDto>> GetWindowsAsync(bool includeHidden, CancellationToken ct);
Task<VisualTreeResultDto> GetVisualTreeAsync(string? rootNodeId, int maxDepth, string treeType, List<string>? includeProperties, CancellationToken ct);
Task<CursorPage<NodeDto>> GetChildrenAsync(string? nodeId, string treeType, string? cursor, int take, CancellationToken ct);
Task<List<AncestorDto>> GetAncestorsAsync(string nodeId, int? maxLevels, CancellationToken ct);
Task<FindElementResultDto> FindElementsAsync(string? typeName, string? name, string? rootNodeId, List<PropertyConditionDto>? conditions, string treeType, int maxResults, CancellationToken ct);
Task<InspectElementDto> InspectElementAsync(string nodeId, CancellationToken ct);
Task<CursorPage<PropertyDto>> GetPropertiesAsync(string nodeId, string? filter, string? category, bool includeDefaults, string? cursor, int take, CancellationToken ct);
Task<SetPropertyResultDto> SetPropertyAsync(string nodeId, string propertyName, string value, CancellationToken ct);
Task<BindingInfoDto> GetBindingInfoAsync(string nodeId, string propertyName, CancellationToken ct);
Task<CursorPage<DiagnosticItemDto>> RunDiagnosticsAsync(string? nodeId, List<string>? providers, string? minLevel, string? cursor, int take, CancellationToken ct);
Task<CursorPage<ResourceDto>> GetResourcesAsync(string? nodeId, string? resourceKey, string? cursor, int take, CancellationToken ct);
Task<ScreenshotResultDto> CaptureScreenshotAsync(string? nodeId, CancellationToken ct);
Task<List<TriggerDto>> GetTriggersAsync(string nodeId, CancellationToken ct);
Task<List<BehaviorDto>> GetBehaviorsAsync(string nodeId, CancellationToken ct);Also create these additional DTOs (not listed above):
VisualTreeResultDto: Root (NodeDto), Truncated (bool), ReturnedNodeCount (int)AncestorDto: NodeId (string), TypeName, Name, DataContextTypePropertyConditionDto: Property (string), Operator ("Equals"|"Contains"), Value (string)ScreenshotResultDto: Metadata (ScreenshotMetadataDto), PngBytes (byte[])
- Create
SnoopErrorCodeenum:NodeNotFound,DispatcherBusy,OperationTimedOut,PropertyReadOnly,TypeConversionFailed,UnsupportedPropertyType,MutationDisabled,PropertyRedacted,SessionNotFound,ProtocolMismatch,ElementNotRenderable(11 codes) - Create
SnoopExceptionwith Code, Message, Suggestion properties - Create
SnoopSuggestionsstatic class with canonical suggestion strings from PRD (one per error code):NodeNotFound→ "Re-navigate from wpf_get_windows — element was likely garbage collected"DispatcherBusy→ "Retry the call; if repeated, the WPF app may be performing a long UI operation"OperationTimedOut→ "Reduce scope (smaller subtree, fewer properties) or retry when app is idle"PropertyReadOnly→ "This property cannot be set; use wpf_get_properties to find writable properties"TypeConversionFailed→ "Check value format; Color=#RRGGBB or named; Thickness=L,T,R,B; see tool description"UnsupportedPropertyType→ "Only primitive and common WPF value types are settable; see tool description for list"MutationDisabled→ "Mutations disabled; set EnableMutation=true in SnoopAgentOptions to allow changes"PropertyRedacted→ "This property is redacted for security; its value cannot be read or set"SessionNotFound→ "No active session; the target process may have exited"ProtocolMismatch→ "Agent and host protocol versions differ; update to matching versions"ElementNotRenderable→ "Element has zero size or is not visible; try wpf_get_windows for a full window screenshot instead"
- Create
CursorPage<T>: Items (List), NextCursor (string, nullable), TotalCount (int), HasMore (bool), Stale (bool), Truncated (bool) - Create protocol types:
PipeRequest: Id (int), Method (string), ParamsJson (string — raw JSON of method parameters)PipeResponse: Id (int), ResultJson (string, nullable — raw JSON of result), Error (PipeError, nullable)PipeError: Code (string), Message (string), Suggestion (string)PipeCancel: Id (int), Cancel (bool — always true on wire)HandshakeChallenge: SessionToken (string), ProtocolVersion (int) — sent by hostHandshakeResponse: ProtocolVersion (int), AgentVersion (string), TargetRuntime (string), SessionToken (string), Dispatchers (List), Capabilities (List) — sent by agent Note:ParamsJson/ResultJsonare raw JSON strings, not escaped. The transport layer parses method-specific types.
- Create
ProtocolConstants: MaxFrameSize = 10_485_760, ProtocolVersion = 1
Acceptance Criteria:
-
netstandard2.0, zero NuGet deps - All DTOs are mutable POCOs with parameterless constructors,
[DataContract]class attr,[DataMember(Name="camelCase")]on all properties - No
Dictionary<K,V>in any DTO — useList<NameValuePairDto>instead - All DTO fields match specs above (especially
BindingInfoDto.ChildBindingslist) -
ISnoopInspectorhas all 15 methods, allTask-based withCancellationToken - All 11 error codes defined
-
CursorPage<T>has all 6 fields - Protocol types defined
-
dotnet build Snoop.slnpasses
PRD ref: US-002
Blocks: BEAD-024, BEAD-025
Depends on: nothing (parallel with BEAD-001)
Goal: Extract injection logic from Snoop/ GUI project into a reusable library.
Estimated: 1 new .csproj + moves, ~80 LOC new code
Source files to move:
Snoop/InjectorLauncherManager.cs→Snoop.Injector/InjectorLauncherManager.csSnoop/ProcessInfo.cs→Snoop.Injector/ProcessInfo.csSnoop/WindowInfo.cs→Snoop.Injector/WindowInfo.cs
Files to modify:
Snoop/Snoop.csproj— add<ProjectReference>toSnoop.Injector, remove moved filesSnoop.sln—dotnet sln add Snoop.Injector/Snoop.Injector.csproj
Steps:
- Create
Snoop.Injector/Snoop.Injector.csprojtargetingnet462;net6.0-windows;net8.0-windows - Move files (see above). IMPORTANT:
ProcessInfo.Snoop()currently usestypeof(SnoopManager).GetMethod(nameof(SnoopManager.StartSnoop))— NOT string constants. Change it to use theInjectorLauncherManager.Launch(processInfo, hwnd, assemblyName, className, methodName, settingsFile)overload with literal strings"Snoop.Core","Snoop.Infrastructure.SnoopManager","StartSnoop". This removes theSnoop.Coretype dependency fromSnoop.Injector. - Include InjectorLauncher binaries (x86, x64, ARM64). Note: The existing codebase handles binary distribution via the Nuke build in
.build/Build.cs, NOT via<Content>items inSnoop.csproj. Check.build/Build.csfor the existing copy/distribution pattern and replicate it.InjectorLauncherManagerresolves them byPath.Combine(directory, $"Snoop.InjectorLauncher.{architecture}.exe")wheredirectoryis the exe's directory. For the newSnoop.Injectorproject, add<Content Include="..." CopyToOutputDirectory="PreserveNewest" />items pointing to the build output, OR extend the Nuke build to copy them. - Update
Snoop/Snoop.csprojto referenceSnoop.Injector - Verify
dotnet build Snoop.slnanddotnet test Snoop.Core.Testspass
Acceptance Criteria:
-
Snoop.Injector.csprojtargetsnet462;net6.0-windows;net8.0-windows - All injection code moved from
Snoop/ -
ProcessInfouses string constants (notypeof(SnoopManager)) -
Snoop/referencesSnoop.Injector - InjectorLauncher binaries included as content files
-
dotnet build Snoop.slnpasses -
dotnet test Snoop.Core.Testspasses
PRD ref: US-003 (part 1 of 2) Depends on: BEAD-001 Blocks: BEAD-003-inspector Goal: Create the engine project and implement the infrastructure classes that SnoopInspector depends on. No Dispatcher or WPF knowledge needed. Estimated: 8 files + .csproj, ~400 LOC
Files to create:
SnoopWPF.Agent.Engine/SnoopWPF.Agent.Engine.csproj(targetsnet462;net6.0-windows;net8.0-windows, UseWpf=true)SnoopWPF.Agent.Engine/NodeRegistry.csSnoopWPF.Agent.Engine/CursorManager.csSnoopWPF.Agent.Engine/RedactionFilter.csSnoopWPF.Agent.Engine/DtoProjection.csSnoopWPF.Agent.Tests/SnoopWPF.Agent.Tests.csprojSnoopWPF.Agent.Tests/NodeRegistryTests.csSnoopWPF.Agent.Tests/CursorManagerTests.csSnoopWPF.Agent.Tests/RedactionFilterTests.cs
Steps:
- Create
SnoopWPF.Agent.Engine.csproj. References:Snoop.Core,SnoopWPF.Agent.Contracts. - NodeRegistry:
ConditionalWeakTable<object, NodeRegistration>(forward: object → nodeId)ConcurrentDictionary<string, WeakReference<object>>(reverse: nodeId string → object). Must be ConcurrentDictionary — the 60s timer sweep iterates it concurrently with Dispatcher-thread registrations. Keyed by full node ID string"0:42"(not int — future multi-dispatcher compatible).- Monotonic counter. Single-dispatcher: always
0:{counter}. - Lazy pruning on reverse lookup access: when
TryGetTarget()fails, remove entry. - 60s periodic sweep:
System.Threading.Timercallback iterates reverse dict, removes dead entries. No WPF API access in sweep —TryGetTarget()only reads the reference, it does not access the WPF object. Cancel timer inDispose(). - Testability: Accept
TimeSpan sweepIntervalin constructor (default 60s) so tests can control sweep timing. Exposeinternal void ForceSweep()for unit tests.
- CursorManager:
- Snapshot: stores
string[]of child nodeIds, keyed by opaque cursor token - 30s TTL per snapshot. Returns
stale: truewhen snapshot expired (regenerate fresh page). - Thread-safe (ConcurrentDictionary or lock)
- Testability: Accept
Func<DateTimeOffset> clockin constructor (defaultDateTimeOffset.UtcNow) so tests can control time without sleeping 30s.
- Snapshot: stores
- RedactionFilter:
- Contains-match (case-insensitive) on:
password,passwd,pwd,secret,apikey,connectionstring,connstr,credential,privatekey,sharedkey,cookie,sessionkey,authorization,authtoken,authkey,accesstoken,bearertoken,refreshtoken,sessiontoken,sastoken,jwttoken - Also: all
SecureString-typed properties,PasswordBox.Password bool IsRedacted(string propertyName, Type propertyType)— pure function, no WPF dependency
- Contains-match (case-insensitive) on:
- DtoProjection: Static helper methods to convert Snoop types (
TreeItem,PropertyInformation) to DTOs (NodeDto,PropertyDto). Pure mapping — these methods must ONLY be called from within a Dispatcher.Invoke block in SnoopInspector (next bead). They are NOT thread-safe and access live WPF objects. UsepropInfo.StringValueorvalue.ToString()for display formatting — NEVER callTypeDescriptor.GetConverter()(see global security rules at top). - Create
SnoopWPF.Agent.Testsproject with unit tests for all above. - Add both projects to
Snoop.sln.
Acceptance Criteria:
- Engine project targets
net462;net6.0-windows;net8.0-windows - NodeRegistry:
IDisposable, forward + reverse lookup works; GC'd objects pruned; 60s timer sweep works; reverse dict size cap (triggerForceSweep()at 10,000 entries) - CursorManager:
IDisposable, snapshot cached, stale after 30s, thread-safe, has background sweep (same pattern as NodeRegistry) to evict expired entries — prevents unbounded accumulation in long sessions - RedactionFilter: all 21 keywords match by contains (case-insensitive); SecureString/PasswordBox detected
- DtoProjection: converts TreeItem → NodeDto, PropertyInformation → PropertyDto with correct fields
-
dotnet test SnoopWPF.Agent.Testspasses -
dotnet build Snoop.slnpasses
PRD ref: US-003 (part 2 of 2) Depends on: BEAD-003-infra Blocks: BEAD-003b, BEAD-003c, BEAD-004a Goal: Implement the SnoopInspector class with Dispatcher marshaling, tree traversal, and property inspection. Estimated: 2 files, ~400 LOC (requires deep understanding of PropertyInformation and TreeService)
Files to create:
SnoopWPF.Agent.Engine/SnoopInspector.csSnoopWPF.Agent.Engine/SnoopInspectorOptions.cs
Key Snoop.Core classes to understand first:
Snoop.Core/Infrastructure/PropertyInformation.cs(~1066 lines) — IS a DependencyObject with live WPF bindingsSnoop.Core/Data/Tree/TreeService.cs— abstract; useTreeService.From(TreeType)for concrete instances (VisualTreeService, LogicalTreeService, AutomationPeerTreeService)Snoop.Core/Infrastructure/Diagnostics/DiagnosticContext.cs
Steps:
- Create
SnoopInspectorOptions:TimeoutMs (default 5000),EnableMutation (default false),EnableRedaction (default true) - Create
SnoopInspectorimplementingISnoopInspector:- Constructor:
SnoopInspector(Dispatcher dispatcher, object? rootTarget = null, SnoopInspectorOptions? options = null).rootTargetisApplication.Currentin NuGet mode or injection root in injection mode. - Threading pattern (CRITICAL — be precise):
- Dispatcher shutdown guard: At entry of every method, check
if (dispatcher.HasShutdownStarted) throw new SnoopException(SessionNotFound, ...). Also addDebug.Assert(!dispatcher.CheckAccess(), "SnoopInspector methods must not be called from the Dispatcher thread")to prevent deadlocks. - Each method queues work via
await Dispatcher.InvokeAsync(() => { /* synchronous work block */ }, DispatcherPriority.Send). Inside the lambda, all WPF access is a single synchronous block. Do NOT use multiple awaitedInvokeAsynccalls for work that must be atomic. - Timeout: wrap with
Task.WhenAny(dispatcherTask, Task.Delay(TimeoutMs))— if timeout wins, throwSnoopException(DispatcherBusy)if work hasn't started, orSnoopException(OperationTimedOut)if work started but didn't finish. - SnoopInspector must NOT be called from the Dispatcher thread — the
await InvokeAsyncpattern requires a non-Dispatcher caller. Integration tests must run on a separate thread.
- Dispatcher shutdown guard: At entry of every method, check
- Constructor:
- Tree traversal:
var treeService = TreeService.From(treeType); var root = treeService.Construct(target, parent: null);Walkroot.Children. Dispose theTreeServiceafter each call — it holdsDiagnosticContextevent subscriptions that leak if not disposed. Note:Construct()internally callsReload()which triggers side effects on the target app (layout passes,ExpandTo()on children). This is expected Snoop behavior. - Property inspection lifecycle (CRITICAL — must be ONE synchronous block inside one
Dispatcher.InvokeAsynclambda):var props = PropertyInformation.GetProperties(target);- Read values from each
PropertyInformation(checkRedactionFilter.IsRedactedFIRST — skip getter for redacted props, return[REDACTED]) - Project to
PropertyDto[]viaDtoProjection(usespropInfo.StringValueorvalue.ToString(), NEVERTypeDescriptor.GetConverter()) - Call
Teardown()on eachPropertyInformation - After
Teardown(): stop orphaned timers.PropertyInformationconstructor starts aDispatcherTimerviaOnValueChanged.Teardown()callsBindingOperations.ClearAllBindings()but does NOT stop the timer. After teardown, check ifchangeTimerfield (private) is non-null via reflection and callStop(). Or: add aStopChangeTimer()method toPropertyInformationin Snoop.Core (small additive change). - Return projected DTOs
- All of the above in ONE synchronous block — no interleaving allowed
- Path alias support: Accept node addresses as
Window\Grid\StackPanel\Button(backslash-separated type names). Resolve by walking tree from root matching each segment by type name. - Properties sorted by name for stable cursor pagination
includePropertieson GetVisualTreeAsync: WhenincludePropertiesis non-null (max 10 entries), for each tree node read the named DependencyProperties and include inNodeDto.Propertiesdict. Apply redaction — redacted props return"[REDACTED]"inline.- GC/detachment guard: After resolving a nodeId to an object inside the Dispatcher block, verify connectivity. Branch by type:
- For
Windowtargets: usewindow.IsInitialized(NOTPresentationSource.FromVisual— that returns null forVisibility.Hiddenwindows, which are valid inspection targets). Reference:SnoopWindowUtils.csandWindowHelper.csin Snoop.Core. - For other
Visualtargets: usePresentationSource.FromVisual(el) != null - For non-
Visuallogical tree nodes (e.g., CLR data objects): skip the check — these don't have visual connectivity but are valid logical children If detached, throwSnoopException(NodeNotFound, SnoopSuggestions.NodeNotFound). Also:DtoProjectionmust checktarget is DependencyObjectbefore attempting DP property inspection. Non-DependencyObjectnodes return CLR properties only viaTypeDescriptor.GetProperties(), not DependencyProperty info.
- For
- Null target guard: If
rootTargetis null orTreeService.Construct()returns null, throwSnoopException(SessionNotFound, "Application not yet initialized"). - Timeout distinction:
DispatcherBusy= Dispatcher won't accept work within 500ms threshold.OperationTimedOut= Dispatcher accepted work but the operation itself exceedsTimeoutMs. Use two-phase timeout: tryDispatcher.InvokeAsyncwith 500ms for acceptance; once accepted, overall deadline isTimeoutMs. - Redaction on binding info: In
GetBindingInfoAsync, if the property being inspected is redacted, return[REDACTED]forBindingInfoDto.PathandBindingInfoDto.ResolvedValue. Other binding metadata (mode, status, etc.) is OK to return. - Implement methods:
GetSessionInfoAsync,GetWindowsAsync,GetVisualTreeAsync,GetChildrenAsync,InspectElementAsync,GetPropertiesAsync,GetBindingInfoAsync - Stub remaining methods (
GetAncestorsAsync,FindElementsAsync,SetPropertyAsync,RunDiagnosticsAsync,GetResourcesAsync,CaptureScreenshotAsync,GetTriggersAsync,GetBehaviorsAsync) withthrow new NotImplementedException()— filled in later beads (8 stubs)
Acceptance Criteria:
- All methods marshal via single
Dispatcher.InvokeAsync(() => { ... }, DispatcherPriority.Send)— synchronous block inside lambda - PropertyInformation: construct → read → project → Teardown all in ONE synchronous block (no interleaving)
-
includeProperties: reads named DPs, includes in NodeDto.Properties, redacted props return[REDACTED] - GC/detachment guard: detached elements throw
NodeNotFound - Null target: throws
SessionNotFound - Timeout: two-phase —
DispatcherBusy(acceptance timeout) vsOperationTimedOut(execution timeout) - Redacted properties: getter NOT invoked, returns
[REDACTED] - Binding info redaction: redacted property →
[REDACTED]for Path and ResolvedValue - Path alias resolution works (
Window\Grid\Button→ nodeId) - Properties sorted by name
-
wpf_get_childrenroot ordering when nodeId omitted: main window first, then by title -
SnoopInspectorimplementsIDisposable— disposes NodeRegistry (cancels timer), CursorManager (cancels timer), sets_disposedflag - 7 methods fully implemented, 8 stubbed
-
dotnet build Snoop.slnpasses
PRD ref: US-003b Depends on: BEAD-003-inspector Blocks: BEAD-018-combined Estimated: 5 new files + 1 modify, ~300 LOC
Files to create:
SnoopWPF.Agent.Engine/DiagnosticsInspector.csSnoopWPF.Agent.Engine/ResourceInspector.csSnoopWPF.Agent.Engine/ScreenshotCapture.csSnoopWPF.Agent.Tests/DiagnosticsInspectorTests.csSnoopWPF.Agent.Tests/ResourceInspectorTests.cs
Files to modify:
SnoopWPF.Agent.Engine/SnoopInspector.cs— replace stubs forRunDiagnosticsAsync,GetResourcesAsync,CaptureScreenshotAsync
Steps:
DiagnosticsInspector: delegates toDiagnosticContextwith the 5 active providers:FreezeFreezables,LocalResourceDefinitions,NonVirtualizedLists,UnresolvedDynamicResource,BindingLeak. (6th providerBindingDiagnosticProvideris commented out — leave disabled.) Subtree-scoped via root nodeId, cursor-paginated.ResourceInspector: walk up tree from element, collect ResourceDictionary entries with origin labels. Precedence: effective (closest scope) first, shadowed included with their origin.ScreenshotCapture.CaptureAsPng(Visual visual): callVisualCaptureUtil.RenderVisualWithHighQuality(visual, dpiX: 96, dpiY: 96)(intparameters) →RenderTargetBitmap→PngBitmapEncoder→MemoryStream. Returnms.ToArray(). No temp files.- Wire into
SnoopInspector. All three helpers are called from within SnoopInspector's Dispatcher.InvokeAsync block — they must NOT create their own Dispatcher calls or async entry points. - Unit tests
Acceptance Criteria:
- Diagnostics delegates to DiagnosticContext with all 5 providers
- Subtree-scoped diagnostics via root nodeId
- Diagnostics cursor-paginated (
CursorPage<DiagnosticItemDto>) - Resources: effective (closest) first, shadowed included with origin
- Screenshot:
byte[]PNG in memory viaRenderVisualWithHighQuality+PngBitmapEncoder, no temp files - All unit tests pass
PRD ref: US-003c Depends on: BEAD-003-inspector Blocks: BEAD-018-combined (triggers/behaviors tested there) Estimated: 4 new files + 1 modify, ~200 LOC
Files to create:
SnoopWPF.Agent.Engine/TriggerInspector.csSnoopWPF.Agent.Engine/BehaviorInspector.csSnoopWPF.Agent.Tests/TriggerInspectorTests.csSnoopWPF.Agent.Tests/BehaviorInspectorTests.cs
Steps:
TriggerInspector: UseTriggerItemFactory.GetTriggerItem(triggerBase, depObj, triggerSource)to create instances.TriggerSourceenum values:Style,ControlTemplate,DataTemplate,Element(NOT bare "Template"). Walk the element'sStyle?.Triggers,Template?.Triggers, and localTriggerscollection. Project toTriggerDto[]. CallDispose()on eachTriggerItemBaseafter projection. Must be called from within Dispatcher.InvokeAsync block.BehaviorInspector: ReferenceSnoop.Core/Views/BehaviorsTab/BehaviorsView.xaml.cs— theAddBehaviorsFromTypemethod (lines ~111–143) contains the working reflection pattern for bothSystem.Windows.InteractivityandMicrosoft.Xaml.Behaviors. The call site is inUpdateBehaviorListat ~line 106. Extract the reflection logic rather than reimplementing. Must be called from within Dispatcher.InvokeAsync block.- Handle elements with no triggers/behaviors gracefully (return empty arrays)
- Wire into
SnoopInspector - Unit tests
Acceptance Criteria:
- TriggerInspector works for Style, ControlTemplate, DataTemplate, and Element triggers
- BehaviorInspector works with both Interactivity libraries (reflection-based)
- Both inspectors designed to be called from within a Dispatcher.Invoke block (no own Dispatcher calls)
- Graceful empty results for elements without triggers/behaviors
- Unit tests pass
PRD ref: US-003d Depends on: BEAD-001 Blocks: BEAD-022, BEAD-023 Estimated: 1 new file + 2 modifies, ~100 LOC (but SnoopManager.cs is complex — read carefully)
Files to create:
Snoop.Core/Infrastructure/IInjectedAgent.cs
Files to modify:
Snoop.Core/Infrastructure/SnoopManager.cs(~500 lines — read fully before modifying)Snoop.Core/Data/TransientSettingsData.cs(~92 lines)
Steps:
- Create
IInjectedAgentinterface:void Start(TransientSettingsData settings),void Stop() - Add
SnoopStartTarget.HeadlessAgentenum value - Add to
TransientSettingsData:public string? PipeName { get; set; }andpublic string? SessionToken { get; set; }(nullable strings — XmlSerializer handles these cleanly across net462 and net6+) - Add
SnoopManager.HeadlessAgentFactorystatic property:public static Func<IInjectedAgent>? HeadlessAgentFactory { get; set; }— this is for NuGet/in-process mode only, whereSnoopAgent.Start()sets the factory before callingSnoopManager. In injection mode,SnoopAgentEntryPoint.Start()bypassesSnoopManagerentirely and createsSnoopInspectordirectly. - Create
InjectAgentIntoDispatchersoverload forIInjectedAgent(forked from existing Window-based path) — used by NuGet mode - In headless mode: skip
MessageBox.Show(), setMultipleDispatcherMode→AlwaysUse - Replace
ErrorDialog.ShowDialog()→Trace.TraceError()in headless path 7b. Sanitize existing log statements inTransientSettingsData:WriteToFile()(line ~40) logs"Writing transient settings file to \"{settingsFile}\""andLoadCurrent()logs similarly — both leak the settings file path. Remove or redact these log calls. - Settings file security: On net462: create file, then
File.SetAccessControl(path, fileSecurity)(BCL). On net6+: addSystem.IO.FileSystem.AccessControlpackage; create file, then use extension methodnew FileInfo(path).SetAccessControl(fileSecurity)(note:FileStreamconstructor does NOT acceptFileSecurityon net6+). Both:FileSecuritywith inherited ACEs removed, addFileSystemAccessRule(WindowsIdentity.GetCurrent().Name, FullControl, Allow). - Log sanitization: InjectorLauncher must NOT log
TransientSettingsDatafile path or session token. Property values never logged. Exception payloads sanitized before logging (strip paths and tokens). - SnoopLog.txt hardening: Ensure
SnoopLog.txtis created with owner-only DACL (same approach as settings file). Implement per-session rotation (delete/recreate on each injection). - Existing GUI path completely untouched
Acceptance Criteria:
-
IInjectedAgentinterface created -
TransientSettingsDatahasPipeName+SessionToken(nullable strings) -
SnoopManager.HeadlessAgentFactorystatic property - MessageBox/ErrorDialog suppressed in headless mode
- Settings file written with owner-only DACL
- Log sanitization: no session tokens, property values, or pipe names in logs
- Exception payloads sanitized before logging
-
SnoopLog.txt: owner-only DACL + per-session rotation - Unit test:
SnoopWPF.Agent.Tests/LogSanitizationTests.cs— inject known token + pipe name, capture log output, assert neither appears - Existing GUI unaffected
-
dotnet test Snoop.Core.Testspasses
PRD ref: US-003e Depends on: BEAD-003-inspector Note: OPTIONAL — skip unless target app has multiple dispatchers (~5% of WPF apps)
Acceptance Criteria:
- Per-dispatcher
SnoopInspectorinstances with independent work queues - Node IDs:
{dispatcherIdx}:{counter} - Dispatcher routing from nodeId prefix
-
GetSessionInfoAsyncreturns dispatcher list with window nodeIds per dispatcher
PRD ref: US-004 (part 1 of 2) Depends on: BEAD-003-inspector Blocks: BEAD-005 Goal: Create the shared MCP tool handler project + the 7 MVP tools. Estimated: 9 tool/utility files + 7 test files + .csproj, ~450 LOC
Files to create:
SnoopWPF.Agent.Tools/SnoopWPF.Agent.Tools.csproj(net8.0-windows, refs: Contracts + ModelContextProtocol)SnoopWPF.Agent.Tools/SessionInfoTool.csSnoopWPF.Agent.Tools/GetWindowsTool.csSnoopWPF.Agent.Tools/GetVisualTreeTool.csSnoopWPF.Agent.Tools/GetChildrenTool.csSnoopWPF.Agent.Tools/InspectElementTool.csSnoopWPF.Agent.Tools/GetPropertiesTool.csSnoopWPF.Agent.Tools/GetBindingInfoTool.csSnoopWPF.Agent.Tools/ErrorMapping.csSnoopWPF.Agent.Tests/Tools/*Tests.cs(7 test files)
MCP SDK pattern (canonical example for all tool handlers):
using ModelContextProtocol.Server;
[McpServerToolType]
public sealed class SessionInfoTool(ISnoopInspector inspector)
{
[McpServerTool(Name = "wpf_get_session_info",
Description = "Get session info: process name, PID, .NET version, dispatchers, capabilities")]
public async Task<SessionInfoDto> GetSessionInfoAsync(CancellationToken ct)
=> await inspector.GetSessionInfoAsync(ct);
}Register via DI: services.AddSingleton<ISnoopInspector>(inspector); builder.Services.AddMcpServerToolType<SessionInfoTool>();
Steps:
- Create .csproj (
<LangVersion>latest</LangVersion>, addModelContextProtocoltoDirectory.packages.propsif not already from BEAD-000a) - Create
ErrorMapping.cs: Mechanism: each tool handler wraps itsinspector.*Async()call in a try/catch. OnSnoopException, either (a) thrownew McpException(formattedMessage)(the SDK forwardsMcpException.Messageto the client), or (b) returnnew CallToolResult { IsError = true, Content = [new TextContentBlock { Text = errorJson }] }. Option (a) is simpler. The formatted message should be:"[{SCREAMING_SNAKE_CODE}] {message}\n\nSuggestion: {suggestion}". Error code wire format:SnoopErrorCode.NodeNotFound→"NODE_NOT_FOUND". Use manual mapping dict orRegex.Replace(code.ToString(), "([a-z])([A-Z])", "$1_$2").ToUpperInvariant(). - Implement 7 MVP tool handlers following the canonical pattern above
- Special case:
CaptureScreenshotTool(in BEAD-004b) —ISnoopInspector.CaptureScreenshotAsyncreturnsScreenshotResultDto(metadata + byte[]). The tool handler must convert this to a multi-block MCP response:[TextContent(metadata JSON), ImageContent(base64 PNG)]. This requires using the MCP SDK's content block API (verify exact API in BEAD-000a spike). Other tools just return DTOs directly. - Each tool has full
[Description]attribute for MCP schema - Unit tests: each handler with mock
ISnoopInspector GetWindowsAsyncdefault: The tool handler suppliesincludeHidden: falseas the default when the MCP parameter is omitted.
Acceptance Criteria:
- 7 tool handlers as
[McpServerToolType]classes - All operate against
ISnoopInspectorinterface -
ErrorMapping: SnoopException → MCP error with SCREAMING_SNAKE_CASE code + exact PRD suggestion text -
includeHiddendefaults tofalseinGetWindowsTool - All tool parameters have
[Description("...")]attributes (fromSystem.ComponentModel) for JSON Schema field docs — Claude needs these to understand each parameter - Unit tests pass with mock inspector
PRD ref: US-004 (part 2 of 2) Depends on: BEAD-004a Goal: Implement the remaining 8 tool handlers. Estimated: 8 tool files + 8 test files, ~450 LOC
Files to create:
SnoopWPF.Agent.Tools/GetAncestorsTool.csSnoopWPF.Agent.Tools/FindElementsTool.csSnoopWPF.Agent.Tools/SetPropertyTool.csSnoopWPF.Agent.Tools/RunDiagnosticsTool.csSnoopWPF.Agent.Tools/GetResourcesTool.csSnoopWPF.Agent.Tools/CaptureScreenshotTool.csSnoopWPF.Agent.Tools/GetTriggersTool.csSnoopWPF.Agent.Tools/GetBehaviorsTool.csSnoopWPF.Agent.Tests/Tools/*Tests.cs(8 test files)
Acceptance Criteria:
- All 8 handlers implemented following same pattern as BEAD-004a
- EXCEPTION:
CaptureScreenshotToolmust NOT return a DTO directly. It must returnCallToolResultwith two content blocks:TextContentBlock(metadata JSON) +ImageContentBlock(base64 PNG, mimeType "image/png"). This is the only tool that does not use the simple DTO-return pattern. Verify the exact SDK type names from BEAD-000a spike results. - All tool parameters have
[Description("...")]attributes (fromSystem.ComponentModel) for JSON Schema field docs - Unit tests pass with mock inspector
- All 15 tools total now implemented across 004a + 004b
PRD ref: US-005 Depends on: BEAD-004a (minimum), BEAD-004b (for full tool set) Blocks: BEAD-006-012, BEAD-013 Estimated: 5 files + .csproj, ~250 LOC
Files to create:
SnoopWPF.Agent/SnoopWPF.Agent.csproj(net8.0-windows)SnoopWPF.Agent/SnoopAgent.csSnoopWPF.Agent/SnoopAgentOptions.csSnoopWPF.Agent/SnoopAgentHandle.csSnoopWPF.Agent/McpServerSetup.cs
Steps:
- Create .csproj. References: Engine, Tools, Contracts,
ModelContextProtocol.AspNetCore SnoopAgentOptions: Port (default auto), EnableMutation (default false), EnableRedaction (default true), BearerToken (default null = random), LogLevel, TransportModeSnoopAgent.Start(Application, SnoopAgentOptions?)→SnoopAgentHandleSnoopAgentHandle: EndpointUri, BearerToken,Stop()- Reject second
Start()call - Auto-stop on
Application.Exit - Port-in-use: auto-fallback
127.0.0.1only — hardcoded- HTTP: bearer token required via
Authorizationheader, CORS deny all, query-string tokens rejected - Discovery file: Create
%TEMP%\snoop-agent-{pid}.jsonwith owner-only DACL. UseFileStream(path, FileMode.CreateNew)then immediatelynew FileInfo(path).SetAccessControl(fileSecurity)(net6+ viaSystem.IO.FileSystem.AccessControlextension method) orFile.SetAccessControl(path, fileSecurity)(net462 BCL). Note:FileStreamconstructor does NOT acceptFileSecurityon net6+. Write endpoint + token + pid as JSON. Delete inSnoopAgentHandle.Stop()andApplication.Exit. Do NOT useFileOptions.DeleteOnClose. - Console output:
SnoopWPF.Agent MCP server listening at {uri} - Register all tools from
SnoopWPF.Agent.Tools - JSON serialization policy: Configure
JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }for the MCP server's serializer. DTOs have PascalCase C# properties but must serialize as camelCase JSON (hasMore,nodeId, etc.) for AI agent consumption. Check if the MCP SDK exposes serializer options — if not, add[JsonPropertyName]attributes to DTOs (update BEAD-001 accordingly).
Acceptance Criteria:
-
SnoopAgent.Start()returnsSnoopAgentHandle - Second
Start()rejected - Auto-stop on app exit
- Bearer token: required, header-only, CORS deny all, query-string rejected
- Bearer token negative tests: missing header → HTTP 401; wrong token → 401; token in query string → 400/401; OPTIONS preflight → no
Access-Control-Allow-Origin - Discovery file created atomically with owner-only DACL, deleted on Stop/Exit
- All 15 tools registered
-
dotnet build Snoop.slnpasses
PRD ref: US-006–US-012 Depends on: BEAD-005 Goal: Verify each MVP tool works end-to-end against a real WPF app. Primarily test-writing. Estimated: ~400 LOC total (test code)
This is one combined bead. Specific verification per tool:
| Tool | Verify |
|---|---|
wpf_get_session_info |
Returns pid, dotnetVersion, dispatchers array with windowNodeIds, mutationEnabled |
wpf_get_windows |
Main window first; includeHidden default excludes hidden windows |
wpf_get_visual_tree |
maxDepth respected; 5000-node cap; childrenTruncated on cut nodes; includeProperties returns values; redacted props return [REDACTED] inline |
wpf_get_children |
Cursor pagination works; stale: false within 30s, stale: true after 30s+; root-level (no nodeId) returns main window first then by title |
wpf_inspect_element |
All summary fields populated; triggerCount/behaviorCount are null (not evaluated) |
wpf_get_properties |
Filter (case-insensitive substring on property name); category filter; sorted by name; isRedacted on sensitive props; cursor pagination |
wpf_get_binding_info |
dataContextIsNull, dataContextType, resolvedValue, binding error details |
Acceptance Criteria:
- Each tool returns expected data shape (see table above)
- At least one happy-path assertion per tool added to
SnoopWPF.Agent.Tests(not deferred — must assert concrete values, not just compile)
PRD ref: US-013 Depends on: BEAD-005 Blocks: BEAD-014, BEAD-026 Estimated: 5 files + .csproj, ~200 LOC
Steps:
- Create .NET 8 WPF app with:
- Main window + nested layout (Grid → StackPanel → controls)
- Bound TextBlock + ListBox with items
- Intentional binding error (bad path on a TextBlock.Text binding)
- Non-virtualized ListBox (for NonVirtualizedLists diagnostic)
- DataTrigger on a control
- PasswordBox (for redaction testing — password property must be redacted)
- Hidden window (
Visibility.Hidden) — forincludeHiddenand screenshot edge cases - Resource shadowing — define a resource at App level and override at Window level (for precedence testing)
- Unresolved DynamicResource reference (for UnresolvedDynamicResource diagnostic)
- Add
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" />(add toDirectory.packages.propswith version). Include at least one behavior (e.g.,EventTriggerBehavior). - Call
SnoopAgent.Start()on startup --no-agentcommand-line flag disables agent (for injection-mode testing)- Log endpoint URI to console. Do NOT log the bearer token — tests read it from the discovery file or
SnoopAgentHandle.BearerTokenin-process.
Acceptance Criteria:
- App launches with main window
- Contains: binding error, non-virtualized list, triggers, behaviors, PasswordBox, hidden window, resource shadowing, unresolved DynamicResource
-
SnoopAgent.Start()activates MCP server -
--no-agentflag skips agent - Discovery file written (tests read token from there)
- Bearer token NOT logged to console
PRD ref: US-014 Depends on: BEAD-013, BEAD-006-012 Estimated: 8 files + .csproj, ~500 LOC
Files to create:
SnoopWPF.Agent.IntegrationTests/SnoopWPF.Agent.IntegrationTests.csprojSnoopWPF.Agent.IntegrationTests/SampleAppFixture.csSnoopWPF.Agent.IntegrationTests/SessionInfoTests.csSnoopWPF.Agent.IntegrationTests/TreeTests.csSnoopWPF.Agent.IntegrationTests/PropertyTests.csSnoopWPF.Agent.IntegrationTests/BindingTests.csSnoopWPF.Agent.IntegrationTests/ErrorTests.csSnoopWPF.Agent.IntegrationTests/ConcurrencyTests.csSnoopWPF.Agent.IntegrationTests/SerializationRoundTripTests.cs
Steps:
SampleAppFixture: launch sample app as process, poll for discovery file with 15s deadline (100ms intervals — the file may not exist until Kestrel binds), read token, create MCP HTTP client with bearer token. Use assembly-level[SetUpFixture](one shared process for all tests). Mark assembly[NonParallelizable]. Do NOT use[assembly: Apartment(STA)]— tests must run on non-Dispatcher threads (per BEAD-003-inspector threading constraint). ConfigureProcessStartInfowithRedirectStandardInput/Output/Error = true,UseShellExecute = false.- Tests for all MVP tools (session, windows, tree, children, inspect, properties, binding)
- JSON schema validation
- Concurrent request test (no deadlocks)
- Error scenarios: invalid nodeId →
NODE_NOT_FOUND+ suggestion, timeout - DTO serialization round-trip tests: every DTO type through both
System.Text.JsonandDataContractJsonSerializer(from PRD testing plan) - 30s per test, 5min total
Acceptance Criteria:
- Sample app launches and connects via MCP
- All MVP tools tested end-to-end
- Concurrent requests don't deadlock
- Error scenarios: invalid nodeId →
NODE_NOT_FOUND+ suggestion; blocked Dispatcher →DISPATCHER_BUSYwithin TimeoutMs + 500ms - DTO serialization round-trips pass (every DTO type through both
System.Text.JsonandDataContractJsonSerializer) - Performance assertions (use
Stopwatch, names suffixed_PerformanceTarget):GetChildren100 children < 50ms,GetProperties~80 props < 100ms, MCP startup < 200ms, idle memory < 20MB. CI tolerance: Run one warm-up call before timing. IfCIenv var is set, multiply thresholds by 5x (cold JIT on CI runners is 3-10x slower). Use[Category("Performance")]so CI can run them separately. -
dotnet test SnoopWPF.Agent.IntegrationTestspasses
BEAD-015, BEAD-016, BEAD-017 are SERIALIZED (all modify
SnoopInspector.cs). BEAD-018-combined and BEAD-020 are parallel with each other and with 015-017.
PRD ref: US-015
Depends on: BEAD-003-inspector, BEAD-005
Blocks: BEAD-016
Files to modify: SnoopWPF.Agent.Engine/SnoopInspector.cs
Acceptance Criteria:
- Subtree scope via
rootNodeId -
propertyConditionswithEquals/Containsoperators - Type name: short ("Button") or full, case-insensitive
-
totalScanned,truncatedin output -
maxResultscapped at 100 - Integration test added
PRD ref: US-016
Depends on: BEAD-015 (serialized — same file)
Blocks: BEAD-017
Files to modify: SnoopWPF.Agent.Engine/SnoopInspector.cs
Acceptance Criteria:
- Parent first, root last
- Each ancestor includes
dataContextType -
maxLevelsparameter respected - Integration test added
PRD ref: US-017
Depends on: BEAD-016 (serialized — same file)
Files to create: SnoopWPF.Agent.Engine/TypeConverterTable.cs
Files to modify: SnoopWPF.Agent.Engine/SnoopInspector.cs
Steps:
- Hardcoded converter table — NEVER call
TypeDescriptor.GetConverter() - Safe types: string, bool, int, double, float, decimal, long, Color, Thickness, GridLength, CornerRadius, FontWeight, FontStyle, Visibility, HorizontalAlignment, VerticalAlignment, TextAlignment, Point, Size, Rect, all enum types
- Never settable: Uri, ImageSource, BitmapSource, FontFamily, Style, ControlTemplate, DataTemplate, Binding, Type, any UIElement subtype
- For enum types:
Enum.Parse(propertyType, value, ignoreCase: true)— culture-neutral by design - For numeric types:
type.Parse(value, CultureInfo.InvariantCulture) - Errors:
MutationDisabled,PropertyReadOnly,UnsupportedPropertyType,TypeConversionFailed(withexpectedFormathint),PropertyRedacted - Log mutations: nodeId + property name only (NOT before/after values)
Acceptance Criteria:
- Hardcoded converter table, never
TypeDescriptor.GetConverter() -
MutationDisabledwhen opt-in flag is off - Safe types accepted, unsafe rejected
- Enum.Parse with ignoreCase, numeric types with InvariantCulture
- Mutation logging: nodeId + property name only
- Integration test with mutation enabled
PRD ref: US-018, US-019, US-021 Depends on: BEAD-003b, BEAD-003c, BEAD-005 Goal: Add integration tests for diagnostic, resource, trigger, and behavior tools. Estimated: 1 test file, ~150 LOC
Acceptance Criteria:
-
wpf_run_diagnostics: subtree scope, cursor pagination, sorted by level (Critical first), detects binding error + non-virtualized list in sample app, verifies all 5 active provider areas appear inDiagnosticItemDto.Area(FreezeFreezables, LocalResourceDefinitions, NonVirtualizedLists, UnresolvedDynamicResource, BindingLeak) - Performance:
RunDiagnosticson 1000-element tree < 2s (Stopwatch assertion) -
wpf_get_resources: precedence (effective first, shadowed included),resourceKeyfilter, cursor pagination -
wpf_get_triggers: detects DataTrigger in sample app; triggerType, isActive, source, conditions, setters -
wpf_get_behaviors: detects behavior in sample app; typeName, assemblyName, properties
PRD ref: US-020 Depends on: BEAD-003b, BEAD-005 Estimated: 1 test file, ~50 LOC
Acceptance Criteria:
- Multi-block response: TextContent (metadata) + ImageContent (base64 PNG)
- No temp files, no filePath
- Fallback: node → MainWindow → first visible window
- Zero-size elements →
ELEMENT_NOT_RENDERABLEerror - Hidden windows handled
- Integration test verifies ImageContent block structure
- Performance:
CaptureScreenshot< 500ms (Stopwatch assertion)
PRD ref: US-022 Depends on: BEAD-001, BEAD-003d Blocks: BEAD-024, BEAD-025 Estimated: 4 files + .csproj, ~300 LOC
Files to create:
SnoopWPF.Agent.Remote/SnoopWPF.Agent.Remote.csproj(net8.0-windows)SnoopWPF.Agent.Remote/PipeSnoopInspectorProxy.csSnoopWPF.Agent.Remote/PipeConnection.cs(manages theNamedPipeServerStreamon host side)SnoopWPF.Agent.Remote/FramedJsonTransport.csSnoopWPF.Agent.Tests/PipeTransportTests.cs
Pipe topology clarification: In injection mode, the HOST creates NamedPipeServerStream (owns the pipe). The INJECTED AGENT connects as NamedPipeClientStream. PipeSnoopInspectorProxy lives on the HOST side and reads/writes from the server stream. PipeConnection.cs wraps NamedPipeServerStream — the name reflects the host's connection to the pipe, not the Named Pipe "client" role.
Steps:
PipeSnoopInspectorProxyimplements fullISnoopInspector— every method serializes toPipeRequest, sends over pipe, deserializesPipeResponse- Handshake: host sends
{sessionToken, protocolVersion}first (host speaks first), agent responds with capabilities PipeCancelframe for in-flight cancellation (CancellationToken triggers cancel frame)- Per-operation timeout
- Pipe protocol unit tests: framing correctness, 10MB max frame enforcement, cancel frame processing, malformed message handling (from PRD testing plan). Use mock pipe pairs (
MemoryStreamor in-processNamedPipeServerStream/NamedPipeClientStream).
Acceptance Criteria:
-
PipeSnoopInspectorProxyfully implementsISnoopInspector - STJ deserialization of pipe responses uses
PropertyNameCaseInsensitive = true(safety net for net462 DCJS casing) - Handshake: host sends
HandshakeChallenge(sessionToken + protocolVersion); agent respondsHandshakeResponse - Protocol version check: if agent responds with different
ProtocolVersion, close connection withProtocolMismatch -
PipeConnectionvalidates connected client PID viaGetNamedPipeClientProcessId()— rejects mismatches - Cancel frame works (CancellationToken triggers PipeCancel)
- Per-operation timeout
- After pipe disconnect: all subsequent
ISnoopInspectormethod calls throwSnoopException(SessionNotFound)— not rawIOException - Pipe protocol unit tests: framing correctness, 10MB max frame enforcement, cancel frame, malformed message → connection closed with
ProtocolMismatcherror (not crash/hang), version mismatch rejection - Unit tests pass with mock pipe pairs
PRD ref: US-023 Depends on: BEAD-001, BEAD-003-inspector, BEAD-003d Blocks: BEAD-024 Estimated: 4 files + .csproj, ~400 LOC
Files to create:
SnoopWPF.Agent.Injection/SnoopWPF.Agent.Injection.csproj(net462;net6.0-windows, UseWpf=true)SnoopWPF.Agent.Injection/SnoopAgentEntryPoint.csSnoopWPF.Agent.Injection/PipeAgentServer.cs(connects to host pipe as NamedPipeClientStream)SnoopWPF.Agent.Injection/JsonSerializer.cs
CRITICAL: Entry point signature must be public static int Start(string settingsFile) — the return type MUST be int (maps to HRESULT in ExecuteInDefaultAppDomain called by Snoop.GenericInjector/Executor.cpp). Return 0 on success.
Steps:
net462;net6.0-windows, ZERO external NuGet deps (exception:System.IO.Pipes.AccessControlon net462). Note:[DataContract]/[DataMember]attributes fromSystem.Runtime.Serialization.Primitivesare part of netstandard2.0 BCL — not an external dep. 1b. Assembly resolution (CRITICAL for net462): The very first thingStart()must do — BEFORE referencing any Engine/Contracts types — is installAppDomain.CurrentDomain.AssemblyResolve += (sender, args) => { /* resolve from Assembly.GetExecutingAssembly().Location directory */ }. This ensuresSnoopWPF.Agent.Engine.dll,SnoopWPF.Agent.Contracts.dll, andSnoop.Core.dllare found when the CLR tries to load them. All dependency DLLs must be deployed alongsideSnoopWPF.Agent.Injection.dll. On net6+,deps.jsonhandles this automatically.- JSON serialization: net462 →
DataContractJsonSerializer(DTOs have[DataContract]/[DataMember(Name="camelCase")]so DCJS produces camelCase), net6+ →System.Text.JsonwithPropertyNamingPolicy = CamelCase - Custom framed protocol:
{4-byte LE length}{UTF-8 JSON} - Connects to host pipe as
NamedPipeClientStream. Validates session token from handshake. - Settings file: read → extract token → delete file → zero token from memory after handshake. Token zeroing technique: store token as
byte[](notstring— strings are immutable in .NET). After handshake validation:Array.Clear(tokenBytes, 0, tokenBytes.Length); tokenBytes = null;. This actually overwrites memory unlike setting astringto null. - Pipe method dispatch table:
PipeAgentServerreadsPipeRequest.Method(string, e.g.,"GetChildren") and routes to the correspondingISnoopInspectormethod on the localSnoopInspectorinstance. Implement as aDictionary<string, Func<string, CancellationToken, Task<string>>>mapping method names to handlers that deserializeParamsJson, call the inspector, and serialize the result toResultJson. All 15 methods must be in the dispatch table. - Graceful cleanup on disconnect
Acceptance Criteria:
- Entry point:
public static int Start(string settingsFile)returns int -
net462;net6.0-windows, zero external NuGet deps - Framed JSON protocol (no StreamJsonRpc)
- net462: DataContractJsonSerializer, net6+: STJ
- Session token: file deleted, memory zeroed (byte[] cleared with
Array.Clear, not string nullification) - All ISnoopInspector methods available over pipe
- Unit test
SnoopWPF.Agent.Tests/InjectedAgentEntryPointTests.cs: afterStart(), settings file no longer exists; token byte array is cleared
PRD ref: US-024 Depends on: BEAD-002, BEAD-004b (needs all 15 tools), BEAD-022, BEAD-023 Estimated: 2 files + .csproj, ~150 LOC
Files to create:
SnoopWPF.Agent.Host/SnoopWPF.Agent.Host.csproj(net8.0-windows)SnoopWPF.Agent.Host/Program.cs
Steps:
- CLI:
snoop-mcp --pid <pid> [--port <port>] [--enable-mutation] - Default: stdio MCP transport.
--http: HTTP/SSE with bearer token. - Uses
Snoop.Injectorfor injection. CRITICAL wiring: Add a newProcessInfo.InjectAgent(...)method that callsInjectorLauncherManager.Launchwith string constants: assembly"SnoopWPF.Agent.Injection", class"SnoopWPF.Agent.Injection.SnoopAgentEntryPoint", method"Start", and the settings file path. This mirrorsProcessInfo.Snoop()but targets the agent entry point instead ofSnoopManager.StartSnoop. Do NOT setSnoopManager.HeadlessAgentFactory— that mechanism is for NuGet/in-process mode only. In injection mode,SnoopAgentEntryPoint.Start()creates its ownSnoopInspectordirectly without going throughSnoopManager. - Uses
PipeSnoopInspectorProxyfor RPC - Uses
SnoopWPF.Agent.Toolsfor MCP tool handlers (same 15 tools as NuGet mode) - Pipe: random GUID name + user ACL + 10s connection timeout + client PID verification via
GetNamedPipeClientProcessId() - Minimal process handle lifetime: close
PROCESS_ALL_ACCESShandle immediately after injection - Bundle InjectorLauncher binaries as content files with
CopyToOutputDirectory = PreserveNewest— path must be{OutputDir}/Snoop.InjectorLauncher.{arch}.exe(matchesInjectorLauncherManagerresolution pattern) - Graceful shutdown on target exit / Ctrl+C
Acceptance Criteria:
-
snoop-mcp --pid <pid>injects and exposes MCP over stdio -
--httpflag enables HTTP/SSE with bearer token, CORS deny-all, query-string token rejection (same rules as BEAD-005) - Same 15 tools as NuGet mode
- Pipe security: random GUID, user ACL, session token, PID verification via
GetNamedPipeClientProcessId() - Process handle closed immediately after injection
- InjectorLauncher binaries as content files
- Graceful shutdown
PRD ref: US-025 Depends on: BEAD-002, BEAD-022 Estimated: 3+ files + .csproj, ~400 LOC (borderline — can stub table format if context runs low)
Steps:
snoop-cli.exe, net8.0-windows. Dependencies:Snoop.Injector,SnoopWPF.Agent.Remote,SnoopWPF.Agent.Contracts,System.CommandLine,Spectre.Console- Stateless: each command auto-injects, queries, disconnects
- Commands:
session,windows,tree,children,ancestors,find,inspect,properties,set-property,binding,diagnostics,resources,screenshot,triggers,behaviors - Global:
--pid <pid>,--format json|table|tree,--port <port>(connect to existing MCP server — skip injection),--token <bearer>(for authenticated HTTP connection; if omitted with--port, reads from discovery file%TEMP%\snoop-agent-{pid}.json) --porttakes precedence over--pid. When using--port, the CLI acts as an MCP HTTP client (not pipe client).--format tablevia Spectre.Console;--format jsonoutputs raw JSON- Exit code 0/non-zero
Acceptance Criteria:
- All 15 commands implemented
-
--pidvs--portrouting correct -
--format jsonworks for all commands -
--format tablerenders via Spectre.Console - Exit codes correct
PRD ref: US-026 Depends on: BEAD-024, BEAD-013 Estimated: 1 file, ~100 LOC
Steps:
- Launch sample app with
--no-agent - Run
snoop-mcp --pid <pid>as process - Connect via MCP stdio
- Exercise: get_session_info (verify PID matches sample app), get_windows, get_visual_tree, get_properties, get_binding_info
- Error test: invalid nodeId over pipe →
NODE_NOT_FOUND - Verify injection into net8.0 target works
CI Note: CreateRemoteThread-based injection may be blocked by Windows Defender on GitHub Actions hosted runners. Add [Category("RequiresInjection")] to these tests. In CI workflow, add Add-MpPreference -ExclusionPath $env:GITHUB_WORKSPACE before running injection tests, or skip them in CI and require local-only execution.
Acceptance Criteria:
- Sample app launches with
--no-agent - Injection via snoop-mcp succeeds (locally; may require AV exclusion in CI)
- Core tools return correct data
-
ProcessStartInfofor snoop-mcp:RedirectStandardInput/Output/Error = true,UseShellExecute = false
PRD ref: US-027 Depends on: BEAD-015, BEAD-016, BEAD-017, BEAD-018-combined, BEAD-020 (all M3), BEAD-026 Estimated: 3 files modified, ~100 LOC
Steps:
- Verify all projects in
Snoop.sln - Update Nuke build file at
.build/Build.cs— add targets for Agent tests, integration tests, NuGet pack. Rundotnet run --project .build -- --helpto see existing targets first. - GitHub Actions: build + test on
windows-latest - NuGet package
SnoopWPF.Agentwith metadata (MS-PL, description, tags, icon). Pack strategy: Set<IsPackable>false</IsPackable>on Engine, Tools, and Contracts projects. InSnoopWPF.Agent.csproj, add<PrivateAssets>all</PrivateAssets>to each<ProjectReference>so their DLLs are flattened intolib/net8.0-windows/of the package (not listed as separate package deps). Also set<PackageId>SnoopWPF.Agent</PackageId>explicitly.snoop-mcp.exeCANNOT bePublishSingleFile— InjectorLauncher EXEs must be alongside. snoop-mcp.exeandsnoop-cli.exeas artifactsLangVersionset tolatestin new projects- NuGet lock files: add
<RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>toDirectory.build.props(note: lowercase 'b' — matches existing file on disk). Rundotnet restore Snoop.slnto generatepackages.lock.jsonfor each new project. Commit lock files.
Acceptance Criteria:
- All projects in solution
-
.build/Build.csupdated with new targets - CI green on windows-latest
- NuGet package produced
- Executables as artifacts
- Lock files committed
PRD ref: US-028 Depends on: BEAD-027 Estimated: 3 new doc files, ~200 LOC
Steps:
README.md: NuGet quick-start (3 lines), injection mode, Claude Code config for both transportsSECURITY.md: vulnerability disclosure, security contact, mutation/redaction docs, "reading properties executes target app getters" warning, TypeConverter whitelist, no method invocation in v1CHANGELOG.md: 1.0.0 entry- NuGet metadata: MS-PL license, description, tags, icon
- Verify
SnoopWPF.Agentname available on nuget.org - Note: this PRD supersedes
TRANSFORMATION_PLAN.md - Log hardening: Verify
SnoopLog.txtACL and rotation from BEAD-003d is working correctly in all modes.
Acceptance Criteria:
- README: NuGet quick-start + injection mode + Claude Code config
- SECURITY.md: complete with all warnings
- CHANGELOG.md: 1.0.0 entry
- NuGet metadata complete
- SnoopLog.txt ACL and rotation verified
From PRD performance targets. Not a separate bead — add these assertions to integration tests as they are written.
| Metric | Target | Where to test |
|---|---|---|
GetChildren (100 children) |
< 50ms | BEAD-014 |
GetProperties (~80 props) |
< 100ms | BEAD-014 |
RunDiagnostics (1000 elements) |
< 2s | BEAD-018-combined |
CaptureScreenshot |
< 500ms | BEAD-020 |
| MCP server startup | < 200ms | BEAD-014 |
| Memory overhead (idle) | < 20MB | BEAD-014 |
Phase 0:
BEAD-000a BEAD-000b ──> BEAD-000c
BEAD-000d
Milestone 1 (foundation):
BEAD-001 ──┬──> BEAD-003-infra ──> BEAD-003-inspector
│ ├──> BEAD-003b (diag/resources/screenshots)
│ ├──> BEAD-003c (triggers/behaviors)
│ └──> BEAD-003e (multi-dispatcher, OPTIONAL)
└──> BEAD-003d (SnoopManager headless)
BEAD-002 (Injector) — parallel with all of the above
Milestone 2 (MVP NuGet):
BEAD-003-inspector ──> BEAD-004a (7 MVP tools) ──> BEAD-004b (8 more tools)
BEAD-004a ──> BEAD-005 (NuGet server)
BEAD-005 ──> BEAD-006-012 (tool verification)
BEAD-005 ──> BEAD-013 (sample app)
BEAD-013 + BEAD-006-012 ──> BEAD-014 (integration tests)
Milestone 3 (full tools — SERIALIZED for 015-017, rest parallel):
BEAD-005 + BEAD-003-inspector ──> BEAD-015 ──> BEAD-016 ──> BEAD-017
BEAD-003b + BEAD-005 ──> BEAD-018-combined
BEAD-003b + BEAD-005 ──> BEAD-020
[BEAD-003c + BEAD-005 ──> triggers/behaviors tested in BEAD-018-combined]
Milestone 4 (injection):
BEAD-001 + BEAD-003d ──> BEAD-022 (Remote proxy)
BEAD-001 + BEAD-003-inspector + BEAD-003d ──> BEAD-023 (Injection DLL)
BEAD-002 + BEAD-004b + BEAD-022 + BEAD-023 ──> BEAD-024 (Host)
BEAD-002 + BEAD-022 ──> BEAD-025 (CLI)
BEAD-024 + BEAD-013 ──> BEAD-026 (Injection tests)
Milestone 5 (release):
All M3 beads + BEAD-026 ──> BEAD-027 (Build/CI) ──> BEAD-028 (Docs)
Critical path to MVP demo:
BEAD-000a (version pin) → BEAD-001 → BEAD-003-infra → BEAD-003-inspector → BEAD-004a → BEAD-005 → BEAD-013 → BEAD-014
Maximum parallelism:
- Phase 0: 000a, 000b, 000d parallel (000c waits for 000b)
- BEAD-001 and BEAD-002: parallel
- BEAD-003b, 003c, 003d: parallel (all need only 003-inspector or 001)
- BEAD-004b and BEAD-006-012: parallel after BEAD-005
- BEAD-018-combined and BEAD-020: parallel
- BEAD-022 and BEAD-023: parallel
- BEAD-024 and BEAD-025: partially parallel (both need 022)
Total bead count: 30 beads (was 32 before merge/split optimization)
These intentional changes override the PRD. If agents encounter conflicts, the beads are authoritative:
| Item | PRD says | Beads say | Rationale |
|---|---|---|---|
| Error code count | US-001: "all 10 codes" | 11 codes (includes ElementNotRenderable) |
PRD error table lists 11; US-001 text has a typo |
| Reverse node-ID dict key | Dictionary<int, WeakReference<object>> |
Dictionary<string, WeakReference<object>> |
String key "0:42" supports multi-dispatcher from day one |
| Discovery file lifecycle | FILE_FLAG_DELETE_ON_CLOSE |
Explicit delete in Stop() + Application.Exit |
DeleteOnClose deletes on stream dispose — before consumer reads |
| Screenshot API | VisualCaptureUtil.SaveVisual() → byte[] |
RenderVisualWithHighQuality() → PngBitmapEncoder |
SaveVisual writes to disk; we need in-memory |
| TriggerDto.Source values | "Style"|"Template"|"Element" |
"Style"|"ControlTemplate"|"DataTemplate"|"Element" |
Matches actual TriggerSource enum in Snoop.Core |
| Settings file DACL (net6+) | FileStream + FileSecurity constructor |
FileSystemAclExtensions.SetAccessControl() |
FileStream has no FileSecurity overload on net6+ |
| DTO serializer attributes | "NO serializer attributes" | [DataContract] + [DataMember(Name="camelCase")] on all DTOs |
DCJS requires these for correct serialization; attrs are in netstandard2.0 BCL (zero external deps) |
| Dictionary in DTOs | Dictionary<string,string> |
List<NameValuePairDto> |
DCJS serializes Dictionary as array of {Key,Value}; STJ as object — incompatible |
| Pipe protocol types | Single HandshakeMessage |
Split: HandshakeChallenge (host→agent) + HandshakeResponse (agent→host) |
Asymmetric handshake needs distinct types |
| Sample app logging | Logs bearer token to console | Does NOT log token; tests read from discovery file | Logging tokens violates security model |
| SnoopManager.HeadlessAgentFactory scope | Used in injection mode | NuGet/in-process mode ONLY; injection bypasses SnoopManager | Cross-process static property setting is impossible |
- BEAD-000a output (MCP SDK version pin) is a soft prerequisite for BEAD-004a/005 — BEAD-001 can start in parallel with 000a, but 004a should not start until 000a has recorded the pinned SDK version and registration pattern.
- If the MCP SDK
[McpServerToolType]pattern or return types differ from the canonical example in BEAD-004a, update all tool handlers to match the verified pattern. The spike (BEAD-000a) is authoritative for MCP SDK API shape. PropertyInformation.GetProperties()may eagerly evaluate some property values during construction. BEAD-000b (engine spike) must verify whether the redaction "getter NOT invoked" guarantee is achievable with this API. If not, the spike should document the limitation and BEAD-003-inspector must adapt (e.g., by filtering the property list before callingGetProperties, or by accepting that construction-time evaluation is unavoidable for certain properties).DataContractJsonSerializerwithout[DataContract]/[DataMember]attributes: BEAD-000d (pipe spike) must verify that plain POCO classes (no attributes, public settable properties) serialize correctly withDataContractJsonSerializeron net462. If not (DCJS defaults to opt-in with[DataContract]), the Contracts DTOs may need[DataContract]/[DataMember]attributes on a net462-conditional basis, or the injection agent must useJavaScriptSerializeror manual JSON on net462 instead.- BEAD-005 depends on "BEAD-004a (minimum)": This means BEAD-005 registers whichever tools are available at build time. If only BEAD-004a is complete (7 tools), BEAD-005 registers 7. After BEAD-004b completes, BEAD-005 automatically picks up all 15 (they're discovered via
AddMcpServerToolTypecalls or assembly scanning). Thedotnet buildgate passes with either 7 or 15 tools.