Files
ClaudeDo/docs/superpowers/plans/2026-06-04-inherited-settings-and-turns.md
2026-06-04 12:12:37 +02:00

1108 lines
50 KiB
Markdown

# Inherited Settings Display, Overrides, and Turns — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make inherited config values visible in-place with a source-aware marker (`inherited · List` / `inherited · Global`), let users override them, and add **Max turns** as a list- and task-level overridable setting.
**Architecture:** Add a nullable `MaxTurns` override to `ListConfigEntity` and `TaskEntity` resolved task → list → global in `TaskRunner`. Thread it through repositories, SignalR DTOs, the SignalR hub, and the MCP config tools. In the UI, a pure resolution helper computes `(value, source)`; a small reusable badge control plus per-field reset buttons render the marker in both the List settings modal and the task overrides flyout.
**Tech Stack:** .NET 8, EF Core (SQLite), ASP.NET Core SignalR, Avalonia 12 + CommunityToolkit.Mvvm, xUnit. ModelContextProtocol MCP server.
**Build/test reminders (see CLAUDE.md):** build individual csproj with `-c Release` (a running Worker locks Debug). Subagents use the `sonnet` model and stage files explicitly by path — never `git add -A`. `locales/en.json` and `locales/de.json` keys must stay in parity. Changing `IWorkerClient` / DTOs breaks hand-rolled fakes — update `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`.
---
## File Structure
**Created:**
- `src/ClaudeDo.Data/Migrations/<timestamp>_InheritableMaxTurns.cs` (+ `.Designer.cs`, generated by `dotnet ef`)
- `src/ClaudeDo.Ui/Services/InheritanceResolver.cs` — pure helper: `(value, source)` resolution + `InheritSource` enum
- `src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml` (+ `.axaml.cs`) — label + source badge control
- `tests/ClaudeDo.Ui.Tests/InheritanceResolverTests.cs`
- `tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs` (or extend an existing runner-config test file)
- `tests/ClaudeDo.Data.Tests/MaxTurnsRoundTripTests.cs`
**Modified:**
- `src/ClaudeDo.Data/Models/ListConfigEntity.cs`, `TaskEntity.cs`
- `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs`, `TaskEntityConfiguration.cs`
- `src/ClaudeDo.Data/Repositories/ListRepository.cs`, `TaskRepository.cs`
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`, `Services/Interfaces/IWorkerClient.cs`
- `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs`, `Views/Modals/ListSettingsModalView.axaml`
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, `Views/Islands/DetailsIslandView.axaml`
- `locales/en.json`, `locales/de.json`
---
## Task 1: Add `MaxTurns` to entities, EF config, and migration
**Files:**
- Modify: `src/ClaudeDo.Data/Models/ListConfigEntity.cs`
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs`
- Modify: `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs`
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:83-85`
- Create: migration (generated)
- [ ] **Step 1: Add the property to `ListConfigEntity`**
In `src/ClaudeDo.Data/Models/ListConfigEntity.cs`, after the `AgentPath` line:
```csharp
public sealed class ListConfigEntity
{
public required string ListId { get; init; }
public string? Model { get; set; }
public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; }
public int? MaxTurns { get; set; }
// Navigation property
public ListEntity List { get; set; } = null!;
}
```
- [ ] **Step 2: Add the property to `TaskEntity`**
In `src/ClaudeDo.Data/Models/TaskEntity.cs`, beside the existing `Model` / `SystemPrompt` / `AgentPath` override properties, add:
```csharp
public int? MaxTurns { get; set; }
```
- [ ] **Step 3: Map the columns in both entity configurations**
In `ListConfigEntityConfiguration.cs`, after the `AgentPath` mapping:
```csharp
builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
```
In `TaskEntityConfiguration.cs`, after line 85 (`AgentPath` mapping):
```csharp
builder.Property(t => t.MaxTurns).HasColumnName("max_turns");
```
- [ ] **Step 4: Generate the migration**
Run:
```bash
dotnet ef migrations add InheritableMaxTurns --project src/ClaudeDo.Data --startup-project src/ClaudeDo.Worker
```
Expected: creates `src/ClaudeDo.Data/Migrations/<timestamp>_InheritableMaxTurns.cs` (+ Designer) and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method should `AddColumn<int>("max_turns", "list_config", nullable: true)` and `AddColumn<int>("max_turns", "tasks", nullable: true)`.
If the `dotnet ef` tool is unavailable, hand-author the migration mirroring `Migrations/20260603141020_DailyPrepMaxTasks.cs` but with `nullable: true` and no `defaultValue`, adding both columns, and add matching `Property<int?>("MaxTurns").HasColumnName("max_turns")` entries to the snapshot for the `ListConfigEntity` and `TaskEntity` builders.
- [ ] **Step 5: Verify the migration is sane**
Open the generated `_InheritableMaxTurns.cs` and confirm both `AddColumn` calls use `nullable: true` and there is **no** `defaultValue` (null = inherit). Confirm `Down` drops both columns.
- [ ] **Step 6: Build Data**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
Expected: build succeeds.
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Data/Models/ListConfigEntity.cs src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations/
git commit -m "feat(data): add nullable max_turns override to list_config and tasks"
```
---
## Task 2: Repositories persist `MaxTurns`
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/ListRepository.cs:56-70`
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs:157-170`
- Test: `tests/ClaudeDo.Data.Tests/MaxTurnsRoundTripTests.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Data.Tests/MaxTurnsRoundTripTests.cs`. Use the same in-memory/temp-SQLite context setup as a neighboring Data.Tests file (open one, e.g. `ListRepositoryTests.cs`, and copy its context-creation helper exactly — it builds a `ClaudeDoDbContext` against a temp SQLite file and calls `Database.Migrate()` or `EnsureCreated()`).
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Xunit;
namespace ClaudeDo.Data.Tests;
public class MaxTurnsRoundTripTests
{
[Fact]
public async Task ListConfig_persists_max_turns()
{
await using var ctx = TestDb.NewContext(); // use the helper pattern from existing Data.Tests
var lists = new ListRepository(ctx);
await ctx.Lists.AddAsync(new ListEntity { Id = "L1", Name = "L1" });
await ctx.SaveChangesAsync();
await lists.SetConfigAsync(new ListConfigEntity { ListId = "L1", MaxTurns = 42 });
var cfg = await lists.GetConfigAsync("L1");
Assert.Equal(42, cfg!.MaxTurns);
}
[Fact]
public async Task Task_agent_settings_persist_and_clear_max_turns()
{
await using var ctx = TestDb.NewContext();
var tasks = new TaskRepository(ctx);
await ctx.Tasks.AddAsync(new TaskEntity { Id = "T1", ListId = "L1", Title = "t" });
await ctx.SaveChangesAsync();
await tasks.UpdateAgentSettingsAsync("T1", model: null, systemPrompt: null, agentPath: null, maxTurns: 7);
var t = await tasks.GetByIdAsync("T1");
Assert.Equal(7, t!.MaxTurns);
await tasks.UpdateAgentSettingsAsync("T1", null, null, null, maxTurns: null);
var cleared = await tasks.GetByIdAsync("T1");
Assert.Null(cleared!.MaxTurns);
}
}
```
> NOTE: replace `TestDb.NewContext()` with whatever the existing Data.Tests use (they may have a fixture or a static helper). Match the existing pattern exactly; do not invent a new harness. `ListEntity` requires only `Id` + `Name` (other fields have defaults) — verify against the existing tests.
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter MaxTurnsRoundTripTests`
Expected: FAIL — `UpdateAgentSettingsAsync` has no `maxTurns` parameter (compile error), and `SetConfigAsync` does not copy `MaxTurns` onto existing rows.
- [ ] **Step 3: Update `ListRepository.SetConfigAsync`**
In `src/ClaudeDo.Data/Repositories/ListRepository.cs`, in the `else` branch that updates an existing row (after `existing.AgentPath = config.AgentPath;`):
```csharp
existing.MaxTurns = config.MaxTurns;
```
(The `Add` branch already stores the full entity, so no change there.)
- [ ] **Step 4: Update `TaskRepository.UpdateAgentSettingsAsync`**
Replace the method body in `src/ClaudeDo.Data/Repositories/TaskRepository.cs:157-170` with:
```csharp
public async Task UpdateAgentSettingsAsync(
string taskId,
string? model,
string? systemPrompt,
string? agentPath,
int? maxTurns = null,
CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Model, model)
.SetProperty(t => t.SystemPrompt, systemPrompt)
.SetProperty(t => t.AgentPath, agentPath)
.SetProperty(t => t.MaxTurns, maxTurns), ct);
}
```
> `maxTurns` is placed before `ct` with a default so existing 4-arg callers still compile; we update those callers explicitly in Task 4.
- [ ] **Step 5: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter MaxTurnsRoundTripTests`
Expected: PASS (2 tests).
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Data/Repositories/ListRepository.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Data.Tests/MaxTurnsRoundTripTests.cs
git commit -m "feat(data): persist max_turns in list and task repositories"
```
---
## Task 3: Runner resolves `MaxTurns` task → list → global
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:388-394`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs`
> Context: `BuildRunConfig` currently returns `MaxTurns: global.DefaultMaxTurns`. `ClaudeRunConfig.MaxTurns` is `int?` and `ClaudeArgsBuilder` already emits `--max-turns` when `> 0`. Only the resolution line changes.
- [ ] **Step 1: Inspect the method signature**
Open `src/ClaudeDo.Worker/Runner/TaskRunner.cs` around line 360-395 and confirm the names of the in-scope variables: the task entity (`task`), the list config (`listConfig`), and the app settings (`global`). If the config-building logic is not directly unit-testable (private method, lots of dependencies), extract a tiny pure static helper for just the MaxTurns precedence and test that:
```csharp
internal static int? ResolveMaxTurns(int? taskTurns, int? listTurns, int globalDefault)
=> taskTurns ?? listTurns ?? globalDefault;
```
Place it in `TaskRunner` as an `internal static` method.
- [ ] **Step 2: Write the failing test**
Create `tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs`:
```csharp
using ClaudeDo.Worker.Runner;
using Xunit;
namespace ClaudeDo.Worker.Tests.Runner;
public class MaxTurnsResolutionTests
{
[Fact]
public void Task_override_wins()
=> Assert.Equal(5, TaskRunner.ResolveMaxTurns(taskTurns: 5, listTurns: 20, globalDefault: 100));
[Fact]
public void List_override_used_when_no_task_override()
=> Assert.Equal(20, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: 20, globalDefault: 100));
[Fact]
public void Falls_back_to_global_default()
=> Assert.Equal(100, TaskRunner.ResolveMaxTurns(taskTurns: null, listTurns: null, globalDefault: 100));
}
```
- [ ] **Step 3: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter MaxTurnsResolutionTests`
Expected: FAIL — `ResolveMaxTurns` not defined.
- [ ] **Step 4: Add the helper and use it in `BuildRunConfig`**
Add the `ResolveMaxTurns` helper from Step 1 to `TaskRunner`. Then change the `MaxTurns:` argument in the `ClaudeRunConfig` construction (currently `MaxTurns: global.DefaultMaxTurns,`) to:
```csharp
MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, global.DefaultMaxTurns),
```
- [ ] **Step 5: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter MaxTurnsResolutionTests`
Expected: PASS (3 tests).
- [ ] **Step 6: Build Worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: build succeeds.
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/MaxTurnsResolutionTests.cs
git commit -m "feat(worker): resolve max-turns from task then list then global default"
```
---
## Task 4: Thread `MaxTurns` through Worker transport (DTOs, Hub, MCP)
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:58-60, 334-368, 406-417`
- Modify: `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`
- [ ] **Step 1: Extend the Worker-side DTO records**
In `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (lines 58-60), add a trailing nullable `int?` to each record (defaulted so existing positional usages elsewhere keep compiling):
```csharp
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
```
- [ ] **Step 2: Persist `MaxTurns` in `UpdateListConfig`**
In `WorkerHub.UpdateListConfig` (lines 334-359): include MaxTurns in the "is everything empty?" delete check and in the `SetConfigAsync` call.
```csharp
public async Task UpdateListConfig(UpdateListConfigDto dto)
{
using var ctx = _dbFactory.CreateDbContext();
var repo = new ListRepository(ctx);
var model = dto.Model.NullIfBlank();
var systemPrompt = dto.SystemPrompt.NullIfBlank();
var agentPath = dto.AgentPath.NullIfBlank();
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null)
{
await repo.DeleteConfigAsync(dto.ListId);
}
else
{
await repo.SetConfigAsync(new ListConfigEntity
{
ListId = dto.ListId,
Model = model,
SystemPrompt = systemPrompt,
AgentPath = agentPath,
MaxTurns = dto.MaxTurns,
});
}
await _broadcaster.ListUpdated(dto.ListId);
}
```
- [ ] **Step 3: Return `MaxTurns` from `GetListConfig`**
In `WorkerHub.GetListConfig` (line 367), change the return to include MaxTurns:
```csharp
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns);
```
- [ ] **Step 4: Persist `MaxTurns` in `UpdateTaskAgentSettings`**
In `WorkerHub.UpdateTaskAgentSettings` (lines 406-417), pass `dto.MaxTurns` to the repository (note the new `maxTurns` parameter sits before `ct`):
```csharp
await repo.UpdateAgentSettingsAsync(
dto.TaskId,
dto.Model.NullIfBlank(),
dto.SystemPrompt.NullIfBlank(),
dto.AgentPath.NullIfBlank(),
dto.MaxTurns);
```
- [ ] **Step 5: Extend the MCP config tools**
In `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`:
Update the DTO (line 9):
```csharp
public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns);
```
Update `GetListConfig` (line 29):
```csharp
return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns);
```
Update `SetListConfig` signature + body — add an `int? maxTurns` parameter (before `CancellationToken`), include it in the clear-check and the upsert:
```csharp
[McpServerTool, Description("Set a list's default model/system prompt/agent path/max turns. Passing model, systemPrompt, agentPath, and maxTurns all null clears the list config.")]
public async Task SetListConfig(
string listId, string? model, string? systemPrompt, string? agentPath, int? maxTurns, CancellationToken cancellationToken)
{
_ = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
var m = model.NullIfBlank();
var sp = systemPrompt.NullIfBlank();
var ap = agentPath.NullIfBlank();
if (m is null && sp is null && ap is null && maxTurns is null)
await _lists.DeleteConfigAsync(listId, cancellationToken);
else
await _lists.SetConfigAsync(new ListConfigEntity
{
ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, MaxTurns = maxTurns,
}, cancellationToken);
await _broadcaster.ListUpdated(listId);
}
```
Update `SetTaskConfig` signature + body — add `int? maxTurns` (before `CancellationToken`) and pass it through:
```csharp
[McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to clear that override.")]
public async Task SetTaskConfig(
string taskId, string? model, string? systemPrompt, string? agentPath, int? maxTurns, CancellationToken cancellationToken)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, cancellationToken);
await _broadcaster.TaskUpdated(taskId);
}
```
- [ ] **Step 6: Build Worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: build succeeds. (If any other caller constructed these DTOs positionally and now mismatches, fix it — the new params are defaulted, so this should be clean.)
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/External/ConfigMcpTools.cs
git commit -m "feat(worker): expose max-turns override over signalr and mcp config tools"
```
---
## Task 5: Mirror DTO changes in the UI transport layer
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs:524-526`
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` (only if a signature/record change breaks it)
> The UI keeps its own copies of `UpdateListConfigDto`, `UpdateTaskAgentSettingsDto`, `ListConfigDto`. They must mirror the Worker exactly or SignalR (de)serialization drops the field. `IWorkerClient` method signatures don't change (they take whole DTOs), so the interface and stub need no new members — but verify the stub still compiles.
- [ ] **Step 1: Extend the UI DTO records**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs` (lines 524-526):
```csharp
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
```
- [ ] **Step 2: Build the UI project**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: build succeeds (this pulls in Ui + Data).
- [ ] **Step 3: Build the UI test project**
Run: `dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: build succeeds. `StubWorkerClient` returns `null` for `GetListConfigAsync` and ignores `UpdateTaskAgentSettingsAsync(dto)`, so the defaulted record params keep it compiling. If it breaks, fix only the affected line.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Services/WorkerClient.cs
git commit -m "feat(ui): mirror max-turns field on signalr config dtos"
```
---
## Task 6: Localization keys (en + de parity)
**Files:**
- Modify: `locales/en.json`
- Modify: `locales/de.json`
> Localization.Tests enforces key parity. Find the right nesting by searching for the existing sibling keys (`modals.listSettings.model`, `details.modelLabel`, `vm.details.effectiveIfInherited`).
- [ ] **Step 1: Add the new keys to `locales/en.json`**
Add (place each next to its siblings; values shown):
- under `modals.listSettings`: `"maxTurns": "Max turns"`
- under `details`: `"maxTurnsLabel": "Max turns"`, `"systemPromptPrepended": "Prepended automatically:"`
- a new/extended `settings.inherit` group (or wherever shared UI strings live — match existing convention):
- `"inheritedFromList": "inherited · List"`
- `"inheritedFromGlobal": "inherited · Global"`
- `"overrideBadge": "override"`
- `"resetToInherited": "Reset to inherited"`
- [ ] **Step 2: Add the same keys to `locales/de.json`**
Same key paths, German values:
- `modals.listSettings.maxTurns`: `"Max. Turns"`
- `details.maxTurnsLabel`: `"Max. Turns"`
- `details.systemPromptPrepended`: `"Wird automatisch vorangestellt:"`
- `settings.inherit.inheritedFromList`: `"geerbt · Liste"`
- `settings.inherit.inheritedFromGlobal`: `"geerbt · Global"`
- `settings.inherit.overrideBadge`: `"überschrieben"`
- `settings.inherit.resetToInherited`: `"Auf geerbt zurücksetzen"`
- [ ] **Step 3: Remove the now-unused `effectiveIfInherited` keys**
Remove `vm.details.effectiveIfInherited` from BOTH `en.json` and `de.json` (it is replaced by the badge approach in Tasks 8-9; this plan deletes its only usages there). Removing from both keeps parity.
- [ ] **Step 4: Run the localization tests**
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: PASS (en/de key parity holds).
- [ ] **Step 5: Commit**
```bash
git add locales/en.json locales/de.json
git commit -m "feat(i18n): add inherited-marker, turns, and prepended-prompt strings"
```
---
## Task 7: `InheritanceResolver` helper (pure, unit-tested)
**Files:**
- Create: `src/ClaudeDo.Ui/Services/InheritanceResolver.cs`
- Test: `tests/ClaudeDo.Ui.Tests/InheritanceResolverTests.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/InheritanceResolverTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class InheritanceResolverTests
{
[Fact]
public void Task_value_is_an_override()
{
var (value, source) = InheritanceResolver.Resolve("opus", "sonnet", "haiku");
Assert.Equal("opus", value);
Assert.Equal(InheritSource.Override, source);
}
[Fact]
public void Falls_through_to_list()
{
var (value, source) = InheritanceResolver.Resolve(null, "sonnet", "haiku");
Assert.Equal("sonnet", value);
Assert.Equal(InheritSource.List, source);
}
[Fact]
public void Falls_through_to_global()
{
var (value, source) = InheritanceResolver.Resolve(" ", null, "haiku");
Assert.Equal("haiku", value);
Assert.Equal(InheritSource.Global, source);
}
[Fact]
public void List_scope_treats_list_value_as_override()
{
var (value, source) = InheritanceResolver.ResolveList("sonnet", "haiku");
Assert.Equal("sonnet", value);
Assert.Equal(InheritSource.Override, source);
var (value2, source2) = InheritanceResolver.ResolveList(null, "haiku");
Assert.Equal("haiku", value2);
Assert.Equal(InheritSource.Global, source2);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter InheritanceResolverTests`
Expected: FAIL — type does not exist.
- [ ] **Step 3: Implement the helper**
Create `src/ClaudeDo.Ui/Services/InheritanceResolver.cs`:
```csharp
namespace ClaudeDo.Ui.Services;
public enum InheritSource { Override, List, Global }
public static class InheritanceResolver
{
// Task-scope fields: task -> list -> global.
public static (string Value, InheritSource Source) Resolve(
string? taskValue, string? listValue, string? globalValue)
{
if (!string.IsNullOrWhiteSpace(taskValue)) return (taskValue!, InheritSource.Override);
if (!string.IsNullOrWhiteSpace(listValue)) return (listValue!, InheritSource.List);
return (globalValue ?? "", InheritSource.Global);
}
// List-scope fields: list -> global (lists have no tier above them).
public static (string Value, InheritSource Source) ResolveList(
string? listValue, string? globalValue)
{
if (!string.IsNullOrWhiteSpace(listValue)) return (listValue!, InheritSource.Override);
return (globalValue ?? "", InheritSource.Global);
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter InheritanceResolverTests`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Services/InheritanceResolver.cs tests/ClaudeDo.Ui.Tests/InheritanceResolverTests.cs
git commit -m "feat(ui): add inheritance resolver returning value and source"
```
---
## Task 8: `InheritedBadge` reusable control
**Files:**
- Create: `src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml`
- Create: `src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml.cs`
> A tiny `UserControl` rendering a single badge whose text comes from a bound string. The consuming VM supplies the already-localized badge text (e.g. "inherited · List" / "override") and a bool for muted styling. Keeping localization in the VM avoids `Loc` lookups inside the control. No `ICommand` lives in the control — reset buttons are placed by each consumer (Tasks 9-10).
- [ ] **Step 1: Create the control XAML**
`src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml`:
```xml
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ClaudeDo.Ui.Views.Controls.InheritedBadge"
x:Name="Root">
<Border Background="{DynamicResource SubtleFillBrush}"
CornerRadius="4" Padding="6,1"
IsVisible="{Binding #Root.BadgeText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
VerticalAlignment="Center" HorizontalAlignment="Left">
<TextBlock Classes="meta" FontSize="11" Opacity="0.75"
Text="{Binding #Root.BadgeText}"/>
</Border>
</UserControl>
```
> If `SubtleFillBrush` is not a defined token, use an existing subtle background brush from `Design/Tokens.axaml` (open it and pick the closest — e.g. a hover/overlay brush). The user will do the final visual pass.
- [ ] **Step 2: Create the code-behind with a `BadgeText` StyledProperty**
`src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml.cs`:
```csharp
using Avalonia;
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views.Controls;
public partial class InheritedBadge : UserControl
{
public static readonly StyledProperty<string?> BadgeTextProperty =
AvaloniaProperty.Register<InheritedBadge, string?>(nameof(BadgeText));
public string? BadgeText
{
get => GetValue(BadgeTextProperty);
set => SetValue(BadgeTextProperty, value);
}
public InheritedBadge() => InitializeComponent();
}
```
- [ ] **Step 3: Build the UI project**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: build succeeds (control compiles, even though nothing consumes it yet).
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml src/ClaudeDo.Ui/Views/Controls/InheritedBadge.axaml.cs
git commit -m "feat(ui): add reusable inherited-source badge control"
```
---
## Task 9: List settings modal — Turns field + inherited markers
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml`
> Lists inherit only from Global. The ViewModel loads the global defaults (via `GetAppSettingsAsync`) to render the muted resolved value + `inherited · Global` badge. For Model we drop the `(default)` sentinel row and use `SelectedItem == null` to mean "inherited", with `PlaceholderText` showing the resolved value. Turns uses a `NumericUpDown` whose `Value` is `decimal?` (null = inherit) with a `Watermark` showing the global default.
- [ ] **Step 1: Add fields + load logic to the ViewModel**
In `ListSettingsModalViewModel.cs`:
1. Add a `using ClaudeDo.Ui.Services;` if not present (for `InheritanceResolver` / `InheritSource`).
2. Change the Model collection to **not** include the sentinel:
```csharp
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
```
3. Change `SelectedModel` to nullable (null = inherit) and add the badge + Turns properties:
```csharp
[ObservableProperty] private string? _selectedModel; // null = inherit from global
[ObservableProperty] private decimal? _maxTurns; // null = inherit from global
[ObservableProperty] private string _modelInheritedHint = ""; // muted resolved value, e.g. "sonnet"
[ObservableProperty] private string _modelBadge = ""; // localized badge text
[ObservableProperty] private string _turnsInheritedHint = ""; // muted resolved value, e.g. "100"
[ObservableProperty] private string _turnsBadge = "";
[ObservableProperty] private string _agentBadge = "";
[ObservableProperty] private string _agentInheritedHint = "";
```
4. Add badge recompute helpers and wire them to property changes:
```csharp
partial void OnSelectedModelChanged(string? value) => RecomputeModelBadge();
partial void OnMaxTurnsChanged(decimal? value) => RecomputeTurnsBadge();
partial void OnSelectedAgentChanged(AgentInfo? value) => RecomputeAgentBadge();
private string _globalModel = ModelRegistry.DefaultAlias;
private int _globalMaxTurns = 100;
private void RecomputeModelBadge()
{
var overridden = !string.IsNullOrWhiteSpace(SelectedModel);
ModelInheritedHint = _globalModel;
ModelBadge = overridden ? Loc.T("settings.inherit.overrideBadge") : Loc.T("settings.inherit.inheritedFromGlobal");
}
private void RecomputeTurnsBadge()
{
var overridden = MaxTurns is not null;
TurnsInheritedHint = _globalMaxTurns.ToString();
TurnsBadge = overridden ? Loc.T("settings.inherit.overrideBadge") : Loc.T("settings.inherit.inheritedFromGlobal");
}
private void RecomputeAgentBadge()
{
var overridden = SelectedAgent is not null && !string.IsNullOrWhiteSpace(SelectedAgent.Path);
// Agent has no global default; inherited means "(none)".
AgentInheritedHint = "(none)";
AgentBadge = overridden ? Loc.T("settings.inherit.overrideBadge") : Loc.T("settings.inherit.inheritedFromGlobal");
}
```
5. In `LoadAsync`, load globals and seed the new fields. After the `GetListConfigAsync` block, replace the Model/Agent seeding with the null-sentinel approach and add MaxTurns + globals:
```csharp
var app = await _worker.GetAppSettingsAsync();
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
SelectedModel = string.IsNullOrWhiteSpace(config?.Model) ? null : config!.Model!;
MaxTurns = config?.MaxTurns is int mt ? mt : (decimal?)null;
SystemPrompt = config?.SystemPrompt ?? "";
SelectedAgent = string.IsNullOrWhiteSpace(config?.AgentPath)
? Agents[0]
: (Agents.FirstOrDefault(a => a.Path == config!.AgentPath) ?? Agents[0]);
RecomputeModelBadge();
RecomputeTurnsBadge();
RecomputeAgentBadge();
```
(Remove the old line that set `SelectedModel = ... ListDefaultSentinel ...`.)
6. In `SaveAsync`, compute the override values and include MaxTurns:
```csharp
var model = string.IsNullOrWhiteSpace(SelectedModel) ? null : SelectedModel;
var sp = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt;
var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path;
var turns = MaxTurns is decimal d ? (int?)d : null;
await _worker.UpdateListAsync(new UpdateListDto(
ListId,
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
DefaultCommitType));
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(
ListId, model, sp, ap, turns));
```
7. Add reset commands and update `ResetAgentSettings`:
```csharp
[RelayCommand] private void ResetModel() => SelectedModel = null;
[RelayCommand] private void ResetTurns() => MaxTurns = null;
[RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
```
And in the existing `ResetAgentSettings`, replace `SelectedModel = ModelRegistry.ListDefaultSentinel;` with `SelectedModel = null;` and add `MaxTurns = null;`.
- [ ] **Step 2: Update the modal XAML — Model field**
In `ListSettingsModalView.axaml`, add `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` to the root if not already present (it is). Replace the Model `StackPanel` (lines ~76-81) with a label-row that carries the badge + reset, and a ComboBox using null-selection + placeholder:
```xml
<StackPanel Spacing="4">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr modals.listSettings.model}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
<Button Grid.Column="3" Classes="btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetModelCommand}" Padding="6,1"/>
</Grid>
<ComboBox ItemsSource="{Binding ModelOptions}"
SelectedItem="{Binding SelectedModel, Mode=TwoWay}"
PlaceholderText="{Binding ModelInheritedHint}"
HorizontalAlignment="Left" MinWidth="160" />
</StackPanel>
```
- [ ] **Step 3: Update the modal XAML — add the Turns field**
Add a new `StackPanel` inside the AGENT section's inner `StackPanel` (after the Model field, before System prompt):
```xml
<StackPanel Spacing="4">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr modals.listSettings.maxTurns}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
<Button Grid.Column="3" Classes="btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTurnsCommand}" Padding="6,1"/>
</Grid>
<NumericUpDown Value="{Binding MaxTurns, Mode=TwoWay}"
Watermark="{Binding TurnsInheritedHint}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"
HorizontalAlignment="Left" Width="160"/>
</StackPanel>
```
- [ ] **Step 4: Update the modal XAML — Agent field badge + reset**
Wrap the existing Agent `field-label` (line ~91) in the same label-row pattern so it shows `AgentBadge` and a reset button bound to `ResetAgentCommand`. Keep the existing ComboBox + browse button + path TextBlock unchanged below it.
- [ ] **Step 5: Build the UI project**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: build succeeds. Fix any binding/compile errors (compiled bindings require the VM properties to exist — they were added in Step 1).
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml
git commit -m "feat(ui): show inherited markers and max-turns override in list settings"
```
---
## Task 10: Task overrides flyout — Turns field + inherited markers
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml:90-122`
> Task scope falls through task → list → global. Model resolves to global default; Agent has no global tier (inherited = list value or "(none)"); Turns resolves to list value or global default. Reuse `InheritanceResolver.Resolve`. Drop the `(inherit)` sentinel from the Model dropdown and the Agent dropdown's first row; null selection = inherited.
- [ ] **Step 1: Replace the effective-hint fields with badge fields in the ViewModel**
In `DetailsIslandViewModel.cs`:
1. Remove `EffectiveModelLabel` / `EffectiveAgentLabel` computed props and the `OnEffective*HintChanged` partials (lines ~155-159), and the `OnPropertyChanged(nameof(EffectiveModelLabel))` calls at lines ~141-142 and ~291-292.
2. Change the Model options to drop the sentinel:
```csharp
public System.Collections.ObjectModel.ObservableCollection<string> TaskModelOptions { get; } = new(ModelRegistry.Aliases);
```
3. Change `TaskModelSelection` to nullable and add new observable fields:
```csharp
[ObservableProperty] private string? _taskModelSelection; // null = inherit
[ObservableProperty] private decimal? _taskMaxTurns; // null = inherit
[ObservableProperty] private string _modelBadge = "";
[ObservableProperty] private string _modelInheritedHint = "";
[ObservableProperty] private string _turnsBadge = "";
[ObservableProperty] private string _turnsInheritedHint = "";
[ObservableProperty] private string _agentBadge = "";
// keep existing _taskSystemPrompt; EffectiveSystemPromptHint stays as the prepended-prompt hint
```
Keep `EffectiveSystemPromptHint` (used as the read-only "prepended automatically" hint). Remove `EffectiveModelHint` and `EffectiveAgentHint` if nothing else references them after this task.
4. Track globals and recompute badges:
```csharp
private string _globalModel = ModelRegistry.DefaultAlias;
private int _globalMaxTurns = 100;
partial void OnTaskModelSelectionChanged(string? value) => RecomputeModelBadge();
partial void OnTaskMaxTurnsChanged(decimal? value) => RecomputeTurnsBadge();
partial void OnTaskSelectedAgentChanged(AgentInfo? value) => RecomputeAgentBadge();
private string? _listModel, _listAgentName;
private int? _listMaxTurns;
private void RecomputeModelBadge()
{
var (value, source) = InheritanceResolver.Resolve(TaskModelSelection, _listModel, _globalModel);
ModelInheritedHint = value;
ModelBadge = BadgeFor(source, taskSet: !string.IsNullOrWhiteSpace(TaskModelSelection));
}
private void RecomputeTurnsBadge()
{
var (value, source) = InheritanceResolver.Resolve(
TaskMaxTurns?.ToString(), _listMaxTurns?.ToString(), _globalMaxTurns.ToString());
TurnsInheritedHint = value;
TurnsBadge = BadgeFor(source, taskSet: TaskMaxTurns is not null);
}
private void RecomputeAgentBadge()
{
var taskSet = TaskSelectedAgent is not null && !string.IsNullOrWhiteSpace(TaskSelectedAgent.Path);
var (_, source) = InheritanceResolver.Resolve(
taskSet ? TaskSelectedAgent!.Path : null, _listAgentName, null);
AgentBadge = BadgeFor(source, taskSet);
}
private static string BadgeFor(InheritSource source, bool taskSet) => taskSet
? Loc.T("settings.inherit.overrideBadge")
: source == InheritSource.List
? Loc.T("settings.inherit.inheritedFromList")
: Loc.T("settings.inherit.inheritedFromGlobal");
```
- [ ] **Step 2: Update `LoadAgentSettingsAsync` to seed the new fields**
Replace the seeding block (lines ~504-514) with:
```csharp
TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? null : entity.Model!;
TaskMaxTurns = entity.MaxTurns is int tmt ? tmt : (decimal?)null;
TaskSystemPrompt = entity.SystemPrompt ?? "";
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
? TaskAgentOptions[0]
: (TaskAgentOptions.FirstOrDefault(a => a.Path == entity.AgentPath) ?? TaskAgentOptions[0]);
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
var app = await _worker.GetAppSettingsAsync();
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
_listModel = listCfg?.Model;
_listMaxTurns = listCfg?.MaxTurns;
_listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
? null : System.IO.Path.GetFileName(listCfg!.AgentPath!);
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt) ? "" : listCfg!.SystemPrompt!;
RecomputeModelBadge();
RecomputeTurnsBadge();
RecomputeAgentBadge();
```
Also drop the `(inherit)` sentinel row added to `TaskAgentOptions` (line ~500): replace
`TaskAgentOptions.Add(new AgentInfo(ModelRegistry.TaskInheritSentinel, "", ""));`
with a "(none)"/inherit placeholder row (path empty) so `TaskAgentOptions[0]` still means "inherited":
`TaskAgentOptions.Add(new AgentInfo("(inherited)", "", ""));`
- [ ] **Step 3: Update the save + clear paths**
In the agent-save method (line ~481), include MaxTurns:
```csharp
var model = string.IsNullOrWhiteSpace(TaskModelSelection) ? null : TaskModelSelection;
var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
? null : TaskSelectedAgent.Path;
var turns = TaskMaxTurns is decimal d ? (int?)d : null;
await _worker.UpdateTaskAgentSettingsAsync(
new ClaudeDo.Ui.Services.UpdateTaskAgentSettingsDto(Task.Id, model, sp, ap, turns));
```
In the `Bind(null)` reset block (lines ~583-596), set the new fields:
```csharp
TaskModelSelection = null;
TaskMaxTurns = null;
TaskSystemPrompt = "";
TaskSelectedAgent = null;
```
and remove the now-deleted `EffectiveModelHint`/`EffectiveAgentHint` assignments; keep `EffectiveSystemPromptHint = "";`.
- [ ] **Step 4: Add reset commands**
```csharp
[RelayCommand] private void ResetTaskModel() => TaskModelSelection = null;
[RelayCommand] private void ResetTaskTurns() => TaskMaxTurns = null;
[RelayCommand] private void ResetTaskAgent() => TaskSelectedAgent = TaskAgentOptions.Count > 0 ? TaskAgentOptions[0] : null;
```
- [ ] **Step 5: Update the flyout XAML**
In `DetailsIslandView.axaml`, add `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` to the root if absent. Replace the Model block (lines ~90-98) with the badge+reset label row + null-select ComboBox:
```xml
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTaskModelCommand}"/>
</Grid>
<ComboBox ItemsSource="{Binding TaskModelOptions}"
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
PlaceholderText="{Binding ModelInheritedHint}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTaskTurnsCommand}"/>
</Grid>
<NumericUpDown Value="{Binding TaskMaxTurns, Mode=TwoWay}"
Watermark="{Binding TurnsInheritedHint}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"
HorizontalAlignment="Stretch"/>
</StackPanel>
```
For the System prompt block (lines ~100-105), keep the TextBox but change the placeholder hint label to use the new "prepended automatically" key by adding a small caption above/below it:
```xml
<StackPanel Spacing="2">
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
<TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
<TextBlock Classes="meta" Opacity="0.6"
Text="{loc:Tr details.systemPromptPrepended}"
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
Text="{Binding EffectiveSystemPromptHint}"
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
```
For the Agent block (lines ~107-121), wrap the `field-label` in the badge+reset row (badge bound to `AgentBadge`, reset to `ResetTaskAgentCommand`); remove the old `EffectiveAgentLabel` `<TextBlock>` at lines ~118-120; keep the ComboBox.
- [ ] **Step 6: Build the UI project**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: build succeeds. Resolve any leftover references to the removed `Effective*Label`/`Effective*Hint` members.
- [ ] **Step 7: Run the full UI test suite**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: PASS. If `StubWorkerClient.GetAppSettingsAsync` returning null causes a VM test NRE, the VM already null-coalesces (`app?.DefaultModel ?? ...`) — but verify any DetailsIsland VM test still passes; adjust the stub to return a minimal `AppSettingsDto` if a test needs non-null globals.
- [ ] **Step 8: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml
git commit -m "feat(ui): show inherited markers and max-turns override in task flyout"
```
---
## Task 11: Full build + test sweep, docs, visual-verification flag
**Files:**
- Modify: `src/ClaudeDo.Ui/CLAUDE.md` (one-line note on the new flyout/modal fields), `src/ClaudeDo.Data/CLAUDE.md` (note `max_turns` on ListConfig/TaskEntity)
- [ ] **Step 1: Build everything that matters**
Run, in order:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
Expected: both succeed.
- [ ] **Step 2: Run the test suites**
Run:
```bash
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
Expected: all PASS.
- [ ] **Step 3: Update the two CLAUDE.md notes**
In `src/ClaudeDo.Data/CLAUDE.md`, update the `ListConfigEntity` and `TaskEntity` lines to mention `MaxTurns` (nullable override). In `src/ClaudeDo.Ui/CLAUDE.md`, update the `ListSettingsModalView` and `DetailsIslandView` descriptions to mention the Max-turns field and source-aware inherited markers.
- [ ] **Step 4: Commit docs**
```bash
git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md
git commit -m "docs: note max-turns override and inherited markers in module docs"
```
- [ ] **Step 5: Flag the visual-verification gap (do NOT claim the UI works)**
The badge styling, muted placeholder appearance, NumericUpDown watermark, and reset-button layout in both the List settings modal and the task overrides flyout require a human visual pass — they cannot be asserted programmatically. Report to the user: build + automated tests pass; the visual rendering of the inherited markers, placeholders, and reset buttons needs manual confirmation in the running app (open a list's settings and a task's ⚙ flyout, toggle override vs inherited for Model / Turns / Agent).
---
## Self-Review notes
- **Spec coverage:** marker shows resolved value muted (placeholder/watermark) + source-aware badge (Tasks 9-10 via Task 7 resolver) ✓; override semantics with reset (reset commands, Tasks 9-10) ✓; Turns at list + task levels (Tasks 1-5, 9-10) ✓; SystemPrompt stays plain + "prepended automatically" hint (Task 10 Step 5) ✓; MCP parity (Task 4) ✓; localization parity + retire old key (Task 6) ✓; test-fake sync (Task 5) ✓.
- **Naming consistency:** badge VM props named `ModelBadge` / `TurnsBadge` / `AgentBadge`; hints `*InheritedHint`; reset commands `Reset*Command`; resolver `Resolve` / `ResolveList` returning `(string Value, InheritSource Source)`; control property `BadgeText`. These names are used identically across tasks.
- **Known risk:** `AppSettingsDto` already carries `DefaultModel` and `DefaultMaxTurns` (confirmed) — UI globals load works. `NumericUpDown.Value` is `decimal?`, hence the `decimal?` VM properties with `int?` conversion at save.