Files
ClaudeDo/docs/superpowers/plans/2026-10-01-ticket-import-all.md

80 KiB
Raw Permalink Blame History

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 "..." -- <path> <path> ... (after git add <paths> 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.
  • /// <summary> 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<TicketMcpTools>()
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):

    [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:

    [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):

    [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):

    /// <summary>Import the assigned ticket-system items into lists by content, then fill MyDay.</summary>
    ImportTickets

PrimeScheduleEntityConfiguration.cs — extend both converter functions:

    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:

    // 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(...):

        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):

    /// <summary>Writes only the ticket fallback list; blank clears it.</summary>
    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:
    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<string> (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
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
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):

    [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:

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;

/// <summary>
/// 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.
/// </summary>
public sealed class TicketImportAllTests : IDisposable
{
    private readonly List<DbFixture> _dbs = new();
    private readonly List<string> _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<TicketApiException>(() => 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":"<p>Body {{id}}</p>","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<BandelTicketClient>.Instance);
        var import = new TicketImportService(clients, factory, NullLogger<TicketImportService>.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):

    [property: JsonPropertyName("ticketAssignees")] IReadOnlyList<TicketAssignee>? TicketAssignees = null,
    [property: JsonPropertyName("tasks")] IReadOnlyList<TicketBoardTask>? Tasks = null);

and add these records below TicketAssignee:

/// Ein Task unter einem Board-Item (<c>tasks[]</c>). ⚠️ Die Zuweisung steht in
/// <c>ticketTaskAssignees</c>, NICHT in <c>ticketAssignees</c> (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<TicketAssignee>? 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:

    /// <summary>Details eines Tickets oder Tasks (Beschreibung, Parent).</summary>
    public Task<TicketDetail> GetTicketAsync(int ticketId, CancellationToken ct)
        => SendAsync<TicketDetail>(HttpMethod.Get, $"/api/ticket/{ticketId}", null, ct);
  • Step 4: Implement the service

In TicketImportService.cs add the records below TicketImportResult:

/// <summary>Ein offenes, dem Token-Inhaber zugewiesenes Ticket oder Task aus dem Board-Sweep.</summary>
public sealed record TicketCandidate(
    int Id, string Kind, string Title, int StatusId, int ProjectId, string ProjectTitle,
    int? ParentId, string? ParentTitle);

/// <summary>Eine ClaudeDo-Liste als Zuordnungsziel; <see cref="TicketProject"/> ist nur ein Hinweis.</summary>
public sealed record ImportTargetList(string Id, string Name, string? WorkingDir, string? TicketProject);

/// <summary>Was der Zuordnungs-Lauf braucht: offene Items, alle Listen, die Fallback-Liste.</summary>
public sealed record TicketImportCandidates(
    IReadOnlyList<TicketCandidate> Items, IReadOnlyList<ImportTargetList> Lists, ImportTargetList? FallbackList);

/// <summary>Ergebnis eines Einzel-Imports; <see cref="Created"/> = false trägt immer einen Grund.</summary>
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:
    /// <summary>Offene, zugewiesene Items aus allen Projekten, die noch in keiner Liste importiert sind, plus Zuordnungskontext.</summary>
    public async Task<TicketImportCandidates> 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));
    }

    /// <summary>
    /// Legt für ein offenes, zugewiesenes Item einen Idle-Task an. <paramref name="list"/> 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.
    /// </summary>
    public async Task<TicketItemImportResult> 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);
    }

    /// <summary>
    /// 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.
    /// </summary>
    private async Task<(IReadOnlyList<TicketCandidate> Items, IReadOnlyList<TicketProject> Projects)> SweepAsync(
        BandelTicketClient client, CancellationToken ct)
    {
        var identity = await client.GetIdentityAsync(ct);
        var projects = await client.GetProjectsAsync(ct);
        var items = new List<TicketCandidate>();

        foreach (var project in projects)
        {
            IReadOnlyList<TicketBoardItem> 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).");

    /// <summary>Alle bereits importierten TicketRefs — listenübergreifend, sonst entstehen Duplikate.</summary>
    private static async Task<HashSet<string>> 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();

    /// <summary>
    /// Zuweisung ausschließlich über die Assignee-Liste — das flache <c>assigneeName</c> ist live
    /// auf jedem Ticket null (siehe <see cref="TicketBoardItem"/>). Eine Zuweisung an den
    /// Token-Inhaber reicht.
    /// </summary>
    private static bool IsAssignedTo(IReadOnlyList<TicketAssignee>? 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
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
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:

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;

/// <summary>Only the not-configured path — the import logic itself is covered by TicketImportAllTests.</summary>
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<BandelTicketClient>.Instance);
        var import = new TicketImportService(clients, _db.CreateFactory(), NullLogger<TicketImportService>.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:

using System.ComponentModel;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tickets;
using ModelContextProtocol.Server;

namespace ClaudeDo.Worker.External;

/// <summary>get_ticket_import_candidates payload; Available=false carries the reason instead of an error.</summary>
public sealed record TicketImportCandidatesDto(
    bool Available,
    string? Reason,
    IReadOnlyList<TicketCandidate> Items,
    IReadOnlyList<ImportTargetList> Lists,
    ImportTargetList? FallbackList);

/// <summary>import_ticket confirmation; Created=false always carries SkippedReason.</summary>
public sealed record ImportTicketResult(
    bool Created, int? TaskNumber, string? ListName, bool UsedFallback, string? SkippedReason);

/// <summary>Cross-project ticket import for the ImportTickets Prime run. Claude never needs the ticket MCP or the PAT.</summary>
[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<TicketImportCandidatesDto> 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 #<taskNumber> when reporting.")]
    public async Task<ImportTicketResult> 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<TicketMcpTools>() after .WithTools<NotesMcpTools>() (move the ;).

  • Step 4: Build and run scoped tests
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
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:

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<IReadOnlyList<string>, int>? exitCodeFor = null (stored in a field) and a property public List<IReadOnlyList<string>> 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:
    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<PrimeRunner>.Instance,
            broadcaster,
            Tickets(ticketsConfigured));

(Delete the old one-line Dispose.)

  1. Add tests:
    [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

using ClaudeDo.Data;

namespace ClaudeDo.Worker.Prime;

/// <summary>Prompt and CLI args for the ticket-import step of an ImportTickets Prime run.</summary>
public static class TicketImportPrompt
{
    public const string CandidatesTool = "mcp__claudedo__get_ticket_import_candidates";
    public const string ImportTool = "mcp__claudedo__import_ticket";

    /// <summary>One import call per item, so the budget scales with the backlog — daily prep's 30 would cap it at ~25 items.</summary>
    public const int MaxTurns = 60;

    public static IReadOnlyList<string> BuildArgs() =>
    [
        "-p", "--output-format", "stream-json", "--verbose",
        "--permission-mode", "acceptEdits",
        "--max-turns", MaxTurns.ToString(),
        "--allowedTools", CandidatesTool, ImportTool,
    ];

    /// <summary>A schedule's override is appended, never substituted — same rule as <see cref="DailyPrepPrompt"/>.</summary>
    public static string BuildPrompt(string? promptOverride = null)
    {
        var prompt = PromptFiles.Render(PromptKind.TicketImport, new Dictionary<string, string>());
        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:

            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),
            };
    /// <summary>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.</summary>
    private Task<PrimeRunOutcome> 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}");
        });
    }

    /// <summary>Truncates the prep log once and brackets <paramref name="body"/> with PrepStarted/PrepFinished.</summary>
    private async Task<PrimeRunOutcome> RunPrepAsync(string label, Func<LogWriter, Task<PrimeRunOutcome>> 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);
        }
    }

    /// <summary>The MyDay selection run, writing into an already-open prep log.</summary>
    private async Task<PrimeRunOutcome> 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);
    }

    /// <summary>One claude run streaming into the prep log and the UI; a timeout is an outcome, not an exception.</summary>
    private async Task<PrimeRunOutcome> RunLoggedAsync(
        IReadOnlyList<string> 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
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
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):

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:

