diff --git a/docs/superpowers/plans/2026-10-01-ticket-import-all.md b/docs/superpowers/plans/2026-10-01-ticket-import-all.md new file mode 100644 index 00000000..4377da35 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-ticket-import-all.md @@ -0,0 +1,1769 @@ +# Ticket-Import über alle Projekte + Tagesplanung — 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:** One run (button + Prime schedule) imports every open ticket-system item assigned to Mika (tickets and tasks, all projects), lets Claude place each into the ClaudeDo list that fits its content (fallback list otherwise), then runs the existing FillMyDay in the same gate. + +**Architecture:** `TicketImportService` gains a sweep over all project boards (`/api/Navigation/sidebar` → `/api/Board/project/{id}`) plus `GetCandidatesAsync`/`ImportItemAsync`. Two new External-MCP tools (`get_ticket_import_candidates`, `import_ticket`) expose them to a headless `claude -p` run. `PrimeRunner` gets a new `PrimeActionKind.ImportTickets` that runs that import prompt and then the FillMyDay step under one log/one `PrepStarted`/`PrepFinished`. UI: Prep-panel button, Prime-tab radio, fallback-list dropdown in Settings → Ticketsystem. + +**Tech Stack:** .NET 8, EF Core SQLite (migration), ModelContextProtocol server tools, SignalR hub, Avalonia 12 + CommunityToolkit.Mvvm, xUnit. + +**Spec:** `docs/superpowers/specs/2026-10-01-ticket-import-all-design.md` (source of truth). + +## Ground rules for every task + +- Work on `main`. Other sessions share this checkout: **commit only your own paths** with + `git commit -m "..." -- ...` (after `git add ` for new files). Never `git add -A`, never a plain `git commit`. +- Build: `dotnet build ClaudeDo.slnx -c Release` (whole solution, always). +- Tests: only `--no-build --filter` on what the task touched — never the full suite. Report the exact filter you ran. +- No real Claude CLI in tests — fakes only. +- `/// ` on new types/members (1–2 lines). Inline `//` only for a non-obvious *why*. +- Log message tokens are `snake_case` (`{ticket_id}`), like the existing Tickets code. +- Conventional commits, no Co-Authored-By trailer. + +## File map + +| File | Change | +|---|---| +| `src/ClaudeDo.Data/Models/PrimeActionKind.cs` | + `ImportTickets` | +| `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs` | converter `import_tickets` | +| `src/ClaudeDo.Data/Models/AppSettingsEntity.cs` | + `TicketFallbackListId` | +| `src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs` | column `ticket_fallback_list_id` | +| `src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs` | + `SetTicketFallbackListIdAsync` | +| `src/ClaudeDo.Data/Migrations/*_AddTicketFallbackList.*` | new migration | +| `src/ClaudeDo.Data/PromptFiles.cs` | + `PromptKind.TicketImport`, file name, default prompt | +| `src/ClaudeDo.Data/Wire.cs` | `TicketSettingsDto` + `FallbackListId` | +| `src/ClaudeDo.Worker/Tickets/TicketDtos.cs` | + `TicketBoardTask`, `TicketDetail`, `TicketBoardItem.Tasks` | +| `src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs` | + `GetTicketAsync` | +| `src/ClaudeDo.Worker/Tickets/TicketImportService.cs` | sweep, candidates, item import, global dedupe | +| `src/ClaudeDo.Worker/External/TicketMcpTools.cs` | new | +| `src/ClaudeDo.Worker/Program.cs` | `.WithTools()` | +| `src/ClaudeDo.Worker/Prime/TicketImportPrompt.cs` | new | +| `src/ClaudeDo.Worker/Prime/PrimeRunner.cs` | ImportTickets flow, refactor into steps | +| `src/ClaudeDo.Worker/Hub/WorkerHub.cs` | `RunTicketImportNow`, `SetTicketFallbackList`, `GetTicketSettings` async | +| `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, `Services/WorkerClient.cs` | two new calls | +| `src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs`, `DetailsIslandViewModel.cs`, `Views/Islands/DetailsIslandView.axaml` | button | +| `src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs`, `SettingsModalViewModel.cs`, `Views/Modals/SettingsModalView.axaml` | fallback dropdown | +| `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs`, `PrimeClaudeTabViewModel.cs` | new radio | +| `src/ClaudeDo.Localization/locales/en.json`, `de.json` | new keys (parity!) | +| `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` | fake members | +| Docs: `src/ClaudeDo.Worker/CLAUDE.md`, `docs/explore-notes/external-mcp.md`, `docs/open.md` | | + +--- + +### Task 1: Data layer — enum value, fallback-list setting, prompt kind + +**Files:** +- Modify: `src/ClaudeDo.Data/Models/PrimeActionKind.cs` +- Modify: `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs` +- Modify: `src/ClaudeDo.Data/Models/AppSettingsEntity.cs` +- Modify: `src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs` +- Modify: `src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs` +- Modify: `src/ClaudeDo.Data/PromptFiles.cs` +- Create: migration via `dotnet ef` +- Test: `tests/ClaudeDo.Data.Tests/TicketFieldsTests.cs`, `tests/ClaudeDo.Data.Tests/PrimeScheduleRepositoryTests.cs` + +- [ ] **Step 1: Write failing tests** + +Append to `tests/ClaudeDo.Data.Tests/TicketFieldsTests.cs` (inside the class; add `using ClaudeDo.Data.Repositories;` at the top): + +```csharp + [Fact] + public async Task TicketFallbackListId_round_trips_and_survives_a_settings_save() + { + await using (var ctx = NewContext()) + await new AppSettingsRepository(ctx).SetTicketFallbackListIdAsync("l-fallback"); + + // The general settings save must not wipe it — UpdateAsync's copy block doesn't carry it. + await using (var ctx = NewContext()) + { + var repo = new AppSettingsRepository(ctx); + await repo.UpdateAsync(await repo.GetAsync()); + } + + await using var read = NewContext(); + Assert.Equal("l-fallback", (await new AppSettingsRepository(read).GetAsync()).TicketFallbackListId); + } + + [Fact] + public async Task Blank_TicketFallbackListId_is_stored_as_null() + { + await using (var ctx = NewContext()) + await new AppSettingsRepository(ctx).SetTicketFallbackListIdAsync(" "); + + await using var read = NewContext(); + Assert.Null((await new AppSettingsRepository(read).GetAsync()).TicketFallbackListId); + } +``` + +Append to `tests/ClaudeDo.Data.Tests/PrimeScheduleRepositoryTests.cs`: + +```csharp + [Fact] + public async Task ImportTickets_kind_round_trips() + { + var entity = new PrimeScheduleEntity { Kind = PrimeActionKind.ImportTickets }; + + await _repo.UpsertAsync(entity); + var found = await _repo.GetAsync(entity.Id); + + Assert.Equal(PrimeActionKind.ImportTickets, found!.Kind); + } +``` + +Append to `tests/ClaudeDo.Data.Tests/PromptFilesTests.cs` (inside the class): + +```csharp + [Fact] + public void TicketImport_default_names_both_tools() + { + var d = PromptFiles.DefaultFor(PromptKind.TicketImport); + Assert.Contains("mcp__claudedo__get_ticket_import_candidates", d); + Assert.Contains("mcp__claudedo__import_ticket", d); + } +``` + +- [ ] **Step 2: Build — expect compile errors** (`SetTicketFallbackListIdAsync`, `ImportTickets`, `TicketImport` missing). + +Run: `dotnet build ClaudeDo.slnx -c Release` + +- [ ] **Step 3: Implement** + +`PrimeActionKind.cs` — add as the **last** value (after `Custom`, add a comma to `Custom`): + +```csharp + /// Import the assigned ticket-system items into lists by content, then fill MyDay. + ImportTickets +``` + +`PrimeScheduleEntityConfiguration.cs` — extend both converter functions: + +```csharp + private static string KindToString(PrimeActionKind v) + => v == PrimeActionKind.Ping ? "ping" + : v == PrimeActionKind.FillMyDay ? "fill_my_day" + : v == PrimeActionKind.Custom ? "custom" + : v == PrimeActionKind.ImportTickets ? "import_tickets" + : throw new ArgumentOutOfRangeException(nameof(v)); + + private static PrimeActionKind KindFromString(string v) + => v == "ping" ? PrimeActionKind.Ping + : v == "fill_my_day" ? PrimeActionKind.FillMyDay + : v == "custom" ? PrimeActionKind.Custom + : v == "import_tickets" ? PrimeActionKind.ImportTickets + : throw new ArgumentOutOfRangeException(nameof(v)); +``` + +`AppSettingsEntity.cs` — append after `ListHandlerMaxHandoffs`: + +```csharp + // List that receives imported ticket items Claude could not place (import_ticket without a + // list). Null = such items are skipped. No FK: a deleted list simply reads as unset at import. + public string? TicketFallbackListId { get; set; } +``` + +`AppSettingsEntityConfiguration.cs` — before `builder.HasData(...)`: + +```csharp + builder.Property(s => s.TicketFallbackListId) + .HasColumnName("ticket_fallback_list_id"); +``` + +`AppSettingsRepository.cs` — add below `SetRepoImportFoldersAsync` (do **not** add the field to `UpdateAsync`'s copy block; the dedicated writer is what keeps a general settings save from resetting it): + +```csharp + /// Writes only the ticket fallback list; blank clears it. + public async Task SetTicketFallbackListIdAsync(string? listId, CancellationToken ct = default) + { + var row = await GetOrCreateTrackedRowAsync(ct); + row.TicketFallbackListId = string.IsNullOrWhiteSpace(listId) ? null : listId.Trim(); + await _context.SaveChangesAsync(ct); + } +``` + +`PromptFiles.cs`: +- enum line 8: append `, TicketImport` after `PowerUseAddendum`. +- `FileNameFor`: add `PromptKind.TicketImport => "ticket-import.md",` +- `DefaultFor`: add `PromptKind.TicketImport => TicketImportDefault,` +- add the constant next to `DailyPrepDefault`: + +```csharp + private const string TicketImportDefault = """ + You are sorting my assigned items from the company ticket system into my ClaudeDo lists. + + 1. Call mcp__claudedo__get_ticket_import_candidates. If available is false, say why and stop. + If items is empty, say so and stop. + 2. For every item, pick the list whose purpose fits the item's CONTENT — its title, its + parent and its project. A list's ticketProject is only a hint: one ticket project can + hold work for several repositories, and several lists can share one project. + 3. Import each item exactly once with mcp__claudedo__import_ticket(ticketId, list). + If no list clearly fits, omit list — the item then goes to the fallback list. + Never force a weak match just to avoid the fallback. + 4. Do not create, edit or move anything else. + 5. Finish with a short summary: imported items per list, items that went to the fallback + list, and skipped items with their reason. + """; +``` + +- [ ] **Step 4: Create the migration** + +Run: `dotnet ef migrations add AddTicketFallbackList --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --configuration Release` + +Open the generated `*_AddTicketFallbackList.cs`. It must contain exactly one `AddColumn` (`ticket_fallback_list_id`, table `app_settings`, nullable) and the matching `DropColumn`. The enum change needs no migration (string column). **If the migration contains anything else, stop and report** — that means the snapshot drifted. + +- [ ] **Step 5: Build and run the scoped tests** + +```bash +dotnet build ClaudeDo.slnx -c Release +dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~TicketFieldsTests|FullyQualifiedName~PrimeScheduleRepositoryTests|FullyQualifiedName~PromptFiles|FullyQualifiedName~MigrationSchemaDrift|FullyQualifiedName~MigrationBaseline" +``` +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Data/Migrations/*_AddTicketFallbackList.cs src/ClaudeDo.Data/Migrations/*_AddTicketFallbackList.Designer.cs +git commit -m "feat(data): add ImportTickets prime kind, ticket fallback list and import prompt" -- \ + src/ClaudeDo.Data/Models/PrimeActionKind.cs src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs \ + src/ClaudeDo.Data/Models/AppSettingsEntity.cs src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs \ + src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs src/ClaudeDo.Data/PromptFiles.cs \ + src/ClaudeDo.Data/Migrations \ + tests/ClaudeDo.Data.Tests/TicketFieldsTests.cs tests/ClaudeDo.Data.Tests/PrimeScheduleRepositoryTests.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs +``` + +--- + +### Task 2: Ticket sweep, candidates, single-item import, global dedupe + +**Files:** +- Modify: `src/ClaudeDo.Worker/Tickets/TicketDtos.cs` +- Modify: `src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs` +- Modify: `src/ClaudeDo.Worker/Tickets/TicketImportService.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Tickets/TicketImportAllTests.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Add to `TicketImportServiceTests.cs` (the existing per-list import must now dedupe across lists): + +```csharp + [Fact] + public async Task Ticket_already_imported_into_another_list_is_not_imported_again() + { + var (import, ctx) = await HarnessAsync(); + ctx.Lists.Add(new ListEntity { Id = "l2", Name = "Other", CreatedAt = DateTime.UtcNow, DefaultCommitType = "chore" }); + await ctx.SaveChangesAsync(); + await SeedExistingTaskAsync(ctx, "l2", "bandel:1"); + + var result = await import.ImportAsync(ListId, CancellationToken.None); + + Assert.Equal(1, result.Created); // only #3 + Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:1")); + } +``` + +Create `tests/ClaudeDo.Worker.Tests/Tickets/TicketImportAllTests.cs`: + +```csharp +using System.Net; +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Tickets; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Tickets; + +/// +/// Sweep über alle Projekt-Boards. Projekt 10 "Lager": #100 Ticket mein/offen mit Tasks +/// #101 mein/offen · #102 fremd · #103 mein/fertig · #104 mein, hat Kinder (übersprungen); +/// #110 fremdes Ticket mit Task #111 mein/in Bearbeitung (zählt trotzdem). +/// Projekt 20 "Web": #200 mein/offen · #201 mein/fertig. +/// +public sealed class TicketImportAllTests : IDisposable +{ + private readonly List _dbs = new(); + private readonly List _patDirs = new(); + + public void Dispose() + { + foreach (var db in _dbs) db.Dispose(); + foreach (var dir in _patDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public async Task Candidates_are_open_assigned_tickets_and_tasks_across_all_projects() + { + var (import, _) = await HarnessAsync(); + + var c = await import.GetCandidatesAsync(CancellationToken.None); + + Assert.Equal([100, 101, 111, 200], c.Items.Select(i => i.Id).Order()); + var task = c.Items.Single(i => i.Id == 111); + Assert.Equal("task", task.Kind); + Assert.Equal(110, task.ParentId); + Assert.Equal("Foreign parent", task.ParentTitle); + Assert.Equal("Lager", task.ProjectTitle); + Assert.Equal("ticket", c.Items.Single(i => i.Id == 200).Kind); + } + + [Fact] + public async Task Candidates_skip_items_already_imported_in_any_list() + { + var (import, ctx) = await HarnessAsync(); + await SeedTaskAsync(ctx, "l2", "bandel:200"); + + var c = await import.GetCandidatesAsync(CancellationToken.None); + + Assert.DoesNotContain(c.Items, i => i.Id == 200); + } + + [Fact] + public async Task Candidates_carry_every_list_with_its_project_hint_and_the_fallback() + { + var (import, ctx) = await HarnessAsync(); + await new AppSettingsRepository(ctx).SetTicketFallbackListIdAsync("l2"); + + var c = await import.GetCandidatesAsync(CancellationToken.None); + + Assert.Equal(["l1", "l2"], c.Lists.Select(l => l.Id).Order()); + Assert.Equal("Lager", c.Lists.Single(l => l.Id == "l1").TicketProject); + Assert.Null(c.Lists.Single(l => l.Id == "l2").TicketProject); + Assert.Equal("l2", c.FallbackList?.Id); + } + + [Fact] + public async Task ImportItem_into_a_list_by_name_creates_an_idle_task_with_parent_note() + { + var (import, ctx) = await HarnessAsync(); + + var r = await import.ImportItemAsync(101, "hub lagertechnik", CancellationToken.None); + + Assert.True(r.Created); + Assert.False(r.UsedFallback); + Assert.Equal("l1", r.ListId); + Assert.Equal("Hub Lagertechnik", r.ListName); + Assert.True(r.TaskNumber > 0); + var task = await ctx.Tasks.SingleAsync(t => t.TicketRef == "bandel:101"); + Assert.Equal(r.TaskId, task.Id); + Assert.Equal("Task 101", task.Title); + Assert.Equal(TaskStatus.Idle, task.Status); + Assert.Equal("ticketsystem", task.CreatedBy); + Assert.Contains("Body 101", task.Description); + Assert.Contains("Ticket #101", task.Description); + Assert.Contains("Teil von #100 Mine", task.Description); + } + + [Fact] + public async Task ImportItem_without_list_goes_to_the_fallback_list() + { + var (import, ctx) = await HarnessAsync(); + await new AppSettingsRepository(ctx).SetTicketFallbackListIdAsync("l2"); + + var r = await import.ImportItemAsync(200, null, CancellationToken.None); + + Assert.True(r.Created); + Assert.True(r.UsedFallback); + Assert.Equal("l2", r.ListId); + Assert.DoesNotContain("Teil von", (await ctx.Tasks.SingleAsync(t => t.TicketRef == "bandel:200")).Description); + } + + [Fact] + public async Task ImportItem_without_list_and_without_fallback_is_skipped() + { + var (import, ctx) = await HarnessAsync(); + + var r = await import.ImportItemAsync(200, null, CancellationToken.None); + + Assert.False(r.Created); + Assert.Contains("fallback", r.SkippedReason, StringComparison.OrdinalIgnoreCase); + Assert.False(await ctx.Tasks.AnyAsync(t => t.TicketRef == "bandel:200")); + } + + [Fact] + public async Task ImportItem_twice_creates_one_task() + { + var (import, ctx) = await HarnessAsync(); + + await import.ImportItemAsync(200, "l1", CancellationToken.None); + var second = await import.ImportItemAsync(200, "l2", CancellationToken.None); + + Assert.False(second.Created); + Assert.Contains("already", second.SkippedReason); + Assert.Equal(1, await ctx.Tasks.CountAsync(t => t.TicketRef == "bandel:200")); + } + + [Fact] + public async Task ImportItem_refuses_an_item_not_assigned_to_me() + { + var (import, ctx) = await HarnessAsync(); + + var r = await import.ImportItemAsync(102, "l1", CancellationToken.None); + + Assert.False(r.Created); + Assert.False(await ctx.Tasks.AnyAsync(t => t.TicketRef == "bandel:102")); + } + + [Fact] + public async Task ImportItem_with_an_unknown_list_names_the_known_lists() + { + var (import, _) = await HarnessAsync(); + + var r = await import.ImportItemAsync(200, "nope", CancellationToken.None); + + Assert.False(r.Created); + Assert.Contains("Hub Lagertechnik", r.SkippedReason); + } + + [Fact] + public async Task GetCandidates_throws_when_not_configured() + { + var (import, _) = await HarnessAsync(configured: false); + + await Assert.ThrowsAsync(() => import.GetCandidatesAsync(CancellationToken.None)); + } + + // ── Helfer ────────────────────────────────────────────────────────────── + + private sealed class StubHttpClientFactory(StubHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(handler); + } + + private const string Me = """{"assigneeId":26,"assigneeName":"mika.kuns"}"""; + private const string Other = """{"assigneeId":8,"assigneeName":"jemand.anders"}"""; + + private static string Envelope(string data) => $$"""{"success":true,"data":{{data}},"message":"ok"}"""; + + private static (HttpStatusCode, string) Respond(HttpRequestMessage request) + { + var path = request.RequestUri!.AbsolutePath; + if (path.EndsWith("/pat/me", StringComparison.Ordinal)) + return (HttpStatusCode.OK, Envelope("""{"userName":"mika.kuns","isPat":true,"scopes":[]}""")); + if (path.EndsWith("/Navigation/sidebar", StringComparison.Ordinal)) + return (HttpStatusCode.OK, Envelope(""" + [{"departmentId":1,"departmentName":"Entwicklung","projects":[{"id":10,"title":"Lager"},{"id":20,"title":"Web"}]}] + """)); + if (path.EndsWith("/Board/project/10", StringComparison.Ordinal)) + return (HttpStatusCode.OK, Envelope($$""" + [ + {"id":100,"title":"Mine","description":null,"statusId":1,"statusName":"Offen","ticketAssignees":[{{Me}}], + "tasks":[ + {"id":101,"title":"Task 101","statusId":1,"hasChildren":false,"ticketTaskAssignees":[{{Me}}]}, + {"id":102,"title":"Task 102","statusId":1,"hasChildren":false,"ticketTaskAssignees":[{{Other}}]}, + {"id":103,"title":"Task 103","statusId":3,"hasChildren":false,"ticketTaskAssignees":[{{Me}}]}, + {"id":104,"title":"Task 104","statusId":1,"hasChildren":true,"ticketTaskAssignees":[{{Me}}]} + ]}, + {"id":110,"title":"Foreign parent","description":null,"statusId":1,"statusName":"Offen","ticketAssignees":[{{Other}}], + "tasks":[{"id":111,"title":"Task 111","statusId":2,"hasChildren":false,"ticketTaskAssignees":[{{Me}}]}]} + ] + """)); + if (path.EndsWith("/Board/project/20", StringComparison.Ordinal)) + return (HttpStatusCode.OK, Envelope($$""" + [ + {"id":200,"title":"Web thing","description":null,"statusId":1,"statusName":"Offen","ticketAssignees":[{{Me}}]}, + {"id":201,"title":"Web done","description":null,"statusId":3,"statusName":"Fertig","ticketAssignees":[{{Me}}]} + ] + """)); + if (path.StartsWith("/api/ticket/", StringComparison.OrdinalIgnoreCase)) + { + var id = path[(path.LastIndexOf('/') + 1)..]; + return (HttpStatusCode.OK, Envelope($$"""{"id":{{id}},"title":"T","description":"

