# AGENTS.md — operational rules for LLM contributors > Audience: an agent (Claude or otherwise) returning to ClaudeForge cold. > Purpose: surface cross-file contracts that aren't visible from a single-file > read, so you don't break invariants you can't see. > Methodology rationale: see [`AGENT-ONBOARDING.md`](./AGENT-ONBOARDING.md). > Narrative architecture and prose context live in [`CLAUDE.md`](./CLAUDE.md). This file is **fact-shaped**: every claim cites a file path, function name, or test name. If a fact here is wrong, grep for the identifier — code drift will surface as a missing or relocated symbol, not as silently-stale prose. Two specific anti-patterns this file refuses on principle: - **No hardcoded source-line numbers** (`Foo.cs:245`). They drift on every refactor and turn the doc into a liar. Cite the file, the type, the method, or `nameof()` — let `grep` do the locating. - **No timestamps in prose** ("Reported 2026-05-13", "shipped 2026-05-07"). `git log` and `git blame` are the authoritative source for when a thing happened; carrying the date in prose adds maintenance debt with no corresponding benefit. --- ## 1. Hard invariants | Invariant | Failure signature if you break it | Canonical source | |-----------|-----------------------------------|------------------| | **Compound editors must use the force-fire `MarkModified()` pattern**, never bare `IsModified = true`. CommunityToolkit.Mvvm's `[ObservableProperty]` setter elides equal assignments, so a bare assignment when the flag is already `true` (e.g. after `LoadFromLayered` set it for an already-populated scope) is a no-op and the live-write / Save-button-enable chain never runs. | User edits or removes an item on a loaded compound editor → Save button stays disabled. Or: user edits a property whose backing field was already populated → no live-write to disk. | Helper: `MarkModified()` in `McpServersEditorViewModel`, mirrored in `HooksEditorViewModel`, `PermissionsEditorViewModel`, `EnabledPluginsEditorViewModel`, `MarketplacesEditorViewModel`. Sidecar: [`src/ClaudeForge/ViewModels/Editors/AGENTS.md`](./src/ClaudeForge/ViewModels/Editors/AGENTS.md). | | **`ConfigScope` is a struct, and two of its semantics are invisible to the compiler.** (1) `default(ConfigScope)` MUST stay `Managed` — it is backed by a single ordinal for exactly this reason, and a dozen editors declare `private ConfigScope _lastScope;` with no initialiser. (2) `ToString()` MUST keep returning the old enum member names; it is consumed as data, not displayed. Also: it cannot be a default parameter value or a `case` label — use an overload and `when` guards. | A richer struct shape (e.g. a record of `Id`/`Priority`/`DisplayName`/`IsReadOnly`) compiles, passes **2,791 of 2,792 tests**, and silently changes what an uninitialised scope means. Breaking `ToString()` silently breaks scope brushes, tooltips and labels across the editors. | Type: `src/AgentForge.Core/Settings/ConfigScope.cs`. Guard: `tests/AgentForge.Core.Tests/Settings/ConfigScopeTests.cs`. Sidecar: [`src/AgentForge.Core/Settings/AGENTS.md`](./src/AgentForge.Core/Settings/AGENTS.md). | | **A guard's comment is not evidence the guard covers what it says.** `_reloadPending` claimed to "prevent concurrent calls to `LoadAllWorkspacesAsync`"; it guards `ReloadCoreAsync`, one of three callers. Serialisation now lives in `LoadAllWorkspacesAsync` itself. When a concurrency invariant matters, put it in the method performing the destructive change — not in each caller. | A use-after-dispose race (`ObjectDisposedException` from the nav-tree build) survived for years because the test written to cover it named the wrong guard AND could not fail. `OpenProjectAsync` sets `IsLoading` without checking it and awaits a dialog first, so a file-watcher reload starts underneath it. | Type: `MainWindowViewModel.LoadAllWorkspacesAsync` — read its remarks before changing it; **serialise, never coalesce** (a joined load never opens the new `ProjectRoot`) and **chain, never lock** (the load path can re-enter). Guard: `ReloadHardeningTests`. | | **`Session.Dispatch(async () => …)` in a headless test CANNOT FAIL.** It binds `Dispatch(Func)` with `T = Task`, yielding `Task` whose inner task is never awaited. Return a value from the lambda so it binds `Dispatch(Func>)`, then canary with `Assert.Fail`. Non-`async` lambdas are fine — they bind `Dispatch(Action, ct)`. | 19 tests were inert from the day they were written and hid **two real defects**, including a data-loss path where an unparseable config was swapped into memory and then saved over. | Guards: `tests/ClaudeForge.Tests/Headless/*`. Working pattern: `ExportArchiveTests`. | | **The scope ladder belongs to the product, and `ScopeLadder.Default` must stay encoded as a `null` field inside `ConfigScope`.** A product supplies its ladder via `AgentConfigClientCore.Scopes`; `ClaudeConfigClientBase` returns `ScopeLadder.Default` itself rather than a copy, because ladder identity is by instance. Also: `ScopeLadder._isDefault` is a field on purpose — `ReferenceEquals(this, Default)` is `false` during `Default`'s own construction. | A private copy of Claude's four rungs yields scopes that compare unequal to `ConfigScope.User` at ~1,100 sites. The `ReferenceEquals` form makes `ConfigScope.All`'s scopes unequal to `ConfigScope.Managed`, and `ConfigScopeAdapterTests` does **not** notice — its cache is built from `ConfigScope.All`, so it stays self-consistent while wrong. | Type: `src/AgentForge.Core/Settings/ScopeLadder.cs`. Guards: `ScopeLadderTests`, `ProductScopeLadderTests`. | | **Product-neutral code must ask `ConfigScope.IsReadOnly`, never compare against `ConfigScope.Managed`.** A second product's ladder can have more than one policy rung — OpenCode has managed *and* macOS MDM. | A settings value set by an unrecognised policy scope reads as user-editable: the UI offers to change it and the change never takes effect. | `LayeredValue.IsManagedLocked`, `AgentConfigClientCore.EditableScopes`. Claude-specific code (the discoverer, the scope legend, the converters) may still name `Managed` freely. | | **A host that BACKS UP a product must also be able to RESTORE it — pass the same set to `BackupRequest.Products` and to `new BackupEngine(restorableProducts:)`.** A backup takes its products from the request; a restore is driven by an archive whose manifest records only folder names, and resolves them through the engine's own list. `OpenCodeBackup.Engine` exists for exactly this. | ⛔ **A one-way backup, and it reports success.** `BackupEngine.Default` writes OpenCode archives happily and restores nothing from them: the archive exists, the restore says "Restored 0 items", and the user believes they have a backup. Asserting on archive ENTRIES cannot catch it — only a round trip can. | `OpenCode.Sdk/OpenCodeBackup.cs`; guard: `OpenCodeBackupRoundTripTests`. ⓘ Both live on the parked branch — see plans/00003 Phase 0. | | **Everything the shared Backup page says about a HOST's own files comes from `BackupPageOptions`, never from a literal in `AgentForge.Avalonia.Shell`.** Every member of that record is `required` on purpose: a second host is then a compile error until it states what it backs up, what it calls things, which processes to warn about, and which credential store its prompt is asking about. ⛔ `CredentialsPathDisplay` is the one that got away — the include-credentials prompt hardcoded `~/.claude/.credentials.json` and survived the page's extraction from ClaudeForge, because nothing about a hardcoded string fails when it is the only host. | ⛔ **A consent prompt about the wrong file.** OpenCodeForge would have asked the user whether to include a Claude credential file its archives have never contained, while the data actually at stake — `opencode.db` and its two SQLite sidecars, which hold `access_token`, `refresh_token` and `secret` — went unnamed. An "omit" and an "include" are then both decisions about something else, and nothing throws either way. ⚠ The same shape survives one rung down in `BackupRowViewModel.AbbreviateClient`, which maps Claude's two product names and passes anything else through — verbose rather than wrong, and tracked in `PROGRESS.md` rather than fixed. | Record: `src/AgentForge.Avalonia.Shell/Backup/BackupPageOptions.cs`. Hosts: `ClaudeForge.ViewModels.ClaudeBackupPage`, `OpenCodeForge.ViewModels.OpenCodeBackupPage`. Guards: `OpenCodeBackupWiringTests.TheCredentialsPromptNamesOpenCodesOwnStore` (+ `TheProcessAdvisoryNamesOpenCode`); the `required` modifier is the guard for a *future* host. | | **A host's Backup view is a SIBLING of the other's, not a copy — and its deliberate omissions are asserted as markup.** ClaudeForge's `BackupRestoreView.axaml` offers three scope radios and an MSIX tab; OpenCodeForge's offers two radios and no MSIX tab. Both differences are load-bearing: `BackupMode.Full` differs from `SettingsOnly` only by which `SkippedSubdirs` it admits and `OpenCodeProducts.Config` declares none, and `MsixPathProbe` scans `%LOCALAPPDATA%\Packages` for a `Claude_*` package. Literal colours do not cross either — ClaudeForge's six advisory hex literals are light-mode only. | ⛔ A third scope radio produces byte-identical archives labelled differently in the manifest, and the Restore tab's Mode column then displays the difference as if it meant something. ⛔ An MSIX tab offers, from inside OpenCodeForge, to repair Claude Desktop — `ShowMsixTab` goes true on any Windows box that merely *has* it installed. ⚠ Copied hex literals are unreadable in Semi Dark, and the repo's no-hex guard is scoped to view-models and says nothing. **An omission cannot be observed from a running view-model**, which is why these are scans over the AXAML. | Guards: `OpenCodeBackupWiringTests.TheScopeRadiosOfferBackupAndSanitizedOnly` (asserts the `SkippedSubdirs` premise before the conclusion, so it reddens if the premise changes rather than silently outliving it), `TheViewBindsNothingFromTheMsixSurface`, `TheViewUsesThemeTokensRatherThanLiteralColours`. | | **OpenCode has TWO live config roots, and a backup carries both: `config/` from `GlobalDirectory(env)`, `config-default/` from `DefaultGlobalDirectory()`.** `$OPENCODE_CONFIG_DIR` redirects the config that LOADS, while the default root stays live because plugin discovery reads it regardless — so neither alone is the install. `config-default/` is gated by `ProductArchiveSection.IncludeWhen` to the case where the two genuinely differ, compared as normalised full paths. ⛔ **`IncludeWhen` is consulted by the WRITER ONLY.** `RestoreEngine` never calls it: what a restore may apply is decided by what is in the archive, because the machine reading it need not have the environment of the machine that wrote it. | ⛔⛔ **The archive comes back EMPTY and the page says "Backup saved".** Measured 2026-09-12 before the fix: with the variable pointed at a populated directory, a backup of `OpenCodeProducts.Config` produced an archive whose complete contents were `Schemas/opencode-config.json` and `manifest.json` — not one configuration file — and reported success. ⚠ **Ungating the second section is the mirror failure**: with the variable unset both roots resolve to one directory, so every config file is archived twice under two names and restored twice. Neither throws. ⚠ **No manifest bump**: `BackupManifest.Clients` records top-level archive folders (`ArchiveFolder`), and `config-default/` is a sub-path inside `OpenCode/`. An older build reading a newer archive iterates its own sections, so it restores `config/` and ignores `config-default/` — degraded, never wrong. | Layout: `OpenCode.Sdk/OpenCodeProducts.cs` (+ its private `SameDirectory`, which asks `OperatingSystem.IsWindows()` and NOT `PlatformInfo.Current` — the latter is a simulation seam the `--windows`/`--macos`/`--linux` flags drive, and case sensitivity here is a fact about the disk). Gate: `ProductArchiveSection.IncludeWhen`, honoured in `BackupEngine`'s section loop. Guards: `OpenCodeRedirectedConfigBackupTests` — 4 tests, each canaried; ⚠ `OpenCodeBackupRoundTripTests` CANNOT cover this, since it redirects the HOME directory and then writes into `DefaultGlobalDirectory()`, leaving the two roots one folder for its whole run. A test that never sets the variable cannot fail this way however thorough it is otherwise. | | **A product's backup layout lives on its `ProductDescriptor`, and its destinations MUST stay `Func`.** `ProductBackupLayout` (sections + skip rules) is how a product in another assembly supplies backup data to `AgentForge.Core`, which must never reference it. Destinations are factories because descriptors are `static readonly`: a resolved path freezes to whichever profile was current when the type initialiser ran. `BackupMode` cannot appear in these types — `AgentForge.Abstractions` is BCL-only — hence the `IncludedInFullBackup` bool. | ⛔ A frozen destination means a sequential suite restores into **another test's sandbox**, writing real files into a real home directory; it reads as flakiness, not as a stale value. A wrong `IsDirectory` or sub-path restores nothing and still reports success — neither throws. | Types: `src/AgentForge.Abstractions/Configuration/ProductBackupLayout.cs` + siblings. Consumers: `BackupEngine.ShouldSkipHomeSubdir`, `RestoreEngine.RestorableProducts`. Guards: `ProductBackupLayoutTests`, `CrossAssemblyBackupLayoutTests`. | | **`tests/AgentForge.Core.Tests/Fixtures/*.zip` are FROZEN archives and are never regenerated.** Each was minted by the shipped engine at a known layout; `BackupArchiveCompatibilityTests` restores them to prove archives already on users' disks still restore. A change that cannot restore one needs a migration, or a SECOND fixture added beside it — never a re-mint. | ⛔ Every other round-trip test creates its archive with the same build that reads it, so **none of them would notice** a layout change that broke old archives. Re-minting turns the suite green by deleting the only evidence of what users hold. | Fixture: `Fixtures/backup-v1-claudeforge.zip` (`manifest.json` v1, `ClaudeCode/` + `ClaudeDesktop/` prefixes). Guard: `tests/AgentForge.Core.Tests/Backup/BackupArchiveCompatibilityTests.cs`. | | **`FootprintCategory` is a struct over a product-supplied `FootprintCatalog`, with the same two invisible semantics as `ConfigScope`.** (1) `FootprintCatalog.Default` MUST stay encoded as a `null` field so `default(FootprintCategory)` is still `SessionTranscripts` and the statics still equal what a Claude client hands out. (2) `ToString()` MUST keep returning the former enum member names and `Id` the lower-case machine key — the app's resx label lookup is keyed by `Id`, and catalog ORDER is the footprint table's render order. It cannot be a `case` label, an `[InlineData]` argument, or a default parameter value. | ⛔ `Enum.GetValues(typeof(FootprintCategory))` is **reflection**: it kept compiling after the type stopped being an enum and threw `"Type provided must be an Enum"` at run time — the one call site in the conversion the compiler could not point at. A renamed `Id` silently drops a category to its `ToString()` fallback, which reads as a translation gap rather than a rename. | Types: `src/AgentForge.Sdk/Memory/FootprintCategory.cs`, `FootprintCatalog.cs`, `FootprintSource.cs`, `FootprintRoots.cs`. Guard: `tests/AgentForge.Sdk.Tests/Memory/FootprintCatalogTests.cs`. | | **`_suppressStateSave` latch must be set BEFORE `Shutdown()` in `ClearAppData`**. Otherwise `OnClosed → SaveWindowState` re-creates the file `WindowStateService.Delete()` just removed. | User clicks Clear App Data → app exits → next launch reads the freshly re-saved file instead of clean defaults. | Latch declared on `MainWindowViewModel`. Set in `ClearAppData` (must precede `WindowStateService.Delete()` and the `Shutdown()` call). | | **`PlatformInfo.Current` for UI / display branches; `OperatingSystem.IsWindows()` (or `RuntimeInformation.IsOSPlatform`) for platform-intrinsic APIs (registry, MSIX, env-var Machine scope)**. Emulation flags `--windows` / `--macos` / `--linux` swap `PlatformInfo.Current` but cannot make Windows registry calls work on Linux, so platform-intrinsic call sites must keep using the real-OS check. | Running on Windows with `--linux` shows Windows install commands instead of Linux ones (UI used real-OS check). Or: registry call attempted on Linux because the call site went through `PlatformInfo.Current`. | Abstraction: `src/AgentForge.Core/Platform/PlatformInfo.cs` (`PlatformInfo.Current`, `RuntimePlatformInfo`, `EmulatedPlatformInfo`). Decision tree: [`PLATFORM.md`](./PLATFORM.md). | | **`WindowStateService.StatePath` is a property, not `static readonly`**. Tests mutate `PlatformPaths.TestUserProfileOverride` between runs; a cached path captures the host's real `%USERPROFILE%` at type-init and bypasses the sandbox forever after. | Tests touch `~/.claude/cache/ClaudeForge-gui-state.json` on the developer's real machine instead of the per-test sandbox. | `src/ClaudeForge/Services/WindowStateService.cs` — must declare `private static string StatePath =>` (the `=>`, NOT `=`). | | **Every `DataTemplate` and `UserControl` in AXAML sets `x:DataType`**. Compiled-binding mode requires it; reflection-based binding triggers IL2026 warnings under `PublishTrimmed=true`. **It is also the strongest correctness guard available for AXAML in this repo**: with it, a renamed or mistyped bound member is a **build error** (`AVLN2000: Unable to resolve property or method of name 'X' on type 'Y'`) instead of a control that silently renders nothing. One attribute buys what no unit test here can — the headless app is deliberately stripped of the App's resource dictionaries, so views cannot be instantiated in tests. | `dotnet publish -c Release` emits IL2026 warnings; bindings silently fail at runtime in trimmed Release builds. Without it, a view-model rename compiles clean and the surface is empty at runtime. | Convention from `CLAUDE.md` "Key conventions". Trim-warning baseline: [`TRIMMING.md`](./TRIMMING.md). Verified both directions 2026-08-18 on `BackupRestoreView.axaml`'s per-product checkbox template. | | **`ProductDescriptor.ArchiveFolder` is a PERSISTED value, and both sides of the archive now read it.** It names the top-level folder a product's files occupy inside a backup zip *and* the string listed in that archive's `manifest.clients`. Changing an existing product's value moves the writer and the reader together, so it stays self-consistent — new archives work perfectly while every archive already on a user's disk stops being recognised. | Restore silently finds nothing for that product; the restore browser shows a raw unrecognised client name in its column. No exception, no failing assertion. | Values: `SchemaRegistry.ClaudeCodeProduct` / `ClaudeDesktopProduct`. Guards: `BackupEngineTests.ArchiveFolderNames_AreTheValuesAlreadyOnUsersDisks` (+ the two `manifest.clients` writer tests) — they look tautological on purpose. | | **Every `JsonSerializer.Serialize/Deserialize` call uses a source-generated context (`AppJsonContext` or `CoreJsonContext`)**. Reflection-based overloads (`JsonSerializer.Deserialize(json)`) emit IL2026 warnings and break in trimmed/AOT builds. | IL2026 warnings; runtime `MissingMetadataException` in published builds. | Contexts: `src/ClaudeForge/Services/AppJsonContext.cs`, `src/AgentForge.Core/Schema/CoreJsonContext.cs`. Existing pattern: `WindowStateService.Load/Save` uses `AppJsonContext.Default.WindowState`. | | **`JsonArray.Add(...)` calls cast the value to `(JsonNode?)` before passing.** The generic `Add(T)` overload is `RequiresUnreferencedCode`; overload resolution picks the generic over the non-generic `Add(JsonNode?)` when the argument is a concrete `JsonValue` / `JsonObject`. Cast forces the safe overload. | IL2026 errors in trim publish; the same call sites we hit on the Hooks / MCP / Marketplaces / Permissions / Essentials editors. | Pattern: `arr.Add((JsonNode?)JsonValue.Create(s))`. Documented: [XamlQuality's `avalonia-gotchas.md`](https://github.com/JanusMael/Bennewitz.Ninja.XamlQuality/blob/main/docs/avalonia-gotchas.md) "Trim safety" section. | | **A tooltip on a parent covers its children — set it once, on the parent.** Since Avalonia 11.1.0, `ToolTipService` walks up from the element under the pointer, so a child with no tooltip of its own shows its nearest ancestor's. ⚠ This row said the opposite until 2026-09-25: that was Avalonia 11.0 behaviour, and XamlQuality measured it against every tag. | Only on 11.0: hovering a badge's text showed nothing while the padding fired. On 12.x a duplicate tooltip on the child is redundant, not wrong. | `PropertyEditorWrapper.axaml`'s scope badge and the NEW badge + nav-tree icons in `MainWindow.axaml` still set it on BOTH — harmless on 12.x. Gotcha entry: [XamlQuality's `avalonia-gotchas.md`](https://github.com/JanusMael/Bennewitz.Ninja.XamlQuality/blob/main/docs/avalonia-gotchas.md) "A tooltip covers its host's children, so set it once, on the parent". | | **`Orientation="Horizontal" StackPanel` does NOT constrain its children's width** — `TextWrapping="Wrap"` on a child `TextBlock` will not wrap. Use `DockPanel LastChildFill="True"` with the bullet / icon docked Left and the wrappable text in the fill slot. | Long bullet text overflows the right edge of the panel. | Documented: WelcomeView's bullet rows. Gotcha entry: [XamlQuality's `avalonia-gotchas.md`](https://github.com/JanusMael/Bennewitz.Ninja.XamlQuality/blob/main/docs/avalonia-gotchas.md) "TextWrapping never engages". | | **Computed `bool IsXyz => predicate()` properties bound from AXAML are unreliable on Linux.** Avalonia compiled bindings sometimes fail to re-evaluate the getter on manual `OnPropertyChanged(nameof(IsXyz))` notifications. Use an `[ObservableProperty]`-backed field with a `RecomputeIsXyz()` helper called from value-changed partials instead. | Visual state stuck stale until a workspace reload — reproduced on the Essentials page's danger banner before the conversion. | Pattern: `EssentialsCardViewModel.IsDanger` / `RecomputeIsDanger`. Gotcha entry: [XamlQuality's `avalonia-gotchas.md`](https://github.com/JanusMael/Bennewitz.Ninja.XamlQuality/blob/main/docs/avalonia-gotchas.md) "Compiled bindings don't reliably re-evaluate". | | **A property a Style sets must NOT also be set as an attribute on the element.** Avalonia precedence is Animation > **LocalValue** > Style; an attribute is a LocalValue and outranks EVERY Style setter regardless of selector. So a conditional `Classes.foo="{Binding Bool}"` cannot work if the element also writes that property inline — put the default in a style too. ⚠ This row also said a control's `Styles` apply to its descendants and not to itself, so selectors had to be hoisted to the parent; that was wrong until 2026-10-12, and XamlQuality measured on 12.1.3 that a control's own `Styles` reach the control itself too. Hoisting is harmless, not required. | A class-driven visual (e.g. the deep-link orange frame on the property-filter box) never appears even though the flag is true and the selector matches. | Correct shape: `Border.filter-frame` / `.filter-frame.nav-filter` in `GroupPropertiesView.axaml`; working reference `Button.hint-segment` / `.active` in `GuidedRuleBuilderView.axaml`. Gotcha entry: [XamlQuality's `avalonia-gotchas.md`](https://github.com/JanusMael/Bennewitz.Ninja.XamlQuality/blob/main/docs/avalonia-gotchas.md) "Styling / theming". Token policy: [`docs/UI-STYLE-GUIDE.md`](./docs/UI-STYLE-GUIDE.md) §2. | | **A `DataTemplate` for a SUBCLASS view-model must be declared BEFORE the base type's template.** Templates match in declaration order and a base-type template also matches derived instances, so the base wins if it comes first. | A new derived editor VM silently renders with the generic base template (e.g. the `model` picker falling back to the raw-id enum box). | `PropertyEditorWrapper.axaml`: `ModelPropertyEditorViewModel` template sits immediately above `EnumPropertyEditorViewModel`. Gotcha entry: [XamlQuality's `avalonia-gotchas.md`](https://github.com/JanusMael/Bennewitz.Ninja.XamlQuality/blob/main/docs/avalonia-gotchas.md) "Templates / controls". | | **Virtualization is per items-host and needs a bounded viewport — it does NOT reach into a nested items host.** A virtualized outer row whose template contains a plain `ItemsControl` realizes that inner list in FULL. For large nested collections, gate them behind a collapsed section whose `ItemsSource` is **empty while collapsed** (`IsVisible="False"` still realizes the subtree). | Page takes seconds to open despite a virtualized list and cached VMs — `env`'s ~305 declared vars built 306 `PropertyEditorWrapper`s (~4.4 s), while `Advanced`'s 94 *top-level* editors realized only 7 and opened instantly. | Lazy gate: `ObjectPropertyEditorViewModel.VisibleChildren` + `PropertyCategoryViewModel`. Regression probe: the `[PropView.Realized] group=… wrappers=N` trace in `GroupPropertiesView.axaml.cs` (a healthy page realizes a screenful; hundreds = something is eagerly building a subtree). Gotcha entry: [XamlQuality's `avalonia-gotchas.md`](https://github.com/JanusMael/Bennewitz.Ninja.XamlQuality/blob/main/docs/avalonia-gotchas.md) "Virtualization / perf". | | **`SettingsDocument.HasActualChanges` and `JsonDiff.Compute` MUST agree on what counts as a user-visible change.** Both strip the tool-managed `"//"` header-comment key (timestamp marker). If they diverge, `HasUnsavedChanges` can report dirty while the per-property dialog has nothing to show — silent-save bug. | Save button enabled but no save-changes dialog appears; rolling log shows `[Save] dialog gate: summaryNull=True ... hasUnsaved=True`. | Strip site: `SettingsDocument.HasActualChanges` (`DeepEqualsIgnoringMetadata`). Mirror site: `JsonDiff.Compute` (top of file comment block). Safety net: `MainWindowViewModel.SaveCoreAsync` falls back to a generic confirmation dialog if the two ever disagree again. | | **`SensitiveKeys.IsSensitive` checks PATH SEGMENTS, not the full dotted path string.** Anything under `env`, `headers`, `credentials`, `auth`, `authorization` is redacted regardless of nesting depth. The substring pass (token / secret / password / apikey / api_key / api-key / bearer) is the secondary catcher for keys NAMED with secret-bearing terminology outside a known section. | A nested secret-bearing key (e.g. an MCP server's `headers.Authorization`) leaks into the rolling log if a callsite uses full-path exact-matching. | Source: `src/AgentForge.Sdk/Diagnostics/SensitiveKeys.cs` `_segmentExact` set + per-segment loop. Tests: `tests/AgentForge.Sdk.Tests/Diagnostics/SensitiveKeysTests.cs` covers a representative set of nested-path cases. | | **`OnPropertyChanged(nameof(AvailableProfileEntries))` from `LoadAllWorkspacesAsync`, `OnProfileApplied`, `OnProfileDeleted`, AND `OnProfileCreated` MUST be wrapped in `_suppressProfileChangeReload`.** The toolbar ComboBox's TwoWay-bound `SelectedItem` (→ `SelectedProfileEntry`) can write back the freshly-resolved record reference when ItemsSource refreshes; the setter feeds `SelectedProfile`, which fires `OnSelectedProfileChanged` → `_ = ReloadAsync()`. Without the suppression flag the app spins in a reload loop (inside `LoadAllWorkspacesAsync`) or produces a redundant queued reload (inside `OnProfileApplied`). Sibling rule: **VM `Refresh()`/`RefreshAsync()` methods called from `BuildNavigationTree` must offload heavy IO to the thread pool** so the dispatcher stays responsive across a reload. Three currently-correct examples: `BackupRestoreViewModel.RebuildBackupListAsync`, `MemoryEditorViewModel.RefreshAsync`, `EssentialsViewModel.UpdateEnvSourceLabelsAsync`. | Switching to a fresh profile produces a permanently-spinning reload (`[Profiles] After load` + `[Schema] Post-reload validation` loop) and a debugger pause catches the active frame deep inside `UserMemoryService.ReadFirstNonEmptyLine` on the dispatcher thread. | Flag: `MainWindowViewModel._suppressProfileChangeReload`; set/cleared around the AvailableProfileEntries notifications in `LoadAllWorkspacesAsync` and the `OnProfileApplied` callback; checked in `OnSelectedProfileChanged`. Regression tests: `RefreshAsync_RunsTier1ScanOnThreadPool` (Memory), `RefreshAsync_RunsEnvProbeOnThreadPool` (Essentials) — both assert via `Thread.CurrentThread.IsThreadPoolThread` at the moment IO is invoked. | | **`IsLoading`-style re-entry guards on `[ObservableProperty]`-bound VMs MUST scope tightly around the synchronous property assignment, NEVER span an `await`.** Pattern: `IsLoading = true; ; IsLoading = false; `. Reason: bound editors (NumericUpDown, TextBox, CheckBox) call the OnXChanged partial method when the user mutates the property; if `IsLoading` is still true because a read is awaiting an IO continuation, the partial method short-circuits and the user write is silently dropped — appears in the UI but never reaches the SDK, doesn't surface on the save-changes dialog, and reverts on the next reload. Currently-correct site: `EssentialsViewModel.ReadEnvIntAsync` (sync `IsLoading=true/false` around `card.IntValue = …`, then `await UpdateEnvSourceLabelsAsync` OUTSIDE the guard). | User types into a numeric editor; spinner accepts the value but Save dialog shows nothing and the value vanishes on next reload. | Regression test: `IntValueWrite_NotSuppressed_WhileReadIsInAsyncPhase` in `EssentialsViewModelTests` — uses a `ManualResetEventSlim`-gated env provider to keep `UpdateEnvSourceLabelsAsync` suspended at its Task.Run, then writes IntValue and asserts the SDK reflects it. Fails if anyone widens the guard scope back over the await. | | **`SettingsGroupEditorViewModel.ApplyToWorkspace` MUST gate flush on `_userEditedPaths`, NOT on `editor.IsModified`.** Compound editors set `IsModified=true` at load time whenever their scope has data (per the editor sidecar contract — that's how the Save button knows the scope is non-empty), so an IsModified-only gate flushes every loaded editor's in-memory snapshot back to the SDK on every save. When ANOTHER view-model (Essentials, the Environment top-level VM, future SDK consumers) writes to the same top-level key out-of-band, the flush clobbers it. `_userEditedPaths` is populated only by `OnEditorPropertyChanged` — which fires only on post-load user mutations because subscription happens AFTER `LoadFromValue` in `RebuildEditors` — and is cleared on every rebuild. This restricts the flush to its actual safety-net role: re-applying user edits whose live-write may have failed. **Edge case:** `_userEditedPaths` is cleared on EVERY `RebuildEditors`, including the one fired by scope-change (`OnEditingScopeChanged` → `RebuildEditors`). A user who edits + sees a live-write fail + changes scope before clicking Save will lose the failed write (the safety-net flush has nothing to retry). Currently considered acceptable because live-write failures are themselves rare; document explicitly if you ever widen the failure surface. | Typing into an Essentials card → save dialog showed only unrelated changes, value vanished on reload. Log line `[Editor.Flush] writing path 'env'…` from the Environment group editor revealed it was flushing its load-time env snapshot back over the Essentials write. Same shape would clobber out-of-band writes to `permissions`, `mcpServers`, `hooks`, `enabledPlugins`, `extraKnownMarketplaces`, `preferences`, etc. | Set declared on `SettingsGroupEditorViewModel` as `_userEditedPaths`; populated at the top of the IsModified branch in `OnEditorPropertyChanged`; cleared at the top of `RebuildEditors`; checked in `ApplyToWorkspace`. Regression test: `ApplyToWorkspace_DoesNotClobberOutOfBandWrites_OnUntouchedEditors` — loads a group editor over a workspace with existing data, writes to the same path via the workspace directly (simulating an out-of-band SDK write), calls `ApplyToWorkspace`, asserts the out-of-band write survives. Permanent companion audit log: `[Editor.UserEdit]` emitted on every user-driven mutation through the live-write path, with values routed through `FormatValueForAuditLog` so sensitive paths (env, headers, …) are redacted and compound values are summarised structurally (see the redaction-classifier invariant below). | | **Centre status-bar emissions MUST use the typed `SetStatusActive` / `SetStatusSuccess` / `SetStatusWarning` / `SetStatusFailure` / `SetStatusState` helpers on `MainWindowViewModel`.** Writing to the legacy `StatusMessage` setter still compiles (it's an alias kept for older tests / a couple of doc examples) but routes the value to `StatusKind.State` — gray plain text, no icon, no auto-clear, no × dismiss button. A new failure emitted via the legacy setter renders looking exactly like "Ready" / "Project: foo" — the visual urgency the user needs is silently lost. The five typed helpers force the caller to classify severity at the callsite so the View can render the matching pill (green ✓ / amber ⚠ / red ✗ with × / blue …) and the auto-clear / dismiss lifecycle works correctly. | A `Save failed: Access denied` message added via `StatusMessage = "Save failed: …"` renders as quiet gray text instead of the red dismissible pill the user expects; the error blends into background chrome and can be missed. | Helpers: `SetStatusActive` / `SetStatusSuccess` / `SetStatusWarning` / `SetStatusFailure` / `SetStatusState` on `MainWindowViewModel`. Substrate: `src/AgentForge.Avalonia.Shell/Status/StatusController.cs` + `StatusKind.cs` — **product-neutral as of Phase 5 slice 1**; the typed helpers stay on `MainWindowViewModel`, only the substrate moved. The controller's own emitting surface is typed too (`SetActive` / `SetSuccess` / `SetWarning` / `SetFailure` / `SetState`; `Set` is private), so the legacy `StatusMessage` setter is the only remaining way to emit an unclassified status. Auto-clear runs on an injected `TimeProvider` with per-instance delays; there is no static test seam to reset. Lock: `tests/ClaudeForge.Tests/ViewModels/Status/StatusControllerTests.cs` — pins each kind's lifecycle (auto-clear for Success / Warning, sticks-until-dismiss for Failure, replace-cancels-pending) on a hand-advanced fake clock, including that a clear which comes due before the next message does not clear it. | | **Config saves go through `IConfigWriter`, and the writer is chosen at the composition point — never read from `DebugFlags` inside Core.** `ConfigFileLoader.SaveAsync` defaults to the comment-preserving `JsoncEditWriter`; `--writer legacy` selects `LegacySerializingWriter`. `DebugFlags` lives in the **app** assembly and `AgentForge.Core` must never reference it, so the flag is parsed in the app, resolved to an instance in `MainWindowViewModel.SelectedConfigWriter()`, and threaded through the SDK client constructor. ⏳ **`--writer legacy`, `SelectedConfigWriter`, and `LegacySerializingWriter` are a ONE-RELEASE hatch — delete all three after one clean release.** Two writers means every save-path change must be correct twice, and the lossy one is the one nobody remembers to test. | Reading `DebugFlags` from Core is a compile error today and would invert the layering if "fixed" by adding a reference. Letting the hatch persist: a comment-destroying writer stays reachable indefinitely, and the next save-path change silently regresses only under the flag. | Contract + rationale: [`docs/JSONC-WRITER.md`](./docs/JSONC-WRITER.md). Interface: `src/AgentForge.Abstractions/Configuration/IConfigWriter.cs`. Selection: `MainWindowViewModel.SelectedConfigWriter()`. Flag: `DebugFlags.ConfigWriterName`. Lossiness is asserted, not assumed, by `ConfigFileLoaderPreservationTests.LegacyWriter_StillReSerializes_SoTheContrastIsExplicit`. | | **`JsonRedactor.IsSensitiveKey` (Core) and `SensitiveKeys.IsSensitive` (Sdk) MUST agree on every single-segment key.** They're parallel classifiers — duplicated because `AgentForge.Core` can't reference `AgentForge.Sdk` per the layering contract — and they back three different redaction surfaces: the audit-log live-write (Sdk), the save-diff log (Sdk via `WorkspaceDiagnostics`), and the `BackupMode.Sanitized` JSON walker (Core). The `RedactedMarker` literal (`"[redacted]"`) MUST be identical too so log greps / report templates / support workflows don't have to handle two different placeholders. If you add a new sensitive-token to one side and forget the other, one redaction surface starts leaking secrets the others scrub. | A new sensitive-key name (e.g. `"clientCertificate"`) added to `SensitiveKeys._segmentExact` but not mirrored in `JsonRedactor.SegmentExact` → audit logs scrub `clientCertificate` values but sanitized backups emit them verbatim. | Sources: `src/AgentForge.Sdk/Diagnostics/SensitiveKeys.cs` (`_segmentExact`, substring list, `RedactedMarker`), `src/AgentForge.Core/Backup/JsonRedactor.cs` (`SegmentExact`, `SubstringTokens`, `RedactedMarker`). Drift guard: `tests/AgentForge.Sdk.Tests/Diagnostics/SensitiveKeysParityTests.cs` runs a representative sample through both classifiers and asserts identical answers + identical markers. | | **`[Editor.UserEdit]` / `[Editor.Flush]` audit-log emission of editor values MUST route through `SettingsGroupEditorViewModel.FormatValueForAuditLog`.** Direct `value?.ToJsonString()` calls leak secrets: the `env` group editor's value is the WHOLE env JSON object (which contains `ANTHROPIC_API_KEY` and friends), and compound editors like `mcpServers` / `hooks` nest secret-bearing keys (`headers.Authorization`) one level below where `SensitiveKeys.IsSensitive(editor.Path)` can classify them from the top-level path alone. `FormatValueForAuditLog` enforces three rules: (a) path is sensitive per `SensitiveKeys` → emit `RedactedMarker` only; (b) value is `JsonObject` or `JsonArray` → emit shape+size summary (`"(JsonObject, 1234 chars)"`), NOT contents; (c) scalar leaf on non-sensitive path → emit `value.ToJsonString()`. The Save-time `WorkspaceDiagnostics.LogDiffs` is the complementary path — it uses `JsonDiff` to recurse and apply per-nested-leaf redaction, so the "what changed?" forensic detail is preserved at save time without ever inlining secrets at the audit-log layer. | Permanent audit-log trail that logs raw `value.ToJsonString()` leaks the entire env map into the rolling log file on the first edit. | Helper: `FormatValueForAuditLog` on `SettingsGroupEditorViewModel`. Regression tests in `SettingsGroupEditorViewModelTests`: `FormatValueForAuditLog_RedactsEnvPath`, `FormatValueForAuditLog_CompoundValue_ReturnsStructuralSummaryNotContents`, `FormatValueForAuditLog_LeafValue_LogsValueAsJson`, `FormatValueForAuditLog_NullValue_RendersExplicitNullToken`. | | **`return Session.Dispatch(async () => { … })` makes a headless test PASS UNCONDITIONALLY.** The lambda binds to `Dispatch(Func, …)` with `TResult = Task`, so the call returns `Task`. `Task` *is* a `Task`, so `return`ing it compiles — but the test framework then awaits only the OUTER task, which completes the instant the lambda returns its inner task. Everything after the first `await` in the body, including every assertion, runs unobserved; exceptions are swallowed. `.Unwrap()` makes the failure propagate but then DEADLOCKS, because the dispatcher stops pumping once `Dispatch` returns, so the inner continuations never run. **Don't use `Session.Dispatch` for a body that awaits** — write a plain `async Task` test and construct the view-model directly (`NavigationHeaderClickTests`, `NavigationNodeIdTests`, `DeepPathReloadTests` all do this and assert for real). Verify any new headless test with a temporary `Assert.Fail` at the top of the body: if it still reports Passed, the test is inert. | A test that cannot fail. Provably: adding `Assert.Fail(...)` as the first statement of the lambda in `TransactionalReloadTests.LoadAllWorkspacesAsync_ValidReload_SwapsSdkClients` still reports **Passed**. | **Pre-existing scope: 19 test methods across `Headless/NavigationTreeWelcomeNodeTests.cs` (9), `Headless/ReloadHardeningTests.cs` (7), `Headless/TransactionalReloadTests.cs` (3) currently use this pattern and are therefore inert.** Not fixed here (unrelated to the change that found it, and un-inerting them will likely surface real failures) — needs its own pass. | | **`WindowState.LastDeepPath` is persisted from the `MainWindowViewModel._lastDeepPath` FIELD — never computed inside `SaveWindowState`.** `SaveWindowState` has ~14 call sites and one is the tail of `OnSelectedNodeChanged`. During a reload, `RestoreSelectedNode` sets `SelectedNode` → `OnSelectedNodeChanged` → `SaveWindowState`, and the editor at that instant is freshly rebuilt and empty — so calling `IDeepNavigable.CaptureDeepPath()` there would overwrite the good path with an empty one BEFORE the async restore reads it. Capture only at the explicit points (`CaptureDeepPath(bool)` from navigate-away and from `ReloadCoreAsync`, the latter **outside** the `do/while (_reloadPending)` loop). | The user's in-page position silently stops being restored after any reload. Nothing throws, nothing logs an error, and the persisted `lastDeepPath` degrades to empty on every reload. | Field + rationale comment on `MainWindowViewModel._lastDeepPath`; capture helper `CaptureDeepPath(bool captureTransient)`. Regression test: `tests/ClaudeForge.Tests/Headless/DeepPathReloadTests.cs` → `Reload_DoesNotBlankAPersistedDeepPath`. | | **A deep-link handler applies a filter via `ApplyNavigationFilter(...)`, never by assigning `FilterText`.** The latch inside it (`_applyingNavFilter`) is what tells `OnFilterTextChanged` this was navigation rather than a user edit, which in turn raises `FilterFromNavigation` and draws the orange "navigated" frame. A direct assignment reads as a user edit and silently skips the frame, so the user sees a mysteriously narrowed list with no explanation. Two view-models now implement this pair. | A deep-linked or reload-restored list is filtered but shows no navigated frame; the user can't tell why results are missing. | `SettingsGroupEditorViewModel.ApplyNavigationFilter` (original) and `AgentsSkillsEditorViewModel.ApplyNavigationFilter`. Tests: `AgentsSkillsFilterTests.ApplyNavigationFilter_FlagsNavigationThenUserEditClearsIt`, `DeepPathReloadTests.Reload_RevealsTheRestoredItemByFiltering_WithTheNavigatedFrame`. | | **A view that binds a COMPUTED filtered projection must be told when the underlying collection is rebuilt.** Binding `ItemsSource` to a computed `Filtered*` property means the source `ObservableCollection`'s `Clear()`/`Add()` no longer reaches the UI — the collection the view watches is a new list each time the property is read. The rebuild has to raise `PropertyChanged` for the projection explicitly. | The list silently stops updating on refresh: a newly-created skill/agent never appears, and no error is logged. | `SettingsGroupEditorViewModel` raises `FilteredEditors` by hand for this reason; `AgentsSkillsEditorViewModel.NotifyFilteredListsChanged()` does the same after `FillGrouped` (both success and error paths) and after the async description fill. Regression test: `AgentsSkillsFilterTests.RefreshAsync_RaisesFilteredListNotifications`. | | **A view hosted under a `MaxHeight` in a `DataTemplate` MUST contain a `ScrollViewer`.** `MaxHeight` on a template's root does not scroll — it **CLIPS**, silently and with no scrollbar, and the host page's own scroller sizes itself to the clipped extent so the overflow is unreachable by any means. Applies to every `DataTemplate` in `OpenCodeForge/App.axaml` and `src/ClaudeForge/Controls/PropertyEditorWrapper.axaml`. Either wrap the view's root in `` or omit the `MaxHeight`. | A four-server `lsp` config laid its last two entries out at y=1262 and y=1565 against a window bottom edge of y=1100, while the page's only scrollable pane was already at 100%. Those two were exactly the entries the editor's own red "matches neither form" banner was pointing at — it named a problem the user could not scroll to. **All seven editors that existed at Phase 9a-8 shipped this way**, under an App.axaml comment that claimed the opposite. | Found by screenshot + UIA, not by 3,400 green tests — see the harness recipe in the session anchor. ⭐ **Diagnostic: count the vertically-scrollable panes on a page.** One, on a page full of `MaxHeight`-hosted views, is the signature; after the fix there were three. Rows can be realized with real bounding rectangles and still be unreachable, so assert positions against the window rect, not just presence in the tree. | | **Every interactive control in every `src/**/*.axaml` file MUST have `AutomationProperties.Name` set** (Button, TextBox, ComboBox, CheckBox, ToggleSwitch, RadioButton, Slider, NumericUpDown, DataGrid, ListBox). This is repo-wide, not one app's `Views/` folder: it covers `OpenCodeForge/`, `OpenCode.Avalonia/`, `src/ClaudeForge.Avalonia/` and `src/ClaudeForge/Controls|Resources|Views` alike. (The editor library's markup was covered too until plans/00005 moved it into a package; that coverage now belongs in the `Bennewitz.Ninja.ScopedEditors` repo — see PROGRESS.md.) Screen readers (Windows Narrator, NVDA, JAWS, macOS VoiceOver) read this property to announce the control to blind / low-vision users. Avalonia 12's `ContentControl` auto-derives `Name` from text Content on Buttons, but the auto-derived name picks up emoji glyphs and Alt-mnemonic `_` prefixes verbatim ("Underscore S a v e" / "Floppy disk save"), and non-`ContentControl` controls (ComboBox, TextBox, Slider, DataGrid) have NO auto-derivation at all. Explicit `AutomationProperties.Name="{x:Static loc:Strings.AutoNameXxx}"` is the only reliable surface. Pair with `AutomationProperties.HelpText` when the visible label is ambiguous (e.g. an icon-only `×` button with no surrounding context). Resx convention: `AutoName` / `AutoHelp` keys per `docs/LOCALIZATION-B2-RESOLVED.md`; values are clean text (no emoji, no `_` mnemonic prefix). Reuse existing string keys when the visible label IS a good announcement (e.g. `AutomationProperties.Name="{x:Static loc:Strings.ButtonDelete}"` when Content is `"Delete"`). AccessText (Alt-mnemonics) is for keyboard nav, NOT a substitute for screen-reader names — both must be present on the same button. A control TEMPLATE can also contribute interactive elements that no view declares, so there is no markup to annotate and nothing for the AXAML scan to flag no matter how wide it reaches: `NumericUpDown`'s two spinner buttons are `RepeatButton`s whose content is a `PathIcon`, and with no name set Avalonia's `ContentControlAutomationPeer` falls back to `Content?.ToString()`, so they announced the literal string `Avalonia.Controls.PathIcon` until 2026-09-08 (twelve of them in ClaudeForge, six on OpenCodeForge's Essentials page alone). Name those from the theme, not from a view — `ScopedEditors.AvaloniaUI/Themes/AccessibilityNames.axaml` in the `Bennewitz.Ninja.ScopedEditors` repo (in this tree until plans/00005), which `SemiBundle.axaml` includes so every host that takes the bundle gets them; the `/template/` combinator is required or the selector does not reach inside a control template. | Blind user with NVDA tabs onto an icon-only Backup-tab Delete button → screen reader announces "button" with no further context, or worse "🗑️" rendered as a literal emoji glyph name; user has no way to know what the button does. | Resx convention: `AutoName*` / `AutoHelp*` keys in `src/ClaudeForge/Localization/Strings.resx`. Existing examples: `AutoNameSearchBox`, `AutoNameButtonSave`, `AutoNameToggleTheme`. Guard test: `tests/ClaudeForge.Tests/Accessibility/AxamlAccessibilityCoverageTests.cs` scans every `src/**/*.axaml` file (recursive, bin/obj excluded) and reports any interactive control without `AutomationProperties.Name`; new controls added without a Name fail CI. Existing-gap baseline is tracked in the same test as an explicit allow-list, keyed by REPO-RELATIVE path — bare file names cannot work repo-wide now that `MainWindow.axaml` and `PropertyEditorWrapper.axaml` each exist in two projects. A sibling test, `AxamlScan_CoversEveryUiProject_NotOneHardcodedDirectory`, fails if the scan ever narrows back to a single hardcoded folder, which is how the pre-2026-08-20 version of this guard silently missed every AXAML file outside `src/ClaudeForge/Views`. The resx parity guard is **half** generalised, and the halves matter separately: `LocalizationParityTests` contracts #1–#4 (key parity, TODO markers, coverage, format placeholders) still run against the single hardcoded `src/ClaudeForge/Localization`, but contracts #5–#7 are repo-wide — a `ResxLedger` requires **every** `Strings.resx` under `src/**` to be declared with an explicit `Localized` flag, cross-checks those claims against the locale files on disk, and `TheParityContracts_CoverEveryLocalizedProject` fails the moment a second project is declared localized while #1–#4 still cover only one. So a new resx can no longer appear unguarded (it fails #5 until it is declared), but declaring one *localized* is what forces #1–#4 to be generalised. Tracked as Problem 8 in `OPENCODEFORGE-PLAN.md`, which plans/00003 step 0e removed from this branch and which lives on the parked `feat/agentforge-opencodeforge`. For template parts, which the AXAML scan cannot see at any width: `ScopedEditors.Tests/Themes/TemplatePartAutomationNameTests.cs` in the `Bennewitz.Ninja.ScopedEditors` repo builds real templated controls on the headless UI thread and asserts what their automation PEERS announce — the peer is where the `ToString()` fallback lives, so only the peer can show it is gone — after asserting the template actually produced the parts it is about to check, so a renamed part fails loudly instead of leaving the test with nothing to look at. | | **Where an automation name COMES FROM is not where you put it — four measured traps.** (a) **`AutomationProperties.Name` is IGNORED on a `TextBlock`; its `Text` always wins.** Consequence already in the tree: the repo's 15 `AutomationProperties.Name="{Binding DisplayName}"` attributes on TextBlocks are all **no-ops**, invisible only because each one's `Text` already equals `DisplayName`. Use `AutomationProperties.HelpText` for anything the Text does not already say — UIA announces name then help text. (b) **A container generated from `ItemsSource` takes its name from the ITEM, never from the `ItemTemplate`**, and the FALLBACK differs per container, which is why each one must be MEASURED rather than reasoned about: a `TabItem` falls back to the item's `ToString()` (so it announces a type name); a `TreeViewItem` falls back to **nothing at all** and ignores `ToString()`; a `ListBoxItem` falls back to `ToString()` like a `TabItem`, and either fix works there. ⚠ An `ItemsControl` is **not in this class** — its `ContentPresenter` takes no focus, so the announced name comes from whatever focusable control the template holds, which is why one shared view-model was broken in one app and correct in the other. Name a `TreeViewItem` with a `Style` setter on the container carrying `x:DataType` (a reflection binding there is an IL2026 trim error). A lone bound `TextBlock` as the template root does **not** name the container either. (c) **Wrapping a glyph to carry a name makes it worse**: a `Border` and a `ContentControl` both get **no automation peer**, so the element leaves the control view entirely. (d) **A composite control never HOLDS focus, so its name can be on an element a screen-reader user never lands on.** `NumericUpDown` and `AutoCompleteBox` each surface correctly named under their own control type (`Spinner`; `Group` for the picker) and each hand focus to an inner `PART_TextBox` that had no name and no `LabeledBy` of its own — measured with `HasKeyboardFocusProperty`, True on the inner Edit and False on the named Spinner above it, across 6 number fields and 8 pickers. Fixed in the same theme file by copying the host's name down; no new string, because the name is the host's own. ⚠ Scope such a selector to the control whose `TemplatedParent` OWNS the part, which is not always the one it renders inside: `PART_TextBox` draws within the `ButtonSpinner` but belongs to the `NumericUpDown`'s template, so a `ButtonSpinner /template/` selector matches nothing. UIA shows nesting, never template ownership — read `TemplatedParent` from a headless dump instead of reasoning about it. A host the view left unnamed copies down an empty string and stays unnamed, deliberately, so this cannot paper over a missing name. | **Every one of the repo's eight `ItemsSource`-bound containers was broken — 8 for 8, across three container types.** 5 tabs announced `…OpenCodeArtifactTabViewModel`, 6 announced `…Settings.GroupTab`, both apps' navigation trees announced **nothing** for every row (26 + 3), and all four ListBoxes announced their item's type name (search results, the hooks event rail, the MCP server list, ~90 keybind rows). A severity dot bound to a full sentence announced `▲`. | Guards, one per container type because the accepted fix differs: `ItemsSourceBoundTabsTests` (requires `ToString()`), `ItemsSourceBoundTreeViewsTests` (requires a container-naming `Style`), `ItemsSourceBoundListBoxesTests` (accepts EITHER, since both work there) — each canaried in both directions, including from a second assembly so a guard cannot cover only the file it was written against. ⛔ **`AxamlAccessibilityCoverageTests` is STRUCTURALLY BLIND to this whole class** and scored every one of those files clean — the attribute was present, on the wrong element — so its zero baseline must not be quoted as evidence here. Full entry with the measurements: XamlQuality's `docs/avalonia-gotchas.md` (https://github.com/JanusMael/Bennewitz.Ninja.XamlQuality/blob/main/docs/avalonia-gotchas.md). ⭐ A UIA dump reports `Name` by default, so read `.Current.HelpText` explicitly or a working annotation looks absent. ⛔ It is equally blind to trap (d): the attribute was present, on the correctly-named control, and the element that takes focus was a template part it never sees. `TemplatePartAutomationNameTests` covers (d), canaried five ways including the plausible wrong selector scope. ⭐ `AutomationElement.FocusedElement` is global and unreliable from an agent session — the app cannot be brought foreground on demand and you get the desktop's `Pane class=#32769`; read `HasKeyboardFocusProperty` per element instead. | | **Danger classification: a surface ASKS THE EDITOR *iff* it lacks the inputs, a DIFF surface resolves its value from the DOCUMENT ROOT, and the policy travels PER PRODUCT.** (a) `DangerAssessment` is a function of *path + scope + value*. Search holds only a path, so it delegates via `IDangerAnnotatedEditor` and gets back the very assessment the row renders. An effective row and a pending change hold all three — and the WINNING/TARGET scope and value, not the editing ones — so they call `IDangerClassifier.Classify` themselves. Their dots may legitimately DISAGREE with the editor's (Caution where you edit, Critical once a committed project file wins); do not 'unify' them. (b) `JsonDiff` emits an array change under the ARRAY's path but carries only the changed ELEMENT as its value, so a save-preview row must resolve the key against the document root rather than trusting `PropertyDiff.NewValue`. (c) One app hosts several products: `ProductSection.Danger` and `DirtySource.Danger` carry the table per product, and `ClaudeEditorFactoryConfig.CreateDefault` must NOT default one. | (a) A search hit contradicts the row it navigates to, or an effective row reports the editing scope's severity for a value that came from elsewhere. (b) A rule written for `permissions.allow` is handed one element's string, matches no list pattern, and answers "nothing wrong right now" — a silent false negative on exactly the keys the save dialog exists to catch. (c) Claude DESKTOP's settings and pending writes get labelled with Claude CODE's threat model — blank on most rows since the schemas barely overlap, confidently wrong on any name that collides (`env` is in both). ⛔ And the wiring BETWEEN factory and markup was unguarded until `DangerWiringEndToEndTests`: breaking `MainWindowViewModel`'s `BuildGroups` call reddened NOTHING while every row in the app would have rendered blank. | Contracts: `IDangerClassifier` and `DangerAssessment` (package `Bennewitz.Ninja.ScopedEditors.Abstractions`), `IDangerAnnotatedEditor` (`Bennewitz.Ninja.ScopedEditors.ViewModels`). Matcher: `src/AgentForge.Avalonia.Shell/Danger/TableDangerClassifier.cs` (tier is inherited by descendants; the value predicate and scope escalation run ONLY on an exact match). Tables: `ClaudeDangerTable` (142 keys), `OpenCodeDangerTable` (36 + 13). Guards: `DangerSurfaceMarkupTests` (all six render sites), `DangerWiringEndToEndTests` (the real window, both directions), `SavePreviewDangerTests`, `EffectiveRowDangerTests`. Rendering rules: [`docs/UI-STYLE-GUIDE.md`](./docs/UI-STYLE-GUIDE.md) §3b. | | **A colour token is chosen for ONE contrast floor, and using it at the other role breaks it silently — so severity SIZE, severity TINT and caution TEXT are separate things from the accent.** (a) Text is 4.5:1, a border or glyph is 3.0:1, and no single amber clears both: `AppCautionBrush` is the accent (`#EA580C`), `AppCautionTextBrush` is the text colour (`#9A3412` light; dark is the accent unchanged, already 7.76:1). (b) Equal point sizes are NOT equal drawn sizes — `⊗` inks at **0.797×** `⚠`, and the hollow `○` out-drew Critical — so rank is carried by `AppSeverityToFontSizeConverter` (Critical ×1.55, Caution ×1.15, circles ×1.00) against each site's tier base, never by a literal. (c) The `IsDangerNow` banner is severity-driven (`EscalatedTier` defaults to `Critical`), so its background is DERIVED from the severity brush at 10% by `AppSeverityToTintBrushConverter` rather than declared — and its body text sets no `Foreground` at all, because OpenCodeForge has no `AppPrimaryTextBrush`. (d) Every brush token declared here must be referenced by something. | ⛔ Nothing fails: contrast is invisible to the compiler and to every rendering test, so `AppCautionBrush` sat at 3.07–3.19:1 as text at **six** sites while the suite stayed green. ⛔ A points-only assertion passes at ×1.01 with the size defect still on screen, which is why the guard multiplies scales by MEASURED ink. ⛔ Raising the tint alpha erases the border it sits inside — the banner's border is the SAME colour as its tint, and Caution falls to 2.96:1 at 15%. ⛔ A tint minted fresh per `Convert` goes stale on a theme switch and reads as a slightly-off background rather than a wrong colour; `BrushHelper` tracks it. ⛔ A dead token keeps a comment that reads as current — `InstallBannerCodeBorderBrush` made "is the install banner missing a border?" a question someone had to answer. | Converters: `AppSeverityToFontSizeConverter`, `AppSeverityToTintBrushConverter`, `BrushHelper.ResolveThemedTint`. Guards: `CautionBrushIsNotUsedAsTextTests`, `SeverityTintStaysLegibleTests`, `SeverityGlyphFontSizeMarkupTests`, `AppSeverityToFontSizeConverterTests`, `NoDeadBrushTokensTests`. ⭐ The first three COMPUTE contrast and ink from the hexes in `App.axaml` rather than quoting them, so a palette change is checked rather than recorded. ⚠ `NoDeadBrushTokensTests` excludes the two generated compat shims (their consumers are templates inside theme packages) and exempts four runtime-built key families, each of which must still be constructed by the source it names. The mirror question — referenced here, defined by no theme — belongs to `theme-audit`; see [`docs/THEME-AUDIT.md`](./docs/THEME-AUDIT.md). | | **An app composition root builds its `SchemaRegistry` with `CreateWithNetwork()`; a test builds one with no client, or with a stubbed handler — never with a bare `new HttpClient()`.** A null `HttpClient` means OFFLINE, and that is the constructor's default, so both mistakes compile, run, and pass. ⛔ **As of 2026-09-12 a composition root must ALSO name a `cacheDirectory`, and it must be that app's own.** Null means no disk cache — the same deliberate default, for the same measured reason — so a production site that omits it silently loses the resolved-artifact store and re-resolves every launch, with nothing failing. There is no neutral default on purpose: `~/.claude/cache/schemas` is Claude's answer, and OpenCodeForge's belongs beside its window-state file, **outside** the roots the disk-footprint page measures, or the app's own cache is reported to the user as OpenCode's disk usage. | **Nothing fails.** ClaudeForge shipped `new SchemaRegistry()` in `App.axaml.cs` from the initial commit and never fetched a schema in production, while this repo's docs described the fetch. In the other direction, 26 test sites passed a real `HttpClient` and resolved their assertions against whatever schemastore.org served that day — measured, not inferred: a probe registry built the same way reported `Source=Fetched`. | Composition: `src/ClaudeForge/App.axaml.cs`, and `InitializeAsync` in `OpenCodeForge/ViewModels/MainWindowViewModel.cs`. Guard: `tests/ClaudeForge.Tests/Architecture/ProductionSchemaRegistryTests.cs` — a **source** scan, because a registry deliberately does not expose whether it holds a client. ⛔ It matches **two** spellings; the target-typed `SchemaRegistry x = new(new HttpClient())` escaped the first draft and two live sites survived a pass that reported success. | | **A provenance badge must not describe a fetch that was never attempted.** A product whose `SchemaUrl` is not `https://` (Claude Desktop's is `bundled://…`, its schema being hand-maintained) is never fetched, so the ordinary "tried to fetch and could not" tooltip is false there, and `SchemaRefresher` omits it from a check's results rather than reporting it up to date. | A user reads that the app could not reach the network and goes looking for a proxy or firewall problem that does not exist — on the one section where no request was ever made. | Appliers: `MainWindowViewModel.ApplyProvenanceBadge` in **both** apps (each formats from its own resx — the shared `NavigationNodeViewModel.Badge` is a plain string for exactly this reason). Rule: `SchemaRefresher.IsCheckable`. Guards: `SchemaProvenanceBadgeTests` (both apps), `SchemaRefresherTests`. | | **A themed brush must FOLLOW the variant, never snapshot it.** `BrushHelper.ResolveThemed` hands back ONE shared `SolidColorBrush` per key and re-colours it on `ActualThemeVariantChanged`; `Color` is change-notifying, so elements already holding it re-render. ⛔ Resolving a brush inside an `IValueConverter` and returning it is the bug this replaced: a converter re-runs only when its binding SOURCE changes, and a severity does not change because the theme did — so rows rebuilt by navigation showed the new palette while rows that were not showed the old one, on the same screen. ⚠ The returned brush is SHARED AND MUTABLE: treat it as read-only, and note the cache is identity-only (colour re-read on every resolve) because caching the colour made it stale when dictionaries changed without the variant changing. | Half a screen in one palette and half in the other, changing as you navigate. No test caught it: the two existing themed-lookup classes assert a FRESH Convert respects the variant, which the old code did correctly | `ScopedEditors.Tests/Helpers/ThemedBrushTrackingTests.cs` in the `Bennewitz.Ninja.ScopedEditors` repo — it holds the brush handed out under Light, flips the variant WITHOUT re-converting, and asserts the colour followed | | **An affordance must be FOCUSABLE, not merely announced.** The diagnostics header links were `TextBlock`s with `PointerPressed` handlers and full `AutomationProperties` — so a screen reader announced a correctly-named Hyperlink that no keyboard user could reach or invoke, and the window's only tab stop was its log list. ⛔ Setting `AutomationProperties.Name`/`HelpText`/`ControlTypeOverride` on a non-focusable control is not accessibility; it is the appearance of it. Use a `Button` (focus adornment, Enter/Space, a real `ButtonAutomationPeer`) rather than `Focusable = true` on a TextBlock, which is reachable but draws no focus ring. | Tab appears to do nothing; actions are mouse-only; an automation scan reports the control as present and named | `LayeredEditors.Avalonia.Diagnostics.Tests/AccessibilityCoverageTests` — ⚠ its `IsInteractive` still treats a hand-cursor `TextBlock` as a link so the old pattern is caught, but excludes one inside a `Button`, since Avalonia's `Cursor` is inherited | | **Nothing in the neutral layer may resolve to Claude's data BY DEFAULT.** Claude-named symbols are fine there — `SchemaRegistry`'s Claude product descriptor names Claude's paths because it describes Claude. What is forbidden is a caller reaching Claude data *without having said so*: a `??` fallback, or a constructor chaining to one. The app that means `~/.claude` says so at its own call site. ⛔ **This is the one layering violation that has already shipped a defect.** `AgentConfigClientCore.FootprintService` returned `new FootprintService()`, whose catalog defaults to Claude's seven `~/.claude` categories, and neither OpenCode client overrode it — so both reported **Claude's** disk footprint as their own, and a delete would have removed the other agent's data. ⚠ `AssemblyLayeringTests` cannot see any of this: all three of its methods are about assembly REFERENCES, and Claude-shaped code inside a neutral assembly declares no reference at all. ⭐ Closing the constructor shape is what makes `new T()` safe everywhere — the second-order form carries no Claude token and appears in no scan, so it must be unrepresentable rather than detectable. | The two-app plan predicted the footprint defect in writing, naming the call site and the trigger, and it shipped anyway. A prediction in a document is not a mechanism | `NeutralLayerDefaultsTests` — a source scan over the packable projects (derived from `IsPackable`, not a second list of the eleven), plus reflection over the two audited signatures. ⛔ **`KnownSites` is a ratchet that only SHRINKS**, and a stale entry fails the test, so an exemption cannot outlive its fix. It currently holds **one** entry: `FootprintService` itself, whose default was never removed — `765648a` fixed the call sites — because making it neutral is a real refactor of a feature awaiting manual retest | | **The shared libraries are consumed in two MODES, and the switch is central — no csproj declares both.** `UseSharedPackages` unset or `false` (the default, and what every human runs) means `ProjectReference`; `true` means `PackageReference` at `SharedPackageVersion`, used by the per-PR canary and the release. The rewrite lives in the ROOT `Directory.Build.targets`, applies to every project that is not itself one of the eleven, and switches only references whose file name begins `AgentForge.` or `LayeredEditors.` — product-specific references stay projects. ⛔ **Two MSBuild facts make the obvious spellings fail silently:** `%(Metadata)` in a condition on an item outside a target evaluates to EMPTY without erroring, and item `Remove` is a string comparison that does not normalise paths — this repo spells these references two different ways. The implementation normalises to full paths and uses `Remove` as a set difference for exactly those reasons. | A mixed graph puts two `AgentForge.Core.dll` in one app and MSBuild prefers the project one, so a job named "package canary" reports success over project-built code. A condition that matches nothing switches nothing and the build still succeeds | `.github/workflows/ci.yml` job `package-canary`, driving `scripts/package-canary.ps1`. ⚠ `AssemblyLayeringTests` is NOT affected by the mode: it reads csproj XML from disk, and the transform never edits a csproj. Verified by injecting a bad reference and watching it redden in **both** modes | | **A package and a project do not put the same files in the output directory.** A `ProjectReference` copies the referenced project's XML documentation file next to its DLL; a `PackageReference` leaves it inside the `.nupkg`, because `CopyDocumentationFilesFromPackages` defaults to `false`. The root `Directory.Build.targets` sets it `true` **in package mode only** — repo-wide it would drag every third-party package's XML into every `bin/`. | `PublicSurfaceContractTests.IAgentConfigClient_DocumentsThreadingContract` reads `AgentForge.Sdk.xml` from the test output; it was the one test of 4,295 that failed the first canary run | The `package-canary` job. ⚠ Any other test reading a side-car file next to a shared assembly will fail the same way, and only in package mode | | **A private feed answers 404 for a BAD TOKEN exactly as it does for an unpublished package, so a version check must prove its credentials first.** `scripts/Publish-Packages.ps1` probes `api.github.com/rate_limit` — 401 for a dead token, 200 for any live one, PAT or `GITHUB_TOKEN` — and only then reads a 404 from the feed as "not published". ⛔ Measured on this feed: bad token → **404** on `//index.json`, no auth → 401, and the service index `/index.json` → **200 regardless**, so it proves nothing. The first version of that script passed its own preflight with the literal token `definitely-not-a-valid-token`. ⚠ **One residual gap, covered by push ORDER rather than by the check:** a token with `write:packages` but not `read:packages` is live, passes the probe, and still 404s on every read — so packages are pushed one at a time in a stable order and the run stops on the first failure, which means a re-run of a partly-published set collides at package 1 with the other ten untouched. ⓘ `--skip-duplicate` is deliberately not passed. | A published package version can never be replaced or re-pushed. A vacuous preflight followed by a mid-set failure leaves some ids published forever at a version the rest of the set does not have, and the only recovery is bumping all eleven — which under a day-resolution CalVer means waiting for tomorrow | `scripts/Publish-Packages.ps1` gates 1–3, run by `.github/workflows/release-packages.yml` on a `packages-v*.*.*` tag. Canaried in all three directions: a skewed `BuildTimestamp`, a dead token, and a real token against the live feed | | **A release pins its version from the TAG, and `BuildTimestamp` is the only knob that does it.** Every publish job runs `scripts/Resolve-ReleaseVersion.ps1`, which strips the app's tag prefix, rejects a tag whose date is unreal or whose quarter disagrees with its month, and exports `BuildTimestamp=yyyyMMdd000000` (plus `ReleaseVersion` and `PublicVersion`) into `$GITHUB_ENV`. ⛔ **The two obvious knobs are worse than useless**, measured three ways on one packable project: `-p:PublicVersion=2026.3.901` → package `2026.3.914`, assembly `2026.3.914.1346` (sets no version at all — the generator writes it only as `[AssemblyMetadata]`); `-p:AutoPackageVersion=2026.3.901` → package `2026.3.901`, assembly `2026.3.914.1347` (**skew**, the package lying about its contents); `-p:BuildTimestamp=20260901120000` → both `2026.3.901`. Only `BuildTimestamp` is a `CompilerVisibleProperty`, which is the whole reason. ⚠ The step must be in **every publish job** — `$GITHUB_ENV` does not cross a job boundary. ⓘ A tag release stamps a fourth part of `0`; that is the reproducibility, not a rounding error. | Both release workflows set `PublicVersion` and described a version flow that was not happening, for a phase. Every release so far carried the `AssemblyVersion`/`FileVersion` of the day CI ran, with its own tag one metadata attribute away — so nothing ever looked wrong. They agree by coincidence when a tag is built minutes after it is cut, and diverge on a re-run days later or a tag cut near midnight | `ReleaseWorkflowTests.EveryPublishingJobResolvesItsVersionFromTheTag` counts resolve steps against publish invocations, **over non-comment lines only** — counting raw text let a header comment stand in for a missing step, caught by canarying the test rather than by reading it. `ReleaseWorkflowTests.NoWorkflowSetsPublicVersion` forbids a SECOND setter, not the property: the resolver emits it beside `BuildTimestamp` so the two cannot name different tags | | **The five packages take their version from `$(AutoPackageVersion)`, assigned in the ROOT `Directory.Build.targets` — never in `src/Directory.Build.props`.** AutoVersioning is a *source generator*, so the stamp it writes into the assemblies is invisible to `dotnet pack`; that property is what makes the package version and the assembly version one value instead of two conventions that happen to line up. ⛔ **The wrong file fails SILENTLY.** `src/Directory.Build.props` — where the rest of the package identity lives, which is why someone will try it — is imported BEFORE the NuGet-generated props that define `AutoPackageVersion`, so the assignment evaluates to the empty string, the SDK's own default has already run, and all five pack at `1.0.0` with nothing reporting anything. ⓘ A `-p:PackageVersion=…` on the command line is a GLOBAL property and still outranks the assignment, which is what lets `package-canary.ps1` pack the same tree at a throwaway prerelease. | GitHub Packages refuses to replace a published version. A release cut at `1.0.0` is immutable, and five packages at `1.0.0` built from two different commits are indistinguishable on the feed forever | `PackageVersionLockstepTests` — that the assignment exists and names that property, that `src/Directory.Build.props` does not also make it, that every packable assembly agrees on one three-part stamp **read out of the built DLLs rather than out of the property that produced it** — `AssemblyVersion` as well as `FileVersion`, because identity is what a consumer of these packages binds to — and that `artifacts/localfeed` holds one version with every inter-package dependency naming it (inconclusive, not green, when nothing has been packed). ⚠ The identity clause could not be canaried red: a hand-set `-p:AssemblyVersion` is ignored by the generator and disabling it fails the build with `BAUTOVERSIONING00`, so its subject is a future change to AutoVersioning rather than a mistake available today | | **A plural `` does NOTHING unless the project first clears the inherited singular ``.** MSBuild cross-targets only when `TargetFramework` is empty, and the repo-root `Directory.Build.props` sets it. Three projects — `src/ClaudeForge`, `OpenCodeForge`, `LayeredEditors.Avalonia.Services` (since plans/00005 the `Bennewitz.Ninja.AppServices` packages) — declared `net10.0-windows10.0.19041.0` for a MAUI Essentials share path, each with a comment asserting the plural took precedence, and **none of the three ever built it**. ⛔ Nothing fails: the build succeeds, the suite stays green, `bin/` quietly holds one TFM directory, and the dead `#if` blocks never compile. It surfaced only because `dotnet pack` reads `TargetFrameworks` for the nuspec while the build honours the singular. To multi-target, write `` ahead of the plural form. | A TFM you declared is silently absent from `bin/`; `#if` code for it never compiles; `dotnet pack` emits NU5026 for a file no build wrote | `tests/ClaudeForge.Tests/Architecture/SingleTargetFrameworkTests.cs` — and it fails, rather than passes, if the root stops setting the singular, because that is its entire premise | | **One `SchemaRegistry` per app, handed to every consumer.** `AgentConfigClientCore` builds its own whenever it is passed `null`, so an app that constructs clients without passing one gets a registry per client *plus* its own. OpenCodeForge had three: every schema fetched twice per launch, and an offline launch paying the 3s `FetchTimeout` once per registry rather than once per schema. ⭐ The correctness half is the provenance badge — with separate registries it can report `Fetched` for the pages while the registry the SAVE path validates against fell back to bundled, and no surface disagrees. | Duplicate conditional GETs per launch; a badge that does not speak for save-validation | `OpenCodeForge.Tests/SharedSchemaRegistryTests.cs` — asserts both that the clients share, and that `InitializeAsync` adds no third | | **The shared Backup page's product names and progress phrases come from the host, and each is keyed by something specific.** `BackupPageText.ClientAbbreviations` is keyed by **`ArchiveFolder`**, because that is what `BackupEngine.BuildClientList` writes into `manifest.clients` — a map keyed by `Id` or `DisplayName` compiles, ships, and abbreviates nothing. `BackupPageText.ProgressLabels` is keyed by `ProductArchiveSection.ProgressLabelId` (the archive sub-path joined with `/`, so OpenCode's database sections are `data/opencode.db`, not `opencode.db`) plus every id in `RestoreProgressIds.All` and `BackupProgressIds.All`. ⚠ A missing or misfiled key falls back to the engine's English rather than blanking — correct behaviour, and an invisible failure. | A Clients cell rendering full product names; a progress bar in English inside a translated build | `ClaudeBackupPageProgressTests`, `OpenCodeBackupWiringTests.EveryRestorePhaseHasALabelMatchingTheEngine` — both take ids from the DESCRIPTORS and assert the label matches the engine's wording, because presence alone passes two keys swapped between sections | | **A UI built in C# rather than AXAML needs its automation names set IN CODE, and the AXAML scan is structurally blind to it.** The diagnostics surfaces build their windows and menus in C# — `LiveLogWindow` and `LiveTailWindow` in `src/ClaudeForge/Diagnostics/`, the fatal and non-fatal dialogs in the `Bennewitz.Ninja.AppServices` package (both were `LayeredEditors.Avalonia.Diagnostics` until plans/00005) — so `AxamlAccessibilityCoverageTests` — however wide its file glob reaches — can never see one of its controls. Names go on at construction with `AutomationProperties.SetName` / `SetHelpText`, in English literals: they were written for a host-agnostic library that deliberately had no localization seam (no resx, no `WrapperStrings`-style resolver), and the two windows kept those literals when they moved into the app. Its header links go through `UI/HeaderLink.cs`, which also sets the `Hyperlink` control type. Covers the control types the AXAML rule lists plus `MenuItem`, `SelectableTextBlock`, and a hand-cursor `TextBlock` used as a link. | A blind user reaches the diagnostics window — the one surface a user opens *because* something is already wrong — and the controls announce nothing. The repo-wide AXAML guard reports a clean baseline the whole time, because there is no markup to score. | Guard: `tests/ClaudeForge.Tests/Diagnostics/AccessibilityCoverageTests.cs` builds `FatalErrorDialog`, `NonFatalNoticeDialog`, `LiveLogWindow` (via `LiveLogWindow.RebuildWindowForTesting`) and `LiveTailWindow` (via `WindowForTesting`) on the headless UI thread, walks each logical tree plus attached `ContextMenu`s, and fails any interactive control without a clean-text Name. ⭐ **Each window's interactive-control count is PINNED**, so a control the walker cannot reach is noticed rather than silently skipped — the failure mode that makes a coverage test worthless. | | **Both shipping apps set `TrimMode=partial`, and each pairs it with ``.** ⛔ `link` is UNSUPPORTED by Avalonia — [#16697](https://github.com/AvaloniaUI/Avalonia/issues/16697): COM interop is not supported under it, with reported access violations in `UiaReturnRawElementProvider` for users running Magnifier or a screen reader — and **the build emits no warning**, so a clean six-RID matrix is worth nothing on this question. The `TrimmableAssembly` half is not decoration: under `partial` a non-trimmable assembly is copied WHOLE, so DesignerSupport's unreachable remote-designer entry point becomes analysable and the publish dies. ⓘ This is about a supported configuration, NOT about an accessibility defect — `F5` claimed the trimmed build exposed no UIA tree and is refuted; it exposes 168 descendants under either mode. | Drop the `TrimmableAssembly` entry and every Release publish fails with `NETSDK1144: Optimizing assemblies for size failed`, pointing at `Avalonia.DesignerSupport`'s `IL2026`/`IL2072`/`IL2075` rather than at the missing entry. Revert to `link` and nothing fails at all — which is the actual hazard. | Sites: `src/ClaudeForge/ClaudeForge.csproj`, `OpenCodeForge/OpenCodeForge.csproj` (both halves in each). Guard: `tests/ClaudeForge.Tests/Architecture/TrimModeIntegrityTests.cs` — asserts the mode, asserts the entry, and separately asserts the two apps have not DRIFTED APART, which is the state that once let OpenCodeForge publish untrimmed under a green CI trim check. | | **Friend grants live in ONE linked file, and no project declares its own.** `AssemblyInfo.InternalsVisibleTo.cs` sits beside `ClaudeForge.slnx` and is linked by every project as `../../AssemblyInfo.InternalsVisibleTo.cs` — **relative and forward-slashed**. ⛔ Because it compiles into every linking assembly, **`internal` now means SOLUTION-internal**: a grant applies to every assembly, not the one you had in mind. If a member must not cross an assembly boundary, make it private. ⚠ The names are **assembly** names (`AgentForge.Core`), not the `Bennewitz.Ninja.*` root namespaces — the namespace form compiles, ships, and grants NOTHING. | A backslash in the link path **builds clean on Windows** and breaks the Linux and macOS publishes — measured, which is why the guard asserts the exact string rather than "ends with the filename". A per-project `` added later also builds clean, and silently re-creates the scattered set the shared file replaced. | Site: `AssemblyInfo.InternalsVisibleTo.cs` at the repo root, plus one `` per csproj. Guard: `tests/ClaudeForge.Tests/Architecture/SharedFriendGrantsTests.cs` — the file exists and grants something, every project links it, every link is portable, and no project declares a grant in **either** spelling (the SDK item and a raw `` were both in use before consolidation, so a guard knowing only the first would call three projects clean). | --- ## 2. "If you're doing X, also touch Y" checklists ### X = Adding a new project to the solution - [ ] **List it in `ClaudeForge.slnx`.** The solution file is hand-maintained; a project missing from it silently never builds in CI. `BuildFilePathIntegrityTests` asserts every project on disk is listed. - [ ] **Link the shared friend-grants file**, exactly as every other project does: ``. ⚠ Relative, **forward-slashed**. `SharedFriendGrantsTests` fails otherwise — and a backslash would build clean on Windows while breaking the Linux and macOS publishes. - [ ] **Add its assembly name to `AssemblyInfo.InternalsVisibleTo.cs`** if anything needs to reach its internals, or any of its internals need reaching. ⛔ Do **not** add `` to the csproj — that re-creates the scattered set the shared file replaced, and the guard rejects it in both spellings. - [ ] **Say whether it is packable.** `IsPackable` defaults to `true` for a library, so a product-specific half added under `src/` would be packed and pushed to an immutable feed by a release that was never told about it. `PackageMetadataTests` requires the answer to be explicit. - [ ] **If it is shared and packable**, it must also be named in the package-mode reference switch in the root `Directory.Build.targets`, in `AssemblyLayeringTests`' own selector, and in `nuget.config`'s `packageSourceMapping` — four uncoupled places, and only the package canary catches the fourth. Prefer a family prefix (`AgentForge.*`, `LayeredEditors.*`) so the selectors match it automatically. - [ ] **If it is an app that publishes trimmed**, set `TrimMode=partial` and pair it with ``; `TrimModeIntegrityTests` checks both halves and that the apps have not drifted apart. ### X = Adding a new compound editor (sixth one beyond MCP / Hooks / Permissions / EnabledPlugins / Marketplaces) - [ ] New file in `src/ClaudeForge/ViewModels/Editors/EditorViewModel.cs` extending `PropertyEditorViewModel` (the app shim at `Editors/PropertyEditorViewModel.cs`, NOT the library base). - [ ] Implement `MarkModified()` with the force-fire pattern (force-fire invariant above). Copy from `EnabledPluginsEditorViewModel.MarkModified`. - [ ] If `LoadFromLayered` mutates collections that have subscribed handlers, add a `private bool _isLoading;` guard around the load body and return early in `MarkModified` when set. See parity table in [editor sidecar](./src/ClaudeForge/ViewModels/Editors/AGENTS.md). - [ ] Override `LoadFromLayered(LayeredValue, ConfigScope)`. Call `SetScopeState(layered, editingScope)` first; set `IsModified = scopeValue != null` (or `Count > 0` if empty objects should NOT count). - [ ] Override `OnResetToInherited()`. Cache `_lastLayered` and `_lastScope` in `LoadFromLayered`, then re-call `LoadFromLayered(_lastLayered, _lastScope)` here so reset restores the on-disk state instead of clearing. - [ ] Override `ToJsonValue()`. Return `null` (NOT empty object) when the editor has no content — that's how `RemoveValue` is signalled to the workspace. - [ ] Subscribe child `PropertyChanged` and nested `CollectionChanged` if the editor has any. Pattern: `OnXxxChanged(NotifyCollectionChangedEventArgs)` walks `e.NewItems` (subscribe) and `e.OldItems` (unsubscribe). Existing items present at subscription time must be hooked manually — see `McpServersEditorViewModel.SubscribeEntry`. - [ ] Filter transient input fields in `OnEntryPropertyChanged` (e.g. `NewArg`, `NewServerName`). They MUST NOT mark modified or the Save button flickers per keystroke. Existing filter list: `NewArg`, `NewEnvKey`, `NewEnvValue` (MCP); `NewItemText`, `NewAllowText`, `NewDenyText`, `NewAskText` (Permissions); `NewPluginRef` (EnabledPlugins); `NewName`, `NewSourceType`, `NewSourceValue` (Marketplaces). - [ ] Register the editor type in `src/ClaudeForge/ViewModels/Editors/CompositeEditorFactory.cs` (or `PropertyEditorFactory.cs`, follow the existing dispatch). - [ ] Add a `DataTemplate` for the new VM in `src/ClaudeForge/Controls/PropertyEditorWrapper.axaml`. Set `x:DataType="vm:EditorViewModel"`. - [ ] **Wrap the view's root in a `ScrollViewer` if the `DataTemplate` sets `MaxHeight`** (invariant above). A `MaxHeight` without one clips instead of scrolling, and the clipped rows are unreachable — it shipped in all seven OpenCode editors before 9a-8 caught it. - [ ] **Drive the editor in the running app before calling it done.** The UIA + screenshot harness is cheap and has a track record: it found the clipping defect above, a placeholder clipped by its own box, and confirmed three-state checkboxes render three distinguishable ways. Seed config via `OPENCODE_CONFIG_DIR` (a sandbox — never the user's real `~/.config/opencode/`). Recipe + traps in the session's `uia-screenshot-harness` memory. - [ ] Add a regression test pair in `tests/ClaudeForge.Tests/ViewModels/Editors/EditorViewModelTests.cs`: `EditingXxxAfterLoad_FiresIsModifiedPropertyChanged` and `RemovingXxxAfterLoad_FiresIsModifiedPropertyChanged`. Templates in §3 below. - [ ] If the editor lives under a new top-level navigation node, also touch `src/ClaudeForge/Services/NavigationTreeBuilder.cs` and the `NavTitle*` / `NavDesc*` constants in `MainWindowViewModel`. ### X = Adding a new debug flag (e.g. `--simulate-no-claude`) - [ ] New `public static bool MyFlag { get; private set; }` property in `src/ClaudeForge/Services/DebugFlags.cs`. - [ ] New `case "--myflag":` branch in `DebugFlags.Initialize`. Comparison is `ToLowerInvariant()` — the case label MUST be lowercase. - [ ] **Don't call `Log.*` inside `Initialize`.** It runs BEFORE `Program.Main` configures Serilog (so the culture flag can take effect at step 2). Any warning to emit goes into `_deferredWarnings.Add(...)`; `LogActiveFlags()` flushes them after `ConfigureLogging`. See `--culture` for the canonical two-token pattern. - [ ] Add the flag to `ListActive()` so it appears in the startup log line. - [ ] Reset it in `ResetForTesting()`. - [ ] If the flag takes a VALUE (two-token, e.g. `--culture en-US`): use the `for (var i = 0; i < args.Length; i++)` loop pattern. Validate the value before assigning; reject-with-warning via `_deferredWarnings.Add` rather than throwing. **Consume vs peek — pick deliberately, they behave differently on a typo:** - **Open value set** (any string is plausible — `--culture`, `--deep-link`): `args[++i]` unconditionally. A missing value eats the next token; that is accepted, because you cannot tell a bad value from a flag. - **Closed value set** (`--writer legacy|jsonc`): read `args[i + 1]` WITHOUT advancing, and `i++` only once the value is recognised. `--writer --linux` then rejects the writer *and* still honours `--linux`, instead of silently swallowing it. Consuming first is a real bug — it was caught here only because the test asserted the next flag still took effect. - [ ] Read the flag at the relevant call site, ORed with the production condition (existing template: `ShowInstallBanner = DebugFlags.ShowInstallBanner || (!Detected)`). - [ ] Add a test in `tests/ClaudeForge.Tests/Services/DebugFlagsTests.cs` that asserts the flag flips on the matching arg and stays default otherwise. For two-token flags, add cases for missing-value (last arg with no value), invalid-value (validation rejects), and value-then-next-flag (consumes the value and lets the outer loop see the next flag). - [ ] Add the flag to the **"available flags:"** message in `DebugFlags.Initialize`, so `--debug-help` can discover it. ⛔ This step was missing from this list and present on the CLI-tool list below, which is the wrong way round — a new *flag* had no instruction to become discoverable while a new *tool* was told to advertise itself as one. `DebugFlagsTests.EveryFlagInitializeParses_IsAdvertisedByDebugHelp` now fails on an undocumented flag, in both directions, so neither omission can recur silently. - [ ] Document the flag in `CLAUDE.md`'s debug-flags table. - [ ] Document the flag in `README.md`'s features section if it's user-visible. ### X = Adding a new GUI-bypass CLI tool (e.g. `--cleanup-restore-sidecars`, hypothetical `--vacuum-sessions`) **Distinct from debug flags.** Debug flags tweak GUI state; CLI tools run a task and exit. Pattern established by `--cleanup-restore-sidecars`. - [ ] Implement the actual work as `internal static` in `AgentForge.Core` (NOT in the GUI assembly). The Core project has no Avalonia dependency, so the logic is testable without spinning up a headless dispatcher and importable by future out-of-tree consumers (CLI wrapper, MCP server, etc.). Example: `src/AgentForge.Core/Backup/RestoreSidecarCleanup.cs`. - [ ] Return a structured `Result` record from the worker — counts, byte totals, failure messages capped at a sensible threshold (20 for the existing tool) to bound process memory. - [ ] Accept an optional `Action? onProgress` callback for long-running operations. Heartbeat every 1 000 items (or analogous granularity) so the CLI surface can stream status to stderr. - [ ] In `src/ClaudeForge/Program.cs`, add the flag to the CLI-bypass `foreach (var arg in args)` loop ABOVE `BuildAvaloniaApp()` and return after dispatching — never start Avalonia for these tools. - [ ] Wrap the CLI entry in a private static `RunXxx()` helper that: - **MUST** call `TryAttachParentConsole()` at the top. The binary is `WinExe` on Windows, which detaches stdout/stderr from the parent terminal at startup. Without `AttachConsole(ATTACH_PARENT_PROCESS)`, every `Console.WriteLine` vanishes silently. - Prints a `[ClaudeForge] …` start line to `Console.Error`. - Logs the action to Serilog at `[.Command] action=…` so the rolling log records the run even when the terminal is detached. - Streams progress to `Console.Error` via the `onProgress` callback. - Prints a final summary to `Console.Error` AND mirrors it to `Log.Information(…)`. - On failure: dump the first ≤20 failure messages to BOTH surfaces and log each at `Log.Warning(…)`. - Calls `Log.CloseAndFlush()` before returning. Avoid the outer `finally` in `Main` for CLI tools — the explicit flush makes intent obvious and avoids racing with the parent terminal which may already be re-attached to the next command. - [ ] Document the tool in `CLAUDE.md`'s **CLI-bypass tools** section (NOT the debug-flags table — they're conceptually different). - [ ] Document the tool in `README.md` under the relevant user-facing section (Backup workflow for the cleanup tool, etc.). - [ ] Add the tool to the **CLI-bypass tools** message in `DebugFlags.Initialize` — the *second* `_deferredWarnings.Add` in the `--debug-help` arm, NOT the "available flags:" one. ⛔ This bullet used to say "update `--debug-help`'s emitted line", which contradicted the bullet three above it and is exactly how `--cleanup-restore-sidecars` came to be advertised as a debug flag: it told a user it was a flag and sent the next maintainer looking for a `case` that does not exist. `DebugFlagsTests.DebugHelpAdvertisesNothingItCannotParse` now fails if a tool re-enters the flags list. - [ ] Tests: cover the worker's Result shape in `AgentForge.Core.Tests` (no GUI deps). Verify the file/directory side effects, the failure-resilience path (locked / read-only / missing inputs), and the progress callback contract. ### X = Adding a new persisted UI-state field - [ ] New property on `WindowState` in `src/ClaudeForge/Services/WindowStateService.cs`. Add `[JsonPropertyName("yourKey")]`. - [ ] If the type is not already in `AppJsonContext`, add it. The serializer call at `WindowStateService.Load` / `Save` already uses `AppJsonContext.Default.WindowState`, so as long as the new property's type is reachable from `WindowState` the existing context covers it. Verify by running `dotnet publish` once and checking for IL2026. - [ ] Cache the field in `MainWindowViewModel` if it's read more than once per session, and load it from `WindowStateService.Load()` at construction time. - [ ] Save it via `MainWindowViewModel.SaveWindowState(...)` — that is the ONE call site that writes UI state. - [ ] Regression test in `tests/ClaudeForge.Tests/Services/WindowStateServiceTests.cs` — round-trip: write a `WindowState` with the new field set, read back, assert equal. Use `PlatformPaths.TestUserProfileOverride` for sandboxing (template in §3). ### X = Adding a new localized string - [ ] Add the key to `src/ClaudeForge/Localization/Strings.resx` (+ a ``). - [ ] Add the key with a **real translation** to EVERY `Strings..resx` (de/es/fr/ja/ko/pt/ru/zh). `LocalizationParityTests` enforces full parity, forbids `TODO` markers, and rejects near-copies of English — a missing/placeholder translation fails the test gate. - [ ] Add the matching `public static string KeyName { get; }` entry to `Strings.Designer.cs` (NOT auto-generated by source gen — manually edit to mirror the .resx entry). - [ ] Reference it as the literal token `Strings.KeyName` (C#) or `{x:Static loc:Strings.KeyName}` (AXAML) — the `loc:` namespace is already declared in existing views. The **dead-string guard** (`Directory.Build.targets`) fails the build for an unreferenced key, and its **dynamic-access tripwire** fails the build on by-name/reflective access (`Strings.ResourceManager`, `typeof(Strings)`). For an id→string map use a `switch` whose arms each return a literal `Strings.` (see `ViewModels/Catalog/CatalogLocalization.cs`). - [ ] DO NOT inline user-visible English in code or AXAML. The existing convention is in [`LOCALIZATION.md`](./LOCALIZATION.md). ### X = Adding / changing a `model`, `effortLevel`, or `permissions.defaultMode` value - [ ] Edit the catalog `src/AgentForge.Core/Assets/ModelCatalog/model-catalog.json` — it's the single source of truth for these lists + their relationships (which efforts a model supports, auto-mode capability). DO NOT hardcode a new list in a view-model. - [ ] `pwsh scripts/validate-model-catalog.ps1` must pass (structural + cross-relationship checks). - [ ] If you added a `permissions.defaultMode` value, add its localized label/description to `ViewModels/Catalog/CatalogLocalization.cs` (literal `Strings.` arms) and the matching `Strings` keys (all locales). Keep `ModelCatalogSchemaParityTests` green (catalog enums ↔ bundled JSON-schema enums). - [ ] App consumers read through the SDK: `client.Models` (`IModelCatalogAccessor`) — never the Core catalog directly (SDK-first). Full design: [`docs/MODEL-CATALOG.md`](./docs/MODEL-CATALOG.md). ### X = A new model launches and takes over a family alias (e.g. Opus 5 → `opus`) The schema refresh (`scripts/refresh-schema.{ps1,sh}`) does **NOT** carry model names — schemastore.org omits them, so expect it to report "already up to date". The pickers come entirely from the catalog + two hand-curated overlays. When the `opus`/`sonnet`/… alias moves to a new snapshot, edit these four and keep the invariant **one non-legacy row per family alias**: - [ ] **Verify the facts first** — never from recall: has the exact Claude API IDs, context/effort, and the "Legacy models" line that tells you whom to demote (a family can supersede itself: Fable 5 → Fable 5.1). - [ ] `ModelCatalog/model-catalog.json` — add the new row (copy the outgoing build's effort/`supports1m`/`supportsAutoMode` flags unless docs say otherwise), move the `alias` onto it, repoint the `aliases` map, and **demote the previous holder to `legacy: true`, `alias: null`** (keep the row — still a valid pin). - [ ] `Schemas/claude-code-settings.overlay.json` — swap the family's pinned snapshot id in `model.examples` (+ the `model.description` e.g.). This is the AutoCompleteBox list; the refresh never touches the overlay. - [ ] `Descriptions/claude-code-settings.enumdescriptions.json` — mirror that swap and update the alias tooltip ("today Opus N"). **Every `model.examples` value needs a key here** or `ModelPropertyPromotionTests` fails (each picker item needs a tooltip). Replace in place, don't accumulate stale rows. - [ ] `tests/AgentForge.Core.Tests/Catalog/ModelCatalogTests.cs` — update the `Resolve("opus")` / `Resolve("opus[1m]")` asserts to the new id (they hardcode the alias target). Tests that use the demoted id as a *concrete* model stay green — its capabilities didn't move. - [ ] Verify: `pwsh scripts/validate-model-catalog.ps1`, then `dotnet test ClaudeForge.slnx --filter "FullyQualifiedName~ModelCatalog|FullyQualifiedName~ModelPropertyPromotion"`. Full playbook + rationale: [`docs/MODEL-CATALOG.md`](./docs/MODEL-CATALOG.md) § New model launch. ### X = Adding a new share operation (new `ShareXxxCommand` in a view-model) - [ ] Accept `IShareService? shareService` as a constructor parameter (nullable — unit tests pass `null`; the app wires the real service). - [ ] For file-sharing commands (`ShareFileAsync`): also accept or inject a `Func` that returns the path lazily at execute-time (makes the provider testable without real Serilog / disk setup). See `AboutEditorViewModel._logPathProvider` pattern. - [ ] Implement the async handler: guard the service; call `ShareFileAsync` or `ShareTextAsync`; catch broadly and log via `Log.Error`. Never throw to the caller. - [ ] ⛔ **REPORT THE OUTCOME. A share that says nothing is the F3 defect, and it survived in three places.** Both methods return `Task`; the handler picks a sentence from it and emits it through an `Action` hook the host wires to the centre pill. ⚠ A missing SERVICE reports `Unavailable` — it does not return quietly. Writing the legacy `StatusMessage` setter instead still compiles and routes to grey `StatusKind.State` with no icon and no auto-clear: wrong channel. - [ ] ⭐ **For a FILE share, do not write your own switch** — call `FileShareStatus.Describe(outcome, revealed, unavailable, failed)`. Three sentences, not six: `ShareFileAsync` can only report revealed / unavailable / failed, and the mapper reports anything else as a failure because it means the service broke its contract. - [ ] ⚠ **Say what actually happened.** No platform here opens a share sheet — Windows copies or reveals, macOS copies or reveals, Linux opens a mail client or a directory. A sentence reading "Shared" is wrong on every one of them. - [ ] Wire `[RelayCommand]` on the async handler. If the command should only be enabled when the path is known, add `CanExecute = nameof(CanShareXxx)` and evaluate the service + path together. - [ ] Bind in the corresponding view AXAML: `Command="{Binding ShareXxxCommand}"`. - [ ] Wire the hook at the host: `OnTerminalStatus = RouteTerminalStatus` in `MainWindowViewModel`. ⚠ For a **cached** view-model (the two About ones) that goes *inside* the `??=`, or it re-attaches to an instance the tree already replaced. - [ ] Strings come from resx in **all nine** locale files plus `Strings.Designer.cs`. ⚠ In a **shared library** they cannot: add them to `BackupPageText`-style host-supplied text instead, and remember `required` members mean **OpenCodeForge must supply them too**. - [ ] Test stub: `RecordingShareService` (file-scoped in `tests/ClaudeForge.Tests/ViewModels/ShareServiceTests.cs`) records all calls and lets a test drive `NextOutcome`. ⚠ Its default is `Unavailable`, deliberately — a stub that records a payload has shared nothing, so a test asserting success must say which success it arranged. - [ ] Minimum test coverage: `NullService_SaysUnavailable`, `NullPath_CannotExecute` (if path-conditional), `Calls_ServiceWithExpectedPayload`, `Title_MatchesDisplayName`, and **one assertion per outcome** that the right sentence and severity reach the hook. Reference implementations: `EffectiveSettingsViewModel.ShareConfigCommand` (text, six outcomes), `AboutEditorViewModel.ShareLogCommand` and `BackupRestoreViewModel.ShareBackupCommand` (file, via `FileShareStatus`). Guards: `ShareOutcomeTests`, `FileShareStatusTests`. --- ### X = Refactoring a platform check Decision tree: ``` Is this branch about UI / display content (install command preview, About-page platform name, scope colour, path string shown to user)? YES → use PlatformInfo.Current.IsWindows / IsMacOS / IsLinux. Emulation flags will toggle this, which is what you want. NO → is this a platform-intrinsic API (Windows registry, MSIX, env-var Machine scope, macOS Keychain, Linux secret-service)? YES → use OperatingSystem.IsWindows() / IsMacOS() / IsLinux(). Wrap in [SupportedOSPlatform("windows")] guards as needed — the analyzer enforces this for registry / MSIX APIs. Emulation flags MUST NOT redirect these calls. ``` Reference: [`PLATFORM.md`](./PLATFORM.md). ### X = Adding a new `SchemaValueType` branch - [ ] Add the case to the editor-construction dispatch in `src/ClaudeForge/ViewModels/Editors/PropertyEditorFactory.cs` / `DefaultEditorFactory.cs` / `CompositeEditorFactory.cs`. - [ ] Add the case to the JSON-tab placeholder builder (`BuildPlaceholder` switch — search for it; emits a typed stub for the "Show all" toggle). - [ ] Override `ToJsonValue()` for the new editor to emit the right shape. - [ ] Add a regression test in `tests/ClaudeForge.Tests/ViewModels/Editors/PropertyEditorFactoryTests.cs`. ### X = Adding a new bundled-schema property whose `SchemaValueType` is `Complex` / `Object` / `Array` The factory has typed-fallback dispatch helpers that concentrate per-property knowledge. Without explicit dispatch, an unmatched Complex / Unknown property falls through to `JsonRawPropertyEditorViewModel` (safe but raw JSON) — fine as a default, but a typed editor is usually one switch arm and a per-shape VM. - [ ] **Complex (object with no declared properties)** — extend `DefaultEditorFactory.CreateComplexFallback` with a `schema.Name` arm pointing at the right typed VM. Examples live there: `modelOverrides → StringMapPropertyEditorViewModel`. For a string→string map, reuse `StringMapPropertyEditorViewModel` and pass any per-property `keySuggestions` list at construction. For richer shapes, write a new VM and add it. - [ ] **Array** — extend `DefaultEditorFactory.CreateArrayObjectFallback` with a `schema.Name` arm. Examples: `allowedMcpServers/deniedMcpServers → McpServerListEditorViewModel` (3-variant discriminated union), `strictKnownMarketplaces/blockedMarketplaces → MarketplaceListEditorViewModel` (8-variant `source`-discriminated union). - [ ] **Truly unknown** — leave the fall-through to `JsonRawPropertyEditorViewModel`; it parses on every keystroke and refuses to write garbage, so the user can't silently corrupt the property even without a typed editor. - [ ] Regression test in `PropertyEditorFactoryTests` asserting the dispatch returns the expected VM type AND that any injected suggestion lists are populated (e.g. `Complex_ModelOverrides_DispatchesToStringMapEditor` asserts "sonnet" appears in `KeySuggestions`). - [ ] If the new VM aggregates a collection or subscribes to children in its constructor, follow the [editors-AGENTS sidecar](./src/ClaudeForge/ViewModels/Editors/AGENTS.md) `_isLoading` + `MarkModified` contract. ### X = Making a page deep-navigable (implementing `IDeepNavigable`) A page becomes addressable by `--deep-link` and restorable across a reload by implementing `src/AgentForge.Avalonia.Shell/Navigation/IDeepNavigable.cs`. Grammar and nav-tree resolution are already done — see `ViewModels/NavDeepPath.cs`. Reference implementations: `AgentsSkillsEditorViewModel` (tab + item + transient state) and `SettingsGroupEditorViewModel` (tab only, and the one that covers every settings page at once); `EffectiveSettingsViewModel` / `BackupRestoreViewModel` show the minimal index-backed form. - [ ] The node needs a `NodeId` (see the nav-page checklist below). Without one the page can never be addressed. - [ ] Give every in-page tab / segment a **stable id const** plus a `SelectSegment(string id)`-style setter, mirroring `GroupTab.PropertiesId` + `SettingsGroupEditorViewModel.SelectTab`. Do NOT let the external contract be a bare tab index — reordering tabs would silently repoint every saved path. - [ ] `CaptureDeepPath()` returns culture-invariant, persistable segments. **Never a filesystem path** — the separator is `/`, so a path can't survive a round trip. For an item key use `NavDeepPath.FormatItemKey(name, source)`; qualify it so a restore can't land on a same-named item from another scope or plugin. `FormatItemKey` encodes separators inside the *source* via `NavDeepPath.EncodeSource` (`/` → `:`), because a plugin's source IS a path and a raw one blew `MaxSegments`, silently discarding every plugin artifact's remembered position. Compare sources through `EncodeSource` on both sides — it is idempotent, so either spelling resolves. - [ ] Derive the tab segment from the **selected item's own identity** where possible, not from the currently-visible tab index, so the captured pair is self-consistent by construction (`AgentsSkillsEditorViewModel.SegmentIdForCategory`). - [ ] Override `CaptureTransientState()` **only** if the page holds state that must survive an in-process reload but must never be persisted — the canonical case is an unsaved edit buffer on a page that writes files directly (so it isn't part of `HasUnsavedChanges`). Return an immutable record. The default returns `null`. - [ ] `TryRestoreDeepPathAsync` must: select the tab FIRST (landing on the right tab beats landing on the default one even when the item is gone); await any **in-flight** load rather than starting a competing one (expose a `Task? LastRefresh` seam — see the `LastRefresh` docstring for why a second walk is actively harmful); honour `DeepRestoreMode.Locate` by NOT entering an editing experience; and return `false` rather than throwing when the target no longer exists. - [ ] **Implement `ReapplyTab(segments)`** unless a view rebuild genuinely cannot disturb the page's tab. Selecting a nav node rebuilds the view, and that rebuild can land *after* the synchronous part of the restore, leaving the default tab selected — the restore then logs `applied=true` while the user looks at the wrong tab. The host re-asserts once at `DispatcherPriority.Loaded` through this member, so it must be cheap, synchronous and idempotent. It replaced a type test for one concrete VM in `MainWindowViewModel`, which would have silently excluded every page added after it. - [ ] Reveal the target with `ApplyNavigationFilter(...)` (see the invariant above), not by scrolling — plain `ItemsControl` has no `ScrollIntoView`. - [ ] Treat an unrecognised `transientState` as `null` instead of casting blindly. - [ ] Tests: capture/restore round-trip through the STRING form (`NavDeepPath.Format` → `TryParse` → `Resolve`); `Locate` does not enter edit mode while `Full` does; a missing item returns `false` but still selects the tab; the captured path contains no path separators. Templates in `tests/ClaudeForge.Tests/ViewModels/AgentsSkillsDeepPathTests.cs`; a tab-only page's smaller set in `GroupEditorDeepNavigationTests.cs`; the item-key encoding contract in `NavDeepPathSourceEncodingTests.cs`. - [ ] A place-keeping test must close the window **gracefully** (`CloseMainWindow()` / WM_CLOSE). `Stop-Process -Force` skips the window's `Closed` handler, so `CaptureDeepPathForShutdown` never runs and the test reads a stale `~/.claude/cache/ClaudeForge-gui-state.json` — it will "pass" against whatever the previous run left behind. ### X = Adding a new top-level navigation page - [ ] Constants in `src/ClaudeForge/ViewModels/MainWindowViewModel.cs` — `NavTitleXxx` and `NavDescXxx` (search for existing examples like `NavTitleMemory`/`NavDescMemory`). Both go through `Strings.resx`. - [ ] **A `NavIdXxx` const and `NodeId = NavIdXxx` on the node.** This is the culture-invariant key deep links and persisted UI state resolve against; a node without one is permanently unaddressable. Ids must be unique among **SIBLINGS**, not tree-wide (`version-info` legitimately exists under both product headers, and a settings-group name may repeat across products) — the path grammar is `/`. Divider nodes get NO id. Settings-group children derive theirs from `NavDeepPath.Slug(group.Title)`. Guard test: `tests/ClaudeForge.Tests/ViewModels/NavigationNodeIdTests.cs`. - [ ] Add the node directly in `MainWindowViewModel`'s nav-tree construction loop (look for the `// --- Memory & Footprint ---` block — top-level pages are added inline alongside Profiles / Backup / Environment / Memory, NOT through `NavigationTreeBuilder` which is settings-group-specific). - [ ] **Set `IsTopLevel = true`** on the `NavigationNodeViewModel`. Without it, the icon column collapses (no icon renders) and the node looks like a sub-item. Top-level dividers (`new NavigationNodeViewModel("─────") { IsDivider = true, IsTopLevel = true }`) also need both flags. - [ ] **Pick a basic-Unicode icon glyph**, NOT an emoji-presentation one. `★` (U+2605) is good; `⭐` (U+2B50) renders as missing-glyph on Linux without an emoji font. Other working glyphs already in the tree: `⚙` (U+2699), `🖥` (U+1F5A5), `📊` (U+1F4CA), `👤` (U+1F464), `💾` (U+1F4BE), `🌐` (U+1F310), `🧠` (U+1F9E0), `ℹ` (ℹ). Test by setting `--linux` (emulation) and / or running on real Linux. - [ ] **If the page must refresh on arrival, or tidy up on the way out, implement `INavigablePage`** (`src/AgentForge.Avalonia.Shell/Navigation/INavigablePage.cs`). `OnNavigatedTo` fires after the page becomes active — use it for anything read from the filesystem or the OS, which no `Changed` event covers. `OnNavigatedFrom(replaced)` fires on the way out; **check `replaced` before discarding transient state** (a typed filter, a scroll position), because a page that survives a reload is re-attached to a freshly built node and gets the hook with `replaced: false` even though the user never navigated away. Both members have default no-op bodies, so implement only the half you need. ⚠ Before this interface the host switched on each concrete view-model type, so a new page was silently never refreshed — no compiler signal, no symptom but stale content. Guard: `tests/ClaudeForge.Tests/Headless/NavigationPageLifecycleTests.cs`. - [ ] If the page should survive workspace reload (long-running tool VM with state mid-edit, like Backup / Profiles / About / Essentials): cache the VM in a `_xxxVm` field, lazy-init in `BuildNavigationTree`, add it to `IsPersistentToolVm()` so `DisposeNavigationEditors` skips it, and dispose in `MainWindowViewModel.Dispose`. See `_essentialsVm` for the canonical wiring. Add a `GetXxxVmForTesting()` test seam. - [ ] New view + view-model under `src/ClaudeForge/Views/` and `src/ClaudeForge/ViewModels/`. Register the DataTemplate in `App.axaml`'s ``. - [ ] AXAML accent-pill heading pattern — copy from any existing settings page (e.g. `src/ClaudeForge/Views/PermissionsEditorView.axaml`, `MemoryEditorView.axaml`). The pill uses `{DynamicResource NavSelectedAccentBrush}`. - [ ] Resource keys for title / description in `Strings.resx` + `Strings.zh-CN.resx` (with `TODO zh-CN translation` comment) + `Strings.Designer.cs`. - [ ] **Put every user-visible string in `src/ClaudeForge/Localization/Strings.resx`, not in a resource set of its own.** `LocalizationParityTests` resolves that one directory **by hardcoded path**, so a resx anywhere else is invisible to all four parity contracts — no missing-key check, no `TODO`-placeholder check, nothing. That is not hypothetical: the 93 strings in `src/ClaudeForge.Avalonia/Localization/Strings.resx` have **no locale siblings at all** and nothing reports it. If neutral code needs wording, have the app pass it in (see `ClaudeSaveDialogText` / `SaveDialogText`) rather than moving the keys — moving a translated key out of the guarded directory silently un-translates it in eight locales. - [ ] If the page hosts dialog content with titlebars: use `AppIcon.SmallInstance` (64-px render of the small SVG) on the dialog's `Icon`, NOT `AppIcon.Instance` (256-px detailed master). Dialog titlebars scale down — the simplified small SVG reads more clearly. See `AboutDialog.axaml.cs` / `SaveChangesDialog.axaml.cs`. ### X = Adding a new project (`src/` or `tests/`) **Four registrations, not one.** Three guards enforce them, and two of the three fired the first time this was done for `AgentForge.Artifacts` — so this list is measured, not guessed. - [ ] `ClaudeForge.slnx` — under `` or `/tests/`. Guard: `BuildFilePathIntegrityTests.EveryProjectOnDiskIsInTheSolution`. ⚠ A project missing here **never builds in CI at all**, which is silent locally because `dotnet build ` still works. - [ ] ⓘ **No `.slnf` filters on this branch.** They and `SolutionFilterTests` were removed in plans/00003 Phase 0: with one product a filter selects the whole solution, and its guards could no longer fail. ⚠ **Restore both filters and the guard from the parked branch when a second product returns** — a shared project belongs to *every* product's filter, only a product-specific project goes in one, and `FilterIsSharedPlusExactlyOneProduct` failed on **both** filters the first time that was got wrong. - [ ] The test project needs its own entry in all of the above, same rules. - [ ] `Description` in the csproj. This repo uses it as the project's design rationale — see `src/AgentForge.Core/AgentForge.Core.csproj` for the tone. ⚠ Since plan 00001 item 2 it is **also** the nuspec description of any packable project, so it is read by a second audience on the feed page; write it for both, and keep the rationale. - [ ] `InternalsVisibleTo` for the test project if anything is `internal`. - [ ] **`AgentForge.*` may never reference `ClaudeForge.*` or `OpenCode.*`.** No widening needed — `SharedProjectsNeverDeclareAProductReference` globs `AgentForge.*.csproj` across `src/` and `tests/`, so a new project is inside the net automatically. ✅ Verified by canary: pointing `AgentForge.Artifacts` at `ClaudeForge.Sdk.Claude` failed and named the file. - [ ] A test project gets a `Parallelization.cs`. Default to **sequential** unless the tests are pure in-memory with no filesystem, no statics and no process-global seam. Copy the *reasoning*, not just the attribute: `tests/AgentForge.Artifacts.Tests/Parallelization.cs` states why it is safe, and `tests/ClaudeForge.Tests/Parallelization.cs` states why that suite sets `CollectionBehavior(DisableTestParallelization = true)`. A single class that must never run beside another joins `[Collection("DoNotParallelize")]`, defined in `tests/Shared/MessageAssert.cs`. - [ ] **The trim gate names its apps explicitly** in `.github/workflows/ci.yml`. A new *library* needs nothing there, but it is only trim-checked once an app references it — so a library added ahead of its consumers is **not** yet covered by that gate. Say so rather than assuming green. - [ ] **State `IsPackable` explicitly**, `true` or `false`, in the csproj next to ``. Five shared projects under `src/` are published as NuGet packages on GitHub Packages; the SDK defaults a library to **packable**, so a project that says nothing is pushed to the feed by the next release — and GitHub Packages will not let a version be replaced. ⛔ **Those packages are PUBLIC** (they inherit the repository's visibility; measured after the first publish), so an accidentally-packable project is published to the world, not merely to a token-holder. The feed still demands a token for every read, which is a separate property and the one the phrase "private feed" means elsewhere in this file. Guard: `PackageMetadataTests.EverySrcProjectStatesIsPackableExplicitly`, `src/` only. ⚠ It deliberately does **not** check *which* projects pack: that would need a list of the five, and the list is what drifts. - [ ] **A packable project needs a real ``**, which the row above already asks for — but here it also ships. The SDK's default is the literal string `Package Description`, which is what two packages were about to publish. Guard: `PackageMetadataTests.EveryPackableProjectDescribesItself`. - [ ] **A shared library's name must begin `AgentForge.`** — that prefix is how the package-mode reference switch finds it (see *Working with the shared libraries as packages*). A packable project the selector misses fails `PackageMetadataTests`, which asserts the selected set and the packable set are the same set, in **both** directions. ⓘ There is no exact-name exception today. `JsonC` was the one, a general-purpose JSONC reader outside the family, until plans/00007 moved it to its own repository; it is now an ordinary nuget.org `PackageReference`. Prefer a family prefix — an exception costs **four** edits in four uncoupled places: `Directory.Build.targets` (the switch), `PackageMetadataTests`, `AssemblyLayeringTests`, and **`nuget.config`'s `packageSourceMapping`**. ⛔ The fourth is the one the `JsonC` rename missed, and only the package canary catches it: a normal build never asks for these package ids, and the symptom is `NU1101 "no packages exist with this id"` naming only nuget.org while the real feeds sit under *"were not considered"* — which reads like a missing package, not a mapping gap. - [ ] **The csproj file name must equal ``** for anything under `src/`. `src/Directory.Build.props` builds `` as `Bennewitz.Ninja.$(MSBuildProjectName)` — it cannot read `$(AssemblyName)`, which is assigned after that file is imported — so a divergence ships the package under the file's name. Guard: `PackageMetadataTests.EverySrcProjectsAssemblyNameMatchesItsFileName`. - [ ] **Do not add ``** without first writing `` in the same `PropertyGroup`. See §1; three projects declared a TFM that never built. Guard: `SingleTargetFrameworkTests`. - [ ] Line endings **CRLF** and **no BOM** for `.cs`/`.csproj` — match the siblings, and check with `head -c3 | od -An -tx1`. ⚠ Python's `encoding="utf-8-sig"` **adds** a BOM on write; it silently changed six files' first bytes in session 10. ### X = Working with the shared libraries as packages Development mode is the default and needs nothing: `ProjectReference`, no feed, no credentials. Everything here is for the **package** mode the per-PR canary and the release publish use. - [ ] **Run the canary before changing anything about the reference graph.** `pwsh -NoProfile -File scripts/package-canary.ps1` packs this commit at a throwaway timestamped version into `artifacts/localfeed`, then builds, tests and publishes both apps against those packages. `-PackOnly` stops after packing; `-SkipPublish` shortens the loop. - [ ] ⛔ **Never re-use a version.** NuGet extracts by id+version and will not re-extract, so packing the same version twice makes the second run validate the first run's packages and report success. The script defaults to a per-second timestamp **and** points `NUGET_PACKAGES` at a directory named for it. Both, not either. - [ ] ⛔ **Changing a packaged library's PUBLIC API updates its surface baseline in the same commit.** `PublicSurfaceBaselineTests` renders every `IsPackable=true` assembly's exported types and members and diffs them against `tests/ClaudeForge.Tests/Architecture/PublicSurface/.txt`. A mismatch fails and writes a `.txt.actual` beside the baseline — read that, and if the change is deliberate, copy it over the baseline **in the same commit** so a reviewer sees the diff. ⚠ The test never repairs its own baseline: one that did would report a break once and then bless it. ⭐ **Why this exists at all:** a breaking signature change to `IShareService` went through a full green suite of 4,367 tests with nothing noticing, on 2026-09-16. `PublicSurfaceContract Tests` looks like the guard and is not — it is in `AgentForge.Sdk.Tests`, covers one assembly, and enforces house style rather than API shape. ⓘ Nullable annotations are a known blind spot; enum member VALUES are not, so a renumbering fails loudly. - [ ] ⚠ **A new shared library must be named `AgentForge.*` or `LayeredEditors.*`** to be picked up, or be named outright in the switch's exact-name list. The switch in the root `Directory.Build.targets` matches on those selectors, and `PackageMetadataTests` asserts the selected set is exactly the packable set, in both directions, so a project the selector misses fails rather than silently staying a project reference. - [ ] ⛔ **`AssemblyLayeringTests` keeps its OWN copy of the selectors** — `SharedProjectGlobs` for csproj files and a parallel assembly-glob list — and does not share them with the switch. A library that leaves a family therefore drops out of the layering scan, and the vacuity guard does **not** notice, because the remaining family members still satisfy "at least one". That is exactly what the `JsonC` rename would have done unguarded. - [ ] **Credentials for the private feed go in your USER config, never in `nuget.config`:** ```bash dotnet nuget update source github --username --password --store-password-in-clear-text ``` GitHub Actions uses `GITHUB_TOKEN` instead. ⓘ You do not need any of this to build, test or run the repo, and you do not need it for the canary either — that packs into a local folder feed. `nuget.config`'s source mapping is what keeps the private feed from being contacted for `Avalonia` or `Serilog`, which is what would otherwise 401 a credential-free clone. ### X = Pushing more than two refs at once (bulk branch or tag work) ⛔ **This remote caps a push at TWO refs.** Deleting or updating three or more branches or tags in one `git push` is refused, every ref in the push, with a summary line that names no limit and reads like a permissions failure: ``` ! [remote rejected] (push declined due to repository rule violations) ``` - [ ] **Read the `remote:` lines, which carry the only useful text** — `git push … 2>&1 | grep '^remote:'` prints *"Pushes can not update more than 2 branches or tags."* Wrappers routinely swallow these. - [ ] ⚠ **Do not conclude "no rules exist" from the API.** `gh api repos/JanusMael/ClaudeForge/rulesets` returns `[]` because the rule is **inherited**, not repo-local. Ask the push, not the endpoint. - [ ] **Chunk at two, and verify each chunk's exit code.** A native command's failure does not throw under `$ErrorActionPreference = 'Stop'`; test `$LASTEXITCODE` or the script reports success over a no-op. - [ ] ⛔ **In PowerShell pass `$array`, never `@array`.** The `@` sigil is *splatting* and applies to cmdlets and functions only — against `git.exe` it passes nothing at all, so the push silently does nothing and still prints whatever your script prints next. - [ ] **Verify against the remote, not a local cache.** `git ls-remote --heads origin` is the answer; a stale remote-tracking ref names a branch that is already gone, and deleting a missing ref fails the whole push. ### X = Hand-porting a fix between this branch and `main` A cherry-pick is impossible across the Phase-1 renames — `ClaudeForge.Core` → `AgentForge.Core`, `ClaudeForge.Sdk` → `ClaudeForge.Sdk.Claude`. The feature branch's paths do not exist on `main`, and a pick drags the whole rename across. So ports are hand-authored, and that is where things go wrong. - [ ] **Confirm the source file is otherwise identical to the destination's**, before the fix, modulo namespace. If it is not, you are porting a fix *and* a divergence and should stop. - [ ] ⛔ **Move the bytes, never the decoded text.** `git format-patch` + `git am`, `git cherry-pick`, or a shell-redirected `git show ref:path > file` are byte-exact. A script that captures `git show` output into a variable is **not** — see [Capturing child-process output](#capturing-child-process-output-that-may-contain-non-ascii). This corrupted 41 sequences across four files in 2026-09. - [ ] **Verify byte-exactness, not just a green build.** `grep -c '—'` on source and destination must match, and `grep -rlP '\xce\x93[\xc2-\xc3]'` must print nothing. The suite cannot see this class of damage. - [ ] **Check line endings and BOM survived**: `head -c3 | od -An -tx1` (no `ef bb bf`) and CRLF counts matching the destination's siblings. - [ ] **Build and test on the destination's own layout**, in a worktree off `origin/main` — not on the feature branch and not by assumption. - [ ] **One PR per concern, not per port run.** Two fixes with different risk profiles get two PRs, so the riskier one cannot ride in on the safer one's back. ### X = `OpenCodeDatabaseSchemaTests` went red (OpenCode's database schema moved) **This is the alarm working, not a chore.** A Phase-14 `Full` backup includes `opencode.db` behind an opt-in with an advisory that the archive **contains credentials**, and `Sanitized` mode excludes it. Both rest on a claim about what that file holds, and `OpenCodeSecretColumns` is a snapshot of **upstream's** schema — so a stale one means the app is telling the user something untrue about their own backup. - [ ] `pwsh -NoProfile -File scripts/refresh-opencode-db-schema.ps1` — captures from the live install. Needs `sqlite3` (`winget install SQLite.SQLite`) and an existing `opencode.db`. - [ ] `git diff OpenCode.Sdk/Assets/OpenCodeDatabaseSchema.json` — **read it.** This diff is the review the guard exists to force; everything else here is bookkeeping. - [ ] Decide, per new column, whether it carries secret material. ⚠ **The name is not enough** — the most sensitive column in the database is `credential.value`. - [ ] Add a genuine new secret to `OpenCodeSecretColumns.ByTable`. Add a false alarm to `KnownNonSecrets` in the test **with a stated reason** (`session.tokens_input` is an LLM usage counter; `account.token_expiry` is a timestamp, not a credential). - [ ] ⚠ **If the credential tables ever go away entirely**, that is the other direction and matters just as much: the backup would be warning about a file that no longer holds secrets. Revisit the advisory and the `Sanitized` exclusion rather than only the constant. - [ ] **Only then** update `OpenCodeSecretColumns.ExpectedTableColumnDigest`. ⛔ Bumping that constant to get to green is the single way to defeat this whole mechanism. - [ ] Re-run; confirm green. ⚠ **The digest test fires on ANY table or column change**, including ones with nothing to do with secrets. That is deliberate and not a defect to narrow: it has to fire on a column nobody has classified yet, so it cannot be filtered by a name pattern of its own. ⓘ No test reads a live database — CI has no OpenCode install, and a guard that skips is a guard that never fires. Capture happens on a maintainer's machine; the suite guards invariants over the committed artifact. --- ## 3. Test seam quick-reference ### `PlatformPaths.TestUserProfileOverride` sandbox Every test that reads or writes anything path-relative MUST scope the writes to a temp dir. ```csharp private readonly string _sandbox; public MyTests() // xUnit: a fresh instance per test, so the constructor is set-up { _sandbox = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_sandbox); Directory.CreateDirectory(Path.Combine(_sandbox, ".claude")); PlatformPaths.TestUserProfileOverride = _sandbox; } public void Dispose() // ...and Dispose is clean-up (the class implements IDisposable) { PlatformPaths.TestUserProfileOverride = null; if (Directory.Exists(_sandbox)) Directory.Delete(_sandbox, recursive: true); } ``` Live example: `tests/ClaudeForge.Tests/ViewModels/HasUnsavedChangesRecheckTests.cs`. ### `PlatformPaths.TestSuppressClaudeCodeBinaryProbe` switch `TestUserProfileOverride` does not reach `PlatformPaths.TryFindClaudeCodeBinary`: its PATH probe reads the real process environment, and the Unix system-wide entries in `CanonicalClaudeCodeCandidates` are absolute paths. A test that needs the "Claude Code not detected" state on a machine with the CLI installed (anything asserting on `MainWindowViewModel.ShowInstallBanner`, or on `PlatformPaths.IsClaudeCodeInstalled` falling through to its `settings.json` check) sets the switch for the duration of the test: ```csharp PlatformPaths.TestSuppressClaudeCodeBinaryProbe = true; try { /* assertions */ } finally { PlatformPaths.TestSuppressClaudeCodeBinaryProbe = false; } ``` It is checked before the process-lifetime caches, so `InvalidatePathCache()` is not needed afterwards. `internal`, `AsyncLocal`-backed, exposed to both test projects via `InternalsVisibleTo`. Live example: `InstallBanner_AutoClearsDismissedFlag_WhenProductAppears` in `tests/ClaudeForge.Tests/ViewModels/HasUnsavedChangesRecheckTests.cs`; the switch's own contract test is `TryFindClaudeCodeBinary_ProbeSuppressed_ReportsNotFoundWithoutCachingTheMiss` in `tests/AgentForge.Core.Tests/Platform/ClaudeCodeDetectionTests.cs`. ### `MainWindowViewModel.GetClaudeCodeWorkspaceForTesting()` test seam For tests that need to mutate the workspace directly without driving the UI: ```csharp var vm = new MainWindowViewModel(new SchemaRegistry(), new NullDialogService()); await vm.InitializeCommand.ExecuteAsync(null); var workspace = vm.GetClaudeCodeWorkspaceForTesting(); Assert.IsNotNull(workspace); workspace!.SetValue("model", JsonValue.Create("opus")!, ConfigScope.User); Assert.IsTrue(vm.HasUnsavedChanges); ``` The seam is `internal` and exposed to `ClaudeForge.Tests` via `InternalsVisibleTo`. Declared on `MainWindowViewModel`. ### `DebugFlags.ResetForTesting()` Static state isolation between tests: ```csharp public void Dispose() => DebugFlags.ResetForTesting(); // class : IDisposable ``` Internally it also calls `PlatformInfo.ResetForTesting()`, so a single call covers both. ### `PlatformInfo.ResetForTesting()` / `OverrideForDebug()` Direct platform emulation in a test: ```csharp PlatformInfo.OverrideForDebug(EmulatedPlatformInfo.ForId("linux")); try { /* assertions */ } finally { PlatformInfo.ResetForTesting(); } ``` Source: `src/AgentForge.Core/Platform/PlatformInfo.cs`. ### `LiveLogWindow.RebuildWindowForTesting(...)` / `LiveTailWindow.WindowForTesting` `LiveLogWindow.Initialize` latches on its first call, and its header links exist only when a log-file path, a logs directory, or a launch action was supplied. A headless test that needs the full window rebuilds it explicitly and gets the `Window` back: ```csharp Window window = LiveLogWindow.RebuildWindowForTesting(() => sink.CurrentFilePath, logsDirectory, "Events", () => { }); ``` When the argument-less window is enough, `LiveLogWindow.WindowForTesting` returns whatever `Initialize` (or the last rebuild) latched — `LiveLogWindowKeyTests` uses it. `LiveTailWindow` is an instance; `tail.WindowForTesting` returns the window it built. All of these are UI-thread only — dispatch a synchronous body through `HeadlessUnitTestSession.Dispatch` (an awaiting body is inert; see §1). Live example: `tests/ClaudeForge.Tests/Diagnostics/AccessibilityCoverageTests.cs`. ### Force-fire fired-count assertion (the force-fire contract test) The pattern that locks the force-fire invariant in place: ```csharp [Fact] public void RemovingXxxAfterLoad_FiresIsModifiedPropertyChanged() { // Arrange: load a populated scope so IsModified starts true. var vm = new MyEditorViewModel(SchemaRegistry.Empty, ConfigScope.User); vm.LoadFromLayered(LayeredWith(ConfigScope.User, populatedJsonObject), ConfigScope.User); Assert.True(vm.IsModified, "Precondition: load must leave IsModified=true."); var fired = 0; vm.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(MyEditorViewModel.IsModified)) fired++; }; // Act: simulate the user mutation. vm.MyCollection.RemoveAt(0); // Assert: the force-fire pattern emitted PropertyChanged even though // IsModified was already true — that's what wakes the live-write chain. Assert.True(fired >= 1, "PropertyChanged(IsModified) must fire on user remove, even though " + "the flag was already true from the load."); } ``` Live examples in `tests/ClaudeForge.Tests/ViewModels/Editors/McpServersEditorViewModelTests.cs`: `RemoveServerAfterLoad_FiresIsModifiedPropertyChanged` and `AddServerAfterLoad_FiresIsModifiedPropertyChanged`. ### `RestoreEngine` internal-static seams The eight `internal static` methods in `src/AgentForge.Core/Backup/RestoreEngine.cs` are test seams (callable via `InternalsVisibleTo("AgentForge.Core.Tests")`): | Method | What it tests | |---|---| | `ResolveSafeExtractPath(baseDir, entryFullName)` | Zip-slip defence: traversal / absolute path / ADS / containment check | | `IsUnderUserProfile(candidate)` | Security predicate gating manifest-provided paths (UNC reject, malformed reject, equality vs. startswith branches) | | `RestoreSection(srcFile, destFile, stamp)` | Single-file restore + `.pre-restore-{stamp}.bak` sidecar | | `RestoreDirectory(srcDir, destDir, stamp)` | Recursive directory restore + per-file containment re-check | | `RestoreProjects(tempRoot, manifest, stamp, skipped, fileFailures)` | Manifest-driven projects subtree restore + `IsUnderUserProfile` gate | | `RestoreWorktrees(tempRoot, stamp, skipped, fileFailures)` | Worktree-metadata-driven restore + `IsUnderUserProfile` gate | | `EvictOldSidecarsIfNeeded(liveFile)` | Sidecar cap (3 per file, evict oldest at write time) | | `ContainsRedactedMarker(tempRoot)` | Tamper detection — scans extracted `*.json` for the `[redacted]` literal | All exercised by `tests/AgentForge.Core.Tests/Backup/RestoreEngineTests.cs`. When refactoring, prefer keeping these `internal` rather than `private` — a future contributor adding a test for a private static would otherwise reach for reflection. Tests under-user-profile paths (RestoreProjects / RestoreWorktrees happy-path) use a `CreateUnderUserProfile(suffix)` helper that drains via the `_underProfileCleanup` queue in `Teardown` to avoid leaking real home-directory subtrees. ### `SchemaRegistry` overlay-merge seams Two `internal static` methods in `src/AgentForge.Core/Schema/SchemaRegistry.cs` lock the overlay-merge plumbing (see CLAUDE.md "Schema loading priority"): | Method | What it tests | |---|---| | `TryReadBundledBytesMerged(cacheFileName)` | Production E2E path — reads base + applies sibling `.overlay.json` | | `ApplyMergePatch(target, patch)` | RFC 7396 unit semantics — primitive replace, recursive object merge, null-deletes-key, array wholesale replace, primitive patch replaces object target, null target with object patch, key-order preservation | Both exercised by `tests/AgentForge.Core.Tests/Schema/SchemaRegistryOverlayTests.cs`. Adding a new bundled schema with hand-curated additions: create the base file + sibling `.overlay.json` under `src/AgentForge.Core/Assets/Schemas/` (the existing `EmbeddedResource Include="Assets\Schemas\**\*.json"` glob picks both up automatically); the loader merges them at load time. ### Draining fire-and-forget work before deleting a sandbox Two hops of deliberately unawaited work can outlive the test that started them and race the test's clean-up `Directory.Delete`, which on Windows fails with *"the process cannot access the file `claude-code-settings.json`"*. Both are now observable; a fixture that triggers either MUST drain it before removing its sandbox. | Seam | Covers | |---|---| | `MainWindowViewModel.LastAutomaticReload` | The reload kicked by an automatic trigger — `OnSelectedProfileChanged` (so: any assignment to `SelectedProfile`) and the `ConfigFileWatcher` fire. Both call `ReloadCoreAsync` without awaiting it. | | `SchemaRegistry.WhenDiskCacheIdleAsync()` | The disk-cache sync `GetSchemaAsync` starts for a bundled schema. It is a cache warm kept off the startup path, so it is never awaited in production. | Order matters and is one-directional: the reload is what *starts* the sync, so await the reload first or the sync snapshot is taken before the work exists. ```csharp public async ValueTask DisposeAsync() // class : IAsyncDisposable { if (_vm.LastAutomaticReload is { } reload) { // Wait for it to FINISH, not to succeed; reading Exception marks a fault observed. await reload.ContinueWith(static t => _ = t.Exception, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); } await _schemaRegistry.WhenDiskCacheIdleAsync(); _vm.Dispose(); _schemaRegistry.Dispose(); PlatformPaths.TestUserProfileOverride = null; TestCleanupHelpers.DeleteDirectoryWithRetry(_sandbox); } ``` Hold the `SchemaRegistry` in a field rather than inlining it into the view-model constructor, or there is nothing to await. `WhenDiskCacheIdleAsync` returns a snapshot and never faults. `DeleteDirectoryWithRetry` stays as the backstop for the `FileSystemWatcher` handle, which no seam covers; it is best-effort and warns rather than throwing. Live example: `tests/ClaudeForge.Tests/ViewModels/AvailableProfileEntriesTests.cs`. ### Injected `TimeProvider` instead of a static clock seam Two classes take a `TimeProvider` so a test advances a clock rather than sleeping. Both keep a parameterless-equivalent overload that supplies `TimeProvider.System`, so no production callsite passes one. | Class | Constructor | What becomes assertable | |---|---|---| | `StatusController` | `StatusController(TimeProvider, Action? dispatch = null)` | Auto-clear dwell per severity — a warning's dwell is *longer* than a success's, which the old `DelayOverride` seam made indistinguishable | | `MainWindowViewModel` | `MainWindowViewModel(SchemaRegistry, IDialogService, IShareService? = null, TimeProvider? = null)` | The post-save watcher-suppression window, both halves | For the window, drive the pair of seams rather than the watcher: `StampSelfWriteSuppressionWindow()` opens it and `IsWithinSelfWriteSuppressionWindow()` reads it, so no dispatcher is needed — the watcher callback itself posts to `Dispatcher.UIThread` and is not reachable from a plain unit test. `SelfWriteSuppressionWindow` is the named duration; assert against it rather than restating the number. The comparison is strict, so the deadline instant is already *outside* the window. `FakeTimeProvider` comes from `Microsoft.Extensions.TimeProvider.Testing`, referenced in `tests/Directory.Build.props` for every test project. Live examples: `tests/ClaudeForge.Tests/ViewModels/SelfWriteSuppressionWindowTests.cs` and `tests/ClaudeForge.Tests/ViewModels/Status/StatusControllerTests.cs`. **A `FakeTimeProvider` does not advance on its own**, so any `Task.Delay` routed through it — the backup-state debounce (`BackupStateSaveDebounce`) and the update-recheck poll — stays pending for the whole test unless the test advances past it. Both are fire-and-forget and cancelled on `Dispose`, so leaving them pending is fine; expecting them to fire without an `Advance` is not. ### `LayeredWithXxx(scope, jsonObj)` builder helpers Most editor tests need a `LayeredValue` with one or two scope entries. The convention is a private helper: ```csharp private static LayeredValue LayeredWith(ConfigScope scope, JsonNode value) => new("myKey", new[] { new ScopeEntry(scope, value, "/test/path") }) { EffectiveValue = value, EffectiveScope = scope, }; ``` Pattern visible in: `tests/ClaudeForge.Tests/ViewModels/Editors/McpServersEditorViewModelTests.cs`, `PermissionsEditorViewModelTests.cs`. Search for `LayeredWith` to find existing helpers in any new test file you write. --- ## 4. Anti-patterns (side-by-side) ### Bare `IsModified = true` when the flag is already true ```csharp // WRONG — silently elided when IsModified was already true from the load. private void OnSomethingChanged() => IsModified = true; ``` ```csharp // RIGHT — force-fire pattern. private void MarkModified() { if (_isLoading) return; if (IsModified) OnPropertyChanged(nameof(IsModified)); else IsModified = true; } ``` What breaks: the live-write to disk and the Save-button-enable chain are both subscribed to `PropertyChanged(IsModified)`; an elided assignment means neither fires. Locked by `RemoveServerAfterLoad_FiresIsModifiedPropertyChanged`. ### Reflection-based `JsonSerializer` ```csharp // WRONG — IL2026 in trimmed builds; runtime crash in AOT. var state = JsonSerializer.Deserialize(json); ``` ```csharp // RIGHT — source-generated context. var state = JsonSerializer.Deserialize(json, AppJsonContext.Default.WindowState); ``` What breaks: published Release builds (`PublishTrimmed=true`) emit IL2026, and the deserializer fails at runtime because the property metadata was trimmed. See [`TRIMMING.md`](./TRIMMING.md). ### `Environment.GetFolderPath` not honoring the test override ```csharp // WRONG — goes around TestUserProfileOverride, touches the developer's real ~/. private static readonly string ConfigPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "settings.json"); ``` ```csharp // RIGHT — route through PlatformPaths so the test sandbox applies. private static string ConfigPath => Path.Combine(PlatformPaths.UserProfile, ".claude", "settings.json"); ``` What breaks: tests pollute the developer's real Claude config; CI is fine but local runs leave artefacts behind. The `=>` (property) instead of `=` (field) is the second half of the next anti-pattern. ### `RuntimeInformation.IsOSPlatform` on a UI / display surface ```csharp // WRONG — UI shows the host OS's install command even when --linux is set. if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) InstallCommand = "winget install ..."; ``` ```csharp // RIGHT — emulation-aware. if (PlatformInfo.Current.IsWindows) InstallCommand = "winget install ..."; ``` What breaks: the `--linux` debug flag (and the equivalent for macOS) is meant to preview cross-platform UI without rebooting. Using the runtime check defeats it. The reverse applies: registry / MSIX call sites MUST use `OperatingSystem.IsWindows()` because they cannot run on Linux regardless of emulation. ### `static readonly` capturing host state at type-init ```csharp // WRONG — captured at type init, before the test's constructor runs. private static readonly string StatePath = Path.Combine(PlatformPaths.ClaudeHome, "cache", "ClaudeForge-gui-state.json"); ``` ```csharp // RIGHT — recomputed on every access. Cheap (3 Path.Combine calls). private static string StatePath => Path.Combine(PlatformPaths.ClaudeHome, "cache", "ClaudeForge-gui-state.json"); ``` What breaks: tests that run BEFORE the type is referenced see the override; tests that run AFTER see the cached real-host path. Order-dependent failures — passes alone, fails in suite. Live fix: `src/ClaudeForge/Services/WindowStateService.cs`. ### Bare `catch { }` ```csharp // WRONG — swallows OutOfMemoryException, ThreadAbortException, etc. try { Save(state); } catch { } ``` ```csharp // RIGHT — filter exception types you actually expect. try { Save(state); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { Log.Warning(ex, "Failed to save state"); } ``` What breaks: real bugs become invisible. Existing convention in CLAUDE.md "Key conventions". Pattern visible in `WindowStateService.Load/Save/Delete`. ### Capturing child-process output that may contain non-ASCII ```powershell # WRONG — PowerShell decodes the child's stdout through [Console]::OutputEncoding, # which on a Windows console is OEM CP437. UTF-8 E2 80 94 (—) comes back as the # three chars Γ Ç ö, and writing that out as UTF-8 DOUBLE-ENCODES it. $text = & git show "$ref`:$path" Set-Content -LiteralPath $dest -Value $text -Encoding utf8NoBOM ``` ```powershell # RIGHT — read raw bytes; never let a console code page see them. $psi = [System.Diagnostics.ProcessStartInfo]::new('git') $psi.ArgumentList.Add('show'); $psi.ArgumentList.Add("$ref`:$path") $psi.RedirectStandardOutput = $true; $psi.UseShellExecute = $false $proc = [System.Diagnostics.Process]::Start($psi) $buf = [System.IO.MemoryStream]::new() $proc.StandardOutput.BaseStream.CopyTo($buf); $proc.WaitForExit() # Strict UTF-8: a bad decode THROWS instead of silently substituting U+FFFD. $text = [System.Text.UTF8Encoding]::new($false, $true).GetString($buf.ToArray()) ``` ```powershell # ACCEPTABLE when you must capture — the line already in packaging/Resubmit-Winget.ps1. [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 ``` ⛔ **What breaks: nothing you can see.** Mojibake inside an XML doc comment compiles cleanly, no analyzer objects, and the whole suite stays green — **a passing suite is not evidence here.** It only becomes visible when a corrupted literal reaches a user, such as a Serilog message or a manifest field. ⚠ **This repo has been bitten twice, in two different surfaces.** `packaging/Resubmit-Winget.ps1` (search for *"don't again"* — it moved when the script was renamed, and a line number in prose goes stale the first time anyone edits above it) carries the fix and the note *"Shipped that way in 2026.3.810; don't again"* — mangled em dashes reached a **published winget manifest**. It happened again in 2026-09 when a script hand-ported files between branches and double-encoded **41 sequences across four files**, which reached `main` through two merged PRs before a reviewer caught it. ⭐ **Filing it under "winget" is why it recurred** — the precedent did not fire for someone whose task was "port a file". It is a **console-decoding** defect, not a packaging one. **Detect it** — a leading `Γ` on a run of Latin-1 punctuation is the signature. Expect zero: ```bash grep -rlP '\xce\x93[\xc2-\xc3]' --include=*.cs src/ tests/ ``` ⚠ `packaging/Resubmit-Winget.ps1` and `.github/workflows/winget-submit.yml` legitimately contain `ΓÇö` because they *document* it. Do not "fix" those. **Verify a port byte-for-byte** rather than trusting a build: count a distinctive non-ASCII character on both sides — `grep -c '—' ` — and require equal counts plus zero `Γ`. --- ## 5. Verify-before-shipping checklist ```bash # 1. Build clean. dotnet build # Expected: 0 errors, 0 warnings. # 2. Tests green. dotnet test --no-build # Expected: 0 failed. The total / skip counts drift per release; the green # baseline is whatever the most recent successful run on main reported. # 2b. The path guard now asks GIT, not your filesystem, so there is no manual # sweep to remember. BuildFilePathIntegrityTests requires every hardcoded # repo-relative path in a build file to be BOTH present on disk AND tracked # by git, which means a doc or comment naming a build output reddens here # exactly as it does on a CI runner. # ⛔ The advice this replaces was `find src tests -type d -empty`, and it # could not see the defect that bit three times: the directory was FULL of # files locally, merely untracked. Emptiness was never the property that # mattered; "survives a fresh clone" is. # ⓘ A path you have only just created reddens until you `git add` it. That # is correct rather than annoying — CI cannot see it either. git status --porcelain --ignored=no # untracked files a fresh clone will not have # Expected: nothing here that a build file already points at. # 3. Trim-safe publish — required after touching anything reflection-y, JSON # serialization, AXAML DataTemplate/UserControl, or third-party deps. pwsh src/publish/publish.ps1 -All -Rids win-x64 # Expected: 0 IL2026 / IL2070 / IL3050 warnings. # Repeat across RIDs you actually need to verify (the script handles all six # in one invocation when called with -All and no -Rids restriction). # Reference: TRIMMING.md. # 4. Manual smoke for UI changes: # - Launch the app, verify no startup exception in Serilog output. # - Click through Claude Code → General → MCP → Permissions → Hooks → Plugins → Marketplaces. # - For each compound editor: edit a value, confirm Save enables; click # Reset, confirm Save disables. # - Toggle Effective and JSON tabs on at least one page. ``` --- ## 6. Claims about code that nothing checks One shape, four ways in: **a statement about the system that no mechanism can contradict.** Every row below has been paid for at least twice — once here, and once in an unrelated WPF→Avalonia port (TailBlazer, `feature/UpgradeToNet10`, unpushed; cited as narrative because there is no remote to link). ⭐ §1 already bans two special cases of this — *"a guard's comment is not evidence the guard covers what it says"* and *"`Session.Dispatch(async () => …)` in a headless test CANNOT FAIL"*. This section is the general form, and those two rows remain the canonical instances. **Read them first; these rules exist because that pair was not enough on its own.** | Invariant | Failure signature if you break it | Canonical source | |-----------|-----------------------------------|------------------| | **A conditional written in the future tense stops being read once its condition is met.** "When X happens, do Y" is a plan, and nothing re-evaluates a plan. Write the condition as a check, or as a defect with a date — never as an intention. | Nothing is wrong at the line; the world moved and the line did not. `ci.yml` and `package-canary.ps1` both asserted the release published from the shared packages — **false for every release ever cut**, and defended only by the comments making the claim. In the port: two files said the container would move up *"when the shell gains tabs"*, and the shell had had tabs for a fortnight while the stale arrangement silently blocked a feature. | **The `ci.yml` fix is the model**: a build-time guard (`GuardShippingPublishUsesPackages`), *plus* rewording both comments to say the claim was false when written rather than deleting them. Where no check is possible, the comment carries a **date and a named condition**, so a later reader can tell whether it has expired. A comment that cannot expire visibly is the thing being banned. | | **A registration is not a caller.** An interface can have an implementation and a container registration on every side and still have **zero callers** on one. Grep for the **caller**, not the interface — and treat each half of a shared type's doc comment as a separate claim. | Every check that looks like a check passes: the grep finds it registered in both roots, the container resolves it, the composition-root test is green. The feature does not crash and shows no wrong value — it behaves as though its input never changes. Here: `SchemaRegistry` resolved perfectly and was constructed **OFFLINE by default**, so the shipped app validated against bundled schemas for a whole phase; `FootprintService` resolved perfectly and reported Claude's seven categories as OpenCode's, where a delete would have taken the other agent's data. In the port: a window-activation publisher had two implementations, two registrations and **no caller on one side for a fortnight**, defaulting to `true` — so "alert me while I'm in another window" behaved as if the window were always focused. | **`ProductionSchemaRegistryTests` is the existing instance of the right shape** — a *source* scan, and it had to be, because a registry deliberately does not expose whether it holds a client. ⛔ **There is no generic guard, and pretending otherwise is how this rule fails:** "grep for the caller" is a review habit, not a mechanism. The enforceable narrowing is **a seam whose implementation is *wiring* — an event hook, a subscription — earns its own named source-scanning test the moment it is written**, because no file's absence is conspicuous. | | **A correction that lands in one document does not reach the code it corrects** — and a correction naming a guard is not finished until that guard exists. | Three layers with no contact between them, all present in this repo when this row was written. `AgentForge.Core/Schema/SchemaDiskCache.cs` asserted in the **present tense** that "OpenCodeForge builds three registries at launch"; [`CLAUDE.md`](./CLAUDE.md) carried a dated correction saying exactly that (*"That was accurate when written"*); and the `SharedSchemaRegistryTests` that correction names **does not exist on this branch** — `find` returns nothing. ⚠ **Severity, precisely: a documentation defect, not a behavioural one.** The comment was the rationale for a per-artifact-path semaphore and **the semaphore was always correct**. Overclaiming it would be the same failure this section bans. ⛔ It shipped: `AgentForge.Core` is one of the eleven published packages. | **The correction goes at the site of the claim**; a copy elsewhere points *to* it, never the reverse. **Before naming a guard in a correction, `find` it** — a named test that does not exist reads exactly like one that does. A claim about a component on a branch this one cannot see is not checkable at all: give it a date and the branch name so the merge can re-run the row above over it. | | **A test's name, its remark, and its actual discriminating power are three different things, and only the third protects anything.** | A test that passes for a reason other than the one it advertises, or whose reach is narrower than its name. `AxamlAccessibilityCoverageTests` silently scanned **one hardcoded directory** and missed every AXAML file outside it. In the port: of three tests written for one fix, the remark claimed a particular one would have caught the original defect — **it would not have**; it resolved twice from one container and was always green, while the bug was two containers. | **Break the thing and watch the test fail, on both legs, before writing any remark about what it catches** — the §1 canary discipline, applied to the remark as well as the assertion. Where *reach* is the risk, **a sibling test asserts the scope of the first**: `AxamlScan_CoversEveryUiProject_NotOneHardcodedDirectory` is exactly that, and it is why narrowing the scan again turns something red. When a remark turns out to be wrong, **correct it in the file** rather than quietly — the wrong claim is worth more as a warning than as a tidy sentence. | ### Two distinctions, because these rules overreach without them **A claim about what another component DOES decays; a record of why a decision WAS MADE does not.** Seven files under `src/` reference `OpenCodeForge`, and only **two** were the shape above. The rest are history (*"was `ClaudeForge.Controls.TipCell` until OpenCodeForge grew a grid of its own"*), past-tense rationale (`GithubReleaseChecker` on why `/releases/latest` was abandoned; `SearchResultViewModel` on what UIA announced before the fix), a placement argument, and `BackupPageOptions` documenting that `"opencode"` is a **value a host supplies** to a `required` property — API documentation illustrated by both hosts, not an assertion about either. ⛔ **Deleting those would delete the reasons things are the way they are.** The test that separates them: does the sentence describe what another component **does**, or what a caller must **supply**? **A count is a claim.** The "seven files" above began as a filename-level grep whose matches were never read; reading them split the seven two ways and produced a far sharper instance than the number had. ⭐ **Re-derive a count, never copy one** — and a total that reconciles is not evidence that the rows behind it do. --- ## 7. Pointer index — which doc owns which concern | Concern | Owning doc | |---------|------------| | Why these agent docs exist; methodology rationale | [`AGENT-ONBOARDING.md`](./AGENT-ONBOARDING.md) | | Architecture decisions, build/run, gotchas | [`CLAUDE.md`](./CLAUDE.md) | | Platform abstraction, debug flags, `PlatformInfo` decision tree | [`PLATFORM.md`](./PLATFORM.md) | | Trimming, `PublishTrimmed`, ILLink, IL2026 diagnostics | [`TRIMMING.md`](./TRIMMING.md) | | Avalonia / .NET 10 foot-guns: style precedence (LocalValue beats Style), `Styles` scoping, `DataTemplate` order, virtualization, TextWrapping, tooltip propagation, lifetime, JsonArray.Add | [XamlQuality's `avalonia-gotchas.md`](https://github.com/JanusMael/Bennewitz.Ninja.XamlQuality/blob/main/docs/avalonia-gotchas.md) | | Linux desktop integration: X11 vs Wayland, `.desktop` file install, icon themes | [`docs/LINUX-DESKTOP-INTEGRATION.md`](./docs/LINUX-DESKTOP-INTEGRATION.md) | | Essentials-page card list, severity tiers, add-a-card checklist | [`docs/ESSENTIALS-PAGE.md`](./docs/ESSENTIALS-PAGE.md) | | Localized-string workflow (`Strings.resx` + Designer + `{x:Static}`) | [`LOCALIZATION.md`](./LOCALIZATION.md) | | Build / test / PR workflow, contributor setup | [`CONTRIBUTING.md`](./CONTRIBUTING.md) | | CI / release workflow reference, publish.ps1 wiring | [`.github/WORKFLOWS.md`](./.github/WORKFLOWS.md) | | **Text corruption when a script captures process output** (UTF-8 double-encoded via OEM CP437 — `—` becomes `ΓÇö`); hand-porting files between branches | §4 *Capturing child-process output*, §2 *Hand-porting a fix* — **both in this file**. ⚠ Listed here by MECHANISM on purpose: the fix already existed in `packaging/Resubmit-Winget.ps1`, filed under winget, and did not fire for someone whose task was "port a file" | | Public-facing description, install instructions, feature list | [`README.md`](./README.md) | | Compound-editor contract: force-fire, `_isLoading`, child subs, parity table | [`src/ClaudeForge/ViewModels/Editors/AGENTS.md`](./src/ClaudeForge/ViewModels/Editors/AGENTS.md) | | Workspace / scope semantics: `ConfigScope` order, `IsDirty` vs `HasActualChanges`, merge rules | [`src/AgentForge.Core/Settings/AGENTS.md`](./src/AgentForge.Core/Settings/AGENTS.md) | | SDK architecture: what the SDK has/doesn't have, `_suppressForwarder`, `_cachedSchemaNodes`, `Changed` threading, `SearchSchema`, test seams | [`src/AgentForge.Sdk/AGENTS.md`](./src/AgentForge.Sdk/AGENTS.md) | | ViewModel layer: MWVM integration hub, nav tree structure, the search seam (`ClaudeSyntheticSearch` here, machinery in the shell), specialized editors, JsonPath→NavNode mapping, deep-path capture/restore | [`src/ClaudeForge/ViewModels/AGENTS.md`](./src/ClaudeForge/ViewModels/AGENTS.md) | | Deep linking: `NodeId` identity, path grammar, `--deep-link`, reload restore, `Locate` vs `Full`, tab re-assert, item-key source encoding | `src/AgentForge.Avalonia.Shell/Navigation/NavDeepPath.cs`, `IDeepNavigable.cs`; wiring in `MainWindowViewModel` (`CaptureDeepPath`, `TryQueueDeepRestore`, `ApplyPendingDeepRestore`); §2 checklist above | | YAML front matter: which tokens the editor supports, which round-trip verbatim by design, the round-trip and block-shape contracts | [`docs/YAML-FRONT-MATTER.md`](./docs/YAML-FRONT-MATTER.md); `src/AgentForge.Sdk/Memory/YamlFrontMatter.cs`, `FrontMatter.cs` | | Save / restore confirmation dialog: diff projection, path shortening, value truncation, per-mode wording | `src/AgentForge.Avalonia.Shell/Save/` (`SaveDialogBuilder.cs`, `SaveChangesDialogViewModel.cs`, `SaveDialogText.cs`); this app's wording in `src/ClaudeForge/ViewModels/ClaudeSaveDialogText.cs`; the view stays in `src/ClaudeForge/Views/SaveChangesDialog.axaml` | | Page navigation lifecycle: arrive / leave hooks, the `replaced` flag, why a type switch was the wrong shape | `src/AgentForge.Avalonia.Shell/Navigation/INavigablePage.cs`; dispatch in `MainWindowViewModel.OnSelectedNodeChanged`; guard `tests/ClaudeForge.Tests/Headless/NavigationPageLifecycleTests.cs` | | Splitting a flat schema into editor pages: property→page map, page order, the catch-all | `src/AgentForge.Avalonia.Shell/Navigation/SchemaPageLayout.cs`; this app's tables in `src/ClaudeForge/Services/NavigationTreeBuilder.cs` | | Global search: trigger rules, pinned synthetic rows, the two editor interfaces search dispatches on | `src/AgentForge.Avalonia.Shell/Search/` (`SearchViewModel.cs`, `SearchTrigger.cs`, `SyntheticSearchEntry.cs`, `SearchableEditors.cs`); this app's table in `src/ClaudeForge/ViewModels/ClaudeSyntheticSearch.cs`; §3 of the ViewModels guide | | Share service — hands text / files to the desktop; ⚠ **there is no share sheet**, on any platform | `IShareService` (package `Bennewitz.Ninja.AppServices.Abstractions`), `DefaultShareService` (`Bennewitz.Ninja.AppServices`) (read its remarks first — the MAUI path it used to carry was behind a TFM that never built); view-model integrations in `BackupRestoreViewModel`, `EffectiveSettingsViewModel`, `AboutEditorViewModel` | | Making a desktop UI an agent can drive and verify: the method, not this app's gaps | [XamlQuality's `ai-drivable-ui.md`](https://github.com/JanusMael/Bennewitz.Ninja.XamlQuality/blob/main/docs/ai-drivable-ui.md); this app's own gaps stay in [`docs/UIA-AUTOMATION-GAPS.md`](./docs/UIA-AUTOMATION-GAPS.md) | When in doubt, follow the pointer instead of duplicating content here. ⭐ **Avalonia foot-guns and drivable-UI lessons go to XamlQuality, and no copy stays here.** `docs/avalonia-gotchas.md` and `docs/ai-drivable-ui.md` in `JanusMael/Bennewitz.Ninja.XamlQuality` are the one living copy of each. Send a new finding, or a correction to an entry, to the XamlQuality session by message, with the Avalonia/.NET versions and the measurement or source behind it; when no such session is running, open an issue in that repository. XamlQuality checks the claim before it lands and replies landed, changed or declined. A lesson that holds only for this app stays here, in the table above or in its owning doc.