public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet, string? FallbackListId = null);

WorkerHub.cs — replace GetTicketSettings:

    // 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<TicketSettingsDto> 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);
    }

    /// <summary>Sets the list that import_ticket uses when Claude passes no list; null clears it.</summary>
    public async Task SetTicketFallbackList(string? listId)
    {
        using var ctx = _dbFactory.CreateDbContext();
        await new AppSettingsRepository(ctx).SetTicketFallbackListIdAsync(listId, Context.ConnectionAborted);
    }

Replace RunDailyPrepNow with:

    public Task<bool> RunDailyPrepNow() => RunPrimeNowAsync(ClaudeDo.Data.Models.PrimeActionKind.FillMyDay);

    /// <summary>The "Tickets + plan day" button: ticket import, then FillMyDay, through the same Prime gate.</summary>
    public Task<bool> 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<bool> 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
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
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:

    /// <summary>Ticket import, then the daily prep, in one Prime run.</summary>
    Task<bool> RunTicketImportNowAsync();

next to the ticket members:

    /// <summary>Sets the fallback list for imported ticket items; null clears it.</summary>
    Task SetTicketFallbackListAsync(string? listId);

WorkerClient.cs:

    public Task<bool> RunTicketImportNowAsync()
        => InvokeTimedAsync<bool>("RunTicketImportNow", () => _hub.InvokeAsync<bool>("RunTicketImportNow"));
    public Task SetTicketFallbackListAsync(string? listId)
        => InvokeTimedAsync("SetTicketFallbackList", () => _hub.InvokeAsync("SetTicketFallbackList", listId));