Body {{id}}

","parentTicketID":null}""")); + } + return (HttpStatusCode.NotFound, ""); + } + + private async Task<(TicketImportService Import, ClaudeDoDbContext Ctx)> HarnessAsync(bool configured = true) + { + var db = new DbFixture(); + _dbs.Add(db); + var factory = db.CreateFactory(); + + await using (var seed = factory.CreateDbContext()) + { + seed.Lists.Add(new ListEntity { Id = "l1", Name = "Hub Lagertechnik", CreatedAt = DateTime.UtcNow, DefaultCommitType = "chore", SortOrder = 0 }); + seed.Lists.Add(new ListEntity { Id = "l2", Name = "Inbox", CreatedAt = DateTime.UtcNow, DefaultCommitType = "chore", SortOrder = 1 }); + seed.ListConfigs.Add(new ListConfigEntity { ListId = "l1", TicketProjectId = 10 }); + await seed.SaveChangesAsync(); + } + + var patDir = Path.Combine(Path.GetTempPath(), "cdo-ticketall-" + Guid.NewGuid().ToString("N")); + _patDirs.Add(patDir); + var patStore = new TicketPatStore(new DpapiTokenStore(Path.Combine(patDir, "ticket.pat"))); + var worker = new WorkerConfig(); + if (configured) + { + worker.TicketApiBaseUrl = "http://api.local"; + patStore.Store.Save("tsp_x"); + } + + var clients = new TicketClientFactory( + new TicketSystemConfig(worker, patStore), + new StubHttpClientFactory(new StubHandler(Respond)), + NullLogger.Instance); + var import = new TicketImportService(clients, factory, NullLogger.Instance); + return (import, factory.CreateDbContext()); + } + + private static Task SeedTaskAsync(ClaudeDoDbContext ctx, string listId, string ticketRef) + => new TaskRepository(ctx).AddAsync(new TaskEntity + { + Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Pre-existing", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, TicketRef = ticketRef, + }); +} +``` + +- [ ] **Step 2: Build — expect compile errors** (`GetCandidatesAsync`, `ImportItemAsync` missing). `dotnet build ClaudeDo.slnx -c Release` + +- [ ] **Step 3: Implement DTOs and client call** + +`TicketDtos.cs` — add a last parameter to `TicketBoardItem` (after `TicketAssignees`): + +```csharp + [property: JsonPropertyName("ticketAssignees")] IReadOnlyList? TicketAssignees = null, + [property: JsonPropertyName("tasks")] IReadOnlyList? Tasks = null); +``` + +and add these records below `TicketAssignee`: + +```csharp +/// Ein Task unter einem Board-Item (tasks[]). ⚠️ Die Zuweisung steht in +/// ticketTaskAssignees, NICHT in ticketAssignees (live geprüft 2026-10-01). +public sealed record TicketBoardTask( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("statusId")] int StatusId, + [property: JsonPropertyName("hasChildren")] bool HasChildren, + [property: JsonPropertyName("ticketTaskAssignees")] IReadOnlyList? TicketTaskAssignees = null); + +/// GET /api/ticket/{id} — Ausschnitt; die Route liefert Tickets und Tasks gleichermaßen. +public sealed record TicketDetail( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("title")] string? Title, + [property: JsonPropertyName("description")] string? Description, + [property: JsonPropertyName("parentTicketID")] int? ParentTicketId); +``` + +`BandelTicketClient.cs` — add below `GetProjectBoardAsync`: + +```csharp + /// Details eines Tickets oder Tasks (Beschreibung, Parent). + public Task GetTicketAsync(int ticketId, CancellationToken ct) + => SendAsync(HttpMethod.Get, $"/api/ticket/{ticketId}", null, ct); +``` + +- [ ] **Step 4: Implement the service** + +In `TicketImportService.cs` add the records below `TicketImportResult`: + +```csharp +/// Ein offenes, dem Token-Inhaber zugewiesenes Ticket oder Task aus dem Board-Sweep. +public sealed record TicketCandidate( + int Id, string Kind, string Title, int StatusId, int ProjectId, string ProjectTitle, + int? ParentId, string? ParentTitle); + +/// Eine ClaudeDo-Liste als Zuordnungsziel; ist nur ein Hinweis. +public sealed record ImportTargetList(string Id, string Name, string? WorkingDir, string? TicketProject); + +/// Was der Zuordnungs-Lauf braucht: offene Items, alle Listen, die Fallback-Liste. +public sealed record TicketImportCandidates( + IReadOnlyList Items, IReadOnlyList Lists, ImportTargetList? FallbackList); + +/// Ergebnis eines Einzel-Imports; = false trägt immer einen Grund. +public sealed record TicketItemImportResult( + bool Created, string? TaskId, int? TaskNumber, string? ListId, string? ListName, + bool UsedFallback, string? SkippedReason); +``` + +Inside the class: + +1. Replace the dedupe query in `ImportAsync` (the `known`/`knownSet` lines) with + `var knownSet = await KnownRefsAsync(ctx, ct);` — dedupe is now across **all** lists. +2. In `ImportAsync`, change `.Where(t => IsAssignedTo(t, identity.UserName))` to + `.Where(t => IsAssignedTo(t.TicketAssignees, identity.UserName))`, the title line to + `Title = Clean(ticket.Title),` and the description line to + `Description = BuildDescription(ticket.Description, ticket.Id, null, null),`. +3. Replace the client-creation line in `ImportAsync` with `var client = RequireClient();`. +4. Replace `IsAssignedTo` and `BuildDescription` with the versions below and add the new members: + +```csharp + /// Offene, zugewiesene Items aus allen Projekten, die noch in keiner Liste importiert sind, plus Zuordnungskontext. + public async Task GetCandidatesAsync(CancellationToken ct) + { + var client = RequireClient(); + var (items, projects) = await SweepAsync(client, ct); + + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var known = await KnownRefsAsync(ctx, ct); + var fallbackId = (await new AppSettingsRepository(ctx).GetAsync(ct)).TicketFallbackListId; + + var projectTitles = projects.ToDictionary(p => p.Id, p => p.Title); + var lists = await ctx.Lists.AsNoTracking().Include(l => l.Config) + .OrderBy(l => l.SortOrder).ThenBy(l => l.Name) + .ToListAsync(ct); + var targets = lists.Select(l => new ImportTargetList( + l.Id, l.Name, l.WorkingDir, + l.Config?.TicketProjectId is { } pid ? projectTitles.GetValueOrDefault(pid, $"#{pid}") : null)) + .ToList(); + + return new TicketImportCandidates( + items.Where(i => !known.Contains(Ref(i.Id))).ToList(), + targets, + targets.FirstOrDefault(t => t.Id == fallbackId)); + } + + /// + /// Legt für ein offenes, zugewiesenes Item einen Idle-Task an. ist Id + /// oder Name; leer → Fallback-Liste aus den App-Settings. Fachliche Ablehnungen (schon + /// importiert, keine Fallback-Liste, unbekannte Liste, nicht zugewiesen) sind ein Ergebnis, + /// keine Exception. + /// + public async Task ImportItemAsync(int ticketId, string? list, CancellationToken ct) + { + var client = RequireClient(); + var reference = Ref(ticketId); + + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + if ((await KnownRefsAsync(ctx, ct)).Contains(reference)) + return Skipped($"Ticket #{ticketId} is already imported."); + + var usedFallback = string.IsNullOrWhiteSpace(list); + ListEntity? target; + if (usedFallback) + { + var fallbackId = (await new AppSettingsRepository(ctx).GetAsync(ct)).TicketFallbackListId; + target = fallbackId is null ? null : await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == fallbackId, ct); + if (target is null) return Skipped("No fallback list is set (Settings → Ticket system), so the item was skipped."); + } + else + { + var all = await ctx.Lists.AsNoTracking().ToListAsync(ct); + target = all.FirstOrDefault(l => l.Id == list) + ?? all.FirstOrDefault(l => string.Equals(l.Name, list!.Trim(), StringComparison.OrdinalIgnoreCase)); + if (target is null) + return Skipped($"No list matches '{list}'. Known lists: {string.Join(", ", all.Select(l => l.Name))}."); + } + + // ponytail: voller Sweep pro Import (~1,2 s) statt Cache — prüft gleich mit, dass das Item offen und zugewiesen ist; cachen, falls Importe spürbar langsam werden + var (items, _) = await SweepAsync(client, ct); + var item = items.FirstOrDefault(i => i.Id == ticketId); + if (item is null) return Skipped($"Ticket #{ticketId} is not an open item assigned to you."); + + var detail = await client.GetTicketAsync(ticketId, ct); + + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = target.Id, + Title = item.Title, + Description = BuildDescription(detail.Description, item.Id, item.ParentId, item.ParentTitle), + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + TicketRef = reference, + CreatedBy = "ticketsystem", + }; + await new TaskRepository(ctx).AddAsync(task, ct); + + _logger.LogInformation( + "Ticket {ticket_id} als Task {task_id} in Liste {list_id} importiert (Fallback: {used_fallback})", + ticketId, task.Id, target.Id, usedFallback); + + return new TicketItemImportResult(true, task.Id, task.Number, target.Id, target.Name, usedFallback, null); + + static TicketItemImportResult Skipped(string reason) => new(false, null, null, null, null, false, reason); + } + + /// + /// Sweep über alle sichtbaren Projekt-Boards: offene Tickets UND Tasks des Token-Inhabers. + /// Ein Task zählt unabhängig davon, wem sein Parent-Ticket gehört. + /// + private async Task<(IReadOnlyList Items, IReadOnlyList Projects)> SweepAsync( + BandelTicketClient client, CancellationToken ct) + { + var identity = await client.GetIdentityAsync(ct); + var projects = await client.GetProjectsAsync(ct); + var items = new List(); + + foreach (var project in projects) + { + IReadOnlyList board; + try + { + board = await client.GetProjectBoardAsync(project.Id, ct); + } + catch (TicketApiException ex) + { + // Ein unlesbares Board darf den Sweep über die übrigen Projekte nicht abbrechen. + _logger.LogWarning("Board von Projekt {project_id} übersprungen: {reason}", project.Id, ex.Message); + continue; + } + + foreach (var ticket in board) + { + var title = Clean(ticket.Title); + if (ImportableStatuses.Contains(ticket.StatusId) && IsAssignedTo(ticket.TicketAssignees, identity.UserName)) + items.Add(new TicketCandidate(ticket.Id, "ticket", title, ticket.StatusId, project.Id, project.Title, null, null)); + + foreach (var task in ticket.Tasks ?? []) + { + // ponytail: Subtasks (Ebene 3) nicht rekursiv über GET /api/item/{id}/children — live gibt es keine; nachrüsten, sobald sie vorkommen + if (task.HasChildren) continue; + if (!ImportableStatuses.Contains(task.StatusId)) continue; + if (!IsAssignedTo(task.TicketTaskAssignees, identity.UserName)) continue; + items.Add(new TicketCandidate( + task.Id, "task", Clean(task.Title), task.StatusId, project.Id, project.Title, ticket.Id, title)); + } + } + } + + return (items.DistinctBy(i => i.Id).ToList(), projects); + } + + private BandelTicketClient RequireClient() => _clients.Create() + ?? throw new TicketApiException("Ticketsystem ist nicht eingerichtet (Base-URL oder Token fehlt)."); + + /// Alle bereits importierten TicketRefs — listenübergreifend, sonst entstehen Duplikate. + private static async Task> KnownRefsAsync(ClaudeDoDbContext ctx, CancellationToken ct) + => (await ctx.Tasks.AsNoTracking() + .Where(t => t.TicketRef != null) + .Select(t => t.TicketRef!) + .ToListAsync(ct)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + private static string Ref(int ticketId) => $"bandel:{ticketId}"; + + private static string Clean(string title) => WebUtility.HtmlDecode(title).Trim(); + + /// + /// Zuweisung ausschließlich über die Assignee-Liste — das flache assigneeName ist live + /// auf jedem Ticket null (siehe ). Eine Zuweisung an den + /// Token-Inhaber reicht. + /// + private static bool IsAssignedTo(IReadOnlyList? assignees, string userName) + => assignees?.Any(a => string.Equals(a.AssigneeName, userName, StringComparison.OrdinalIgnoreCase)) ?? false; + + private static string BuildDescription(string? html, int id, int? parentId, string? parentTitle) + { + var body = string.IsNullOrWhiteSpace(html) ? "" : HtmlToMarkdown(html); + var footer = $"Ticket #{id}" + (parentId is { } p ? $"\n\nTeil von #{p} {parentTitle}" : ""); + return (body.Length > 0 ? body + "\n\n" : "") + footer; + } +``` + +`ImportAsync`'s own `Ref`: replace `var reference = $"bandel:{ticket.Id}";` with `var reference = Ref(ticket.Id);`. + +- [ ] **Step 5: Build and run scoped tests** + +```bash +dotnet build ClaudeDo.slnx -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~Tests.Tickets" +``` +Expected: all PASS (new + existing Tickets tests). + +- [ ] **Step 6: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Tickets/TicketImportAllTests.cs +git commit -m "feat(tickets): sweep all project boards for assigned tickets and tasks" -- \ + src/ClaudeDo.Worker/Tickets/TicketDtos.cs src/ClaudeDo.Worker/Tickets/BandelTicketClient.cs \ + src/ClaudeDo.Worker/Tickets/TicketImportService.cs \ + tests/ClaudeDo.Worker.Tests/Tickets/TicketImportAllTests.cs tests/ClaudeDo.Worker.Tests/Tickets/TicketImportServiceTests.cs +``` + +--- + +### Task 3: External MCP tools `get_ticket_import_candidates` / `import_ticket` + +**Files:** +- Create: `src/ClaudeDo.Worker/External/TicketMcpTools.cs` +- Modify: `src/ClaudeDo.Worker/Program.cs` (the `.WithTools<...>()` chain ~line 403–420) +- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs` (`ExpectedToolNames`) +- Create: `tests/ClaudeDo.Worker.Tests/External/TicketMcpToolsTests.cs` + +- [ ] **Step 1: Write the failing tests** + +In `ExternalMcpToolSchemaTests.cs` add `"get_ticket_import_candidates", "import_ticket",` to `ExpectedToolNames` (e.g. on a new line after `"add_note",`). + +Create `tests/ClaudeDo.Worker.Tests/External/TicketMcpToolsTests.cs`: + +```csharp +using ClaudeDo.Worker; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Tickets; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClaudeDo.Worker.Tests.External; + +/// Only the not-configured path — the import logic itself is covered by TicketImportAllTests. +public sealed class TicketMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly string _patDir = Path.Combine(Path.GetTempPath(), "cdo-ticketmcp-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + _db.Dispose(); + try { Directory.Delete(_patDir, recursive: true); } catch { /* best effort */ } + } + + private TicketMcpTools Sut() + { + var config = new TicketSystemConfig(new WorkerConfig(), new TicketPatStore(new DpapiTokenStore(Path.Combine(_patDir, "ticket.pat")))); + var clients = new TicketClientFactory(config, null!, NullLogger.Instance); + var import = new TicketImportService(clients, _db.CreateFactory(), NullLogger.Instance); + return new TicketMcpTools(import, new HubBroadcaster(new CapturingHubContext())); + } + + [Fact] + public async Task Candidates_report_unavailable_instead_of_throwing_when_not_configured() + { + var dto = await Sut().GetTicketImportCandidates(CancellationToken.None); + + Assert.False(dto.Available); + Assert.False(string.IsNullOrWhiteSpace(dto.Reason)); + Assert.Empty(dto.Items); + } + + [Fact] + public async Task Import_reports_skipped_instead_of_throwing_when_not_configured() + { + var result = await Sut().ImportTicket(1, null, CancellationToken.None); + + Assert.False(result.Created); + Assert.False(string.IsNullOrWhiteSpace(result.SkippedReason)); + } +} +``` + +- [ ] **Step 2: Build — expect compile errors.** `dotnet build ClaudeDo.slnx -c Release` + +- [ ] **Step 3: Implement** + +Create `src/ClaudeDo.Worker/External/TicketMcpTools.cs`: + +```csharp +using System.ComponentModel; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tickets; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +/// get_ticket_import_candidates payload; Available=false carries the reason instead of an error. +public sealed record TicketImportCandidatesDto( + bool Available, + string? Reason, + IReadOnlyList Items, + IReadOnlyList Lists, + ImportTargetList? FallbackList); + +/// import_ticket confirmation; Created=false always carries SkippedReason. +public sealed record ImportTicketResult( + bool Created, int? TaskNumber, string? ListName, bool UsedFallback, string? SkippedReason); + +/// Cross-project ticket import for the ImportTickets Prime run. Claude never needs the ticket MCP or the PAT. +[McpServerToolType] +public sealed class TicketMcpTools +{ + private readonly TicketImportService _import; + private readonly HubBroadcaster _broadcaster; + + public TicketMcpTools(TicketImportService import, HubBroadcaster broadcaster) + { + _import = import; + _broadcaster = broadcaster; + } + + [McpServerTool, Description( + "Ticket import: the open ticket-system items assigned to me that are not a ClaudeDo task yet, plus every " + + "list as a target and the fallback list. Place each item in the list whose purpose fits its content; a " + + "list's ticketProject is only a hint. available=false means the ticket system is not set up — see reason.")] + public async Task GetTicketImportCandidates(CancellationToken cancellationToken) + { + try + { + var c = await _import.GetCandidatesAsync(cancellationToken); + return new TicketImportCandidatesDto(true, null, c.Items, c.Lists, c.FallbackList); + } + catch (TicketApiException ex) + { + return new TicketImportCandidatesDto(false, ex.Message, [], [], null); + } + } + + [McpServerTool, Description( + "Ticket import: create one Idle task from an item returned by get_ticket_import_candidates. Omit list when " + + "no list clearly fits — the item then goes to the fallback list, or is skipped when none is set. " + + "created=false always carries skippedReason. Refer to a created task as # when reporting.")] + public async Task ImportTicket( + [Description("Item id from get_ticket_import_candidates.")] int ticketId, + [Description("Target list id or name. Omit to use the fallback list.")] string? list = null, + CancellationToken cancellationToken = default) + { + TicketItemImportResult r; + try + { + r = await _import.ImportItemAsync(ticketId, list, cancellationToken); + } + catch (TicketApiException ex) + { + return new ImportTicketResult(false, null, null, false, ex.Message); + } + + if (r.Created) + { + // Same out-of-band-creator rule as the hub's ImportTickets: new rows only appear via TaskUpdated. + await _broadcaster.TaskUpdated(r.TaskId!); + await _broadcaster.ListUpdated(r.ListId!); + } + return new ImportTicketResult(r.Created, r.TaskNumber, r.ListName, r.UsedFallback, r.SkippedReason); + } +} +``` + +`Program.cs`: append `.WithTools()` after `.WithTools()` (move the `;`). + +- [ ] **Step 4: Build and run scoped tests** + +```bash +dotnet build ClaudeDo.slnx -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~ExternalMcpToolSchemaTests|FullyQualifiedName~TicketMcpToolsTests" +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/External/TicketMcpTools.cs tests/ClaudeDo.Worker.Tests/External/TicketMcpToolsTests.cs +git commit -m "feat(mcp): add get_ticket_import_candidates and import_ticket tools" -- \ + src/ClaudeDo.Worker/External/TicketMcpTools.cs src/ClaudeDo.Worker/Program.cs \ + tests/ClaudeDo.Worker.Tests/External/TicketMcpToolsTests.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs +``` + +--- + +### Task 4: PrimeRunner — ImportTickets = import step + FillMyDay step in one gate + +**Files:** +- Create: `src/ClaudeDo.Worker/Prime/TicketImportPrompt.cs` +- Modify: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Prime/TicketImportPromptTests.cs` + +`PrimeRunner` gets a new ctor dependency `TicketSystemConfig tickets` (last parameter; DI already registers it as a singleton in `Program.cs`). + +- [ ] **Step 1: Write the failing tests** + +Create `tests/ClaudeDo.Worker.Tests/Prime/TicketImportPromptTests.cs`: + +```csharp +using ClaudeDo.Worker.Prime; + +namespace ClaudeDo.Worker.Tests.Prime; + +public class TicketImportPromptTests +{ + [Fact] + public void Args_allow_only_the_two_ticket_tools() + { + var args = TicketImportPrompt.BuildArgs(); + var i = args.ToList().IndexOf("--allowedTools"); + + Assert.Equal([TicketImportPrompt.CandidatesTool, TicketImportPrompt.ImportTool], args.Skip(i + 1)); + Assert.Contains("acceptEdits", args); + } + + [Fact] + public void Override_is_appended_never_substituted() + { + var baseline = TicketImportPrompt.BuildPrompt(); + var prompt = TicketImportPrompt.BuildPrompt("Lager items go to Hub."); + + Assert.StartsWith(baseline, prompt); + Assert.EndsWith("Lager items go to Hub.", prompt); + Assert.Equal(baseline, TicketImportPrompt.BuildPrompt(" ")); + } +} +``` + +In `PrimeRunnerTests.cs`: + +1. Add usings: `using ClaudeDo.Worker;`, `using ClaudeDo.Worker.Config;`, `using ClaudeDo.Worker.Tickets;`. +2. Extend `FakeClaudeProcess`: add ctor parameter `Func, int>? exitCodeFor = null` (stored in a field) and a property `public List> AllArguments { get; } = [];`. In `RunAsync`: `AllArguments.Add(arguments);` first, and compute `var exitCode = _exitCodeFor?.Invoke(arguments) ?? _exitCode;` — use `exitCode` for both `ExitCode` and the `ResultMarkdown` condition. +3. Make the class own a temp PAT dir and a ticket config; replace the second `NewRunner` overload: + +```csharp + private readonly string _patDir = Path.Combine(Path.GetTempPath(), "cdo-prime-pat-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + _db.Dispose(); + try { Directory.Delete(_patDir, recursive: true); } catch { /* best effort */ } + } + + private TicketSystemConfig Tickets(bool configured) + { + var pat = new TicketPatStore(new DpapiTokenStore(Path.Combine(_patDir, "ticket.pat"))); + var worker = new WorkerConfig(); + if (configured) + { + worker.TicketApiBaseUrl = "http://api.local"; + pat.Store.Save("tsp_x"); + } + return new TicketSystemConfig(worker, pat); + } + + private PrimeRunner NewRunner(FakeClaudeProcess claude, IPrimeBroadcaster broadcaster, bool ticketsConfigured = false) => + new PrimeRunner( + claude, + _db.CreateFactory(), + new FakeClock(), + NullLogger.Instance, + broadcaster, + Tickets(ticketsConfigured)); +``` +(Delete the old one-line `Dispose`.) + +4. Add tests: + +```csharp + [Fact] + public async Task ImportTickets_runs_the_import_then_the_day_plan_in_one_prep_session() + { + var broadcaster = new RecordingPrimeBroadcaster(); + var claude = new FakeClaudeProcess(exitCode: 0, result: "ok"); + var runner = NewRunner(claude, broadcaster, ticketsConfigured: true); + + var outcome = await runner.FireAsync(Schedule(PrimeActionKind.ImportTickets), CancellationToken.None); + + Assert.True(outcome.Success); + Assert.Equal(2, claude.AllArguments.Count); + Assert.Contains(TicketImportPrompt.ImportTool, claude.AllArguments[0]); + Assert.Contains(DailyPrepPrompt.SetMyDayTool, claude.AllArguments[1]); + Assert.Equal(1, broadcaster.StartedCount); + Assert.Equal([true], broadcaster.FinishedResults); + } + + [Fact] + public async Task ImportTickets_failed_import_still_plans_the_day_and_reports_the_failure() + { + var broadcaster = new RecordingPrimeBroadcaster(); + var claude = new FakeClaudeProcess(exitCodeFor: args => args.Contains(TicketImportPrompt.ImportTool) ? 1 : 0); + var runner = NewRunner(claude, broadcaster, ticketsConfigured: true); + + var outcome = await runner.FireAsync(Schedule(PrimeActionKind.ImportTickets), CancellationToken.None); + + Assert.Equal(2, claude.AllArguments.Count); + Assert.False(outcome.Success); + Assert.Contains("Ticket import failed", outcome.Message); + Assert.Contains("Daily prep complete", outcome.Message); + Assert.Equal([false], broadcaster.FinishedResults); + } + + [Fact] + public async Task ImportTickets_without_a_ticket_system_is_plain_fill_my_day() + { + var claude = new FakeClaudeProcess(exitCode: 0, result: "ok"); + var runner = NewRunner(claude, new RecordingPrimeBroadcaster(), ticketsConfigured: false); + + var outcome = await runner.FireAsync(Schedule(PrimeActionKind.ImportTickets), CancellationToken.None); + + Assert.True(outcome.Success); + var only = Assert.Single(claude.AllArguments); + Assert.Contains(DailyPrepPrompt.SetMyDayTool, only); + } + + [Fact] + public async Task ImportTickets_writes_both_runs_into_one_prep_log() + { + var path = DailyPrepPrompt.LogPath(); + if (File.Exists(path)) File.Delete(path); + var claude = new FakeClaudeProcess(emitLines: ["step-line"], exitCode: 0, result: "ok"); + var runner = NewRunner(claude, new RecordingPrimeBroadcaster(), ticketsConfigured: true); + + await runner.FireAsync(Schedule(PrimeActionKind.ImportTickets), CancellationToken.None); + + var lines = (await File.ReadAllLinesAsync(path)).Count(l => l.Contains("step-line")); + Assert.Equal(2, lines); + } +``` + +- [ ] **Step 2: Build — expect compile errors.** `dotnet build ClaudeDo.slnx -c Release` + +- [ ] **Step 3: Implement `TicketImportPrompt.cs`** + +```csharp +using ClaudeDo.Data; + +namespace ClaudeDo.Worker.Prime; + +/// Prompt and CLI args for the ticket-import step of an ImportTickets Prime run. +public static class TicketImportPrompt +{ + public const string CandidatesTool = "mcp__claudedo__get_ticket_import_candidates"; + public const string ImportTool = "mcp__claudedo__import_ticket"; + + /// One import call per item, so the budget scales with the backlog — daily prep's 30 would cap it at ~25 items. + public const int MaxTurns = 60; + + public static IReadOnlyList BuildArgs() => + [ + "-p", "--output-format", "stream-json", "--verbose", + "--permission-mode", "acceptEdits", + "--max-turns", MaxTurns.ToString(), + "--allowedTools", CandidatesTool, ImportTool, + ]; + + /// A schedule's override is appended, never substituted — same rule as . + public static string BuildPrompt(string? promptOverride = null) + { + var prompt = PromptFiles.Render(PromptKind.TicketImport, new Dictionary()); + return string.IsNullOrWhiteSpace(promptOverride) ? prompt : $"{prompt}\n\n{promptOverride}"; + } +} +``` + +- [ ] **Step 4: Rewrite `PrimeRunner.cs`** + +Add `using ClaudeDo.Worker.Tickets;`, a field `private readonly TicketSystemConfig _tickets;`, a last ctor parameter `TicketSystemConfig tickets` assigned to it. Then replace `FireAsync`'s switch and the whole `RunFillMyDayAsync` method with: + +```csharp + return schedule.Kind switch + { + PrimeActionKind.FillMyDay => await RunPrepAsync("Daily prep", log => FillMyDayStepAsync(schedule, log, ct)), + PrimeActionKind.ImportTickets => await RunImportTicketsAsync(schedule, ct), + PrimeActionKind.Custom => await RunCustomAsync(schedule, ct), + _ => await RunPingAsync(ct), + }; +``` + +```csharp + /// Ticket import, then FillMyDay — one log, one PrepStarted/PrepFinished pair. A failed import never + /// blocks the day plan (the existing tasks are still worth planning); without a ticket system it is plain FillMyDay. + private Task RunImportTicketsAsync(PrimeScheduleDto schedule, CancellationToken ct) + { + if (!_tickets.IsConfigured) + return RunPrepAsync("Daily prep", log => FillMyDayStepAsync(schedule, log, ct)); + + return RunPrepAsync("Ticket import", async log => + { + PrimeRunOutcome import; + try + { + import = await RunLoggedAsync( + TicketImportPrompt.BuildArgs(), TicketImportPrompt.BuildPrompt(schedule.PromptOverride), + log, "Tickets imported", ct); + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + _logger.LogWarning(ex, "Ticket import step failed"); + import = new PrimeRunOutcome(false, ex.Message); + } + + var day = await FillMyDayStepAsync(schedule, log, ct); + return import.Success + ? day with { Message = $"Tickets imported; {day.Message}" } + : new PrimeRunOutcome(false, $"Ticket import failed ({import.Message}); {day.Message}"); + }); + } + + /// Truncates the prep log once and brackets with PrepStarted/PrepFinished. + private async Task RunPrepAsync(string label, Func> body) + { + var success = false; + try + { + var logPath = DailyPrepPrompt.LogPath(); + try { if (File.Exists(logPath)) File.Delete(logPath); } catch { /* best effort */ } + await using var logWriter = new LogWriter(logPath); + + await _broadcaster.PrepStartedAsync(); + + var outcome = await body(logWriter); + success = outcome.Success; + return outcome; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "{Label} run failed", label); + return new PrimeRunOutcome(false, ex.Message); + } + finally + { + await _broadcaster.PrepFinishedAsync(success); + } + } + + /// The MyDay selection run, writing into an already-open prep log. + private async Task FillMyDayStepAsync(PrimeScheduleDto schedule, LogWriter log, CancellationToken ct) + { + int maxTasks; + await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct)) + { + var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct); + maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks; + } + + var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime); + return await RunLoggedAsync( + DailyPrepPrompt.BuildArgs(MaxTurns), + DailyPrepPrompt.BuildPrompt(maxTasks, today, schedule.PromptOverride), + log, "Daily prep complete", ct); + } + + /// One claude run streaming into the prep log and the UI; a timeout is an outcome, not an exception. + private async Task RunLoggedAsync( + IReadOnlyList args, string prompt, LogWriter log, string successMessage, CancellationToken ct) + { + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(FireTimeout); + + var result = await _claude.RunAsync( + arguments: args, + prompt: prompt, + workingDirectory: Cwd(), + onStdoutLine: async line => + { + await log.WriteLineAsync(line); + await _broadcaster.PrepLineAsync(line); + }, + ct: timeoutCts.Token); + + return result.IsSuccess + ? new PrimeRunOutcome(true, successMessage) + : new PrimeRunOutcome(false, $"exit code {result.ExitCode}"); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min"); + } + } +``` + +`RunPingAsync`, `RunCustomAsync`, `RunQuietAsync`, `Cwd` stay unchanged. Check `LogWriter`'s namespace (`ClaudeDo.Worker.Runner`, already imported). + +- [ ] **Step 5: Build and run scoped tests** + +```bash +dotnet build ClaudeDo.slnx -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~Tests.Prime" +``` +Expected: all PASS (existing PrimeRunner/DailyPrep/Scheduler tests unchanged in behaviour). + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/Prime/TicketImportPrompt.cs tests/ClaudeDo.Worker.Tests/Prime/TicketImportPromptTests.cs +git commit -m "feat(prime): ImportTickets runs the ticket import before filling MyDay" -- \ + src/ClaudeDo.Worker/Prime/TicketImportPrompt.cs src/ClaudeDo.Worker/Prime/PrimeRunner.cs \ + tests/ClaudeDo.Worker.Tests/Prime/TicketImportPromptTests.cs tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs +``` + +--- + +### Task 5: Hub — run now, fallback list read/write + +**Files:** +- Modify: `src/ClaudeDo.Data/Wire.cs:225` +- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (`RunDailyPrepNow` ~1031, `GetTicketSettings` ~1366) +- Create: `tests/ClaudeDo.Worker.Tests/Hub/TicketFallbackHubTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Worker.Tests/Hub/TicketFallbackHubTests.cs` (hub construction copied from `ClearMyDayHubTests`, plus the named `ticketConfig` argument): + +```csharp +using ClaudeDo.Worker; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Tickets; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Hub; + +public sealed class TicketFallbackHubTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly string _patDir = Path.Combine(Path.GetTempPath(), "cdo-hubpat-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + _db.Dispose(); + try { Directory.Delete(_patDir, recursive: true); } catch { /* best effort */ } + } + + private WorkerHub CreateHub() + { + var ticketConfig = new TicketSystemConfig( + new WorkerConfig(), new TicketPatStore(new DpapiTokenStore(Path.Combine(_patDir, "ticket.pat")))); + var hub = new WorkerHub( + null!, null!, null!, null!, new HubBroadcaster(new CapturingHubContext()), _db.CreateFactory(), + null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new DpapiTokenStore("unused.token")), + new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!, + ticketConfig: ticketConfig); + hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); + hub.Context = new FakeHubCallerContext(); + return hub; + } + + [Fact] + public async Task Fallback_list_set_through_the_hub_comes_back_in_the_ticket_settings() + { + var hub = CreateHub(); + + await hub.SetTicketFallbackList("l-fallback"); + var dto = await hub.GetTicketSettings(); + + Assert.Equal("l-fallback", dto.FallbackListId); + Assert.False(dto.TokenSet); + + await hub.SetTicketFallbackList(null); + Assert.Null((await hub.GetTicketSettings()).FallbackListId); + } +} +``` + +If the positional argument count no longer matches the current `WorkerHub` ctor, copy the current construction from `ClearMyDayHubTests.CreateHub` and only append `ticketConfig: ticketConfig`. + +- [ ] **Step 2: Build — expect compile errors.** `dotnet build ClaudeDo.slnx -c Release` + +- [ ] **Step 3: Implement** + +`Wire.cs:225`: + +```csharp +public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet, string? FallbackListId = null); +``` + +`WorkerHub.cs` — replace `GetTicketSettings`: + +```csharp + // Liefert die EFFEKTIVE Base-URL (inkl. ergänztem Schema), nicht den rohen Feldwert: sonst + // sieht der Nutzer im Tab weiter "host.local", während der Client "http://host.local" ruft. + public async Task GetTicketSettings() + { + using var ctx = _dbFactory.CreateDbContext(); + var settings = await new AppSettingsRepository(ctx).GetAsync(Context.ConnectionAborted); + return new TicketSettingsDto(_ticketConfig.BaseUrl, _ticketConfig.Token is not null, settings.TicketFallbackListId); + } + + /// Sets the list that import_ticket uses when Claude passes no list; null clears it. + public async Task SetTicketFallbackList(string? listId) + { + using var ctx = _dbFactory.CreateDbContext(); + await new AppSettingsRepository(ctx).SetTicketFallbackListIdAsync(listId, Context.ConnectionAborted); + } +``` + +Replace `RunDailyPrepNow` with: + +```csharp + public Task RunDailyPrepNow() => RunPrimeNowAsync(ClaudeDo.Data.Models.PrimeActionKind.FillMyDay); + + /// The "Tickets + plan day" button: ticket import, then FillMyDay, through the same Prime gate. + public Task RunTicketImportNow() => RunPrimeNowAsync(ClaudeDo.Data.Models.PrimeActionKind.ImportTickets); + + // A button means its own kind no matter how the schedules are configured — hence a synthetic schedule. + private async Task RunPrimeNowAsync(ClaudeDo.Data.Models.PrimeActionKind kind) + { + var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null, kind); + var firedAt = DateTimeOffset.Now; + var outcome = await _primeRunner.FireAsync(schedule, Context.ConnectionAborted); + await _broadcaster.PrimeFired(Guid.Empty, outcome.Success, outcome.Message, firedAt); + return outcome.Success; + } +``` + +No hub method may take a `CancellationToken` parameter (breaks every SignalR call) — none above does. + +- [ ] **Step 4: Build and run scoped tests** + +```bash +dotnet build ClaudeDo.slnx -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~TicketFallbackHubTests|FullyQualifiedName~ClearMyDayHubTests" +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/ClaudeDo.Worker.Tests/Hub/TicketFallbackHubTests.cs +git commit -m "feat(hub): add RunTicketImportNow and ticket fallback list endpoints" -- \ + src/ClaudeDo.Data/Wire.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Hub/TicketFallbackHubTests.cs +``` + +--- + +### Task 6: UI — Prep button, fallback dropdown, Prime radio, locales + +**Files:** +- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` (~line 192 and ~246), `src/ClaudeDo.Ui/Services/WorkerClient.cs` (~504 and ~758) +- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` +- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs`, `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (`ShowPrep` ~579), `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` (~140) +- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs`, `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs:111`, `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` (Tickets tab ~740, Prime radios ~359) +- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs`, `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs:41` +- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json` +- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/PrepPanelViewModelTests.cs`, create `tests/ClaudeDo.Ui.Tests/ViewModels/TicketSettingsTabViewModelTests.cs`, `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs` + +- [ ] **Step 1: Worker client surface + stub** (needed for the tests to compile) + +`IWorkerClient.cs` — next to `RunDailyPrepNowAsync`: +```csharp + /// Ticket import, then the daily prep, in one Prime run. + Task RunTicketImportNowAsync(); +``` +next to the ticket members: +```csharp + /// Sets the fallback list for imported ticket items; null clears it. + Task SetTicketFallbackListAsync(string? listId); +``` + +`WorkerClient.cs`: +```csharp + public Task RunTicketImportNowAsync() + => InvokeTimedAsync("RunTicketImportNow", () => _hub.InvokeAsync("RunTicketImportNow")); +``` +```csharp + public Task SetTicketFallbackListAsync(string? listId) + => InvokeTimedAsync("SetTicketFallbackList", () => _hub.InvokeAsync("SetTicketFallbackList", listId)); +``` + +`StubWorkerClient.cs` — next to `RunDailyPrepNowCalls` / `RunDailyPrepNowAsync`: +```csharp + public int RunTicketImportNowCalls { get; private set; } + public virtual Task RunTicketImportNowAsync() { RunTicketImportNowCalls++; return Task.FromResult(false); } +``` +next to the ticket members: +```csharp + public virtual Task SetTicketFallbackListAsync(string? listId) => Task.CompletedTask; +``` +Search the test projects for any other `IWorkerClient` implementation (`grep -rn ": IWorkerClient" tests`) and add the two members there too. + +- [ ] **Step 2: Write the failing tests** + +Append to `PrepPanelViewModelTests.cs`: + +```csharp + private sealed class TicketWorker : StubWorkerClient + { + public TicketSettingsDto? Settings { get; set; } + public override Task GetTicketSettingsAsync() => Task.FromResult(Settings); + } + + [Theory] + [InlineData("http://api", true, true)] + [InlineData("http://api", false, false)] + [InlineData(null, true, false)] + public async Task TicketsConfigured_needs_base_url_and_token(string? url, bool tokenSet, bool expected) + { + var worker = new TicketWorker { Settings = new TicketSettingsDto(url, tokenSet) }; + var vm = new PrepPanelViewModel(worker); + + await vm.RefreshTicketsConfiguredAsync(); + + Assert.Equal(expected, vm.TicketsConfigured); + } + + [Fact] + public async Task ImportTicketsAndPlanDay_calls_the_worker_and_shares_the_plan_day_lock() + { + var worker = new Worker(); + var vm = new PrepPanelViewModel(worker); + + var plan = vm.PlanDayCommand.ExecuteAsync(null); + Assert.False(vm.ImportTicketsAndPlanDayCommand.CanExecute(null)); + worker.Gate.SetResult(true); + await plan; + + await vm.ImportTicketsAndPlanDayCommand.ExecuteAsync(null); + Assert.Equal(1, worker.RunTicketImportNowCalls); + } +``` +(Add `using ClaudeDo.Data.Wire;` if `TicketSettingsDto` doesn't resolve.) + +Create `tests/ClaudeDo.Ui.Tests/ViewModels/TicketSettingsTabViewModelTests.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Modals.Settings; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public sealed class TicketSettingsTabViewModelTests : IDisposable +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ticketsettings_{Guid.NewGuid():N}.db"); + + public TicketSettingsTabViewModelTests() + { + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "Hub", CreatedAt = DateTime.UtcNow, SortOrder = 0 }); + ctx.Lists.Add(new ListEntity { Id = "l2", Name = "Inbox", CreatedAt = DateTime.UtcNow, SortOrder = 1 }); + ctx.SaveChanges(); + } + + public void Dispose() + { + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(_dbPath + suffix); } catch { } + } + + private ClaudeDoDbContext NewContext() => new(new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}").Options); + + private sealed class Worker : StubWorkerClient + { + public TicketSettingsDto? Settings { get; set; } + public List SavedFallbacks { get; } = new(); + public override Task GetTicketSettingsAsync() => Task.FromResult(Settings); + public override Task SetTicketFallbackListAsync(string? listId) { SavedFallbacks.Add(listId); return Task.CompletedTask; } + } + + [Fact] + public async Task Load_offers_none_plus_every_list_and_selects_the_stored_fallback() + { + var worker = new Worker { Settings = new TicketSettingsDto("http://api", true, "l2") }; + var vm = new TicketSettingsTabViewModel(worker, new TestDbFactory(NewContext)); + + await vm.LoadAsync(); + + Assert.Equal([null, "l1", "l2"], vm.FallbackLists.Select(o => o.Id)); + Assert.Equal("l2", vm.SelectedFallbackList?.Id); + } + + [Fact] + public async Task Save_writes_the_selected_fallback_and_none_as_null() + { + var worker = new Worker { Settings = new TicketSettingsDto("http://api", true, "l2") }; + var vm = new TicketSettingsTabViewModel(worker, new TestDbFactory(NewContext)); + await vm.LoadAsync(); + + vm.SelectedFallbackList = vm.FallbackLists[0]; + await vm.SaveAsync(); + + Assert.Equal([null], worker.SavedFallbacks); + } + + [Fact] + public async Task Save_does_not_touch_the_fallback_when_the_load_failed() + { + var worker = new Worker { Settings = null }; + var vm = new TicketSettingsTabViewModel(worker, new TestDbFactory(NewContext)); + await vm.LoadAsync(); + + await vm.SaveAsync(); + + Assert.Empty(worker.SavedFallbacks); + } +} +``` + +Append to `PrimeClaudeTabViewModelTests.cs` a test that `PrimeScheduleRowViewModel.IsImportTickets = true` sets `Kind == PrimeActionKind.ImportTickets` and that the tab's `AnyFillMyDay` becomes true for such a row — build the row/tab the same way the existing tests in that file do. + +- [ ] **Step 3: Build — expect compile errors.** `dotnet build ClaudeDo.slnx -c Release` + +- [ ] **Step 4: Implement the Prep panel** + +`PrepPanelViewModel.cs`: +- Add `using ClaudeDo.Ui.ViewModels.Modals.Settings;` (for `TicketSettingsTabViewModel.FeatureEnabled`). +- Field: +```csharp + // The button only makes sense with a configured ticket system — otherwise it would just plan the day. + [ObservableProperty] private bool _ticketsConfigured; +``` +- In the `PrepOperation.PropertyChanged` handler, after `PlanDayCommand.NotifyCanExecuteChanged();` add `ImportTicketsAndPlanDayCommand.NotifyCanExecuteChanged();`. +- Members: +```csharp + [RelayCommand(CanExecute = nameof(CanPlanDay))] + private async System.Threading.Tasks.Task ImportTicketsAndPlanDayAsync() + { + using var op = PrepOperation.Begin(Loc.T("ops.reports.runningTicketImport")); + try { await _worker.RunTicketImportNowAsync(); } + catch { } + } + + /// Reads whether the ticket system has a base URL and a token; keeps the last value when the worker is unreachable. + public async System.Threading.Tasks.Task RefreshTicketsConfiguredAsync() + { + if (!TicketSettingsTabViewModel.FeatureEnabled) { TicketsConfigured = false; return; } + TicketSettingsDto? dto; + try { dto = await _worker.GetTicketSettingsAsync(); } + catch { return; } + if (dto is null) return; + TicketsConfigured = !string.IsNullOrWhiteSpace(dto.ApiBaseUrl) && dto.TokenSet; + } +``` + +`DetailsIslandViewModel.ShowPrep()` — after `_ = Prep.LoadLastPrepLogIfEmptyAsync();` add `_ = Prep.RefreshTicketsConfiguredAsync();`. + +`DetailsIslandView.axaml` — inside the Prep-mode `StackPanel`, right after the Plan-day `Button`: +```xml +