Compare commits

...
70 Commits
Author SHA1 Message Date
mika kuns d258ab4164 test(worker): start_list_handler ins erwartete MCP-Tool-Inventar aufnehmen
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 42s
2026-09-02 21:57:16 +02:00
mika kuns 574edf6c1a Merge branch 'claudedo/f2f0c6ee1e974c09bc8a8261978cba34'
feat(worker): Handler-Session vor dem Usage-Limit sauber uebergeben (#275); Konflikte mit start_list_handler aufgeloest, WorkerLog-Aufruf im Fire-and-forget-Pfad wieder abgesichert
2026-09-02 21:50:26 +02:00
mika kuns fe8186514f feat(worker): Handler-Session vor dem Usage-Limit warnen und Handoff bis zum Reset zurueckhalten
Eine PowerUse-Handler-Session soll am 5h-Limit nicht mehr mitten im Merge
sterben, sondern vorher selbst sauber uebergeben:

- HandlerUsageGuard (neu) warnt die laufende Session genau einmal pro Runde,
  sobald FiveHour.Utilization den Schwellwert PowerUseHandoffPct erreicht.
  Die Warnung wird per HandlerUsageWarning-Broadcast an Mission Control
  geschickt und dort in das Pane der Handler-TaskId getippt.
- handoff_list_handler broadcastet den Handoff dann nicht sofort, sondern parkt
  ihn bis ResetsAt + 60 s; das Ergebnis traegt deferredUntil. Ohne ResetsAt
  wird wie bisher sofort gesendet.
- Der geparkte Handoff liegt im HandlerRoundState (handler-rounds.json) und
  wird vom Guard-Tick ausgeloest, also auch nach einem Worker-Neustart wieder
  eingeplant bzw. sofort gesendet, wenn er ueberfaellig ist.
- Review-Befund aus #272: HandlerRestartCoordinator wartet nicht mehr selbst
  bis ResetsAt, sondern parkt den Neustart im selben Slot. AutoRestartUsed wird
  erst gesetzt, wenn der Broadcast wirklich rausging - ein Worker-Absturz
  waehrend der Wartezeit laesst die Runde nicht mehr dauerhaft tot zurueck.
  Zurueckgehaltener Handoff und Auto-Neustart teilen sich einen Slot, also
  oeffnet nie beides dieselbe Runde. UsageLimitProofPct entfaellt zugunsten von
  PowerUseHandoffPct.
- PtyTerminalSession.SendLineAsync schreibt in die laufende Session. Gegen eine
  echte claude-Session verifiziert: Text und Enter muessen zwei getrennte
  Writes sein, die Enter-Bytes kommen von Terminal.GenerateKeyInput.
2026-09-02 21:35:29 +02:00
mika kuns c2166a0d4f feat(worker): start_list_handler MCP-Tool startet einen List-Handler-Run wie der Button 2026-09-02 21:05:55 +02:00
mika kuns c3a418f077 Merge branch 'claudedo/b346a534f0fc4635bd7614a00ab2a20e' 2026-09-02 20:55:43 +02:00
mika kuns 2ec3af96d5 chore(claude-do): merge PowerUse-Zeitfenster + Handoff-Schwellwert als Settings (Usa
ClaudeDo-Task: aa446890-82f0-49b9-aef9-13a788d9c514
2026-09-02 19:27:01 +02:00
mika kuns 016b9203f8 feat(worker): Handler-Session nach Usage-Limit-Reset automatisch neu starten
Eine List-Handler-ConPTY-Session, die mitten in einer Phase stirbt, war bisher
ein stilles totes Tile: InteractiveTerminalViewModel.OnSessionProcessExited
setzte nur bei einem Tod innerhalb des 5s-Grace-Fensters ein sichtbares Banner.

Neu:
- HandlerRoundState (JSON unter ~/.claudeDo/handler-rounds.json, keine
  EF-Migration): Phase + Task-Set der laufenden Runde, geschrieben von
  BuildForMergeHelperAsync ("triage") und BuildForMergeHelperHandoffAsync.
  Ohne das existieren beide nur im transienten HandoffRequested-Broadcast.
- HandlerRestartCoordinator: entscheidet den Neustart, fail-closed. Beleg fuer
  "war das Usage-Limit" ist FiveHour.Utilization >= 95 zum Todeszeitpunkt --
  eine ConPTY-Session hat kein stdout, TaskRunner.IsUsageLimitMessage hat hier
  nichts zu lesen. Nur bei PowerUse (beide Keys), max ein Neustart pro Runde,
  Warten bis FiveHour.ResetsAt, dann HandoffRequested erneut broadcasten.
  Verbraucht kein Handoff-Budget (HandoffRoundTracker.Register wird nicht
  angefasst). WorkerLog-Zeilen beim Tod und beim Neustart.
- Ui meldet den Exit-Code (nur dort bekannt) via
  WorkerHub.ReportInteractiveSessionExited, ohne selbst zu filtern.

Sibling zu UsageLimitAutoContinueCoordinator, kein Umbau davon: der sieht nur
headless Runs.
2026-09-02 19:17:51 +02:00
mika kuns 69898be5c9 feat(usage): PowerUse-Zeitfenster + Handoff-Schwellwert als Settings
Drei neue app_settings-Spalten (Migration AddPowerUseWindow):
power_use_window_start/end (HH:mm, lokal, nullable) und
power_use_handoff_pct (NOT NULL DEFAULT 95, geclampt 50-99).

Die Fensterlogik liegt als reine Funktion in PowerUseWindow
(Contains/Normalize/IsOpen): beide Grenzen leer oder nur eine gesetzt =
rund um die Uhr, Ende vor Start wrappt ueber Mitternacht, Grenzen
halboffen. Geprueft wird in ComposeHandlerPromptAsync, also pro Phase
zum Zeitpunkt des Prompt-Baus - die Uhr ist per Func<DateTime>
injizierbar. Bedienung im Usage-Monitor neben dem PowerUse-Toggle
(Commit auf Blur/Enter, ein Write fuer alle vier Felder); in
Settings->General unsichtbar, aber load->save durchgereicht.

Nebenbefund, gleicher Pfad: AppSettingsRepository.UpdateAsync kopiert
Feld fuer Feld und hat ModelPresets, PowerUseEnabled und
ListHandlerMaxHandoffs nie geschrieben - jeder Save dieser drei war
still wirkungslos. Mit ergaenzt und per Round-trip-Test gepinnt.
2026-09-02 19:09:47 +02:00
mika kuns 20b3b4ce2b docs(claude-do): Handler-Prompt praezisieren (Wave-Plan, Task-Split, review_task-Aufruf) 2026-09-02 18:43:15 +02:00
Mika Kuns 42356241e3 feat(claude-do): PowerUse fuer List-Handler-Runs + konfigurierbares Handoff-Budget 2026-09-02 15:56:10 +02:00
Mika Kuns 675b36e923 Merge branch 'main' into feat/poweruse-list-handler
Kollision mit #264 (Handoff-Budget im Brief sichtbar machen): beide Seiten haben
HandoffRoundTracker, HandoffMcpTools und InteractiveLaunchSpecService angefasst.

Der Konflikt war inhaltlich, nicht nur textuell. #264 liest den Cap an zwei
Stellen aus der Konstante (GetStatus und das roundInfo-Rendering), waehrend
dieser Branch ihn zum Parameter aus app_settings gemacht hat. Ein reiner
Text-Merge haette kompiliert und trotzdem gelogen: Register haette den
konfigurierten Cap durchgesetzt, waehrend der Brief dem Modell weiter "2 Runden
uebrig" gemeldet haette, obwohl es 39 hat.

Aufgeloest so, dass der Cap eine einzige Quelle hat:
- GetStatus(taskId, maxHandoffs) nimmt den Cap als Parameter, mit demselben
  Clamp wie Register. Die Rechnerei liegt damit an einer Stelle.
- GetHandoffStatus liest das Setting und gibt es weiter.
- Das roundInfo-Rendering benutzt GetStatus statt eigener Arithmetik.

Zwei Tests nageln genau diesen Drift fest, damit er beim naechsten Merge nicht
unbemerkt zurueckkommt: einer auf GetHandoffStatus, einer auf den Brief.

MaxHandoffs -> DefaultMaxHandoffs in #264s vier Test-Referenzen; die laufen alle
mit Default-Settings, die Semantik bleibt.
2026-09-02 15:48:43 +02:00
Mika Kuns 416997d7e0 docs: Test-Scoping fuer Implementer-Tasks, PowerUse in den Explore-Notes
Die Build-Sektion hat bisher ein Rezept mit allen sechs Testprojekten vorgegeben;
Implementer haben daraus "volle Suite vor fertig" gelesen. Bei bis zu 5
parallelen Tasks kostet das Stunden - und kann das nicht finden, wofuer es
teuer ist: jeder Task arbeitet in seinem eigenen Worktree mit nur seiner eigenen
Aenderung, eine Kollision zwischen zwei parallelen Tasks ist fuer beide
unsichtbar. Kollisionen zeigen sich nach dem Merge, und genau dort laeuft
verify_merges den Verify-Command einmal pro Batch.

Neue Regel: Solution-Build immer (42s, faengt projektuebergreifende
Compile-Brueche), Tests nur --no-build --filter auf das Beruehrte, und melden
welcher Filter benutzt wurde. Die Messwerte stehen dabei, damit die Regel nicht
als Geschmacksfrage gelesen wird.

Messung 2026-09-02, sequenziell nach einem Build: Worker.Tests 507s, Ui 33s,
Data 8s, Rest je 3s, Build 42s. Parallel ueber die Solution ist es schlechter,
nicht besser (Worker 801s, Ui 82s) - diese Tests legen echte Git-Worktrees an
und spawnen Prozesse, sie drangeln sich statt sich zu teilen.

Ausserdem korrigiert: die Zeile "dotnet build ClaudeDo.slnx requires .NET 9"
war veraltet.
2026-09-02 15:44:13 +02:00
Mika Kuns 193752540b feat(ui): PowerUse-Toggle im Usage-Monitor, Opt-in pro Liste
Der Toggle sitzt unter den Gauges im Usage-Monitor, weil "das restliche Fenster
ausnutzen" eine Entscheidung ist, die man beim Blick auf das restliche Fenster
trifft. Er speichert sofort per Read-modify-write, gleiche Disziplin wie
SaveStagesAsync, damit er keine Felder ueberschreibt, die er nicht besitzt.

Zwei Stellen, die das Opt-in still ausgeschaltet haetten:
- WorkerHub.UpdateListConfig loescht die Config-Zeile, wenn alle Felder leer
  sind. PowerUseAllowed muss in diese Leer-Pruefung, sonst verschwindet das
  Opt-in sofort wieder, wenn es das einzige gesetzte Feld ist.
- SettingsModalViewModel baut das AppSettingsDto positional neu auf. Ohne
  Round-trip haette jedes Speichern der Settings PowerUseEnabled auf den
  Record-Default false zurueckgesetzt. Gleiches Muster wie _throttleStages.

Das Handoff-Budget ist als Spinner in Settings -> General editierbar, weil es
auch fuer normale (interaktive) Handler-Runs gilt und nicht an PowerUse haengt.
Das Addendum selbst ist ueber Settings -> Files anpassbar.
2026-09-02 15:44:13 +02:00
Mika Kuns 453d61ca58 feat(worker): PowerUse-Addendum an die Handler-Prompts, Cap aus den Settings
HandoffRoundTracker.Register nimmt den Cap jetzt als Parameter statt aus einer
Konstante; HandoffMcpTools liest ihn pro Aufruf aus app_settings. Werte unter 1
werden auf 1 geklemmt (in WorkerHub UND in Register): der Cap ist das Einzige,
was eine entlaufene Handoff-Kette begrenzt, "0 = unbegrenzt" darf kein Aufrufer
waehlen koennen.

InteractiveLaunchSpecService.ComposeHandlerPromptAsync haengt das PowerUse-
Addendum an den Phasen-Prompt, aber nur wenn globaler Schalter und Listen-Opt-in
beide gesetzt sind. Bewusst ein Addendum statt einer zweiten Prompt-Garnitur:
die gemeinsamen 95% bleiben an einer Stelle, und ein Run ohne PowerUse bekommt
einen byte-identischen Prompt wie vorher. Das Addendum ist als Override der
interaktiven Regeln formuliert (nie fragen, entscheiden und protokollieren;
Konflikte loesen oder abbrechen, nie im Tree liegen lassen; Arbeit ernten, wenn
eine Runde sonst leer laeuft).

Drei Tests pinnen alle vier Ecken des UND, weil die Sicherheitsaussage des
Features genau daran haengt.
2026-09-02 15:43:48 +02:00
Mika Kuns 3cf9628f46 feat(data): PowerUse-Flags und konfigurierbares Handoff-Budget
app_settings bekommt power_use_enabled (Master-Schalter, wird im Usage-Monitor
gesetzt) und list_handler_max_handoffs (Default 4 = zwei Merge-Runden).
list_config bekommt power_use_allowed als Opt-in pro Liste.

Ein Handler-Run laeuft nur unbeaufsichtigt, wenn BEIDE Schalter gesetzt sind.
Damit kann der globale Schalter ueber Nacht an bleiben, ohne dass der Handler
einer nicht zugestimmten Liste sein Verhalten aendert.

UpdateListConfigDto.PowerUseAllowed ist bewusst bool? (tri-state, null =
gespeicherten Wert behalten), analog zu TicketProjectId: SetConfigAsync kopiert
verbatim, ein non-nullable bool haette jedem fremden Writer erlaubt, das Opt-in
per Auslassung zu widerrufen. Der Test dazu nagelt genau das fest.

Migration ist drei reine ALTER TABLE ADD COLUMN, kein Table-Rebuild.
2026-09-02 15:43:32 +02:00
Mika Kuns b00bdf28ae Merge branch 'claudedo/0a185f8e823d42fc9a5b97c5eb91c616' 2026-09-02 15:30:04 +02:00
Mika Kuns 91a37984c6 feat(claude-do): merge Handoff-Budget im Brief sichtbar machen und pro Task mitzähl
ClaudeDo-Task: bdcd601a-2fed-44d8-9164-6d2d008e7f31
2026-09-02 15:28:59 +02:00
Mika Kuns 7ee7e33654 feat(claude-do): merge Mehrfach-Abhängigkeiten: dependsOnTaskId kann nur einen Vorg
ClaudeDo-Task: cda94cd3-71c3-447f-8971-9d57be939cad
2026-09-02 15:18:54 +02:00
Mika Kuns c25732cff8 feat(claude-do): depends_on als Menge statt Einzelfeld
Ein Task kann jetzt auf beliebig viele Vorgaenger warten. Die Einzelspalte
tasks.depends_on_task_id wird durch die Beziehungstabelle task_dependencies
ersetzt (Composite-PK, beide FKs ON DELETE CASCADE -- das reproduziert das
alte ON DELETE SET NULL: geloeschter Vorgaenger = Kante weg = Task wieder
pickbar). BlockedByTaskId bleibt unberuehrt.

- Migration AddTaskDependencies kopiert jede bestehende depends_on_task_id
  verlustfrei in die Tabelle, bevor die Spalte fallengelassen wird
- QueuePicker gatet per doppelt negiertem NOT EXISTS: kein Task mit 0 Kanten
  wird blockiert, alle Kanten muessen done sein, eine Kante auf einen
  verschwundenen Task blockiert weiter
- SetDependsOnAsync ersetzt die ganze Menge; Zykluspruefung ist jetzt eine
  BFS ueber den Graphen, faengt also auch A->B->C->A
- blockedReason nennt alle offenen Vorgaenger (External/TaskBlocking, von
  ExternalMcpService und TaskWaitMcpTools gemeinsam genutzt)
- add_task/update_task nehmen dependsOnTaskIds; die alte Skalarform bleibt
  als Ein-Element-Menge inkl. ""-Clear-Sentinel gueltig
- UI: Fan-in-Zeile bleibt flach und listet alle Vorgaenger im After-Label,
  die lineare Kette rendert unveraendert
2026-09-02 15:08:05 +02:00
Mika Kuns 2330b426c6 feat(claude-do): Expose HandoffRoundTracker budget via an MCP tool (e.g. get_
The handoff-budget task (round/remaining/final now surfaced in the handoff.md brief via HandoffRoundTracker.GetStatus, wired through InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync) only reaches the session at the start of a handoff round. There is no way to query the current round/remaining budget for a handler task mid-session or from outside (e.g. the UI, or a script). Consider add

ClaudeDo-Task: 0a185f8e-823d-42fc-9a5b-97c5eb91c616
2026-09-02 14:43:44 +02:00
Mika Kuns 81a5fc5d33 feat(worker): surface the handoff round budget in the merge-helper handoff brief
A handler session only learned the chain was capped when handoff_list_handler
threw at the end of its work, stranding open tasks. The round count already
lived in HandoffRoundTracker the whole time -- it just never reached the
brief. InteractiveLaunchSpecService now reads HandoffRoundTracker.GetState
(read-only, doesn't advance the round) and renders round N of MaxHandoffs
plus the remaining count into the handoff brief, replacing the old one-line
finalNote. The rejection error also names the next action (start a new
handler run) instead of just failing.
2026-09-02 14:26:56 +02:00
Mika Kuns 84d955dfc7 docs(claude-do): plan for multi-dependency depends_on relation table 2026-09-02 14:15:39 +02:00
Mika Kuns 20685827fb feat(claude-do): merge Merge-Session soll die Queue selbst beobachten statt fertige
ClaudeDo-Task: 1788adc4-c671-4942-ab78-1ef1812da494
2026-09-02 13:59:27 +02:00
Mika Kuns 4ee1d05f06 fix(worker): carry ExpectsNoDiff into the relocated merge MCP tools
Conflict resolution for the #266 merge: #247 had moved PreviewMerge/PreviewMergeSet
and MergePreviewSetEntryDto out of ExternalMcpService into MergeMcpTools, so #266's
in-place edits to those had to be re-applied at the new location, together with the
PreviewMergeSet_ReportsExpectsNoDiffPerTask test.
2026-09-02 13:59:22 +02:00
Mika Kuns 6c1ea1ae25 feat(claude-do): Merge-Session soll die Queue selbst beobachten statt fertige
## WO — verifiziert am 2026-09-02, nicht neu suchen

- Merge-Phasen-Prompt: `src/ClaudeDo.Data/PromptFiles.cs`, Konstante `MergeHelperMergeDefault` (ab Z. 495). Der neue Warte-Abschnitt gehört zwischen "## Verify once — after the last merge" (Z. 526) und "## Handoff / end" (Z. 542) — also nach dem Verify, vor der Handoff-Entscheidung.
- Brief-Template: `MergeHelperInitialDefault` (Z. 555) und `Mer

ClaudeDo-Task: 1788adc4-c671-4942-ab78-1ef1812da494
2026-09-02 13:49:58 +02:00
Mika Kuns 7e847638cf Merge branch 'claudedo/9a02814991ef42a5a9a063d5ea579d62' 2026-09-02 13:49:56 +02:00
Mika Kuns aadc926ffe refactor(claude-do): merge refactor(worker): move the merge and worktree MCP tools out
ClaudeDo-Task: 1862e160-ffde-4a50-aa03-bb7251f56088
2026-09-02 13:43:52 +02:00
Mika Kuns c2ef446fe1 test(data,worker): cover ExpectsNoDiff persistence and MCP wiring
Data.Tests: default-false and true-roundtrip on TaskEntity.
Worker.Tests: add_task/update_task set/clear/leave-unchanged, and
preview_merge_set surfaces the flag per task.
2026-09-02 13:39:25 +02:00
Mika Kuns 7bcca3647e refactor(claude-do): refactor(worker): move the merge and worktree MCP tools out
`src/ClaudeDo.Worker/External/ExternalMcpService.cs` ist **1897 Zeilen** (nachgezählt 2026-09-02; die Datei ist seit dem Anlegen dieses Tasks von 1696 weiter gewachsen): ~30 DTO-Records oben plus ~25 Tool-Methoden — während **13** Geschwisterdateien `*McpTools.cs` im selben Ordner das Topic-Split-Muster schon tragen (`ListMcpTools`, `ConfigMcpTools`, `BatchMcpTools`, `NotesMcpTools`, `QueueStateMc

ClaudeDo-Task: 1862e160-ffde-4a50-aa03-bb7251f56088
2026-09-02 13:28:33 +02:00
Mika Kuns 244ba9b72f chore: merge main into task branch 2026-09-02 13:13:59 +02:00
Mika Kuns 0b506f529a feat(data): add ExpectsNoDiff flag for verification/audit tasks
An empty diff from a review/audit/verification task looks identical to
one that silently failed to commit. ExpectsNoDiff (TaskEntity, add_task/
update_task, preview_merge_set, review_task via TaskRefDto) lets the
merge helper tell them apart instead of treating every 0-changed-files
result as suspicious.
2026-09-02 13:13:13 +02:00
Mika Kuns 7a0b97df59 refactor(claude-do): refactor(worker): move the merge and worktree MCP tools out
`src/ClaudeDo.Worker/External/ExternalMcpService.cs` ist **1897 Zeilen** (nachgezählt 2026-09-02; die Datei ist seit dem Anlegen dieses Tasks von 1696 weiter gewachsen): ~30 DTO-Records oben plus ~25 Tool-Methoden — während **13** Geschwisterdateien `*McpTools.cs` im selben Ordner das Topic-Split-Muster schon tragen (`ListMcpTools`, `ConfigMcpTools`, `BatchMcpTools`, `NotesMcpTools`, `QueueStateMc

ClaudeDo-Task: 1862e160-ffde-4a50-aa03-bb7251f56088
2026-09-02 13:02:10 +02:00
Mika Kuns a0c277100e test(worker): assert the full external MCP tool-name inventory
Guardrail for the upcoming ExternalMcpService.cs topic split: proves the
move is name-preserving by pinning the complete set of registered tool
names (computed via the SDK's own McpServerTool.Create, not a
reimplementation of the naming rule) before any file is moved.
2026-09-02 13:02:10 +02:00
Mika Kuns 651ddbc31c chore(claude-do): merge feat(worktrees): warn about git worktrees ClaudeDo doesn't k
ClaudeDo-Task: 765fed13-db66-44e4-9285-c4b2d415b443
2026-09-02 13:01:57 +02:00
Mika Kuns 477a216567 chore(claude-do): merge refactor(tests): one IWorkerClient test double instead of tw
ClaudeDo-Task: 9ff7ec49-36c9-4b09-8747-e57bf74e37f2
2026-09-02 13:00:14 +02:00
Mika Kuns 6a4fa15372 fix(claude-do): merge batch_get_tasks: failureReason wird im fields-Parameter abge
ClaudeDo-Task: df16476b-5a10-446c-9ebc-99ddcc64e476
2026-09-02 12:59:52 +02:00
Mika Kuns 411eb7487b chore(claude-do): merge TaskRowViewModel: manuelle OnPropertyChanged-Kette auf [Noti
ClaudeDo-Task: 87976462-5845-4e01-8233-32afadca02bd
2026-09-02 12:59:35 +02:00
Mika Kuns ce70ba1c46 chore(claude-do): merge test(data): assert the migrated schema matches the EF model
ClaudeDo-Task: 9b8d47d7-e42f-4ea5-8c86-fd10a420e7f6
2026-09-02 12:59:18 +02:00
Mika Kuns 21769eb928 chore(claude-do): merge fix(localization): make the locale-parity test actually able
ClaudeDo-Task: dee67d25-ac34-4a71-b71d-6bbb1130943f
2026-09-02 12:59:07 +02:00
Mika Kuns 9b3f7ac5c8 chore(claude-do): merge fix(tests): Ui.Tests order-dependent flakiness — Loc.Current
ClaudeDo-Task: 618729f9-2d23-48e0-8952-7f17a7eb69a3
2026-09-02 12:58:44 +02:00
Mika Kuns 25f6691aa0 feat(claude-do): merge List-Handler-Prompt: Abhängigkeits-Audit in der Triage + Sch
ClaudeDo-Task: 04e9bc67-2612-4124-b513-83a02ecaa602
2026-09-02 12:58:31 +02:00
Mika Kuns dc1e67d07b fix(tests): stop Ui.Tests order-dependent flakiness on shared Loc.Current
Loc.Current (src/ClaudeDo.Ui/Localization/Loc.cs) is a process-wide mutable
static; which localizer is active depended on xUnit's parallel test
scheduling. Ui.Tests: disable collection parallelization (cheap, pure VM
suite) and install the real localizer in a module initializer so no test
can ever observe the uninitialized key-echo default. Worker.Tests: install
the same module initializer but leave parallelization on — disabling it
measured a 27% wall-clock regression on its real-SQLite/git suite, and the
initializer alone already removes the race. AgentConfigEditorViewModelTests
now installs its own localizer instead of relying on ambient state.
2026-09-02 12:55:51 +02:00
Mika KunsandClaude Haiku 4.5 912201770f fix(worker): add failureReason field to batch_get_tasks
The tool description claimed Failed tasks carry failureReason, but the field
was missing from BatchTaskDetailDto and rejected when requested in `fields`.
Add failureReason to the DTO, KnownDetailFields, and BuildDetail so list
handlers and other callers can read failure reasons without per-task get_task
calls. Tested: 31 BatchMcpToolsTests pass, including two new cases for
failureReason retrieval on Failed and Idle tasks.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-02 12:20:02 +02:00
Mika Kuns 9f90e75a85 refactor(tests): share StubWorkerClient between Ui.Tests and Worker.Tests
Worker.Tests carried its own hand-rolled FakeWorkerClient implementing the
whole IWorkerClient surface, duplicating StubWorkerClient in Ui.Tests. Every
hub-signature change had to be applied twice. Link StubWorkerClient.cs into
Worker.Tests via a csproj Compile Include and subclass it there; add
SetTaskStatusCalls tracking to the shared stub (the one behavior Worker.Tests
needed that Ui.Tests didn't have).
2026-09-02 12:17:00 +02:00
Mika Kuns 9e80564665 feat(data): add dependency audit and diff-review threshold to list-handler prompts
Triage phase now audits dependsOnTaskId chains before handoff: distinguishes real
from artificial (plan-numbered) dependencies, resolves artificial ones via
update_task, groups survivors into a wave plan, and checks the wave count against
the 4-handoff cap (2 merge rounds) so a chain too long to finish says so explicitly
instead of stranding mid-run.

Merge phase now reads a task's own diff when preview_merge_set's changedFileCount
is below 5, reserving the sonnet review subagent for larger diffs where the
context-protection cost is actually worth it.

Updates DefaultFor_merge_helper_merge_never_reads_diffs_itself, renamed to
DefaultFor_merge_helper_merge_gates_the_diff_subagent_on_a_file_count_threshold,
to match the new threshold behavior.
2026-09-02 12:16:44 +02:00
Mika Kuns b24ff614ff chore(claude-do): TaskRowViewModel: manuelle OnPropertyChanged-Kette auf [Noti
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs enthält ~107 handgeschriebene OnPropertyChanged(nameof(...))-Aufrufe in partial void OnXChanged-Methoden. Das Projekt nutzt an anderen Stellen den Generator: [NotifyPropertyChangedFor] in DetailsIslandViewModel.cs, ConflictResolverViewModel.cs, AgentConfigEditorViewModel.cs, Installer/WizardViewModel.cs.

Das ist die eine Datei, in der eine ve

ClaudeDo-Task: 87976462-5845-4e01-8233-32afadca02bd
2026-09-02 12:16:06 +02:00
Mika Kuns 25ead4c8dd chore(claude-do): feat(worktrees): warn about git worktrees ClaudeDo doesn't k
From `docs/open.md` (re-verified against the code 2026-09-02, still open): nothing reconciles `git worktree list` against the `worktrees` table. `ExternalMcpService.ListWorktrees` and the Worktrees overview read DB rows only; `WorktreeManager` calls git solely inside its self-heal path (`ListWorktreePathsForBranchAsync`). Observed once: 22 git-registered worktrees + 26 `claudedo/*` branches in `C:

ClaudeDo-Task: 765fed13-db66-44e4-9285-c4b2d415b443
2026-09-02 12:09:12 +02:00
Mika Kuns e099d4f6cf test(data): assert migrated schema matches EF model (schema-drift guard)
Sibling migrations branched off the same parent have silently dropped
each other's columns during a SQLite table rebuild before, and every
other Data.Tests fixture uses EnsureCreated, which skips the migration
chain entirely and would never catch that.
2026-09-02 11:53:28 +02:00
Mika Kuns 4c78a5d6fd fix(localization): make locale-parity test able to detect a malformed locale file
LocaleStore.Load silently skips a *.json that fails to parse, so a broken
de.json left store.Available with only "en" and the parity loop compared
English against itself, always green. Add two guards before the loop:
loaded-locale count must match the number of *.json files in the folder,
and "de" must be present via TryGet, both with failure messages naming the
missing file. Also independently JsonDocument.Parse every *.json so a
syntax error surfaces as a syntax error with the file name, not as
"locale missing". LocaleStore's catch-and-skip is untouched -- a broken
translation must still not crash the app at runtime.
2026-09-02 11:52:34 +02:00
mika kuns 56bc1651a8 fix(ui): don't leave an orphaned list behind on cancel
"New list" pre-creates the DB row before opening List Settings.
Cancelling or closing that modal left an untitled empty list behind;
CreateListAsync now rolls the row back unless the modal reports Saved.

Also moves the new-list/import-repo buttons up next to the "My Lists"
label instead of a row below the list, and puts Notes above Important
in the smart-list nav order.
2026-09-02 10:00:43 +02:00
mika kuns 80fe188a48 feat(ui): add "move to list" to the task row context menu
Reuses the existing drag-drop move (MoveTaskToListAsync) so the same
running/worktree guards apply — only ListId changes.

Also count WaitingForReview tasks (not Done-with-a-branch) for the
review portion of the status pill, and localize the pill and open-task
count strings that were still hardcoded English.
2026-09-02 10:00:21 +02:00
mika kuns c347e4fc5f perf(worker): switch to workstation GC to cut idle memory
Sdk.Web defaults to Server GC, one heap per core with a ~350 MB
gen0 budget each — an idle worker sat at ~230 MB private. Workstation
GC drops that to ~40 MB with no server-side concurrency to lose.
2026-09-02 09:59:51 +02:00
mika kuns 6be62f02cc refactor(ui): remove the stale-worker banner
The banner compared the running worker's build SHA against the
selected list's HEAD via a git ancestor check on every list switch
and reconnect. It added a git-rev-parse MSBuild target, a hub call,
and a poll loop for a corner case that hasn't come up in practice —
not worth the moving parts.
2026-09-02 09:59:39 +02:00
mika kuns b6e5876b07 fix(ui): stop treating git worktrees and submodules as repos
.git as a file (worktrees, submodules) isn't a standalone repo — the
scanner was flooding the import list with worktree entries alongside
their real repo.
2026-09-02 09:59:12 +02:00
mika kuns 30d0611b8e fix(worker): fix Bandel ticket sync and reactivate the integration
The status PATCH needs statusName alongside statusId or the API 400s,
so every write-back had been silently failing. The board's flat
assigneeName is null on every live ticket; the real assignee list is
ticketAssignees[]. Import now also picks up In Bearbeitung tickets,
not just Offen ones.

Both issues are live-verified against the real API, so the feature
flag flips back on.
2026-09-02 09:59:06 +02:00
CubeGameLPandClaude Opus 5 f640580463 fix(notes): Shift+Enter in the capture box, not just in the note rows
The previous fix put AcceptsReturn on the rows in the DataTemplate and
missed the box you actually type new notes into, which still had it off
and only a KeyBinding for Enter — so Shift+Enter there did nothing.

Both now go through the same tunnel handler: Enter files the note,
Shift+Enter is left to the TextBox for the line break. The KeyBinding is
gone; with AcceptsReturn on it could never have fired anyway, because
TextBox consumes Enter in its own OnKeyDown before bindings or bubbling
handlers run.

Verified against the running app: a note typed with Shift+Enter is
stored as "fe\r\ndwa".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:46:59 +02:00
CubeGameLPandClaude Opus 5 b90c5855f4 fix(notes): let Shift+Enter break the line, and clear the leftovers around the editor
Shift+Enter did nothing because AcceptsReturn was only set from a style
setter and never reached the box. Setting it on the element is half the
fix: TextBox.OnKeyDown consumes Enter regardless of modifiers and runs
before any bubbling handler, so the split chain would have died in its
place. Enter is now taken in the tunnel phase and Shift+Enter is left to
the box.

Alongside, the notes list stops borrowing furniture it has no use for:
no "0 open tasks" subtitle, no show-completed and list-settings buttons,
and no details pane beside it — the editor spans that column instead of
leaving it empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:36:28 +02:00
CubeGameLPandClaude Opus 5 0f04b796f4 feat(notes): notes get their own list, a day log and an Enter chain
The editor was reachable from one button that only appeared in My Day,
which is how it got forgotten. It is now a permanent sidebar entry next
to My Day and Planned, and it fills the tasks island rather than the
details pane, because a note list is content.

Inside, the date picker is gone: today sits at the top with the capture
box, older days follow as dated groups. Enter in a note saves it and
opens the next one, Shift+Enter breaks the line so notes can be several,
Backspace on an empty note deletes it and steps back up. Deleting also
has a visible hover button instead of only "clear the text and hope".
Agent-written notes carry a marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:19:37 +02:00
CubeGameLPandClaude Opus 5 ae0c7d3771 feat(notes): add_note MCP tool with a per-day agent cap
Agents can write into the user's daily notes, but only within limits:
max 10 agent notes a day, max 500 characters, add only — no edit, no
delete. A new from_agent column separates their lines from the user's
so the cap counts the right ones and the UI can mark them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:02:37 +02:00
CubeGameLP cf093e3178 docs: empty-repo hardening notes + verification item
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit d771dbf03575bb60e692ede44110ff17c3043f39)
2026-08-28 20:34:24 +02:00
CubeGameLP e0748ba351 fix(worktree): auto-bootstrap commit-less repos before creating worktrees
Covers autonomous runs, interactive ConPTY sessions (GetInteractiveLaunchSpec),
planning sessions, and improvement/planning children — every path that needs a
base commit now self-heals on a fresh 'git init' repo instead of surfacing
"ambiguous argument 'HEAD'" as a HubException.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit ec16a85c495ceceaa22f922e077a27dcda54f035)
2026-08-28 20:33:45 +02:00
CubeGameLP de44196b9a fix(git): bootstrap commit-less repos and survive missing git identity
A fresh 'git init' repo (unborn HEAD) failed every worktree-based flow with raw
git stderr ('ambiguous argument HEAD'). EnsureHeadCommitAsync now creates an
empty initial commit via plumbing only (mktree/commit-tree/update-ref) so the
user's index and working tree are untouched; broken HEADs get a clear error.
CommitAsync retries once with a ClaudeDo identity when the machine has none,
and RevParseHeadAsync reports 'no commits yet' instead of raw git output.
Also: git stdin is now BOM-less UTF-8 (git rejects BOM-prefixed input).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 21af231f3e7b2cf056efe475916627f0cfeeb5c6)
2026-08-28 20:33:45 +02:00
CubeGameLP c259254921 build: allow SDK roll-forward to a newer major (PCs without a .NET 8 SDK)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 76942bc5c55f3f44e1289ff0d6e690fad57e95b7)
2026-08-28 20:31:50 +02:00
CubeGameLP fea8236e8a docs(git): spec + plan for empty-repo (unborn HEAD) hardening
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5f4b7a9e26af0635cfaecfbc55bfe928dd3466d2)
2026-08-28 20:31:50 +02:00
ClaudeDo CI 1d6cd49beb docs(changelog): update for v2.12.0 2026-08-28 12:17:54 +00:00
mika kuns c31f79d3d4 feat(ui): wählbares Listen-Icon und aufgeräumter Task-Detail-Header
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 41s
Listen bekommen eine Icon-Spalte (Migration AddListIcon) und einen Icon-Picker
in den Listeneinstellungen; 34 kuratierte Geometrien aus der Icon-Bibliothek,
Fallback auf "Folder" bei unbekanntem Key.

Der Metadata-Footer im Task-Detail entfällt; created-at wandert in die
Header-Zeile neben das ID-Badge, der Close-Button (und CloseDetailsCommand)
fällt weg.
2026-08-28 14:17:38 +02:00
mika kuns 22e70ffcb4 feat(ui): Mission Control als Session-Hub
- "+" öffnet ein Menü mit allen Listen (working dir) statt direkt den
  Ordner-Dialog; "Ordner wählen…" bleibt als Fallback erste Zeile, damit
  das Menü auch bei fehlgeschlagener Listen-Abfrage nutzbar bleibt
- Rail-Rows: Kontextmenü "Sitzung öffnen" (gesperrt für Running — der
  Worktree gehört dort dem autonomen Lauf)
- Aktivitäts-Strip statt reiner Queue: WaitingForReview/Failed/Roadblock
  kommen dazu, Attention zuerst, unterschieden nur über Farbe (Aktionen
  bleiben in der Task-Detailansicht)
2026-08-28 14:17:38 +02:00
Mika Kuns 8d597df448 fix(worker): make re-approve after revert_merge do a real merge, not a silent no-op
ApproveAndMergeAsync treated any non-Active worktree (including Kept, the
state RevertMergeAsync leaves behind) the same as "never had a worktree",
so re-approving a reverted task ran only the verify gate and marked the
task Done without merging anything back.

Now a Kept worktree is distinguished: if its branch is gone (the common
case, since approve normally deletes it), approve returns Blocked instead
of a fake "merged". If the branch survives, its tip is already an ancestor
of the target (from the original merge), so a plain `git merge` would
silently no-op ("Already up to date."); cherry-picking the original merge
commit's own diff (-m 1) is what actually restores the content, recorded
as a fresh MergeCommit.
2026-08-28 14:17:38 +02:00
Mika Kuns 1affb39716 fix(worker): survive a worker restart when cancelling a mid-merge unit-merge parent
CancelAsync only checked PlanningMergeOrchestrator's in-memory HasActiveMerge, which is
empty after a restart. IActiveMergeState gains an async fallback that checks on-disk
(GitService.IsMidMergeAsync) for a WaitingForReview parent with children, so the guard
still blocks cancel until the merge is continued or aborted.
2026-08-28 14:17:38 +02:00
ClaudeDo CI bbdb022327 docs(changelog): update for v2.11.0 2026-08-27 14:47:02 +00:00
186 changed files with 16828 additions and 3593 deletions
+136
View File
@@ -1,5 +1,141 @@
# Changelog # Changelog
## v2.12.0 — 2026-08-28
### Features
- wählbares Listen-Icon und aufgeräumter Task-Detail-Header (c31f79d3)
- Mission Control als Session-Hub (22e70ffc)
### Fixes
- make re-approve after revert_merge do a real merge, not a silent no-op (8d597df4)
- survive a worker restart when cancelling a mid-merge unit-merge parent (1affb397)
### Documentation
- update for v2.11.0 (bbdb0223)
## v2.11.0 — 2026-08-27
### Features
- Listeneinstellungen mit Kategorie-Sidebar (4e55f916)
- Ticketsystem-Anbindung hinter einen Aus-Schalter legen (8329c925)
- Ticket-Projekt pro Liste und Import ueber das Kontextmenue (e91ade9c)
- Settings-Tab fuer die Ticketsystem-Anbindung (020f93d5)
- Ticket-Settings, Projektliste und Import ueber den Hub (f0a3a186)
- Ticket-Import pro Liste (2e236736)
- Ticket-Status-Rueckmeldung ueber TaskStateService (5aafeb6c)
- BandelTicketClient fuer die Ticketsystem-REST-API (10652780)
- TicketSystemConfig + ticket_api_base_url in worker.config.json (2a3c937f)
- ticket_ref auf tasks, ticket_project_id auf list_config (3171690b)
- claude_bin im Dateien-Tab setzbar, mit Auflösungs-Check (13b81cac)
- Throttle-Stufen im Ausfuehrung-Tab sichtbar machen (31d53b00)
- Permission-Modus pro Liste und pro Task ueberschreibbar (4464dc6f)
- Listen-Einstellungen — Checkbox fuer serialize_on_file_overlap (5cc99bfe)
- Review-Merge-Button — Claude nimmt Waiting-for-Review pro Liste ab (741c7ac5)
- verify once per batch via skipVerify + verify_merges (168ab1cd)
- run both MCP servers stateless (69fcceb2)
- add the Prime action radio group to settings (f3cb34b9)
- add the action kind to the settings viewmodels (57be4961)
- add Prime action-kind strings in en and de (514f142a)
- carry the action kind through the hub and validate custom prompts (4e8e0334)
- branch PrimeRunner on the schedule action kind (cf6a6b62)
- migrate prime_schedules with action_kind defaulting to ping (71d2972a)
- add PrimeActionKind to the schedule entity (022eaf38)
- open the TokenTracker dashboard from the usage monitor (3003cfa5)
- show cost columns and the TokenTracker hint card in the usage monitor (074c0e4d)
- surface TokenTracker status and cost in the usage monitor (bac0d69a)
- add TokenTracker analytics strings in en and de (66f743dd)
- add TokenTracker status, refresh and install to the worker client (3c3bb306)
- serve model and task usage from the TokenTracker export (44337d4b)
- cache the TokenTracker export with single-flight refresh (3f3638c1)
- run the TokenTracker CLI through the shared executable resolver (a9218045)
- pin the TokenTracker CLI invocations (d6b22ea5)
- hold the last good TokenTracker export fail-open (580d4e07)
- aggregate TokenTracker sessions by model and per task (2fca0cc4)
- parse the TokenTracker sessions export defensively (106f9a37)
- add TokenTracker export models and test fixture (16952b64)
- reproduce TokenTracker session_hash for task attribution (1cb574bd)
- Improve App Prompts (79438c00)
- Toggle "Continue on session limit reset" für Usage-Limit-Abbrüche (07dd7570)
- confirm main-window close when Mission Control sessions are open (0892883a)
- "Worktree öffnen" im Kontextmenü der Task-Zeile (ac9b0dab)
- update_task kann isManual setzen + List-Handler stellt seinen Task auf Review (ea2271cc)
- Usage-Monitor zeigt Reset-Uhrzeit hinter der Restzeit (8f983851)
- Erststart-Banner und Textkette Repo→Task→Queue→Review (UX-Struktur C) (111dd0fb)
- Settings-Modal auf Sidebar-Kategorien umbauen (56c3719e)
- Task-Zeile entdichten und Kontextmenü gruppieren (UX-Struktur A) (51dc7bd6)
- surface startup recovery via OperationProgress channel (6ec81f65)
- report worktree-creation phase on OperationProgress (eba0d842)
- surface rebase and worktree-maintenance progress on OperationProgress (42de97e1)
- Empty States für Detail-Pane und Task-Liste (UX-Audit #4) (9159f1b5)
### Fixes
- Base-URL ohne Schema laesst die Ticket-Anbindung scheitern (3322152b)
- Import-Erfolgsmeldung nicht mehr als Fehler einfaerben (2fe2ac83)
- MergeHelperSelectionModal — Task-Nummer anzeigen, Titel wrappen, Status-Spalte verbreitern (24e1d648)
- Branch-Zeile im Detail-Header — Icon stroked rendern, ohne Task ausblenden (18755910)
- Review-Merge-Button-Icon stroked rendern statt gefuellt — Linien waren unsichtbar (d7a3aa80)
- block delete on interactive-session tasks, clear ghost selection (c1ab042d)
- store ScheduledFor as real UTC instead of mislabeled local time (f70aec49)
- make modify/delete and rename/delete merge conflicts visible instead of silently staging them (533a287e)
- close status-force bypasses on Running tasks (299c7b74)
- allow the done toggle from any non-Running status (f80af122)
- restore the Settings modal body and tidy the Prime schedule row (d71c0cea)
- close the typographic quote that made de.json unparseable (9ab17d82)
- failureReason "usage_limit" nachziehen + Worktree-Gate prüft die Platte (29171b10)
- Usage-Limit-Auto-Continue verbrennt nicht mehr alle Kandidaten außer dem ersten (3dfae30f)
- thread handler task id into list-handler kickoff prompts (9c4a63d2)
- UsagePillViewModel loads snapshot on connection restore (7f94bf4a)
- Description-Editbox nutzt volle Kartenhoehe statt 320px-Deckel (a6d165a1)
- route done-toggle and dequeue through guarded TaskStateService transitions (7f337b36)
- Reactivität — Description-Save-Race, UsageMonitor-Leak, Listen-Live-Refresh (52efe3ec)
- notify IsTasksEmptyRepoHintVisible on every list switch (b997af44)
- Aktions- und Bestätigungs-Konsistenz (UX-Audit #3) (2935eef7)
- surface precondition hints for silently-disabled review buttons (UX-Audit #2) (dbb73bae)
- silent no-ops raise ErrorReported instead of swallowing failures (UX-Audit #1) (99bb5be5)
- propagate unit-merge failures instead of reporting success (f2058430)
- Stuck-Running-Fenster in Continue- und Stop-Pfad schließen (389c9126)
### Refactoring
- Datei-Scope-Serialisierung entfernen (84219a9f)
- OnlineTokenStore zu DpapiTokenStore verallgemeinert (41bfc242)
- Icon-Faerbung zentral im ICON-PAINTING-Block (1ef67760)
- drop the remaining single-implementation interfaces (0f007c53)
- merge the one-expression converters into SimpleConverters.cs (c593be2f)
- declare the SignalR wire DTOs once in ClaudeDo.Data (8930b564)
- squash the 40 migrations into one InitialCreate (39d82413)
- collapse single-implementation interfaces (296251e2)
- delete unreachable members and redundant package refs (81994aad)
- drop the transcript aggregation path in favour of TokenTracker (40879705)
- dedupe path-open helper across five view models (b2a1b790)
- derive TaskRowViewModel CanX/DisabledReason pairs from one gate method (baba9216)
- unify list-linked-repo predicate into RepoLinkage (9578c950)
- dedupe FormatRemaining between UsagePill and UsageMonitorModal (e164f1e3)
### Documentation
- Ticketsystem-Anbindung als pausiert vermerken (cb69e2a7)
- Ticketsystem-Anbindung in den CLAUDE.md-Dateien (fb1ceac1)
- IsConfigured-Kosten praezise beschreiben (e9dfea90)
- Implementierungsplan, Base-URL in worker.config.json (5a13ad44)
- Design fuer die Ticketsystem-Anbindung (63184ea3)
- neue Config-Felder + Migration-Fixture-Falle in den CLAUDE.md-Dateien (d474fd13)
- bump review-merge verified-against to 168ab1cd (187f5fc5)
- document the schedule action kinds (a1d74ab4)
- implementation plan for schedule action kinds (2dec590d)
- add the TokenTracker analytics implementation plan (884b990e)
- document TokenTracker as the analytics backend (c0bf7db6)
- spec per-schedule action kind (Ping/FillMyDay/Custom) (a873eaa9)
- spec for TokenTracker as the token-analytics backend (0e74650d)
- C5 periodische Dienste abgesagt — kein Mehrwert über bestehende Broadcasts (29868962)
- design and plan for the four structural UX themes (4cf08f81)
- update for v2.10.0 (925ba655)
## v2.10.0 — 2026-08-17 ## v2.10.0 — 2026-08-17
### Features ### Features
+28 -4
View File
@@ -66,14 +66,38 @@ Commit freely (per task + the spec/plan docs). Never push without asking.
## Building & Testing ## Building & Testing
`dotnet build ClaudeDo.slnx` requires .NET 9; on .NET 8 build individual projects with `-c Release` (a running Worker locks the `Debug` output). **Build the whole solution, test only what you touched.** Always `-c Release` — a running Worker locks the `Debug` output.
```bash ```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release # pulls in Ui + Data dotnet build ClaudeDo.slnx -c Release # ~40s, catches cross-project breaks
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build \
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release # also: Data.Tests, Ui.Tests, Localization.Tests, Installer.Tests, Releases.Tests --filter "FullyQualifiedName~Handoff" # seconds, not minutes
``` ```
`.slnx` needs SDK 9+ (this machine is on 10). On an older SDK build the projects individually:
`src/ClaudeDo.App` (pulls in Ui + Data) and `src/ClaudeDo.Worker`.
### Do NOT run the full test suite to finish a task
Measured 2026-09-02, sequentially after one build: **Worker.Tests 507s**, Ui.Tests 33s, Data.Tests 8s,
Localization/Installer/Releases 3s each, build 42s — ~10 min for all six. Run in parallel across
projects it is *worse*, not better (Worker.Tests 801s, Ui.Tests 82s): these tests create real git
worktrees and spawn processes, so they thrash rather than share.
With up to 5 implementer tasks running at once, a full suite per task costs hours — and **cannot find
what it costs that for.** Each task works in its own worktree containing only its own change, so a
collision between two parallel tasks is invisible to both. Collisions surface after the merge, which
is exactly where `verify_merges` runs the list's verify command **once per batch**. The full suite is
the merge gate's job, not the task's.
So, per task:
- **Build:** the whole solution, always. It's cheap and it catches the compile break your change
caused three projects over.
- **Tests:** `--no-build --filter` scoped to what you touched. Add a filter for the classes covering
your change; if you genuinely can't scope it, run the one relevant test project, never all six.
- **Report** what you ran and what you did not, so the merge session knows what is still unverified.
- Never report "all tests pass" when you ran a filtered subset. Say which filter you used.
### Gotchas ### Gotchas
- **Subagents:** use the `sonnet` model; stage files explicitly by path — never `git add -A` (parallel sessions often leave unrelated WIP in the tree). - **Subagents:** use the `sonnet` model; stage files explicitly by path — never `git add -A` (parallel sessions often leave unrelated WIP in the tree).
- **Icons:** `PathIcon` *fills* its geometry — line-art icons must be filled geometry or a `Path Classes="icon-stroke"`, else they render invisible. Colour comes from the `ICON PAINTING` block in `IslandStyles.axaml` (class + state); setting `Foreground`/`Stroke` inline kills the hover state. - **Icons:** `PathIcon` *fills* its geometry — line-art icons must be filled geometry or a `Path Classes="icon-stroke"`, else they render invisible. Colour comes from the `ICON PAINTING` block in `IslandStyles.axaml` (class + state); setting `Foreground`/`Stroke` inline kills the hover state.
+53
View File
@@ -115,6 +115,22 @@ share the same prompt/model as their non-final counterpart and differ only in on
rendered into the handoff kickoff file (`PromptKind.MergeHelperHandoff`) marking the final round rendered into the handoff kickoff file (`PromptKind.MergeHelperHandoff`) marking the final round
and forbidding further reruns. and forbidding further reruns.
The chain length is bounded server-side by `HandoffRoundTracker`, whose cap comes from
`AppSettingsEntity.ListHandlerMaxHandoffs` (default 4 = two merge rounds; `HandoffMcpTools` reads it
per call and `Register` clamps to min 1, so 0 can never mean "unbounded"). Past the cap a
`wait`/`merge` request is silently coerced to its `_final` counterpart — check the result's
`coerced` flag — and any handoff after a `merge_final` round throws.
**PowerUse.** When `AppSettingsEntity.PowerUseEnabled` (toggled on the usage monitor) **and** the
list's own `ListConfigEntity.PowerUseAllowed` are both set, `InteractiveLaunchSpecService.
ComposeHandlerPromptAsync` appends `PromptKind.PowerUseAddendum` to whichever handler prompt the
phase resolved to. The addendum is written as an *override* of the base prompt's interactive rules
(never ask, decide and record instead; resolve or abort conflicts rather than leaving them in the
tree; harvest new tasks when a round would starve). Both keys are required on purpose: the global
switch can stay on overnight without turning an unrelated list's handler run unattended. A run that
is not in PowerUse gets a byte-identical prompt to the pre-PowerUse behaviour — there is deliberately
no second set of handler prompts to keep in sync.
Each handoff hands the SAME handler task id to a NEW tile, and `MissionControlViewModel. Each handoff hands the SAME handler task id to a NEW tile, and `MissionControlViewModel.
OpenMergeHelperHandoffConPtySessionAsync` closes the outgoing phase's pane (`CloseConPtySession`, OpenMergeHelperHandoffConPtySessionAsync` closes the outgoing phase's pane (`CloseConPtySession`,
which disposes its `PtyTerminalSession` and kills the underlying `claude` process) before opening which disposes its `PtyTerminalSession` and kills the underlying `claude` process) before opening
@@ -127,6 +143,43 @@ outgoing session is only ever told to end its turn, never to exit. This keeps th
`MCP_TOOL_TIMEOUT` is 200 s here — `TaskWaitMcpTools` clamps its own timeout to 170 s to stay `MCP_TOOL_TIMEOUT` is 200 s here — `TaskWaitMcpTools` clamps its own timeout to 170 s to stay
comfortably under it (see [external-mcp.md](external-mcp.md)). comfortably under it (see [external-mcp.md](external-mcp.md)).
### Auto-restart after the usage limit
A handler session that dies mid-phase used to be a silent dead tile: `InteractiveTerminalViewModel.
OnSessionProcessExited` only raised a visible banner for a death **inside** its 5 s
`StartupGraceWindow`. It now raises `ExitedAfterStartup` for a later non-zero exit; the pane
forwards it as `SessionDiedAfterStartup(taskId, exitCode)` and `MissionControlViewModel` reports it
via `WorkerHub.ReportInteractiveSessionExited`. Every task-based pane reports — the UI does no
filtering, because the exit code is the *only* thing it knows.
`HandlerRestartCoordinator` (Worker) decides the rest and **fails closed** — unlike the usage gate,
every missing signal means "don't relaunch":
- **Which round was running** comes from `HandlerRoundState`, a JSON file at
`~/.claudeDo/handler-rounds.json` (one `HandlerRound` per handler task id, deliberately not a DB
column). `BuildForMergeHelperAsync` writes the opening round as phase `"triage"`;
`BuildForMergeHelperHandoffAsync` writes each subsequent `MergeHelperPhase`. Without it the phase
and task set exist *only* in the transient `HandoffRequested` broadcast, so a dead tile could not
be reopened. A `"triage"` round is never reopened — there is no handoff spec for it.
- **Was it really the usage limit?** A ConPTY session has no captured stdout, so `TaskRunner.
IsUsageLimitMessage` (which matches the CLI's own "session limit" text) has nothing to read. The
corroborating witness is `UsageState.Snapshot.FiveHour.Utilization ≥ 95`
(`HandlerRestartCoordinator.UsageLimitProofPct`) at the moment of death. A crash at 40 % is not
restarted; no snapshot at all is not restarted.
- **PowerUse only** — both keys, exactly like the prompt addendum above.
- **One restart per round**, claimed via `HandlerRoundState.TryClaimAutoRestart` before the wait, so
a restart that dies again stays dead. `HandlerRoundState.Save` carries `AutoRestartUsed` over when
the phase is unchanged, because the restart's own launch-spec build writes the round again — a
naive overwrite would hand out a fresh budget every time and loop all night.
- The restart waits out `FiveHour.ResetsAt`, then re-broadcasts `HandoffRequested` with the
persisted phase + task set, which the UI already handles by reopening the tile. It goes **around**
`handoff_list_handler`, so `HandoffRoundTracker.Register` is never called and the run loses no
handoff budget.
Sibling to `UsageLimitAutoContinueCoordinator`, not an extension of it: that one only ever sees
headless runs (`FailureReason == "usage_limit"`, resumed through the override slot), and a ConPTY
session has no `task_runs` row to classify.
### The host task and its commit range ### The host task and its commit range
The handler run **owns a real ClaudeDo task**, created by `CreateMergeHelperTask` (hub) → The handler run **owns a real ClaudeDo task**, created by `CreateMergeHelperTask` (hub) →
+32 -23
View File
@@ -46,36 +46,45 @@ verifiziert und deshalb hier entfernt. Es bleiben die Entscheidungen, die daran
## Offene Verifikation (2026-08-27, Ticketsystem-Anbindung) ## Offene Verifikation (2026-08-27, Ticketsystem-Anbindung)
> **PAUSIERT.** `TicketSettingsTabViewModel.FeatureEnabled = false` versteckt Settings-Tab, > **Reaktiviert am 2026-09-01.** `FeatureEnabled = true`. Die API läuft unter
> Projekt-Dropdown und Kontextmenü-Eintrag; zum Reaktivieren die Konstante umlegen. Grund: die > `http://192.168.45.161` — der Hostname `bandelapis.fb-tuning.local` lehnt die Verbindung
> API ist unter `http://bandelapis.fb-tuning.local` nicht erreichbar (*connection actively > weiterhin ab, die Base-URL muss also die **IP** sein.
> refused* — nichts lauscht dort). Kein ClaudeDo-Bug und kein CORS; wahrscheinlich fehlt eine
> IIS-Hostname-Bindung oder der Port stimmt nicht (`deploy/deploy-iis-winrm.ps1` der API notiert,
> dass srv-04b diese Sites *by IP:port rather than by hostname* erreicht). Wird mit dem
> API-Kollegen geklärt. Die Punkte unten bleiben stehen, bis die Erreichbarkeit steht.
Tasks 1–9 sind implementiert und gemergt (Build + Tests grün, `_broadcaster.TaskUpdated` genau Alle vier Routen sind jetzt live gegen die echte API geprüft. Die aus dem `Bandel.APIs`-Quellcode
einmal in `TaskStateService.cs`), aber **kein Smoke-Test gegen die echte Bandel-API ist abgeleiteten Shapes stimmten bis auf zwei Punkte, beide gefixt und mit Tests belegt:
gelaufen**. Die fünf Punkte aus der „Manuellen Verifikation" des Specs
(`docs/superpowers/specs/2026-08-27-ticketsystem-integration-design.md`):
- [ ] PAT in den Settings hinterlegen, „Verbindung testen" → korrekter Benutzername + Scopes. - `PATCH /api/Ticket/{id}/status` braucht **`statusId` UND `statusName`** — ohne den Namen 400
- [ ] Liste mit Projekt `393 Bandel.LagerApp` verknüpfen, „Update from Ticketsystem" → nur („The StatusName field is required."), also hätte *jede* Write-Back fehlgeschlagen. Der Name
eigene, offene Tickets erscheinen als Tasks. wird nicht gegen die Id validiert, muss aber nicht-leer sein (`TicketStatusMap.NameOf`).
- [ ] Einen Task laufen lassen → Ticket steht im Ticketsystem auf `InBearbeitung`. - Das flache `assigneeName` im Board-Item ist auf **jedem** Ticket null (alle 63 in Abteilung
Entwicklung geprüft). Der Assignee-Filter des Imports hätte damit immer 0 Tasks erzeugt. Echte
Quelle: `ticketAssignees[].assigneeName`, das exakt das Format von `/pat/me`s `userName` hat.
`personInChargeName` ist die falsche Quelle (mal Login, mal Anzeigename).
Import zieht jetzt `Offen` **und** `InBearbeitung`, weiterhin nur eigene Tickets.
Bleibt manuell zu prüfen (UI-Pfad, nie sichtbar gelaufen):
- [ ] PAT in Settings → Ticketsystem hinterlegen (Base-URL `http://192.168.45.161`),
„Verbindung testen" → `mika.kuns` + 9 Scopes.
- [ ] Liste mit einem Projekt verknüpfen (Rechtsklick → Listeneinstellungen → Ticket-Projekt),
„Update from Ticketsystem" → nur eigene offene/laufende Tickets erscheinen als Tasks.
Achtung: aktuell ist **kein** offenes Ticket in Entwicklung dir zugewiesen — zum Testen
erst im Ticketsystem zuweisen, sonst ist 0 importiert das korrekte Ergebnis.
- [ ] Einen Task laufen lassen → Ticket steht auf `InBearbeitung`.
- [ ] Approve/Merge → Ticket steht auf `Fertig`. - [ ] Approve/Merge → Ticket steht auf `Fertig`.
- [ ] Base-URL leeren → Menüeintrag verschwindet, keine Netzwerkaufrufe mehr. - [ ] Base-URL leeren → Menüeintrag verschwindet, keine Netzwerkaufrufe mehr.
- [ ] Sichtprüfung des Settings-Tabs + des Ticket-Projekt-Dropdowns (nie gerendert gesehen).
**Wichtig:** die Response-Shapes der Bandel-API (`BandelEnvelope<T>`, `TicketIdentity`,
`TicketBoardItem`, `SidebarDepartment`/`SidebarProject`, `TicketDtos.cs`) wurden aus dem
**Quellcode** von `Bandel.APIs` abgeleitet, **nie live gegen die echte API verifiziert** — es gab
keinen einzigen echten Netzwerkaufruf während der Implementierung. Die Punkte 1–4 oben sind
zugleich der erste Test dieser Annahme: schlägt einer davon mit einem Deserialisierungs- oder
404/Formatfehler fehl, ist das erwartbar und kein Regressions-Bug — dann müssen die DTOs in
`src/ClaudeDo.Worker/Tickets/TicketDtos.cs` an die tatsächliche Antwort angepasst werden.
## Offene Verifikation (2026-07-27) ## Offene Verifikation (2026-07-27)
- **Empty-Repo-Hardening (2026-07-30)** — Worker/App neu starten (laufende Instanz hat den
Fix noch nicht), dann auf dem Task „HEy this is a Test" (Test List → `C:\Dev\TestRepo`,
aktuell 0 Commits) „Open ConPTY session" klicken: muss jetzt starten und in `C:\Dev\TestRepo`
genau einen leeren Commit „chore: initialize repository (ClaudeDo)" hinterlassen. Idealerweise
auch einmal auf dem Zweit-PC gegentesten (dort zusätzlich: Task-Run auf einem PC ohne
`git config user.name/email` — Auto-Commit darf nicht mehr scheitern).
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer, - **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
the context-menu item appears only on lists with a working dir, and the selection dialog has no the context-menu item appears only on lists with a working dir, and the selection dialog has no
LIST column. (Der real-Claude-Smoke-Run der fünf Phasen ist am 2026-07-29 gelaufen — 6/6 sauber LIST column. (Der real-Claude-Smoke-Run der fünf Phasen ist am 2026-07-29 gelaufen — 6/6 sauber
@@ -0,0 +1,58 @@
# Empty-repo hardening — implementation plan
Spec: `docs/superpowers/specs/2026-07-30-empty-repo-hardening-design.md`
## Task 1 — GitService: HasHeadCommitAsync + EnsureHeadCommitAsync + friendly unborn error
**Files:** `src/ClaudeDo.Data/Git/GitService.cs`,
`tests/ClaudeDo.Worker.Tests/Runner/GitServiceBootstrapTests.cs` (new)
1. Tests first (skip when git unavailable, mirroring `WorktreeManagerTests`):
- fresh `git init` repo (no commit): `HasHeadCommitAsync` false; after a commit true.
- `EnsureHeadCommitAsync` on empty repo → true; `RevParseHeadAsync` then succeeds;
`git ls-tree HEAD` empty; second call → false.
- staged file in empty repo survives bootstrap: still staged, not in the commit.
- corrupt HEAD (write a garbage SHA into `.git/HEAD` as a detached head):
`EnsureHeadCommitAsync` throws `InvalidOperationException` mentioning the dir.
- `RevParseHeadAsync` on empty repo: message says "has no commits yet" (no raw
"ambiguous argument").
2. Implement: `HasHeadCommitAsync` (`rev-parse --verify --quiet HEAD`),
`EnsureHeadCommitAsync` (symbolic-ref guard → hash-object/commit-tree/update-ref
plumbing behind a static `SemaphoreSlim` with post-acquire re-check), unborn-HEAD
message in `RevParseHeadAsync`.
3. Build Data + run new tests. Commit `fix(git): bootstrap empty repos with an initial commit`.
## Task 2 — CommitAsync identity fallback
**Files:** same two files.
1. Test: repo with local `user.name`/`user.email` set to empty strings → `CommitAsync`
succeeds (retry path), commit exists.
2. Implement: on identity-signature failure, retry once with
`-c user.name=ClaudeDo -c user.email=claudedo@local`.
3. Commit `fix(git): fall back to a ClaudeDo identity when git has none`.
## Task 3 — call sites
**Files:** `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`,
`src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`,
`tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs`
1. Test: `CreateAsync` on a commit-less repo succeeds; worktree on disk; DB row Active.
2. Wire `EnsureHeadCommitAsync` into `WorktreeManager.CreateAsync` (log info on
bootstrap) and `PlanningSessionManager.StartAsync`.
3. Full Worker.Tests run. Commit `fix(worktree): auto-bootstrap commit-less repos before creating worktrees`.
## Task 4 — docs
- `src/ClaudeDo.Data/CLAUDE.md`: GitService bullet gains bootstrap/identity-fallback.
- `docs/open.md`: verification item — open a ConPTY session on a fresh `git init` repo
on a second PC.
- Commit `docs: empty-repo hardening notes`.
## Verification
- `dotnet test tests/ClaudeDo.Worker.Tests -c Release` green.
- Manual: task 61609f79… in "Test List" (`C:\Dev\TestRepo`, currently commit-less) —
opening the ConPTY session must now succeed and leave `C:\Dev\TestRepo` with exactly
one empty commit by ClaudeDo.
@@ -0,0 +1,147 @@
# Mehrfach-Abhängigkeiten (`depends_on` als Menge) — Implementation Plan
**Goal:** A task can declare *n* predecessors instead of exactly one. The queue picker
claims it only once **all** of them are `Done`.
**Architecture:** Replace the single `tasks.depends_on_task_id` column with a
`task_dependencies(task_id, depends_on_task_id)` relation table (composite PK, both FKs to
`tasks.id`, `ON DELETE CASCADE`). Cascade reproduces today's `ON DELETE SET NULL` semantics
on a join table: deleting a predecessor drops the edge, so the dependent becomes pickable
instead of blocked forever. `BlockedByTaskId` (the internal planning chain) is untouched —
the split stays deliberate.
**Tech Stack:** .NET 8, EF Core (SQLite), SignalR, Avalonia 12, xUnit.
**Not in scope:** `BlockedByTaskId`; rewriting the list-handler prompt to *use* multi-deps;
a graph visualizer.
---
## File Structure
**Create:**
- `src/ClaudeDo.Data/Models/TaskDependencyEntity.cs`
- `src/ClaudeDo.Data/Configuration/TaskDependencyEntityConfiguration.cs`
- `src/ClaudeDo.Data/Migrations/<ts>_AddTaskDependencies.cs` — scaffolded
- `src/ClaudeDo.Worker/External/TaskBlocking.cs` — the shared `blockedReason` builder
(two consumers: `ExternalMcpService`, `TaskWaitMcpTools`)
- `tests/ClaudeDo.Data.Tests/TaskDependencyMigrationTests.cs` — real `Migrate()` on a DB
seeded with legacy `depends_on_task_id` rows
**Modify:**
- `src/ClaudeDo.Data/Models/TaskEntity.cs` — `DependsOnTaskId` → `Dependencies` nav +
`[NotMapped] DependsOnTaskIds`
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — drop column/FK/index
- `src/ClaudeDo.Data/ClaudeDoDbContext.cs` — `DbSet<TaskDependencyEntity>`
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — `.Include(t => t.Dependencies)` on
the three read paths; `UpdateAsync` must not push the nav graph
- `src/ClaudeDo.Worker/Queue/QueuePicker.cs` — `NOT EXISTS` over the relation table
- `src/ClaudeDo.Worker/State/TaskStateService.cs` — `SetDependsOnAsync` takes a set,
graph-wide cycle check
- `src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs`
- `src/ClaudeDo.Worker/External/ExternalMcpService.cs` — DTOs, `add_task`, `update_task`,
`ComputeBlockedInfoAsync`
- `src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs`
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`,
`TasksIslandViewModel.cs` — display of *n* predecessors
- `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Worker/CLAUDE.md`
- test files listed per task below
---
## Task 1 — Schema + migration
- [ ] `TaskDependencyEntity { string TaskId; string DependsOnTaskId; }`, table
`task_dependencies`, composite PK, both FKs cascade, index on `depends_on_task_id`.
- [ ] `TaskEntity.Dependencies` (`ICollection<TaskDependencyEntity>`), `[NotMapped]
DependsOnTaskIds` projection. `DependsOnTaskId` and its FK/index are removed.
- [ ] Scaffold the migration, then **hand-edit the Up body** so the data copy sits between
create and drop:
`CreateTable` → `INSERT INTO task_dependencies (task_id, depends_on_task_id) SELECT
id, depends_on_task_id FROM tasks WHERE depends_on_task_id IS NOT NULL` →
`DropColumn`. Down reverses it (add column, `UPDATE … FROM` the first edge, drop
table) — lossy for >1 edge, which is inherent and documented in the migration.
- [ ] Verify: `dotnet ef database update` against a temp DB seeded with legacy rows;
`pragma_table_info` diff. **`DropColumn` forces a SQLite table rebuild** — the
`ef-migration-parallel-designer-collision` trap applies: if another migration lands
in parallel, hand-check both `.Designer.cs` snapshots.
**Test:** `TaskDependencyMigrationTests` — migrate to the previous migration, seed a task
with `depends_on_task_id` via raw SQL, migrate to head, assert the edge is in
`task_dependencies` and nothing else changed. (Acceptance #2.)
## Task 2 — Queue gating
- [ ] `QueuePicker` replaces the `depends_on_task_id IS NULL OR EXISTS(...)` clause with
a doubly-negated `NOT EXISTS` over `task_dependencies`, so a dangling edge keeps
blocking exactly as today:
```sql
AND NOT EXISTS (
SELECT 1 FROM task_dependencies td
WHERE td.task_id = t.id
AND NOT EXISTS (SELECT 1 FROM tasks d
WHERE d.id = td.depends_on_task_id AND d.status = 'done')
)
```
**Tests** (`QueuePickerTests`, real SQLite): n=2 both `Done` → claimed; one `Done` one
`Running` → skipped; zero deps → claimed (regression); one dep `Failed` → still skipped,
no cascade (acceptance #6); one dep `Cancelled` → still skipped.
## Task 3 — Write path + cycle detection
- [ ] `ITaskStateService.SetDependsOnAsync(string taskId, IReadOnlyList<string>
dependsOnTaskIds, CancellationToken)` — empty list clears. Rejects self-reference,
unknown id, duplicate handling by de-dup.
- [ ] Cycle check walks the **graph**: load the whole edge set once (it only ever holds
*declared* dependencies, so it stays small — `ponytail:` note, switch to a recursive
CTE if it ever isn't), BFS from every proposed predecessor; reaching `taskId` is a
cycle. Visited set guards pre-existing bad data.
- [ ] Replace rows transactionally (`ExecuteDelete` for `taskId` + `AddRange`), wake the
queue when the set shrank.
**Tests** (`TaskStateServiceTests`): sets n edges; empty list clears + wakes; self-ref
rejected; unknown id rejected; **A→B→C→A rejected** (acceptance #3); adding a second edge
leaves the first intact.
## Task 4 — MCP surface
- [ ] `add_task` / `update_task` keep `dependsOnTaskId` (string) **and** gain
`dependsOnTaskIds` (`string[]?`). Precedence: `dependsOnTaskIds` non-null wins;
otherwise the legacy scalar is treated as a one-element set, with `""` still the
clear sentinel. Both run through `TaskIdResolver` so `#123` keeps working.
- [ ] `TaskDto`/`TaskRefDto`: `DependsOnTaskId` → `DependsOnTaskIds`
(`IReadOnlyList<string>`). Only the *input* form is contractually frozen.
- [ ] `TaskBlocking.Describe` builds one reason naming **every** unmet predecessor
(acceptance #4); `ComputeBlockedInfoAsync` and `TaskWaitMcpTools.CheckOnceAsync`
both use it.
**Tests** (`ExternalMcpServiceTests`, `TaskWaitMcpToolsTests`, `BatchMcpToolsTests`):
legacy scalar still sets + clears the link (acceptance #5, explicit back-compat test);
`dependsOnTaskIds` sets n; cycle via the list form throws; `blockedReason` names both
open predecessors.
## Task 5 — UI
- [ ] `TaskRowViewModel.DependsOnTaskIds`; `DependsOnTaskId` becomes a computed
"single predecessor or null" so the existing linear-chain rail is unchanged for the
1-dependency case.
- [ ] `BuildAfterLabel` joins all resolvable predecessors (`#3, #7`); a row with >1
predecessor gets the label, never a rail (a rail can only render a line).
- [ ] `GroupingKey` folds the joined ids so the reconcile tick still detects a change.
- [ ] `.Include(t => t.Dependencies)` on the `TasksIslandViewModel` query sites that
hydrate rows.
**Tests** (`TasksIslandChainTests`, `TasksIslandClassifyItemsTests`,
`TasksIslandReconcileTickTests`): existing chain tests keep passing via the 1-dep path;
a 2-dep row shows a joined after-label and no rail.
## Task 6 — Docs + verify
- [ ] `src/ClaudeDo.Data/CLAUDE.md` (Models, Schema) and
`src/ClaudeDo.Worker/CLAUDE.md` (Status Model table, picker bullet,
`TaskStateService` bullet) describe the relation table (acceptance #7).
- [ ] VERIFY (acceptance #8): `dotnet build src/ClaudeDo.App -c Release`,
`dotnet build src/ClaudeDo.Worker -c Release`, `dotnet test` for
`ClaudeDo.Data.Tests`, `ClaudeDo.Worker.Tests`, `ClaudeDo.Ui.Tests` (`-c Release`),
report counts.
@@ -0,0 +1,98 @@
# Empty-repo hardening (unborn HEAD) — design
**Date:** 2026-07-30
**Status:** approved (option "Auto-Bootstrap" chosen by Mika)
## Problem
`GetInteractiveLaunchSpec` (embedded ConPTY session) fails with a raw git error when the
task's list points at a git repository that has **no commits yet** (fresh `git init`,
unborn HEAD):
```
HubException: git rev-parse HEAD failed (exit 128): fatal: ambiguous argument 'HEAD': unknown revision ...
```
Root cause chain: `InteractiveLaunchSpecService.BuildForTaskAsync` → `WorktreeManager.CreateAsync`
→ `IsGitRepoAsync` (passes — `rev-parse --git-dir` works on an empty repo) →
`ResolveBaseCommitAsync` → `RevParseHeadAsync` (fatals — HEAD is unborn). The same gap
exists in `PlanningSessionManager.StartAsync` and hits autonomous runs (`TaskRunner` →
`CreateAsync` → task `Failed` with the same cryptic message).
This is a "works on my laptop" class of bug: on a fresh PC the first thing a user tries is
a freshly init-ed test repo, which breaks instantly. A second member of the same class:
a PC without `git config user.name/email` breaks every auto-commit
("Please tell me who you are").
## Decision
**Auto-bootstrap:** when a worktree (task run / ConPTY / planning) needs a base commit and
the repo has zero commits, ClaudeDo creates an empty initial commit automatically and
proceeds. Nothing can be lost — the repo is empty. Genuinely broken states (HEAD not a
symbolic ref and not resolvable) still produce a clear, actionable error instead of raw
git stderr.
## Design
### 1. `GitService.HasHeadCommitAsync(dir)`
`git rev-parse --verify --quiet HEAD` → exit 0 means HEAD resolves to a commit.
### 2. `GitService.EnsureHeadCommitAsync(dir)` → `bool` (true = bootstrapped)
- HEAD resolves → return `false` (no-op).
- `git symbolic-ref -q HEAD` fails → repo is corrupt (detached HEAD onto a missing
commit, mangled `.git/HEAD`) → throw `InvalidOperationException` with a clear message
naming the directory. Auto-fixing a corrupt repo is out of scope by design.
- Otherwise HEAD is an unborn branch → bootstrap **via plumbing only** (never touches the
user's index or working tree — staged/untracked files stay exactly as they were):
1. `git hash-object -w -t tree --stdin` with empty stdin → empty tree object.
2. `git -c user.name=ClaudeDo -c user.email=claudedo@local commit-tree <tree> -m
"chore: initialize repository (ClaudeDo)"` — inline identity so this works on a PC
with no git identity configured.
3. `git update-ref <unborn-branch-ref> <commit>`.
- Serialized behind a process-wide gate with a re-check after acquiring, so two parallel
task starts on the same empty repo bootstrap exactly once.
### 3. Defense-in-depth: friendly `RevParseHeadAsync` error
When `rev-parse HEAD` fails with the unborn-HEAD signature ("ambiguous argument 'HEAD'"),
throw "The repository at {dir} has no commits yet" instead of raw stderr — covers any
future call site that forgets the preflight.
### 4. Call sites
- `WorktreeManager.CreateAsync` — after the `IsGitRepoAsync` guard, call
`EnsureHeadCommitAsync`; log when a bootstrap happened. Covers autonomous runs,
interactive ConPTY sessions, improvement/planning children.
- `PlanningSessionManager.StartAsync` — same, before its own `RevParseHeadAsync`.
### 5. Git identity fallback for auto-commits
`GitService.CommitAsync`: if the commit fails with the missing/empty-identity signature,
retry once with inline `-c user.name="ClaudeDo" -c user.email="claudedo@local"`.
Happy path costs nothing (no preflight call); a fresh PC without git identity no longer
fails every task auto-commit.
## Out of scope
- UI validation when configuring a list's WorkingDir (nice-to-have; the worktree paths now
either self-heal or produce clear errors).
- Repairing corrupt repos.
## Test plan (real git, Worker.Tests)
- `HasHeadCommitAsync`: false on fresh `git init`, true after a commit.
- `EnsureHeadCommitAsync` on an empty repo: returns true, `rev-parse HEAD` then works,
tree of the bootstrap commit is empty, second call returns false.
- Staged-file preservation: file staged in empty repo → after bootstrap it is still
staged and NOT part of the bootstrap commit.
- Corrupt HEAD (point `.git/HEAD` at a bogus ref? no — detach onto a garbage SHA by
writing `.git/HEAD` directly): clear error, no bootstrap.
- `CommitAsync` identity fallback: local `user.name`/`user.email` set to empty strings
(forces the identity failure deterministically regardless of global config) → commit
succeeds via fallback, author is ClaudeDo.
- `WorktreeManager.CreateAsync` on an empty repo: succeeds; worktree exists; base commit
= bootstrap commit.
- `PlanningSessionManager.StartAsync` on an empty repo: succeeds (if existing test infra
makes this cheap; otherwise covered by the shared code path).
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"sdk": { "sdk": {
"version": "8.0.418", "version": "8.0.418",
"rollForward": "latestFeature" "rollForward": "latestMajor"
} }
} }
+2 -2
View File
@@ -165,13 +165,13 @@ sealed class Program
sc.AddSingleton<TasksIslandViewModel>(sp => sc.AddSingleton<TasksIslandViewModel>(sp =>
new TasksIslandViewModel( new TasksIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>())); sp.GetRequiredService<IWorkerClient>(),
sp.GetRequiredService<INotesApi>()));
sc.AddSingleton<DetailsIslandViewModel>(sp => sc.AddSingleton<DetailsIslandViewModel>(sp =>
new DetailsIslandViewModel( new DetailsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>(), sp.GetRequiredService<IWorkerClient>(),
sp, sp,
sp.GetRequiredService<INotesApi>(),
sp.GetRequiredService<MergeCoordinator>())); sp.GetRequiredService<MergeCoordinator>()));
sc.AddSingleton<UsagePillViewModel>(sp => sc.AddSingleton<UsagePillViewModel>(sp =>
new UsagePillViewModel(sp.GetRequiredService<IWorkerClient>())); new UsagePillViewModel(sp.GetRequiredService<IWorkerClient>()));
+29 -8
View File
@@ -4,9 +4,10 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Models ## Models
- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), DependsOnTaskId (FK to a user/MCP-declared predecessor, distinct from BlockedByTaskId), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns / PermissionMode (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId, TicketRef, Number (global immutable human-readable alias). - **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), Dependencies (n user/MCP-declared predecessors via `task_dependencies`, distinct from BlockedByTaskId), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns / PermissionMode (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId, TicketRef, Number (global immutable human-readable alias).
- `TicketRef` (`ticket_ref`, nullable string) — link to an external ticket, format `<provider>:<id>` (currently always `bandel:<ticketId>`). Set by the ticket import, otherwise null. → `ClaudeDo.Worker/CLAUDE.md` Tickets section. - `TicketRef` (`ticket_ref`, nullable string) — link to an external ticket, format `<provider>:<id>` (currently always `bandel:<ticketId>`). Set by the ticket import, otherwise null. → `ClaudeDo.Worker/CLAUDE.md` Tickets section.
- Status / PlanningPhase / BlockedByTaskId / DependsOnTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model. - Status / PlanningPhase / BlockedByTaskId / dependency-set semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model.
- `Dependencies` (`ICollection<TaskDependencyEntity>`) + the `[NotMapped] DependsOnTaskIds` id projection. **Only populated where the query `Include`s it** — `TaskRepository.GetByIdAsync`/`GetByNumberAsync`/`GetByListIdAsync` do, and so do the `TasksIslandViewModel` row-hydration queries. The queue gate itself is raw SQL in `QueuePicker` and never reads the nav, so a forgotten Include misreports the UI/MCP `blocked` flag but can never let a blocked task run. ⚠️ `TaskRepository.UpdateAsync` marks **only the root entity** Modified (`Entry().State`, not `Update()`) — `Update()` marks the loaded graph, and an all-key `task_dependencies` row has nothing to SET.
- `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). - `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). - `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). The one-time backfill for pre-existing rows lived in the `AddTaskNumbers` migration, squashed away 2026-08-26. - `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). The one-time backfill for pre-existing rows lived in the `AddTaskNumbers` migration, squashed away 2026-08-26.
@@ -20,6 +21,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → `daily_notes` - **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → `daily_notes`
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → `week_reports`, unique index on (start_date, end_date) - **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → `week_reports`, unique index on (start_date, end_date)
- **TaskAttachmentEntity** — Id, TaskId (FK, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → `task_attachments` - **TaskAttachmentEntity** — Id, TaskId (FK, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → `task_attachments`
- **TaskDependencyEntity** — one declared "run after" edge: TaskId + DependsOnTaskId, composite PK, table `task_dependencies`. Both FKs are `ON DELETE CASCADE`, which is what the old single `depends_on_task_id` column got from `ON DELETE SET NULL`: deleting a predecessor drops the edge so the dependent becomes pickable instead of blocked on a task that's gone. Written **only** by `TaskStateService.SetDependsOnAsync`.
- **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files - **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files
### AppSettingsEntity ### AppSettingsEntity
@@ -37,10 +39,20 @@ Beyond the basics it carries:
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. | | `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. | | `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
| `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. | | `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. |
| `PowerUseEnabled` | `power_use_enabled` | false | PowerUse master switch, toggled on the **usage monitor** (not Settings). ⚠️ Never sufficient alone — a handler run is in PowerUse only when `list_config.power_use_allowed` is also set. |
| `ListHandlerMaxHandoffs` | `list_handler_max_handoffs` | 4 | Cap on a list-handler run's wait↔merge handoff chain (`HandoffRoundTracker`). 4 = two merge rounds. Independent of PowerUse — applies to interactive runs too. Clamped to min 1 in `WorkerHub.UpdateAppSettings` and again in `Register`, because 0 would remove the only bound on a runaway chain. |
| `PowerUseWindowStart` / `PowerUseWindowEnd` | `power_use_window_*` | null | Optional local-time window (`HH:mm`) narrowing when PowerUse applies. Both null (or only one set) = round the clock, i.e. byte-identical to the pre-window behaviour; end before start wraps over midnight. Semantics live in the pure `PowerUseWindow`; checked in `InteractiveLaunchSpecService.ComposeHandlerPromptAsync` **per phase** at prompt-build time, so a chain handing off past the end time drops the addendum from there on. Unparseable input is stored as null, not rejected. |
| `PowerUseHandoffPct` | `power_use_handoff_pct` | 95 | 5h-usage percentage at which a PowerUse handler session should hand off. Clamped 50..99. Stored + shown on the usage monitor; the consumer is the handoff prompt. |
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`. All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
⚠️ **`AppSettingsRepository.UpdateAsync` copies the incoming entity field by field** onto the
singleton row — a property missing from that block never persists, however correctly the caller
filled the entity, and nothing fails. `ModelPresets`, `PowerUseEnabled` and
`ListHandlerMaxHandoffs` were all silently unwritable that way until 2026-09-02. Add a new column
to that block, not just to the entity + the hub.
### Model / effort registries ### Model / effort registries
- **ModelPresets / ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`), one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1–200) and **never throw** — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns)` always returns a usable row; resolution order and the fallback trap → [worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md). - **ModelPresets / ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`), one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1–200) and **never throw** — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns)` always returns a usable row; resolution order and the fallback trap → [worker-task-pipeline](../../docs/explore-notes/worker-task-pipeline.md).
@@ -80,10 +92,15 @@ dir(s) via an optional `AttachmentStore` ctor param (defaults to the production
- Merge: ff-only, no-ff, abort, mid-merge detection (`MERGE_HEAD`), conflicted files - Merge: ff-only, no-ff, abort, mid-merge detection (`MERGE_HEAD`), conflicted files
- Revert: `RevertMergeCommitAsync` (`git revert --no-edit -m 1 <sha>`), `RevertAbortAsync`, `IsMidRevertAsync` (`REVERT_HEAD`, mirrors `IsMidMergeAsync`) - Revert: `RevertMergeCommitAsync` (`git revert --no-edit -m 1 <sha>`), `RevertAbortAsync`, `IsMidRevertAsync` (`REVERT_HEAD`, mirrors `IsMidMergeAsync`)
- `PreviewMergeAsync` (non-destructive check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo - `PreviewMergeAsync` (non-destructive check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
- Empty repos: `HasHeadCommitAsync` (HEAD resolves to a real commit) and `EnsureHeadCommitAsync` — a fresh `git init` repo (unborn HEAD) gets an empty bootstrap commit via plumbing only (mktree/commit-tree/update-ref), so the user's index and working tree stay untouched; identity is passed inline, so it also works with no git config, and a broken HEAD throws instead of guessing
- Identity fallback: `CommitAsync` retries once as the ClaudeDo identity when the machine has none configured
⚠️ **Revert never resets or rewrites** — it always produces a new commit, because the working ⚠️ **Revert never resets or rewrites** — it always produces a new commit, because the working
directory it operates on is shared with other concurrent sessions. directory it operates on is shared with other concurrent sessions.
⚠️ **Git stdin is BOM-less UTF-8** — `Encoding.UTF8` writes a BOM preamble, which git rejects
as input (`mktree` fails with "input format error", and commit messages would carry it).
## Schema ## Schema
Tables (one per line so parallel migrations don't collide on the same line): Tables (one per line so parallel migrations don't collide on the same line):
@@ -98,6 +115,7 @@ Tables (one per line so parallel migrations don't collide on the same line):
- `daily_notes` - `daily_notes`
- `week_reports` - `week_reports`
- `task_attachments` - `task_attachments`
- `task_dependencies`
Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into
a single `20260826094154_InitialCreate` on 2026-08-26** — the schema history is git history now, a single `20260826094154_InitialCreate` on 2026-08-26** — the schema history is git history now,
@@ -107,8 +125,9 @@ not a migration chain. Consequences:
(pre-EF databases without a history, and databases whose history still lists the old chain ending (pre-EF databases without a history, and databases whose history still lists the old chain ending
at `20260825063230_AddPrimeActionKind`). A database that stopped *mid*-chain can no longer be at `20260825063230_AddPrimeActionKind`). A database that stopped *mid*-chain can no longer be
upgraded and throws with a reinstall message instead of being stamped onto a schema it lacks. upgraded and throws with a reinstall message instead of being stamped onto a schema it lacks.
Covered by `MigrationBaselineTests` — the only test that runs a real `Migrate()` (every other Covered by `MigrationBaselineTests` — one of only two tests that run a real `Migrate()`
Data.Tests fixture uses `EnsureCreated`, which skips migrations entirely). Its fixture builds the (`TaskDependencyMigrationTests` is the other; every other Data.Tests fixture uses
`EnsureCreated`, which skips migrations entirely). Its fixture builds the
legacy database by migrating **to** `InitialCreate` and seeding rows with raw SQL: `EnsureCreated` legacy database by migrating **to** `InitialCreate` and seeding rows with raw SQL: `EnsureCreated`
would build *today's* schema, so every post-squash migration would then re-add an existing column would build *today's* schema, so every post-squash migration would then re-add an existing column
("duplicate column name"). Assert "nothing pending", never "InitialCreate is the only history row". ("duplicate column name"). Assert "nothing pending", never "InitialCreate is the only history row".
@@ -117,10 +136,12 @@ not a migration chain. Consequences:
Release` — `Data` is its own startup project (it holds the `Design` package and a design-time Release` — `Data` is its own startup project (it holds the `Design` package and a design-time
factory), and `--configuration Release` is needed because a running Worker locks `Debug`. factory), and `--configuration Release` is needed because a running Worker locks `Debug`.
`tasks` holds `status`, `planning_phase` (default `none`), `tasks` holds `status`, `planning_phase` (default `none`) and `blocked_by_task_id` (FK to
`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`), and `depends_on_task_id` (same FK `tasks.id`, `ON DELETE SET NULL`). The user/MCP-declared predecessors live in `task_dependencies`
shape, but a separate column — see Worker/CLAUDE.md → Status Model for why it isn't unified with instead — a **set**, so a task can wait on n of them; see Worker/CLAUDE.md → Status Model for why
`blocked_by_task_id`). it isn't unified with `blocked_by_task_id`. `AddTaskDependencies` (2026-09-02) migrated the former
single `tasks.depends_on_task_id` column into that table (each value became a one-element set) and
dropped it; the `Down` direction is inherently lossy for more than one edge.
## Conventions ## Conventions
+1
View File
@@ -41,6 +41,7 @@ public class ClaudeDoDbContext : DbContext
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance); => optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
public DbSet<TaskEntity> Tasks => Set<TaskEntity>(); public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<TaskDependencyEntity> TaskDependencies => Set<TaskDependencyEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>(); public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>(); public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>(); public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
@@ -70,6 +70,17 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.AutoContinueOnUsageLimit) builder.Property(s => s.AutoContinueOnUsageLimit)
.HasColumnName("auto_continue_on_usage_limit").IsRequired().HasDefaultValue(false); .HasColumnName("auto_continue_on_usage_limit").IsRequired().HasDefaultValue(false);
builder.Property(s => s.PowerUseEnabled)
.HasColumnName("power_use_enabled").IsRequired().HasDefaultValue(false);
builder.Property(s => s.PowerUseWindowStart)
.HasColumnName("power_use_window_start");
builder.Property(s => s.PowerUseWindowEnd)
.HasColumnName("power_use_window_end");
builder.Property(s => s.PowerUseHandoffPct)
.HasColumnName("power_use_handoff_pct").IsRequired().HasDefaultValue(95);
builder.Property(s => s.ListHandlerMaxHandoffs)
.HasColumnName("list_handler_max_handoffs").IsRequired().HasDefaultValue(4);
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
} }
} }
@@ -14,6 +14,7 @@ public class DailyNoteEntityConfiguration : IEntityTypeConfiguration<DailyNoteEn
builder.Property(n => n.Date).HasColumnName("note_date").IsRequired(); builder.Property(n => n.Date).HasColumnName("note_date").IsRequired();
builder.Property(n => n.Text).HasColumnName("text").IsRequired(); builder.Property(n => n.Text).HasColumnName("text").IsRequired();
builder.Property(n => n.SortOrder).HasColumnName("sort_order").IsRequired(); builder.Property(n => n.SortOrder).HasColumnName("sort_order").IsRequired();
builder.Property(n => n.FromAgent).HasColumnName("from_agent").IsRequired();
builder.Property(n => n.CreatedAt).HasColumnName("created_at").IsRequired(); builder.Property(n => n.CreatedAt).HasColumnName("created_at").IsRequired();
builder.HasIndex(n => n.Date); builder.HasIndex(n => n.Date);
} }
@@ -20,5 +20,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
builder.Property(c => c.VerifyCommand).HasColumnName("verify_command"); builder.Property(c => c.VerifyCommand).HasColumnName("verify_command");
builder.Property(c => c.PermissionMode).HasColumnName("permission_mode"); builder.Property(c => c.PermissionMode).HasColumnName("permission_mode");
builder.Property(c => c.TicketProjectId).HasColumnName("ticket_project_id"); builder.Property(c => c.TicketProjectId).HasColumnName("ticket_project_id");
builder.Property(c => c.PowerUseAllowed).HasColumnName("power_use_allowed");
} }
} }
@@ -19,6 +19,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false); builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false); builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false);
builder.Property(l => l.Icon).HasColumnName("icon");
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort"); builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
@@ -0,0 +1,32 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class TaskDependencyEntityConfiguration : IEntityTypeConfiguration<TaskDependencyEntity>
{
public void Configure(EntityTypeBuilder<TaskDependencyEntity> builder)
{
builder.ToTable("task_dependencies");
builder.HasKey(d => new { d.TaskId, d.DependsOnTaskId });
builder.Property(d => d.TaskId).HasColumnName("task_id");
builder.Property(d => d.DependsOnTaskId).HasColumnName("depends_on_task_id");
// Cascade on both sides reproduces what the old single depends_on_task_id column got from
// ON DELETE SET NULL: deleting the predecessor drops the edge, so the dependent becomes
// pickable rather than blocked forever on a task that no longer exists.
builder.HasOne<TaskEntity>()
.WithMany(t => t.Dependencies)
.HasForeignKey(d => d.TaskId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<TaskEntity>()
.WithMany()
.HasForeignKey(d => d.DependsOnTaskId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(d => d.DependsOnTaskId).HasDatabaseName("idx_task_dependencies_depends_on");
}
}
@@ -75,7 +75,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired() builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired()
.HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None); .HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None);
builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id"); builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id");
builder.Property(t => t.DependsOnTaskId).HasColumnName("depends_on_task_id");
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for"); builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
builder.Property(t => t.Result).HasColumnName("result"); builder.Property(t => t.Result).HasColumnName("result");
builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback"); builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");
@@ -97,6 +96,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false); builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false);
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false); builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
builder.Property(t => t.IsManual).HasColumnName("is_manual").HasDefaultValue(false); builder.Property(t => t.IsManual).HasColumnName("is_manual").HasDefaultValue(false);
builder.Property(t => t.ExpectsNoDiff).HasColumnName("expects_no_diff").HasDefaultValue(false);
builder.Property(t => t.Notes).HasColumnName("notes"); builder.Property(t => t.Notes).HasColumnName("notes");
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(t => t.SessionSkills).HasColumnName("session_skills"); builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
@@ -123,12 +123,8 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
.HasForeignKey(t => t.BlockedByTaskId) .HasForeignKey(t => t.BlockedByTaskId)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
// DependsOn: user/MCP-declared predecessor. SetNull on delete so the dependent becomes // DependsOn edges live in task_dependencies (a task can declare n predecessors) —
// pickable rather than blocked forever on a task that no longer exists. // configured in TaskDependencyEntityConfiguration, which also owns the Dependencies nav.
builder.HasOne<TaskEntity>()
.WithMany()
.HasForeignKey(t => t.DependsOnTaskId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(t => t.List) builder.HasOne(t => t.List)
.WithMany(l => l.Tasks) .WithMany(l => l.Tasks)
@@ -145,6 +141,5 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort"); builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id"); builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by"); builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");
builder.HasIndex(t => t.DependsOnTaskId).HasDatabaseName("idx_tasks_depends_on");
} }
} }
+151 -1
View File
@@ -25,7 +25,16 @@ public sealed class GitService
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(dir, ["rev-parse", "HEAD"], ct); var (exitCode, stdout, stderr) = await RunGitAsync(dir, ["rev-parse", "HEAD"], ct);
if (exitCode != 0) if (exitCode != 0)
{
// Unborn HEAD (fresh `git init`, no commits) — the raw git stderr ("ambiguous
// argument 'HEAD'") is useless to a user; call sites should have run
// EnsureHeadCommitAsync, this message is the safety net for any that didn't.
if (stderr.Contains("ambiguous argument 'HEAD'", StringComparison.Ordinal) ||
stderr.Contains("unknown revision", StringComparison.Ordinal))
throw new InvalidOperationException(
$"The repository at {dir} has no commits yet (unborn or broken HEAD) — ClaudeDo needs at least one commit to work in it.");
throw new InvalidOperationException($"git rev-parse HEAD failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git rev-parse HEAD failed (exit {exitCode}): {stderr}");
}
return stdout.Trim(); return stdout.Trim();
} }
@@ -55,6 +64,67 @@ public sealed class GitService
return exitCode == 0 ? stdout.Trim() : null; return exitCode == 0 ? stdout.Trim() : null;
} }
/// <summary>HEAD resolves to a commit (false for a fresh `git init` repo or a broken HEAD).</summary>
public async Task<bool> HasHeadCommitAsync(string dir, CancellationToken ct = default)
{
// ^{commit} forces the object to actually exist and be a commit — a bare
// `--verify HEAD` happily accepts a detached HEAD pointing at a missing object.
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], ct);
return exitCode == 0;
}
private const string FallbackUserName = "ClaudeDo";
private const string FallbackUserEmail = "claudedo@local";
// Two parallel task starts on the same empty repo must create exactly one initial commit.
private static readonly SemaphoreSlim BootstrapGate = new(1, 1);
/// <summary>
/// Guarantees HEAD resolves to a commit so worktrees can be created. A repo with an
/// unborn HEAD (fresh `git init`) gets an empty initial commit built from plumbing only
/// (hash-object/commit-tree/update-ref) — the user's index and working tree are never
/// touched, so staged and untracked files stay exactly as they were. Identity is passed
/// inline, so this also works on machines with no git identity configured. A repo whose
/// HEAD is broken (not a symbolic ref and not resolvable) throws instead of guessing.
/// </summary>
/// <returns>true if a bootstrap commit was created; false if HEAD already resolved.</returns>
public async Task<bool> EnsureHeadCommitAsync(string dir, CancellationToken ct = default)
{
if (await HasHeadCommitAsync(dir, ct)) return false;
var (symExit, symRef, _) = await RunGitAsync(dir, ["symbolic-ref", "--quiet", "HEAD"], ct);
if (symExit != 0)
throw new InvalidOperationException(
$"The repository at {dir} has a broken HEAD (detached onto a missing commit, or a corrupt .git/HEAD). Repair the repository manually.");
await BootstrapGate.WaitAsync(ct);
try
{
if (await HasHeadCommitAsync(dir, ct)) return false;
var (treeExit, treeSha, treeErr) = await RunGitAsync(dir, ["mktree"], ct, stdinData: "");
if (treeExit != 0)
throw new InvalidOperationException($"git mktree failed (exit {treeExit}): {treeErr}");
var (commitExit, commitSha, commitErr) = await RunGitAsync(dir,
["-c", $"user.name={FallbackUserName}", "-c", $"user.email={FallbackUserEmail}",
"commit-tree", treeSha.Trim(), "-m", "chore: initialize repository (ClaudeDo)"], ct);
if (commitExit != 0)
throw new InvalidOperationException($"git commit-tree failed (exit {commitExit}): {commitErr}");
var (refExit, _, refErr) = await RunGitAsync(dir,
["update-ref", symRef.Trim(), commitSha.Trim()], ct);
if (refExit != 0)
throw new InvalidOperationException($"git update-ref failed (exit {refExit}): {refErr}");
return true;
}
finally
{
BootstrapGate.Release();
}
}
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default) public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
{ {
await WorktreeAddGate.WaitAsync(ct); await WorktreeAddGate.WaitAsync(ct);
@@ -135,10 +205,24 @@ public sealed class GitService
{ {
// Use -F - (read message from stdin) to handle multi-line messages safely. // Use -F - (read message from stdin) to handle multi-line messages safely.
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["commit", "-F", "-"], ct, stdinData: message); var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["commit", "-F", "-"], ct, stdinData: message);
if (exitCode != 0 && IsMissingIdentityError(stderr))
{
// A machine without git identity configured must not fail every auto-commit —
// retry once as ClaudeDo. The happy path pays no preflight cost.
(exitCode, _, stderr) = await RunGitAsync(worktreePath,
["-c", $"user.name={FallbackUserName}", "-c", $"user.email={FallbackUserEmail}",
"commit", "-F", "-"], ct, stdinData: message);
}
if (exitCode != 0) if (exitCode != 0)
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
} }
private static bool IsMissingIdentityError(string stderr) =>
stderr.Contains("tell me who you are", StringComparison.OrdinalIgnoreCase) ||
stderr.Contains("empty ident", StringComparison.OrdinalIgnoreCase) ||
stderr.Contains("no email was given", StringComparison.OrdinalIgnoreCase) ||
stderr.Contains("unable to auto-detect email address", StringComparison.OrdinalIgnoreCase);
public async Task<string> GetDiffAsync( public async Task<string> GetDiffAsync(
string worktreePath, IReadOnlyList<string>? paths = null, CancellationToken ct = default) string worktreePath, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
{ {
@@ -254,6 +338,38 @@ public sealed class GitService
return paths; return paths;
} }
/// <summary>All git-registered worktrees for <paramref name="repoDir"/>, with their branch (short name, null if detached).</summary>
public async Task<List<(string Path, string? Branch)>> ListWorktreesAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["worktree", "list", "--porcelain"], ct);
if (exitCode != 0) return new();
var result = new List<(string Path, string? Branch)>();
string? currentPath = null;
string? currentBranch = null;
foreach (var raw in stdout.Split('\n'))
{
var line = raw.TrimEnd('\r');
if (line.StartsWith("worktree ", StringComparison.Ordinal))
{
currentPath = line["worktree ".Length..].Trim();
}
else if (line.StartsWith("branch ", StringComparison.Ordinal))
{
var b = line["branch ".Length..].Trim();
currentBranch = b.StartsWith("refs/heads/", StringComparison.Ordinal) ? b["refs/heads/".Length..] : b;
}
else if (string.IsNullOrWhiteSpace(line))
{
if (currentPath is not null) result.Add((currentPath, currentBranch));
currentPath = null;
currentBranch = null;
}
}
if (currentPath is not null) result.Add((currentPath, currentBranch));
return result;
}
public async Task WorktreePruneAsync(string repoDir, CancellationToken ct = default) public async Task WorktreePruneAsync(string repoDir, CancellationToken ct = default)
{ {
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "prune"], ct); var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["worktree", "prune"], ct);
@@ -356,6 +472,38 @@ public sealed class GitService
throw new InvalidOperationException($"git revert --abort failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git revert --abort failed (exit {exitCode}): {stderr}");
} }
/// <summary>
/// Re-applies a merge commit's own diff (against its first parent, `-m 1`) as a new commit on
/// top of HEAD. Needed to redo a merge that was previously reverted: once a branch tip is an
/// ancestor of HEAD, `git merge` on that branch again is a silent "Already up to date." no-op
/// (ancestry, not content, is what merge checks) -- cherry-picking the original merge commit's
/// diff is what actually restores the content.
/// </summary>
public async Task<(int ExitCode, string Stderr)> CherryPickMergeCommitAsync(
string repoDir, string mergeCommitSha, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["-c", "merge.conflictStyle=diff3", "cherry-pick", "-m", "1", "--no-edit", mergeCommitSha], ct);
return (exitCode, stderr);
}
public async Task<bool> IsMidCherryPickAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
if (exitCode != 0) return false;
var gitDir = stdout.Trim();
if (!Path.IsPathRooted(gitDir))
gitDir = Path.Combine(repoDir, gitDir);
return File.Exists(Path.Combine(gitDir, "CHERRY_PICK_HEAD"));
}
public async Task CherryPickAbortAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["cherry-pick", "--abort"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git cherry-pick --abort failed (exit {exitCode}): {stderr}");
}
public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default) public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
@@ -552,7 +700,9 @@ public sealed class GitService
CreateNoWindow = true, CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8, StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8,
StandardInputEncoding = stdinData is not null ? Encoding.UTF8 : null, // BOM-less: Encoding.UTF8 writes a BOM preamble, which git rejects as input
// (mktree "input format error") and would prepend to stdin commit messages.
StandardInputEncoding = stdinData is not null ? new UTF8Encoding(false) : null,
}; };
psi.ArgumentList.Add("-C"); psi.ArgumentList.Add("-C");
psi.ArgumentList.Add(workDir); psi.ArgumentList.Add(workDir);
@@ -0,0 +1,978 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260828084750_AddListIcon")]
partial class AddListIcon
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<bool>("AutoContinueOnUsageLimit")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("auto_continue_on_usage_limit");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<int>("NextTaskNumber")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("next_task_number");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
AutoContinueOnUsageLimit = false,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
NextTaskNumber = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<int?>("TicketProjectId")
.HasColumnType("INTEGER")
.HasColumnName("ticket_project_id");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<string>("Icon")
.HasColumnType("TEXT")
.HasColumnName("icon");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<string>("Kind")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("ping")
.HasColumnName("action_kind");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<int?>("FailureMaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("failure_max_turns");
b.Property<string>("FailureReason")
.HasColumnType("TEXT")
.HasColumnName("failure_reason");
b.Property<int?>("FailureTurnsUsed")
.HasColumnType("INTEGER")
.HasColumnName("failure_turns_used");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<int>("Number")
.HasColumnType("INTEGER")
.HasColumnName("number");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("TicketRef")
.HasColumnType("TEXT")
.HasColumnName("ticket_ref");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.Property<DateTime?>("UsageLimitAutoContinuedAt")
.HasColumnType("TEXT")
.HasColumnName("usage_limit_auto_continued_at");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("DependsOnTaskId")
.HasDatabaseName("idx_tasks_depends_on");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Number")
.IsUnique()
.HasDatabaseName("idx_tasks_number");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<string>("Errors")
.HasColumnType("TEXT")
.HasColumnName("errors");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<string>("ResultSubtype")
.HasColumnType("TEXT")
.HasColumnName("result_subtype");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("TerminalReason")
.HasColumnType("TEXT")
.HasColumnName("terminal_reason");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddListIcon : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "icon",
table: "lists",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "icon",
table: "lists");
}
}
}
@@ -0,0 +1,982 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260828185839_AddDailyNoteFromAgent")]
partial class AddDailyNoteFromAgent
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<bool>("AutoContinueOnUsageLimit")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("auto_continue_on_usage_limit");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<int>("NextTaskNumber")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("next_task_number");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
AutoContinueOnUsageLimit = false,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
NextTaskNumber = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<bool>("FromAgent")
.HasColumnType("INTEGER")
.HasColumnName("from_agent");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<int?>("TicketProjectId")
.HasColumnType("INTEGER")
.HasColumnName("ticket_project_id");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<string>("Icon")
.HasColumnType("TEXT")
.HasColumnName("icon");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<string>("Kind")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("ping")
.HasColumnName("action_kind");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<int?>("FailureMaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("failure_max_turns");
b.Property<string>("FailureReason")
.HasColumnType("TEXT")
.HasColumnName("failure_reason");
b.Property<int?>("FailureTurnsUsed")
.HasColumnType("INTEGER")
.HasColumnName("failure_turns_used");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<int>("Number")
.HasColumnType("INTEGER")
.HasColumnName("number");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("TicketRef")
.HasColumnType("TEXT")
.HasColumnName("ticket_ref");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.Property<DateTime?>("UsageLimitAutoContinuedAt")
.HasColumnType("TEXT")
.HasColumnName("usage_limit_auto_continued_at");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("DependsOnTaskId")
.HasDatabaseName("idx_tasks_depends_on");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Number")
.IsUnique()
.HasDatabaseName("idx_tasks_number");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<string>("Errors")
.HasColumnType("TEXT")
.HasColumnName("errors");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<string>("ResultSubtype")
.HasColumnType("TEXT")
.HasColumnName("result_subtype");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("TerminalReason")
.HasColumnType("TEXT")
.HasColumnName("terminal_reason");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddDailyNoteFromAgent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "from_agent",
table: "daily_notes",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "from_agent",
table: "daily_notes");
}
}
}
@@ -0,0 +1,988 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260902102003_AddExpectsNoDiff")]
partial class AddExpectsNoDiff
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<bool>("AutoContinueOnUsageLimit")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("auto_continue_on_usage_limit");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<int>("NextTaskNumber")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("next_task_number");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
AutoContinueOnUsageLimit = false,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
NextTaskNumber = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<bool>("FromAgent")
.HasColumnType("INTEGER")
.HasColumnName("from_agent");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<int?>("TicketProjectId")
.HasColumnType("INTEGER")
.HasColumnName("ticket_project_id");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<string>("Icon")
.HasColumnType("TEXT")
.HasColumnName("icon");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<string>("Kind")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("ping")
.HasColumnName("action_kind");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<bool>("ExpectsNoDiff")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("expects_no_diff");
b.Property<int?>("FailureMaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("failure_max_turns");
b.Property<string>("FailureReason")
.HasColumnType("TEXT")
.HasColumnName("failure_reason");
b.Property<int?>("FailureTurnsUsed")
.HasColumnType("INTEGER")
.HasColumnName("failure_turns_used");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<int>("Number")
.HasColumnType("INTEGER")
.HasColumnName("number");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("TicketRef")
.HasColumnType("TEXT")
.HasColumnName("ticket_ref");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.Property<DateTime?>("UsageLimitAutoContinuedAt")
.HasColumnType("TEXT")
.HasColumnName("usage_limit_auto_continued_at");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("DependsOnTaskId")
.HasDatabaseName("idx_tasks_depends_on");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Number")
.IsUnique()
.HasDatabaseName("idx_tasks_number");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<string>("Errors")
.HasColumnType("TEXT")
.HasColumnName("errors");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<string>("ResultSubtype")
.HasColumnType("TEXT")
.HasColumnName("result_subtype");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("TerminalReason")
.HasColumnType("TEXT")
.HasColumnName("terminal_reason");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddExpectsNoDiff : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "expects_no_diff",
table: "tasks",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "expects_no_diff",
table: "tasks");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <summary>
/// Turns the single <c>tasks.depends_on_task_id</c> column into a set: one row per declared
/// "run after" edge in <c>task_dependencies</c>. Hand-edited after scaffolding so the data copy
/// sits between CreateTable and DropColumn — the scaffolded order dropped the column first and
/// would have discarded every existing link.
/// </summary>
public partial class AddTaskDependencies : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "task_dependencies",
columns: table => new
{
task_id = table.Column<string>(type: "TEXT", nullable: false),
depends_on_task_id = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_task_dependencies", x => new { x.task_id, x.depends_on_task_id });
table.ForeignKey(
name: "FK_task_dependencies_tasks_depends_on_task_id",
column: x => x.depends_on_task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_task_dependencies_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "idx_task_dependencies_depends_on",
table: "task_dependencies",
column: "depends_on_task_id");
// Every existing link becomes a one-element set. The old FK was ON DELETE SET NULL, so
// a surviving value always points at an existing task and can't violate the new FK.
migrationBuilder.Sql("""
INSERT INTO task_dependencies (task_id, depends_on_task_id)
SELECT id, depends_on_task_id FROM tasks WHERE depends_on_task_id IS NOT NULL
""");
migrationBuilder.DropForeignKey(
name: "FK_tasks_tasks_depends_on_task_id",
table: "tasks");
migrationBuilder.DropIndex(
name: "idx_tasks_depends_on",
table: "tasks");
migrationBuilder.DropColumn(
name: "depends_on_task_id",
table: "tasks");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "depends_on_task_id",
table: "tasks",
type: "TEXT",
nullable: true);
// Inherently lossy: a task with several predecessors keeps only one. There is no
// single-column shape that could hold the rest.
migrationBuilder.Sql("""
UPDATE tasks SET depends_on_task_id = (
SELECT td.depends_on_task_id FROM task_dependencies td
WHERE td.task_id = tasks.id
ORDER BY td.depends_on_task_id
LIMIT 1
)
""");
migrationBuilder.DropTable(
name: "task_dependencies");
migrationBuilder.CreateIndex(
name: "idx_tasks_depends_on",
table: "tasks",
column: "depends_on_task_id");
migrationBuilder.AddForeignKey(
name: "FK_tasks_tasks_depends_on_task_id",
table: "tasks",
column: "depends_on_task_id",
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddPowerUse : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "power_use_allowed",
table: "list_config",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "list_handler_max_handoffs",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 4);
migrationBuilder.AddColumn<bool>(
name: "power_use_enabled",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "list_handler_max_handoffs",
value: 4);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "power_use_allowed",
table: "list_config");
migrationBuilder.DropColumn(
name: "list_handler_max_handoffs",
table: "app_settings");
migrationBuilder.DropColumn(
name: "power_use_enabled",
table: "app_settings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddPowerUseWindow : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "power_use_handoff_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 95);
migrationBuilder.AddColumn<string>(
name: "power_use_window_end",
table: "app_settings",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "power_use_window_start",
table: "app_settings",
type: "TEXT",
nullable: true);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
columns: new[] { "power_use_handoff_pct", "power_use_window_end", "power_use_window_start" },
values: new object[] { 95, null, null });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "power_use_handoff_pct",
table: "app_settings");
migrationBuilder.DropColumn(
name: "power_use_window_end",
table: "app_settings");
migrationBuilder.DropColumn(
name: "power_use_window_start",
table: "app_settings");
}
}
}
@@ -66,6 +66,12 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue("bypassPermissions") .HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode"); .HasColumnName("default_permission_mode");
b.Property<int>("ListHandlerMaxHandoffs")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(4)
.HasColumnName("list_handler_max_handoffs");
b.Property<int>("MaxParallelExecutions") b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -88,6 +94,26 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(1) .HasDefaultValue(1)
.HasColumnName("next_task_number"); .HasColumnName("next_task_number");
b.Property<bool>("PowerUseEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("power_use_enabled");
b.Property<int>("PowerUseHandoffPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(95)
.HasColumnName("power_use_handoff_pct");
b.Property<string>("PowerUseWindowEnd")
.HasColumnType("TEXT")
.HasColumnName("power_use_window_end");
b.Property<string>("PowerUseWindowStart")
.HasColumnType("TEXT")
.HasColumnName("power_use_window_start");
b.Property<string>("RepoImportFolders") b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("repo_import_folders"); .HasColumnName("repo_import_folders");
@@ -175,9 +201,12 @@ namespace ClaudeDo.Data.Migrations
DefaultMaxTurns = 40, DefaultMaxTurns = 40,
DefaultModel = "sonnet", DefaultModel = "sonnet",
DefaultPermissionMode = "auto", DefaultPermissionMode = "auto",
ListHandlerMaxHandoffs = 4,
MaxParallelExecutions = 1, MaxParallelExecutions = 1,
MaxTurnsCeiling = 80, MaxTurnsCeiling = 80,
NextTaskNumber = 1, NextTaskNumber = 1,
PowerUseEnabled = false,
PowerUseHandoffPct = 95,
StandupWeekday = 3, StandupWeekday = 3,
UsageGateFiveHourPct = 80, UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90, UsageGateSevenDayPct = 90,
@@ -205,6 +234,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("note_date"); .HasColumnName("note_date");
b.Property<bool>("FromAgent")
.HasColumnType("INTEGER")
.HasColumnName("from_agent");
b.Property<int>("SortOrder") b.Property<int>("SortOrder")
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasColumnName("sort_order"); .HasColumnName("sort_order");
@@ -243,6 +276,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("permission_mode"); .HasColumnName("permission_mode");
b.Property<bool>("PowerUseAllowed")
.HasColumnType("INTEGER")
.HasColumnName("power_use_allowed");
b.Property<string>("SessionSkills") b.Property<string>("SessionSkills")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("session_skills"); .HasColumnName("session_skills");
@@ -287,6 +324,10 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(false) .HasDefaultValue(false)
.HasColumnName("findings_tracked"); .HasColumnName("findings_tracked");
b.Property<string>("Icon")
.HasColumnType("TEXT")
.HasColumnName("icon");
b.Property<bool>("IsManual") b.Property<bool>("IsManual")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -467,6 +508,24 @@ namespace ClaudeDo.Data.Migrations
b.ToTable("task_attachments", (string)null); b.ToTable("task_attachments", (string)null);
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.HasKey("TaskId", "DependsOnTaskId");
b.HasIndex("DependsOnTaskId")
.HasDatabaseName("idx_task_dependencies_depends_on");
b.ToTable("task_dependencies", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -496,14 +555,16 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("created_by"); .HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description") b.Property<string>("Description")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("description"); .HasColumnName("description");
b.Property<bool>("ExpectsNoDiff")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("expects_no_diff");
b.Property<int?>("FailureMaxTurns") b.Property<int?>("FailureMaxTurns")
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasColumnName("failure_max_turns"); .HasColumnName("failure_max_turns");
@@ -661,9 +722,6 @@ namespace ClaudeDo.Data.Migrations
b.HasIndex("BlockedByTaskId") b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by"); .HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("DependsOnTaskId")
.HasDatabaseName("idx_tasks_depends_on");
b.HasIndex("ListId") b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id"); .HasDatabaseName("idx_tasks_list_id");
@@ -898,6 +956,21 @@ namespace ClaudeDo.Data.Migrations
b.Navigation("Task"); b.Navigation("Task");
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany("Dependencies")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
@@ -905,11 +978,6 @@ namespace ClaudeDo.Data.Migrations
.HasForeignKey("BlockedByTaskId") .HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks") .WithMany("Tasks")
.HasForeignKey("ListId") .HasForeignKey("ListId")
@@ -959,6 +1027,8 @@ namespace ClaudeDo.Data.Migrations
{ {
b.Navigation("Children"); b.Navigation("Children");
b.Navigation("Dependencies");
b.Navigation("Runs"); b.Navigation("Runs");
b.Navigation("Subtasks"); b.Navigation("Subtasks");
@@ -60,4 +60,28 @@ public sealed class AppSettingsEntity
// automatic ContinueTask once the 5h window resets, and the gate-blocked queue wakes exactly // automatic ContinueTask once the 5h window resets, and the gate-blocked queue wakes exactly
// at that reset time instead of only on the 30s backstop. // at that reset time instead of only on the 30s backstop.
public bool AutoContinueOnUsageLimit { get; set; } public bool AutoContinueOnUsageLimit { get; set; }
// Master switch for PowerUse: list-handler runs go unattended (never ask the user) and harvest
// new work when a round would otherwise starve. Toggled from the usage monitor, because it is a
// deliberate "spend the remaining window" decision rather than a normal default. Off by default,
// and on its own it changes nothing — a run is only in PowerUse when the LIST also opted in via
// ListConfigEntity.PowerUseAllowed, so leaving this on cannot make an unrelated list's handler
// run unattended. Composition lives in InteractiveLaunchSpecService.
public bool PowerUseEnabled { get; set; }
// Optional local-time window (both `HH:mm`) narrowing when PowerUse applies, checked per phase
// at prompt-build time. Both null (the default) = round the clock, byte-identical to the
// pre-window behaviour; only one set counts as unset too. End before start wraps over midnight.
// Semantics live in PowerUseWindow.
public string? PowerUseWindowStart { get; set; }
public string? PowerUseWindowEnd { get; set; }
// Percentage of the 5h usage window at which a PowerUse handler session is told to hand off
// rather than start more work. Stored and displayed here; the consumer is the handoff prompt.
public int PowerUseHandoffPct { get; set; } = 95;
// Server-side cap on a list-handler run's wait<->merge handoff chain (HandoffRoundTracker).
// 4 = two full wait/merge cycles, the long-standing default. Raise it for a run meant to keep
// going for hours. It exists to bound runaway handoff loops, so it must stay >= 1.
public int ListHandlerMaxHandoffs { get; set; } = 4;
} }
@@ -6,5 +6,9 @@ public sealed class DailyNoteEntity
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public string Text { get; set; } = string.Empty; public string Text { get; set; } = string.Empty;
public int SortOrder { get; set; } public int SortOrder { get; set; }
/// <summary>True when an agent wrote this note through the MCP tool rather than the user.
/// Drives the UI marker and the per-day cap in <c>NotesMcpTools</c>.</summary>
public bool FromAgent { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }
@@ -17,6 +17,12 @@ public sealed class ListConfigEntity
// Feld mitführen, sonst setzt es sich still zurück. // Feld mitführen, sonst setzt es sich still zurück.
public int? TicketProjectId { get; set; } public int? TicketProjectId { get; set; }
// Per-Liste-Opt-in für PowerUse. Ein Handler-Run läuft nur unattended, wenn BEIDES gesetzt ist:
// dieses Flag und AppSettingsEntity.PowerUseEnabled. Der globale Toggle bleibt damit gefahrlos
// an — eine Liste, die hier nicht zugestimmt hat, behält das interaktive Verhalten. Default
// false, also opt-in. ⚠️ Gleiche Falle wie TicketProjectId: SetConfigAsync kopiert verbatim.
public bool PowerUseAllowed { get; set; }
// Navigation property // Navigation property
public ListEntity List { get; set; } = null!; public ListEntity List { get; set; } = null!;
} }
+2
View File
@@ -14,6 +14,8 @@ public sealed class ListEntity
// When true the project's .claudedo/ findings store is committed with the repo; when false it // When true the project's .claudedo/ findings store is committed with the repo; when false it
// is kept out of git via .git/info/exclude (see FindingsStore). // is kept out of git via .git/info/exclude (see FindingsStore).
public bool FindingsTracked { get; set; } public bool FindingsTracked { get; set; }
// Icon key into the UI's icon library (e.g. "Star"); null = the default folder icon.
public string? Icon { get; set; }
// Navigation properties // Navigation properties
public ListConfigEntity? Config { get; set; } public ListConfigEntity? Config { get; set; }
@@ -0,0 +1,32 @@
namespace ClaudeDo.Data.Models;
/// <summary>
/// The optional local-time window restricting when PowerUse applies
/// (<see cref="AppSettingsEntity.PowerUseWindowStart"/> / <c>End</c>, both <c>HH:mm</c>).
/// Pure on purpose — the caller supplies "now", so the window logic is testable without a clock.
/// </summary>
public static class PowerUseWindow
{
/// <summary>Parses an <c>HH:mm</c> string; anything else (including null/blank) is null.</summary>
public static TimeOnly? Parse(string? value)
=> TimeOnly.TryParseExact(value?.Trim(), "HH:mm", out var t) ? t : null;
/// <summary>Round-trips a stored/typed value through <see cref="Parse"/>, so an unparseable
/// string is stored as null instead of sitting in the column pretending to be a window.</summary>
public static string? Normalize(string? value) => Parse(value)?.ToString("HH:mm");
/// <summary>
/// True when <paramref name="now"/> falls inside the window. Half-open: start is included,
/// end is not. Either bound missing = no window at all = always true (the pre-window
/// behaviour). A window whose end is before its start wraps over midnight.
/// </summary>
public static bool Contains(TimeOnly now, TimeOnly? start, TimeOnly? end)
{
if (start is not { } from || end is not { } to) return true;
return from < to ? now >= from && now < to : now >= from || now < to;
}
/// <summary>Convenience overload for the two stored strings and a wall clock.</summary>
public static bool IsOpen(DateTime now, string? start, string? end)
=> Contains(TimeOnly.FromDateTime(now), Parse(start), Parse(end));
}
@@ -0,0 +1,13 @@
namespace ClaudeDo.Data.Models;
/// <summary>
/// One declared "task must run after task" edge. A task can carry n of them; the queue picker
/// claims it only once every predecessor reached <see cref="TaskStatus.Done"/>.
/// Deliberately separate from <see cref="TaskEntity.BlockedByTaskId"/>, the planning chain's own
/// internal single link — see ClaudeDo.Worker/CLAUDE.md → Status Model.
/// </summary>
public sealed class TaskDependencyEntity
{
public required string TaskId { get; init; }
public required string DependsOnTaskId { get; init; }
}
+17 -6
View File
@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace ClaudeDo.Data.Models; namespace ClaudeDo.Data.Models;
public enum TaskStatus public enum TaskStatus
@@ -33,12 +35,17 @@ public sealed class TaskEntity
public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None; public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None;
public string? BlockedByTaskId { get; set; } public string? BlockedByTaskId { get; set; }
// A user/MCP-declared predecessor, distinct from BlockedByTaskId (the planning chain's own // User/MCP-declared predecessors, distinct from BlockedByTaskId (the planning chain's own
// internal link): the picker also skips a Queued task while this is set and the referenced // internal link): the picker skips a Queued task until EVERY predecessor reached Done.
// task's Status isn't Done. Unlike the chain, a Failed/Cancelled dependency does NOT cascade // Unlike the chain, a Failed/Cancelled dependency does NOT cascade or auto-resolve -- the
// or auto-resolve -- the dependent just stays blocked and reports why (see TaskStateService. // dependent just stays blocked and reports why (see TaskStateService.SetDependsOnAsync and
// SetDependsOnAsync and QueuePicker). // QueuePicker). Only loaded where a read path Includes it; the picker gates in raw SQL and
public string? DependsOnTaskId { get; set; } // never relies on this collection.
public ICollection<TaskDependencyEntity> Dependencies { get; set; } = new List<TaskDependencyEntity>();
[NotMapped]
public IReadOnlyList<string> DependsOnTaskIds =>
Dependencies.Select(d => d.DependsOnTaskId).ToList();
public DateTime? ScheduledFor { get; set; } public DateTime? ScheduledFor { get; set; }
public string? Result { get; set; } public string? Result { get; set; }
public string? ReviewFeedback { get; set; } public string? ReviewFeedback { get; set; }
@@ -71,6 +78,10 @@ public sealed class TaskEntity
// list handler) and the Claude affordances are hidden; a hand-driven ConPTY session is still // list handler) and the Claude affordances are hidden; a hand-driven ConPTY session is still
// allowed. New tasks in a manual list default to true. // allowed. New tasks in a manual list default to true.
public bool IsManual { get; set; } public bool IsManual { get; set; }
// Set on verification/audit/research tasks whose correct outcome is an empty diff (e.g. "build
// and test, don't change code"). Lets the merge-helper session tell that apart from a task that
// silently failed to commit -- both look identical to preview_merge_set otherwise.
public bool ExpectsNoDiff { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public string? SessionSkills { get; set; } public string? SessionSkills { get; set; }
+114 -5
View File
@@ -5,7 +5,7 @@ using System.Text.RegularExpressions;
namespace ClaudeDo.Data; namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperWait, MergeHelperMerge, MergeHelperInitial, MergeHelperHandoff } public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperWait, MergeHelperMerge, MergeHelperInitial, MergeHelperHandoff, PowerUseAddendum }
/// <summary> /// <summary>
/// How a prompt kind's on-disk override (if any) relates to the bundled default. /// How a prompt kind's on-disk override (if any) relates to the bundled default.
@@ -44,6 +44,7 @@ public static class PromptFiles
PromptKind.MergeHelperMerge => "merge-helper-merge.md", PromptKind.MergeHelperMerge => "merge-helper-merge.md",
PromptKind.MergeHelperInitial => "merge-helper-initial.md", PromptKind.MergeHelperInitial => "merge-helper-initial.md",
PromptKind.MergeHelperHandoff => "merge-helper-handoff.md", PromptKind.MergeHelperHandoff => "merge-helper-handoff.md",
PromptKind.PowerUseAddendum => "power-use.md",
_ => throw new ArgumentOutOfRangeException(nameof(kind)) _ => throw new ArgumentOutOfRangeException(nameof(kind))
}; };
@@ -221,6 +222,7 @@ public static class PromptFiles
PromptKind.MergeHelperMerge => MergeHelperMergeDefault, PromptKind.MergeHelperMerge => MergeHelperMergeDefault,
PromptKind.MergeHelperInitial => MergeHelperInitialDefault, PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault, PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault,
PromptKind.PowerUseAddendum => PowerUseAddendumDefault,
_ => "" _ => ""
}; };
@@ -448,6 +450,15 @@ public static class PromptFiles
Cancel nothing without an explicit answer. Cancel nothing without an explicit answer.
## Phase 1.5 — Audit dependencies
For every surviving task that carries a dependsOnTaskId, decide whether the link is real or artificial before you hand off. Real: task B genuinely needs something task A produces — a symbol/type B's code references, a changed signature, a migration that must land before repository code assumes it. Artificial: the tasks just got numbered in sequence by the plan but touch disjoint files for unrelated reasons. Task descriptions usually name their target files explicitly — use that to check for real file/symbol overlap instead of trusting the order.
Resolve every artificial link with update_task(taskId, dependsOnTaskId="") — a chain numbered 1→2→3→... is the default shape to suspect, not the default shape to trust.
Group what's left into waves: batches that can run in parallel, where a later wave depends only on tasks in an earlier one. Report this as a wave plan (e.g. "[3] → [4,5,6] → [7]"), not a flat list — the Wait/Merge sessions after you need the batching, not just which ids survived.
Then check the wave count against the handoff cap. This list-handler chain allows a limited number of handoffs (wait, merge, wait_final, merge_final) — by default 4, i.e. exactly two merge rounds, but it is configurable and may be higher for this run. Assume two rounds unless the kickoff tells you otherwise. If your wave plan needs more rounds than that to clear, say so explicitly when you hand off — name which waves won't fit — so the Merge session ends by telling the user "the rest needs manual approval in the UI" instead of silently stranding mid-chain.
## Phase 2 — Enhance for execution ## Phase 2 — Enhance for execution
Before touching any task, check the list's verify command ONCE for this whole run (not per task): call get_list_config. If verifyCommand is already set, leave it alone — never overwrite an existing one — and move straight to sharpening tasks below. Before touching any task, check the list's verify command ONCE for this whole run (not per task): call get_list_config. If verifyCommand is already set, leave it alone — never overwrite an existing one — and move straight to sharpening tasks below.
@@ -471,7 +482,7 @@ public static class PromptFiles
Once every surviving task is enhanced, print your triage summary — one line per task from the brief: Once every surviving task is enhanced, print your triage summary — one line per task from the brief:
title — dedupe action (kept / merged into X / cancelled as duplicate of X) — enhanced (yes/no). title — dedupe action (kept / merged into X / cancelled as duplicate of X) — enhanced (yes/no).
Then call handoff_list_handler(taskId, survivingTaskIds, nextPhase: "wait"). That opens a fresh session to carry out the wait/merge phases with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop — do not continue into phase 3 yourself. Then print the wave plan from Phase 1.5, including any "won't fit in two rounds" note, and call handoff_list_handler(taskId, survivingTaskIds, nextPhase: "wait"). That opens a fresh session to carry out the wait/merge phases with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop — do not continue into phase 3 yourself.
"""; """;
private const string MergeHelperWaitDefault = """ private const string MergeHelperWaitDefault = """
@@ -505,8 +516,8 @@ public static class PromptFiles
One task at a time. Default to the order the brief lists them. If the overlap check shows two or more tasks touching the same file, tell the user which tasks collide and merge those in an order you can justify (e.g. the one making the smaller change first) — deviate from brief order only with that stated reason. One task at a time. Default to the order the brief lists them. If the overlap check shows two or more tasks touching the same file, tell the user which tasks collide and merge those in an order you can justify (e.g. the one making the smaller change first) — deviate from brief order only with that stated reason.
1. Never read a task's diff yourself. Instead start a sonnet subagent (Task tool) per task to inspect get_task_diff and sanity-check it against the task's title and description; only its short verdict (clean / risky + reason) comes back into your own context. If preview_merge_set flagged this task in an overlap, tell the subagent to also skim the diff of the other task(s) sharing that file. 1. Check this task's changedFileCount from preview_merge_set first. Below 5 changed files, read get_task_diff yourself and sanity-check it against the task's title and description — a subagent hop is wasted context for a diff that small. At 5 or more, delegate instead: start a sonnet subagent (Task tool) to inspect get_task_diff and sanity-check it, so only its short verdict (clean / risky + reason) comes back into your own context — the whole point of never reading a large diff yourself. If preview_merge_set flagged this task in an overlap, also skim (or have the subagent skim) the diff of the other task(s) sharing that file.
2. Before approving, cross-check changedFileCount from preview_merge_set (or the length of get_task_diff's files list — the diff tool has no changedFileCount field). 0 changed files means an empty branch, not a clean success — do not approve it; treat it like a risky diff instead. 2. Before approving, cross-check changedFileCount from preview_merge_set (or the length of get_task_diff's files list — the diff tool has no changedFileCount field). 0 changed files means an empty branch, not a clean success — do not approve it; treat it like a risky diff instead. Exception: if preview_merge_set's expectsNoDiff is true for this task (a verification/audit/research task deliberately expected to change nothing), 0 changed files is the correct outcome — read the task's Result to confirm it reports what was checked and that it passed, then approve normally.
3. If the subagent's verdict is risky, or the change looks wrong or incomplete, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip. 3. If the subagent's verdict is risky, or the change looks wrong or incomplete, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip.
4. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true, skipVerify=true). skipVerify defers the list's verify command to ONE verify_merges call after the last merge (below) instead of paying it per merge. 4. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true, skipVerify=true). skipVerify defers the list's verify command to ONE verify_merges call after the last merge (below) instead of paying it per merge.
- Clean merge → mergeStatus "merged_verify_pending" (expected: the task deliberately stays WaitingForReview until verify_merges passes) or "merged" (list has no verify command; the task is Done). Either way, move on. - Clean merge → mergeStatus "merged_verify_pending" (expected: the task deliberately stays WaitingForReview until verify_merges passes) or "merged" (list has no verify command; the task is Done). Either way, move on.
@@ -539,6 +550,30 @@ public static class PromptFiles
Allow at most ONE rerun per task in this session. Allow at most ONE rerun per task in this session.
## Keep watching the queue
Do not assume the tasks you were handed are the only ones that matter. A prior Wait session
can already have finished before every one of its tasks actually reached a terminal status,
or new tasks can still be running when you start — merging only what happened to be ready the
moment you looked in and then stopping is how work silently falls through the cracks.
Before you go to Handoff / end, call get_queue_state and check whether any task id from your
brief still shows up in activeSlots or waitingTaskIds. If none do, your list is clear — go to
Handoff / end now.
If some do, call wait_for_task_change with exactly those still-busy task ids,
timeoutSeconds=900, treatWaitingForChildrenAsBusy=true. When it reports a change, go back to
Phase 4 and merge/review whatever it now says is WaitingForReview, then repeat this check.
This is NOT a `sleep`-and-poll loop — never write your own retry/sleep loop around get_task or
get_queue_state. wait_for_task_change already blocks server-side and reports progress so it
survives your own client's idle-silence abort; that is the only waiting mechanism allowed here.
Hard cap: stop after at most THREE consecutive wait_for_task_change calls made this way (three
900s timeouts ≈ 45 minutes total), even if tasks are still busy. When you stop — because the
queue cleared or because you hit the cap — report whatever is still Queued/Running in the
summary below and proceed to Handoff / end exactly as if this section didn't exist.
## Handoff / end ## Handoff / end
- If you started any reruns: call handoff_list_handler(taskId, <the rerun task ids>, nextPhase: "wait_final"), then stop your turn immediately — do not merge anything yet that you just restarted. Do NOT submit this run for review yet — the chain isn't finished. - If you started any reruns: call handoff_list_handler(taskId, <the rerun task ids>, nextPhase: "wait_final"), then stop your turn immediately — do not merge anything yet that you just restarted. Do NOT submit this run for review yet — the chain isn't finished.
@@ -584,7 +619,81 @@ public static class PromptFiles
Start with phase 3 (run), continuing through review/merge and the summary as your Start with phase 3 (run), continuing through review/merge and the summary as your
instructions describe. instructions describe.
{finalNote} {roundInfo}
""";
// Appended to the triage/wait/merge system prompt when a run is in PowerUse (global switch AND
// the list's own opt-in). Deliberately an addendum rather than a second set of prompts: the
// shared 95% stays in one place, and a normal run's prompt is byte-identical to what it was.
// It therefore has to read as an OVERRIDE of the rules above it, not as standalone instructions.
private const string PowerUseAddendumDefault = """
# PowerUse mode — these rules supersede the ones above
PowerUse is ON for this run. Everything above was written for a session with a user watching it. That assumption no longer holds, and where the two conflict, this section wins.
## No user exists
Nobody is watching and nobody can answer a question. Every instruction above that says to ask the user, confirm with the user, or stop and let the user decide is replaced by: decide it yourself, act, and record the decision in your summary. A question asked here is a hung session and a wasted run.
This does not lower the bar for what you may do — it removes escalation as an option, so the decisions you make must be ones you can defend in writing afterwards. Where a rule above offered "ask the user" as the safe path, the safe path is now the conservative action plus a written record, never the bold one.
Specifically:
- **Duplicate pairs (Triage Phase 1):** decide them. Clear duplicate — same goal, or one fully contained in the other — fold the loser's unique content into the survivor and cancel the loser. Genuinely different intent that merely touches the same area — keep both and chain them with dependsOnTaskId so they cannot run against the same files at once.
- **A task too vague to sharpen (Triage Phase 2):** cancel it with a recorded reason rather than guessing a specification for someone else's half-sentence. Guessing burns a whole slot on work nobody asked for.
- **Wave plan vs. handoff cap (Triage Phase 1.5):** do not assume two rounds, and never trim the wave plan to fit a cap or defer waves to "manual approval in the UI" — nobody will be there. Call get_handoff_status with your own handler task id to read this run's real cap, then plan the waves the work actually needs and hand off the first one.
- **A task that bundles several independent features (Triage Phase 2):** split it yourself — create the pieces, chain them with dependsOnTaskId where one builds on another, cancel or narrow the original, and record what you did.
- **Verify command not set (Triage Phase 2):** determine it, run it once via Bash to prove it works, then set it. Never write a command you have not actually run.
- **A risky or wrong-looking diff (Merge Phase 4):** if it contradicts the task's own acceptance criteria, call review_task(taskId, decision="reject_rerun", feedback=...) with concrete feedback naming what was missing. If it merely looks unusual but meets the criteria, merge it and say why. If it looks actively destructive — deletes code no task asked to delete, drops a migration, rewrites unrelated files — leave it unmerged and record it as HELD FOR REVIEW. Never merge something you would describe as destructive.
- **A conflict you cannot resolve with confidence (Merge Phase 4):** call abort_merge(taskId) and record it as HELD FOR REVIEW. Aborting is always correct over guessing: the task keeps its status, and a rerun against the new main usually removes the conflict entirely.
## Conflicts are the normal case
Never end your turn with a conflicted merge left in the working tree. A half-merged tree makes the repo unbuildable for every session and every task after you, and any verify that runs against it reports a false red.
Prefer get_merge_conflicts + resolve_conflict_hunk over reading marker text by hand — for a large conflict that is the only tractable route; a 200k-character marker dump is not something to read linearly.
The common collision shape in a parallel run is one task MOVING code out of a file while another task ADDS to it. The resolution is almost always to keep the move and re-apply the addition in the file the code moved to — not to pick a side. Picking "ours" silently deletes the other task's work.
When ordering merges that overlap on a file, merge the task that moves or deletes code BEFORE the task that only adds to it. A move landing second turns every addition into a conflict.
## Rerun budget
At most TWO reruns per task across the whole run, replacing the single-rerun limit above. A task that has already been rerun twice is recorded as HELD FOR REVIEW instead of getting a third attempt — three failures on the same task is a specification problem, not bad luck, and further attempts starve the rest of the run.
## Harvest — so the run does not starve
This applies to the Merge phase only, after its merges and reruns are done.
If you have fewer than five tasks to hand to the next round, harvest new work for this list's repo before handing off. Only for the list named in the brief — never another list or repo.
Sources, in this order; prefer one you have not exhausted yet:
1. **Verify/build output** — real compiler warnings. One task per warning group.
2. **`ponytail:` comments** — deliberate shortcuts with a named ceiling and upgrade path. Take the ones whose upgrade is small.
3. **TODO / HACK / FIXME** — only the concretely formulated ones.
4. **Unfinished plan slices** — `docs/superpowers/plans/*.md` whose last slices never landed. Check against `git log` before believing a slice is open. One task per slice, naming the plan path and slice number.
5. **Test gaps at bug hotspots** — `git log --grep="^fix"` over recent commits; files with repeated fixes and no test.
6. **Over-engineering** — the ponytail-audit skill against individual subfolders, never the whole repo at once. Deletion candidates.
7. **Feature work** — improvements that follow clearly from what the code already does and from the plans and specs under `docs/`. Ground each one in something you actually read; a feature you cannot point at a file for is a guess, not a proposal.
Rules for every harvested task, without exception:
- Write it the way the Triage phase would: concrete acceptance criteria, real file paths found with Read/Grep/Glob, and what is out of scope. A vague harvested task wastes a whole slot.
- Keep it narrow — one concern, ideally under five changed files. Narrow tasks merge in parallel; broad ones collide with everything.
- Do not hand two new tasks to the same round if they touch the same file. Stagger them across rounds, or chain them with dependsOnTaskId.
- Tag every harvested task `createdBy: "power-use"` so a later review can tell your proposals from the ones a human asked for.
- A task that only verifies, audits or researches and is not meant to change code must be created with `expectsNoDiff: true`, or the next Merge session will read its empty diff as a failure.
## Verify red is the one thing you must not paper over
If verify_merges returns verify_failed, the target branch is now RED. Do not try to fix it inside this session. Create ONE task with add_task(queueImmediately: true, model: "sonnet") titled `fix: verify red after this round`, put the failing output tail in its description, and hand that task to the next round as its ONLY entry. Merge nothing further until it has landed.
## The summary is the only record
Nobody saw you work, so anything not written down did not happen. In addition to the per-task lines the Merge phase already asks for, your summary must carry these sections. An empty section is a valid answer; an omitted one is not:
- **HELD FOR REVIEW** — every task you did not merge, with the reason.
- **Conflicts resolved** — per conflict: the file, which two tasks collided, and which way you resolved it. Someone must be able to find your resolution without re-deriving it.
- **Harvested** — every task you created, grouped by source.
- **Round state** — which round this was and what you handed to the next one.
"""; """;
private const string WeeklyReportDefault = """ private const string WeeklyReportDefault = """
@@ -93,6 +93,16 @@ public sealed class AppSettingsRepository
row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100); row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100);
row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100); row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100);
row.AutoContinueOnUsageLimit = updated.AutoContinueOnUsageLimit; row.AutoContinueOnUsageLimit = updated.AutoContinueOnUsageLimit;
// ModelPresets / PowerUse* / ListHandlerMaxHandoffs were missing from this copy block, so
// every write of them was silently dropped (the row keeps whatever it already had). This
// block is the single write path for the singleton row — a field absent here does not
// persist, no matter how correctly the hub fills the entity.
row.ModelPresets = string.IsNullOrWhiteSpace(updated.ModelPresets) ? row.ModelPresets : updated.ModelPresets;
row.PowerUseEnabled = updated.PowerUseEnabled;
row.PowerUseWindowStart = PowerUseWindow.Normalize(updated.PowerUseWindowStart);
row.PowerUseWindowEnd = PowerUseWindow.Normalize(updated.PowerUseWindowEnd);
row.PowerUseHandoffPct = Math.Clamp(updated.PowerUseHandoffPct, 50, 99);
row.ListHandlerMaxHandoffs = Math.Max(1, updated.ListHandlerMaxHandoffs);
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
@@ -22,7 +22,13 @@ public sealed class DailyNoteRepository
.OrderBy(n => n.Date).ThenBy(n => n.SortOrder) .OrderBy(n => n.Date).ThenBy(n => n.SortOrder)
.ToListAsync(ct); .ToListAsync(ct);
public async Task<DailyNoteEntity> AddAsync(DateOnly day, string text, CancellationToken ct = default) /// <summary>Notes an agent wrote on <paramref name="day"/> — the number the MCP cap counts against.</summary>
public async Task<int> CountAgentNotesAsync(DateOnly day, CancellationToken ct = default) =>
await _context.DailyNotes.AsNoTracking()
.CountAsync(n => n.Date == day && n.FromAgent, ct);
public async Task<DailyNoteEntity> AddAsync(
DateOnly day, string text, bool fromAgent = false, CancellationToken ct = default)
{ {
var nextOrder = await _context.DailyNotes var nextOrder = await _context.DailyNotes
.Where(n => n.Date == day) .Where(n => n.Date == day)
@@ -34,6 +40,7 @@ public sealed class DailyNoteRepository
Date = day, Date = day,
Text = text, Text = text,
SortOrder = nextOrder + 1, SortOrder = nextOrder + 1,
FromAgent = fromAgent,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
}; };
_context.DailyNotes.Add(note); _context.DailyNotes.Add(note);
@@ -90,6 +90,7 @@ public sealed class ListRepository
existing.VerifyCommand = config.VerifyCommand; existing.VerifyCommand = config.VerifyCommand;
existing.PermissionMode = config.PermissionMode; existing.PermissionMode = config.PermissionMode;
existing.TicketProjectId = config.TicketProjectId; existing.TicketProjectId = config.TicketProjectId;
existing.PowerUseAllowed = config.PowerUseAllowed;
} }
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
@@ -34,7 +34,10 @@ public sealed class TaskRepository
var tracked = _context.Tasks.Local.FirstOrDefault(t => t.Id == entity.Id); var tracked = _context.Tasks.Local.FirstOrDefault(t => t.Id == entity.Id);
if (tracked is not null && !ReferenceEquals(tracked, entity)) if (tracked is not null && !ReferenceEquals(tracked, entity))
_context.Entry(tracked).State = Microsoft.EntityFrameworkCore.EntityState.Detached; _context.Entry(tracked).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
_context.Tasks.Update(entity); // Entry().State instead of Update(): Update marks the whole loaded graph Modified, which
// for the all-key task_dependencies rows means an UPDATE with nothing to set. Dependency
// edges are written only by TaskStateService.SetDependsOnAsync.
_context.Entry(entity).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
@@ -46,17 +49,20 @@ public sealed class TaskRepository
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default) public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
{ {
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct); return await _context.Tasks.AsNoTracking().Include(t => t.Dependencies)
.FirstOrDefaultAsync(t => t.Id == taskId, ct);
} }
public async Task<TaskEntity?> GetByNumberAsync(int number, CancellationToken ct = default) public async Task<TaskEntity?> GetByNumberAsync(int number, CancellationToken ct = default)
{ {
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Number == number, ct); return await _context.Tasks.AsNoTracking().Include(t => t.Dependencies)
.FirstOrDefaultAsync(t => t.Number == number, ct);
} }
public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default) public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
{ {
return await _context.Tasks return await _context.Tasks
.Include(t => t.Dependencies)
.Where(t => t.ListId == listId) .Where(t => t.ListId == listId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);
+16 -6
View File
@@ -13,8 +13,6 @@ namespace ClaudeDo.Data.Wire;
public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt); public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt);
public record WorkerBuildInfoDto(string? BuildSha);
public record AppSettingsDto( public record AppSettingsDto(
string DefaultClaudeInstructions, string DefaultClaudeInstructions,
string DefaultModel, string DefaultModel,
@@ -38,7 +36,17 @@ public record AppSettingsDto(
int UsageThrottleFiveHourHardPct = 65, int UsageThrottleFiveHourHardPct = 65,
int UsageThrottleSevenDaySoftPct = 50, int UsageThrottleSevenDaySoftPct = 50,
int UsageThrottleSevenDayHardPct = 65, int UsageThrottleSevenDayHardPct = 65,
bool AutoContinueOnUsageLimit = false); bool AutoContinueOnUsageLimit = false,
// PowerUse master switch — toggled on the usage monitor, not typed in Settings. Only takes
// effect for a list that also set PowerUseAllowed.
bool PowerUseEnabled = false,
int ListHandlerMaxHandoffs = 4,
// PowerUse time window (`HH:mm`, local, null = round the clock) + the 5h-usage percentage at
// which a PowerUse handler session should hand off. Also usage-monitor-owned, so Settings has
// to round-trip them too.
string? PowerUseWindowStart = null,
string? PowerUseWindowEnd = null,
int PowerUseHandoffPct = 95);
public record ModelPresetDto(string Model, string Effort, int MaxTurns); public record ModelPresetDto(string Model, string Effort, int MaxTurns);
@@ -92,16 +100,18 @@ public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<Merg
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false); public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false, string? Icon = null);
// TicketProjectId is tri-state on purpose: a caller that doesn't own the field (anything but the // TicketProjectId is tri-state on purpose: a caller that doesn't own the field (anything but the
// list-settings modal) must not be able to clear it by omission — SetConfigAsync copies the entity // list-settings modal) must not be able to clear it by omission — SetConfigAsync copies the entity
// verbatim. Its scale: null = keep stored, <= 0 = clear the link, > 0 = set it. // verbatim. Its scale: null = keep stored, <= 0 = clear the link, > 0 = set it.
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null); // PowerUseAllowed is nullable for the same reason: null = keep stored, false = revoke the opt-in.
// A non-nullable bool here would let any caller that omits it silently revoke PowerUse for the list.
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool? PowerUseAllowed = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? PermissionMode = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? PermissionMode = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null); public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool PowerUseAllowed = false);
public record SeedResultDto(int Copied, int Skipped); public record SeedResultDto(int Copied, int Skipped);
+30 -10
View File
@@ -41,6 +41,8 @@
"usageGateSevenDayPct": "7-Tage-Fenster (%)", "usageGateSevenDayPct": "7-Tage-Fenster (%)",
"autoContinueOnUsageLimit": "Nach Limit-Reset fortsetzen", "autoContinueOnUsageLimit": "Nach Limit-Reset fortsetzen",
"autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Queue genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.", "autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Queue genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.",
"listHandlerMaxHandoffs": "Handoff-Budget des List-Handlers",
"listHandlerMaxHandoffsHint": "Wie viele Wait/Merge-Handoffs ein „Let Claude handle it\"-Run verketten darf. 4 entspricht zwei Merge-Runden. Für Läufe, die stundenlang durchlaufen sollen, höher setzen; der Wert begrenzt eine entlaufene Kette und darf daher nicht 0 sein.",
"throttleStagesHeading": "Throttle-Stufen", "throttleStagesHeading": "Throttle-Stufen",
"throttleStagesHint": "Die Parallelität wird vor dem harten Gate begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.", "throttleStagesHint": "Die Parallelität wird vor dem harten Gate begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.",
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%", "throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
@@ -85,6 +87,7 @@
"retryPrompt": "Wiederholung", "retryPrompt": "Wiederholung",
"dailyPrepPrompt": "Tagesplanung", "dailyPrepPrompt": "Tagesplanung",
"weeklyReportPrompt": "Wochenbericht", "weeklyReportPrompt": "Wochenbericht",
"powerUsePrompt": "PowerUse-Addendum",
"viewPrompt": "Ansehen", "viewPrompt": "Ansehen",
"customizedSection": "ANGEPASST", "customizedSection": "ANGEPASST",
"customizedHint": "Diese Prompts weichen vom mitgelieferten Standard ab und werden unverändert beibehalten. Zurücksetzen, um künftige Standard-Verbesserungen zu erhalten.", "customizedHint": "Diese Prompts weichen vom mitgelieferten Standard ab und werden unverändert beibehalten. Zurücksetzen, um künftige Standard-Verbesserungen zu erhalten.",
@@ -177,7 +180,6 @@
"listSettingsTip": "Listeneinstellungen", "listSettingsTip": "Listeneinstellungen",
"addPlaceholder": "Aufgabe hinzufügen…", "addPlaceholder": "Aufgabe hinzufügen…",
"enterKey": "ENTER", "enterKey": "ENTER",
"notesPinnedRow": "Notizen (Tagesnotizen)",
"clearDayTip": "Tag leeren", "clearDayTip": "Tag leeren",
"planMyDayTip": "Meinen Tag planen", "planMyDayTip": "Meinen Tag planen",
"overdue": "ÜBERFÄLLIG", "overdue": "ÜBERFÄLLIG",
@@ -209,6 +211,7 @@
"ctxRefineTask": "Aufgabe verfeinern", "ctxRefineTask": "Aufgabe verfeinern",
"ctxPlanning": "Planung", "ctxPlanning": "Planung",
"ctxSchedule": "Zeitplan", "ctxSchedule": "Zeitplan",
"ctxMoveToList": "In Liste verschieben",
"ctxDeleteTask": "Aufgabe löschen", "ctxDeleteTask": "Aufgabe löschen",
"reasonAlreadyRunning": "Aufgabe läuft bereits", "reasonAlreadyRunning": "Aufgabe läuft bereits",
"reasonAlreadyQueued": "Bereits in der Warteschlange", "reasonAlreadyQueued": "Bereits in der Warteschlange",
@@ -261,7 +264,6 @@
"killSessionTip": "Laufende Sitzung beenden", "killSessionTip": "Laufende Sitzung beenden",
"sendToQueue": "In Warteschlange einreihen", "sendToQueue": "In Warteschlange einreihen",
"sendToQueueTip": "Diese Aufgabe in die Warteschlange einreihen, damit Claude sie beim nächsten Lauf übernimmt", "sendToQueueTip": "Diese Aufgabe in die Warteschlange einreihen, damit Claude sie beim nächsten Lauf übernimmt",
"closeTip": "Schließen",
"copyTaskIdTip": "Aufgaben-ID kopieren", "copyTaskIdTip": "Aufgaben-ID kopieren",
"starTip": "Favorit", "starTip": "Favorit",
"agentSettingsTip": "Agent-Einstellungen", "agentSettingsTip": "Agent-Einstellungen",
@@ -300,8 +302,11 @@
}, },
"notes": { "notes": {
"today": "Heute", "today": "Heute",
"add": "Hinzufügen", "yesterday": "Gestern",
"newNotePlaceholder": "Neue Notiz…" "newNotePlaceholder": "Neue Notiz…",
"emptyHint": "Noch nichts notiert. Tippen, Enter, fertig.",
"deleteTip": "Notiz löschen",
"fromAgentTip": "Von Claude notiert"
}, },
"session": { "session": {
"chipLive": "LIVE", "chipLive": "LIVE",
@@ -320,14 +325,20 @@
"newSession": "Neue Sitzung", "newSession": "Neue Sitzung",
"empty": "Keine laufenden Aufgaben", "empty": "Keine laufenden Aufgaben",
"settings": "Einstellungen", "settings": "Einstellungen",
"queue": "Warteschlange", "activity": "Aktivität",
"blocked": "Blockiert", "blocked": "Blockiert",
"running": "Läuft", "running": "Läuft",
"review": "Im Review",
"failed": "Fehlgeschlagen",
"roadblock": "Blockade gemeldet",
"openSession": "Sitzung öffnen",
"browseForFolder": "Ordner wählen…",
"focusMode": "Fokus", "focusMode": "Fokus",
"overviewMode": "Übersicht", "overviewMode": "Übersicht",
"closeSession": "Sitzung beenden", "closeSession": "Sitzung beenden",
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}", "conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
"conptyStarting": "Sitzung wird gestartet…", "conptyStarting": "Sitzung wird gestartet…",
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
"mergeHelperTitle": "Merge-Helfer", "mergeHelperTitle": "Merge-Helfer",
"mergeHelperHandoffTitleSuffix": " (Übergabe)", "mergeHelperHandoffTitleSuffix": " (Übergabe)",
"mergeHelperTaskTitle": "Listen-Handler: {0}", "mergeHelperTaskTitle": "Listen-Handler: {0}",
@@ -391,6 +402,11 @@
"manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.", "manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.",
"findingsTracked": "Findings-Ordner .claudedo einchecken", "findingsTracked": "Findings-Ordner .claudedo einchecken",
"findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.", "findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.",
"powerUseAllowed": "PowerUse für diese Liste erlauben",
"powerUseAllowedHint": "Erlaubt einem List-Handler-Run auf dieser Liste, unbeaufsichtigt zu laufen, sobald PowerUse im Usage-Monitor eingeschaltet ist: er fragt nichts, löst Konflikte selbst und sammelt neue Arbeit. Beide Schalter sind nötig — ein global aktives PowerUse ändert an einer Liste ohne Opt-in also nichts.",
"sectionIcon": "ICON",
"icon": "Listen-Icon",
"iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.",
"sectionAgent": "AGENT", "sectionAgent": "AGENT",
"resetAgentSettings": "Agent-Einstellungen zurücksetzen", "resetAgentSettings": "Agent-Einstellungen zurücksetzen",
"sectionVerify": "VERIFIKATION", "sectionVerify": "VERIFIKATION",
@@ -494,6 +510,12 @@
"title": "USAGE MONITOR", "title": "USAGE MONITOR",
"windowTitle": "Usage Monitor", "windowTitle": "Usage Monitor",
"noGauges": "Noch keine Nutzungslimits gemeldet.", "noGauges": "Noch keine Nutzungslimits gemeldet.",
"powerUse": "PowerUse",
"powerUseHint": "Das restliche Fenster ausnutzen: ein List-Handler-Run fragt nichts, löst Konflikte selbst, sammelt neue Arbeit und handoffet weiter, solange sein Budget reicht. Wirkt nur auf Listen, die PowerUse in ihren Listen-Einstellungen erlaubt haben.",
"powerUseWindow": "Nur zwischen",
"powerUseWindowPlaceholder": "HH:mm",
"powerUseHandoffPct": "Übergabe bei (% von 5h)",
"powerUseWindowHint": "Beide Zeiten leer = rund um die Uhr. Das Fenster wird bei jedem Start einer Handler-Phase geprüft — eine Kette, die über das Ende hinausläuft, macht ohne PowerUse weiter.",
"refresh": "Jetzt aktualisieren", "refresh": "Jetzt aktualisieren",
"refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.", "refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
"staleFormat": "Werte veraltet (Stand {0})", "staleFormat": "Werte veraltet (Stand {0})",
@@ -711,9 +733,6 @@
"updateNow": "Jetzt aktualisieren", "updateNow": "Jetzt aktualisieren",
"dismiss": "Ausblenden" "dismiss": "Ausblenden"
}, },
"staleWorker": {
"message": "Der Worker läuft auf einem älteren Stand als der gemergte main-Branch dieses Repos — Neustart nötig, damit gemergte Änderungen wirken."
},
"externalMerge": { "externalMerge": {
"banner": "Eine Claude-Session löst gerade einen Merge-Konflikt auf — bitte keine Dateien im Repository bearbeiten.", "banner": "Eine Claude-Session löst gerade einen Merge-Konflikt auf — bitte keine Dateien im Repository bearbeiten.",
"open": "Resolver öffnen" "open": "Resolver öffnen"
@@ -730,7 +749,7 @@
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" }, "planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" }, "taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"queue": { "baseDirtyWarning": "Achtung: Das Repo dieser Liste hat uncommittete Änderungen ({0} geändert, {1} untracked) — ein neuer Worktree startet vom letzten Commit und enthält sie nicht." }, "queue": { "baseDirtyWarning": "Achtung: Das Repo dieser Liste hat uncommittete Änderungen ({0} geändert, {1} untracked) — ein neuer Worktree startet vom letzten Commit und enthält sie nicht." },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, "tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "statusPill": "{0} laufend · {1} Prüfung", "openTaskOne": "1 offene Aufgabe", "openTasks": "{0} offene Aufgaben", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." }, "diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" }, "planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" }, "merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
@@ -742,6 +761,7 @@
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}", "loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}", "refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}",
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}", "thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}",
"powerUseSaveFailed": "PowerUse-Schalter konnte nicht gespeichert werden: {0}",
"installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.", "installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.",
"installFailed": "TokenTracker konnte nicht installiert werden: {0}", "installFailed": "TokenTracker konnte nicht installiert werden: {0}",
"analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}", "analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}",
@@ -756,7 +776,7 @@
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" }, "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, "mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "smartNotes": "Notizen", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" } "repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
}, },
"ops": { "ops": {
+30 -10
View File
@@ -41,6 +41,8 @@
"usageGateSevenDayPct": "7-day window (%)", "usageGateSevenDayPct": "7-day window (%)",
"autoContinueOnUsageLimit": "Continue on session limit reset", "autoContinueOnUsageLimit": "Continue on session limit reset",
"autoContinueOnUsageLimitHint": "When a run stops because it hit the usage limit, automatically continue it once the 5-hour window resets. Also wakes the paused queue exactly at reset time instead of waiting for the next backstop check.", "autoContinueOnUsageLimitHint": "When a run stops because it hit the usage limit, automatically continue it once the 5-hour window resets. Also wakes the paused queue exactly at reset time instead of waiting for the next backstop check.",
"listHandlerMaxHandoffs": "List-handler handoff budget",
"listHandlerMaxHandoffsHint": "How many wait/merge handoffs one \"Let Claude handle it\" run may chain. 4 means two merge rounds. Raise it for runs meant to keep going for hours; it bounds a runaway chain, so it cannot be 0.",
"throttleStagesHeading": "Throttle stages", "throttleStagesHeading": "Throttle stages",
"throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.", "throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.",
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%", "throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
@@ -85,6 +87,7 @@
"retryPrompt": "Retry", "retryPrompt": "Retry",
"dailyPrepPrompt": "Daily prep", "dailyPrepPrompt": "Daily prep",
"weeklyReportPrompt": "Weekly report", "weeklyReportPrompt": "Weekly report",
"powerUsePrompt": "PowerUse addendum",
"viewPrompt": "View", "viewPrompt": "View",
"customizedSection": "CUSTOMIZED", "customizedSection": "CUSTOMIZED",
"customizedHint": "These prompts differ from the bundled default and are kept as-is. Reset to pick up future default improvements.", "customizedHint": "These prompts differ from the bundled default and are kept as-is. Reset to pick up future default improvements.",
@@ -177,7 +180,6 @@
"listSettingsTip": "List settings", "listSettingsTip": "List settings",
"addPlaceholder": "Add a task…", "addPlaceholder": "Add a task…",
"enterKey": "ENTER", "enterKey": "ENTER",
"notesPinnedRow": "Notes (daily notes)",
"clearDayTip": "Clear day", "clearDayTip": "Clear day",
"planMyDayTip": "Plan My Day", "planMyDayTip": "Plan My Day",
"overdue": "OVERDUE", "overdue": "OVERDUE",
@@ -209,6 +211,7 @@
"ctxRefineTask": "Refine task", "ctxRefineTask": "Refine task",
"ctxPlanning": "Planning", "ctxPlanning": "Planning",
"ctxSchedule": "Schedule", "ctxSchedule": "Schedule",
"ctxMoveToList": "Move to list",
"ctxDeleteTask": "Delete task", "ctxDeleteTask": "Delete task",
"reasonAlreadyRunning": "Task is already running", "reasonAlreadyRunning": "Task is already running",
"reasonAlreadyQueued": "Already in the queue", "reasonAlreadyQueued": "Already in the queue",
@@ -261,7 +264,6 @@
"killSessionTip": "Kill the running session", "killSessionTip": "Kill the running session",
"sendToQueue": "Send to queue", "sendToQueue": "Send to queue",
"sendToQueueTip": "Queue this task so Claude picks it up on the next run", "sendToQueueTip": "Queue this task so Claude picks it up on the next run",
"closeTip": "Close",
"copyTaskIdTip": "Copy task ID", "copyTaskIdTip": "Copy task ID",
"starTip": "Star", "starTip": "Star",
"agentSettingsTip": "Agent settings", "agentSettingsTip": "Agent settings",
@@ -300,8 +302,11 @@
}, },
"notes": { "notes": {
"today": "Today", "today": "Today",
"add": "Add", "yesterday": "Yesterday",
"newNotePlaceholder": "New note…" "newNotePlaceholder": "New note…",
"emptyHint": "Nothing noted yet. Type, hit Enter, done.",
"deleteTip": "Delete note",
"fromAgentTip": "Written by Claude"
}, },
"session": { "session": {
"chipLive": "LIVE", "chipLive": "LIVE",
@@ -320,14 +325,20 @@
"newSession": "New session", "newSession": "New session",
"empty": "No running tasks", "empty": "No running tasks",
"settings": "Settings", "settings": "Settings",
"queue": "Queue", "activity": "Activity",
"blocked": "Blocked", "blocked": "Blocked",
"running": "Running", "running": "Running",
"review": "In review",
"failed": "Failed",
"roadblock": "Roadblock reported",
"openSession": "Open session",
"browseForFolder": "Choose folder…",
"focusMode": "Focus", "focusMode": "Focus",
"overviewMode": "Overview", "overviewMode": "Overview",
"closeSession": "Kill session", "closeSession": "Kill session",
"conptyLaunchFailed": "Couldn't open interactive session: {0}", "conptyLaunchFailed": "Couldn't open interactive session: {0}",
"conptyStarting": "Starting session…", "conptyStarting": "Starting session…",
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
"mergeHelperTitle": "Merge Helper", "mergeHelperTitle": "Merge Helper",
"mergeHelperHandoffTitleSuffix": " (Handoff)", "mergeHelperHandoffTitleSuffix": " (Handoff)",
"mergeHelperTaskTitle": "List handler: {0}", "mergeHelperTaskTitle": "List handler: {0}",
@@ -391,6 +402,11 @@
"manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.", "manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.",
"findingsTracked": "Commit the .claudedo findings folder", "findingsTracked": "Commit the .claudedo findings folder",
"findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.", "findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.",
"powerUseAllowed": "Allow PowerUse for this list",
"powerUseAllowedHint": "Lets a list-handler run on this list go unattended when PowerUse is switched on in the usage monitor: it asks nothing, resolves conflicts itself, and harvests new work. Both switches are needed, so leaving PowerUse on globally never changes a list that hasn't opted in.",
"sectionIcon": "ICON",
"icon": "List icon",
"iconHint": "Shown next to the list name in the sidebar.",
"sectionAgent": "AGENT", "sectionAgent": "AGENT",
"resetAgentSettings": "Reset agent settings", "resetAgentSettings": "Reset agent settings",
"sectionVerify": "VERIFICATION", "sectionVerify": "VERIFICATION",
@@ -494,6 +510,12 @@
"title": "USAGE MONITOR", "title": "USAGE MONITOR",
"windowTitle": "Usage Monitor", "windowTitle": "Usage Monitor",
"noGauges": "No usage limits reported yet.", "noGauges": "No usage limits reported yet.",
"powerUse": "PowerUse",
"powerUseHint": "Spend the rest of the window: a list-handler run asks nothing, resolves conflicts itself, harvests new work, and keeps handing off for as long as its budget allows. Only affects lists that allowed PowerUse in their list settings.",
"powerUseWindow": "Only between",
"powerUseWindowPlaceholder": "HH:mm",
"powerUseHandoffPct": "Hand off at (% of 5h)",
"powerUseWindowHint": "Leave both times empty for round the clock. A window is checked whenever a handler phase starts, so a chain that runs past the end time continues without PowerUse.",
"refresh": "Refresh now", "refresh": "Refresh now",
"refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.", "refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.",
"staleFormat": "Values stale (as of {0})", "staleFormat": "Values stale (as of {0})",
@@ -711,9 +733,6 @@
"updateNow": "Update now", "updateNow": "Update now",
"dismiss": "Dismiss" "dismiss": "Dismiss"
}, },
"staleWorker": {
"message": "The worker is running an older build than this repo's merged main — restart it so your merged changes take effect."
},
"externalMerge": { "externalMerge": {
"banner": "A Claude session is resolving a merge conflict — don't edit files in the repository.", "banner": "A Claude session is resolving a merge conflict — don't edit files in the repository.",
"open": "Open resolver" "open": "Open resolver"
@@ -730,7 +749,7 @@
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" }, "planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" }, "taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"queue": { "baseDirtyWarning": "Heads up: this list's repo has uncommitted changes ({0} modified, {1} untracked) — a new worktree starts from the last commit and won't include them." }, "queue": { "baseDirtyWarning": "Heads up: this list's repo has uncommitted changes ({0} modified, {1} untracked) — a new worktree starts from the last commit and won't include them." },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}", "openWorktreeFailed": "Couldn't open worktree: {0}" }, "tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "statusPill": "{0} running · {1} review", "openTaskOne": "1 open task", "openTasks": "{0} open tasks", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}", "openWorktreeFailed": "Couldn't open worktree: {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." }, "diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" }, "planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" }, "merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
@@ -742,6 +761,7 @@
"loadFailed": "Couldn't load usage data: {0}", "loadFailed": "Couldn't load usage data: {0}",
"refreshFailed": "Couldn't refresh usage: {0}", "refreshFailed": "Couldn't refresh usage: {0}",
"thresholdSaveFailed": "Couldn't save the threshold: {0}", "thresholdSaveFailed": "Couldn't save the threshold: {0}",
"powerUseSaveFailed": "Couldn't save the PowerUse switch: {0}",
"installStarted": "Installing TokenTracker — watch the log strip for progress.", "installStarted": "Installing TokenTracker — watch the log strip for progress.",
"installFailed": "Couldn't install TokenTracker: {0}", "installFailed": "Couldn't install TokenTracker: {0}",
"analyticsRefreshFailed": "Couldn't refresh the analytics: {0}", "analyticsRefreshFailed": "Couldn't refresh the analytics: {0}",
@@ -756,7 +776,7 @@
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" }, "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" }, "mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" }, "about": { "openPathFailed": "Couldn't open path: {0}" },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" }, "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "smartNotes": "Notes", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" },
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" } "repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
}, },
"ops": { "ops": {
+88 -10
View File
@@ -33,8 +33,9 @@
<!-- All d-strings sourced from icons.jsx --> <!-- All d-strings sourced from icons.jsx -->
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- Icon.Sun --> <!-- Icon.Sun — filled disc + 8 rays. The old stroke-only version rendered as a bare dot in a
<StreamGeometry x:Key="Icon.Sun">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M12 3v2M12 19v2M3 12h2M19 12h2M5.5 5.5l1.4 1.4M17.1 17.1l1.4 1.4M5.5 18.5l1.4-1.4M17.1 6.9l1.4-1.4</StreamGeometry> PathIcon (verified by rendering it); rays are kept clear of the disc so EvenOdd is safe. -->
<StreamGeometry x:Key="Icon.Sun">M7.5 12 C7.5 9.51 9.51 7.5 12 7.5 C14.49 7.5 16.5 9.51 16.5 12 C16.5 14.49 14.49 16.5 12 16.5 C9.51 16.5 7.5 14.49 7.5 12 Z M11 2 H13 V5.5 H11 Z M11 18.5 H13 V22 H11 Z M2 11 H5.5 V13 H2 Z M18.5 11 H22 V13 H18.5 Z M4.22 5.64 L5.64 4.22 L8.11 6.69 L6.69 8.11 Z M18.36 4.22 L19.78 5.64 L17.31 8.11 L15.89 6.69 Z M5.64 19.78 L4.22 18.36 L6.69 15.89 L8.11 17.31 Z M19.78 18.36 L18.36 19.78 L15.89 17.31 L17.31 15.89 Z</StreamGeometry>
<!-- Icon.Activity (pulse waveform) — filled outline of the polyline; PathIcon fills, a stroke-only pulse renders as blobs --> <!-- Icon.Activity (pulse waveform) — filled outline of the polyline; PathIcon fills, a stroke-only pulse renders as blobs -->
<StreamGeometry x:Key="Icon.Activity">M3,11 L6.28,11 L9,2.84 L12.86,14.43 L14.46,8.04 L17.41,11 L21,11 L21,13 L16.59,13 L15.54,11.96 L13.14,21.57 L9,9.16 L7.72,13 L3,13 Z</StreamGeometry> <StreamGeometry x:Key="Icon.Activity">M3,11 L6.28,11 L9,2.84 L12.86,14.43 L14.46,8.04 L17.41,11 L21,11 L21,13 L16.59,13 L15.54,11.96 L13.14,21.57 L9,9.16 L7.72,13 L3,13 Z</StreamGeometry>
@@ -42,20 +43,23 @@
<!-- Icon.Star --> <!-- Icon.Star -->
<StreamGeometry x:Key="Icon.Star">M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z</StreamGeometry> <StreamGeometry x:Key="Icon.Star">M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z</StreamGeometry>
<!-- Icon.Calendar --> <!-- Icon.Calendar — solid header band, hollow body, two binder posts and a date block.
<StreamGeometry x:Key="Icon.Calendar">M3.5 5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v15a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z M3.5 10h17M8 3v4M16 3v4</StreamGeometry> The old stroke version filled into a plain black square. -->
<StreamGeometry x:Key="Icon.Calendar">F0 M3 5 H21 V21 H3 Z M5 11 H19 V19 H5 Z M7 2 H9 V5 H7 Z M15 2 H17 V5 H15 Z M7.5 13 H10 V15.5 H7.5 Z</StreamGeometry>
<!-- Icon.Eye --> <!-- Icon.Eye -->
<StreamGeometry x:Key="Icon.Eye">M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z</StreamGeometry> <StreamGeometry x:Key="Icon.Eye">M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z</StreamGeometry>
<!-- Icon.Inbox --> <!-- Icon.Inbox — tray frame with an arrow dropping into the upper compartment.
<StreamGeometry x:Key="Icon.Inbox">M3 13h5l1 2h6l1-2h5M3 13l3-8h12l3 8v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry> The old stroke version filled into a solid slab. -->
<StreamGeometry x:Key="Icon.Inbox">F0 M3 5 H21 V19 H3 Z M5 7 H19 V12 H5 Z M5 14 H19 V17 H5 Z M11 7.6 H13 V9.4 H15 L12 12 L9 9.4 H11 Z</StreamGeometry>
<!-- Icon.Folder --> <!-- Icon.Folder -->
<StreamGeometry x:Key="Icon.Folder">M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry> <StreamGeometry x:Key="Icon.Folder">M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry>
<!-- Icon.Search --> <!-- Icon.Search — hollow ring + handle. The old stroke version filled into a solid disc with
<StreamGeometry x:Key="Icon.Search">M11 4a7 7 0 1 0 0 14A7 7 0 0 0 11 4z M20 20l-3.5-3.5</StreamGeometry> no handle at all (this one is also the magnifier in the lists search box). -->
<StreamGeometry x:Key="Icon.Search">F0 M3 10.5 C3 6.36 6.36 3 10.5 3 C14.64 3 18 6.36 18 10.5 C18 14.64 14.64 18 10.5 18 C6.36 18 3 14.64 3 10.5 Z M5.2 10.5 C5.2 7.57 7.57 5.2 10.5 5.2 C13.43 5.2 15.8 7.57 15.8 10.5 C15.8 13.43 13.43 15.8 10.5 15.8 C7.57 15.8 5.2 13.43 5.2 10.5 Z M14.88 16.58 L16.58 14.88 L22.05 20.35 L20.35 22.05 Z</StreamGeometry>
<!-- Icon.Plus — filled cross (PathIcon fills, so a stroke-only plus renders invisible) --> <!-- Icon.Plus — filled cross (PathIcon fills, so a stroke-only plus renders invisible) -->
<StreamGeometry x:Key="Icon.Plus">M10.6 5 H13.4 V10.6 H19 V13.4 H13.4 V19 H10.6 V13.4 H5 V10.6 H10.6 Z</StreamGeometry> <StreamGeometry x:Key="Icon.Plus">M10.6 5 H13.4 V10.6 H19 V13.4 H13.4 V19 H10.6 V13.4 H5 V10.6 H10.6 Z</StreamGeometry>
@@ -87,8 +91,8 @@
<!-- Icon.Stop — filled square (stop / interrupt) --> <!-- Icon.Stop — filled square (stop / interrupt) -->
<StreamGeometry x:Key="Icon.Stop">M4 4 H20 V20 H4 Z</StreamGeometry> <StreamGeometry x:Key="Icon.Stop">M4 4 H20 V20 H4 Z</StreamGeometry>
<!-- Icon.Check --> <!-- Icon.Check — filled tick. The old stroke version filled into a solid wedge. -->
<StreamGeometry x:Key="Icon.Check">M4 12l5 5 11-11</StreamGeometry> <StreamGeometry x:Key="Icon.Check">M9.6 18.2 L3.4 12 L5.5 9.9 L9.6 14 L18.5 5.1 L20.6 7.2 Z</StreamGeometry>
<!-- Icon.ArrowOut — filled arrow for "open external" button --> <!-- Icon.ArrowOut — filled arrow for "open external" button -->
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry> <StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
@@ -115,6 +119,75 @@
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw --> <!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry> <StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
<!-- ============================================================ -->
<!-- List icons — pickable in list settings → Icon tab. -->
<!-- All filled outlines (PathIcon fills; a stroke-only path is -->
<!-- invisible). Holes rely on the EvenOdd default; the two that -->
<!-- want overlapping subpaths to merge declare F1 explicitly. -->
<!-- ============================================================ -->
<!-- Icon.Box — carton: solid lid band, open body, seam -->
<StreamGeometry x:Key="Icon.Box">F0 M3 5 H21 V20 H3 Z M5 11 H19 V18 H5 Z M11 11 H13 V14 H11 Z</StreamGeometry>
<!-- Icon.Truck — box body + cab + two wheels (F1: wheels overlap the body) -->
<StreamGeometry x:Key="Icon.Truck">F1 M2 6 H13.5 V16 H2 Z M13.5 9.5 H17.4 L21 13.1 V16 H13.5 Z M7 15.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z M17 15.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z</StreamGeometry>
<!-- Icon.MapPin — location pin with punched-out centre -->
<StreamGeometry x:Key="Icon.MapPin">F0 M12 2 C8.1 2 5 5.1 5 9 C5 14.2 12 22 12 22 C12 22 19 14.2 19 9 C19 5.1 15.9 2 12 2 Z M12 6.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z</StreamGeometry>
<!-- Icon.Chart — three rising bars -->
<StreamGeometry x:Key="Icon.Chart">M4 13 H8 V20 H4 Z M10 8 H14 V20 H10 Z M16 4 H20 V20 H16 Z</StreamGeometry>
<!-- Icon.Server — two rack units with punched-out status LEDs -->
<StreamGeometry x:Key="Icon.Server">F0 M3 4 H21 V10 H3 Z M3 14 H21 V20 H3 Z M5.5 6 H7.5 V8 H5.5 Z M5.5 16 H7.5 V18 H5.5 Z</StreamGeometry>
<!-- Icon.Chip — CPU die with hollow core and eight pins -->
<StreamGeometry x:Key="Icon.Chip">F0 M7 7 H17 V17 H7 Z M9.5 9.5 H14.5 V14.5 H9.5 Z M9 3 H10.5 V6.5 H9 Z M13.5 3 H15 V6.5 H13.5 Z M9 17.5 H10.5 V21 H9 Z M13.5 17.5 H15 V21 H13.5 Z M3 9 H6.5 V10.5 H3 Z M3 13.5 H6.5 V15 H3 Z M17.5 9 H21 V10.5 H17.5 Z M17.5 13.5 H21 V15 H17.5 Z</StreamGeometry>
<!-- Icon.Bolt — lightning bolt -->
<StreamGeometry x:Key="Icon.Bolt">M13 2 L4 14 H10 L9 22 L20 9 H13.6 Z</StreamGeometry>
<!-- Icon.Rocket — hull with porthole hole + exhaust -->
<StreamGeometry x:Key="Icon.Rocket">F0 M12 2 C15.5 5 17.5 9.5 17.5 14 V17 L14.5 19 H9.5 L6.5 17 V14 C6.5 9.5 8.5 5 12 2 Z M12 7.5 a2.2 2.2 0 1 0 0 4.4 a2.2 2.2 0 1 0 0-4.4 Z M9.5 19.5 H14.5 L13 22.5 H11 Z</StreamGeometry>
<!-- Icon.Bulb — bulb over two screw-base bands -->
<StreamGeometry x:Key="Icon.Bulb">F0 M12 2 C8.1 2 5 5.1 5 9 C5 11.6 6.4 13.9 8.5 15.1 V17 H15.5 V15.1 C17.6 13.9 19 11.6 19 9 C19 5.1 15.9 2 12 2 Z M8.5 18.2 H15.5 V19.8 H8.5 Z M9.8 20.9 H14.2 V22.3 H9.8 Z</StreamGeometry>
<!-- Icon.Flask — Erlenmeyer flask, single closed outline -->
<StreamGeometry x:Key="Icon.Flask">M9 2 H15 V4 H14 V9.3 L19.4 19.4 A1.5 1.5 0 0 1 18.1 21.6 H5.9 A1.5 1.5 0 0 1 4.6 19.4 L10 9.3 V4 H9 Z</StreamGeometry>
<!-- Icon.Shield — shield with a punched-out check -->
<StreamGeometry x:Key="Icon.Shield">F0 M12 2 L4 5 V11 C4 16 7.4 20.3 12 21.5 C16.6 20.3 20 16 20 11 V5 Z M11 16.2 L7.2 12.4 L8.9 10.7 L11 12.8 L15.4 8.4 L17.1 10.1 Z</StreamGeometry>
<!-- Icon.Camera — body with viewfinder bump and punched-out lens -->
<StreamGeometry x:Key="Icon.Camera">F0 M9 3 H15 L16.5 5.5 H20 A2 2 0 0 1 22 7.5 V19 A2 2 0 0 1 20 21 H4 A2 2 0 0 1 2 19 V7.5 A2 2 0 0 1 4 5.5 H7.5 Z M12 9 a4.5 4.5 0 1 0 0 9 a4.5 4.5 0 1 0 0-9 Z</StreamGeometry>
<!-- Icon.Image — frame with mountains and sun inside the cut-out -->
<StreamGeometry x:Key="Icon.Image">F0 M3 4 H21 V20 H3 Z M5 6 H19 V18 H5 Z M6.5 16.5 L10.5 11 L13.5 14.5 L15.5 12.5 L17.5 16.5 Z M8 8 a1.6 1.6 0 1 0 0 3.2 a1.6 1.6 0 1 0 0-3.2 Z</StreamGeometry>
<!-- Icon.Barcode — six bars of varying width -->
<StreamGeometry x:Key="Icon.Barcode">M3 4 H5 V20 H3 Z M6.5 4 H7.5 V20 H6.5 Z M9 4 H11.5 V20 H9 Z M13 4 H14 V20 H13 Z M15.5 4 H17.5 V20 H15.5 Z M19 4 H21 V20 H19 Z</StreamGeometry>
<!-- Icon.Mail — envelope: frame cut out, flap drawn back inside it -->
<StreamGeometry x:Key="Icon.Mail">F0 M2 5 H22 V19 H2 Z M4 7 H20 V17 H4 Z M4 7 L12 13.5 L20 7 L20 8.7 L12 15.2 L4 8.7 Z</StreamGeometry>
<!-- Icon.Terminal — console frame with prompt chevron and caret -->
<StreamGeometry x:Key="Icon.Terminal">F0 M2 4 H22 V20 H2 Z M4 6 H20 V18 H4 Z M6 8.4 L7.4 7 L11.4 11 L7.4 15 L6 13.6 L8.6 11 Z M12.5 13.4 H17 V15.2 H12.5 Z</StreamGeometry>
<!-- Icon.Code — angle brackets around a slash -->
<StreamGeometry x:Key="Icon.Code">M8.6 5.4 L10 6.8 L5.8 11 L10 15.2 L8.6 16.6 L3 11 Z M15.4 5.4 L21 11 L15.4 16.6 L14 15.2 L18.2 11 L14 6.8 Z M13.6 3.4 L15.4 3.9 L10.4 20.6 L8.6 20.1 Z</StreamGeometry>
<!-- Icon.Database — cylinder with the top ellipse punched out. Béziers, not arcs: an arc
spanning exactly its own diameter degenerates to a straight line here (measured). -->
<StreamGeometry x:Key="Icon.Database">F0 M5.3 5.2 C5.3 3.43 8.3 2 12 2 C15.7 2 18.7 3.43 18.7 5.2 V18.8 C18.7 20.57 15.7 22 12 22 C8.3 22 5.3 20.57 5.3 18.8 Z M5.3 5.2 C5.3 3.43 8.3 2 12 2 C15.7 2 18.7 3.43 18.7 5.2 C18.7 6.97 15.7 8.4 12 8.4 C8.3 8.4 5.3 6.97 5.3 5.2 Z</StreamGeometry>
<!-- Icon.Wand — wand shaft plus three sparkles (kept clear of the shaft) -->
<StreamGeometry x:Key="Icon.Wand">M3.3 19.3 L13.3 9.3 L14.7 10.7 L4.7 20.7 Z M18 2.5 L19.1 5.9 L22.5 7 L19.1 8.1 L18 11.5 L16.9 8.1 L13.5 7 L16.9 5.9 Z M6 3 L6.7 4.8 L8.5 5.5 L6.7 6.2 L6 8 L5.3 6.2 L3.5 5.5 L5.3 4.8 Z M19.5 15 L20.2 16.8 L22 17.5 L20.2 18.2 L19.5 20 L18.8 18.2 L17 17.5 L18.8 16.8 Z</StreamGeometry>
<!-- Icon.Clock — ring with hands; the hands only touch at x=12.8, never overlap (EvenOdd).
Béziers, not arcs: see Icon.Database. -->
<StreamGeometry x:Key="Icon.Clock">F0 M2 12 C2 6.48 6.48 2 12 2 C17.52 2 22 6.48 22 12 C22 17.52 17.52 22 12 22 C6.48 22 2 17.52 2 12 Z M4 12 C4 7.58 7.58 4 12 4 C16.42 4 20 7.58 20 12 C20 16.42 16.42 20 12 20 C7.58 20 4 16.42 4 12 Z M11.2 6.5 H12.8 V12.8 H11.2 Z M12.8 11.2 H16.5 V12.8 H12.8 Z</StreamGeometry>
<!-- Badge brushes --> <!-- Badge brushes -->
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/> <SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/> <SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
@@ -789,6 +862,11 @@
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" /> <Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" /> <Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Style> </Style>
<!-- Resting icon colour. Must live here, not inline on the PathIcon: a local value outranks
every style, so an inline Foreground makes the .active accent below unreachable. -->
<Style Selector="Border.list-item PathIcon.list-icon">
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<!-- Active item text / icon colors --> <!-- Active item text / icon colors -->
<Style Selector="Border.list-item.active TextBlock.list-label"> <Style Selector="Border.list-item.active TextBlock.list-label">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextBrush}" />
@@ -5,6 +5,8 @@ namespace ClaudeDo.Ui.Services.Interfaces;
public interface INotesApi public interface INotesApi
{ {
Task<List<DailyNoteDto>> ListAsync(DateOnly day); Task<List<DailyNoteDto>> ListAsync(DateOnly day);
/// <summary>Both ends inclusive — today plus its history in one call.</summary>
Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end);
Task<DailyNoteDto?> AddAsync(DateOnly day, string text); Task<DailyNoteDto?> AddAsync(DateOnly day, string text);
Task UpdateAsync(string id, string text); Task UpdateAsync(string id, string text);
Task DeleteAsync(string id); Task DeleteAsync(string id);
@@ -30,6 +30,17 @@ public interface IWorkerClient : INotifyPropertyChanged
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary> /// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent; event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
/// <summary>start_list_handler was called through the external MCP: (listId, taskIds). The UI
/// starts a list-handler run for them exactly like the "Let Claude handle it" button.</summary>
event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
/// <summary>The 5h usage window is running out under a running list-handler session:
/// (handlerTaskId, message). Mission Control types the message into that task's ConPTY pane so
/// the session can hand off cleanly; no pane for the task means nothing happens.</summary>
event Action<string, string>? HandlerUsageWarningEvent;
/// <summary>An agent added a note through the MCP tool — an open notes editor reloads.</summary>
event Action? NotesUpdatedEvent;
event Action? PrepStartedEvent; event Action? PrepStartedEvent;
event Action<string>? PrepLineEvent; event Action<string>? PrepLineEvent;
event Action<bool>? PrepFinishedEvent; event Action<bool>? PrepFinishedEvent;
@@ -143,6 +154,10 @@ public interface IWorkerClient : INotifyPropertyChanged
/// session's role: "wait" | "merge" | "wait_final" | "merge_final".</summary> /// session's role: "wait" | "merge" | "wait_final" | "merge_final".</summary>
Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync( Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default); string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default);
/// <summary>Reports that a task-based ConPTY session's process exited non-zero after it had been
/// running. The exit code only exists in this process; the worker decides whether it was a
/// list-handler session killed by the usage limit and reopens the same phase after the 5h reset.</summary>
Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default);
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY /// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary> /// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default); Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
@@ -172,15 +187,14 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<AppSettingsDto?> GetAppSettingsAsync(); Task<AppSettingsDto?> GetAppSettingsAsync();
Task UpdateAppSettingsAsync(AppSettingsDto dto); Task UpdateAppSettingsAsync(AppSettingsDto dto);
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day); Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
/// <summary>Both ends inclusive.</summary>
Task<List<DailyNoteDto>> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end);
Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text); Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text);
Task UpdateDailyNoteAsync(string id, string text); Task UpdateDailyNoteAsync(string id, string text);
Task DeleteDailyNoteAsync(string id); Task DeleteDailyNoteAsync(string id);
Task<string> GetLastPrepLogAsync(); Task<string> GetLastPrepLogAsync();
Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync(); Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync();
/// <summary>Git SHA the running worker was built from (null when offline or the build isn't stamped).</summary>
Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync();
Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync(); Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync();
Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto); Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto);
Task DeletePrimeScheduleAsync(Guid id); Task DeletePrimeScheduleAsync(Guid id);
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Reflection;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Iciclecreek.Terminal; using Iciclecreek.Terminal;
@@ -93,6 +94,56 @@ public sealed class PtyTerminalSession : IDisposable
ProcessExited?.Invoke(this, e.ExitCode); ProcessExited?.Invoke(this, e.ExitCode);
} }
/// <summary>
/// Types <paramref name="text"/> into the running session and submits it, exactly as if the user
/// had typed it — used to warn a list-handler session before the usage limit
/// (<c>HandlerUsageWarning</c>). Verified against a real `claude` TUI, including while it was
/// mid-turn (the message is queued and acted on when the turn ends).
///
/// Three non-obvious details, all established by that spike:
/// • <see cref="TerminalControl"/> exposes no public write path at all — its only public paste is
/// <c>TerminalView.PasteAsync()</c>, which takes no argument and reads the clipboard. The
/// private <c>SendToPtyAsync</c> on the inner view is the write seam, hence the reflection.
/// • The text and the Enter must be TWO writes. A single <c>text + "\r"</c> chunk lands in
/// claude's composer but is never submitted — a chunk carrying an embedded CR reads as pasted
/// text, not as a keypress.
/// • The Enter bytes come from the emulator (<c>GenerateKeyInput</c>) rather than a literal
/// "\r", because claude turns Win32 input mode on and the emulator owns that encoding.
///
/// Best-effort by design: a package upgrade that renames the private member costs a warning that
/// never arrives, never a crashed Mission Control.
/// </summary>
public async Task<bool> SendLineAsync(string text)
{
if (_control is not { } control || !IsRunning || string.IsNullOrEmpty(text)) return false;
try
{
await SendRawAsync(control, text);
await Task.Delay(SubmitDelay);
await SendRawAsync(control, control.Terminal.GenerateKeyInput(XTerm.Input.Key.Enter, XTerm.Input.KeyModifiers.None));
return true;
}
catch (Exception)
{
return false;
}
}
// Long enough that the two writes can't be read as one chunk by the child's stdin reader.
private static readonly TimeSpan SubmitDelay = TimeSpan.FromMilliseconds(150);
private static readonly FieldInfo? s_terminalViewField =
typeof(TerminalControl).GetField("_terminalView", BindingFlags.Instance | BindingFlags.NonPublic);
private static readonly MethodInfo? s_sendToPty =
typeof(TerminalView).GetMethod("SendToPtyAsync", BindingFlags.Instance | BindingFlags.NonPublic);
private static Task SendRawAsync(TerminalControl control, string data)
{
if (s_terminalViewField?.GetValue(control) is not TerminalView view || s_sendToPty is null)
throw new InvalidOperationException("Iciclecreek.Avalonia.Terminal no longer exposes a PTY write seam.");
return (Task)s_sendToPty.Invoke(view, new object[] { data, CancellationToken.None })!;
}
public void Kill() public void Kill()
{ {
try { _control?.Kill(); } try { _control?.Kill(); }
+3 -5
View File
@@ -67,9 +67,7 @@ public static class RepoScanner
} }
} }
private static bool IsRepo(string dir) // A real clone has .git as a directory. Worktrees and submodules have it as a file
{ // ("gitdir: ...") — those are not standalone repos and would flood the list.
var gitPath = Path.Combine(dir, ".git"); private static bool IsRepo(string dir) => Directory.Exists(Path.Combine(dir, ".git"));
return Directory.Exists(gitPath) || File.Exists(gitPath);
}
} }
+1 -1
View File
@@ -1,3 +1,3 @@
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
public sealed record DailyNoteDto(string Id, string Date, string Text, int SortOrder); public sealed record DailyNoteDto(string Id, string Date, string Text, int SortOrder, bool FromAgent);
+20 -3
View File
@@ -49,11 +49,14 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string, string, string>? TaskQuestionAskedEvent; public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent; public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent; public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
public event Action<string, string>? HandlerUsageWarningEvent;
public event Action? ConnectionRestoredEvent; public event Action? ConnectionRestoredEvent;
public event Action<string>? WorktreeUpdatedEvent; public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? ListUpdatedEvent; public event Action<string>? ListUpdatedEvent;
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent; public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
public event Action? NotesUpdatedEvent;
public event Action? PrepStartedEvent; public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent; public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent; public event Action<bool>? PrepFinishedEvent;
@@ -158,6 +161,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase)); Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase));
}); });
_hub.On<string, IReadOnlyList<string>>("ListHandlerStartRequested", (listId, taskIds) =>
{
Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds));
});
_hub.On<string, string>("HandlerUsageWarning", (taskId, message) =>
{
Dispatcher.UIThread.Post(() => HandlerUsageWarningEvent?.Invoke(taskId, message));
});
_hub.On<string>("WorktreeUpdated", taskId => _hub.On<string>("WorktreeUpdated", taskId =>
{ {
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId)); Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
@@ -213,6 +226,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => PrimeFired?.Invoke(new PrimeFiredEvent(id, ok, msg, when))); Dispatcher.UIThread.Post(() => PrimeFired?.Invoke(new PrimeFiredEvent(id, ok, msg, when)));
}); });
_hub.On("NotesUpdated", () => Dispatcher.UIThread.Post(() => NotesUpdatedEvent?.Invoke()));
_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke())); _hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke()));
_hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line))); _hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line)));
_hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok))); _hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok)));
@@ -475,6 +489,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day) public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>(); => await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();
public async Task<List<DailyNoteDto>> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end)
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotesBetween", IsoDay(start), IsoDay(end)) ?? new List<DailyNoteDto>();
public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text) public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
=> await InvokeTimedAsync<DailyNoteDto>("AddDailyNote", () => _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text)); => await InvokeTimedAsync<DailyNoteDto>("AddDailyNote", () => _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text));
@@ -487,9 +504,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<string> GetLastPrepLogAsync() public async Task<string> GetLastPrepLogAsync()
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty; => await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
public Task<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync()
=> TryInvokeAsync<WorkerBuildInfoDto>("GetWorkerBuildInfo");
public async Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync() public async Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync()
=> await TryInvokeAsync<List<WorkerLogEntry>>("GetRecentLogs") ?? new List<WorkerLogEntry>(); => await TryInvokeAsync<List<WorkerLogEntry>>("GetRecentLogs") ?? new List<WorkerLogEntry>();
@@ -624,6 +638,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default) string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, nextPhase, ct)); => await InvokeTimedAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, nextPhase, ct));
public Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
=> InvokeTimedAsync("ReportInteractiveSessionExited", () => _hub.InvokeAsync("ReportInteractiveSessionExited", taskId, exitCode, ct));
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct)); => await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
@@ -7,6 +7,7 @@ public sealed class WorkerNotesApi : INotesApi
private readonly IWorkerClient _client; private readonly IWorkerClient _client;
public WorkerNotesApi(IWorkerClient client) => _client = client; public WorkerNotesApi(IWorkerClient client) => _client = client;
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day); public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day);
public Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end) => _client.GetDailyNotesBetweenAsync(start, end);
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text); public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text);
public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text); public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text);
public Task DeleteAsync(string id) => _client.DeleteDailyNoteAsync(id); public Task DeleteAsync(string id) => _client.DeleteDailyNoteAsync(id);
@@ -208,11 +208,13 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
catch { } catch { }
} }
// verifyCommand and ticketProjectId are List-only fields owned by ListSettingsModalViewModel // verifyCommand, ticketProjectId and powerUseAllowed are List-only fields owned by
// (not this editor, which is also reused for Task scope); the caller passes them through so the // ListSettingsModalViewModel (not this editor, which is also reused for Task scope); the caller
// single UpdateListConfig call carries the full desired row instead of clobbering it. // passes them through so the single UpdateListConfig call carries the full desired row instead
// of clobbering it. powerUseAllowed stays null-by-default for the same reason ticketProjectId
// does: a caller that doesn't own it must not revoke the list's opt-in by omission.
public async System.Threading.Tasks.Task SaveAsync( public async System.Threading.Tasks.Task SaveAsync(
string? verifyCommand = null, int? ticketProjectId = null) string? verifyCommand = null, int? ticketProjectId = null, bool? powerUseAllowed = null)
{ {
if (TargetId is null) return; if (TargetId is null) return;
var model = string.IsNullOrWhiteSpace(Model) ? null : Model; var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
@@ -225,7 +227,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
if (_scope == AgentConfigScope.Task) if (_scope == AgentConfigScope.Task)
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission)); await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission));
else else
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId)); await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId, powerUseAllowed));
} }
private List<string>? SelectedSessionSkillNames() private List<string>? SelectedSessionSkillNames()
@@ -96,6 +96,13 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
} }
} }
/// <summary>Raised when the process died non-zero *after* clearing <see cref="StartupGraceWindow"/> —
/// i.e. a session that ran, then broke. That used to be a silent dead tile with no signal at all;
/// the worker turns it into a usage-limit auto-restart for list-handler sessions
/// (<c>HandlerRestartCoordinator</c>). A startup death keeps using <see cref="StartError"/> instead:
/// it never reached a state worth resuming.</summary>
public event Action<int>? ExitedAfterStartup;
/// <summary>Internal (not private) so tests can drive it directly without a real ConPTY spawn.</summary> /// <summary>Internal (not private) so tests can drive it directly without a real ConPTY spawn.</summary>
internal void OnSessionProcessExited(object? sender, int exitCode) internal void OnSessionProcessExited(object? sender, int exitCode)
{ {
@@ -103,11 +110,16 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
HasExited = true; HasExited = true;
ExitCode = exitCode; ExitCode = exitCode;
if (exitCode == 0) return;
if (_startedAtUtc is not { } startedAt) return; // never reached "running" — nothing ran to resume
// Died at startup: route through the same banner as a launch-time failure instead of // Died at startup: route through the same banner as a launch-time failure instead of
// leaving a dead terminal with no Retry affordance (ConPtyPaneViewModel.CanRetry // leaving a dead terminal with no Retry affordance (ConPtyPaneViewModel.CanRetry
// requires StartError). // requires StartError).
if (exitCode != 0 && _startedAtUtc is { } startedAt && _utcNow() - startedAt < StartupGraceWindow) if (_utcNow() - startedAt < StartupGraceWindow)
StartError = $"Session exited immediately (code {exitCode})."; StartError = $"Session exited immediately (code {exitCode}).";
else
ExitedAfterStartup?.Invoke(exitCode);
} }
/// <summary>Reports a failure that happened before <see cref="Start"/> could be called (e.g. the /// <summary>Reports a failure that happened before <see cref="Start"/> could be called (e.g. the
@@ -119,6 +131,11 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
StartError = message; StartError = message;
} }
/// <summary>Types a line into the live session and submits it — see
/// <see cref="PtyTerminalSession.SendLineAsync"/>. False when there is no running process to
/// type into (or the write seam is gone).</summary>
public Task<bool> SendLineAsync(string text) => _session.SendLineAsync(text);
public void Kill() => _session.Kill(); public void Kill() => _session.Kill();
public void Dispose() public void Dispose()
@@ -20,7 +20,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
private readonly INotesApi _notesApi;
private readonly MergeCoordinator _merge; private readonly MergeCoordinator _merge;
// ── Section view models ─────────────────────────────────────────────────── // ── Section view models ───────────────────────────────────────────────────
@@ -39,29 +38,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private readonly Action<string, string?> _workerPlanningMergeAbortedHandler; private readonly Action<string, string?> _workerPlanningMergeAbortedHandler;
private readonly Action<string> _workerPlanningCompletedHandler; private readonly Action<string> _workerPlanningCompletedHandler;
[ObservableProperty] private bool _isNotesMode;
[ObservableProperty] private bool _isPrepMode; [ObservableProperty] private bool _isPrepMode;
public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode; public bool IsTaskDetailVisible => !IsPrepMode;
/// <summary>Centered placeholder shown when no task is selected — hidden in Notes/Prep mode, /// <summary>Centered placeholder shown when no task is selected — hidden in Prep mode,
/// where <see cref="IsTaskDetailVisible"/> is already false.</summary> /// where <see cref="IsTaskDetailVisible"/> is already false.</summary>
public bool IsEmptyStateVisible => Task is null && IsTaskDetailVisible; public bool IsEmptyStateVisible => Task is null && IsTaskDetailVisible;
partial void OnIsNotesModeChanged(bool value)
{
OnPropertyChanged(nameof(IsTaskDetailVisible));
OnPropertyChanged(nameof(IsEmptyStateVisible));
}
partial void OnIsPrepModeChanged(bool value) partial void OnIsPrepModeChanged(bool value)
{ {
OnPropertyChanged(nameof(IsTaskDetailVisible)); OnPropertyChanged(nameof(IsTaskDetailVisible));
OnPropertyChanged(nameof(IsEmptyStateVisible)); OnPropertyChanged(nameof(IsEmptyStateVisible));
} }
public NotesEditorViewModel Notes { get; private set; } = null!;
// Current task row (set by IslandsShellViewModel via Bind) // Current task row (set by IslandsShellViewModel via Bind)
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))] [NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
@@ -295,7 +285,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private bool _suppressTitleSave; private bool _suppressTitleSave;
private CancellationTokenSource? _titleSaveCts; private CancellationTokenSource? _titleSaveCts;
// Set by shell so CloseDetailCommand can clear SelectedTask // Set by shell so deleting a task can clear SelectedTask
public Action? CloseDetail { get; set; } public Action? CloseDetail { get; set; }
// Set by shell so DeleteTaskCommand can remove from list // Set by shell so DeleteTaskCommand can remove from list
@@ -324,13 +314,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
IWorkerClient worker, IWorkerClient worker,
IServiceProvider services, IServiceProvider services,
INotesApi notesApi,
MergeCoordinator merge) MergeCoordinator merge)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker; _worker = worker;
_services = services; _services = services;
_notesApi = notesApi;
_merge = merge; _merge = merge;
Monitor = new TaskMonitorViewModel(dbFactory, worker); Monitor = new TaskMonitorViewModel(dbFactory, worker);
@@ -374,8 +362,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
if (e.PropertyName == nameof(OperationStatus.IsRunning)) ParkReviewCommand.NotifyCanExecuteChanged(); if (e.PropertyName == nameof(OperationStatus.IsRunning)) ParkReviewCommand.NotifyCanExecuteChanged();
}; };
Notes = new NotesEditorViewModel(_notesApi);
Notes.ErrorReported += msg => { if (ShowErrorAsync is not null) _ = ShowErrorAsync(msg); };
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged(); Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count); Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge)); Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge));
@@ -592,18 +578,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
catch { } catch { }
} }
public void ShowNotes()
{
Bind(null, "notes");
IsPrepMode = false;
IsNotesMode = true;
_ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
}
public void ShowPrep() public void ShowPrep()
{ {
Bind(null, "prep"); Bind(null, "prep");
IsNotesMode = false;
IsPrepMode = true; IsPrepMode = true;
_ = Prep.LoadLastPrepLogIfEmptyAsync(); _ = Prep.LoadLastPrepLogIfEmptyAsync();
} }
@@ -612,7 +589,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
/// logged with the bind timing so rebind churn can be attributed to a trigger.</param> /// logged with the bind timing so rebind churn can be attributed to a trigger.</param>
public void Bind(TaskRowViewModel? row, string source = "?") public void Bind(TaskRowViewModel? row, string source = "?")
{ {
IsNotesMode = false;
IsPrepMode = false; IsPrepMode = false;
_loadCts?.Cancel(); _loadCts?.Cancel();
_loadCts?.Dispose(); _loadCts?.Dispose();
@@ -1008,9 +984,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
} }
} }
[RelayCommand]
private void CloseDetails() => CloseDetail?.Invoke();
[RelayCommand] [RelayCommand]
private async System.Threading.Tasks.Task ToggleStarAsync() private async System.Threading.Tasks.Task ToggleStarAsync()
{ {
@@ -24,6 +24,6 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
// Set while a dragged task hovers over this row as a move target — distinct from // Set while a dragged task hovers over this row as a move target — distinct from
// DropHintAbove/Below, which mark an insertion point for list reordering. // DropHintAbove/Below, which mark an insertion point for list reordering.
[ObservableProperty] private bool _isTaskDropTarget; [ObservableProperty] private bool _isTaskDropTarget;
public string? IconKey { get; init; } // Settable (not init) so a list-settings icon change refreshes the row in place.
public string? DotColorKey { get; init; } [ObservableProperty] private string? _iconKey;
} }
@@ -22,6 +22,10 @@ public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> Tas
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{ {
/// <summary>The day-log list. A smart list with no tasks in it: selecting it makes the tasks
/// island show the notes editor instead of rows.</summary>
public const string NotesListId = "smart:notes";
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IServiceProvider? _services; private readonly IServiceProvider? _services;
private readonly IWorkerClient? _worker; private readonly IWorkerClient? _worker;
@@ -54,7 +58,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (row is null || Dialogs is null || _services is null) return; if (row is null || Dialogs is null || _services is null) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id; var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked); await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
await Dialogs.ShowListSettingsAsync(vm); await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync(); if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(row.Id); else await RefreshRowAsync(row.Id);
@@ -255,6 +259,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
"smart:my-day" => "vm.lists.smartMyDay", "smart:my-day" => "vm.lists.smartMyDay",
"smart:important" => "vm.lists.smartImportant", "smart:important" => "vm.lists.smartImportant",
"smart:planned" => "vm.lists.smartPlanned", "smart:planned" => "vm.lists.smartPlanned",
NotesListId => "vm.lists.smartNotes",
"virtual:queued" => "vm.lists.virtualQueue", "virtual:queued" => "vm.lists.virtualQueue",
"virtual:running" => "vm.lists.virtualRunning", "virtual:running" => "vm.lists.virtualRunning",
"virtual:review" => "vm.lists.virtualReview", "virtual:review" => "vm.lists.virtualReview",
@@ -277,6 +282,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var smart = new[] var smart = new[]
{ {
new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" }, new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" },
new ListNavItemViewModel { Id = NotesListId, Name = Loc.T("vm.lists.smartNotes"), Kind = ListKind.Smart, IconKey = "Text" },
new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "Star" }, new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "Star" },
new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "Calendar" }, new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "Calendar" },
new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "Inbox" }, new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "Inbox" },
@@ -293,8 +299,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx); var lists = new ListRepository(ctx);
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" }); var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
var dotColors = new[] { "Moss", "Peat", "Sage" };
int idx = 0;
foreach (var l in await lists.GetAllAsync(ct)) foreach (var l in await lists.GetAllAsync(ct))
{ {
if (seedNames.Contains(l.Name)) continue; if (seedNames.Contains(l.Name)) continue;
@@ -304,8 +308,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Id = $"user:{l.Id}", Id = $"user:{l.Id}",
Name = l.Name, Name = l.Name,
Kind = ListKind.User, Kind = ListKind.User,
IconKey = "Folder", IconKey = l.Icon ?? "Folder",
DotColorKey = dotColors[idx % dotColors.Length],
WorkingDir = l.WorkingDir, WorkingDir = l.WorkingDir,
DefaultCommitType = l.DefaultCommitType, DefaultCommitType = l.DefaultCommitType,
IsManual = l.IsManual, IsManual = l.IsManual,
@@ -314,7 +317,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
}; };
Items.Add(item); Items.Add(item);
UserLists.Add(item); UserLists.Add(item);
idx++;
} }
ok = true; ok = true;
} }
@@ -395,7 +397,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Name = entity.Name, Name = entity.Name,
Kind = ListKind.User, Kind = ListKind.User,
IconKey = "Folder", IconKey = "Folder",
DotColorKey = "Moss",
WorkingDir = entity.WorkingDir, WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType, DefaultCommitType = entity.DefaultCommitType,
}; };
@@ -407,9 +408,17 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (Dialogs is not null && _services is not null) if (Dialogs is not null && _services is not null)
{ {
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked); await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
await Dialogs.ShowListSettingsAsync(vm); await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync(); if (!vm.Saved && !vm.Deleted)
{
// Cancel/close right after "New list" means "never mind" — drop the row we
// pre-created, otherwise an untitled empty list is left behind.
await using var ctx = await _dbFactory.CreateDbContextAsync();
await new ListRepository(ctx).DeleteAsync(entity.Id);
await LoadAsync();
}
else if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(item.Id); else await RefreshRowAsync(item.Id);
} }
} }
@@ -507,6 +516,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
row.DefaultCommitType = entity.DefaultCommitType; row.DefaultCommitType = entity.DefaultCommitType;
row.IsManual = entity.IsManual; row.IsManual = entity.IsManual;
row.FindingsTracked = entity.FindingsTracked; row.FindingsTracked = entity.FindingsTracked;
row.IconKey = entity.Icon ?? "Folder";
row.HasTicketProject = hasTicketProject; row.HasTicketProject = hasTicketProject;
RecomputeHasNoLinkedRepo(); RecomputeHasNoLinkedRepo();
} }
@@ -547,14 +557,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// </summary> /// </summary>
private async Task AddRowAsync(ListRepository lists, ListEntity entity, bool hasTicketProject = false) private async Task AddRowAsync(ListRepository lists, ListEntity entity, bool hasTicketProject = false)
{ {
var dotColors = new[] { "Moss", "Peat", "Sage" };
var item = new ListNavItemViewModel var item = new ListNavItemViewModel
{ {
Id = $"user:{entity.Id}", Id = $"user:{entity.Id}",
Name = entity.Name, Name = entity.Name,
Kind = ListKind.User, Kind = ListKind.User,
IconKey = "Folder", IconKey = entity.Icon ?? "Folder",
DotColorKey = dotColors[UserLists.Count % dotColors.Length],
WorkingDir = entity.WorkingDir, WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType, DefaultCommitType = entity.DefaultCommitType,
IsManual = entity.IsManual, IsManual = entity.IsManual,
@@ -1,25 +1,57 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces; using ClaudeDo.Ui.Services.Interfaces;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Islands; namespace ClaudeDo.Ui.ViewModels.Islands;
/// <summary>Date separator between the note groups. Rows carry these inline (like the task list's
/// HeaderRow) so one ItemsControl renders today and the history without a nested control per day.</summary>
public sealed class NoteDayHeaderViewModel
{
public DateOnly Day { get; }
public string Label { get; }
public bool IsToday { get; }
public NoteDayHeaderViewModel(DateOnly day, DateOnly today)
{
Day = day;
IsToday = day == today;
Label = day == today ? Loc.T("notes.today")
: day == today.AddDays(-1) ? Loc.T("notes.yesterday")
: day.ToString("dddd, dd.MM.");
}
}
public sealed partial class NoteBulletViewModel : ViewModelBase public sealed partial class NoteBulletViewModel : ViewModelBase
{ {
public string Id { get; } /// <summary>Empty until the note is persisted — a row the user started but hasn't typed into
/// yet exists only in the list, so an abandoned Enter leaves nothing behind in the database.</summary>
public string Id { get; set; }
public DateOnly Day { get; }
public bool FromAgent { get; }
public bool IsNew => Id.Length == 0;
[ObservableProperty] private string _text; [ObservableProperty] private string _text;
public NoteBulletViewModel(string id, string text) public NoteBulletViewModel(string id, DateOnly day, string text, bool fromAgent = false)
{ {
Id = id; Id = id;
Day = day;
_text = text; _text = text;
FromAgent = fromAgent;
} }
} }
public sealed partial class NotesEditorViewModel : ViewModelBase public sealed partial class NotesEditorViewModel : ViewModelBase
{ {
/// <summary>How far the history scrolls back. Older notes stay in the database and in the week
/// report; they are simply not worth the round trip in a day log.</summary>
public const int HistoryDays = 14;
private readonly INotesApi _api; private readonly INotesApi _api;
public NotesEditorViewModel(INotesApi api) => _api = api; public NotesEditorViewModel(INotesApi api) => _api = api;
@@ -27,32 +59,58 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
// Raised when a worker call fails so the host VM can surface it (e.g. via ShowErrorAsync). // Raised when a worker call fails so the host VM can surface it (e.g. via ShowErrorAsync).
public event Action<string>? ErrorReported; public event Action<string>? ErrorReported;
public ObservableCollection<NoteBulletViewModel> Bullets { get; } = new(); /// <summary>Raised after a row is inserted so the view can move the caret into it.</summary>
public event Action<NoteBulletViewModel>? FocusRequested;
/// <summary>NoteDayHeaderViewModel | NoteBulletViewModel, newest day first.</summary>
public ObservableCollection<object> Rows { get; } = new();
[ObservableProperty] private DateOnly _currentDay = DateOnly.FromDateTime(DateTime.Today);
[ObservableProperty] private string _newBulletText = ""; [ObservableProperty] private string _newBulletText = "";
[ObservableProperty] private bool _isEmpty = true;
public DateTime CurrentDate public DateOnly Today { get; private set; } = DateOnly.FromDateTime(DateTime.Today);
/// <summary>Loads today plus <see cref="HistoryDays"/> days of history.</summary>
public async Task LoadAsync()
{ {
get => CurrentDay.ToDateTime(TimeOnly.MinValue); Today = DateOnly.FromDateTime(DateTime.Today);
set { var d = DateOnly.FromDateTime(value); if (d != CurrentDay) _ = LoadDayAsync(d); } List<DailyNoteDto> notes;
try
{
notes = await _api.ListBetweenAsync(Today.AddDays(-HistoryDays), Today);
}
catch (Exception ex)
{
ErrorReported?.Invoke(ex.Message);
return;
}
Rows.Clear();
// Today always gets a header, even with nothing under it — it is where typing lands.
Rows.Add(new NoteDayHeaderViewModel(Today, Today));
foreach (var n in notes.Where(n => Day(n) == Today).OrderBy(n => n.SortOrder))
Rows.Add(Bullet(n));
foreach (var group in notes.Where(n => Day(n) != Today)
.GroupBy(Day).OrderByDescending(g => g.Key))
{
Rows.Add(new NoteDayHeaderViewModel(group.Key, Today));
foreach (var n in group.OrderBy(n => n.SortOrder))
Rows.Add(Bullet(n));
}
RefreshEmpty();
} }
public string CurrentDayLabel => CurrentDay.ToString("dddd, dd.MM.yyyy"); private static DateOnly Day(DailyNoteDto n) => DateOnly.Parse(n.Date);
public async Task LoadDayAsync(DateOnly day) private static NoteBulletViewModel Bullet(DailyNoteDto n) =>
{ new(n.Id, Day(n), n.Text, n.FromAgent);
CurrentDay = day;
OnPropertyChanged(nameof(CurrentDate));
OnPropertyChanged(nameof(CurrentDayLabel));
Bullets.Clear();
foreach (var dto in await _api.ListAsync(day))
Bullets.Add(MakeBullet(dto.Id, dto.Text));
}
private NoteBulletViewModel MakeBullet(string id, string text) => private void RefreshEmpty() => IsEmpty = !Rows.OfType<NoteBulletViewModel>().Any();
new(id, text);
/// <summary>The box above the list: Enter files the note under today and keeps the box focused,
/// so a burst of notes is one uninterrupted stream of typing.</summary>
[RelayCommand] [RelayCommand]
private async Task AddBullet() private async Task AddBullet()
{ {
@@ -60,9 +118,10 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
if (text.Length == 0) return; if (text.Length == 0) return;
try try
{ {
var dto = await _api.AddAsync(CurrentDay, text); var dto = await _api.AddAsync(Today, text);
if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text)); if (dto is not null) Rows.Insert(1, Bullet(dto));
NewBulletText = ""; NewBulletText = "";
RefreshEmpty();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -70,21 +129,68 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
} }
} }
[RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1)); /// <summary>Enter inside a note: save it, then open an empty row underneath and focus it.</summary>
[RelayCommand] private Task NextDay() => LoadDayAsync(CurrentDay.AddDays(1)); [RelayCommand]
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today)); private async Task SplitBullet(NoteBulletViewModel? b)
{
if (b is null) return;
await CommitBullet(b);
if (!Rows.Contains(b)) return; // committing an emptied note removed it
var fresh = new NoteBulletViewModel("", b.Day, "");
Rows.Insert(Rows.IndexOf(b) + 1, fresh);
FocusRequested?.Invoke(fresh);
}
/// <summary>Backspace in an already-empty note: drop it and put the caret in the one above.</summary>
[RelayCommand]
private async Task DeleteBullet(NoteBulletViewModel? b)
{
if (b is null) return;
var index = Rows.IndexOf(b);
await RemoveAsync(b);
for (var i = index - 1; i >= 0; i--)
if (Rows[i] is NoteBulletViewModel previous)
{
FocusRequested?.Invoke(previous);
return;
}
}
[RelayCommand] [RelayCommand]
private async Task CommitBullet(NoteBulletViewModel? b) private async Task CommitBullet(NoteBulletViewModel? b)
{ {
if (b is null) return; if (b is null) return;
var text = b.Text?.Trim() ?? ""; var text = b.Text?.Trim() ?? "";
if (text.Length == 0) try
{ {
await _api.DeleteAsync(b.Id); if (text.Length == 0)
Bullets.Remove(b); {
return; await RemoveAsync(b);
return;
}
if (b.IsNew)
{
var dto = await _api.AddAsync(b.Day, text);
if (dto is not null) b.Id = dto.Id;
}
else
{
await _api.UpdateAsync(b.Id, text);
}
} }
await _api.UpdateAsync(b.Id, text); catch (Exception ex)
{
ErrorReported?.Invoke(ex.Message);
}
}
private async Task RemoveAsync(NoteBulletViewModel b)
{
if (!b.IsNew) await _api.DeleteAsync(b.Id);
Rows.Remove(b);
RefreshEmpty();
} }
} }
@@ -8,67 +8,213 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TaskRowViewModel : ViewModelBase public sealed partial class TaskRowViewModel : ViewModelBase
{ {
public required string Id { get; init; } public required string Id { get; init; }
[ObservableProperty] private int _number; [ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowNumberBadge))]
private int _number;
[ObservableProperty] private string _title = ""; [ObservableProperty] private string _title = "";
[ObservableProperty] private string _listName = ""; [ObservableProperty] private string _listName = "";
[ObservableProperty] private bool _done; [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsOverdue))]
[NotifyPropertyChangedFor(nameof(CanAddToMyDay))]
private bool _done;
[ObservableProperty] private bool _isStarred; [ObservableProperty] private bool _isStarred;
[ObservableProperty] private bool _isMyDay; [ObservableProperty]
[ObservableProperty] private bool _isSelected; [NotifyPropertyChangedFor(nameof(CanAddToMyDay))]
[ObservableProperty] private TaskStatus _status; private bool _isMyDay;
[ObservableProperty] private PlanningPhase _planningPhase; [ObservableProperty]
[ObservableProperty] private string? _branch; [NotifyPropertyChangedFor(nameof(ShowRowActions))]
private bool _isSelected;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
[NotifyPropertyChangedFor(nameof(StatusLabel))]
[NotifyPropertyChangedFor(nameof(ShowStatusChip))]
[NotifyPropertyChangedFor(nameof(IsRunning))]
[NotifyPropertyChangedFor(nameof(IsWaitingForReview))]
[NotifyPropertyChangedFor(nameof(IsParked))]
[NotifyPropertyChangedFor(nameof(IsQueued))]
[NotifyPropertyChangedFor(nameof(IsWaiting))]
[NotifyPropertyChangedFor(nameof(HasFailureReason))]
[NotifyPropertyChangedFor(nameof(FailureReasonTooltip))]
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
[NotifyPropertyChangedFor(nameof(IsDraft))]
[NotifyPropertyChangedFor(nameof(IsPlanned))]
[NotifyPropertyChangedFor(nameof(CanOpenPlanningSession))]
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
[NotifyPropertyChangedFor(nameof(CanRefine))]
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
[NotifyPropertyChangedFor(nameof(CancelDisabledReason))]
[NotifyPropertyChangedFor(nameof(RefineDisabledReason))]
[NotifyPropertyChangedFor(nameof(PlanningDisabledReason))]
[NotifyPropertyChangedFor(nameof(MarkAsDisabledReason))]
private TaskStatus _status;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsPlanningParent))]
[NotifyPropertyChangedFor(nameof(PlanningBadge))]
[NotifyPropertyChangedFor(nameof(ShowStatusChip))]
[NotifyPropertyChangedFor(nameof(IsPlanActive))]
[NotifyPropertyChangedFor(nameof(IsPlanFinalized))]
[NotifyPropertyChangedFor(nameof(CanOpenPlanningSession))]
[NotifyPropertyChangedFor(nameof(CanResumeOrDiscardPlanning))]
[NotifyPropertyChangedFor(nameof(CanQueuePlan))]
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
[NotifyPropertyChangedFor(nameof(CanFinalizePlanning))]
[NotifyPropertyChangedFor(nameof(CanRefine))]
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
[NotifyPropertyChangedFor(nameof(RefineDisabledReason))]
[NotifyPropertyChangedFor(nameof(PlanningDisabledReason))]
private PlanningPhase _planningPhase;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasBranch))]
private string? _branch;
[ObservableProperty] private string? _diffStat; [ObservableProperty] private string? _diffStat;
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState? _worktreeState; [ObservableProperty]
[ObservableProperty] private string? _worktreePath; [NotifyPropertyChangedFor(nameof(IsParked))]
[ObservableProperty] private DateTime? _scheduledFor; [NotifyPropertyChangedFor(nameof(StatusLabel))]
[ObservableProperty] private int _diffAdditions; private ClaudeDo.Data.Models.WorktreeState? _worktreeState;
[ObservableProperty] private int _diffDeletions; [ObservableProperty]
[ObservableProperty] private bool _dropHintAbove; [NotifyPropertyChangedFor(nameof(CanOpenWorktree))]
[ObservableProperty] private bool _dropHintBelow; [NotifyPropertyChangedFor(nameof(OpenWorktreeDisabledReason))]
[ObservableProperty] private string? _parentTaskId; private string? _worktreePath;
[ObservableProperty] private string? _createdBy; [ObservableProperty]
[ObservableProperty] private string? _blockedByTaskId; [NotifyPropertyChangedFor(nameof(IsOverdue))]
[ObservableProperty] private string? _dependsOnTaskId; [NotifyPropertyChangedFor(nameof(HasSchedule))]
private DateTime? _scheduledFor;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasDiff))]
[NotifyPropertyChangedFor(nameof(DiffAdditionsText))]
private int _diffAdditions;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasDiff))]
[NotifyPropertyChangedFor(nameof(DiffDeletionsText))]
private int _diffDeletions;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDropTarget))]
private bool _dropHintAbove;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDropTarget))]
private bool _dropHintBelow;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsChild))]
[NotifyPropertyChangedFor(nameof(IsAgentSuggested))]
[NotifyPropertyChangedFor(nameof(ShowAsChild))]
[NotifyPropertyChangedFor(nameof(IsDraft))]
[NotifyPropertyChangedFor(nameof(IsPlanned))]
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
[NotifyPropertyChangedFor(nameof(CanOpenPlanningSession))]
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
[NotifyPropertyChangedFor(nameof(PlanningDisabledReason))]
private string? _parentTaskId;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAgentSuggested))]
private string? _createdBy;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsQueued))]
[NotifyPropertyChangedFor(nameof(IsWaiting))]
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
private string? _blockedByTaskId;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DependsOnTaskId))]
private IReadOnlyList<string> _dependsOnTaskIds = [];
// The single predecessor, or null when the task has none or several. The chain rail can only
// draw a line, so a task waiting on n predecessors gets a ChainAfterLabel instead — see
// TasksIslandViewModel.ApplyChainGrouping.
public string? DependsOnTaskId => DependsOnTaskIds.Count == 1 ? DependsOnTaskIds[0] : null;
[ObservableProperty] private bool _isExpanded = true; [ObservableProperty] private bool _isExpanded = true;
[ObservableProperty] private bool _hasPlanningChildren; [ObservableProperty]
[ObservableProperty] private bool _hasQueuedSubtasks; [NotifyPropertyChangedFor(nameof(IsPlanningParent))]
[NotifyPropertyChangedFor(nameof(CanQueuePlan))]
private bool _hasPlanningChildren;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
[NotifyPropertyChangedFor(nameof(CanQueuePlan))]
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
private bool _hasQueuedSubtasks;
[ObservableProperty] private bool _showListChip = true; [ObservableProperty] private bool _showListChip = true;
[ObservableProperty] private bool _parentFinalized; [ObservableProperty]
[ObservableProperty] private bool _parentInView = true; [NotifyPropertyChangedFor(nameof(IsDraft))]
[NotifyPropertyChangedFor(nameof(IsPlanned))]
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
private bool _parentFinalized;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowAsChild))]
[NotifyPropertyChangedFor(nameof(IsDraft))]
[NotifyPropertyChangedFor(nameof(IsPlanned))]
private bool _parentInView = true;
// The three below are extrinsic — computed and assigned by TasksIslandViewModel.Regroup // The three below are extrinsic — computed and assigned by TasksIslandViewModel.Regroup
// (it alone has the cross-row graph needed to walk DependsOnTaskId chains), mirroring how // (it alone has the cross-row graph needed to walk DependsOnTaskId chains), mirroring how
// ParentInView is assigned by the same pass rather than derived locally. // ParentInView is assigned by the same pass rather than derived locally.
[ObservableProperty] private bool _showAsChainMember; [ObservableProperty] private bool _showAsChainMember;
[ObservableProperty] private int? _chainStep; [ObservableProperty] private int? _chainStep;
[ObservableProperty] private string? _chainAfterLabel; [ObservableProperty] private string? _chainAfterLabel;
[ObservableProperty] private int _roadblockCount; [ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasRoadblock))]
[NotifyPropertyChangedFor(nameof(RoadblockTooltip))]
private int _roadblockCount;
// Only meaningful when Status=Failed; stamped by TaskRunner.MarkFailed. "unknown" for a // Only meaningful when Status=Failed; stamped by TaskRunner.MarkFailed. "unknown" for a
// Failed task that predates this field. Null on every other status. // Failed task that predates this field. Null on every other status.
[ObservableProperty] private string? _failureReason; [ObservableProperty]
[ObservableProperty] private int? _failureTurnsUsed; [NotifyPropertyChangedFor(nameof(HasFailureReason))]
[ObservableProperty] private int? _failureMaxTurns; [NotifyPropertyChangedFor(nameof(FailureReasonLabel))]
[ObservableProperty] private bool _isRefining; [NotifyPropertyChangedFor(nameof(FailureReasonTooltip))]
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
private string? _failureReason;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FailureReasonTooltip))]
private int? _failureTurnsUsed;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FailureReasonTooltip))]
private int? _failureMaxTurns;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanRefine))]
[NotifyPropertyChangedFor(nameof(RefineDisabledReason))]
private bool _isRefining;
// Manual = a reminder only the user can do. Every "hand this to Claude" affordance is hidden // Manual = a reminder only the user can do. Every "hand this to Claude" affordance is hidden
// and automation skips it; opening a hand-driven ConPTY session stays allowed. // and automation skips it; opening a hand-driven ConPTY session stays allowed.
[ObservableProperty] private bool _isManual; [ObservableProperty]
[NotifyPropertyChangedFor(nameof(ManualBadge))]
[NotifyPropertyChangedFor(nameof(CanRefine))]
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
[NotifyPropertyChangedFor(nameof(CanOpenPlanningSession))]
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
[NotifyPropertyChangedFor(nameof(RefineDisabledReason))]
[NotifyPropertyChangedFor(nameof(PlanningDisabledReason))]
private bool _isManual;
// Set by the shell from Mission Control's open ConPTY panes: this task has a live hand-driven // Set by the shell from Mission Control's open ConPTY panes: this task has a live hand-driven
// session, which outranks the persisted status on the lifecycle chip (an interactive task is // session, which outranks the persisted status on the lifecycle chip (an interactive task is
// typically Idle+Active-worktree, i.e. would otherwise read "Parked"). // typically Idle+Active-worktree, i.e. would otherwise read "Parked").
[ObservableProperty] private bool _hasInteractiveSession; [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsParked))]
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
[NotifyPropertyChangedFor(nameof(StatusLabel))]
[NotifyPropertyChangedFor(nameof(ShowStatusChip))]
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
[NotifyPropertyChangedFor(nameof(CanSendToQueue))]
[NotifyPropertyChangedFor(nameof(SendToQueueDisabledReason))]
[NotifyPropertyChangedFor(nameof(CanDeleteTask))]
[NotifyPropertyChangedFor(nameof(DeleteDisabledReason))]
private bool _hasInteractiveSession;
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style. // Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
[ObservableProperty] private bool _isDragging; [ObservableProperty] private bool _isDragging;
// Set from PointerEntered/PointerExited in TaskRowView.axaml.cs. Drives ShowRowActions // Set from PointerEntered/PointerExited in TaskRowView.axaml.cs. Drives ShowRowActions
// together with IsSelected, so hover-only affordances (refine button, unset star, dequeue-X) // together with IsSelected, so hover-only affordances (refine button, unset star, dequeue-X)
// stay reachable via keyboard/selection, not just mouse hover. // stay reachable via keyboard/selection, not just mouse hover.
[ObservableProperty] private bool _isHovered; [ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowRowActions))]
private bool _isHovered;
// Transient: set from HubBroadcaster's OperationProgress while the worker is still creating // Transient: set from HubBroadcaster's OperationProgress while the worker is still creating
// this task's worktree (the silent gap between Queued and the first agent output). Cleared // this task's worktree (the silent gap between Queued and the first agent output). Cleared
// by the next entity refresh — UpdateFromEntity always reflects a settled state, so there's // by the next entity refresh — UpdateFromEntity always reflects a settled state, so there's
// nothing left to show past that point. // nothing left to show past that point.
[ObservableProperty] private string? _creationPhase; [ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasCreationPhase))]
[NotifyPropertyChangedFor(nameof(CreationPhaseLabel))]
private string? _creationPhase;
// True while a drag is hovering this row (i.e. it would show a drop-hint gap). Used to // True while a drag is hovering this row (i.e. it would show a drop-hint gap). Used to
// suppress the ordinary hover highlight/transitions so they don't fight the hint. // suppress the ordinary hover highlight/transitions so they don't fight the hint.
@@ -309,179 +455,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
_ => "idle", _ => "idle",
}; };
partial void OnStatusChanged(TaskStatus value)
{
OnPropertyChanged(nameof(StatusChipClass));
OnPropertyChanged(nameof(StatusLabel));
OnPropertyChanged(nameof(ShowStatusChip));
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsWaitingForReview));
OnPropertyChanged(nameof(IsParked));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsWaiting));
OnPropertyChanged(nameof(HasFailureReason));
OnPropertyChanged(nameof(FailureReasonTooltip));
OnPropertyChanged(nameof(StatusChipTooltip));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanRefine));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
OnPropertyChanged(nameof(CancelDisabledReason));
OnPropertyChanged(nameof(RefineDisabledReason));
OnPropertyChanged(nameof(PlanningDisabledReason));
OnPropertyChanged(nameof(MarkAsDisabledReason));
}
partial void OnParentTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsChild));
OnPropertyChanged(nameof(IsAgentSuggested));
OnPropertyChanged(nameof(ShowAsChild));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
OnPropertyChanged(nameof(PlanningDisabledReason));
}
partial void OnParentInViewChanged(bool value)
{
OnPropertyChanged(nameof(ShowAsChild));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
}
partial void OnCreatedByChanged(string? value) => OnPropertyChanged(nameof(IsAgentSuggested));
partial void OnParentFinalizedChanged(bool value)
{
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
}
partial void OnPlanningPhaseChanged(PlanningPhase value)
{
OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(PlanningBadge));
OnPropertyChanged(nameof(ShowStatusChip));
OnPropertyChanged(nameof(IsPlanActive));
OnPropertyChanged(nameof(IsPlanFinalized));
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
OnPropertyChanged(nameof(CanQueuePlan));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanFinalizePlanning));
OnPropertyChanged(nameof(CanRefine));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
OnPropertyChanged(nameof(RefineDisabledReason));
OnPropertyChanged(nameof(PlanningDisabledReason));
}
partial void OnIsRefiningChanged(bool value)
{
OnPropertyChanged(nameof(CanRefine));
OnPropertyChanged(nameof(RefineDisabledReason));
}
partial void OnIsManualChanged(bool value)
{
OnPropertyChanged(nameof(ManualBadge));
OnPropertyChanged(nameof(CanRefine));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
OnPropertyChanged(nameof(RefineDisabledReason));
OnPropertyChanged(nameof(PlanningDisabledReason));
}
partial void OnHasInteractiveSessionChanged(bool value)
{
OnPropertyChanged(nameof(IsParked));
OnPropertyChanged(nameof(StatusChipClass));
OnPropertyChanged(nameof(StatusLabel));
OnPropertyChanged(nameof(ShowStatusChip));
OnPropertyChanged(nameof(StatusChipTooltip));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
OnPropertyChanged(nameof(CanDeleteTask));
OnPropertyChanged(nameof(DeleteDisabledReason));
}
partial void OnFailureReasonChanged(string? value)
{
OnPropertyChanged(nameof(HasFailureReason));
OnPropertyChanged(nameof(FailureReasonLabel));
OnPropertyChanged(nameof(FailureReasonTooltip));
OnPropertyChanged(nameof(StatusChipTooltip));
}
partial void OnFailureTurnsUsedChanged(int? value) => OnPropertyChanged(nameof(FailureReasonTooltip));
partial void OnFailureMaxTurnsChanged(int? value) => OnPropertyChanged(nameof(FailureReasonTooltip));
partial void OnHasQueuedSubtasksChanged(bool value)
{
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanQueuePlan));
OnPropertyChanged(nameof(SendToQueueDisabledReason));
}
partial void OnIsHoveredChanged(bool value) => OnPropertyChanged(nameof(ShowRowActions));
partial void OnIsSelectedChanged(bool value) => OnPropertyChanged(nameof(ShowRowActions));
partial void OnBlockedByTaskIdChanged(string? value)
{
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsWaiting));
OnPropertyChanged(nameof(StatusChipClass));
}
partial void OnHasPlanningChildrenChanged(bool value)
{
OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(CanQueuePlan));
}
partial void OnNumberChanged(int value) => OnPropertyChanged(nameof(ShowNumberBadge));
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
partial void OnWorktreePathChanged(string? value)
{
OnPropertyChanged(nameof(CanOpenWorktree));
OnPropertyChanged(nameof(OpenWorktreeDisabledReason));
}
partial void OnWorktreeStateChanged(ClaudeDo.Data.Models.WorktreeState? value)
{
OnPropertyChanged(nameof(IsParked));
OnPropertyChanged(nameof(StatusLabel));
}
partial void OnDoneChanged(bool value)
{
OnPropertyChanged(nameof(IsOverdue));
OnPropertyChanged(nameof(CanAddToMyDay));
}
partial void OnIsMyDayChanged(bool value) => OnPropertyChanged(nameof(CanAddToMyDay));
partial void OnScheduledForChanged(DateTime? value)
{
OnPropertyChanged(nameof(IsOverdue));
OnPropertyChanged(nameof(HasSchedule));
}
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
partial void OnRoadblockCountChanged(int value) { OnPropertyChanged(nameof(HasRoadblock)); OnPropertyChanged(nameof(RoadblockTooltip)); }
partial void OnDropHintAboveChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget));
partial void OnDropHintBelowChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget));
partial void OnCreationPhaseChanged(string? value)
{
OnPropertyChanged(nameof(HasCreationPhase));
OnPropertyChanged(nameof(CreationPhaseLabel));
}
public void RefreshLocalized() public void RefreshLocalized()
{ {
OnPropertyChanged(nameof(StatusLabel)); OnPropertyChanged(nameof(StatusLabel));
@@ -522,7 +495,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
ParentTaskId = t.ParentTaskId; ParentTaskId = t.ParentTaskId;
CreatedBy = t.CreatedBy; CreatedBy = t.CreatedBy;
BlockedByTaskId = t.BlockedByTaskId; BlockedByTaskId = t.BlockedByTaskId;
DependsOnTaskId = t.DependsOnTaskId; // Guarded: TaskEntity.DependsOnTaskIds projects a fresh List every read, so a plain
// assignment would fire PropertyChanged on every reconcile tick even when nothing moved.
var deps = t.DependsOnTaskIds;
if (!DependsOnTaskIds.SequenceEqual(deps)) DependsOnTaskIds = deps;
RoadblockCount = t.RoadblockCount; RoadblockCount = t.RoadblockCount;
FailureReason = t.FailureReason; FailureReason = t.FailureReason;
FailureTurnsUsed = t.FailureTurnsUsed; FailureTurnsUsed = t.FailureTurnsUsed;
@@ -9,6 +9,7 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -62,18 +63,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
public event EventHandler? FocusAddTaskRequested; public event EventHandler? FocusAddTaskRequested;
public event EventHandler? TasksChanged; public event EventHandler? TasksChanged;
public event Action? NotesRequested;
public event Action? PrepRequested; public event Action? PrepRequested;
public event Action<string>? ErrorReported; public event Action<string>? ErrorReported;
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty); public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
[RelayCommand]
private void OpenNotes()
{
SelectFrom(null, "notes");
NotesRequested?.Invoke();
}
[RelayCommand] [RelayCommand]
private void ShowPrepLog() => PrepRequested?.Invoke(); private void ShowPrepLog() => PrepRequested?.Invoke();
@@ -118,8 +111,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private bool _hasCompleted; private bool _hasCompleted;
[ObservableProperty] private bool _showOpenLabel; [ObservableProperty] private bool _showOpenLabel;
[ObservableProperty] private string _completedHeader = ""; [ObservableProperty] private string _completedHeader = "";
[ObservableProperty] private bool _showNotesRow;
[ObservableProperty] private bool _isMyDayList; [ObservableProperty] private bool _isMyDayList;
/// <summary>The notes list is content, not a task list — the island swaps its rows for the
/// notes editor instead of showing an empty task list.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))]
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
private bool _isNotesList;
/// <summary>Null only in tests that construct the island without a notes API.</summary>
public NotesEditorViewModel? Notes { get; }
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))] [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
private bool _isLetClaudeVisible; private bool _isLetClaudeVisible;
@@ -128,7 +130,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
/// <summary>No visible tasks below the add-task row — every item lands in one of /// <summary>No visible tasks below the add-task row — every item lands in one of
/// Overdue/Open/Completed, so all-false here always means the list has zero items.</summary> /// Overdue/Open/Completed, so all-false here always means the list has zero items.</summary>
public bool IsTasksEmptyHintVisible => !HasOverdue && !HasOpen && !HasCompleted; public bool IsTasksEmptyHintVisible => !HasOverdue && !HasOpen && !HasCompleted && !IsNotesList;
/// <summary>Extra empty-state line for a User list with no linked working dir, where the /// <summary>Extra empty-state line for a User list with no linked working dir, where the
/// execution features (queue, Let Claude handle it, Quick session) are unavailable. Derived /// execution features (queue, Let Claude handle it, Quick session) are unavailable. Derived
@@ -182,10 +184,20 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private readonly EventHandler _langChangedHandler; private readonly EventHandler _langChangedHandler;
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null) public TasksIslandViewModel(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IWorkerClient? worker = null,
INotesApi? notesApi = null)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker; _worker = worker;
if (notesApi is not null)
{
Notes = new NotesEditorViewModel(notesApi);
Notes.ErrorReported += msg => ErrorReported?.Invoke(msg);
if (worker is not null)
worker.NotesUpdatedEvent += () => { if (IsNotesList) _ = Notes.LoadAsync(); };
}
_completedHeaderRow = new() { ActionCommand = ClearCompletedCommand }; _completedHeaderRow = new() { ActionCommand = ClearCompletedCommand };
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader"); CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
if (_worker is not null) if (_worker is not null)
@@ -301,6 +313,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
var entity = await db.Tasks var entity = await db.Tasks
.Include(t => t.List) .Include(t => t.List)
.Include(t => t.Worktree) .Include(t => t.Worktree)
.Include(t => t.Dependencies)
.FirstOrDefaultAsync(t => t.Id == taskId); .FirstOrDefaultAsync(t => t.Id == taskId);
// A newer refresh for this task started while we were reading — its result is fresher. // A newer refresh for this task started while we were reading — its result is fresher.
@@ -417,14 +430,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
HasOpen = false; HasOpen = false;
HasCompleted = false; HasCompleted = false;
ShowOpenLabel = false; ShowOpenLabel = false;
ShowNotesRow = false;
if (listChanged) SelectFrom(null, "list-change"); if (listChanged) SelectFrom(null, "list-change");
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; IsMergeReviewsVisible = false; LoadTask = Task.CompletedTask; return; } if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; IsMergeReviewsVisible = false; LoadTask = Task.CompletedTask; return; }
HeaderTitle = list.Name; HeaderTitle = list.Name;
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant(); HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
ShowNotesRow = list.Id == "smart:my-day";
IsMyDayList = list.Id == "smart:my-day"; IsMyDayList = list.Id == "smart:my-day";
IsNotesList = list.Id == ListsIslandViewModel.NotesListId;
if (IsNotesList && Notes is not null) _ = Notes.LoadAsync();
IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir); IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
IsQuickClaudeVisible = IsLetClaudeVisible; IsQuickClaudeVisible = IsLetClaudeVisible;
IsMergeReviewsVisible = list.Id == "virtual:review"; IsMergeReviewsVisible = list.Id == "virtual:review";
@@ -441,7 +454,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{ {
await using var db = await _dbFactory.CreateDbContextAsync(ct); await using var db = await _dbFactory.CreateDbContextAsync(ct);
var filter = _filters.Resolve(list.Id); var filter = _filters.Resolve(list.Id);
var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree); var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree).Include(t => t.Dependencies);
var filteredList = filter is null var filteredList = filter is null
? new List<TaskEntity>() ? new List<TaskEntity>()
@@ -710,12 +723,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
} }
// Content only — "after " + localization is Slice 2's concern (the view), which is why this // Content only — "after " + localization is Slice 2's concern (the view), which is why this
// holds just the identifying fragment rather than an assembled sentence. // holds just the identifying fragment rather than an assembled sentence. Names every
// predecessor it can resolve: a task can wait on n of them, and hiding all but the first
// would misreport what the row is actually waiting for.
private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById) private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
{ {
if (string.IsNullOrEmpty(row.DependsOnTaskId)) return null; var parts = row.DependsOnTaskIds
if (!rowsById.TryGetValue(row.DependsOnTaskId, out var predecessor)) return null; .Select(id => rowsById.TryGetValue(id, out var p) ? p : null)
return predecessor.Number > 0 ? $"#{predecessor.Number}" : predecessor.Title; .Where(p => p is not null)
.Select(p => p!.Number > 0 ? $"#{p.Number}" : p.Title)
.ToList();
return parts.Count == 0 ? null : string.Join(", ", parts);
} }
// Assigns ShowAsChainMember/ChainStep/ChainAfterLabel for every row in `section`, then // Assigns ShowAsChainMember/ChainStep/ChainAfterLabel for every row in `section`, then
@@ -731,7 +749,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
foreach (var r in section) foreach (var r in section)
{ {
if (string.IsNullOrEmpty(r.DependsOnTaskId)) if (r.DependsOnTaskIds.Count == 0)
{ {
r.ShowAsChainMember = false; r.ShowAsChainMember = false;
r.ChainStep = null; r.ChainStep = null;
@@ -739,6 +757,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
continue; continue;
} }
// Several predecessors aren't a chain — the rail draws one line, so a fan-in row
// only ever gets the label listing everything it waits on.
if (r.DependsOnTaskId is null)
{
r.ShowAsChainMember = false;
r.ChainStep = null;
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
continue;
}
// A planning child keeps its parent indent — chain membership only ever shows as a // A planning child keeps its parent indent — chain membership only ever shows as a
// label for it, never as a second, nested rail (design: "parent wins"). // label for it, never as a second, nested rail (design: "parent wins").
if (r.IsChild) if (r.IsChild)
@@ -794,13 +822,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
var now = DateTime.Now; var now = DateTime.Now;
var open = Items.Count(i => !i.Done); var open = Items.Count(i => !i.Done);
var running = Items.Count(i => i.Status == TaskStatus.Running); var running = Items.Count(i => i.Status == TaskStatus.Running);
var review = Items.Count(i => i.Status == TaskStatus.Done && i.Branch != null); var review = Items.Count(i => i.Status == TaskStatus.WaitingForReview);
Subtitle = open == 1 ? "1 open task" : $"{open} open tasks"; // The notes list holds no tasks — an "0 open tasks" line under its header is just noise.
Subtitle = IsNotesList ? ""
: open == 1 ? Loc.T("vm.tasksIsland.openTaskOne")
: Loc.T("vm.tasksIsland.openTasks", open);
if (running > 0 || review > 0) if (running > 0 || review > 0)
{ {
StatusPill = $"{running} running · {review} review"; StatusPill = Loc.T("vm.tasksIsland.statusPill", running, review);
HasStatusPill = true; HasStatusPill = true;
} }
else else
@@ -1669,6 +1700,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
entities = await db.Tasks entities = await db.Tasks
.Include(t => t.List) .Include(t => t.List)
.Include(t => t.Worktree) .Include(t => t.Worktree)
.Include(t => t.Dependencies)
.Where(t => idSet.Contains(t.Id)) .Where(t => idSet.Contains(t.Id))
.ToListAsync(ct); .ToListAsync(ct);
dbOk = true; dbOk = true;
@@ -1713,6 +1745,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// The slice of a row that Regroup reads: which section it lands in, where it sits inside it, // The slice of a row that Regroup reads: which section it lands in, where it sits inside it,
// and what its chain rail/after-chip says. Only fields UpdateFromEntity actually writes — // and what its chain rail/after-chip says. Only fields UpdateFromEntity actually writes —
// IsExpanded and HasPlanningChildren are owned elsewhere and would produce false positives. // IsExpanded and HasPlanningChildren are owned elsewhere and would produce false positives.
private static (bool, DateTime?, string?, string?, PlanningPhase, int, string) GroupingKey(TaskRowViewModel r) => private static (bool, DateTime?, string?, string, PlanningPhase, int, string) GroupingKey(TaskRowViewModel r) =>
(r.Done, r.ScheduledFor, r.ParentTaskId, r.DependsOnTaskId, r.PlanningPhase, r.Number, r.Title); (r.Done, r.ScheduledFor, r.ParentTaskId, string.Join(",", r.DependsOnTaskIds),
r.PlanningPhase, r.Number, r.Title);
} }
@@ -5,7 +5,6 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
@@ -52,7 +51,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private readonly UpdateCheckService _updateCheck = null!; private readonly UpdateCheckService _updateCheck = null!;
private readonly InstallerLocator _installerLocator = null!; private readonly InstallerLocator _installerLocator = null!;
private readonly WorkerLocator _workerLocator = null!; private readonly WorkerLocator _workerLocator = null!;
private readonly GitService? _git;
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!; private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!; private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
@@ -121,11 +119,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string? _updateBannerLatestVersion; [ObservableProperty] private string? _updateBannerLatestVersion;
private bool _bannerDismissedThisSession; private bool _bannerDismissedThisSession;
// Persistent (not auto-clearing) banner: the running worker predates the selected list's
// merged HEAD, so "verified" claims made against the current process are stale. ClaudeDo-repo
// only — see RefreshStaleWorkerCheckAsync.
[ObservableProperty] private bool _isStaleWorkerBannerVisible;
// planningTaskId -> subtaskId, for unit-merge conflicts an MCP session (not the UI) started. // planningTaskId -> subtaskId, for unit-merge conflicts an MCP session (not the UI) started.
// Kept as a dictionary so a later conflict on the same planning task updates in place instead // Kept as a dictionary so a later conflict on the same planning task updates in place instead
// of piling up duplicate entries. // of piling up duplicate entries.
@@ -145,7 +138,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
[ObservableProperty] [ObservableProperty]
private bool _isWorkerLogVisible; private bool _isWorkerLogVisible;
public bool ShowDetails => WindowWidth >= 1100; /// <summary>The notes list has nothing to show a detail pane for, so the pane goes away and
/// the editor takes the space instead of leaving an empty column beside it.</summary>
public bool ShowDetails => WindowWidth >= 1100 && Tasks?.IsNotesList != true;
/// <summary>Widens the tasks island over the splitter and the details column when that column
/// is hidden — a fixed-width ColumnDefinition keeps its space even with an invisible child.</summary>
public int TasksColumnSpan => ShowDetails ? 1 : 3;
public bool ShowLists => WindowWidth >= 780; public bool ShowLists => WindowWidth >= 780;
private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false }; private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false };
@@ -168,10 +168,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
partial void OnWindowWidthChanged(double value) partial void OnWindowWidthChanged(double value)
{ {
OnPropertyChanged(nameof(ShowDetails)); NotifyDetailsVisibility();
OnPropertyChanged(nameof(ShowLists)); OnPropertyChanged(nameof(ShowLists));
} }
private void NotifyDetailsVisibility()
{
OnPropertyChanged(nameof(ShowDetails));
OnPropertyChanged(nameof(TasksColumnSpan));
}
public void OnWorkerLogReceived(WorkerLogEntry entry) public void OnWorkerLogReceived(WorkerLogEntry entry)
{ {
var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm"); var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
@@ -315,8 +321,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Func<MergeModalViewModel> mergeVmFactory, Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory, Func<RepoImportModalViewModel> repoImportVmFactory,
MissionControlViewModel missionControl, MissionControlViewModel missionControl,
UsagePillViewModel usagePill, UsagePillViewModel usagePill)
GitService? git = null)
{ {
Lists = lists; Tasks = tasks; Details = details; Worker = worker; Lists = lists; Tasks = tasks; Details = details; Worker = worker;
MissionControl = missionControl; MissionControl = missionControl;
@@ -336,12 +341,13 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_usageMonitorVmFactory = usageMonitorVmFactory; _usageMonitorVmFactory = usageMonitorVmFactory;
_mergeVmFactory = mergeVmFactory; _mergeVmFactory = mergeVmFactory;
_repoImportVmFactory = repoImportVmFactory; _repoImportVmFactory = repoImportVmFactory;
_git = git;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource);
Tasks.NotesRequested += () => Details.ShowNotes();
Tasks.PrepRequested += () => Details.ShowPrep(); Tasks.PrepRequested += () => Details.ShowPrep();
Tasks.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(TasksIslandViewModel.IsNotesList)) NotifyDetailsVisibility();
};
Tasks.ErrorReported += FlashFooterError; Tasks.ErrorReported += FlashFooterError;
Lists.ErrorReported += FlashFooterError; Lists.ErrorReported += FlashFooterError;
Lists.SuccessReported += FlashFooterSuccess; Lists.SuccessReported += FlashFooterSuccess;
@@ -402,7 +408,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
}; };
Worker.OperationProgressEvent += OnOperationProgress; Worker.OperationProgressEvent += OnOperationProgress;
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived; Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync();
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict; Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted; Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted;
Worker.PlanningCompletedEvent += OnPlanningMergeCompleted; Worker.PlanningCompletedEvent += OnPlanningMergeCompleted;
@@ -455,8 +460,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_connectTimer.Dispose(); _connectTimer.Dispose();
_primeStatusTimer.Stop(); _primeStatusTimer.Stop();
_primeStatusTimer.Dispose(); _primeStatusTimer.Dispose();
_staleWorkerCts?.Cancel();
_staleWorkerCts?.Dispose();
} }
private void RefreshBannerFromStatus() private void RefreshBannerFromStatus()
@@ -476,57 +479,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
} }
} }
private CancellationTokenSource? _staleWorkerCts;
// Re-evaluates the stale-worker banner for the currently selected list. Cheap (one hub call +
// up to two git subprocesses) and only ever runs for a git-backed list, so it's fine to fire on
// every selection change / reconnect rather than caching.
private async Task RefreshStaleWorkerCheckAsync()
{
_staleWorkerCts?.Cancel();
var cts = new CancellationTokenSource();
_staleWorkerCts = cts;
var stale = await ComputeIsStaleWorkerAsync(Lists?.SelectedList?.WorkingDir, cts.Token);
if (cts.IsCancellationRequested) return;
IsStaleWorkerBannerVisible = stale;
}
private async Task<bool> ComputeIsStaleWorkerAsync(string? workingDir, CancellationToken ct)
{
if (_git is null || Worker is null || string.IsNullOrWhiteSpace(workingDir)) return false;
try
{
var buildInfo = await Worker.GetWorkerBuildInfoAsync();
var buildSha = buildInfo?.BuildSha;
if (string.IsNullOrWhiteSpace(buildSha)) return false;
if (!await _git.IsGitRepoAsync(workingDir, ct)) return false;
var head = await _git.RevParseHeadAsync(workingDir, ct);
var isAncestor = string.Equals(buildSha, head, StringComparison.OrdinalIgnoreCase)
? (bool?)false // equal — never "stale" on a match, and no need to ask git
: await _git.IsAncestorAsync(workingDir, buildSha, head, ct);
return ShouldShowStaleWorkerBanner(buildSha, head, isAncestor);
}
catch
{
// Worker offline, dir no longer a repo, etc. — unknown, so stay quiet.
return false;
}
}
// Pure decision extracted for testability. isAncestor is the tri-state result of
// `git merge-base --is-ancestor buildSha head`: true = worker predates head (stale), false =
// equal or diverged (never claim "stale" on a match or an unrelated history), null = unknown
// (e.g. buildSha isn't a commit this repo knows about — never treat "unknown" as "stale").
internal static bool ShouldShowStaleWorkerBanner(string? buildSha, string? headSha, bool? isAncestor)
{
if (string.IsNullOrWhiteSpace(buildSha) || string.IsNullOrWhiteSpace(headSha)) return false;
if (string.Equals(buildSha, headSha, StringComparison.OrdinalIgnoreCase)) return false;
return isAncestor == true;
}
[RelayCommand] [RelayCommand]
private void OpenMissionControl() private void OpenMissionControl()
{ {
@@ -36,6 +36,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
/// commits the worktree and moves the task to WaitingForReview.</summary> /// commits the worktree and moves the task to WaitingForReview.</summary>
public event Action<string>? SubmitForReviewRequested; public event Action<string>? SubmitForReviewRequested;
/// <summary>Raised with <c>(taskId, exitCode)</c> when a task-based session that had been running
/// dies non-zero. The host reports it to the worker, which decides whether this was a list
/// handler killed by the usage limit and worth reopening. Ad-hoc panes never raise it — there is
/// no task to reopen.</summary>
public event Action<string, int>? SessionDiedAfterStartup;
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane /// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites. /// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>) /// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
@@ -49,7 +55,34 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
TaskId = taskId; TaskId = taskId;
_displayTitle = displayTitle; _displayTitle = displayTitle;
_descriptorFactory = descriptorFactory; _descriptorFactory = descriptorFactory;
Terminal.PropertyChanged += OnTerminalPropertyChanged; Subscribe(Terminal);
}
// Terminal is swapped wholesale on Retry, so the handlers follow the property rather than being
// wired once at construction. (The initial value comes from the field initializer, which never
// runs through the setter -- hence the explicit Subscribe in the constructor.)
partial void OnTerminalChanging(InteractiveTerminalViewModel? oldValue, InteractiveTerminalViewModel newValue)
{
if (oldValue is not null) Unsubscribe(oldValue);
}
partial void OnTerminalChanged(InteractiveTerminalViewModel value) => Subscribe(value);
private void Subscribe(InteractiveTerminalViewModel terminal)
{
terminal.PropertyChanged += OnTerminalPropertyChanged;
terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup;
}
private void Unsubscribe(InteractiveTerminalViewModel terminal)
{
terminal.PropertyChanged -= OnTerminalPropertyChanged;
terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup;
}
private void OnTerminalExitedAfterStartup(int exitCode)
{
if (TaskId is { } id) SessionDiedAfterStartup?.Invoke(id, exitCode);
} }
private readonly Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> _descriptorFactory; private readonly Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> _descriptorFactory;
@@ -90,6 +123,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
} }
} }
/// <summary>Types a message into this pane's live session and submits it (the worker's
/// pre-usage-limit warning). False when the session isn't running.</summary>
public System.Threading.Tasks.Task<bool> SendLineAsync(string text) => Terminal.SendLineAsync(text);
[RelayCommand] [RelayCommand]
private void Close() => CloseRequested?.Invoke(this); private void Close() => CloseRequested?.Invoke(this);
@@ -111,16 +148,15 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
[RelayCommand(CanExecute = nameof(CanRetry))] [RelayCommand(CanExecute = nameof(CanRetry))]
private void Retry() private void Retry()
{ {
Terminal.PropertyChanged -= OnTerminalPropertyChanged; var dead = Terminal;
Terminal.Dispose(); Terminal = new InteractiveTerminalViewModel(); // OnTerminalChanging/Changed move the handlers
Terminal = new InteractiveTerminalViewModel(); dead.Dispose();
Terminal.PropertyChanged += OnTerminalPropertyChanged;
Start(); Start();
} }
public void Dispose() public void Dispose()
{ {
Terminal.PropertyChanged -= OnTerminalPropertyChanged; Unsubscribe(Terminal);
Terminal.Kill(); Terminal.Kill();
Terminal.Dispose(); Terminal.Dispose();
} }
@@ -21,6 +21,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private readonly Action<string> _onTaskUpdated; private readonly Action<string> _onTaskUpdated;
private readonly Action _onConnectionRestored; private readonly Action _onConnectionRestored;
private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested; private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;
private readonly Action<string, IReadOnlyList<string>> _onListHandlerStartRequested;
private readonly Action<string, string> _onHandlerUsageWarning;
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the // Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
// review/merge/status machinery. // review/merge/status machinery.
@@ -58,10 +60,20 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
public bool HasPanes => Panes.Count > 0; public bool HasPanes => Panes.Count > 0;
// Read-only view of the worker queue (tasks waiting to run), shown as a side strip. // Read-only activity strip. Beyond the queue itself (Queued/Running) it also carries the rows
// that want the user's attention (Failed, WaitingForReview, roadblocked) — colour-coded only;
// clicking a row opens the task in the main app, where the actual actions live.
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new(); public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
public bool HasQueued => Queued.Count > 0; public bool HasQueued => Queued.Count > 0;
// "New session" targets: one entry per list with a working dir, plus a browse fallback, so a
// fresh session is two clicks instead of navigating a folder picker.
public ObservableCollection<SessionTargetViewModel> SessionTargets { get; } = new();
// View-layer seam: the folder picker needs a TopLevel, so the view owns it (see
// MissionControlView.OnNewSessionClicked).
public Action? BrowseForSessionRequested { get; set; }
public UsagePillViewModel UsagePill { get; } public UsagePillViewModel UsagePill { get; }
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill) public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
@@ -88,7 +100,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); }; _onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
_worker.HandoffRequestedEvent += _onHandoffRequested; _worker.HandoffRequestedEvent += _onHandoffRequested;
_onListHandlerStartRequested = (listId, taskIds) => { _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); };
_worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested;
_onHandlerUsageWarning = (taskId, message) => { _ = WarnHandlerSessionAsync(taskId, message); };
_worker.HandlerUsageWarningEvent += _onHandlerUsageWarning;
_ = RefreshQueueAsync(); _ = RefreshQueueAsync();
_ = RefreshSessionTargetsAsync();
} }
internal async System.Threading.Tasks.Task RefreshQueueAsync() internal async System.Threading.Tasks.Task RefreshQueueAsync()
@@ -98,23 +116,33 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
await using var ctx = await _dbFactory.CreateDbContextAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync();
var rows = await ctx.Tasks.AsNoTracking() var rows = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued .Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Running) || t.Status == ClaudeDo.Data.Models.TaskStatus.Running
.OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 0 : 1) || t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Failed)
// Attention first (failed, then review), then the queue itself.
.OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Failed ? 0
: t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview ? 1
: t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 2 : 3)
.ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status }) .Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status, t.RoadblockCount })
.ToListAsync(); .ToListAsync();
Queued.Clear(); Queued.Clear();
foreach (var r in rows) foreach (var r in rows)
{ {
var id = r.Id; var id = r.Id;
var running = r.Status == ClaudeDo.Data.Models.TaskStatus.Running;
var (tint, colorKey, labelKey) = Decorate(r.Status, r.RoadblockCount, r.BlockedByTaskId != null);
Queued.Add(new QueuedTaskViewModel Queued.Add(new QueuedTaskViewModel
{ {
Id = id, Id = id,
Title = r.Title ?? string.Empty, Title = r.Title ?? string.Empty,
IsBlocked = r.BlockedByTaskId != null, IsRunning = running,
IsRunning = r.Status == ClaudeDo.Data.Models.TaskStatus.Running, TintKey = tint,
StatusColorKey = colorKey,
StatusLabel = labelKey is null ? null : Loc.T(labelKey),
OpenInAppCommand = new RelayCommand(() => OpenInApp?.Invoke(id)), OpenInAppCommand = new RelayCommand(() => OpenInApp?.Invoke(id)),
OpenSessionCommand = new RelayCommand(() => _ = OpenConPtySessionAsync(id)),
}); });
} }
OnPropertyChanged(nameof(HasQueued)); OnPropertyChanged(nameof(HasQueued));
@@ -122,6 +150,54 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
} }
// Row tint + status label for the activity strip. Keys are resolved to brushes by
// DotBrushConverter ("<key>Brush"), so they must match tokens in Tokens.axaml.
private static (string? Tint, string? ColorKey, string? LabelKey) Decorate(
ClaudeDo.Data.Models.TaskStatus status, int roadblockCount, bool blocked)
=> status switch
{
ClaudeDo.Data.Models.TaskStatus.Failed => ("ErrorTint", "StatusError", "missionControl.failed"),
ClaudeDo.Data.Models.TaskStatus.WaitingForReview when roadblockCount > 0
=> ("RoadblockTint", "Amber", "missionControl.roadblock"),
ClaudeDo.Data.Models.TaskStatus.WaitingForReview => ("DoneTint", "StatusReview", "missionControl.review"),
ClaudeDo.Data.Models.TaskStatus.Running => ("RunningTint", "StatusRunning", "missionControl.running"),
_ when blocked => (null, "Amber", "missionControl.blocked"),
_ => (null, null, null),
};
// Rebuilt on every flyout open — lists change rarely, but a stale target would launch a
// session in a directory that is no longer configured.
internal async System.Threading.Tasks.Task RefreshSessionTargetsAsync()
{
SessionTargets.Clear();
// Browse goes in first so the menu stays usable even if the list query below fails.
SessionTargets.Add(new SessionTargetViewModel
{
Label = Loc.T("missionControl.browseForFolder"),
StartCommand = new RelayCommand(() => BrowseForSessionRequested?.Invoke()),
});
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = await ctx.Lists.AsNoTracking()
.Where(l => l.WorkingDir != null && l.WorkingDir != "")
.OrderBy(l => l.SortOrder).ThenBy(l => l.Name)
.Select(l => new { l.Name, l.WorkingDir })
.ToListAsync();
foreach (var l in lists)
{
var dir = l.WorkingDir!;
SessionTargets.Add(new SessionTargetViewModel
{
Label = l.Name,
StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(dir)),
});
}
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
}
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes // Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes
// through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the // through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the
// manual/draft-child guards apply here too. The interactive-session check has to live here // manual/draft-child guards apply here too. The interactive-session check has to live here
@@ -337,6 +413,19 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase)))); () => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))));
} }
// The 5h usage window is about to run out under this handler session: type the worker's warning
// into its pane so the session can hand off cleanly instead of dying mid-merge. The UI process
// owns the terminal, so this is the only place that can reach it at all. No pane (the tile was
// closed, or this is a second app instance) = nothing to warn, and that is not an error.
internal async System.Threading.Tasks.Task WarnHandlerSessionAsync(string taskId, string message)
{
if (string.IsNullOrEmpty(taskId) || string.IsNullOrEmpty(message)) return;
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane) return;
if (!await pane.SendLineAsync(message))
ErrorReported?.Invoke(Loc.T("missionControl.usageWarningNotDelivered"));
}
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec, // Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
// so the tile is on screen (spinner running) while the worker is still preparing the worktree. // so the tile is on screen (spinner running) while the worker is still preparing the worktree.
private void AddConPtyPane(ConPtyPaneViewModel pane) private void AddConPtyPane(ConPtyPaneViewModel pane)
@@ -344,10 +433,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
pane.ErrorReported += OnConPtyPaneError; pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession; pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview; pane.SubmitForReviewRequested += OnPaneSubmitForReview;
pane.SessionDiedAfterStartup += OnPaneSessionDied;
ConPtySessions.Add(pane); ConPtySessions.Add(pane);
pane.Start(); pane.Start();
} }
// A ConPTY session that had been running died. The UI process owns the terminal, so this is the
// only place the exit code exists -- report it and let the worker decide whether it was a list
// handler killed by the usage limit and worth reopening after the reset
// (HandlerRestartCoordinator). Every other pane is just a no-op there, so no filtering here.
private async void OnPaneSessionDied(string taskId, int exitCode)
{
try
{
await _worker.ReportInteractiveSessionExitedAsync(taskId, exitCode);
}
catch (Exception ex)
{
// A worker that is down cannot restart anything anyway; the dead tile is already visible.
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
}
}
private static async System.Threading.Tasks.Task<TerminalLaunchDescriptor> DescribeAsync( private static async System.Threading.Tasks.Task<TerminalLaunchDescriptor> DescribeAsync(
Func<System.Threading.Tasks.Task<LaunchSpec>> fetch) Func<System.Threading.Tasks.Task<LaunchSpec>> fetch)
{ {
@@ -388,6 +495,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
pane.ErrorReported -= OnConPtyPaneError; pane.ErrorReported -= OnConPtyPaneError;
pane.CloseRequested -= CloseConPtySession; pane.CloseRequested -= CloseConPtySession;
pane.SubmitForReviewRequested -= OnPaneSubmitForReview; pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
ConPtySessions.Remove(pane); ConPtySessions.Remove(pane);
pane.Dispose(); pane.Dispose();
} }
@@ -431,6 +539,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_worker.TaskUpdatedEvent -= _onTaskUpdated; _worker.TaskUpdatedEvent -= _onTaskUpdated;
_worker.ConnectionRestoredEvent -= _onConnectionRestored; _worker.ConnectionRestoredEvent -= _onConnectionRestored;
_worker.HandoffRequestedEvent -= _onHandoffRequested; _worker.HandoffRequestedEvent -= _onHandoffRequested;
_worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested;
_worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning;
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged; ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
Panes.CollectionChanged -= OnPanesChanged; Panes.CollectionChanged -= OnPanesChanged;
foreach (var c in ConPtySessions.ToList()) foreach (var c in ConPtySessions.ToList())
@@ -438,6 +548,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
c.ErrorReported -= OnConPtyPaneError; c.ErrorReported -= OnConPtyPaneError;
c.CloseRequested -= CloseConPtySession; c.CloseRequested -= CloseConPtySession;
c.SubmitForReviewRequested -= OnPaneSubmitForReview; c.SubmitForReviewRequested -= OnPaneSubmitForReview;
c.SessionDiedAfterStartup -= OnPaneSessionDied;
c.Dispose(); c.Dispose();
} }
ConPtySessions.Clear(); ConPtySessions.Clear();
@@ -445,12 +556,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
} }
} }
/// <summary>Read-only display row for a queued or running task in the Mission Control side strip.</summary> /// <summary>Display row in the Mission Control activity strip (queued, running, or wanting
/// attention). Read-only apart from opening the task in the app / in a session.</summary>
public sealed class QueuedTaskViewModel public sealed class QueuedTaskViewModel
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string Title { get; init; } public required string Title { get; init; }
public bool IsBlocked { get; init; }
public bool IsRunning { get; init; } public bool IsRunning { get; init; }
/// <summary>Brush key of the row tint (e.g. "RunningTint"); null = untinted.</summary>
public string? TintKey { get; init; }
public string? TintBorderKey => TintKey is null ? null : TintKey + "Border";
public string? StatusLabel { get; init; }
public string? StatusColorKey { get; init; }
/// <summary>A running task's worktree belongs to the autonomous run — never open a second,
/// interactive claude process into it.</summary>
public bool CanOpenSession => !IsRunning;
public IRelayCommand? OpenInAppCommand { get; init; } public IRelayCommand? OpenInAppCommand { get; init; }
public IRelayCommand? OpenSessionCommand { get; init; }
}
/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary>
public sealed class SessionTargetViewModel
{
public required string Label { get; init; }
public required IRelayCommand StartCommand { get; init; }
} }
@@ -26,6 +26,10 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// True after the list was deleted, so the caller reloads the list nav instead of refreshing the row. // True after the list was deleted, so the caller reloads the list nav instead of refreshing the row.
public bool Deleted { get; private set; } public bool Deleted { get; private set; }
// False when the modal closed via Cancel or the window chrome — CreateListAsync uses this to
// roll the pre-created row back instead of leaving an untitled empty list behind.
public bool Saved { get; private set; }
// Wired by the view to prompt yes/no before deleting and to surface a blocking-FK error. // Wired by the view to prompt yes/no before deleting and to surface a blocking-FK error.
public Func<string, Task<bool>>? ConfirmAsync { get; set; } public Func<string, Task<bool>>? ConfirmAsync { get; set; }
public Func<string, Task>? ShowErrorAsync { get; set; } public Func<string, Task>? ShowErrorAsync { get; set; }
@@ -41,13 +45,39 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// Optional post-merge verification command (build/test), run in WorkingDir after a merge // Optional post-merge verification command (build/test), run in WorkingDir after a merge
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged. // lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
[ObservableProperty] private string _verifyCommand = ""; [ObservableProperty] private string _verifyCommand = "";
// Per-list opt-in for PowerUse. A handler run on this list only goes unattended (no questions,
// harvests new work, longer handoff chain) when this AND the global switch in the usage monitor
// are both on. Off by default — the global switch alone must never change a list's behaviour.
[ObservableProperty] private bool _powerUseAllowed;
// Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab). // Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab).
// Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry).
[ObservableProperty] private string _icon = DefaultIcon;
[ObservableProperty] private bool _ticketsAvailable; [ObservableProperty] private bool _ticketsAvailable;
[ObservableProperty] private TicketProjectOption? _selectedTicketProject; [ObservableProperty] private TicketProjectOption? _selectedTicketProject;
public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new(); public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new();
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types); public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
public const string DefaultIcon = "Folder";
// Curated subset of the Icon.* geometry library in IslandStyles.axaml — window chrome and
// pure stroke icons (PathIcon fills, so those render invisible) are deliberately left out.
// Grouped by theme so the picker grid reads in rows; every entry needs a matching
// "Icon.<key>" StreamGeometry or it renders as an empty slot.
public static readonly string[] IconOptions =
[
// general
"Folder", "Star", "Sun", "Calendar", "Clock", "Inbox", "Mail", "Text", "Search", "Grid",
// dev
"Code", "Terminal", "Database", "Server", "Chip", "Bolt", "Wand", "Flask", "Shield",
// status / flow
"Activity", "Eye", "Check", "Warning", "Chart", "Rocket", "Bulb", "Broom", "AgentSuggested", "Settings",
// things
"Box", "Truck", "MapPin", "Camera", "Image", "Barcode", "Skull",
];
public ObservableCollection<string> Icons { get; } = new(IconOptions);
// The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile), // The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile),
// scoped to this list (list → global inheritance). // scoped to this list (list → global inheritance).
public AgentConfigEditorViewModel Agent { get; } public AgentConfigEditorViewModel Agent { get; }
@@ -68,18 +98,23 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string defaultCommitType, string defaultCommitType,
bool isManual = false, bool isManual = false,
bool findingsTracked = false, bool findingsTracked = false,
string? icon = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
ListId = listId; ListId = listId;
Name = name; Name = name;
IsManual = isManual; IsManual = isManual;
FindingsTracked = findingsTracked; FindingsTracked = findingsTracked;
// An unknown/legacy key would leave the picker with no selection and silently reset the
// icon on the next save — fall back to the default instead.
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
WorkingDir = workingDir ?? ""; WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType; DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
await Agent.LoadForListAsync(listId, ct); await Agent.LoadForListAsync(listId, ct);
var cfg = await _worker.GetListConfigAsync(listId); var cfg = await _worker.GetListConfigAsync(listId);
VerifyCommand = cfg?.VerifyCommand ?? ""; VerifyCommand = cfg?.VerifyCommand ?? "";
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled
? await _worker.GetTicketSettingsAsync() ? await _worker.GetTicketSettingsAsync()
@@ -111,17 +146,22 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir, string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
DefaultCommitType, DefaultCommitType,
IsManual, IsManual,
FindingsTracked)); FindingsTracked,
Icon));
// Tri-state: dropdown never shown (no base URL) -> null, leave the stored link alone. // Tri-state: dropdown never shown (no base URL) -> null, leave the stored link alone.
// Dropdown shown -> always an explicit value, 0 for "— none —" to actively clear it. // Dropdown shown -> always an explicit value, 0 for "— none —" to actively clear it.
// Never null here, or an explicit unlink would silently fail to reach the worker. // Never null here, or an explicit unlink would silently fail to reach the worker.
var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null; var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
// Always explicit (never null) — this modal owns the checkbox, so unchecking it has to reach
// the worker as a real false rather than a "leave it alone".
await Agent.SaveAsync( await Agent.SaveAsync(
string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand, string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand,
ticketProjectId); ticketProjectId,
PowerUseAllowed);
Saved = true;
CloseAction?.Invoke(); CloseAction?.Invoke();
} }
@@ -30,6 +30,7 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
public string RetryPromptPath { get; } = PromptFiles.PathFor(PromptKind.Retry); public string RetryPromptPath { get; } = PromptFiles.PathFor(PromptKind.Retry);
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep); public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport); public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
public string PowerUsePromptPath { get; } = PromptFiles.PathFor(PromptKind.PowerUseAddendum);
public ObservableCollection<CustomizedPromptRowViewModel> CustomizedPrompts { get; } = new(); public ObservableCollection<CustomizedPromptRowViewModel> CustomizedPrompts { get; } = new();
@@ -27,6 +27,9 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
// Off by default: auto-continues a usage-limit-failed task once the 5h window resets, and // Off by default: auto-continues a usage-limit-failed task once the 5h window resets, and
// wakes a gate-blocked queue exactly at that reset time instead of only the 30s backstop. // wakes a gate-blocked queue exactly at that reset time instead of only the 30s backstop.
[ObservableProperty] private bool _autoContinueOnUsageLimit; [ObservableProperty] private bool _autoContinueOnUsageLimit;
// Cap on a list-handler run's wait<->merge handoff chain. 4 = two merge rounds (the long-standing
// default); raise it for runs meant to keep going for hours. Bounds a runaway chain, so never 0.
[ObservableProperty] private int _listHandlerMaxHandoffs = 4;
// Newline-separated path prefixes excluded from the weekly report. // Newline-separated path prefixes excluded from the weekly report.
[ObservableProperty] private string _reportExcludedPaths = @"C:\Private"; [ObservableProperty] private string _reportExcludedPaths = @"C:\Private";
// 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday. // 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday.
@@ -8,20 +8,17 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class TicketSettingsTabViewModel : ViewModelBase public sealed partial class TicketSettingsTabViewModel : ViewModelBase
{ {
/// <summary> /// <summary>
/// Der Aus-Schalter der gesamten Ticketsystem-Anbindung in der UI — hier auf <c>true</c> /// Der Aus-Schalter der gesamten Ticketsystem-Anbindung in der UI, wie bei
/// für die Reaktivierung, wie bei <c>SettingsModalViewModel.ShowOnlineInbox</c>. /// <c>SettingsModalViewModel.ShowOnlineInbox</c>. Er versteckt drei Einstiegspunkte: diesen
/// Settings-Tab, das Ticket-Projekt-Dropdown in den Listen-Einstellungen und den
/// Kontextmenü-Eintrag zum Importieren.
/// ///
/// Pausiert am 2026-08-27: die Bandel-API war unter dem eingetragenen Hostnamen nicht /// Pausiert am 2026-08-27 (API unter dem Hostnamen nicht erreichbar), reaktiviert am
/// erreichbar (Verbindung aktiv abgelehnt — vermutlich fehlende IIS-Hostname-Bindung oder /// 2026-09-01: die API läuft unter <c>http://192.168.45.161</c> und alle vier Routen sind
/// falscher Port, siehe deploy/deploy-iis-winrm.ps1). Solange das offen ist, würde der Tab /// live verifiziert. Base-URL ist deshalb eine IP, kein Hostname — <c>bandelapis.fb-tuning.local</c>
/// nur eine Einrichtung anbieten, die nicht funktionieren kann. /// lehnt die Verbindung weiterhin ab.
///
/// Der Schalter versteckt drei Einstiegspunkte: diesen Settings-Tab, das Ticket-Projekt-
/// Dropdown in den Listen-Einstellungen und den Kontextmenü-Eintrag zum Importieren.
/// Gespeicherte Werte (Base-URL, PAT, verknüpfte Projekte) bleiben unangetastet und greifen
/// beim Umlegen sofort wieder.
/// </summary> /// </summary>
public const bool FeatureEnabled = false; public const bool FeatureEnabled = true;
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
@@ -130,6 +130,14 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
// carried through load→save verbatim so saving Settings can never reset a dragged value. // carried through load→save verbatim so saving Settings can never reset a dragged value.
private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65); private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65);
// Same deal: the PowerUse master switch is toggled on the usage monitor. Carried load→save so
// opening and saving Settings can't switch it off behind the user's back.
private bool _powerUseEnabled;
// Ditto for the PowerUse time window and handoff threshold — usage-monitor-owned, invisible
// here, but they must survive a Settings save.
private (string? Start, string? End, int HandoffPct) _powerUseWindow = (null, null, 95);
public async Task LoadAsync() public async Task LoadAsync()
{ {
IsBusy = true; IsBusy = true;
@@ -147,6 +155,12 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct; General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct; General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
General.AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit; General.AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit;
General.ListHandlerMaxHandoffs = dto.ListHandlerMaxHandoffs;
// Owned by the usage monitor, not editable here — but it MUST be round-tripped,
// because SaveAsync rebuilds the whole DTO positionally and would otherwise write
// the record default (false) back and silently switch PowerUse off.
_powerUseEnabled = dto.PowerUseEnabled;
_powerUseWindow = (dto.PowerUseWindowStart, dto.PowerUseWindowEnd, dto.PowerUseHandoffPct);
_throttleStages = ( _throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct, dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct); dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
@@ -213,7 +227,12 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
_throttleStages.FiveHard, _throttleStages.FiveHard,
_throttleStages.SevenSoft, _throttleStages.SevenSoft,
_throttleStages.SevenHard, _throttleStages.SevenHard,
General.AutoContinueOnUsageLimit); General.AutoContinueOnUsageLimit,
_powerUseEnabled,
General.ListHandlerMaxHandoffs,
_powerUseWindow.Start,
_powerUseWindow.End,
_powerUseWindow.HandoffPct);
await _worker.UpdateAppSettingsAsync(dto); await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync(); await Prime.SaveAsync();
await OnlineInbox.SaveAsync(); await OnlineInbox.SaveAsync();
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@@ -100,6 +101,77 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>(); private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
/// <summary>
/// PowerUse master switch. It lives here rather than in Settings because turning it on is a
/// "spend the rest of the window" decision, made while looking at how much window is left.
/// On its own it changes nothing — a list-handler run only goes unattended when its LIST also
/// opted in (list settings → PowerUse), so this can stay on without affecting other lists.
/// </summary>
[ObservableProperty] private bool _powerUseEnabled;
/// <summary>Optional local-time window (<c>HH:mm</c>) narrowing when PowerUse applies. Both
/// blank = round the clock. Committed on focus loss / Enter, not per keystroke.</summary>
[ObservableProperty] private string? _powerUseWindowStart;
[ObservableProperty] private string? _powerUseWindowEnd;
/// <summary>5h-usage percentage at which a PowerUse handler session should hand off.</summary>
[ObservableProperty] private int _powerUseHandoffPct = 95;
// Set while LoadAsync writes the stored value in, so seeding the property doesn't immediately
// write it straight back to the worker.
private bool _loadingPowerUse;
partial void OnPowerUseEnabledChanged(bool value)
{
if (_loadingPowerUse) return;
_ = SavePowerUse();
}
/// Read-modify-write, same discipline as SaveStagesAsync: never clobber a field this modal
/// doesn't own. All four PowerUse fields go in one write, so committing one box can't drop a
/// value the user just typed into a sibling box.
[RelayCommand]
private async Task SavePowerUse()
{
if (_loadingPowerUse) return;
try
{
var settings = await _worker.GetAppSettingsAsync();
if (settings is null)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
return;
}
// The worker stores an unparseable time as null; normalize here too so the box shows
// what was actually saved instead of a typo that looks like a live window.
var start = PowerUseWindow.Normalize(PowerUseWindowStart);
var end = PowerUseWindow.Normalize(PowerUseWindowEnd);
var pct = Math.Clamp(PowerUseHandoffPct, 50, 99);
await _worker.UpdateAppSettingsAsync(settings with
{
PowerUseEnabled = PowerUseEnabled,
PowerUseWindowStart = start,
PowerUseWindowEnd = end,
PowerUseHandoffPct = pct,
});
_loadingPowerUse = true;
try
{
PowerUseWindowStart = start;
PowerUseWindowEnd = end;
PowerUseHandoffPct = pct;
}
finally { _loadingPowerUse = false; }
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", ex.Message));
}
}
public bool IsStale => Snapshot?.IsStale == true; public bool IsStale => Snapshot?.IsStale == true;
public string? LastError => Snapshot?.LastError; public string? LastError => Snapshot?.LastError;
public bool IsGateBlocked => Snapshot?.IsGateBlocked == true; public bool IsGateBlocked => Snapshot?.IsGateBlocked == true;
@@ -148,6 +220,21 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
Snapshot = snapshot; Snapshot = snapshot;
_worker.UsageUpdatedEvent -= OnUsageUpdated; _worker.UsageUpdatedEvent -= OnUsageUpdated;
_worker.UsageUpdatedEvent += OnUsageUpdated; _worker.UsageUpdatedEvent += OnUsageUpdated;
var settings = await _worker.GetAppSettingsAsync();
if (settings is not null)
{
_loadingPowerUse = true;
try
{
PowerUseEnabled = settings.PowerUseEnabled;
PowerUseWindowStart = settings.PowerUseWindowStart;
PowerUseWindowEnd = settings.PowerUseWindowEnd;
PowerUseHandoffPct = settings.PowerUseHandoffPct;
}
finally { _loadingPowerUse = false; }
}
ApplyPresetRange(SelectedPresetDays); ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync(); await LoadUsageDataAsync();
} }
@@ -10,12 +10,15 @@
<!-- Column 0: id badge + editable title --> <!-- Column 0: id badge + editable title -->
<StackPanel Grid.Column="0" Spacing="0"> <StackPanel Grid.Column="0" Spacing="0">
<TextBlock Classes="meta" <StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
Text="{Binding TaskIdBadge}" <TextBlock Classes="meta"
Margin="0,0,0,4" Text="{Binding TaskIdBadge}"
Cursor="Hand" Cursor="Hand"
ToolTip.Tip="{loc:Tr details.copyTaskIdTip}" ToolTip.Tip="{loc:Tr details.copyTaskIdTip}"
Tapped="OnTaskIdTapped"/> Tapped="OnTaskIdTapped"/>
<!-- created-at moved up from the removed metadata footer -->
<TextBlock Classes="meta" Text="{Binding Task.CreatedAtFormatted}"/>
</StackPanel>
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}" <TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
Background="Transparent" Background="Transparent"
BorderThickness="0" BorderThickness="0"
@@ -11,27 +11,6 @@
<Panel> <Panel>
<DockPanel> <DockPanel>
<!-- ── Metadata footer (sticky bottom) — created-at + close — task detail only ── -->
<Border DockPanel.Dock="Bottom"
IsVisible="{Binding IsTaskDetailVisible}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
Padding="14,8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Classes="meta"
Text="{Binding Task.CreatedAtFormatted}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn"
Command="{Binding CloseDetailsCommand}"
ToolTip.Tip="{loc:Tr details.closeTip}"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.X}" Width="14" Height="14"/>
</Button>
</Grid>
</Border>
<!-- ── Header (sticky top): id · title · trash/skull · gear — task detail only ── --> <!-- ── Header (sticky top): id · title · trash/skull · gear — task detail only ── -->
<Border DockPanel.Dock="Top" Classes="island-header" <Border DockPanel.Dock="Top" Classes="island-header"
IsVisible="{Binding IsTaskDetailVisible}"> IsVisible="{Binding IsTaskDetailVisible}">
@@ -146,17 +125,12 @@
DragCompleted="OnSplitterDragCompleted"/> DragCompleted="OnSplitterDragCompleted"/>
</Grid> </Grid>
<!-- Empty state: no task selected, outside Notes/Prep mode --> <!-- Empty state: no task selected, outside Prep mode -->
<TextBlock IsVisible="{Binding IsEmptyStateVisible}" <TextBlock IsVisible="{Binding IsEmptyStateVisible}"
Text="{loc:Tr details.emptyState}" Text="{loc:Tr details.emptyState}"
Foreground="{DynamicResource TextMuteBrush}" Foreground="{DynamicResource TextMuteBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Notes mode -->
<Panel IsVisible="{Binding IsNotesMode}">
<islands:NotesEditorView DataContext="{Binding Notes}"/>
</Panel>
<!-- Daily-prep mode --> <!-- Daily-prep mode -->
<Panel IsVisible="{Binding IsPrepMode}"> <Panel IsVisible="{Binding IsPrepMode}">
<DockPanel> <DockPanel>
@@ -103,7 +103,6 @@
<PathIcon Grid.Column="0" Classes="list-icon" <PathIcon Grid.Column="0" Classes="list-icon"
Width="14" Height="14" Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}" Data="{Binding IconKey, Converter={StaticResource IconKey}}"
Foreground="{DynamicResource TextMuteBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Name --> <!-- Name -->
<TextBlock Grid.Column="1" Classes="list-label" <TextBlock Grid.Column="1" Classes="list-label"
@@ -119,8 +118,21 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<!-- MY LISTS section --> <!-- MY LISTS section — add-list + repo-import sit next to the label -->
<TextBlock Classes="section-label" Text="{loc:Tr lists.myListsLabel}" Margin="10,10,10,4"/> <Grid ColumnDefinitions="*,Auto,Auto" Margin="10,10,6,4" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="section-label"
Text="{loc:Tr lists.myListsLabel}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn"
Command="{Binding CreateListCommand}"
ToolTip.Tip="{loc:Tr lists.newList}">
<PathIcon Data="{StaticResource Icon.Plus}" Width="13" Height="13"/>
</Button>
<Button Grid.Column="2" Classes="icon-btn"
Command="{Binding OpenRepoImportCommand}"
ToolTip.Tip="{loc:Tr lists.addReposTip}">
<PathIcon Data="{StaticResource Icon.Folder}" Width="14" Height="14"/>
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding UserLists}"> <ItemsControl ItemsSource="{Binding UserLists}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ListNavItemViewModel"> <DataTemplate DataType="vm:ListNavItemViewModel">
@@ -177,11 +189,11 @@
HorizontalAlignment="Left" VerticalAlignment="Center" HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0"/> Margin="-8,0,0,0"/>
</Border> </Border>
<!-- Color dot (6px circle, color from DotColorKey) --> <!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
<Ellipse Grid.Column="0" <PathIcon Grid.Column="0" Classes="list-icon"
Width="6" Height="6" Width="14" Height="14"
Fill="{Binding DotColorKey, Converter={StaticResource DotBrush}}" Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Name --> <!-- Name -->
<TextBlock Grid.Column="1" Classes="list-label" <TextBlock Grid.Column="1" Classes="list-label"
Text="{Binding Name}" Text="{Binding Name}"
@@ -202,26 +214,6 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<!-- New list + import row -->
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="new-list-btn"
Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="{StaticResource Icon.Plus}"
Width="13" Height="13"
VerticalAlignment="Center"/>
<TextBlock Classes="body"
Text="{loc:Tr lists.newList}"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Grid.Column="1" Classes="icon-btn" Margin="6,0,0,0"
Command="{Binding OpenRepoImportCommand}"
ToolTip.Tip="{loc:Tr lists.addReposTip}">
<PathIcon Data="{StaticResource Icon.Folder}" Width="14" Height="14"/>
</Button>
</Grid>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</DockPanel> </DockPanel>
@@ -1,38 +1,113 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.NotesEditorView" x:Class="ClaudeDo.Ui.Views.Islands.NotesEditorView"
x:DataType="vm:NotesEditorViewModel"> x:DataType="vm:NotesEditorViewModel">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8">
<Button Classes="btn" Content="&#x2039;" Command="{Binding PrevDayCommand}"/>
<ctl:ThemedDatePicker SelectedDate="{Binding CurrentDate, Mode=TwoWay}"/>
<Button Classes="btn" Content="&#x203A;" Command="{Binding NextDayCommand}"/>
<Button Classes="btn" Content="{loc:Tr notes.today}" Command="{Binding TodayCommand}"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding CurrentDayLabel}"/>
</StackPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,12,0,8"> <UserControl.Styles>
<Button DockPanel.Dock="Right" Classes="btn" Content="{loc:Tr notes.add}" Margin="8,0,0,0" <!-- A note reads as text, not as a form field: the box only shows itself under the pointer
Command="{Binding AddBulletCommand}"/> and while it has focus. -->
<TextBox PlaceholderText="{loc:Tr notes.newNotePlaceholder}" Text="{Binding NewBulletText}"> <Style Selector="TextBox.note">
<TextBox.KeyBindings> <Setter Property="Background" Value="Transparent"/>
<KeyBinding Gesture="Enter" Command="{Binding AddBulletCommand}"/> <Setter Property="BorderThickness" Value="0"/>
</TextBox.KeyBindings> <Setter Property="Padding" Value="4,3"/>
</TextBox> <Setter Property="MinHeight" Value="0"/>
</DockPanel> </Style>
<Style Selector="TextBox.note /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<ScrollViewer> <Style Selector="Border.note-row">
<ItemsControl ItemsSource="{Binding Bullets}"> <Setter Property="CornerRadius" Value="6"/>
<ItemsControl.ItemTemplate> <Setter Property="Padding" Value="10,1,6,1"/>
<DataTemplate x:DataType="vm:NoteBulletViewModel"> <Setter Property="Margin" Value="4,1"/>
<TextBox Text="{Binding Text}" Margin="0,2" </Style>
LostFocus="OnBulletLostFocus"/> <Style Selector="Border.note-row:pointerover">
</DataTemplate> <Setter Property="Background" Value="{DynamicResource PeatSoftBrush}"/>
</ItemsControl.ItemTemplate> </Style>
</ItemsControl> <!-- Row actions stay out of the way until the row is hovered. -->
</ScrollViewer> <Style Selector="Border.note-row Button.note-del">
<Setter Property="Opacity" Value="0"/>
</Style>
<Style Selector="Border.note-row:pointerover Button.note-del">
<Setter Property="Opacity" Value="1"/>
</Style>
</UserControl.Styles>
<DockPanel LastChildFill="True">
<!-- Today's capture box: Enter files the note and leaves the caret here for the next one. -->
<Border DockPanel.Dock="Top" Classes="add-task" Margin="16,14,16,10">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="add-task-plus" VerticalAlignment="Center">
<PathIcon Width="12" Height="12" Data="{StaticResource Icon.Plus}"
Foreground="{DynamicResource TextFaintBrush}"/>
</Border>
<!-- Enter/Shift+Enter are handled in the code-behind's tunnel handler, same as the note
rows: a KeyBinding would never fire, because AcceptsReturn makes TextBox consume
Enter first. -->
<TextBox Grid.Column="1" x:Name="NewNoteBox" Classes="add-task-input"
PlaceholderText="{loc:Tr notes.newNotePlaceholder}"
Text="{Binding NewBulletText, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap"
VerticalAlignment="Center" Margin="12,0,0,0"/>
<Border Grid.Column="2" Classes="kbd kbd-enter" VerticalAlignment="Center"
IsVisible="{Binding #NewNoteBox.IsFocused}">
<TextBlock Text="{loc:Tr tasks.enterKey}"/>
</Border>
</Grid>
</Border>
<Panel>
<ScrollViewer>
<ItemsControl x:Name="RowsHost" ItemsSource="{Binding Rows}" Margin="10,0,10,12">
<ItemsControl.DataTemplates>
<DataTemplate DataType="vm:NoteDayHeaderViewModel">
<TextBlock Classes="eyebrow section-label" Margin="14,12,14,4" Text="{Binding Label}"/>
</DataTemplate>
<DataTemplate DataType="vm:NoteBulletViewModel">
<Border Classes="note-row">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Ellipse Grid.Column="0" Width="4" Height="4" Margin="0,0,10,0"
VerticalAlignment="Center"
Fill="{DynamicResource TextFaintBrush}"/>
<!-- AcceptsReturn/TextWrapping are set here, not in the style above: TextBox
reads AcceptsReturn in its own OnKeyDown, which runs before any handler a
style or the XAML could attach. Enter is intercepted in the tunnel phase
(see code-behind) so the split still wins over the newline. -->
<TextBox Grid.Column="1" Classes="note" Text="{Binding Text, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap"
LostFocus="OnBulletLostFocus"/>
<PathIcon Grid.Column="2" Width="12" Height="12" Margin="6,0"
VerticalAlignment="Center"
IsVisible="{Binding FromAgent}"
ToolTip.Tip="{loc:Tr notes.fromAgentTip}"
Data="{StaticResource Icon.AgentSuggested}"
Foreground="{DynamicResource TextFaintBrush}"/>
<Button Grid.Column="3" Classes="icon-btn note-del"
VerticalAlignment="Center"
ToolTip.Tip="{loc:Tr notes.deleteTip}"
Click="OnDeleteBulletClick">
<PathIcon Classes="danger" Data="{StaticResource Icon.Trash}" Width="12" Height="12"/>
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.DataTemplates>
</ItemsControl>
</ScrollViewer>
<TextBlock IsVisible="{Binding IsEmpty}"
Text="{loc:Tr notes.emptyHint}"
Foreground="{DynamicResource TextMuteBrush}"
MaxWidth="260" TextAlignment="Center" TextWrapping="Wrap"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsHitTestVisible="False"/>
</Panel>
</DockPanel> </DockPanel>
</UserControl> </UserControl>
@@ -1,12 +1,74 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
public partial class NotesEditorView : UserControl public partial class NotesEditorView : UserControl
{ {
public NotesEditorView() => InitializeComponent(); private NotesEditorViewModel? _vm;
public NotesEditorView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
// Tunnel, not bubble: with AcceptsReturn on, TextBox.OnKeyDown consumes Enter (any
// modifier) before a bubbling handler ever sees it. Taking Enter in the tunnel phase
// keeps the split chain, and letting Shift+Enter through leaves the newline to the box.
AddHandler(KeyDownEvent, OnBulletKeyDown, RoutingStrategies.Tunnel);
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (_vm is not null) _vm.FocusRequested -= FocusBullet;
_vm = DataContext as NotesEditorViewModel;
if (_vm is not null) _vm.FocusRequested += FocusBullet;
}
/// <summary>Rows the VM just inserted have no container yet — wait for the layout pass that
/// creates it before reaching for the TextBox inside.</summary>
private void FocusBullet(NoteBulletViewModel bullet) =>
Dispatcher.UIThread.Post(
() => RowsHost.ContainerFromItem(bullet)?.FindDescendantOfType<TextBox>()?.Focus(),
DispatcherPriority.Loaded);
private void OnBulletKeyDown(object? sender, KeyEventArgs e)
{
// Source, not sender: the tunnel handler is attached to the whole view, so it sees the
// capture box at the top as well as the note rows. Both need the same Enter/Shift+Enter
// split, and neither can get it from a KeyBinding — with AcceptsReturn on, TextBox eats
// Enter in its own OnKeyDown before any bubbling handler or binding runs.
if (e.Source is not TextBox box || DataContext is not NotesEditorViewModel vm) return;
var shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
if (ReferenceEquals(box, NewNoteBox))
{
if (e.Key == Key.Enter && !shift)
{
e.Handled = true;
vm.AddBulletCommand.Execute(null);
}
return;
}
if (box.DataContext is not NoteBulletViewModel bullet) return;
// Shift+Enter falls through to the TextBox and breaks the line — a note can be several.
if (e.Key == Key.Enter && !shift)
{
e.Handled = true;
vm.SplitBulletCommand.Execute(bullet);
}
else if (e.Key == Key.Back && string.IsNullOrEmpty(box.Text))
{
e.Handled = true;
vm.DeleteBulletCommand.Execute(bullet);
}
}
private void OnBulletLostFocus(object? sender, RoutedEventArgs e) private void OnBulletLostFocus(object? sender, RoutedEventArgs e)
{ {
@@ -15,4 +77,11 @@ public partial class NotesEditorView : UserControl
&& vm.CommitBulletCommand.CanExecute(bullet)) && vm.CommitBulletCommand.CanExecute(bullet))
vm.CommitBulletCommand.Execute(bullet); vm.CommitBulletCommand.Execute(bullet);
} }
private void OnDeleteBulletClick(object? sender, RoutedEventArgs e)
{
if (sender is Button { DataContext: NoteBulletViewModel bullet }
&& DataContext is NotesEditorViewModel vm)
vm.DeleteBulletCommand.Execute(bullet);
}
} }
@@ -6,6 +6,7 @@ using Avalonia.Interactivity;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -97,6 +98,23 @@ public partial class TaskRowView : UserControl
schedule.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay)); schedule.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay));
menu.Items.Add(schedule); menu.Items.Add(schedule);
// "Move to list" — user lists only, current list excluded. Reuses the drag-drop move
// (MoveTaskToListAsync), so nothing but ListId changes and the same running/worktree
// guards apply. Hidden entirely when there is nowhere to move to.
var targets = (TopLevel.GetTopLevel(this)?.DataContext as IslandsShellViewModel)?.Lists?.UserLists
.Where(l => l.Id != vm.CurrentListId).ToList() ?? [];
if (targets.Count > 0)
{
var moveTo = new MenuItem { Header = Loc.T("tasks.ctxMoveToList"), DataContext = row };
foreach (var target in targets)
{
var item = new MenuItem { Header = target.Name, DataContext = row, Tag = target };
item.Click += OnMoveToListClick;
moveTo.Items.Add(item);
}
menu.Items.Add(moveTo);
}
menu.Items.Add(new Separator()); menu.Items.Add(new Separator());
menu.Items.Add(MakeItem("tasks.ctxDeleteTask", OnDeleteTaskClick, reason: row.DeleteDisabledReason)); menu.Items.Add(MakeItem("tasks.ctxDeleteTask", OnDeleteTaskClick, reason: row.DeleteDisabledReason));
@@ -152,6 +170,13 @@ public partial class TaskRowView : UserControl
await vm.RemoveFromMyDayCommand.ExecuteAsync(row); await vm.RemoveFromMyDayCommand.ExecuteAsync(row);
} }
private async void OnMoveToListClick(object? sender, RoutedEventArgs e)
{
if (sender is not MenuItem { Tag: ListNavItemViewModel target }) return;
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.MoveTaskToListAsync(row, target);
}
private async void OnDeleteTaskClick(object? sender, RoutedEventArgs e) private async void OnDeleteTaskClick(object? sender, RoutedEventArgs e)
{ {
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
@@ -31,6 +31,7 @@
<TextBlock Text="{Binding StatusPill}"/> <TextBlock Text="{Binding StatusPill}"/>
</Border> </Border>
<Button Classes="icon-btn" Classes.active="{Binding IsShowingCompleted}" <Button Classes="icon-btn" Classes.active="{Binding IsShowingCompleted}"
IsVisible="{Binding !IsNotesList}"
Command="{Binding ToggleShowCompletedCommand}" Command="{Binding ToggleShowCompletedCommand}"
ToolTip.Tip="{loc:Tr tasks.showCompletedTip}"> ToolTip.Tip="{loc:Tr tasks.showCompletedTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/> <PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/>
@@ -60,7 +61,8 @@
<Path Classes="icon-stroke" Data="{StaticResource Icon.PlanDay}"/> <Path Classes="icon-stroke" Data="{StaticResource Icon.PlanDay}"/>
</Viewbox> </Viewbox>
</Button> </Button>
<Button Classes="icon-btn" Command="{Binding OpenListSettingsCommand}" ToolTip.Tip="{loc:Tr tasks.listSettingsTip}"> <Button Classes="icon-btn" IsVisible="{Binding !IsNotesList}"
Command="{Binding OpenListSettingsCommand}" ToolTip.Tip="{loc:Tr tasks.listSettingsTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Settings}"/> <PathIcon Width="15" Height="15" Data="{StaticResource Icon.Settings}"/>
</Button> </Button>
</StackPanel> </StackPanel>
@@ -68,7 +70,8 @@
</Border> </Border>
<!-- Add-task row --> <!-- Add-task row -->
<Border DockPanel.Dock="Top" Classes="add-task" Margin="16,14,16,8"> <Border DockPanel.Dock="Top" Classes="add-task" Margin="16,14,16,8"
IsVisible="{Binding !IsNotesList}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="add-task-plus" VerticalAlignment="Center"> <Border Grid.Column="0" Classes="add-task-plus" VerticalAlignment="Center">
<PathIcon Width="12" Height="12" Data="{StaticResource Icon.Plus}" <PathIcon Width="12" Height="12" Data="{StaticResource Icon.Plus}"
@@ -90,14 +93,6 @@
</Grid> </Grid>
</Border> </Border>
<!-- Notes pinned row (My Day only) -->
<Button DockPanel.Dock="Top"
Classes="btn" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Margin="16,0,16,8"
IsVisible="{Binding ShowNotesRow}"
Command="{Binding OpenNotesCommand}"
Content="{loc:Tr tasks.notesPinnedRow}"/>
<!-- Task list: one flat, virtualized ListBox. Rows is HeaderRow | TaskRowViewModel — group <!-- Task list: one flat, virtualized ListBox. Rows is HeaderRow | TaskRowViewModel — group
headers are regular entries, not a separate ItemsControl per section, so a headers are regular entries, not a separate ItemsControl per section, so a
VirtualizingStackPanel actually bounds the realized container count. VirtualizingStackPanel actually bounds the realized container count.
@@ -106,6 +101,7 @@
unreachable. --> unreachable. -->
<Panel> <Panel>
<ListBox x:Name="RowsListBox" <ListBox x:Name="RowsListBox"
IsVisible="{Binding !IsNotesList}"
ItemsSource="{Binding Rows}" ItemsSource="{Binding Rows}"
Background="Transparent" Background="Transparent"
BorderThickness="0"> BorderThickness="0">
@@ -172,6 +168,11 @@
Foreground="{DynamicResource TextMuteBrush}" Foreground="{DynamicResource TextMuteBrush}"
TextAlignment="Center" TextWrapping="Wrap"/> TextAlignment="Center" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<!-- The notes list is not a task list: same island slot, different content. -->
<Panel IsVisible="{Binding IsNotesList}">
<islands:NotesEditorView DataContext="{Binding Notes}"/>
</Panel>
</Panel> </Panel>
</DockPanel> </DockPanel>
+1 -24
View File
@@ -144,7 +144,7 @@
ResizeDirection="Columns" ResizeDirection="Columns"
ResizeBehavior="PreviousAndNext"/> ResizeBehavior="PreviousAndNext"/>
<Border Grid.Column="2" Classes="island" Margin="3"> <Border Grid.Column="2" Grid.ColumnSpan="{Binding TasksColumnSpan}" Classes="island" Margin="3">
<islands:TasksIslandView DataContext="{Binding Tasks}"/> <islands:TasksIslandView DataContext="{Binding Tasks}"/>
</Border> </Border>
@@ -259,29 +259,6 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Stale-worker notice — bottom-right corner, above the update popup. Persistent
(no auto-dismiss timer): the running worker predates the selected list's merged
HEAD, so a restart is needed before "verified" claims against it are trustworthy. -->
<Border Grid.Row="0" Grid.RowSpan="3"
VerticalAlignment="Bottom" HorizontalAlignment="Right"
Margin="0,0,16,100"
ZIndex="100"
IsVisible="{Binding IsStaleWorkerBannerVisible}"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="6"
Padding="14,10">
<StackPanel Spacing="8" Width="260">
<TextBlock Classes="body" Text="{loc:Tr shell.staleWorker.message}" TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Classes="btn"
Content="{loc:Tr shell.menu.restartWorker}"
Command="{Binding RestartWorkerCommand}"/>
</StackPanel>
</StackPanel>
</Border>
<!-- External merge popup card — bottom-left corner, while an MCP session owns an <!-- External merge popup card — bottom-left corner, while an MCP session owns an
unresolved unit-merge conflict. Does not auto-dismiss; stays until the merge ends. --> unresolved unit-merge conflict. Does not auto-dismiss; stays until the merge ends. -->
<Border Grid.Row="0" Grid.RowSpan="3" <Border Grid.Row="0" Grid.RowSpan="3"
@@ -4,6 +4,7 @@
xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl" xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl"
xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl" xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl"
xmlns:controls="using:ClaudeDo.Ui.Views.Controls" xmlns:controls="using:ClaudeDo.Ui.Views.Controls"
xmlns:conv="using:ClaudeDo.Ui.Converters"
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:MissionControlViewModel" x:DataType="vm:MissionControlViewModel"
x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView"> x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView">
@@ -33,9 +34,19 @@
Margin="0,0,4,0" Margin="0,0,4,0"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Classes="icon-btn" <Button Classes="icon-btn"
Click="OnNewSessionClicked"
ToolTip.Tip="{loc:Tr missionControl.newSession}"> ToolTip.Tip="{loc:Tr missionControl.newSession}">
<PathIcon Data="{StaticResource Icon.Plus}" Width="15" Height="15"/> <PathIcon Data="{StaticResource Icon.Plus}" Width="15" Height="15"/>
<Button.Flyout>
<MenuFlyout ItemsSource="{Binding SessionTargets}" Opening="OnSessionFlyoutOpening">
<MenuFlyout.ItemContainerTheme>
<ControlTheme TargetType="MenuItem" BasedOn="{StaticResource {x:Type MenuItem}}"
x:DataType="vm:SessionTargetViewModel">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command" Value="{Binding StartCommand}"/>
</ControlTheme>
</MenuFlyout.ItemContainerTheme>
</MenuFlyout>
</Button.Flyout>
</Button> </Button>
<Button Classes="icon-btn" <Button Classes="icon-btn"
Command="{Binding ToggleLayoutCommand}" Command="{Binding ToggleLayoutCommand}"
@@ -100,7 +111,7 @@
BorderThickness="1,0,0,0"> BorderThickness="1,0,0,0">
<DockPanel LastChildFill="True" Margin="10,10"> <DockPanel LastChildFill="True" Margin="10,10">
<TextBlock DockPanel.Dock="Top" Classes="eyebrow" <TextBlock DockPanel.Dock="Top" Classes="eyebrow"
Text="{loc:Tr missionControl.queue}" Text="{loc:Tr missionControl.activity}"
Foreground="{DynamicResource TextMuteBrush}" Foreground="{DynamicResource TextMuteBrush}"
LetterSpacing="1.4" Margin="0,0,0,8" /> LetterSpacing="1.4" Margin="0,0,0,8" />
<ScrollViewer> <ScrollViewer>
@@ -112,27 +123,31 @@
Background="Transparent" BorderThickness="0" Background="Transparent" BorderThickness="0"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Cursor="Hand"> Cursor="Hand">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr missionControl.openSession}"
Command="{Binding OpenSessionCommand}"
IsEnabled="{Binding CanOpenSession}" />
</ContextMenu>
</Button.ContextMenu>
<Panel> <Panel>
<Border Background="{DynamicResource SurfaceBrush}" <Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" /> BorderThickness="1" CornerRadius="6" />
<Border Background="{DynamicResource RunningTintBrush}" <!-- Single state tint; brush keys come from the VM (Tokens.axaml). -->
BorderBrush="{DynamicResource RunningTintBorderBrush}" <Border Background="{Binding TintKey, Converter={x:Static conv:DotBrushConverter.Instance}}"
BorderBrush="{Binding TintBorderKey, Converter={x:Static conv:DotBrushConverter.Instance}}"
BorderThickness="1" CornerRadius="6" BorderThickness="1" CornerRadius="6"
IsVisible="{Binding IsRunning}" /> IsVisible="{Binding TintKey, Converter={x:Static ObjectConverters.IsNotNull}}" />
<StackPanel Margin="8,6" Spacing="2"> <StackPanel Margin="8,6" Spacing="2">
<TextBlock Text="{Binding Title}" <TextBlock Text="{Binding Title}"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Title}" ToolTip.Tip="{Binding Title}"
Foreground="{DynamicResource TextDimBrush}" /> Foreground="{DynamicResource TextDimBrush}" />
<TextBlock Classes="meta" <TextBlock Classes="meta"
Text="{loc:Tr missionControl.blocked}" Text="{Binding StatusLabel}"
IsVisible="{Binding IsBlocked}" IsVisible="{Binding StatusLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Foreground="{DynamicResource AmberBrush}" /> Foreground="{Binding StatusColorKey, Converter={x:Static conv:DotBrushConverter.Instance}}" />
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.running}"
IsVisible="{Binding IsRunning}"
Foreground="{DynamicResource StatusRunningBrush}" />
</StackPanel> </StackPanel>
</Panel> </Panel>
</Button> </Button>
@@ -3,7 +3,6 @@ using System.Collections.Specialized;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
@@ -30,8 +29,10 @@ public partial class MissionControlView : UserControl
QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width; QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width;
} }
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path. // Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
private async void OnNewSessionClicked(object? sender, RoutedEventArgs e) private async void BrowseForSessionAsync()
{ {
if (DataContext is not MissionControlViewModel vm) return; if (DataContext is not MissionControlViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
@@ -53,6 +54,7 @@ public partial class MissionControlView : UserControl
{ {
_vm.Panes.CollectionChanged -= OnPanesChanged; _vm.Panes.CollectionChanged -= OnPanesChanged;
_vm.PropertyChanged -= OnViewModelPropertyChanged; _vm.PropertyChanged -= OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = null;
} }
_vm = DataContext as MissionControlViewModel; _vm = DataContext as MissionControlViewModel;
@@ -61,6 +63,7 @@ public partial class MissionControlView : UserControl
{ {
_vm.Panes.CollectionChanged += OnPanesChanged; _vm.Panes.CollectionChanged += OnPanesChanged;
_vm.PropertyChanged += OnViewModelPropertyChanged; _vm.PropertyChanged += OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
} }
RebuildOverviewGrid(); RebuildOverviewGrid();
@@ -39,6 +39,7 @@
Background="Transparent" Margin="12,16,8,8" Background="Transparent" Margin="12,16,8,8"
VerticalAlignment="Top"> VerticalAlignment="Top">
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionGeneral}"/> <ListBoxItem Content="{loc:Tr modals.listSettings.sectionGeneral}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionIcon}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionAgent}"/> <ListBoxItem Content="{loc:Tr modals.listSettings.sectionAgent}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionVerify}"/> <ListBoxItem Content="{loc:Tr modals.listSettings.sectionVerify}"/>
</ListBox> </ListBox>
@@ -102,6 +103,13 @@
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding PowerUseAllowed, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.powerUseAllowed}"/>
<TextBlock Text="{loc:Tr modals.listSettings.powerUseAllowedHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding TicketsAvailable}"> <StackPanel Spacing="4" IsVisible="{Binding TicketsAvailable}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/> <TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/>
<ComboBox ItemsSource="{Binding TicketProjects}" <ComboBox ItemsSource="{Binding TicketProjects}"
@@ -113,6 +121,35 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<!-- ICON -->
<TabItem Header="{loc:Tr modals.listSettings.sectionIcon}">
<ScrollViewer>
<StackPanel Spacing="8" Margin="0,8,0,0">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.icon}"/>
<ListBox ItemsSource="{Binding Icons}"
SelectedItem="{Binding Icon, Mode=TwoWay}"
Background="Transparent" BorderThickness="0"
HorizontalAlignment="Left">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!-- No Foreground here on purpose: the icon inherits the container's, so the
selected/hover states of ListBoxItem reach it (see Ui/CLAUDE.md). -->
<PathIcon Width="20" Height="20" Margin="6"
Data="{Binding Converter={StaticResource IconKey}}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Text="{loc:Tr modals.listSettings.iconHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- AGENT --> <!-- AGENT -->
<TabItem Header="{loc:Tr modals.listSettings.sectionAgent}"> <TabItem Header="{loc:Tr modals.listSettings.sectionAgent}">
<ScrollViewer> <ScrollViewer>
@@ -251,6 +251,17 @@
Content="{loc:Tr settings.general.autoContinueOnUsageLimit}"/> Content="{loc:Tr settings.general.autoContinueOnUsageLimit}"/>
<TextBlock Text="{loc:Tr settings.general.autoContinueOnUsageLimitHint}" <TextBlock Text="{loc:Tr settings.general.autoContinueOnUsageLimitHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
<!-- PowerUse itself is toggled on the usage monitor; only its handoff budget is
typed here, because it also applies to a normal (interactive) handler run. -->
<StackPanel Spacing="4">
<TextBlock Classes="eyebrow" Text="{loc:Tr settings.general.listHandlerMaxHandoffs}"/>
<NumericUpDown Value="{Binding General.ListHandlerMaxHandoffs, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="1" Maximum="200" Increment="2" FormatString="0"
HorizontalAlignment="Left" Width="140"/>
<TextBlock Text="{loc:Tr settings.general.listHandlerMaxHandoffsHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel> </StackPanel>
<!-- Throttle stages live on the usage-monitor gauges (drag + spinner per stage); <!-- Throttle stages live on the usage-monitor gauges (drag + spinner per stage);
@@ -433,7 +444,7 @@
</StackPanel> </StackPanel>
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="{loc:Tr settings.files.promptsSection}"/> <TextBlock Classes="section-label" Text="{loc:Tr settings.files.promptsSection}"/>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="120,*,Auto" RowSpacing="8"> <Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="120,*,Auto" RowSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.files.systemPrompt}" VerticalAlignment="Center"/> <TextBlock Grid.Row="0" Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.files.systemPrompt}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono" Text="{Binding Files.SystemPromptPath}" VerticalAlignment="Center"/> <TextBlock Grid.Row="0" Grid.Column="1" Classes="path-mono" Text="{Binding Files.SystemPromptPath}" VerticalAlignment="Center"/>
<Button Classes="btn" Grid.Row="0" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}" <Button Classes="btn" Grid.Row="0" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}"
@@ -458,6 +469,10 @@
<TextBlock Grid.Row="5" Grid.Column="1" Classes="path-mono" Text="{Binding Files.WeeklyReportPromptPath}" VerticalAlignment="Center"/> <TextBlock Grid.Row="5" Grid.Column="1" Classes="path-mono" Text="{Binding Files.WeeklyReportPromptPath}" VerticalAlignment="Center"/>
<Button Classes="btn" Grid.Row="5" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}" <Button Classes="btn" Grid.Row="5" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}"
Command="{Binding Files.ViewPromptCommand}" CommandParameter="WeeklyReport"/> Command="{Binding Files.ViewPromptCommand}" CommandParameter="WeeklyReport"/>
<TextBlock Grid.Row="6" Grid.Column="0" Classes="field-label" Text="{loc:Tr settings.files.powerUsePrompt}" VerticalAlignment="Center"/>
<TextBlock Grid.Row="6" Grid.Column="1" Classes="path-mono" Text="{Binding Files.PowerUsePromptPath}" VerticalAlignment="Center"/>
<Button Classes="btn" Grid.Row="6" Grid.Column="2" Content="{loc:Tr settings.files.viewPrompt}"
Command="{Binding Files.ViewPromptCommand}" CommandParameter="PowerUseAddendum"/>
</Grid> </Grid>
</StackPanel> </StackPanel>
<StackPanel Spacing="6" IsVisible="{Binding Files.ViewedKindName, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> <StackPanel Spacing="6" IsVisible="{Binding Files.ViewedKindName, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
@@ -134,6 +134,42 @@
Text="{loc:Tr modals.usageMonitor.noGauges}" Text="{loc:Tr modals.usageMonitor.noGauges}"
IsVisible="{Binding !GaugeRows.Count}"/> IsVisible="{Binding !GaugeRows.Count}"/>
<!-- PowerUse: sits under the gauges on purpose — "spend the rest of the window" is a
decision made while looking at how much window is left. Saves immediately on toggle. -->
<Border DockPanel.Dock="Top" Margin="20,12,20,0" Padding="12,10"
Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6">
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding PowerUseEnabled, Mode=TwoWay}"
Content="{loc:Tr modals.usageMonitor.powerUse}"/>
<TextBlock Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr modals.usageMonitor.powerUseHint}"/>
<!-- Window + handoff threshold. Same commit-on-blur/Enter discipline as the gauge
legend boxes: a hub write per keystroke would be one save per typed digit. -->
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,6,0,0">
<TextBlock Classes="meta" VerticalAlignment="Center"
Text="{loc:Tr modals.usageMonitor.powerUseWindow}"/>
<TextBox Width="70" FontSize="12" Padding="6,2"
Watermark="{loc:Tr modals.usageMonitor.powerUseWindowPlaceholder}"
Text="{Binding PowerUseWindowStart, Mode=TwoWay}"
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="–"/>
<TextBox Width="70" FontSize="12" Padding="6,2"
Watermark="{loc:Tr modals.usageMonitor.powerUseWindowPlaceholder}"
Text="{Binding PowerUseWindowEnd, Mode=TwoWay}"
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Margin="12,0,0,0"
Text="{loc:Tr modals.usageMonitor.powerUseHandoffPct}"/>
<NumericUpDown Width="80" FontSize="12" Padding="6,2" ShowButtonSpinner="False"
Minimum="50" Maximum="99" Increment="1" FormatString="0"
Value="{Binding PowerUseHandoffPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
LostFocus="OnPowerUseBoxCommit" KeyDown="OnPowerUseBoxKeyDown"/>
</StackPanel>
<TextBlock Classes="meta" TextWrapping="Wrap" Opacity="0.75"
Text="{loc:Tr modals.usageMonitor.powerUseWindowHint}"/>
</StackPanel>
</Border>
<!-- Period picker --> <!-- Period picker -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="20,12,20,0"> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="20,12,20,0">
<TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.period}" VerticalAlignment="Center"/> <TextBlock Classes="meta" Text="{loc:Tr modals.usageMonitor.period}" VerticalAlignment="Center"/>
@@ -33,4 +33,19 @@ public partial class UsageMonitorModalView : Window
OnStageBoxCommit(sender, e); OnStageBoxCommit(sender, e);
e.Handled = true; e.Handled = true;
} }
/// <summary>Persists the PowerUse window / handoff threshold on blur — a save per keystroke
/// would be one hub round-trip per typed digit.</summary>
private void OnPowerUseBoxCommit(object? sender, RoutedEventArgs e)
{
if (DataContext is not UsageMonitorModalViewModel vm) return;
if (vm.SavePowerUseCommand.CanExecute(null)) vm.SavePowerUseCommand.Execute(null);
}
private void OnPowerUseBoxKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
OnPowerUseBoxCommit(sender, e);
e.Handled = true;
}
} }
+17 -7
View File
@@ -50,8 +50,8 @@ subfolder within their area; the namespace stays the area namespace.
## Architecture ## Architecture
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821` - **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, `DependsOnTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` rejects a self-reference, an unknown dependency id, or a link that would create a cycle (walks the proposed predecessor's own `DependsOnTaskId` chain). - **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, and the `task_dependencies` edges. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` replaces the task's whole dependency **set** (empty list clears it) and rejects a self-reference, an unknown dependency id, or any link that would create a cycle — a BFS over the whole `task_dependencies` edge set from every proposed predecessor, so a multi-edge loop (A→B→C→A) is caught too, and one bad edge leaves the entire set unwritten.
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and (`DependsOnTaskId IS NULL` OR the dependency's `Status = 'done'`); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). - **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't `done`" (doubly negated on purpose: zero edges stays eligible, all edges must be done, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle. - **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. - **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
@@ -67,7 +67,7 @@ not conflated.
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. | | `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. |
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. | | `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. Internal to `PlanningChainCoordinator` — resolves (or cascades) on ANY terminal state of the predecessor. Because it self-resolves, `wait_for_task_change` deliberately keeps **waiting** through it instead of reporting `"Blocked"`. | | `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. Internal to `PlanningChainCoordinator` — resolves (or cascades) on ANY terminal state of the predecessor. Because it self-resolves, `wait_for_task_change` deliberately keeps **waiting** through it instead of reporting `"Blocked"`. |
| `DependsOnTaskId` | nullable FK | User/MCP-declared predecessor (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker only skips a queued row while the dependency's `Status` isn't `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `blocked`/`blockedReason`; `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. | | `Dependencies` | 0..n rows in `task_dependencies` | User/MCP-declared predecessors (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker skips a queued row until **every** dependency reached `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `dependsOnTaskIds` + `blocked`/`blockedReason` (the reason names **all** open predecessors, via `External/TaskBlocking`); `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. |
| `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. | | `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
| `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. | | `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. |
@@ -275,11 +275,16 @@ refresh token uses, one instance per file, DI markers `OnlineRefreshTokenStore`/
distinguish the two singletons). The token never touches `worker.config.json` or the DB. distinguish the two singletons). The token never touches `worker.config.json` or the DB.
- **Import** (`TicketImportService`, hub `ImportTickets`) — per-list, manual only (no polling): pulls - **Import** (`TicketImportService`, hub `ImportTickets`) — per-list, manual only (no polling): pulls
the linked project's board (`ListConfigEntity.TicketProjectId`), keeps tickets that are `StatusId the linked project's board (`ListConfigEntity.TicketProjectId`), keeps tickets whose `StatusId` is
== 1` (Offen) *and* assigned to the PAT owner (case-insensitive name match), skips any `TicketRef` `1` (Offen) or `2` (InBearbeitung) *and* that are assigned to the PAT owner, skips any `TicketRef`
already present on the list, and creates one `Idle` task per remainder via `TaskRepository.AddAsync` already present on the list, and creates one `Idle` task per remainder via `TaskRepository.AddAsync`
(so it gets a `Number` like every other task). Already-imported tickets are never updated — a (so it gets a `Number` like every other task). Already-imported tickets are never updated — a
re-import would overwrite task notes. re-import would overwrite task notes.
⚠️ **The assignee lives in `ticketAssignees[]`, never in the flat `assigneeName`** — that field is
null on every ticket the live API returns (checked across all 63 in Abteilung Entwicklung,
2026-09-01), so the original filter imported nothing, ever. `personInChargeName` is the wrong
source too: it holds the login on some rows and the display name ("Mika Kuns") on others, while
`ticketAssignees[].assigneeName` matches `/pat/me`'s `userName` format exactly.
- **Write-back** (`TicketStatusSync`) hangs off `TaskStateService.NotifyAsync` — the one place every - **Write-back** (`TicketStatusSync`) hangs off `TaskStateService.NotifyAsync` — the one place every
status transition passes through. Mapping: `Running`/`WaitingForReview` → ticket status `2 status transition passes through. Mapping: `Running`/`WaitingForReview` → ticket status `2
InBearbeitung`, `Done` → `3 Fertig`, everything else writes nothing (`1 Offen` is never written InBearbeitung`, `Done` → `3 Fertig`, everything else writes nothing (`1 Offen` is never written
@@ -290,8 +295,13 @@ distinguish the two singletons). The token never touches `worker.config.json` or
- `BandelTicketClient` wraps the four REST calls (`GET /api/ticketsystem/pat/me`, `GET - `BandelTicketClient` wraps the four REST calls (`GET /api/ticketsystem/pat/me`, `GET
/api/Navigation/sidebar`, `GET /api/Board/project/{id}`, `PATCH /api/Ticket/{id}/status`); every /api/Navigation/sidebar`, `GET /api/Board/project/{id}`, `PATCH /api/Ticket/{id}/status`); every
response is unwrapped from `BandelEnvelope<T>` and a non-2xx/`success:false` throws response is unwrapped from `BandelEnvelope<T>` and a non-2xx/`success:false` throws
`TicketApiException` with a readable message. ⚠️ The endpoint shapes were taken from the `TicketApiException` with a readable message. All four are **live-verified (2026-09-01)** against
`Bandel.APIs` source, never confirmed against a live call — see `docs/open.md`. `http://192.168.45.161`.
⚠️ `PATCH /api/Ticket/{id}/status` needs **both** `statusId` and `statusName` — omitting the name
is a 400 (`"The StatusName field is required."`) and no status is written. The name is *not*
validated against the id (`statusId` wins, any non-empty string passes), but it must be there;
`TicketStatusMap.NameOf` supplies the canonical name.
⚠️ The base URL must be the **IP** — `bandelapis.fb-tuning.local` still refuses the connection.
## Notes ## Notes
+3 -14
View File
@@ -25,6 +25,9 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>ClaudeTaskWorker.ico</ApplicationIcon> <ApplicationIcon>ClaudeTaskWorker.ico</ApplicationIcon>
<!-- Sdk.Web defaults to Server GC: one heap per core (22 here) with a ~350 MB gen0 budget,
so an idle worker sat at ~230 MB private. Workstation GC: ~40 MB. -->
<ServerGarbageCollection>false</ServerGarbageCollection>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -35,18 +38,4 @@
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" /> <InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
</ItemGroup> </ItemGroup>
<!-- Stamps this worker's exact commit into AssemblyInformationalVersion as "+{sha}" so a
running instance can report which commit it was built from (see WorkerHub.GetWorkerBuildInfo).
Independent of MinVer: MinVerVersionOverride (release builds) replaces the base version but
doesn't touch SourceRevisionId, and the SDK appends it to InformationalVersion regardless. -->
<Target Name="SetBuildRevisionFromGit" BeforeTargets="GetAssemblyVersion" Condition="'$(SourceRevisionId)' == ''">
<Exec Command="git rev-parse HEAD"
WorkingDirectory="$(MSBuildProjectDirectory)"
ConsoleToMSBuild="true"
StandardOutputImportance="low"
ContinueOnError="WarnAndContinue">
<Output TaskParameter="ConsoleOutput" PropertyName="SourceRevisionId" />
</Exec>
</Target>
</Project> </Project>
+10 -2
View File
@@ -45,6 +45,7 @@ public sealed record BatchTaskDetailDto(
int? SortOrder = null, int? SortOrder = null,
int? RoadblockCount = null, int? RoadblockCount = null,
string? RoadblockText = null, string? RoadblockText = null,
string? FailureReason = null,
bool DescriptionTruncated = false, bool DescriptionTruncated = false,
int? DescriptionFullLength = null, int? DescriptionFullLength = null,
bool ResultTruncated = false, bool ResultTruncated = false,
@@ -80,11 +81,17 @@ public sealed class BatchMcpTools
{ {
"listId", "title", "description", "status", "result", "createdBy", "createdAt", "listId", "title", "description", "status", "result", "createdBy", "createdAt",
"startedAt", "finishedAt", "isMyDay", "sortOrder", "roadblockCount", "roadblockText", "startedAt", "finishedAt", "isMyDay", "sortOrder", "roadblockCount", "roadblockText",
"failureReason",
}; };
private readonly ExternalMcpService _svc; private readonly ExternalMcpService _svc;
private readonly WorktreeMcpTools _worktree;
public BatchMcpTools(ExternalMcpService svc) => _svc = svc; public BatchMcpTools(ExternalMcpService svc, WorktreeMcpTools worktree)
{
_svc = svc;
_worktree = worktree;
}
[McpServerTool, Description( [McpServerTool, Description(
"Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " + "Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " +
@@ -187,6 +194,7 @@ public sealed class BatchMcpTools
SortOrder: Want("sortOrder") ? t.SortOrder : null, SortOrder: Want("sortOrder") ? t.SortOrder : null,
RoadblockCount: Want("roadblockCount") ? t.RoadblockCount : null, RoadblockCount: Want("roadblockCount") ? t.RoadblockCount : null,
RoadblockText: Want("roadblockText") ? ExtractRoadblockText(t.Result) : null, RoadblockText: Want("roadblockText") ? ExtractRoadblockText(t.Result) : null,
FailureReason: Want("failureReason") ? t.FailureReason : null,
DescriptionTruncated: descTruncated, DescriptionTruncated: descTruncated,
DescriptionFullLength: descFullLength, DescriptionFullLength: descFullLength,
ResultTruncated: resultTruncated, ResultTruncated: resultTruncated,
@@ -362,7 +370,7 @@ public sealed class BatchMcpTools
var id = taskIds[i]; var id = taskIds[i];
try try
{ {
var r = await _svc.CleanupTaskWorktree(id, force, cancellationToken); var r = await _worktree.CleanupTaskWorktree(id, force, cancellationToken);
results.Add(new BatchCleanupResult(id, true, r.Removed, r.BranchDeleted, null, r.Number)); results.Add(new BatchCleanupResult(id, true, r.Removed, r.BranchDeleted, null, r.Number));
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
File diff suppressed because it is too large Load Diff
+63 -8
View File
@@ -1,7 +1,9 @@
using System.ComponentModel; using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External; namespace ClaudeDo.Worker.External;
@@ -9,7 +11,13 @@ namespace ClaudeDo.Worker.External;
// Coerced=true means nextPhase asked for a non-final phase but the server-side round cap // Coerced=true means nextPhase asked for a non-final phase but the server-side round cap
// (HandoffRoundTracker) forced it to its "_final" counterpart instead -- NextPhase already // (HandoffRoundTracker) forced it to its "_final" counterpart instead -- NextPhase already
// reflects the effective (post-coercion) value in that case. // reflects the effective (post-coercion) value in that case.
public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int Number, int SurvivingCount, string NextPhase, bool Coerced = false); // DeferredUntil set means the next session was NOT opened yet: the 5h usage window is exhausted and
// the handoff is parked until it resets (HandlerUsageGuard).
public sealed record HandoffListHandlerResult(
bool Requested, string TaskId, int Number, int SurvivingCount, string NextPhase, bool Coerced = false,
DateTimeOffset? DeferredUntil = null);
public sealed record HandoffStatusResult(string TaskId, int Number, int Round, int Remaining, bool Final);
[McpServerToolType] [McpServerToolType]
public sealed class HandoffMcpTools public sealed class HandoffMcpTools
@@ -17,12 +25,21 @@ public sealed class HandoffMcpTools
private readonly TaskRepository _tasks; private readonly TaskRepository _tasks;
private readonly HubBroadcaster _broadcaster; private readonly HubBroadcaster _broadcaster;
private readonly HandoffRoundTracker _rounds; private readonly HandoffRoundTracker _rounds;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly HandlerUsageGuard _usageGuard;
public HandoffMcpTools(TaskRepository tasks, HubBroadcaster broadcaster, HandoffRoundTracker rounds) public HandoffMcpTools(
TaskRepository tasks,
HubBroadcaster broadcaster,
HandoffRoundTracker rounds,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
HandlerUsageGuard usageGuard)
{ {
_tasks = tasks; _tasks = tasks;
_broadcaster = broadcaster; _broadcaster = broadcaster;
_rounds = rounds; _rounds = rounds;
_dbFactory = dbFactory;
_usageGuard = usageGuard;
} }
[McpServerTool, Description( [McpServerTool, Description(
@@ -33,11 +50,14 @@ public sealed class HandoffMcpTools
"after triage) or \"merge\" (review + merge; the normal handoff after wait) -- \"wait_final\"/" + "after triage) or \"merge\" (review + merge; the normal handoff after wait) -- \"wait_final\"/" +
"\"merge_final\" are only for a merge session that started reruns and must eventually stop the " + "\"merge_final\" are only for a merge session that started reruns and must eventually stop the " +
"chain instead of restarting again. An unrecognized value is rejected. This handoff chain is " + "chain instead of restarting again. An unrecognized value is rejected. This handoff chain is " +
"capped server-side at four handoffs (i.e. two " + "capped server-side at a configurable number of handoffs (the ListHandlerMaxHandoffs setting, " +
"4 by default, i.e. two " +
"full wait/merge cycles) for a given handler task: once reached, a \"wait\"/\"merge\" request is " + "full wait/merge cycles) for a given handler task: once reached, a \"wait\"/\"merge\" request is " +
"silently coerced to its \"_final\" counterpart instead (check the result's coerced field), and " + "silently coerced to its \"_final\" counterpart instead (check the result's coerced field), and " +
"any handoff at all after a merge_final round for this task throws. The current tile stays " + "any handoff at all after a merge_final round for this task throws. If the 5h usage window is " +
"open; you must end your own turn immediately after calling this.")] "already exhausted the next session is NOT opened right away -- the handoff is parked until " +
"the window resets and the result carries deferredUntil; say so in your summary. The current " +
"tile stays open; you must end your own turn immediately after calling this either way.")]
public async Task<HandoffListHandlerResult> HandoffListHandler( public async Task<HandoffListHandlerResult> HandoffListHandler(
[Description("This session's own handler task id.")] string taskId, [Description("This session's own handler task id.")] string taskId,
[Description("The tasks that made it past dedupe, in the order to run them.")] IReadOnlyList<string> survivingTaskIds, [Description("The tasks that made it past dedupe, in the order to run them.")] IReadOnlyList<string> survivingTaskIds,
@@ -59,11 +79,46 @@ public sealed class HandoffMcpTools
_ = await _tasks.GetByIdAsync(id, cancellationToken) _ = await _tasks.GetByIdAsync(id, cancellationToken)
?? throw new InvalidOperationException($"Task {id} not found."); ?? throw new InvalidOperationException($"Task {id} not found.");
var round = _rounds.Register(taskId, nextPhase); await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(cancellationToken)).ListHandlerMaxHandoffs;
var round = _rounds.Register(taskId, nextPhase, maxHandoffs);
if (round.Rejected) if (round.Rejected)
throw new InvalidOperationException(round.Error); throw new InvalidOperationException(round.Error);
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase); // The round is registered either way (the budget is spent by asking, not by the tile
return new HandoffListHandlerResult(true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced); // opening); only the broadcast waits when the 5h window is exhausted.
var deferredUntil = await _usageGuard.TryDeferHandoffAsync(
taskId, survivingTaskIds, round.EffectivePhase, cancellationToken);
if (deferredUntil is null)
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
return new HandoffListHandlerResult(
true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced, deferredUntil);
}
[McpServerTool, Description(
"Read-only lookup of a handler task's handoff-round budget (round already used, rounds " +
"remaining before handoff_list_handler starts coercing to a _final phase, and whether the " +
"chain is already sealed by a completed merge_final round) -- for checking the budget mid-" +
"session or from outside a handoff, instead of only seeing it once at the start of a round. " +
"A task that never called handoff_list_handler reports round 0, its full remaining budget, " +
"and final=false. In-memory only, same as the tracker itself: a worker restart resets it." +
McpToolDocs.TaskNumberHint)]
public async Task<HandoffStatusResult> GetHandoffStatus(
[Description("The handler task's own id.")] string taskId,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
// Same setting HandoffListHandler enforces against — read here too, so a raised cap shows up
// in the reported budget instead of the tool confidently understating it.
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(cancellationToken)).ListHandlerMaxHandoffs;
var status = _rounds.GetStatus(taskId, maxHandoffs);
return new HandoffStatusResult(taskId, task.Number, status.Round, status.Remaining, status.Final);
} }
} }
+45 -7
View File
@@ -5,16 +5,18 @@ namespace ClaudeDo.Worker.External;
// Bounds the list-handler wait<->merge handoff chain server-side, so a model that ignores the // Bounds the list-handler wait<->merge handoff chain server-side, so a model that ignores the
// cooperative "this is the FINAL round" line in handoff.md (InteractiveLaunchSpecService's // cooperative "this is the FINAL round" line in handoff.md (InteractiveLaunchSpecService's
// finalNote) cannot loop wait->merge->wait->merge forever, each round spawning a fresh ConPTY // roundInfo, read back via GetState) cannot loop wait->merge->wait->merge forever, each round
// session. Keyed by handler task id -- a "Let Claude handle it" run always creates a fresh // spawning a fresh ConPTY session. Keyed by handler task id -- a "Let Claude handle it" run always creates a fresh
// handler task (InteractiveLaunchSpecService.CreateMergeHelperTaskAsync), so the counter is // handler task (InteractiveLaunchSpecService.CreateMergeHelperTaskAsync), so the counter is
// per-run by construction: no reset logic needed, and a later run on the same list starts at 0 // per-run by construction: no reset logic needed, and a later run on the same list starts at 0
// on its own. In-memory only, same tradeoff as TaskRunTokenRegistry -- a worker restart mid-run // on its own. In-memory only, same tradeoff as TaskRunTokenRegistry -- a worker restart mid-run
// resets the count, which is acceptable since the chain restarts fresh anyway. // resets the count, which is acceptable since the chain restarts fresh anyway.
public sealed class HandoffRoundTracker public sealed class HandoffRoundTracker
{ {
// Two full wait->merge cycles: wait, merge, wait, merge. // Two full wait->merge cycles: wait, merge, wait, merge. Only the fallback now -- the live cap
public const int MaxHandoffs = 4; // comes from AppSettingsEntity.ListHandlerMaxHandoffs, passed per call by HandoffMcpTools, so a
// run meant to keep going for hours can raise it without the bound disappearing entirely.
public const int DefaultMaxHandoffs = 4;
private sealed class TaskState private sealed class TaskState
{ {
@@ -26,20 +28,56 @@ public sealed class HandoffRoundTracker
public sealed record Result(bool Rejected, bool Coerced, string EffectivePhase, string? Error); public sealed record Result(bool Rejected, bool Coerced, string EffectivePhase, string? Error);
public Result Register(string taskId, string nextPhase) /// <summary>Read-only snapshot for a handler task -- does NOT register a round. Used to report
/// the current round/remaining budget (e.g. in the handoff brief) without mutating state that
/// only Register should advance. Count is 0 for a task that hasn't handed off yet.</summary>
public (int Count, bool Sealed) GetState(string taskId)
{ {
if (!_states.TryGetValue(taskId, out var state))
return (0, false);
lock (state)
return (state.Count, state.Sealed);
}
// Read-only budget lookup for a handler task that hasn't (yet) called HandoffListHandler --
// Round/Remaining/Final all report their untouched defaults instead of throwing, since "no
// handoff yet" is a normal state, not an error.
public sealed record Status(int Round, int Remaining, bool Final);
/// <param name="maxHandoffs">The live cap (AppSettingsEntity.ListHandlerMaxHandoffs), passed in
/// rather than read off a constant so the REPORTED budget can never drift from the cap Register
/// actually enforces. A brief telling the model "2 rounds left" while the chain really allows 40
/// is worse than no budget line at all. Clamped like Register's.</param>
public Status GetStatus(string taskId, int maxHandoffs = DefaultMaxHandoffs)
{
var cap = Math.Max(1, maxHandoffs);
if (!_states.TryGetValue(taskId, out var state))
return new Status(0, cap, false);
lock (state)
return new Status(state.Count, Math.Max(0, cap - state.Count), state.Sealed);
}
/// <param name="maxHandoffs">Cap for this chain, normally AppSettingsEntity.ListHandlerMaxHandoffs.
/// Values below 1 are treated as 1: the cap exists to bound the loop, so "no cap" is not an
/// option a caller gets to pick by passing 0.</param>
public Result Register(string taskId, string nextPhase, int maxHandoffs = DefaultMaxHandoffs)
{
var cap = Math.Max(1, maxHandoffs);
var state = _states.GetOrAdd(taskId, _ => new TaskState()); var state = _states.GetOrAdd(taskId, _ => new TaskState());
lock (state) lock (state)
{ {
if (state.Sealed) if (state.Sealed)
return new Result(true, false, nextPhase, return new Result(true, false, nextPhase,
$"Task {taskId} already completed its merge_final round -- no further handoff is allowed."); $"Task {taskId} already completed its merge_final round -- no further handoff is allowed. " +
"Start a new handler run (\"Let Claude handle it\") on this list instead.");
state.Count++; state.Count++;
var isFinal = nextPhase is MergeHelperPhase.WaitFinal or MergeHelperPhase.MergeFinal; var isFinal = nextPhase is MergeHelperPhase.WaitFinal or MergeHelperPhase.MergeFinal;
var effective = nextPhase; var effective = nextPhase;
if (state.Count > MaxHandoffs && !isFinal) if (state.Count > cap && !isFinal)
{ {
effective = nextPhase == MergeHelperPhase.Wait ? MergeHelperPhase.WaitFinal : MergeHelperPhase.MergeFinal; effective = nextPhase == MergeHelperPhase.Wait ? MergeHelperPhase.WaitFinal : MergeHelperPhase.MergeFinal;
isFinal = true; isFinal = true;
+72
View File
@@ -0,0 +1,72 @@
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
public sealed record StartListHandlerResult(bool Requested, string ListId, string ListName, int TaskCount, IReadOnlyList<int> TaskNumbers);
// The "Let Claude handle it" button, reachable from outside the UI. The worker only broadcasts;
// the App owns the ConPTY tile and creates the handler task exactly as the button does
// (MissionControlViewModel.OpenMergeHelperConPtySessionAsync), so there is one code path for
// starting a run, not two. Without a connected App nothing happens -- the result says so.
[McpServerToolType]
public sealed class ListHandlerMcpTools
{
private readonly ListRepository _lists;
private readonly TaskRepository _tasks;
private readonly HubBroadcaster _broadcaster;
public ListHandlerMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
{
_lists = lists;
_tasks = tasks;
_broadcaster = broadcaster;
}
[McpServerTool, Description(
"Start a list-handler run (\"Let Claude handle it\") for a list, exactly like the button in " +
"Mission Control: the connected ClaudeDo App creates the handler task and opens the triage " +
"ConPTY tile for the given tasks. taskIds defaults to every Idle, non-manual task of the list. " +
"Requires the App to be running and connected to this worker -- the worker only broadcasts " +
"the request. taskIds accept task numbers (#123 or bare 123).")]
public async Task<StartListHandlerResult> StartListHandler(
[Description("The list to run the handler on.")] string listId,
[Description("Tasks to hand to the run, in the order to triage them. Omit for all Idle, non-manual tasks of the list.")] IReadOnlyList<string>? taskIds = null,
CancellationToken cancellationToken = default)
{
var list = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
var listTasks = await _tasks.GetByListIdAsync(listId, cancellationToken);
List<TaskEntity> chosen;
if (taskIds is { Count: > 0 })
{
var resolved = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken);
chosen = new List<TaskEntity>();
foreach (var id in resolved)
{
var t = listTasks.FirstOrDefault(x => x.Id == id)
?? throw new InvalidOperationException($"Task {id} not found in list {list.Name}.");
chosen.Add(t);
}
}
else
{
chosen = listTasks
.Where(t => t.Status == TaskStatus.Idle && !t.IsManual && t.ParentTaskId is null)
.OrderBy(t => t.SortOrder)
.ToList();
}
if (chosen.Count == 0)
throw new InvalidOperationException($"No tasks to hand to the list handler for {list.Name}.");
var ids = chosen.Select(t => t.Id).ToList();
await _broadcaster.ListHandlerStartRequested(listId, ids);
return new StartListHandlerResult(true, listId, list.Name, ids.Count, chosen.Select(t => t.Number).ToList());
}
}
+672
View File
@@ -0,0 +1,672 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
public sealed record MergeTaskResultDto(
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts,
bool ConflictsInTree = false, string? RepoPath = null);
public sealed record MergeContinuationResultDto(
bool Merged, string TaskStatus, IReadOnlyList<string> Conflicts,
string? RepoPath, string? Message);
public sealed record ConflictHunkDto(
int Index, int StartLine, string Ours, string? Base, string Theirs);
public sealed record ConflictFileHunksDto(
string Path, bool IsBinary, IReadOnlyList<ConflictHunkDto> Hunks);
public sealed record GetMergeConflictsResultDto(
IReadOnlyList<ConflictFileHunksDto> Files, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);
public sealed record ResolveConflictHunkResultDto(
bool Resolved, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note);
// IsEmpty = the review range contributed nothing (worktree ahead-of-base is empty, or a
// worktree-less handler task's HandlerBaseCommit == HandlerHeadCommit) -- distinguishable from
// a merge that is merely small, so an empty branch can't be misread as "changedFileCount: 0
// means tiny" when it actually means "nothing to review".
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are null unless a verify run was attempted
// (see PreviewMerge/PreviewMergeSet descriptions) -- 0 exit means the merge-tree result built/
// tested clean; a non-zero or -1 (timeout/failed to start) exit means it doesn't, with the tail
// of its output in VerifyOutputTail.
public sealed record MergePreviewToolDto(
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false,
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
// Files this branch touches that the target branch ALSO touched since the branch's fork
// point -- an honest staleness signal `behind` alone can't give (a branch can be far behind
// yet touch nothing the target changed, or close behind yet collide on the one file that
// matters). Empty for a worktree-less handler task, which commits straight onto the list's
// working dir and has no fork point to compare against.
IReadOnlyList<string>? StaleFiles = null);
public sealed record MergePreviewSetEntryDto(
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false,
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null,
IReadOnlyList<string>? StaleFiles = null, int? Number = null,
// Mirrors TaskEntity.ExpectsNoDiff -- lets the merge helper tell a deliberately empty diff
// (verification/audit/research task) apart from a task that silently failed to commit, both
// of which otherwise show IsEmpty=true with no other distinguishing signal.
bool ExpectsNoDiff = false);
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds, IReadOnlyList<int> Numbers);
// TaskId's changed-file list is a non-empty PROPER subset of SupersetTaskId's -- e.g. one task
// deletes two files and another deletes just one of those same two. Stronger than an Overlaps
// entry (which only says some files are shared): a real subset is the best available machine
// signal that TaskId's work may already be entirely covered by SupersetTaskId.
public sealed record SubsetRelationDto(string TaskId, int Number, string SupersetTaskId, int SupersetNumber);
public sealed record MergePreviewSetResultDto(
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
IReadOnlyList<SubsetRelationDto> Subsets);
public sealed record RevertMergeResultDto(
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
[McpServerToolType]
public sealed class MergeMcpTools
{
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly HubBroadcaster _broadcaster;
private readonly GitService _git;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly TaskMergeService _merge;
private readonly PlanningMergeOrchestrator _planningMerge;
public MergeMcpTools(
TaskRepository tasks,
ListRepository lists,
HubBroadcaster broadcaster,
GitService git,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
TaskMergeService merge,
PlanningMergeOrchestrator planningMerge)
{
_tasks = tasks;
_lists = lists;
_broadcaster = broadcaster;
_git = git;
_dbFactory = dbFactory;
_merge = merge;
_planningMerge = planningMerge;
}
[McpServerTool, Description(
"Merge a Done task's worktree branch into targetBranch. For a task still in WaitingForReview prefer " +
"review_task, which merges as part of approving. merged=true carries the new mergeCommit SHA; on conflict " +
"merged=false and conflicts lists the affected files." + McpToolDocs.ProgressHint)]
public async Task<MergeTaskResultDto> MergeTask(
string taskId,
string targetBranch = "main",
[Description("true (default): always create a merge commit (--no-ff).")]
bool noFf = true,
[Description("true: validate preconditions only and do not merge — merged=false then means \"not attempted\".")]
bool dryRun = false,
[Description("true: also allow merging a task in WaitingForReview; false (default) allows Done only.")]
bool allowWaitingForReview = false,
[Description("What to do on conflict. false (default): abort cleanly, leaving no half-merged state. true: " +
"leave the conflict markers in the working tree at repoPath (conflictsInTree=true) so you can " +
"resolve them there and call continue_merge, or abort_merge to cancel.")]
bool leaveConflictsInTree = false,
[Description("Batch mode: defer the list's verify command instead of running it after this one merge " +
"(see review_task's skipVerify). Call verify_merges once after the batch.")]
bool skipVerify = false,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "merge_task started" });
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var canMerge = task.Status == TaskStatus.Done ||
(allowWaitingForReview && task.Status == TaskStatus.WaitingForReview);
if (!canMerge)
throw new InvalidOperationException(
$"Task must be Done to merge (current status: {task.Status}). " +
"Pass allowWaitingForReview=true to also merge a WaitingForReview task.");
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
if (dryRun)
{
using var ctx = _dbFactory.CreateDbContext();
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
if (wt.State != WorktreeState.Active)
throw new InvalidOperationException(
$"Worktree state must be Active to merge (current: {wt.State}).");
return new MergeTaskResultDto(false, null, []);
}
// Blank on purpose: TaskMergeService builds the conventional default message.
var result = await _merge.MergeAsync(
taskId, targetBranch, removeWorktree: false, commitMessage: "", leaveConflictsInTree, cancellationToken, progress, skipVerify);
// merged_verify_pending: the branch IS merged (that's what this tool reports on) — only
// the task's own Done transition is deferred to verify_merges.
if (result.Status is TaskMergeService.StatusMerged or TaskMergeService.StatusMergedVerifyPending)
{
string? mergeCommit = null;
try
{
if (!string.IsNullOrWhiteSpace(list?.WorkingDir) && Directory.Exists(list.WorkingDir))
mergeCommit = await _git.RevParseHeadAsync(list.WorkingDir, cancellationToken);
}
catch { /* mergeCommit is optional */ }
return new MergeTaskResultDto(true, mergeCommit, []);
}
if (result.Status == TaskMergeService.StatusConflict)
return leaveConflictsInTree
? new MergeTaskResultDto(false, null, result.ConflictFiles,
ConflictsInTree: true, RepoPath: list?.WorkingDir)
: new MergeTaskResultDto(false, null, result.ConflictFiles);
throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
}
[McpServerTool, Description(
"Finish an in-progress conflicted merge once you have resolved the conflict markers in the working tree " +
"(repoPath from merge_task/review_task). Pass the PARENT task id to continue a parent/children unit merge. " +
"merged=false with conflicts listed means markers are still present — resolve them and call again. " +
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead." +
McpToolDocs.ProgressHint)]
public async Task<MergeContinuationResultDto> ContinueMerge(
string taskId,
[Description("Batch mode: defer the list's verify command (see review_task's skipVerify). Pass the same " +
"value you approved with — a unit merge remembers its own flag and ignores this one.")]
bool skipVerify = false,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
var workingDir = list?.WorkingDir;
bool merged;
IReadOnlyList<string> conflicts = Array.Empty<string>();
string? repoPath = null;
string? message = null;
if (_planningMerge.HasActiveMerge(taskId))
{
var unitResult = await _planningMerge.ContinueAsync(taskId, cancellationToken, progress);
var parent = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
if (unitResult.Status == TaskMergeService.StatusMergedVerifyPending)
{
merged = true;
message = "unit merged; verify deferred — the parent stays WaitingForReview until " +
"verify_merges (with this parent's id) passes";
}
else if (parent.Status == TaskStatus.Done)
{
merged = true;
}
else
{
var remaining = !string.IsNullOrWhiteSpace(workingDir)
? await _git.ListConflictedFilesAsync(workingDir, cancellationToken)
: new List<string>();
merged = false;
if (remaining.Count > 0)
{
conflicts = remaining;
repoPath = workingDir;
message = "conflicts remain — resolve and call continue_merge again";
}
else
{
message = "unit merge did not complete — the orchestrator aborted or was blocked; " +
"check the parent task and approve again to restart the merge";
}
}
}
else
{
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken, progress, skipVerify);
if (r.Status == TaskMergeService.StatusMerged)
{
merged = true;
}
else if (r.Status == TaskMergeService.StatusMergedVerifyPending)
{
merged = true;
message = "merged; verify deferred — the task stays WaitingForReview until " +
"verify_merges (with its id) passes";
}
else if (r.Status == TaskMergeService.StatusConflict)
{
merged = false;
conflicts = r.ConflictFiles;
repoPath = workingDir;
message = r.ErrorMessage;
}
else
{
throw new InvalidOperationException(r.ErrorMessage ?? "continue failed");
}
}
var reloaded = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return new MergeContinuationResultDto(merged, reloaded.Status.ToString(), conflicts, repoPath, message);
}
[McpServerTool, Description(
"Run the list's verify command ONCE for a batch of merges made with skipVerify=true, then promote the " +
"listed tasks to Done. Call it a single time after the LAST merge of the batch instead of paying the " +
"verify per merge. All tasks must belong to the same list; each must be WaitingForReview with its merge " +
"already landed (mergeStatus merged_verify_pending). status verify_passed: check each entry's promoted " +
"flag (an ineligible task is skipped with a reason, the rest still promote). status verify_failed: the " +
"merges stay in place but NO task reaches Done; the command's output tail is in errorMessage — report it, " +
"don't fix it yourself. A list without a verify command promotes directly." + McpToolDocs.ProgressHint)]
public async Task<ListVerifyResult> VerifyMerges(
[Description("Every task merged with skipVerify=true in this batch (accepts #numbers or ids).")]
string[] taskIds,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
if (taskIds.Length == 0)
throw new InvalidOperationException("taskIds must not be empty.");
// Same rationale as review_task's first report: the verify itself reports periodically,
// but the wait on the per-repo merge gate before it would otherwise sit silent.
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "verify_merges started" });
string? listId = null;
var resolved = new List<string>(taskIds.Length);
foreach (var raw in taskIds)
{
var id = await TaskIdResolver.ResolveAsync(_tasks, raw, cancellationToken);
var task = await _tasks.GetByIdAsync(id, cancellationToken)
?? throw new InvalidOperationException($"Task {raw} not found.");
listId ??= task.ListId;
if (task.ListId != listId)
throw new InvalidOperationException(
"All tasks must belong to the same list — one verify command gates one repo.");
resolved.Add(id);
}
var result = await _merge.RunListVerifyAsync(listId!, resolved, cancellationToken, progress);
if (result.Status == TaskMergeService.StatusBlocked)
throw new InvalidOperationException(result.ErrorMessage ?? "verify blocked");
return result;
}
[McpServerTool, Description(
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
"Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " +
"(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)]
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (_planningMerge.HasActiveMerge(taskId))
{
await _planningMerge.AbortAsync(taskId, cancellationToken);
}
else
{
var r = await _merge.AbortMergeAsync(taskId, cancellationToken);
if (r.Status == TaskMergeService.StatusBlocked)
throw new InvalidOperationException(r.ErrorMessage ?? "abort failed");
}
await _broadcaster.TaskUpdated(taskId);
return ExternalMcpService.ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
}
[McpServerTool, Description(
"List every conflict hunk left in a paused merge (started via merge_task/review_task with " +
"leaveConflictsInTree=true), so you can resolve them with resolve_conflict_hunk instead of parsing marker " +
"text yourself. Each hunk carries the file's path, its index within that file (what resolve_conflict_hunk " +
"expects back), and its startLine. remainingHunks is the total across every file — call continue_merge once " +
"it reaches 0, or abort_merge to cancel. Throws if the task has no in-progress merge." + " " + McpToolDocs.Diff3Note)]
public async Task<GetMergeConflictsResultDto> GetMergeConflicts(string taskId, CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
var files = docs.Files.Select(ToConflictFileHunksDto).ToList();
var remaining = files.Sum(f => f.Hunks.Count);
return new GetMergeConflictsResultDto(files, remaining);
}
[McpServerTool, Description(
"Resolve exactly one conflict hunk from get_merge_conflicts, identified by file path and its index within " +
"that file. Writes only that hunk — every other hunk (in this file or another) is left exactly as it was, " +
"so call get_merge_conflicts again afterward: resolved hunks disappear and every remaining hunk's index " +
"shifts down to stay zero-based. This never decides FOR you which side wins — resolution must be 'ours', " +
"'theirs', 'base' (only valid when that hunk has a diff3 base), or literal replacement text (used exactly " +
"as given, including any trailing newline the surrounding file needs). remainingHunks is the total across " +
"every file; call continue_merge once it reaches 0. Throws if the file/index don't match a current hunk." +
" " + McpToolDocs.Diff3Note)]
public async Task<ResolveConflictHunkResultDto> ResolveConflictHunk(
string taskId,
[Description("File path exactly as returned by get_merge_conflicts, relative to the repo root.")]
string file,
[Description("Zero-based hunk index within that file, as returned by get_merge_conflicts.")]
int index,
[Description("'ours', 'theirs', 'base', or literal text to use for this hunk.")]
string resolution,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(resolution))
throw new InvalidOperationException("resolution is required.");
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
var doc = docs.Files.FirstOrDefault(f => string.Equals(f.Path, file, StringComparison.Ordinal))
?? throw new InvalidOperationException($"File '{file}' has no conflict for task {taskId}.");
if (doc.IsBinary)
throw new InvalidOperationException($"File '{file}' is binary; resolve it directly in the working tree.");
var hunkCount = doc.Segments.Count(s => s.IsConflict);
if (index < 0 || index >= hunkCount)
throw new InvalidOperationException(
$"Hunk index {index} is out of range for '{file}' ({hunkCount} hunk(s) remaining).");
var counter = -1;
var composed = ConflictMarkerParser.Compose(doc.Segments, seg =>
{
counter++;
if (counter != index) return ReconstructConflictMarkers(seg);
var choice = resolution.Trim().ToLowerInvariant();
return choice switch
{
"ours" => seg.Ours,
"theirs" => seg.Theirs,
"base" => seg.Base ?? throw new InvalidOperationException(
$"Hunk {index} in '{file}' has no diff3 base to resolve to."),
_ => resolution,
};
});
await _merge.WriteConflictFileAsync(taskId, file, composed, cancellationToken);
var refreshed = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken);
var remaining = refreshed.Files.Sum(f => f.Segments.Count(s => s.IsConflict));
return new ResolveConflictHunkResultDto(true, remaining);
}
private static ConflictFileHunksDto ToConflictFileHunksDto(ConflictDocumentContent f)
{
var hunks = new List<ConflictHunkDto>();
foreach (var seg in f.Segments)
{
if (!seg.IsConflict) continue;
hunks.Add(new ConflictHunkDto(hunks.Count, seg.StartLine, seg.Ours, seg.Base, seg.Theirs));
}
return new ConflictFileHunksDto(f.Path, f.IsBinary, hunks);
}
// Re-renders a still-unresolved hunk as valid (unlabeled) conflict markers so it round-trips through
// ConflictMarkerParser.Parse again untouched, and git still refuses continue_merge while it stands. The
// original marker labels (e.g. "<<<<<<< HEAD") aren't recoverable from a parsed MergeSegment and aren't
// needed for either check -- ConflictMarkerParser.IsMarker and ContinueMergeAsync's rescan only match the
// marker prefixes.
private static string ReconstructConflictMarkers(MergeSegment seg)
{
var sb = new StringBuilder();
sb.Append("<<<<<<<\n").Append(seg.Ours);
if (seg.Base is not null)
sb.Append("|||||||\n").Append(seg.Base);
sb.Append("=======\n").Append(seg.Theirs);
sb.Append(">>>>>>>\n");
return sb.ToString();
}
[McpServerTool, Description(
"Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " +
"working tree, index and HEAD are untouched. status is 'clean', 'conflict' (conflictFiles lists where git " +
"would stop), or 'untracked_collision' (conflictFiles lists a path the branch adds that already exists, " +
"untracked, in the target working directory — merge-tree can't see the working tree at all, so this is the " +
"only way to catch it before a real merge either refuses or, if that path became tracked in the meantime, " +
"silently overwrites it); behind counts commits on targetBranch not yet on this branch, which flags a stale " +
"branch even when the preview is clean. If the list has a verify command configured, a clean preview is " +
"additionally built/tested in a scratch worktree (still without touching the real working tree) — " +
"verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " +
"tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " +
"isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " +
"small changedFileCount as empty. staleFiles lists files this branch touches that the target branch ALSO " +
"changed since this branch's fork point — a more honest staleness signal than `behind` alone, since a " +
"branch can be far behind yet touch nothing the target changed, or barely behind yet collide on the one " +
"file that matters (always empty for a worktree-less handler task, which has no fork point). Throws if the " +
"task has neither an active worktree nor a handler commit range, or the list's working directory is missing " +
"from disk." + McpToolDocs.ProgressHint)]
public async Task<MergePreviewToolDto> PreviewMerge(
string taskId,
[Description("Branch to preview against; defaults to the repo's current branch.")]
string? targetBranch = null,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var (preview, behind, _, isEmpty, staleFiles, _, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress);
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
}
[McpServerTool, Description(
"Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
"check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
"be previewed (it is then left out of the overlap/subset computation). overlaps names, for each file " +
"touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no " +
"overlaps. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
"SupersetTaskId's — the strongest hint you get post-hoc that TaskId may be redundant with SupersetTaskId, " +
"worth checking before merging both. IMPORTANT: neither overlap nor subset is a safety guarantee — two " +
"tasks touching entirely different files (one deleting a symbol, another still referencing it) can still " +
"collide unflagged, and as with preview_merge a clean result does not mean the merge builds. " +
"runVerify=false (default) never builds — set it true to also run each task's list's verify command in a " +
"scratch worktree per entry (same fields as preview_merge); this can take a long time across many tasks, " +
"since builds run one at a time." + McpToolDocs.ProgressHint)]
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
IReadOnlyList<string> taskIds,
[Description("Branch to preview every task against; defaults to the repo's current branch.")]
string? targetBranch = null,
[Description("true: also run the verify command (if configured) for each task, one build at a time. " +
"false (default): no builds, however many tasks are given.")]
bool runVerify = false,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
if (taskIds is null || taskIds.Count == 0)
throw new InvalidOperationException("taskIds must contain at least one task id.");
taskIds = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken);
var entries = new List<MergePreviewSetEntryDto>();
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
var numbersByTask = new Dictionary<string, int>();
for (var i = 0; i < taskIds.Count; i++)
{
var taskId = taskIds[i];
try
{
var (preview, behind, changedFiles, isEmpty, staleFiles, number, expectsNoDiff) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
entries.Add(new MergePreviewSetEntryDto(
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number, expectsNoDiff));
filesByTask[taskId] = changedFiles;
numbersByTask[taskId] = number;
}
catch (InvalidOperationException ex)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
entries.Add(new MergePreviewSetEntryDto(
taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message,
Number: task?.Number, ExpectsNoDiff: task?.ExpectsNoDiff ?? false));
}
ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
}
var overlaps = filesByTask
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Select(x => x.TaskId).Distinct().Count() > 1)
.Select(g =>
{
var ids = g.Select(x => x.TaskId).Distinct().ToList();
return new FileOverlapDto(g.Key, ids, ids.Select(id => numbersByTask[id]).ToList());
})
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
.ToList();
var subsets = FindSubsetRelations(filesByTask, numbersByTask);
return new MergePreviewSetResultDto(entries, overlaps, subsets);
}
// A task's changed-file set is flagged only against another task's STRICTLY LARGER set that
// contains every one of its files -- an empty set (isEmpty task) never qualifies, since that
// case is already surfaced via IsEmpty and would otherwise match every other task trivially.
private static List<SubsetRelationDto> FindSubsetRelations(
Dictionary<string, IReadOnlyList<string>> filesByTask, Dictionary<string, int> numbersByTask)
{
var fileSets = filesByTask.ToDictionary(
kv => kv.Key,
kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase));
var subsets = new List<SubsetRelationDto>();
foreach (var (taskId, fileSet) in fileSets)
{
if (fileSet.Count == 0) continue;
foreach (var (otherTaskId, otherFileSet) in fileSets)
{
if (otherTaskId == taskId || otherFileSet.Count <= fileSet.Count) continue;
if (fileSet.IsSubsetOf(otherFileSet))
subsets.Add(new SubsetRelationDto(taskId, numbersByTask[taskId], otherTaskId, numbersByTask[otherTaskId]));
}
}
return subsets
.OrderBy(s => s.TaskId, StringComparer.OrdinalIgnoreCase)
.ThenBy(s => s.SupersetTaskId, StringComparer.OrdinalIgnoreCase)
.ToList();
}
// Shared core for PreviewMerge/PreviewMergeSet: throws a clear InvalidOperationException instead of
// TaskMergeService.PreviewAsync's silent "unavailable" status, and adds `behind` + the task's own
// changed-file list (via diff-stat, not the merge-tree preview) for overlap detection, plus
// `isEmpty`. A worktree-less list-handler host task has no branch to merge-tree-preview at all
// (its commits already sit on the list's working dir) — falls back to the fixed
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
// range's own diff-stat instead of throwing "has no worktree".
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number, bool ExpectsNoDiff)> PreviewMergeCoreAsync(
string taskId, string? targetBranch, bool runVerify, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress = null)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
if (wt is not null)
{
if (wt.State != WorktreeState.Active)
throw new InvalidOperationException(
$"Worktree state must be Active to preview a merge (current: {wt.State}).");
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
throw new InvalidOperationException("The list's working directory no longer exists.");
var preview = await _merge.PreviewAsync(taskId, targetBranch ?? "", runVerify, ct, progress);
if (preview.Status == TaskMergeService.PreviewUnavailable)
throw new InvalidOperationException(
"Merge preview unavailable for this task (worktree inactive or repo is not a git repository).");
var target = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
var behind = await WorktreeMcpTools.GitRevListCountAsync(list.WorkingDir, $"{wt.BranchName}..{target}", ct);
var changedFiles = Directory.Exists(wt.Path)
? ExternalMcpService.ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct: ct))
: Array.Empty<string>();
// What the target itself picked up since this branch's fork point, so `behind`
// (a commit count) doesn't have to stand in for "does this collide" -- a branch can
// be far behind but touch nothing the target changed, or close behind and collide on
// the one file that matters.
var targetChangedSinceFork = await _git.GetChangedFileNamesAsync(list.WorkingDir, wt.BaseCommit, target, ct);
var staleFiles = changedFiles
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
.ToList();
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number, task.ExpectsNoDiff);
}
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
{
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
throw new InvalidOperationException("The list's working directory no longer exists.");
var isEmpty = string.Equals(handlerBase, handlerHead, StringComparison.Ordinal);
var changedFiles = isEmpty
? Array.Empty<string>()
: ExternalMcpService.ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct: ct));
var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty<string>(), changedFiles.Count);
// No fork point to diff against: a handler task commits straight onto the list's
// working dir instead of a branch, so there is nothing else that could have "changed
// in the target since the fork".
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number, task.ExpectsNoDiff);
}
throw new InvalidOperationException($"Task {taskId} has no worktree.");
}
[McpServerTool, Description(
"Undo a merged task by reverting its merge commit — `git revert -m 1`, always a new commit and never a " +
"reset/rewrite, since the target working directory is shared with other sessions. Requires the task to be " +
"Done with a Merged worktree that has a recorded merge commit (check get_task_worktree's mergeCommit " +
"first). On success the task returns to WaitingForReview so it can be reconsidered. On conflict the revert " +
"is aborted immediately and conflicts lists the files. Throws if there is no recorded merge commit, the " +
"repo is mid-merge/mid-revert, or the target working tree has uncommitted changes from another session.")]
public async Task<RevertMergeResultDto> RevertMerge(
string taskId,
[Description("Branch carrying the merge commit; defaults to main.")]
string targetBranch = "main",
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken);
if (result.Status == TaskMergeService.StatusReverted)
{
await _broadcaster.TaskUpdated(taskId);
return new RevertMergeResultDto(true, result.RevertCommit, Array.Empty<string>(), null);
}
if (result.Status == TaskMergeService.StatusConflictAborted)
return new RevertMergeResultDto(false, null, result.ConflictFiles, result.ErrorMessage);
throw new InvalidOperationException(result.ErrorMessage ?? $"Revert blocked: {result.Status}");
}
}
+59
View File
@@ -0,0 +1,59 @@
using System.ComponentModel;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record AddNoteResult(bool Saved, string Id, int AgentNotesToday, int RemainingToday);
[McpServerToolType]
public sealed class NotesMcpTools
{
/// <summary>Agent-written notes allowed per day. The user's own notes are never counted or
/// capped — this only stops an agent from burying the day in its own bullets.</summary>
public const int DailyAgentCap = 10;
/// <summary>A note is a line, not a report. Longer output belongs in a task or a finding.</summary>
public const int MaxLength = 500;
private readonly DailyNoteRepository _notes;
private readonly HubBroadcaster _broadcaster;
public NotesMcpTools(DailyNoteRepository notes, HubBroadcaster broadcaster)
{
_notes = notes;
_broadcaster = broadcaster;
}
[McpServerTool, Description(
"Add one short line to the user's notes for today — the day log they read in ClaudeDo, " +
"not a log for you. Write only what the user would want to reread later and would " +
"otherwise lose: a decision they made, something you found that changes their plans, " +
"something they asked you to remember for the day. Task status, your progress and " +
"anything already visible in the task list are noise here. When unsure, don't. " +
"Capped at 10 agent notes per day; you cannot edit or delete notes, so a bad one stays " +
"until the user removes it.")]
public async Task<AddNoteResult> AddNote(
[Description("The note itself: one plain sentence, no markdown, no prefix, max 500 characters.")] string text,
CancellationToken cancellationToken = default)
{
var trimmed = (text ?? "").Trim();
if (trimmed.Length == 0)
throw new InvalidOperationException("text is required.");
if (trimmed.Length > MaxLength)
throw new InvalidOperationException(
$"Note is {trimmed.Length} characters, the limit is {MaxLength}. Shorten it to one line.");
var today = DateOnly.FromDateTime(DateTime.Today);
var used = await _notes.CountAgentNotesAsync(today, cancellationToken);
if (used >= DailyAgentCap)
throw new InvalidOperationException(
$"Daily cap reached: {DailyAgentCap} agent notes already written today. " +
"Nothing was saved — tell the user instead of retrying.");
var note = await _notes.AddAsync(today, trimmed, fromAgent: true, cancellationToken);
await _broadcaster.NotesUpdated();
return new AddNoteResult(true, note.Id, used + 1, DailyAgentCap - used - 1);
}
}
+42
View File
@@ -0,0 +1,42 @@
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
/// <summary>
/// Renders the `blockedReason` string shared by <see cref="ExternalMcpService"/> and
/// <see cref="TaskWaitMcpTools"/>. Lives here so the two can't drift on the wording an agent
/// parses.
/// </summary>
internal static class TaskBlocking
{
/// <summary>
/// The predecessors of <paramref name="dependencyIds"/> that have not reached Done — an id
/// missing from <paramref name="statuses"/> counts as unmet (the picker treats a dangling
/// edge as blocking too).
/// </summary>
public static List<(string Id, TaskStatus? Status)> Unmet(
IEnumerable<string> dependencyIds, IReadOnlyDictionary<string, TaskStatus> statuses)
{
var unmet = new List<(string Id, TaskStatus? Status)>();
foreach (var id in dependencyIds)
{
if (statuses.TryGetValue(id, out var status))
{
if (status != TaskStatus.Done) unmet.Add((id, status));
}
else
{
unmet.Add((id, null));
}
}
return unmet;
}
/// <summary>Names every open predecessor, not just the first — a task can wait on n of them.</summary>
public static string Describe(IReadOnlyList<(string Id, TaskStatus? Status)> unmet)
{
var noun = unmet.Count == 1 ? "task" : "tasks";
var parts = unmet.Select(u => $"{u.Id} (status: {(u.Status?.ToString() ?? "not found")})");
return $"Blocked: depends on {noun} {string.Join(", ", parts)}.";
}
}
+16 -13
View File
@@ -132,17 +132,24 @@ public sealed class TaskWaitMcpTools
var rows = await ctx.Tasks var rows = await ctx.Tasks
.AsNoTracking() .AsNoTracking()
.Where(t => taskIds.Contains(t.Id)) .Where(t => taskIds.Contains(t.Id))
.Select(t => new { t.Id, t.Number, t.Status, t.BlockedByTaskId, t.DependsOnTaskId }) .Select(t => new
{
t.Id,
t.Number,
t.Status,
t.BlockedByTaskId,
DependsOnTaskIds = t.Dependencies.Select(d => d.DependsOnTaskId).ToList(),
})
.ToListAsync(ct); .ToListAsync(ct);
var byId = rows.ToDictionary(r => r.Id, r => r); var byId = rows.ToDictionary(r => r.Id, r => r);
// A Queued task with an unmet depends_on link never becomes "changed" by itself -- the // A Queued task with an unmet depends_on edge never becomes "changed" by itself -- the
// picker will not touch it. Batch-resolve those dependencies' statuses once instead of a // picker will not touch it. Batch-resolve those dependencies' statuses once instead of a
// query per candidate. // query per candidate.
var dependencyIds = rows var dependencyIds = rows
.Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null && r.DependsOnTaskId is not null) .Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null)
.Select(r => r.DependsOnTaskId!) .SelectMany(r => r.DependsOnTaskIds)
.Distinct() .Distinct()
.ToList(); .ToList();
var dependencyStatuses = dependencyIds.Count == 0 var dependencyStatuses = dependencyIds.Count == 0
@@ -168,16 +175,12 @@ public sealed class TaskWaitMcpTools
// every chained child and turn the wait into a one-turn-per-poll busy loop. // every chained child and turn the wait into a one-turn-per-poll busy loop.
if (row.Status == TaskStatus.Queued && row.BlockedByTaskId is null) if (row.Status == TaskStatus.Queued && row.BlockedByTaskId is null)
{ {
if (row.DependsOnTaskId is not null) var unmet = TaskBlocking.Unmet(row.DependsOnTaskIds, dependencyStatuses);
if (unmet.Count > 0)
{ {
var known = dependencyStatuses.TryGetValue(row.DependsOnTaskId, out var depStatus); result.Add(new TaskStatusChangeDto(
if (!known || depStatus != TaskStatus.Done) id, "Blocked", TaskBlocking.Describe(unmet), row.Number));
{ continue;
result.Add(new TaskStatusChangeDto(id, "Blocked",
$"Blocked: depends on task {row.DependsOnTaskId} (status: " +
(known ? depStatus.ToString() : "not found") + ").", row.Number));
continue;
}
} }
} }
+279
View File
@@ -0,0 +1,279 @@
using System.ComponentModel;
using System.Diagnostics;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
public sealed record WorktreeInfoDto(
string Path, string Branch, string HeadCommit, string BaseCommit,
int Ahead, int Behind, bool IsDirty, string? MergeCommit = null);
public sealed record TaskDiffDto(
string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes);
public sealed record WorktreeListItemDto(
string? TaskId, int? Number, string Path, string Branch,
string HeadCommit, bool IsDirty, bool MergedIntoMain);
public sealed record CleanupWorktreeResult(
bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null);
[McpServerToolType]
public sealed class WorktreeMcpTools
{
// Mirrors TaskMergeService.ProgressReportInterval / TaskWaitMcpTools.ProgressReportInterval:
// a separate field (not shared) so shrinking one for a test can't race another's tests. Used
// by the single-element long-runners here (GetTaskDiff, CleanupTaskWorktree) that wrap a bare
// GitService/WorktreeMaintenanceService call with no natural i/n to report instead.
internal static TimeSpan ProgressReportInterval = TimeSpan.FromSeconds(30);
private readonly TaskRepository _tasks;
private readonly GitService _git;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _maintenance;
public WorktreeMcpTools(
TaskRepository tasks,
GitService git,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService maintenance)
{
_tasks = tasks;
_git = git;
_dbFactory = dbFactory;
_maintenance = maintenance;
}
[McpServerTool, Description(
"Get a task's git worktree state — path, branch, base/head commit, ahead/behind counts, isDirty, and the " +
"mergeCommit its branch produced once merged. behind is 0 when the 'main' ref is unreachable, so do not " +
"read 0 as \"up to date\" without checking. A null mergeCommit means revert_merge cannot act on this task. " +
"Throws if the task or its worktree does not exist.")]
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit)
? wt.HeadCommit
: await TryRunGitAsync(wt.Path, ["rev-parse", "HEAD"], cancellationToken) ?? wt.BaseCommit;
var isDirty = Directory.Exists(wt.Path) && await _git.HasChangesAsync(wt.Path, cancellationToken);
var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken);
var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken);
return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty, wt.MergeCommit);
}
[McpServerTool, Description(
"Read what a task actually changed — the diff of its worktree against its base commit (for a worktree-less " +
"list-handler host task, the fixed HandlerBaseCommit..HandlerHeadCommit range over the list's working dir " +
"instead). files lists the changed paths in either mode; truncated=true means the diff was capped and " +
"totalBytes holds its real size. Throws if the task has no worktree/review range, or the relevant " +
"directory is missing from disk.")]
public async Task<TaskDiffDto> GetTaskDiff(
string taskId,
[Description("false (default): the full unified diff, capped at 200 KB. true: a --stat summary with " +
"per-file insertion/deletion counts — start here when the diff may be large.")]
bool stat = false,
[Description("Restrict the diff to these paths (relative to the repo root), e.g. after a --stat pass or a " +
"conflict report already narrowed down which files matter. Omit/empty for every changed file " +
"— the default and the only prior behavior. Works in both stat and full-diff mode.")]
IReadOnlyList<string>? paths = null,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
const int maxBytes = 200 * 1024;
if (stat)
{
var diffStat = await ProgressReporter.RunAsync(
_git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", paths, cancellationToken),
ProgressReportInterval, progress, "computing diff stat");
return new TaskDiffDto(diffStat, ExternalMcpService.ParseDiffStatFileNames(diffStat), false, diffStat.Length);
}
var diff = headCommit is null
? await ProgressReporter.RunAsync(
_git.GetBranchDiffAsync(repoPath, baseCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff")
: await ProgressReporter.RunAsync(
_git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff");
var files = ParseDiffFileNames(diff);
if (diff.Length <= maxBytes)
return new TaskDiffDto(diff, files, false, diff.Length);
return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length);
}
// Resolves where a task's diff lives: a live worktree (repo path = worktree path, diffed
// against HEAD) or, for a worktree-less list-handler host task, the fixed
// HandlerBaseCommit..HandlerHeadCommit range over the list's working dir (headCommit
// non-null signals "fixed range" to the caller instead of "diff against live HEAD").
private async Task<(string RepoPath, string BaseCommit, string? HeadCommit)> LoadDiffRangeAsync(
string taskId, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
if (wt is not null)
{
if (!Directory.Exists(wt.Path))
throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}");
return (wt.Path, wt.BaseCommit, null);
}
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
{
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
throw new InvalidOperationException("The list's working directory no longer exists.");
return (list.WorkingDir, handlerBase, handlerHead);
}
throw new InvalidOperationException($"Task {taskId} has no worktree.");
}
[McpServerTool, Description(
"Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " +
"the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk." +
McpToolDocs.ProgressHint)]
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
// One git status + one rev-parse per row, run concurrently -- with many tracked worktrees
// (this tool's whole point is surfacing leftovers nobody cleaned up) that can still take a
// while, so this reports on the same elapsed-time cadence as a single long git call rather
// than per-row (rows finish out of order under Task.WhenAll, so there's no natural i/n).
var results = await ProgressReporter.RunAsync(
Task.WhenAll(rows.Select(async row =>
{
var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken);
var headCommit = row.PathExistsOnDisk
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
: "";
return new WorktreeListItemDto(
row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit,
isDirty, row.State == WorktreeState.Merged);
})),
ProgressReportInterval, progress, "surveying worktrees");
return results;
}
[McpServerTool, Description(
"Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " +
"went too." + McpToolDocs.NotWhileRunning)]
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
string taskId,
[Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " +
"those changes.")]
bool force = false,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot remove worktree of a running task.");
if (!force && Directory.Exists(wt.Path))
{
var isDirty = await ProgressReporter.RunAsync(
_git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes");
if (isDirty)
throw new InvalidOperationException(
"Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost).");
}
var path = wt.Path;
var result = await ProgressReporter.RunAsync(
_maintenance.ForceRemoveAsync(taskId, cancellationToken), ProgressReportInterval, progress, "removing worktree");
return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted, task.Number);
}
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync(
string taskId, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
return (task, list, wt);
}
private async Task<bool> TryGetIsDirtyAsync(string path, CancellationToken ct)
{
try { return await _git.HasChangesAsync(path, ct); }
catch { return false; }
}
// Minimal git runner for operations not covered by GitService (rev-list --count, rev-parse from worktree).
private static async Task<string?> TryRunGitAsync(string dir, string[] args, CancellationToken ct)
{
try
{
var psi = new ProcessStartInfo("git")
{
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-C");
psi.ArgumentList.Add(dir);
foreach (var a in args) psi.ArgumentList.Add(a);
using var proc = Process.Start(psi)!;
await using var _ = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { } });
var stdout = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync(CancellationToken.None);
ct.ThrowIfCancellationRequested();
return proc.ExitCode == 0 ? stdout.Trim() : null;
}
catch (OperationCanceledException) { throw; }
catch { return null; }
}
internal static async Task<int> GitRevListCountAsync(string dir, string range, CancellationToken ct)
{
var result = await TryRunGitAsync(dir, ["rev-list", "--count", range], ct);
return int.TryParse(result, out var n) ? n : 0;
}
private static IReadOnlyList<string> ParseDiffFileNames(string diff)
{
var files = new List<string>();
foreach (var line in diff.Split('\n'))
{
var s = line.TrimEnd('\r');
if (s.StartsWith("+++ b/", StringComparison.Ordinal))
files.Add(s[6..]);
}
return files;
}
}
+14
View File
@@ -37,6 +37,20 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) => public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) =>
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase); _hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase);
// start_list_handler (External MCP) -- the App creates the handler task and opens the triage
// tile, exactly like the "Let Claude handle it" button.
public Task ListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) =>
_hub.Clients.All.SendAsync("ListHandlerStartRequested", listId, taskIds);
// The 5h usage window is about to run out under a running list-handler session (HandlerUsageGuard).
// Mission Control types `message` straight into that task's ConPTY pane, so the session can hand
// off cleanly instead of dying mid-merge. No pane for the task = nothing happens.
public Task HandlerUsageWarning(string handlerTaskId, string message) =>
_hub.Clients.All.SendAsync("HandlerUsageWarning", handlerTaskId, message);
/// <summary>An agent wrote a note through MCP — the open notes editor reloads.</summary>
public Task NotesUpdated() =>
_hub.Clients.All.SendAsync("NotesUpdated");
public Task ListUpdated(string listId) => public Task ListUpdated(string listId) =>
_hub.Clients.All.SendAsync("ListUpdated", listId); _hub.Clients.All.SendAsync("ListUpdated", listId);
+54 -25
View File
@@ -35,20 +35,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private static readonly string Version = private static readonly string Version =
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
// SourceRevisionId (set by ClaudeDo.Worker.csproj's git-rev-parse build target) is appended
// by the SDK to InformationalVersion as "+{sha}" -- take the segment after the LAST '+' so
// this stays correct even when MinVer's own pre-release metadata already contains a '+'.
private static readonly string? BuildSha = ParseBuildSha(
Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion);
internal static string? ParseBuildSha(string? informationalVersion)
{
if (string.IsNullOrEmpty(informationalVersion)) return null;
var plusIndex = informationalVersion.LastIndexOf('+');
if (plusIndex < 0 || plusIndex == informationalVersion.Length - 1) return null;
return informationalVersion[(plusIndex + 1)..];
}
private readonly QueueService _queue; private readonly QueueService _queue;
private readonly IQueueWaker _waker; private readonly IQueueWaker _waker;
private readonly AgentFileService _agentService; private readonly AgentFileService _agentService;
@@ -86,6 +72,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly TicketPatStore _ticketPat; private readonly TicketPatStore _ticketPat;
private readonly TicketClientFactory _ticketClients; private readonly TicketClientFactory _ticketClients;
private readonly TicketImportService _ticketImport; private readonly TicketImportService _ticketImport;
private readonly HandlerRestartCoordinator? _handlerRestart;
public WorkerHub( public WorkerHub(
QueueService queue, QueueService queue,
@@ -124,7 +111,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
TicketSystemConfig ticketConfig = null!, TicketSystemConfig ticketConfig = null!,
TicketPatStore ticketPat = null!, TicketPatStore ticketPat = null!,
TicketClientFactory ticketClients = null!, TicketClientFactory ticketClients = null!,
TicketImportService ticketImport = null!) TicketImportService ticketImport = null!,
HandlerRestartCoordinator? handlerRestart = null)
{ {
_queue = queue; _queue = queue;
_waker = waker; _waker = waker;
@@ -163,6 +151,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_ticketPat = ticketPat; _ticketPat = ticketPat;
_ticketClients = ticketClients; _ticketClients = ticketClients;
_ticketImport = ticketImport; _ticketImport = ticketImport;
_handlerRestart = handlerRestart;
} }
// Persistence boundary for the session_skills JSON-array columns (task/list/global). // Persistence boundary for the session_skills JSON-array columns (task/list/global).
@@ -233,11 +222,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
public string Ping() => $"pong v{Version}"; public string Ping() => $"pong v{Version}";
/// <summary>Lets the UI detect a worker that's still running an older build than the code it's
/// comparing against (e.g. a just-merged main) — a separate diagnostic rather than extending
/// Ping's "pong vX.Y.Z" text, since that format isn't ours to break for any existing caller.</summary>
public WorkerBuildInfoDto GetWorkerBuildInfo() => new(BuildSha);
public IReadOnlyList<ActiveTaskDto> GetActive() public IReadOnlyList<ActiveTaskDto> GetActive()
{ {
return _queue.GetActive() return _queue.GetActive()
@@ -340,7 +324,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
row.UsageThrottleFiveHourHardPct, row.UsageThrottleFiveHourHardPct,
row.UsageThrottleSevenDaySoftPct, row.UsageThrottleSevenDaySoftPct,
row.UsageThrottleSevenDayHardPct, row.UsageThrottleSevenDayHardPct,
row.AutoContinueOnUsageLimit); row.AutoContinueOnUsageLimit,
row.PowerUseEnabled,
row.ListHandlerMaxHandoffs,
row.PowerUseWindowStart,
row.PowerUseWindowEnd,
row.PowerUseHandoffPct);
} }
public async Task UpdateAppSettings(AppSettingsDto dto) public async Task UpdateAppSettings(AppSettingsDto dto)
@@ -376,6 +365,16 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct, UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct,
UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct, UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct,
AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit, AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit,
PowerUseEnabled = dto.PowerUseEnabled,
// The cap is what bounds a runaway handoff loop; a 0 from a stale or hand-written
// client would remove that bound entirely, so clamp instead of trusting the wire value.
ListHandlerMaxHandoffs = Math.Max(1, dto.ListHandlerMaxHandoffs),
// An unparseable time string is stored as null (= no window) rather than rejected: the
// window is an optional narrowing, and a typo must not silently mean "PowerUse never
// applies". Below 50% a handoff threshold would fire before the session got anywhere.
PowerUseWindowStart = PowerUseWindow.Normalize(dto.PowerUseWindowStart),
PowerUseWindowEnd = PowerUseWindow.Normalize(dto.PowerUseWindowEnd),
PowerUseHandoffPct = Math.Clamp(dto.PowerUseHandoffPct, 50, 99),
}); });
} }
@@ -543,6 +542,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType; entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType;
entity.IsManual = dto.IsManual; entity.IsManual = dto.IsManual;
entity.FindingsTracked = dto.FindingsTracked; entity.FindingsTracked = dto.FindingsTracked;
entity.Icon = string.IsNullOrWhiteSpace(dto.Icon) ? null : dto.Icon;
await repo.UpdateAsync(entity); await repo.UpdateAsync(entity);
await _broadcaster.ListUpdated(dto.Id); await _broadcaster.ListUpdated(dto.Id);
@@ -571,7 +571,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var id => id, var id => id,
}; };
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null) // Gleiches Tri-State-Muster: null = gespeicherten Wert behalten, false = Opt-in widerrufen.
var powerUseAllowed = dto.PowerUseAllowed ?? existing?.PowerUseAllowed ?? false;
// powerUseAllowed muss mit in die Leer-Prüfung: sonst löscht ein Save, bei dem PowerUse das
// EINZIGE gesetzte Feld ist, die Config-Zeile und damit das Opt-in gleich wieder mit.
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null && !powerUseAllowed)
{ {
await repo.DeleteConfigAsync(dto.ListId); await repo.DeleteConfigAsync(dto.ListId);
} }
@@ -588,6 +593,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
VerifyCommand = verifyCommand, VerifyCommand = verifyCommand,
PermissionMode = permissionMode, PermissionMode = permissionMode,
TicketProjectId = ticketProjectId, TicketProjectId = ticketProjectId,
PowerUseAllowed = powerUseAllowed,
}); });
} }
@@ -611,7 +617,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var repo = new ListRepository(ctx); var repo = new ListRepository(ctx);
var config = await repo.GetConfigAsync(listId); var config = await repo.GetConfigAsync(listId);
if (config is null) return null; if (config is null) return null;
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.PermissionMode, config.TicketProjectId); return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.PermissionMode, config.TicketProjectId, config.PowerUseAllowed);
} }
public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status) public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
@@ -792,6 +798,18 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return _interactiveLaunchSpec.BuildForMergeHelperHandoffAsync(taskId, survivingTaskIds, nextPhase, Context.ConnectionAborted); return _interactiveLaunchSpec.BuildForMergeHelperHandoffAsync(taskId, survivingTaskIds, nextPhase, Context.ConnectionAborted);
}); });
// Reports that an embedded ConPTY session's process exited. Only the UI process owns the
// terminal, so the exit code exists nowhere else; every decision about it (was this a list
// handler, did the usage limit kill it, is PowerUse on, when does the window reset) belongs to
// HandlerRestartCoordinator. Fire-and-forget on purpose: the coordinator's task stays parked
// until the 5h reset, which is far longer than this connection or this hub instance lives, so
// it must not run under Context.ConnectionAborted either.
public void ReportInteractiveSessionExited(string taskId, int exitCode)
{
if (_handlerRestart is null) return;
_ = _handlerRestart.OnSessionExitedAsync(taskId, exitCode, CancellationToken.None);
}
// Starts a planning session (worktree + prompt files + token, task -> Planning) and returns // Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
// the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for // the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for
// StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started // StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
@@ -969,14 +987,25 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{ {
using var ctx = _dbFactory.CreateDbContext(); using var ctx = _dbFactory.CreateDbContext();
var notes = await new DailyNoteRepository(ctx).ListByDayAsync(Day(dayIso)); var notes = await new DailyNoteRepository(ctx).ListByDayAsync(Day(dayIso));
return notes.Select(n => new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder)).ToList(); return notes.Select(Dto).ToList();
} }
/// <summary>Both ends inclusive — the editor loads today plus its recent history in one call.</summary>
public async Task<List<DailyNoteDto>> GetDailyNotesBetween(string startIso, string endIso)
{
using var ctx = _dbFactory.CreateDbContext();
var notes = await new DailyNoteRepository(ctx).ListBetweenAsync(Day(startIso), Day(endIso));
return notes.Select(Dto).ToList();
}
private static DailyNoteDto Dto(Data.Models.DailyNoteEntity n) =>
new(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder, n.FromAgent);
public async Task<DailyNoteDto> AddDailyNote(string dayIso, string text) public async Task<DailyNoteDto> AddDailyNote(string dayIso, string text)
{ {
using var ctx = _dbFactory.CreateDbContext(); using var ctx = _dbFactory.CreateDbContext();
var n = await new DailyNoteRepository(ctx).AddAsync(Day(dayIso), text); var n = await new DailyNoteRepository(ctx).AddAsync(Day(dayIso), text);
return new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder); return Dto(n);
} }
public async Task UpdateDailyNote(string id, string text) public async Task UpdateDailyNote(string id, string text)
@@ -986,6 +986,48 @@ public sealed class TaskMergeService
if (task.Status != TaskStatus.WaitingForReview) if (task.Status != TaskStatus.WaitingForReview)
return Blocked("task is not waiting for review"); return Blocked("task is not waiting for review");
if (wt is not null && wt.State == WorktreeState.Kept)
{
// Kept means either RevertMergeAsync undid a previous merge, or the user manually
// parked an active worktree -- either way its branch may still exist. Re-running the
// real merge (not the "nothing to merge" no-op below) is the only way approve can be
// trusted after a revert; if the branch is truly gone (the common case once the
// original approve already deleted it via removeWorktree:true) there is nothing left
// to merge and that must come back as Blocked, never a silent fake "merged".
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
return Blocked("list has no working directory");
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
if (!branches.Contains(wt.BranchName, StringComparer.Ordinal))
return Blocked(
$"worktree was reverted and its branch '{wt.BranchName}' no longer exists — cannot re-approve; discard the task or start a fresh worktree instead.");
var reviveTarget = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
// A branch that was already merged once (reverted or not) is already an ancestor of
// the target tip -- `git merge` on it again is a silent "Already up to date." no-op,
// since merge only checks ancestry, never content. Detect that case up front and
// reapply the original merge commit's own diff instead of falling into the normal
// merge path below, which would report success without actually restoring anything.
var branchTip = await _git.RevParseAsync(list.WorkingDir, wt.BranchName, ct);
var targetTip = await _git.RevParseAsync(list.WorkingDir, reviveTarget, ct);
if (await _git.IsAncestorAsync(list.WorkingDir, branchTip, targetTip, ct) == true)
{
if (string.IsNullOrWhiteSpace(wt.MergeCommit))
return Blocked("worktree branch has no new commits to merge and no merge commit is recorded to reapply");
return await ReapplyRevertedMergeAsync(
task, list, wt, reviveTarget, verifyCommand, ct, progress, skipVerify);
}
using (var ctx = _dbFactory.CreateDbContext())
await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Active, ct);
await _broadcaster.WorktreeUpdated(taskId);
wt.State = WorktreeState.Active;
}
if (wt is null || wt.State != WorktreeState.Active) if (wt is null || wt.State != WorktreeState.Active)
{ {
// There is nothing left to merge -- a sandbox run, or a list-handler task that // There is nothing left to merge -- a sandbox run, or a list-handler task that
@@ -1031,6 +1073,107 @@ public sealed class TaskMergeService
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify); return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify);
} }
/// <summary>
/// Redoes a merge that <see cref="RevertMergeAsync"/> undid (or that predates it, if the branch
/// was already merged before its worktree reached <see cref="WorktreeState.Kept"/>), by
/// cherry-picking the original merge commit's own diff onto <paramref name="targetBranch"/> --
/// see <see cref="GitService.CherryPickMergeCommitAsync"/> for why a plain re-merge can't be
/// used once the branch tip is already an ancestor of the target. Mirrors <see cref="MergeAsync"/>'s
/// shape (gate, dirty/mid-op checks, cleanup, verify gate, approve) but produces a cherry-pick
/// commit instead of a merge commit, and <see cref="WorktreeRepository.SetMergedAsync"/> still
/// records it as this task's <see cref="WorktreeEntity.MergeCommit"/>.
/// </summary>
private async Task<MergeResult> ReapplyRevertedMergeAsync(
TaskEntity task, ListEntity list, WorktreeEntity wt, string targetBranch, string? verifyCommand,
CancellationToken ct, IProgress<ProgressNotificationValue>? progress, bool skipVerify)
{
var taskId = task.Id;
var workingDir = list.WorkingDir!;
await _broadcaster.OperationProgress(taskId, PhaseMerging, 0, 0);
var gate = GetMergeGate(workingDir);
await gate.WaitAsync(ct);
try
{
if (await _git.IsMidMergeAsync(workingDir, ct))
return Blocked("target working directory is mid-merge");
if (await _git.IsMidCherryPickAsync(workingDir, ct))
return Blocked("target working directory is mid-cherry-pick");
if (await _git.HasChangesAsync(workingDir, includeUntracked: false, ct))
return Blocked("target working tree has uncommitted changes");
var currentBranch = await _git.GetCurrentBranchAsync(workingDir, ct);
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
{
try { await _git.CheckoutBranchAsync(workingDir, targetBranch, ct); }
catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
}
var oldTargetTip = await _git.RevParseHeadAsync(workingDir, ct);
var (exitCode, stderr) = await _git.CherryPickMergeCommitAsync(workingDir, wt.MergeCommit!, ct);
if (exitCode != 0)
{
try { await _git.CherryPickAbortAsync(workingDir, ct); }
catch (Exception ex)
{
_logger.LogError(ex, "git cherry-pick --abort failed after conflict — repo is mid-cherry-pick");
return Blocked($"reapply conflict and abort failed: {ex.Message} — repo is mid-cherry-pick, resolve manually");
}
return Blocked($"reapplying the reverted merge conflicted and was aborted: {stderr}");
}
var mergeSha = await _git.RevParseHeadAsync(workingDir, ct);
string? cleanupWarning = null;
try
{
await _git.WorktreeRemoveAsync(workingDir, wt.Path, force: false, ct);
try { await _git.BranchDeleteAsync(workingDir, wt.BranchName, force: false, ct); }
catch (Exception ex)
{
_logger.LogWarning(ex, "branch delete failed for {Branch}", wt.BranchName);
cleanupWarning = $"worktree removed, branch delete failed: {ex.Message}";
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "worktree remove failed for {Path}", wt.Path);
cleanupWarning = $"worktree remove failed: {ex.Message}";
}
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct);
if (!string.IsNullOrWhiteSpace(verifyCommand) && skipVerify)
{
await _broadcaster.WorkerLog(
$"Re-merged #{task.Number} \"{task.Title}\" into {targetBranch} after a revert — verify deferred to batch",
WorkerLogLevel.Info, DateTime.UtcNow);
return new MergeResult(StatusMergedVerifyPending, Array.Empty<string>(), cleanupWarning);
}
var verifyFailure = await RunVerifyGateAsync(
verifyCommand, workingDir, ct, progress,
elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
if (verifyFailure is not null)
{
_logger.LogWarning("Verify command failed after reapplying reverted merge for task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after re-merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure;
}
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation(
"Reapplied reverted merge for task {TaskId} branch {Branch} into {Target}",
taskId, wt.BranchName, targetBranch);
await _broadcaster.WorkerLog($"Re-merged #{task.Number} \"{task.Title}\" into {targetBranch} after a revert", WorkerLogLevel.Success, DateTime.UtcNow);
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
}
finally { gate.Release(); }
}
/// <summary> /// <summary>
/// The batch counterpart to the per-merge verify gate: runs the list's verify command ONCE /// The batch counterpart to the per-merge verify gate: runs the list's verify command ONCE
/// (under the same per-repo gate) and, on success, promotes every listed task to Done via /// (under the same per-repo gate) and, on success, promotes every listed task to Done via

Some files were not shown because too many files have changed in this diff Show More