StubWorkerClient.cs — next to RunDailyPrepNowCalls / RunDailyPrepNowAsync:

    public int RunTicketImportNowCalls { get; private set; }
    public virtual Task<bool> RunTicketImportNowAsync() { RunTicketImportNowCalls++; return Task.FromResult(false); }

next to the ticket members:

    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:

    private sealed class TicketWorker : StubWorkerClient
    {
        public TicketSettingsDto? Settings { get; set; }
        public override Task<TicketSettingsDto?> 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:

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<ClaudeDoDbContext>()
        .UseSqlite($"Data Source={_dbPath}").Options);

    private sealed class Worker : StubWorkerClient
    {
        public TicketSettingsDto? Settings { get; set; }
        public List<string?> SavedFallbacks { get; } = new();
        public override Task<TicketSettingsDto?> 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:
    // 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:
    [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 { }
    }

    /// <summary>Reads whether the ticket system has a base URL and a token; keeps the last value when the worker is unreachable.</summary>
    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:

              <Button Classes="btn"
                      Command="{Binding Prep.ImportTicketsAndPlanDayCommand}"
                      IsEnabled="{Binding Prep.IsPlanDayEnabled}"
                      IsVisible="{Binding Prep.TicketsConfigured}"
                      Content="{loc:Tr details.importTicketsAndPlanDay}"/>
  • Step 5: Implement the fallback dropdown

TicketSettingsTabViewModel.cs:

  • Above the class:
/// <summary>One fallback-list choice; Id null = no fallback.</summary>
public sealed record FallbackListOption(string? Id, string Name);
  • usings: using System.Collections.ObjectModel; using ClaudeDo.Data; using Microsoft.EntityFrameworkCore;
  • ctor becomes public TicketSettingsTabViewModel(IWorkerClient worker, IDbContextFactory<ClaudeDoDbContext> dbFactory) storing _dbFactory.
  • Members:
    public ObservableCollection<FallbackListOption> FallbackLists { get; } = new();
    [ObservableProperty] private FallbackListOption? _selectedFallbackList;

    // Only a successful load may write the fallback back — otherwise an offline load would clear it.
    private bool _fallbackLoaded;
  • In LoadAsync, after TokenSet = dto.TokenSet;:
        await using var ctx = await _dbFactory.CreateDbContextAsync();
        var lists = await ctx.Lists.AsNoTracking()
            .OrderBy(l => l.SortOrder).ThenBy(l => l.Name)
            .Select(l => new FallbackListOption(l.Id, l.Name))
            .ToListAsync();
        FallbackLists.Clear();
        FallbackLists.Add(new FallbackListOption(null, Loc.T("settings.tickets.fallbackNone")));
        foreach (var option in lists) FallbackLists.Add(option);
        SelectedFallbackList = FallbackLists.FirstOrDefault(o => o.Id == dto.FallbackListId) ?? FallbackLists[0];
        _fallbackLoaded = true;
  • At the end of SaveAsync:
        if (_fallbackLoaded)
            await _worker.SetTicketFallbackListAsync(SelectedFallbackList?.Id);

SettingsModalViewModel.cs:111 → Tickets = new TicketSettingsTabViewModel(worker, dbFactory);

SettingsModalView.axaml Tickets tab — before the closing </StackPanel> that follows the Tickets.StatusMessage TextBlock, add:

              <Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>

              <StackPanel Spacing="4">
                <TextBlock Classes="field-label" Text="{loc:Tr settings.tickets.fallbackList}"/>
                <ComboBox ItemsSource="{Binding Tickets.FallbackLists}"
                          SelectedItem="{Binding Tickets.SelectedFallbackList, Mode=TwoWay}"
                          DisplayMemberBinding="{Binding Name}"
                          HorizontalAlignment="Left" MinWidth="240"/>
                <TextBlock Classes="meta" Text="{loc:Tr settings.tickets.fallbackListHint}"
                           TextWrapping="Wrap" Opacity="0.6"/>
              </StackPanel>

If compiled bindings complain about DisplayMemberBinding="{Binding Name}", mirror exactly how ListSettingsModalView.axaml binds Display for TicketProjects (it uses the same form).

  • Step 6: Implement the Prime radio

PrimeScheduleRowViewModel.cs — next to IsCustom:

    public bool IsImportTickets
    {
        get => Kind == PrimeActionKind.ImportTickets;
        set { if (value) Kind = PrimeActionKind.ImportTickets; }
    }

and OnPropertyChanged(nameof(IsImportTickets)); in OnKindChanged. Fix the comment "Three booleans" → "One boolean per kind".

PrimeClaudeTabViewModel.cs:41-42:

    private void UpdateAnyFillMyDay() =>
        AnyFillMyDay = Rows.Any(r => r.Kind is PrimeActionKind.FillMyDay or PrimeActionKind.ImportTickets);

SettingsModalView.axaml — after the actionFillMyDay RadioButton:

                          <RadioButton GroupName="{Binding RadioGroup}"
                                       Content="{loc:Tr settings.prime.actionImportTickets}"
                                       IsChecked="{Binding IsImportTickets, Mode=TwoWay}"/>
  • Step 7: Locales (both files, same keys)

en.json:

  • settings.prime: add "actionImportTickets": "Tickets + plan day",; change actionHint to "Ping only opens the usage window. Fill My Day runs the daily prep. Tickets + plan day first imports your assigned tickets into the matching lists, then runs the daily prep. Custom runs your own prompt with the ClaudeDo tools."; change maxTasksDisabledHint to "Only applies to Fill My Day and Tickets + plan day schedules."
  • settings.tickets: add "fallbackList": "Fallback list", "fallbackListHint": "Imported ticket items Claude can't match to a list land here. None = they are skipped.", "fallbackNone": "(none)".
  • details: add "importTicketsAndPlanDay": "Tickets + plan day", after planDay.
  • ops.reports (the block holding runningDailyPrep): add "runningTicketImport": "Importing tickets, then planning the day…".

de.json (same positions):

  • "actionImportTickets": "Tickets + Tag planen"; actionHint: "Ping öffnet nur das Nutzungsfenster. „Mein Tag füllen“ startet die Tagesplanung. „Tickets + Tag planen“ importiert erst deine zugewiesenen Tickets in die passenden Listen und startet dann die Tagesplanung. „Eigener Prompt“ führt deinen eigenen Prompt mit den ClaudeDo-Tools aus."; maxTasksDisabledHint: "Gilt nur für Zeitpläne mit „Mein Tag füllen“ oder „Tickets + Tag planen“."
  • "fallbackList": "Fallback-Liste", "fallbackListHint": "Ticket-Items, die Claude keiner Liste zuordnen kann, landen hier. Keine = sie werden übersprungen.", "fallbackNone": "(keine)"
  • "importTicketsAndPlanDay": "Tickets + Tag planen"
  • "runningTicketImport": "Tickets werden importiert, danach Tagesplanung…"

Validate both files parse: python -c "import json;json.load(open('src/ClaudeDo.Localization/locales/en.json',encoding='utf-8'));json.load(open('src/ClaudeDo.Localization/locales/de.json',encoding='utf-8'))" (a broken de.json is swallowed silently at runtime).

  • Step 8: Build and run scoped tests
dotnet build ClaudeDo.slnx -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~PrepPanelViewModelTests|FullyQualifiedName~TicketSettingsTabViewModelTests|FullyQualifiedName~PrimeClaudeTabViewModelTests|FullyQualifiedName~SettingsModalViewModelTests|FullyQualifiedName~DetailsIslandPrepModeTests"
dotnet test tests/ClaudeDo.Localization.Tests -c Release --no-build

Expected: PASS.

  • Step 9: Commit
git add tests/ClaudeDo.Ui.Tests/ViewModels/TicketSettingsTabViewModelTests.cs
git commit -m "feat(ui): tickets + plan day button, fallback list and prime action" -- \
  src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs \
  src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs \
  src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml \
  src/ClaudeDo.Ui/ViewModels/Modals/Settings/TicketSettingsTabViewModel.cs src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs \
  src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml \
  src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs \
  src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json \
  tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/PrepPanelViewModelTests.cs \
  tests/ClaudeDo.Ui.Tests/ViewModels/TicketSettingsTabViewModelTests.cs tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs

Task 7: Docs

Files: src/ClaudeDo.Worker/CLAUDE.md, docs/explore-notes/external-mcp.md, docs/open.md

  • Step 1: src/ClaudeDo.Worker/CLAUDE.md
    • Prime section: the kind list becomes Ping, FillMyDay, ImportTickets, Custom; add a table row | ImportTickets | TicketImportPrompt.BuildPrompt, then the FillMyDay prompt (override appended to both) | step 1: get_ticket_import_candidates + import_ticket, max-turns 60; step 2: as FillMyDay | one daily-prep.log for both steps, one PrepStarted/PrepFinished | and one sentence: a failed import step never blocks the FillMyDay step; without a configured ticket system the kind runs plain FillMyDay. Mention WorkerHub.RunTicketImportNow ("Tickets + plan day" button) next to RunDailyPrepNow.
    • Tickets section: add a bullet Cross-project import — TicketImportService.GetCandidatesAsync/ImportItemAsync sweep sidebar → every project board; tickets match on ticketAssignees, tasks (tasks[]) on ticketTaskAssignees; board tasks with hasChildren are skipped; dedupe on TicketRef is across all lists (also in the per-list ImportAsync); import_ticket without list uses AppSettings.TicketFallbackListId (written only via SetTicketFallbackListIdAsync/hub SetTicketFallbackList, never by UpdateAsync). Add GET /api/ticket/{id} to the route list (live-verified 2026-10-01, works for tasks too).
  • Step 2: docs/explore-notes/external-mcp.md — add TicketMcpTools (get_ticket_import_candidates, import_ticket) to the tool inventory, one line each.
  • Step 3: docs/open.md — add an open-verification block:
    • Visual check: "Tickets + Tag planen" button in the Prep panel (only visible with base URL + token), fallback-list dropdown in Settings → Ticketsystem, "Tickets + Tag planen" radio in the Prime tab.
    • E2E: click the button once against the real ticket system; check the items land in sensible lists, the fallback list catches the rest, then MyDay is filled; second click imports nothing new.
    • PATCH /api/Ticket/{id}/status on a task (not a ticket) is untested — after the first imported task changes status, check the Worker log for a TicketStatusSync warning.
  • Step 4: Commit
git commit -m "docs(tickets): document cross-project ticket import and open checks" -- \
  src/ClaudeDo.Worker/CLAUDE.md docs/explore-notes/external-mcp.md docs/open.md